frest
Version:
REST client for browser with Fetch
640 lines (587 loc) • 18.7 kB
text/typescript
/**
* @module frest
*/
import { FrestError } from './FrestError';
import {
ConfigMergeType,
ConfigType,
Config,
ErrorInterceptor,
Interceptors,
FrestRequest,
RequestInterceptor,
ResponseInterceptor,
FrestResponse,
FrestErrorType,
RequestType,
HttpMethod,
ResponseTransformer,
RequestTransformer,
} from './types';
import xhr from './xhr';
import * as utils from './utils';
import { InterceptorManager } from './InterceptorManager';
interface InternalAfterFetch {
raw: Response;
request: FrestRequest;
}
const methodsNoData: string[] = ['get', 'delete', 'options', 'download'];
const methodsData: string[] = ['post', 'put', 'patch', 'upload'];
const setCt = (headers: Headers, value: string) => {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', value);
}
};
const json: ResponseTransformer = raw => {
const ct = raw.headers.get('Content-Type');
if (ct && ct.indexOf('application/json') >= 0) {
return raw
.clone()
.json()
.catch(_ => raw.body);
}
return raw.body;
};
const req: RequestTransformer = ({ headers }, data) => {
if (
utils.isFormData(data) ||
utils.isArrayBuffer(data) ||
utils.isBuffer(data) ||
utils.isStream(data) ||
utils.isFile(data) ||
utils.isBlob(data)
) {
return data;
}
if (utils.isArrayBufferView(data)) {
return data.buffer;
}
if (utils.isURLSearchParams(data)) {
setCt(headers, 'application/x-www-form-urlencoded;charset=utf-8');
return data.toString();
}
if (utils.isObject(data)) {
setCt(headers, 'application/json;charset=utf-8');
return JSON.stringify(data);
}
return data;
};
const resp = <T = any>(
raw: Response,
// tslint:disable-next-line:no-object-literal-type-assertion
data: T = {} as T,
): FrestResponse<T> => ({
raw,
data,
headers: raw.headers,
ok: raw.ok,
redirected: raw.redirected,
status: raw.status,
statusText: raw.statusText,
trailer: raw.trailer,
type: raw.type,
url: raw.url,
});
const checkInt = (ret: any, type: 'response' | 'request') => {
if (!ret) {
const w = type === 'request' ? `${type} config` : type;
throw new Error(`one of interceptor didn't return ${w}`);
}
};
/**
* Default configuration if Frest instance is created without any configuration.
* @public
*/
export const DEFAULT_CONFIG: Config = {
base: '',
fetch,
headers: {
common: new Headers({ Accept: 'application/json, text/plain, */*' }),
post: new Headers(),
get: new Headers(),
put: new Headers(),
delete: new Headers(),
patch: new Headers(),
options: new Headers(),
},
method: 'GET',
transformResponse: [json],
transformRequest: [req],
};
/**
* Frest constructor/class signature.
* @remarks
* This is only used for UMD build.
* @public
*/
export interface FrestConstructor {
new (config?: ConfigType): Frest;
}
/**
* The main Frest class.
* @public
*/
class Frest {
public config: Config;
public interceptors: Interceptors = {
request: new InterceptorManager<RequestInterceptor>(),
response: new InterceptorManager<ResponseInterceptor>(),
error: new InterceptorManager<ErrorInterceptor>(),
};
/**
* Creates an instance of Frest.
* @param config - Configuration for this instance.
* Can be string or array of string (in which it'll be the `base` URL for
* every requests), or a {@link Config} object. Defaults to `DEFAULT_CONFIG`
*/
constructor(config?: ConfigType) {
if (config && typeof config === 'string') {
this.config = { ...DEFAULT_CONFIG, base: config };
} else if (config && typeof config === 'object') {
const headers = {
...DEFAULT_CONFIG.headers,
...config.headers,
};
this.config = { ...DEFAULT_CONFIG, ...config, headers };
} else {
this.config = { ...DEFAULT_CONFIG };
}
this.config.base = utils.trimSlashes(this.config.base);
}
/**
* Get base URL used in this instance.
*/
public get base(): string {
return this.config.base;
}
public create(config?: ConfigType) {
return new Frest(config);
}
/**
* Merge this instance config with the one provided here.
* @param config - Configuration to be merged into this instance's configuration.
*/
public mergeConfig(config: ConfigMergeType) {
const headers = {
...this.config.headers,
...config.headers,
};
this.config = { ...this.config, ...config, headers };
}
/**
* Get `fetch` function used in this instance.
* @remarks
* This can be the native `fetch` API or any function with similar signature.
*/
public get fetchFn(): typeof window.fetch {
return this.config.fetch;
}
/**
* Get full URL from the provided path and query object/string.
* @remarks
* This will use the instance's `base` URL configuration and construct full
* URL to the provided arguments.
*
* @param path - Endpoint path
* @param query - query object/string to include
* @returns Full URL to the provided arguments.
*/
public parsePath(path: string | string[], query?: any): string {
const paths: string[] = path
? path instanceof Array
? path
: [path]
: [''];
query = this.parseQuery(query);
return utils.trimSlashes(
`${this.config.base}/${paths.map(encodeURI).join('/')}${query}`,
);
}
/**
* Utility function to parse a query object into string.
*
* @remarks
* This is a shortcut to the `utils.parseQuery` function.
*
* @param query - The query to parse. It can be object/string
* @returns Parsed query string
*/
public parseQuery(query: any): string {
return utils.parseQuery(query);
}
/**
* Make a request to an endpoint.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
public request<T = any>(
init: RequestType,
request: Partial<FrestRequest> = {},
): Promise<FrestResponse<T>> {
const conf = {
action: 'request',
method: this.config.method,
...this.requestConfig(init, request),
};
return this.internalRequest<T>({ ...conf, headers: this.headers(conf) });
}
private internalRequest<T = any>(
request: FrestRequest,
): Promise<FrestResponse<T>> {
return this.before(request)
.then(this.req)
.then(this.after)
.catch(this.onError(request));
}
private requestConfig(init: RequestType, request: Partial<FrestRequest>) {
const { fetch, base, method, headers, ...rest } = this.config;
if (typeof init === 'string' || init instanceof Array) {
return {
path: init,
...rest,
...request,
};
}
return {
path: '',
...rest,
...init,
};
}
private headers(request: { method: HttpMethod; headers?: Headers }) {
const method = request.method.toLowerCase();
const headers = new Headers(this.config.headers.common);
this.config.headers[method].forEach((v: string, k: string) => {
headers.set(k, v);
});
if (request.headers) {
request.headers.forEach((v, k) => {
headers.set(k, v);
});
}
return headers;
}
private getFetch(request: FrestRequest): typeof fetch {
if (request.action === 'upload' || request.action === 'download') {
return xhr as any;
}
if (typeof request.fetch === 'function') {
return request.fetch;
} else if (typeof this.config.fetch === 'function') {
return this.config.fetch;
}
throw new FrestError(
'Fetch API is not available in this browser',
this,
request,
);
}
private before(request: FrestRequest) {
return new Promise<FrestRequest>((resolve, reject) => {
let dataPromise = Promise.resolve<any>(request.body);
for (let i = 0; i < request.transformRequest.length; i++) {
if (methodsData.indexOf(request.method.toLowerCase()) >= 0) {
dataPromise = dataPromise.then(data =>
request.transformRequest[i](request, data),
);
}
}
let requestPromise = dataPromise.then(body => {
request.body = body;
return request;
});
for (let i = 0; i < this.interceptors.request.handlers.length; i++) {
requestPromise = requestPromise.then(requestConfig => {
checkInt(requestConfig, 'request');
return this.interceptors.request.handlers[i]({
frest: this,
request: requestConfig,
});
});
}
requestPromise
.then(requestConfig => {
checkInt(requestConfig, 'request');
resolve(requestConfig);
})
.catch(e => {
const cause = typeof e === 'string' ? e : e.message ? e.message : e;
reject(
new FrestError(
`Error in request transform/interceptor: ${cause}`,
this,
request,
),
);
});
});
}
private req = (request: FrestRequest): Promise<InternalAfterFetch> => {
let fetchFn: typeof fetch;
try {
fetchFn = this.getFetch(request);
} catch (error) {
return Promise.reject(error);
}
const fullPath = this.parsePath(request.path, request.query);
return fetchFn(fullPath, request).then<InternalAfterFetch>(raw => ({
request,
raw,
}));
};
private after = (afterFetch: InternalAfterFetch): Promise<FrestResponse> => {
const { raw, request } = afterFetch;
let dataPromise = Promise.resolve<any>({});
for (let i = 0; i < request.transformResponse.length; i++) {
dataPromise = dataPromise.then(data =>
request.transformResponse[i](raw, data),
);
}
if (!raw.ok) {
return dataPromise.then(data =>
Promise.reject(
new FrestError(
`Non OK HTTP response status: ${raw.status} - ${raw.statusText}`,
this,
request,
resp(raw, data),
),
),
);
}
let responsePromise = dataPromise.then(data => resp(raw, data));
for (let i = 0; i < this.interceptors.response.handlers.length; i++) {
responsePromise = responsePromise.then(response => {
checkInt(response, 'response');
return this.interceptors.response.handlers[i]({
frest: this,
request,
response,
});
});
}
return responsePromise
.then(r => {
checkInt(r, 'response');
return r;
})
.catch(e => {
const cause = typeof e === 'string' ? e : e.message ? e.message : e;
return Promise.reject(
new FrestError(
`Error in response transform/interceptor: ${cause}`,
this,
request,
resp(raw),
),
);
});
};
private onError = (request: FrestRequest) => (e: any): any => {
let err: FrestErrorType = this.toFrestError(e, request);
if (this.interceptors.error.handlers.length === 0) {
return Promise.reject(err);
}
return new Promise<any>((resolve, reject) => {
let promise: Promise<FrestResponse | undefined | null> = Promise.resolve(
null,
);
let recovery: FrestResponse | undefined | null = null;
for (let i = 0; i < this.interceptors.error.handlers.length; i++) {
if (recovery != null) {
break;
}
promise = promise
// eslint-disable-next-line no-loop-func
.then(rec => {
if (rec != null) {
recovery = rec;
return rec;
}
return this.interceptors.error.handlers[i](err);
})
// eslint-disable-next-line no-loop-func
.catch(ee => {
err = this.toFrestError(ee, request);
return null;
});
}
promise.then(res => {
if (res) {
resolve(res);
} else {
reject(err);
}
});
});
};
private toFrestError(e: any, requestConfig: FrestRequest): FrestErrorType {
return utils.isFrestError(e)
? new FrestError(e.message, this, requestConfig, e.response)
: e;
}
}
for (let i = 0; i < methodsNoData.length; i++) {
const method = methodsNoData[i];
const meth = method === 'download' ? 'GET' : method.toUpperCase();
Frest.prototype[method] = function(
this: any,
init: RequestType,
request: Partial<FrestRequest> = {},
) {
const conf = {
action: method,
method: meth,
...this.requestConfig(init, request),
};
return this.internalRequest({ ...conf, headers: this.headers(conf) });
};
}
for (let i = 0; i < methodsData.length; i++) {
const method = methodsData[i];
const meth = method === 'upload' ? 'POST' : method.toUpperCase();
Frest.prototype[method] = function(
this: any,
init: RequestType,
body?: any,
request: Partial<FrestRequest> = {},
) {
const conf = {
action: method,
method: meth,
...this.requestConfig(init, request),
};
return this.internalRequest({ body, ...conf, headers: this.headers(conf) });
};
}
interface Frest {
/**
* Make a request to an endpoint with HTTP `POST` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
post<T = any>(
init: RequestType,
body?: any,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Make a request to an endpoint with HTTP `GET` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
get<T = any>(
init: RequestType,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Make a request to an endpoint with HTTP `PUT` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
put<T = any>(
init: RequestType,
body?: any,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Make a request to an endpoint with HTTP `PATCH` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
patch<T = any>(
init: RequestType,
body?: any,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Make a request to an endpoint with HTTP `DELETE` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
delete<T = any>(
init: RequestType,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Make a request to an endpoint with HTTP `OPTIONS` method.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
options<T = any>(
init: RequestType,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Upload something to an endpoint.
* @remarks
* This is a special request function which will use `XMLHTTPRequest` to support
* upload progress. By default the HTTP method used is `POST`. Currently only
* support request body of `FormData` object.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
upload<T = any>(
init: RequestType,
body?: any,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
/**
* Download something from an endpoint.
* @remarks
* This is a special request function which will use `XMLHTTPRequest` to support
* download progress. By default the HTTP method used is `GET`.
*
* @template T - The type of response's body, if any. Defaults to `any`.
* @param init - A string, string array, or request configuration object.
* @param request - request configuration if the first arg is string
* or string array
* @returns Response promise which will be resolved when the request is successful.
* The promise will throws in case of error in any request life-cycle.
*/
download<T = any>(
init: RequestType,
request?: Partial<FrestRequest>,
): Promise<FrestResponse<T>>;
}
export { Frest };