fix: use window location as base url

chore: add dist folder to repo
chore(deps): add node-fetch for tests
chore: change prepare request visibility
chore: add tests for prepare request method
chore: update example

Closes #20
This commit is contained in:
Dmitriy Pleshevskiy 2020-12-06 19:18:00 +03:00
parent 817b8e46ad
commit 49aac94675
27 changed files with 1899 additions and 2158 deletions

1
.gitignore vendored
View File

@ -8,7 +8,6 @@
/coverage
# production
/target
/public
# misc

View File

@ -21,3 +21,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
"@testing-library/user-event": "^12.2.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-rest-request": "file:../../",
"react-rest-request": "github:pleshevskiy/react-rest-request",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.0",
"web-vitals": "^0.2.4"
@ -40,6 +40,6 @@
"devDependencies": {
"@types/react-dom": "^16.9.9",
"@types/react-router-dom": "^5.1.6",
"typescript": "^4.0.5"
"typescript": "4.0.5"
}
}

View File

@ -1,24 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"rootDir": "src",
"outDir": "build",
"module": "esnext",
"target": "es6",
"lib": [
"dom",
"dom.iterable",
"esnext"
"es2020"
],
"jsx": "react",
"moduleResolution": "node",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"pretty": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noEmit": true,
"jsx": "react"
"strict": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"declaration": true
},
"include": [
"src"

31
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "react-rest-request",
"version": "0.3.2",
"version": "0.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1067,6 +1067,29 @@
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
"dev": true
},
"@types/node-fetch": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz",
"integrity": "sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==",
"dev": true,
"requires": {
"@types/node": "*",
"form-data": "^3.0.0"
},
"dependencies": {
"form-data": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"@types/normalize-package-data": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
@ -5499,6 +5522,12 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
"dev": true
},
"node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",

View File

@ -10,6 +10,7 @@
],
"scripts": {
"test": "jest",
"build": "rm -rf target && tsc",
"prepublishOnly": "rm -rf target && tsc",
"report-coverage": "cat coverage/lcov.info | coveralls"
},
@ -40,6 +41,7 @@
"devDependencies": {
"@types/jest": "^26.0.15",
"@types/lodash.isequal": "^4.5.5",
"@types/node-fetch": "^2.5.7",
"@types/react": "^16.9.55",
"@typescript-eslint/eslint-plugin": "^4.6.1",
"@typescript-eslint/parser": "^4.6.1",
@ -47,6 +49,7 @@
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^26.6.3",
"node-fetch": "^2.6.1",
"ts-jest": "^26.4.3",
"typescript": "^4.0.5"
}

View File

@ -25,12 +25,18 @@ export type ClientResponse<Data extends Record<string, any>> = Readonly<
export class Client {
constructor(private config: ClientConfig) {}
private prepareRequest(props: PrepareRequestProps) {
public prepareRequest(props: PrepareRequestProps) {
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);
const defaultBaseUrl = (window as Window | undefined)?.location.href;
const sourceUrl = /https?:\/\//.test(props.url) ?
props.url
: this.config.baseUrl + props.url;
if (!defaultBaseUrl && sourceUrl.startsWith('/')) {
throw new Error(`Invalid request method: ${sourceUrl}`);
}
const url = new URL(sourceUrl, defaultBaseUrl);
if (!requestCanContainBody) {
invariant(!(props.variables instanceof FormData), `Method ${props.method} cannot contain body`);
@ -58,11 +64,14 @@ export class Client {
undefined
);
return new Request(url.toString(), {
headers,
method: props.method,
body,
});
return new Request(
url.toString(),
{
headers,
method: props.method,
body,
}
);
}
public request<Data extends Record<string, any>>(

1
target/client-hook.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export declare function useClient(): import("./client").Client[];

5
target/client-hook.js Normal file
View File

@ -0,0 +1,5 @@
import { useRequestContext } from './request-context';
export function useClient() {
const { client } = useRequestContext();
return [client];
}

23
target/client.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import { Method } from './endpoint';
export declare type ClientConfig = {
baseUrl: string;
};
declare type PrepareRequestProps = {
url: string;
method: Method;
headers: Record<string, string>;
variables: Record<string, any> | FormData;
};
export declare type RequestProps<R> = PrepareRequestProps & {
transformResponseData?: (data: unknown) => R;
};
export declare type ClientResponse<Data extends Record<string, any>> = Readonly<Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'> & {
data: Data;
}>;
export declare class Client {
private config;
constructor(config: ClientConfig);
private prepareRequest;
request<Data extends Record<string, any>>({ transformResponseData, ...restProps }: RequestProps<Data>): Promise<ClientResponse<Data>>;
}
export {};

67
target/client.js Normal file
View File

@ -0,0 +1,67 @@
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
import invariant from 'tiny-invariant';
import { Method } from './endpoint';
import { formDataFromObject, isFunction, urlSearchParamsFromObject } from './misc';
export class Client {
constructor(config) {
this.config = config;
}
prepareRequest(props) {
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,
});
}
request(_a) {
var { transformResponseData } = _a, restProps = __rest(_a, ["transformResponseData"]);
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
.then(res => Promise.all([res, res.json(), false]))
.then(([res, data]) => {
return {
ok: res.ok,
redirected: res.redirected,
status: res.status,
statusText: res.statusText,
type: res.type,
headers: res.headers,
url: res.url,
data: isFunction(transformResponseData) ? transformResponseData(data) : data,
};
})
.then((res) => {
if (!res.ok) {
throw res;
}
return res;
});
}
}

