Initial commit
feat(context): add context for request feat(client): add hook for get memo rest client feat(endpoint): add endpoint object for configure rest methods feat(request): add request hook refac(request): return cached data for same query refac(request): remove void variables for endpoints feat(request): add headers to request cache
This commit is contained in:
commit
e9c18c9fea
14 changed files with 3021 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
target/
|
||||||
|
|
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*.js
|
49
.eslintrc.yml
Normal file
49
.eslintrc.yml
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
env:
|
||||||
|
es6: true
|
||||||
|
node: true
|
||||||
|
browser: true
|
||||||
|
parser: '@typescript-eslint/parser'
|
||||||
|
plugins:
|
||||||
|
- 'react-hooks'
|
||||||
|
extends:
|
||||||
|
- 'plugin:react/recommended'
|
||||||
|
- 'plugin:react-hooks/recommended'
|
||||||
|
- 'plugin:@typescript-eslint/recommended'
|
||||||
|
parserOptions:
|
||||||
|
ecmaVersion: 2020
|
||||||
|
sourceType: 'module'
|
||||||
|
ecmaFeatures:
|
||||||
|
jsx: true
|
||||||
|
settings:
|
||||||
|
react:
|
||||||
|
version: 'detect'
|
||||||
|
rules:
|
||||||
|
no-case-declarations: off
|
||||||
|
linebreak-style: off
|
||||||
|
indent:
|
||||||
|
- error
|
||||||
|
- 4
|
||||||
|
- { "SwitchCase": 1 }
|
||||||
|
quotes:
|
||||||
|
- error
|
||||||
|
- single
|
||||||
|
semi:
|
||||||
|
- error
|
||||||
|
- always
|
||||||
|
'@typescript-eslint/no-unused-vars':
|
||||||
|
- warn
|
||||||
|
- vars: all
|
||||||
|
args: after-used
|
||||||
|
argsIgnorePattern: ^_
|
||||||
|
varsIgnorePattern: ^_
|
||||||
|
ignoreRestSiblings: true
|
||||||
|
'@typescript-eslint/no-empty-interface': off
|
||||||
|
'@typescript-eslint/no-explicit-any': off
|
||||||
|
'@typescript-eslint/no-extra-semi': off
|
||||||
|
'@typescript-eslint/explicit-function-return-type': off
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': off
|
||||||
|
'@typescript-eslint/camelcase': off
|
||||||
|
'@typescript-eslint/no-use-before-define': off
|
||||||
|
'react/prop-types': off
|
||||||
|
'react/display-name': off
|
||||||
|
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/target
|
||||||
|
/public
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
10
README.md
Normal file
10
README.md
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# react-rest-request
|
||||||
|
|
||||||
|
Minimalistic REST API client for React inspired by Apollo.
|
||||||
|
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-rest-request
|
||||||
|
```
|
2641
package-lock.json
generated
Normal file
2641
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
package.json
Normal file
47
package.json
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "react-rest-request",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Minimalistic REST API client for React inspired by Apollo",
|
||||||
|
"readme": "README.md",
|
||||||
|
"main": "./target/index.js",
|
||||||
|
"types": "./target/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prepublishOnly": "tsc"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/pleshevskiy/react-rest-request.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"rest",
|
||||||
|
"request",
|
||||||
|
"fetch",
|
||||||
|
"hook"
|
||||||
|
],
|
||||||
|
"author": "Dmitriy Pleshevskiy <dmitriy@ideascup.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pleshevskiy/react-rest-request/issues"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/lodash.isequal": "^4.5.5",
|
||||||
|
"@types/react": "^16.9.55",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.6.1",
|
||||||
|
"@typescript-eslint/parser": "^4.6.1",
|
||||||
|
"eslint": "^7.12.1",
|
||||||
|
"eslint-plugin-react": "^7.21.5",
|
||||||
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
|
"typescript": "^4.0.5"
|
||||||
|
}
|
||||||
|
}
|
14
src/client-hook.ts
Normal file
14
src/client-hook.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [client];
|
||||||
|
}
|
14
src/endpoint.ts
Normal file
14
src/endpoint.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
export enum Method {
|
||||||
|
GET = 'GET',
|
||||||
|
PUT = 'PUT',
|
||||||
|
POST = 'POST',
|
||||||
|
PATCH = 'PATCH',
|
||||||
|
DELETE = 'DELETE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Endpoint<P = void> = Readonly<{
|
||||||
|
method: Method;
|
||||||
|
url: string | ((params: P) => string);
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}>
|
5
src/index.ts
Normal file
5
src/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './endpoint';
|
||||||
|
export * from './client-hook';
|
||||||
|
export * from './request-hook';
|
||||||
|
export * from './request-context';
|
||||||
|
export * from './reducer';
|
45
src/reducer.ts
Normal file
45
src/reducer.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
export type RequestState<R> = Readonly<{
|
||||||
|
data: R | null;
|
||||||
|
loading: boolean;
|
||||||
|
isCalled: boolean;
|
||||||
|
prevHeaders?: Record<string, string>;
|
||||||
|
prevVariables?: Record<string, any>;
|
||||||
|
prevParams?: Record<string, any>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type RequestAction<R> =
|
||||||
|
| { type: 'call', headers: Record<string, string>, variables: Record<string, any>, params?: Record<string, any> }
|
||||||
|
| { type: 'success', response: AxiosResponse<R> }
|
||||||
|
| { type: 'failure', response: AxiosResponse<R> }
|
||||||
|
|
||||||
|
export function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>) {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'call': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
isCalled: true,
|
||||||
|
prevHeaders: action.headers,
|
||||||
|
prevVariables: action.variables,
|
||||||
|
prevParams: action.params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'success': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
data: action.response.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'failure': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
data: null,
|
||||||
|
// TODO: need to append errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
src/request-context.tsx
Normal file
29
src/request-context.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
|
|
||||||
|
export type RequestContextData = Readonly<{
|
||||||
|
baseUrl: string;
|
||||||
|
defaultHeaders?: Record<string, string>;
|
||||||
|
}>
|
||||||
|
|
||||||
|
const RequestContext = React.createContext<RequestContextData | null>(null);
|
||||||
|
|
||||||
|
export type RequestProviderProps = Readonly<React.PropsWithChildren<RequestContextData>>
|
||||||
|
|
||||||
|
export function RequestProvider({ baseUrl, defaultHeaders, children }: RequestProviderProps) {
|
||||||
|
return (
|
||||||
|
<RequestContext.Provider value={{ baseUrl, defaultHeaders }}>
|
||||||
|
{children}
|
||||||
|
</RequestContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequestContext() {
|
||||||
|
const context = React.useContext(RequestContext);
|
||||||
|
|
||||||
|
invariant(context, 'useRequestContext() must be a child of <RequestProvider />');
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
117
src/request-hook.ts
Normal file
117
src/request-hook.ts
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
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 { RequestAction, requestReducer, RequestState } from './reducer';
|
||||||
|
import { useRequestContext } from './request-context';
|
||||||
|
|
||||||
|
export type RequestConfig<R, V, P = void> = Readonly<{
|
||||||
|
variables?: V;
|
||||||
|
params?: P;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
onComplete?: (data: R) => unknown;
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type RequestHandlerConfig<R, V, P> = Readonly<
|
||||||
|
RequestConfig<R, V, P>
|
||||||
|
& { force?: boolean }
|
||||||
|
>
|
||||||
|
|
||||||
|
export type RequestHandler<R, V, P> = (config?: RequestHandlerConfig<R, V, P>) => Promise<R | null>;
|
||||||
|
|
||||||
|
export function useRequest<R = Record<string, any>, V = Record<string, any>, P = void>(
|
||||||
|
endpoint: Endpoint<P>,
|
||||||
|
config?: RequestConfig<R, V, P>,
|
||||||
|
): [RequestHandler<R, V, P>, RequestState<R>] {
|
||||||
|
const [client] = useClient();
|
||||||
|
const { defaultHeaders } = useRequestContext();
|
||||||
|
const [state, dispatch] = React.useReducer<React.Reducer<RequestState<R>, RequestAction<R>>>(
|
||||||
|
requestReducer,
|
||||||
|
{
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
isCalled: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const variableParam = requestVariableParamByMethod(endpoint.method);
|
||||||
|
|
||||||
|
const handler = React.useCallback(
|
||||||
|
(handlerConfig?: RequestHandlerConfig<R, V, P>) => {
|
||||||
|
if (state?.loading) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let params: P | undefined;
|
||||||
|
let endpointUrl: string;
|
||||||
|
let isSameRequest = true;
|
||||||
|
if (typeof endpoint.url === 'function') {
|
||||||
|
params = handlerConfig?.params ?? config?.params;
|
||||||
|
invariant(params, 'Endpoind required params');
|
||||||
|
|
||||||
|
endpointUrl = endpoint.url(params);
|
||||||
|
|
||||||
|
isSameRequest = !!state?.prevParams && isEqual(state.prevParams, params);
|
||||||
|
} else {
|
||||||
|
endpointUrl = endpoint.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
...config?.variables,
|
||||||
|
...handlerConfig?.variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...defaultHeaders,
|
||||||
|
...endpoint.headers,
|
||||||
|
...config?.headers,
|
||||||
|
...handlerConfig?.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
isSameRequest
|
||||||
|
&& state?.prevVariables && isEqual(state.prevVariables, variables)
|
||||||
|
&& state?.prevHeaders && isEqual(state.prevHeaders, headers)
|
||||||
|
&& !handlerConfig?.force
|
||||||
|
) {
|
||||||
|
return Promise.resolve(state.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onComplete = config?.onComplete ?? handlerConfig?.onComplete;
|
||||||
|
|
||||||
|
dispatch({ type: 'call', headers, variables, params });
|
||||||
|
|
||||||
|
return client
|
||||||
|
.request<R>({
|
||||||
|
...endpoint,
|
||||||
|
url: endpointUrl,
|
||||||
|
headers,
|
||||||
|
[variableParam]: variables,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(response) => {
|
||||||
|
dispatch({ type: 'success', response });
|
||||||
|
if (typeof onComplete === 'function') {
|
||||||
|
onComplete(response.data);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
dispatch({ type: 'failure', response });
|
||||||
|
throw response;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[state, config, client, endpoint, variableParam, defaultHeaders]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
handler,
|
||||||
|
state,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestVariableParamByMethod(method: Method) {
|
||||||
|
return [Method.GET, Method.DELETE].includes(method) ? 'params' : 'data';
|
||||||
|
}
|
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "target",
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "es6",
|
||||||
|
"lib": ["dom", "es2020"],
|
||||||
|
"jsx": "react",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"pretty": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"declaration": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
}
|
Reference in a new issue