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