UNPKG

@zimic/fetch

Version:

Next-gen TypeScript-first fetch API client

273 lines (223 loc) 9.32 kB
import { HttpSchemaPath, HttpSchemaMethod, HttpSearchParams, LiteralHttpSchemaPathFromNonLiteral, HttpSchema, HttpHeaders, } from '@zimic/http'; import createRegexFromURL from '@zimic/utils/url/createRegExpFromURL'; import excludeURLParams from '@zimic/utils/url/excludeURLParams'; import joinURL from '@zimic/utils/url/joinURL'; import FetchResponseError from './errors/FetchResponseError'; import { FetchInput, FetchOptions, Fetch, FetchDefaults } from './types/public'; import { FetchRequestConstructor, FetchRequestInit, FetchRequest, FetchResponse } from './types/requests'; class FetchClient<Schema extends HttpSchema> implements Omit<Fetch<Schema>, 'defaults' | 'loose' | 'Request'> { fetch: Fetch<Schema>; constructor({ onRequest, onResponse, ...defaults }: FetchOptions<Schema>) { this.fetch = this.createFetchFunction(); this.fetch.defaults = { ...defaults, headers: defaults.headers ?? {}, searchParams: defaults.searchParams ?? {}, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any this.fetch.loose = this.fetch as Fetch<any> as Fetch.Loose; this.fetch.Request = this.createRequestClass(this.fetch.defaults); this.fetch.onRequest = onRequest; this.fetch.onResponse = onResponse; } private createFetchFunction() { const fetch = async < Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.NonLiteral<Schema, Method>, >( input: FetchInput<Schema, Method, Path>, init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>, ) => { const request = await this.createFetchRequest<Method, Path>(input, init); const requestClone = request.clone(); const rawResponse = await globalThis.fetch( // Optimize type checking by narrowing the type of request requestClone as Request, ); const response = await this.createFetchResponse< Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path> >(request, rawResponse); return response; }; Object.setPrototypeOf(fetch, this); return fetch as Fetch<Schema>; } private async createFetchRequest< Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.NonLiteral<Schema, Method>, >( input: FetchInput<Schema, Method, Path>, init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>, ) { let request = input instanceof Request ? input : new this.fetch.Request(input, init); if (this.fetch.onRequest) { const requestAfterInterceptor = await this.fetch.onRequest( // Optimize type checking by narrowing the type of request request as FetchRequest.Loose, ); if (requestAfterInterceptor !== request) { const isFetchRequest = requestAfterInterceptor instanceof this.fetch.Request; request = isFetchRequest ? (requestAfterInterceptor as Request as typeof request) : new this.fetch.Request(requestAfterInterceptor as FetchInput<Schema, Method, Path>, init); } } return request; } private async createFetchResponse< Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.Literal<Schema, Method>, >(fetchRequest: FetchRequest<Schema, Method, Path>, rawResponse: Response) { let response = this.defineFetchResponseProperties<Method, Path>(fetchRequest, rawResponse); if (this.fetch.onResponse) { const responseAfterInterceptor = await this.fetch.onResponse( // Optimize type checking by narrowing the type of response response as FetchResponse.Loose, ); const isFetchResponse = responseAfterInterceptor instanceof Response && 'request' in responseAfterInterceptor && responseAfterInterceptor.request instanceof this.fetch.Request; response = isFetchResponse ? (responseAfterInterceptor as typeof response) : this.defineFetchResponseProperties<Method, Path>(fetchRequest, responseAfterInterceptor); } return response; } private defineFetchResponseProperties< Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.Literal<Schema, Method>, >(fetchRequest: FetchRequest<Schema, Method, Path>, response: Response) { const fetchResponse = response as FetchResponse<Schema, Method, Path>; Object.defineProperty(fetchResponse, 'request', { value: fetchRequest, writable: false, enumerable: true, configurable: false, }); let responseError: FetchResponse.Loose['error'] | undefined; Object.defineProperty(fetchResponse, 'error', { get() { if (responseError === undefined) { responseError = fetchResponse.ok ? null : new FetchResponseError( fetchRequest, fetchResponse as FetchResponse<Schema, Method, Path, true, 'manual'>, ); } return responseError; }, enumerable: true, configurable: false, }); return fetchResponse; } private createRequestClass(defaults: FetchDefaults) { class Request< Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.NonLiteral<Schema, Method>, > extends globalThis.Request { path: LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>; constructor( input: FetchInput<Schema, Method, Path>, init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>, ) { const initWithDefaults = { ...defaults, ...init }; const headersFromDefaults = new HttpHeaders(defaults.headers); const headersFromInit = new HttpHeaders((init satisfies RequestInit as RequestInit).headers); let url: URL; const baseURL = new URL(initWithDefaults.baseURL); if (input instanceof globalThis.Request) { // Optimize type checking by narrowing the type of input const request = input as globalThis.Request; // Optimize type checking by narrowing the type of headers const headersFromRequest = new HttpHeaders(input.headers as Headers); initWithDefaults.headers = { ...headersFromDefaults.toObject(), ...headersFromRequest.toObject(), ...headersFromInit.toObject(), }; super(request, initWithDefaults); url = new URL(input.url); } else { initWithDefaults.headers = { ...headersFromDefaults.toObject(), ...headersFromInit.toObject(), }; url = input instanceof URL ? new URL(input) : new URL(joinURL(baseURL, input)); const searchParamsFromDefaults = new HttpSearchParams(defaults.searchParams); const searchParamsFromInit = new HttpSearchParams(initWithDefaults.searchParams); initWithDefaults.searchParams = { ...searchParamsFromDefaults.toObject(), ...searchParamsFromInit.toObject(), }; url.search = new HttpSearchParams(initWithDefaults.searchParams).toString(); super(url, initWithDefaults); } const baseURLWithoutTrailingSlash = baseURL.toString().replace(/\/$/, ''); this.path = excludeURLParams(url) .toString() .replace(baseURLWithoutTrailingSlash, '') as LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>; } clone(): Request<Method, Path> { const rawClone = super.clone(); return new Request<Method, Path>( rawClone as unknown as FetchInput<Schema, Method, Path>, rawClone as unknown as FetchRequestInit< Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path> >, ); } } return Request as FetchRequestConstructor<Schema>; } isRequest<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>( request: unknown, method: Method, path: Path, ): request is FetchRequest<Schema, Method, Path> { return ( request instanceof Request && request.method === method && 'path' in request && typeof request.path === 'string' && createRegexFromURL(path).test(request.path) ); } isResponse<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>( response: unknown, method: Method, path: Path, ): response is FetchResponse<Schema, Method, Path> { return ( response instanceof Response && 'request' in response && this.isRequest(response.request, method, path) && 'error' in response && (response.error === null || response.error instanceof FetchResponseError) ); } isResponseError<Path extends HttpSchemaPath.Literal<Schema, Method>, Method extends HttpSchemaMethod<Schema>>( error: unknown, method: Method, path: Path, ): error is FetchResponseError<Schema, Method, Path> { return ( error instanceof FetchResponseError && this.isRequest(error.request, method, path) && this.isResponse(error.response, method, path) ); } } export default FetchClient;