14
target/endpoint.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
export declare enum Method {
HEAD = "HEAD",
GET = "GET",
PUT = "PUT",
POST = "POST",
PATCH = "PATCH",
DELETE = "DELETE"
}
export declare type Endpoint<R, V, P = void> = Readonly<{
method: Method;
url: string | ((params: P) => string);
headers?: Record<string, string>;
transformResponseData?: (data: any) => R;
}>;

9
target/endpoint.js Normal file
View File

@ -0,0 +1,9 @@
export var Method;
(function (Method) {
Method["HEAD"] = "HEAD";
Method["GET"] = "GET";
Method["PUT"] = "PUT";
Method["POST"] = "POST";
Method["PATCH"] = "PATCH";
Method["DELETE"] = "DELETE";
})(Method || (Method = {}));

7
target/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export * from './endpoint';
export * from './client';
export * from './client-hook';
export * from './lazy-request-hook';
export * from './request-hook';
export * from './request-context';
export * from './reducer';

7
target/index.js Normal file
View File

@ -0,0 +1,7 @@
export * from './endpoint';
export * from './client';
export * from './client-hook';
export * from './lazy-request-hook';
export * from './request-hook';
export * from './request-context';
export * from './reducer';

15
target/lazy-request-hook.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { Endpoint } from './endpoint';
import { PublicRequestState } from './reducer';
import { ClientResponse } from './client';
export declare type LazyRequestConfig<R, V, P = void> = Readonly<{
variables?: V;
params?: P;
headers?: Record<string, string>;
onComplete?: (data: R) => unknown;
onFailure?: (res: ClientResponse<R>) => unknown;
}>;
export declare type LazyRequestHandlerConfig<R, V, P> = Readonly<LazyRequestConfig<R, V, P> & {
force?: boolean;
}>;
export declare type RequestHandler<R, V, P> = (config?: LazyRequestHandlerConfig<R, V, P>) => Promise<R | null>;
export declare function useLazyRequest<R = Record<string, any>, V = Record<string, any>, P = void>(endpoint: Endpoint<R, V, P>, config?: LazyRequestConfig<R, V, P>): [RequestHandler<R, V, P>, PublicRequestState<R>];

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import invariant from 'tiny-invariant';
import isEqual from 'lodash.isequal';
import { useClient } from './client-hook';
import { requestReducer } from './reducer';
import { useRequestContext } from './request-context';
import { isFunction } from './misc';
export function useLazyRequest(endpoint, config) {
const [client] = useClient();
const { defaultHeaders } = useRequestContext();
const [state, dispatch] = React.useReducer(requestReducer, {
data: null,
loading: false,
isCalled: false,
});
const transformResponseData = useCallback((data) => {
return isFunction(endpoint.transformResponseData) ?
endpoint.transformResponseData(data)
: data;
}, [endpoint]);
const handler = React.useCallback((handlerConfig) => {
var _a, _b, _c;
if (state === null || state === void 0 ? void 0 : state.loading) {
return Promise.resolve(null);
}
let params;
let endpointUrl;
let isSameRequest = true;
if (isFunction(endpoint.url)) {
params = (_a = handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.params) !== null && _a !== void 0 ? _a : config === null || config === void 0 ? void 0 : config.params;
invariant(params, 'Endpoind required params');
endpointUrl = endpoint.url(params);
isSameRequest = !!(state === null || state === void 0 ? void 0 : state.prevParams) && isEqual(state.prevParams, params);
}
else {
endpointUrl = endpoint.url;
}
const variables = Object.assign(Object.assign({}, config === null || config === void 0 ? void 0 : config.variables), handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.variables);
const headers = Object.assign(Object.assign(Object.assign(Object.assign({}, defaultHeaders), endpoint.headers), config === null || config === void 0 ? void 0 : config.headers), handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.headers);
if (isSameRequest
&& (state === null || state === void 0 ? void 0 : state.prevVariables) && isEqual(state.prevVariables, variables)
&& (state === null || state === void 0 ? void 0 : state.prevHeaders) && isEqual(state.prevHeaders, headers)
&& !(handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.force)) {
return Promise.resolve(state.data);
}
const onComplete = (_b = handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.onComplete) !== null && _b !== void 0 ? _b : config === null || config === void 0 ? void 0 : config.onComplete;
const onFailure = (_c = handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.onFailure) !== null && _c !== void 0 ? _c : config === null || config === void 0 ? void 0 : config.onFailure;
dispatch({ type: 'call', headers, variables, params });
return client
.request(Object.assign(Object.assign({}, endpoint), { url: endpointUrl, headers,
variables,
transformResponseData }))
.then((response) => {
dispatch({ type: 'success', response });
if (isFunction(onComplete)) {
onComplete(response.data);
}
return response.data;
}, (response) => {
dispatch({ type: 'failure', response });
if (isFunction(onFailure)) {
onFailure(response);
}
return null;
});
}, [state, config, client, endpoint, defaultHeaders, transformResponseData]);
return [
handler,
{
data: state.data,
loading: state.loading,
isCalled: state.isCalled,
},
];
}

