feat!(client): add client fetch based

breaking!(context): client prop instead base url
refac: change axios response to client response
chore(deps): remove axios
chore: update example

Closes #2
This commit is contained in:
Dmitriy Pleshevskiy 2020-11-04 14:36:44 +03:00
parent c20198f14f
commit 243f4f77e5
15 changed files with 136 additions and 81 deletions

View file

@ -15,9 +15,11 @@ npm install react-rest-request --save
```typescript ```typescript
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Endpoint, Method, useRequest, RequestProvider } from 'react-rest-request'; import { Client, Endpoint, Method, useRequest, RequestProvider } from 'react-rest-request';
const BASE_API_URL = 'https://sampleapis.com/movies/api'; const client = Client({
baseUrl: 'https://sampleapis.com/movies/api',
});
type Movie = Readonly<{ type Movie = Readonly<{
id: number; id: number;
@ -55,7 +57,7 @@ function App() {
} }
ReactDOM.render( ReactDOM.render(
<RequestProvider baseUrl={BASE_API_URL}> <RequestProvider client={client}>
<App /> <App />
</RequestProvider>, </RequestProvider>,
document.getElementById('root'), document.getElementById('root'),

View file

@ -12381,11 +12381,6 @@
"react-rest-request": { "react-rest-request": {
"version": "file:../..", "version": "file:../..",
"dependencies": { "dependencies": {
"color-convert": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz",
"integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ=="
},
"cross-spawn": { "cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -12408,11 +12403,6 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
}, },
"fastq": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.8.0.tgz",
"integrity": "sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q=="
},
"globals": { "globals": {
"version": "12.4.0", "version": "12.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz",
@ -12428,11 +12418,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
}, },
"run-parallel": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz",
"integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q=="
},
"shebang-command": { "shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -12445,26 +12430,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
"supports-color": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.3.0.tgz",
"integrity": "sha512-0aP01LLIskjKs3lq52EC0aGBAJhLq7B2Rd8HC/DR/PtNNpcLilNmHC12O+hu0usQpo7wtHNRqtrhBwtDb0+dNg=="
},
"uri-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"requires": {
"punycode": "^2.1.0"
},
"dependencies": {
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
}
}
} }
} }
}, },
@ -14879,6 +14844,12 @@
"is-typedarray": "^1.0.0" "is-typedarray": "^1.0.0"
} }
}, },
"typescript": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz",
"integrity": "sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==",
"dev": true
},
"unicode-canonical-property-names-ecmascript": { "unicode-canonical-property-names-ecmascript": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",

View file

@ -37,6 +37,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/react-dom": "^16.9.9" "@types/react-dom": "^16.9.9",
"typescript": "^4.0.5"
} }
} }

View file

@ -1 +0,0 @@
export const BASE_API_URL = 'https://sampleapis.com/movies/api';

View file

@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { RequestProvider } from 'react-rest-request'; import { RequestProvider, Client } from 'react-rest-request';
import { BASE_API_URL } from './constants';
import App from './app'; import App from './app';
const client = new Client({
baseUrl: 'https://sampleapis.com/movies/api',
});
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<RequestProvider baseUrl={BASE_API_URL}> <RequestProvider client={client}>
<App /> <App />
</RequestProvider> </RequestProvider>
</React.StrictMode>, </React.StrictMode>,

13
package-lock.json generated
View file

@ -520,14 +520,6 @@
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
"dev": true "dev": true
}, },
"axios": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz",
"integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==",
"requires": {
"follow-redirects": "^1.10.0"
}
},
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -1115,11 +1107,6 @@
"integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==",
"dev": true "dev": true
}, },
"follow-redirects": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
},
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",

View file

@ -29,7 +29,6 @@
}, },
"homepage": "https://github.com/pleshevskiy/react-rest-request#readme", "homepage": "https://github.com/pleshevskiy/react-rest-request#readme",
"dependencies": { "dependencies": {
"axios": "^0.20.0",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"react": "^17.0.1", "react": "^17.0.1",
"tiny-invariant": "^1.1.0" "tiny-invariant": "^1.1.0"

View file

@ -1,14 +1,7 @@
import Axios from 'axios';
import React from 'react';
import { useRequestContext } from './request-context'; import { useRequestContext } from './request-context';
export function useClient() { export function useClient() {
const { baseUrl } = useRequestContext(); const { client } = useRequestContext();
const client = React.useMemo(
() => Axios.create({ baseURL: baseUrl }),
[baseUrl]
);
return [client]; return [client];
} }

71
src/client.ts Normal file
View file

@ -0,0 +1,71 @@
import invariant from 'tiny-invariant';
import { Method } from './endpoint';
import { formDataFromObject, urlSearchParamsFromObject } from './misc';
export type ClientConfig = {
baseUrl: string,
}
export type RequestProps = {
url: string,
method: Method,
headers: Record<string, string>,
variables: Record<string, any> | FormData,
}
export type ClientResponse<Data extends Record<string, any>> = Readonly<Response & {
data: Data;
}>
export class Client {
constructor(private config: ClientConfig) {}
private prepareRequest(props: RequestProps) {
const requestCanContainBody = [Method.POST, Method.PATCH, Method.PUT].includes(props.method);
const url = /https?:\/\//.test(props.url) ?
new URL(props.url)
: new URL(this.config.baseUrl + props.url);
if (!requestCanContainBody) {
invariant(!(props.variables instanceof FormData), `Method ${props.method} cannot contain body`);
url.search = urlSearchParamsFromObject(props.variables).toString();
}
const headers = new Headers(props.headers);
if (requestCanContainBody && !headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
const contentType = headers.get('content-type');
const body = !requestCanContainBody ? (
undefined
) : contentType === 'application/json' ? (
JSON.stringify(props.variables)
) : contentType === 'multipart/form-data' ? (
props.variables instanceof FormData ? (
props.variables
) : (
formDataFromObject(props.variables)
)
) : (
/* TODO: need to add more content-type of body */
undefined
);
return new Request(url.toString(), {
headers,
method: props.method,
body,
});
}
public request<Data extends Record<string, any>>(props: RequestProps): Promise<ClientResponse<Data>> {
const req = this.prepareRequest(props);
return fetch(req)
// TODO: need to check response headers and parse json only if content-type header is application/json
.then(res => Promise.all([res, res.json()]))
.then(([res, data]) => ({ ...res, data }));
}
}

View file

@ -1,4 +1,5 @@
export * from './endpoint'; export * from './endpoint';
export * from './client';
export * from './client-hook'; export * from './client-hook';
export * from './request-hook'; export * from './request-hook';
export * from './request-context'; export * from './request-context';

33
src/misc.ts Normal file
View file

@ -0,0 +1,33 @@
export function isObject(val: any) {
return Object.prototype.toString.call(val) === '[object Object]';
}
export function formDataFromObject(obj: Record<string, any>) {
const formData = new FormData();
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
value.forEach(val => formData.append(key, val));
} else if (isObject(value)) {
formData.set(key, JSON.stringify(value));
} else {
formData.set(key, value);
}
}
return formData;
}
export function urlSearchParamsFromObject(obj: Record<string, any>) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
const arrayKey = `${key}[]`;
value.forEach(val => searchParams.append(arrayKey, val));
} else {
searchParams.set(key, value);
}
}
return searchParams;
}

