diff --git a/README.md b/README.md index e23371d..ffc7850 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ type Movie = Readonly<{ imdbId: string; }> -const MoviesEndpoint: Endpoint = { +const MoviesEndpoint: Endpoint = { method: Method.GET, url: '/action-adventure', }; @@ -56,3 +56,34 @@ ReactDOM.render( document.getElementById('root'), ); ``` + +### Transform response + +If you have an endpoint that doesn't fit into your beautiful architecture +with its response data, you can transform the response before it's written +to the state. + +```typescript +import { Endpoint, Method } from 'react-rest-request'; + +export type Movie = Readonly<{ + id: number; + title: string; + posterURL: string; + imdbId: string; +}> + +export const MoviesEndpoint: Endpoint = { + method: Method.GET, + url: '/action-adventure', + transformResponseData(data: Movie[]) { + return { + items: data, + } + } +}; + +export type MoviesResponse = { + items: Movie[], +} +``` diff --git a/examples/movies/src/endpoint.ts b/examples/movies/src/endpoint.ts index b190c93..498505e 100644 --- a/examples/movies/src/endpoint.ts +++ b/examples/movies/src/endpoint.ts @@ -7,15 +7,21 @@ export type Movie = Readonly<{ imdbId: string; }> -export const MoviesEndpoint: Endpoint = { +export const MoviesEndpoint: Endpoint = { method: Method.GET, url: '/action-adventure', + transformResponseData(data: Movie[]) { + return { + items: data, + } + } }; -export type MoviesResponse = Movie[]; +export type MoviesResponse = { + items: Movie[], +} - -export const MovieEndpoint: Endpoint = { +export const MovieEndpoint: Endpoint = { method: Method.GET, url: ({ id }) => `/action-adventure/${id}`, }; diff --git a/examples/movies/src/pages.tsx b/examples/movies/src/pages.tsx index 90166ee..80fd9ca 100644 --- a/examples/movies/src/pages.tsx +++ b/examples/movies/src/pages.tsx @@ -9,9 +9,9 @@ export function MoviesPage() { return !data ? (
{ loading ? 'Loading...' : 'Something went wrong' }
- ) : Array.isArray(data) ? ( + ) : (
    - {data + {data.items .filter(movie => !!movie.title) .map(movie => (
  • @@ -22,7 +22,7 @@ export function MoviesPage() { NOT EXIST
- ) :
{JSON.stringify(data)}
; + ); } export function MoviePage() { diff --git a/src/client.ts b/src/client.ts index aa7f094..a39afe2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,22 @@ import invariant from 'tiny-invariant'; import { Method } from './endpoint'; -import { formDataFromObject, urlSearchParamsFromObject } from './misc'; +import { formDataFromObject, isFunction, urlSearchParamsFromObject } from './misc'; export type ClientConfig = { baseUrl: string, } -export type RequestProps = { +type PrepareRequestProps = { url: string, method: Method, headers: Record, variables: Record | FormData, } +export type RequestProps = PrepareRequestProps & { + transformResponseData?: (data: unknown) => R, +} + export type ClientResponse> = Readonly< Pick & { data: Data } @@ -21,7 +25,7 @@ export type ClientResponse> = Readonly< export class Client { constructor(private config: ClientConfig) {} - private prepareRequest(props: RequestProps) { + private prepareRequest(props: PrepareRequestProps) { const requestCanContainBody = [Method.POST, Method.PATCH, Method.PUT].includes(props.method); const url = /https?:\/\//.test(props.url) ? @@ -61,8 +65,13 @@ export class Client { }); } - public request>(props: RequestProps): Promise> { - const req = this.prepareRequest(props); + public request>( + { + transformResponseData, + ...restProps + }: RequestProps + ): Promise> { + const req = this.prepareRequest(restProps); return fetch(req) // TODO: need to check response headers and parse json only if content-type header is application/json @@ -76,7 +85,7 @@ export class Client { type: res.type, headers: res.headers, url: res.url, - data + data: isFunction(transformResponseData) ? transformResponseData(data) : data, }; }) .then((res) => { diff --git a/src/endpoint.ts b/src/endpoint.ts index 3c9469f..0dd4417 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -8,8 +8,9 @@ export enum Method { DELETE = 'DELETE', } -export type Endpoint

= Readonly<{ +export type Endpoint = Readonly<{ method: Method; url: string | ((params: P) => string); headers?: Record; + transformResponseData?: (data: any) => R; }> diff --git a/src/lazy-request-hook.ts b/src/lazy-request-hook.ts index dadd674..4691d16 100644 --- a/src/lazy-request-hook.ts +++ b/src/lazy-request-hook.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import invariant from 'tiny-invariant'; import isEqual from 'lodash.isequal'; import { useClient } from './client-hook'; @@ -6,6 +6,7 @@ import { Endpoint } from './endpoint'; import { PublicRequestState, RequestAction, requestReducer, RequestState } from './reducer'; import { useRequestContext } from './request-context'; import { ClientResponse } from './client'; +import { isFunction } from './misc'; export type LazyRequestConfig = Readonly<{ variables?: V; @@ -23,7 +24,7 @@ export type LazyRequestHandlerConfig = Readonly< export type RequestHandler = (config?: LazyRequestHandlerConfig) => Promise; export function useLazyRequest, V = Record, P = void>( - endpoint: Endpoint

, + endpoint: Endpoint, config?: LazyRequestConfig, ): [RequestHandler, PublicRequestState] { const [client] = useClient(); @@ -37,6 +38,15 @@ export function useLazyRequest, V = Record, } ); + const transformResponseData = useCallback( + (data: unknown): R => { + return isFunction(endpoint.transformResponseData) ? + endpoint.transformResponseData(data) + : data as R; + }, + [endpoint] + ); + const handler = React.useCallback( (handlerConfig?: LazyRequestHandlerConfig) => { if (state?.loading) { @@ -46,7 +56,7 @@ export function useLazyRequest, V = Record, let params: P | undefined; let endpointUrl: string; let isSameRequest = true; - if (typeof endpoint.url === 'function') { + if (isFunction(endpoint.url)) { params = handlerConfig?.params ?? config?.params; invariant(params, 'Endpoind required params'); @@ -89,25 +99,28 @@ export function useLazyRequest, V = Record, url: endpointUrl, headers, variables, + transformResponseData, }) .then( (response) => { dispatch({ type: 'success', response }); - if (typeof onComplete === 'function') { + + if (isFunction(onComplete)) { onComplete(response.data); } + return response.data; }, (response) => { dispatch({ type: 'failure', response }); - if (typeof onFailure === 'function') { + if (isFunction(onFailure)) { onFailure(response); } return null; } ); }, - [state, config, client, endpoint, defaultHeaders] + [state, config, client, endpoint, defaultHeaders, transformResponseData] ); return [ diff --git a/src/misc.ts b/src/misc.ts index 510c376..ba3c3a5 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1,8 +1,12 @@ -export function isObject(val: any) { +export function isObject(val: any): val is Record { return Object.prototype.toString.call(val) === '[object Object]'; } +export function isFunction(val: any): val is (...args: any[]) => any { + return typeof val === 'function'; +} + export function formDataFromObject(obj: Record) { const formData = new FormData(); Object.entries(obj) diff --git a/src/request-hook.ts b/src/request-hook.ts index 2cba9f7..367e064 100644 --- a/src/request-hook.ts +++ b/src/request-hook.ts @@ -11,7 +11,7 @@ export type RequestConfig = Readonly< > export function useRequest, V = Record, P = void>( - endpoint: Endpoint

, + endpoint: Endpoint, config?: RequestConfig, ) { invariant( diff --git a/tests/misc.spec.ts b/tests/misc.spec.ts index f607c25..7b291ef 100644 --- a/tests/misc.spec.ts +++ b/tests/misc.spec.ts @@ -1,4 +1,4 @@ -import { formDataFromObject, isObject, urlSearchParamsFromObject } from '../src/misc'; +import { formDataFromObject, isFunction, isObject, urlSearchParamsFromObject } from '../src/misc'; describe('misc', () => { describe('isObject', () => { @@ -19,6 +19,24 @@ describe('misc', () => { }); }); + describe('isFunction', () => { + it('should return thruthy successfully', () => { + expect(isFunction(function () { return null; })).toBeTruthy(); + }); + + it('should return falsy', () => { + expect(isFunction(1)).toBeFalsy(); + expect(isFunction(true)).toBeFalsy(); + expect(isFunction('')).toBeFalsy(); + expect(isFunction([])).toBeFalsy(); + expect(isFunction({})).toBeFalsy(); + expect(isFunction(null)).toBeFalsy(); + expect(isFunction(undefined)).toBeFalsy(); + expect(isFunction(NaN)).toBeFalsy(); + expect(isFunction(Infinity)).toBeFalsy(); + }); + }); + describe('formDataFromObject', () => { it('should convert object to form data successfully', () => { const formData = formDataFromObject({