4
target/misc.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export declare function isObject(val: any): val is Record<string, unknown>;
export declare function isFunction(val: any): val is (...args: any[]) => any;
export declare function formDataFromObject(obj: Record<string, any>): FormData;
export declare function urlSearchParamsFromObject(obj: Record<string, any>): URLSearchParams;

42
target/misc.js Normal file
View File

@ -0,0 +1,42 @@
export function isObject(val) {
return Object.prototype.toString.call(val) === '[object Object]';
}
export function isFunction(val) {
return typeof val === 'function';
}
export function formDataFromObject(obj) {
const formData = new FormData();
Object.entries(obj)
.filter(([_, value]) => value !== undefined)
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value
.filter(val => val !== undefined)
.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) {
const searchParams = new URLSearchParams();
Object.entries(obj)
.filter(([_, value]) => value !== undefined)
.forEach(([key, value]) => {
if (Array.isArray(value)) {
const arrayKey = `${key}[]`;
value
.filter(val => val !== undefined)
.forEach(val => searchParams.append(arrayKey, val));
}
else {
searchParams.set(key, value);
}
});
return searchParams;
}

44
target/reducer.d.ts vendored Normal file
View File

@ -0,0 +1,44 @@
import { ClientResponse } from './client';
export declare type RequestState<R> = Readonly<{
data: R | null;
loading: boolean;
isCalled: boolean;
prevHeaders?: Record<string, string>;
prevVariables?: Record<string, any>;
prevParams?: Record<string, any>;
}>;
export declare type PublicRequestState<R> = Pick<RequestState<R>, 'data' | 'loading' | 'isCalled'>;
export declare type RequestAction<R> = {
type: 'call';
headers: Record<string, string>;
variables: Record<string, any>;
params?: Record<string, any>;
} | {
type: 'success';
response: ClientResponse<R>;
} | {
type: 'failure';
response: ClientResponse<R>;
};
export declare function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>): {
loading: boolean;
isCalled: boolean;
prevHeaders: Record<string, string>;
prevVariables: Record<string, any>;
prevParams: Record<string, any> | undefined;
data: R | null;
} | {
loading: boolean;
data: R;
isCalled: boolean;
prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined;
prevParams?: Record<string, any> | undefined;
} | {
loading: boolean;
data: null;
isCalled: boolean;
prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined;
prevParams?: Record<string, any> | undefined;
};