View file

@ -1,4 +1,4 @@
import { AxiosResponse } from 'axios'; import { ClientResponse } from './client';
export type RequestState<R> = Readonly<{ export type RequestState<R> = Readonly<{
data: R | null; data: R | null;
@ -11,8 +11,8 @@ export type RequestState<R> = Readonly<{
export type RequestAction<R> = export type RequestAction<R> =
| { type: 'call', headers: Record<string, string>, variables: Record<string, any>, params?: Record<string, any> } | { type: 'call', headers: Record<string, string>, variables: Record<string, any>, params?: Record<string, any> }
| { type: 'success', response: AxiosResponse<R> } | { type: 'success', response: ClientResponse<R> }
| { type: 'failure', response: AxiosResponse<R> } | { type: 'failure', response: ClientResponse<R> }
export function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>) { export function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>) {
switch (action.type) { switch (action.type) {

View file

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
import { Client } from './client';
export type RequestContextData = Readonly<{ export type RequestContextData = Readonly<{
baseUrl: string; client: Client;
defaultHeaders?: Record<string, string>; defaultHeaders?: Record<string, string>;
}> }>
@ -11,9 +12,9 @@ const RequestContext = React.createContext<RequestContextData | null>(null);
export type RequestProviderProps = Readonly<React.PropsWithChildren<RequestContextData>> export type RequestProviderProps = Readonly<React.PropsWithChildren<RequestContextData>>
export function RequestProvider({ baseUrl, defaultHeaders, children }: RequestProviderProps) { export function RequestProvider({ client, defaultHeaders, children }: RequestProviderProps) {
return ( return (
<RequestContext.Provider value={{ baseUrl, defaultHeaders }}> <RequestContext.Provider value={{ client, defaultHeaders }}>
{children} {children}
</RequestContext.Provider> </RequestContext.Provider>
); );

View file

@ -2,7 +2,7 @@ import React from 'react';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
import isEqual from 'lodash.isequal'; import isEqual from 'lodash.isequal';
import { useClient } from './client-hook'; import { useClient } from './client-hook';
import { Endpoint, Method } from './endpoint'; import { Endpoint } from './endpoint';
import { RequestAction, requestReducer, RequestState } from './reducer'; import { RequestAction, requestReducer, RequestState } from './reducer';
import { useRequestContext } from './request-context'; import { useRequestContext } from './request-context';
@ -35,8 +35,6 @@ export function useRequest<R = Record<string, any>, V = Record<string, any>, P =
} }
); );
const variableParam = requestVariableParamByMethod(endpoint.method);
const handler = React.useCallback( const handler = React.useCallback(
(handlerConfig?: RequestHandlerConfig<R, V, P>) => { (handlerConfig?: RequestHandlerConfig<R, V, P>) => {
if (state?.loading) { if (state?.loading) {
@ -87,7 +85,7 @@ export function useRequest<R = Record<string, any>, V = Record<string, any>, P =
...endpoint, ...endpoint,
url: endpointUrl, url: endpointUrl,
headers, headers,
[variableParam]: variables, variables,
}) })
.then( .then(
(response) => { (response) => {
@ -103,7 +101,7 @@ export function useRequest<R = Record<string, any>, V = Record<string, any>, P =
} }
); );
}, },
[state, config, client, endpoint, variableParam, defaultHeaders] [state, config, client, endpoint, defaultHeaders]
); );
return [ return [
@ -111,7 +109,3 @@ export function useRequest<R = Record<string, any>, V = Record<string, any>, P =
state, state,
]; ];
} }
function requestVariableParamByMethod(method: Method) {
return [Method.GET, Method.DELETE].includes(method) ? 'params' : 'data';
}

View file

@ -17,6 +17,6 @@
"declaration": true, "declaration": true,
}, },
"include": [ "include": [
"src/**/*" "src"
], ],
} }