2020-11-04 14:36:44 +03:00
|
|
|
import invariant from 'tiny-invariant';
|
|
|
|
import { Method } from './endpoint';
|
2020-11-20 20:01:24 +03:00
|
|
|
import { formDataFromObject, isFunction, urlSearchParamsFromObject } from './misc';
|
2020-11-04 14:36:44 +03:00
|
|
|
|
|
|
|
export type ClientConfig = {
|
|
|
|
baseUrl: string,
|
|
|
|
}
|
|
|
|
|
2020-11-20 20:01:24 +03:00
|
|
|
type PrepareRequestProps = {
|
2020-11-04 14:36:44 +03:00
|
|
|
url: string,
|
|
|
|
method: Method,
|
|
|
|
headers: Record<string, string>,
|
|
|
|
variables: Record<string, any> | FormData,
|
|
|
|
}
|
|
|
|
|
2020-11-20 20:01:24 +03:00
|
|
|
export type RequestProps<R> = PrepareRequestProps & {
|
|
|
|
transformResponseData?: (data: unknown) => R,
|
|
|
|
}
|
|
|
|
|
2020-12-23 05:14:20 +03:00
|
|
|
export type ResponseWithError =
|
2020-11-06 00:53:09 +03:00
|
|
|
Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'>
|
2020-12-23 05:14:20 +03:00
|
|
|
& Readonly<{ error?: Error, canceled?: boolean }>
|
|
|
|
|
|
|
|
export type ClientResponse<Data extends Record<string, any>> =
|
|
|
|
ResponseWithError
|
|
|
|
& Readonly<{ data: Data }>
|
2020-11-04 14:36:44 +03:00
|
|
|
|
|
|
|
export class Client {
|
2020-12-23 05:14:20 +03:00
|
|
|
private controller = new AbortController();
|
|
|
|
|
2020-11-04 14:36:44 +03:00
|
|
|
constructor(private config: ClientConfig) {}
|
|
|
|
|
2020-12-06 19:18:00 +03:00
|
|
|
public prepareRequest(props: PrepareRequestProps) {
|
2020-11-04 14:36:44 +03:00
|
|
|
const requestCanContainBody = [Method.POST, Method.PATCH, Method.PUT].includes(props.method);
|
|
|
|
|
2020-12-06 19:18:00 +03:00
|
|
|
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);
|
2020-11-04 14:36:44 +03:00
|
|
|
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
|
|
|
|
);
|
|
|
|
|
2020-12-06 19:18:00 +03:00
|
|
|
return new Request(
|
|
|
|
url.toString(),
|
|
|
|
{
|
|
|
|
headers,
|
|
|
|
method: props.method,
|
|
|
|
body,
|
|
|
|
}
|
|
|
|
);
|
2020-11-04 14:36:44 +03:00
|
|
|
}
|
|
|
|
|
2020-11-20 20:01:24 +03:00
|
|
|
public request<Data extends Record<string, any>>(
|
|
|
|
{
|
|
|
|
transformResponseData,
|
|
|
|
...restProps
|
|
|
|
}: RequestProps<Data>
|
|
|
|
): Promise<ClientResponse<Data>> {
|
|
|
|
const req = this.prepareRequest(restProps);
|
2020-11-04 14:36:44 +03:00
|
|
|
|
2020-12-23 05:14:20 +03:00
|
|
|
return fetch(req, { signal: this.controller.signal })
|
2020-11-04 14:36:44 +03:00
|
|
|
// TODO: need to check response headers and parse json only if content-type header is application/json
|
2020-12-23 05:14:20 +03:00
|
|
|
.then(
|
|
|
|
(res) => Promise.all([res, res.json()]),
|
|
|
|
(err) => {
|
|
|
|
const canceled = err.name === 'AbortError';
|
|
|
|
return Promise.all([
|
|
|
|
{
|
|
|
|
ok: false,
|
|
|
|
redirected: false,
|
|
|
|
status: canceled ? 499 : 400,
|
|
|
|
statusText: canceled ? 'Client Closed Request' : err.toString(),
|
|
|
|
type: 'basic',
|
|
|
|
headers: {},
|
|
|
|
url: req.url,
|
|
|
|
error: err,
|
|
|
|
canceled,
|
|
|
|
} as ResponseWithError,
|
|
|
|
{}
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
)
|
|
|
|
.then(([res, data]): ClientResponse<Data> => {
|
2020-11-06 00:53:09 +03:00
|
|
|
return {
|
|
|
|
ok: res.ok,
|
|
|
|
redirected: res.redirected,
|
|
|
|
status: res.status,
|
|
|
|
statusText: res.statusText,
|
|
|
|
type: res.type,
|
|
|
|
headers: res.headers,
|
|
|
|
url: res.url,
|
2020-12-23 05:14:20 +03:00
|
|
|
error: 'error' in res ? res.error : undefined,
|
|
|
|
canceled: 'canceled' in res ? res.canceled : false,
|
2020-11-20 20:01:24 +03:00
|
|
|
data: isFunction(transformResponseData) ? transformResponseData(data) : data,
|
2020-11-06 00:53:09 +03:00
|
|
|
};
|
|
|
|
})
|
|
|
|
.then((res) => {
|
|
|
|
if (!res.ok) {
|
|
|
|
throw res;
|
|
|
|
}
|
|
|
|
|
|
|
|
return res;
|
|
|
|
});
|
2020-11-04 14:36:44 +03:00
|
|
|
}
|
2020-12-23 05:14:20 +03:00
|
|
|
|
|
|
|
public cancelRequest() {
|
|
|
|
this.controller.abort();
|
|
|
|
this.controller = new AbortController();
|
|
|
|
}
|
2020-11-04 14:36:44 +03:00
|
|
|
}
|