13
target/reducer.js Normal file
View File

@ -0,0 +1,13 @@
export function requestReducer(state, action) {
switch (action.type) {
case 'call': {
return Object.assign(Object.assign({}, state), { loading: true, isCalled: true, prevHeaders: action.headers, prevVariables: action.variables, prevParams: action.params });
}
case 'success': {
return Object.assign(Object.assign({}, state), { loading: false, data: action.response.data });
}
case 'failure': {
return Object.assign(Object.assign({}, state), { loading: false, data: null });
}
}
}

12
target/request-context.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
import { Client } from './client';
export declare type RequestContextData = Readonly<{
client: Client;
defaultHeaders?: Record<string, string>;
}>;
export declare type RequestProviderProps = Readonly<React.PropsWithChildren<RequestContextData>>;
export declare function RequestProvider({ client, defaultHeaders, children }: RequestProviderProps): JSX.Element;
export declare function useRequestContext(): Readonly<{
client: Client;
defaultHeaders?: Record<string, string> | undefined;
}>;

11
target/request-context.js Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import invariant from 'tiny-invariant';
const RequestContext = React.createContext(null);
export function RequestProvider({ client, defaultHeaders, children }) {
return (React.createElement(RequestContext.Provider, { value: { client, defaultHeaders } }, children));
}
export function useRequestContext() {
const context = React.useContext(RequestContext);
invariant(context, 'useRequestContext() must be a child of <RequestProvider />');
return context;
}

13
target/request-hook.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import { Endpoint } from './endpoint';
import { LazyRequestConfig } from './lazy-request-hook';
export declare type RequestConfig<R, V, P> = Readonly<LazyRequestConfig<R, V, P> & {
skip?: boolean;
}>;
export declare function useRequest<R = Record<string, any>, V = Record<string, any>, P = void>(endpoint: Endpoint<R, V, P>, config?: RequestConfig<R, V, P>): Pick<Readonly<{
data: R | null;
loading: boolean;
isCalled: boolean;
prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined;
prevParams?: Record<string, any> | undefined;
}>, "loading" | "data" | "isCalled">;

15
target/request-hook.js Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import invariant from 'tiny-invariant';
import { Method } from './endpoint';
import { useLazyRequest } from './lazy-request-hook';
export function useRequest(endpoint, config) {
invariant(endpoint.method !== Method.DELETE, `You cannot use useRequest with ${endpoint.method} method`);
const [handler, state] = useLazyRequest(endpoint, config);
const skip = React.useMemo(() => { var _a; return (_a = config === null || config === void 0 ? void 0 : config.skip) !== null && _a !== void 0 ? _a : false; }, [config]);
React.useEffect(() => {
if (!skip) {
handler();
}
}, [skip, handler]);
return state;
}

46
tests/client.spec.ts Normal file
View File

@ -0,0 +1,46 @@
import { Client } from '../src/client';
import { Method } from '../src/endpoint';
import * as nodeFetch from 'node-fetch';
beforeAll(() => {
global.Response = nodeFetch.Response as any;
global.Request = nodeFetch.Request as any;
global.Headers = nodeFetch.Headers as any;
});
describe('client', () => {
describe('::prepareRequest', () => {
it('should prepare request successfully', () => {
const client = new Client({
baseUrl: 'https://example.org/api'
});
const preparedRequest = client.prepareRequest({
url: '/',
method: Method.GET,
headers: {},
variables: {},
});
expect(preparedRequest.url).toBe('https://example.org/api/');
expect(preparedRequest.method).toBe(Method.GET);
});
it('should prepare request successfully if client with relative base url', () => {
const client = new Client({
baseUrl: '/api'
});
const preparedRequest = client.prepareRequest({
url: '/',
method: Method.GET,
headers: {},
variables: {},
});
expect(preparedRequest.url).toBe('http://localhost/api/');
expect(preparedRequest.method).toBe(Method.GET);
});
});
});