diff --git a/README.md b/README.md index 2bf64b4..487387d 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,11 @@ npm install react-rest-request --save ```typescript import React from 'react'; 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<{ id: number; @@ -55,7 +57,7 @@ function App() { } ReactDOM.render( - + , document.getElementById('root'), diff --git a/examples/movies/package-lock.json b/examples/movies/package-lock.json index 209d865..306059f 100644 --- a/examples/movies/package-lock.json +++ b/examples/movies/package-lock.json @@ -12381,11 +12381,6 @@ "react-rest-request": { "version": "file:../..", "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": { "version": "7.0.3", "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", "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": { "version": "12.4.0", "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", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12445,26 +12430,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "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" } }, + "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": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/examples/movies/package.json b/examples/movies/package.json index 3e45658..80cb015 100644 --- a/examples/movies/package.json +++ b/examples/movies/package.json @@ -37,6 +37,7 @@ ] }, "devDependencies": { - "@types/react-dom": "^16.9.9" + "@types/react-dom": "^16.9.9", + "typescript": "^4.0.5" } } diff --git a/examples/movies/src/constants.ts b/examples/movies/src/constants.ts deleted file mode 100644 index 20826e6..0000000 --- a/examples/movies/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const BASE_API_URL = 'https://sampleapis.com/movies/api'; diff --git a/examples/movies/src/index.tsx b/examples/movies/src/index.tsx index 5a82ef9..cd89f23 100644 --- a/examples/movies/src/index.tsx +++ b/examples/movies/src/index.tsx @@ -1,12 +1,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { RequestProvider } from 'react-rest-request'; -import { BASE_API_URL } from './constants'; +import { RequestProvider, Client } from 'react-rest-request'; import App from './app'; +const client = new Client({ + baseUrl: 'https://sampleapis.com/movies/api', +}); + ReactDOM.render( - + , diff --git a/package-lock.json b/package-lock.json index 761ba86..7a690a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -520,14 +520,6 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1115,11 +1107,6 @@ "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index c233c97..8881504 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ }, "homepage": "https://github.com/pleshevskiy/react-rest-request#readme", "dependencies": { - "axios": "^0.20.0", "lodash.isequal": "^4.5.0", "react": "^17.0.1", "tiny-invariant": "^1.1.0" diff --git a/src/client-hook.ts b/src/client-hook.ts index cd07824..c94850c 100644 --- a/src/client-hook.ts +++ b/src/client-hook.ts @@ -1,14 +1,7 @@ -import Axios from 'axios'; -import React from 'react'; import { useRequestContext } from './request-context'; export function useClient() { - const { baseUrl } = useRequestContext(); - - const client = React.useMemo( - () => Axios.create({ baseURL: baseUrl }), - [baseUrl] - ); + const { client } = useRequestContext(); return [client]; } diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..82bf8d8 --- /dev/null +++ b/src/client.ts @@ -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, + variables: Record | FormData, +} + +export type ClientResponse> = Readonly + +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>(props: RequestProps): Promise> { + 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 })); + } +} diff --git a/src/index.ts b/src/index.ts index 3feebfc..fcabddc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './endpoint'; +export * from './client'; export * from './client-hook'; export * from './request-hook'; export * from './request-context'; diff --git a/src/misc.ts b/src/misc.ts new file mode 100644 index 0000000..4947ee4 --- /dev/null +++ b/src/misc.ts @@ -0,0 +1,33 @@ + +export function isObject(val: any) { + return Object.prototype.toString.call(val) === '[object Object]'; +} + +export function formDataFromObject(obj: Record) { + 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) { + 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; +} diff --git a/src/reducer.ts b/src/reducer.ts index 45e62e5..e4d2989 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -1,4 +1,4 @@ -import { AxiosResponse } from 'axios'; +import { ClientResponse } from './client'; export type RequestState = Readonly<{ data: R | null; @@ -11,8 +11,8 @@ export type RequestState = Readonly<{ export type RequestAction = | { type: 'call', headers: Record, variables: Record, params?: Record } - | { type: 'success', response: AxiosResponse } - | { type: 'failure', response: AxiosResponse } + | { type: 'success', response: ClientResponse } + | { type: 'failure', response: ClientResponse } export function requestReducer(state: RequestState, action: RequestAction) { switch (action.type) { diff --git a/src/request-context.tsx b/src/request-context.tsx index 8f3f227..bbe84d8 100644 --- a/src/request-context.tsx +++ b/src/request-context.tsx @@ -1,9 +1,10 @@ import React from 'react'; import invariant from 'tiny-invariant'; +import { Client } from './client'; export type RequestContextData = Readonly<{ - baseUrl: string; + client: Client; defaultHeaders?: Record; }> @@ -11,9 +12,9 @@ const RequestContext = React.createContext(null); export type RequestProviderProps = Readonly> -export function RequestProvider({ baseUrl, defaultHeaders, children }: RequestProviderProps) { +export function RequestProvider({ client, defaultHeaders, children }: RequestProviderProps) { return ( - + {children} ); diff --git a/src/request-hook.ts b/src/request-hook.ts index a8d8856..2ae071c 100644 --- a/src/request-hook.ts +++ b/src/request-hook.ts @@ -2,7 +2,7 @@ import React from 'react'; import invariant from 'tiny-invariant'; import isEqual from 'lodash.isequal'; import { useClient } from './client-hook'; -import { Endpoint, Method } from './endpoint'; +import { Endpoint } from './endpoint'; import { RequestAction, requestReducer, RequestState } from './reducer'; import { useRequestContext } from './request-context'; @@ -35,8 +35,6 @@ export function useRequest, V = Record, P = } ); - const variableParam = requestVariableParamByMethod(endpoint.method); - const handler = React.useCallback( (handlerConfig?: RequestHandlerConfig) => { if (state?.loading) { @@ -87,7 +85,7 @@ export function useRequest, V = Record, P = ...endpoint, url: endpointUrl, headers, - [variableParam]: variables, + variables, }) .then( (response) => { @@ -103,7 +101,7 @@ export function useRequest, V = Record, P = } ); }, - [state, config, client, endpoint, variableParam, defaultHeaders] + [state, config, client, endpoint, defaultHeaders] ); return [ @@ -111,7 +109,3 @@ export function useRequest, V = Record, P = state, ]; } - -function requestVariableParamByMethod(method: Method) { - return [Method.GET, Method.DELETE].includes(method) ? 'params' : 'data'; -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index cc61ab0..757be8a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "declaration": true, }, "include": [ - "src/**/*" + "src" ], }