UNPKG

@aurelia/fetch-client

Version:

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![CircleCI](https://circleci.com/

448 lines (396 loc) • 15.9 kB
import { DI, IIndexable, factory, resolve } from '@aurelia/kernel'; import { HttpClientConfiguration } from './http-client-configuration'; import { IFetchInterceptor } from './interfaces'; import { RetryInterceptor } from './interceptors'; import { ErrorNames, createMappedError } from './errors'; const absoluteUrlRegexp = /^([a-z][a-z0-9+\-.]*:)?\/\//i; /** * An interface to resolve what fetch function will be used for the http client * Default to the global fetch function via global `fetch` variable. */ export const IFetchFn = /*@__PURE__*/DI.createInterface<typeof fetch>('fetch', x => { if (typeof fetch !== 'function') { throw createMappedError(ErrorNames.http_client_fetch_fn_not_found); } return x.instance(fetch); }); export const IHttpClient = /*@__PURE__*/DI.createInterface<IHttpClient>('IHttpClient', x => x.aliasTo(HttpClient)); export interface IHttpClient extends HttpClient {} /** * An HTTP client based on the Fetch API. */ export class HttpClient { /** * The current number of active requests. * Requests being processed by interceptors are considered active. */ public activeRequestCount: number = 0; /** * Indicates whether or not the client is currently making one or more requests. */ public isRequesting: boolean = false; /** * Indicates whether or not the client has been configured. */ public isConfigured: boolean = false; /** * The base URL set by the config. */ public baseUrl: string = ''; /** * The default request init to merge with values specified at request time. */ public defaults: RequestInit | null = null; /** * @internal */ private _interceptors: IFetchInterceptor[] = []; /** * The interceptors to be run during requests. */ public get interceptors(): IFetchInterceptor[] { return this._interceptors.slice(0); } /** @internal */ private _dispatcher: Node | null = null; /** @internal */ private readonly _createConfiguration = resolve(factory(HttpClientConfiguration)); /** @internal */ private readonly _fetchFn = resolve(IFetchFn); /** * Configure this client with default settings to be used by all requests. * * @param config - A configuration object, or a function that takes a config * object and configures it. * @returns The chainable instance of this HttpClient. * @chainable */ public configure(config: RequestInit | ((config: HttpClientConfiguration) => HttpClientConfiguration | void) | HttpClientConfiguration): HttpClient { let normalizedConfig: HttpClientConfiguration; if (typeof config === 'object') { const requestInitConfiguration = { defaults: config as RequestInit }; normalizedConfig = requestInitConfiguration as HttpClientConfiguration; } else if (typeof config === 'function') { normalizedConfig = this._createConfiguration(); normalizedConfig.baseUrl = this.baseUrl; normalizedConfig.defaults = { ...this.defaults }; normalizedConfig.interceptors = this._interceptors; normalizedConfig.dispatcher = this._dispatcher; const c = config(normalizedConfig); if (c != null) { if (typeof c === 'object') { normalizedConfig = c; } else { throw createMappedError(ErrorNames.http_client_configure_invalid_return, typeof c); } } } else { throw createMappedError(ErrorNames.http_client_configure_invalid_config, typeof config); } const defaults = normalizedConfig.defaults; if (defaults?.headers instanceof Headers) { // Headers instances are not iterable in all browsers. Require a plain // object here to allow default headers to be merged into request headers. // extract throwing error into an utility function throw createMappedError(ErrorNames.http_client_configure_invalid_header); } const interceptors = normalizedConfig.interceptors; if (interceptors?.length > 0) { // find if there is a RetryInterceptor if (interceptors.filter(x => x instanceof RetryInterceptor).length > 1) { throw createMappedError(ErrorNames.http_client_more_than_one_retry_interceptor); } const retryInterceptorIndex = interceptors.findIndex(x => x instanceof RetryInterceptor); if (retryInterceptorIndex >= 0 && retryInterceptorIndex !== interceptors.length - 1) { throw createMappedError(ErrorNames.http_client_retry_interceptor_not_last); } // const cacheInterceptorIndex = interceptors.findIndex(x => x instanceof CacheInterceptor); // if (cacheInterceptorIndex >= 0) { // if (retryInterceptorIndex > 0) { // if (cacheInterceptorIndex < retryInterceptorIndex - 1) { // throw new Error('The cache interceptor must be defined before the retry interceptor.'); // } // } else { // if (cacheInterceptorIndex !== interceptors.length - 1) { // throw new Error('The cache interceptor is only allowed as the last interceptor or second last before the retry interceptor'); // } // } // } } this.baseUrl = normalizedConfig.baseUrl; this.defaults = defaults; this._interceptors = normalizedConfig.interceptors ?? []; this._dispatcher = normalizedConfig.dispatcher; this.isConfigured = true; return this; } /** * Starts the process of fetching a resource. Default configuration parameters * will be applied to the Request. The constructed Request will be passed to * registered request interceptors before being sent. The Response will be passed * to registered Response interceptors before it is returned. * * See also https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public fetch(input: Request | string, init?: RequestInit): Promise<Response> { this._trackRequestStart(); let request = this.buildRequest(input, init); return this.processRequest(request, this._interceptors) .then(result => { let response: Promise<Response>; if (result instanceof Response) { response = Promise.resolve(result); } else if (result instanceof Request) { request = result; // if called directly, context of the fetch fn will be this HttpClient instance // which will throw illegal invokcation response = this._fetchFn.call(void 0, request); } else { throw createMappedError(ErrorNames.http_client_invalid_request_from_interceptor, result); } return this.processResponse(response, this._interceptors, request); }) .then(result => { if (result instanceof Request) { return this.fetch(result); } return result; }) .then( result => { this._trackRequestEnd(); return result; }, error => { this._trackRequestEnd(); throw error; } ); } /** * Creates a new Request object using the current configuration of this http client */ public buildRequest(input: string | Request, init: RequestInit | undefined): Request { const defaults = this.defaults ?? {}; let request: Request; let body: unknown; let requestContentType: string | null; const parsedDefaultHeaders = parseHeaderValues(defaults.headers as IIndexable); if (input instanceof Request) { request = input; requestContentType = new Headers(request.headers).get('Content-Type'); } else { if (!init) { init = {}; } body = init.body; const bodyObj = body !== undefined ? { body: body as BodyInit } : null; const requestInit: RequestInit = { ...defaults, headers: {}, ...init, ...bodyObj }; requestContentType = new Headers(requestInit.headers as Headers).get('Content-Type'); request = new Request(getRequestUrl(this.baseUrl, input), requestInit); } if (!requestContentType) { if (new Headers(parsedDefaultHeaders).has('content-type')) { if (__DEV__) { // eslint-disable-next-line no-console console.warn('Request was created with header "content-type", converted to "Content-Type" instead.'); } request.headers.set('Content-Type', new Headers(parsedDefaultHeaders).get('content-type') as string); } else if (body !== undefined && isJSON(body)) { request.headers.set('Content-Type', 'application/json'); } } setDefaultHeaders(request.headers, parsedDefaultHeaders); if (body instanceof Blob && body.type) { // work around bug in IE & Edge where the Blob type is ignored in the request // https://connect.microsoft.com/IE/feedback/details/2136163 request.headers.set('Content-Type', body.type); } return request; } /** * Calls fetch as a GET request. * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public get(input: Request | string, init?: RequestInit): Promise<Response> { return this.fetch(input, init); } /** * Calls fetch with request method set to POST. * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param body - The body of the request. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public post(input: Request | string, body?: BodyInit, init?: RequestInit): Promise<Response> { return this._callFetch(input, body, init, 'POST'); } /** * Calls fetch with request method set to PUT. * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param body - The body of the request. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public put(input: Request | string, body?: BodyInit, init?: RequestInit): Promise<Response> { return this._callFetch(input, body, init, 'PUT'); } /** * Calls fetch with request method set to PATCH. * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param body - The body of the request. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public patch(input: Request | string, body?: BodyInit, init?: RequestInit): Promise<Response> { return this._callFetch(input, body, init, 'PATCH'); } /** * Calls fetch with request method set to DELETE. * * @param input - The resource that you wish to fetch. Either a * Request object, or a string containing the URL of the resource. * @param body - The body of the request. * @param init - An options object containing settings to be applied to * the Request. * @returns A Promise for the Response from the fetch request. */ public delete(input: Request | string, body?: BodyInit, init?: RequestInit): Promise<Response> { return this._callFetch(input, body, init, 'DELETE'); } /** * Dispose and cleanup used resources of this client. */ public dispose() { this._interceptors.forEach(i => i.dispose?.()); this._interceptors.length = 0; this._dispatcher = null; } /** @internal */ private _trackRequestStart(): void { this.isRequesting = !!(++this.activeRequestCount); if (this.isRequesting && this._dispatcher != null) { dispatch(this._dispatcher, HttpClientEvent.started); } } /** @internal */ private _trackRequestEnd(): void { this.isRequesting = !!(--this.activeRequestCount); if (!this.isRequesting && this._dispatcher != null) { dispatch(this._dispatcher, HttpClientEvent.drained); } } private processRequest(request: Request, interceptors: IFetchInterceptor[]): Promise<Request | Response> { return this._applyInterceptors(request, interceptors, 'request', 'requestError', Request, this); } private processResponse(response: Promise<Response>, interceptors: IFetchInterceptor[], request: Request): Promise<Request | Response> { return this._applyInterceptors(response, interceptors, 'response', 'responseError', Response, request, this); } /** @internal */ private _applyInterceptors( input: Request | Response | Promise<Response | Request>, interceptors: IFetchInterceptor[] | undefined, successName: 'request' | 'response', errorName: 'requestError' | 'responseError', Type: typeof Request | typeof Response, ...interceptorArgs: unknown[] ): Promise<Request | Response> { return (interceptors ?? []) .reduce( (chain, interceptor) => { const successHandler = interceptor[successName]; const errorHandler = interceptor[errorName]; return chain.then( successHandler ? (value => value instanceof Type ? successHandler.call(interceptor, value, ...interceptorArgs) : value) : identity, errorHandler ? (reason => errorHandler.call(interceptor, reason, ...interceptorArgs)) : thrower); }, Promise.resolve(input) ); } /** @internal */ private _callFetch(input: string | Request, body: BodyInit | undefined, init: RequestInit | undefined, method: string): Promise<Response> { if (!init) { init = {}; } init.method = method; if (body != null) { init.body = body; } return this.fetch(input, init); } } function parseHeaderValues(headers: Record<string, unknown> | undefined): Record<string, string> { const parsedHeaders: Record<string, string> = {}; const $headers = headers ?? {}; for (const name of Object.keys($headers)) { parsedHeaders[name] = (typeof $headers[name] === 'function') ? ($headers[name] as () => string)() : $headers[name] as string; } return parsedHeaders; } function getRequestUrl(baseUrl: string, url: string): string { if (absoluteUrlRegexp.test(url)) { return url; } return (baseUrl ?? '') + url; } function setDefaultHeaders(headers: Headers, defaultHeaders?: Record<string, string>): void { const $defaultHeaders = defaultHeaders ?? {}; for (const name of Object.keys($defaultHeaders)) { if (!headers.has(name)) { headers.set(name, $defaultHeaders[name]); } } } function isJSON(str: unknown): boolean { try { JSON.parse(str as string); } catch (err) { return false; } return true; } function identity(x: unknown): unknown { return x; } function thrower(x: unknown): never { throw x; } function dispatch(node: Node, name: string): void { const evt = new node.ownerDocument!.defaultView!.CustomEvent(name, { bubbles: true, cancelable: true }); setTimeout(() => { node.dispatchEvent(evt); }, 1); } /** * A lookup containing events used by HttpClient. */ export const HttpClientEvent = /*@__PURE__*/Object.freeze({ /** * Event to be triggered when a request is sent. */ started: 'aurelia-fetch-client-request-started', /** * Event to be triggered when a request is completed. */ drained: 'aurelia-fetch-client-requests-drained' });