UNPKG

@zimic/interceptor

Version:

Next-gen TypeScript-first HTTP intercepting and mocking

408 lines (332 loc) 13.6 kB
import { HttpHeadersInit, HttpHeaders, HttpBody, HttpResponse, HttpMethod, HttpMethodSchema, HttpSchema, HttpStatusCode, InferPathParams, parseHttpBody, HttpSearchParams, } from '@zimic/http'; import isDefined from '@zimic/utils/data/isDefined'; import { Default, PossiblePromise } from '@zimic/utils/types'; import color from 'picocolors'; import { removeArrayElement } from '@/utils/arrays'; import { isClientSide } from '@/utils/environment'; import { methodCanHaveResponseBody } from '@/utils/http'; import { formatValueToLog, logger } from '@/utils/logging'; import HttpInterceptorClient, { AnyHttpInterceptorClient } from '../interceptor/HttpInterceptorClient'; import { HttpInterceptorPlatform, HttpInterceptorType, UnhandledRequestStrategy } from '../interceptor/types/options'; import { UnhandledHttpInterceptorRequestPath, UnhandledHttpInterceptorRequestMethodSchema, } from '../interceptor/types/requests'; import { HTTP_INTERCEPTOR_REQUEST_HIDDEN_PROPERTIES, HTTP_INTERCEPTOR_RESPONSE_HIDDEN_PROPERTIES, HttpInterceptorRequest, HttpInterceptorResponse, } from '../requestHandler/types/requests'; import { DEFAULT_UNHANDLED_REQUEST_STRATEGY } from './constants'; import { MSWHttpResponseFactory } from './types/msw'; import { HttpInterceptorWorkerType } from './types/options'; abstract class HttpInterceptorWorker { abstract get type(): HttpInterceptorWorkerType; platform: HttpInterceptorPlatform | null = null; isRunning = false; private startingPromise?: Promise<void>; private stoppingPromise?: Promise<void>; private runningInterceptors: AnyHttpInterceptorClient[] = []; abstract start(): Promise<void>; protected async sharedStart(internalStart: () => Promise<void>) { if (this.isRunning) { return; } if (this.startingPromise) { return this.startingPromise; } try { this.startingPromise = internalStart(); await this.startingPromise; this.startingPromise = undefined; } catch (error) { // In server side (e.g. Node.js), we need to manually log the error because this will be treated as an unhandled // promise rejection. If we don't log it, the output won't contain details about the error. In the browser, // uncaught promise rejections are automatically logged. if (!isClientSide()) { console.error(error); } await this.stop(); throw error; } } abstract stop(): Promise<void>; protected async sharedStop(internalStop: () => PossiblePromise<void>) { if (!this.isRunning) { return; } if (this.stoppingPromise) { return this.stoppingPromise; } const stoppingResult = internalStop(); if (stoppingResult instanceof Promise) { this.stoppingPromise = stoppingResult; await this.stoppingPromise; } this.stoppingPromise = undefined; } abstract use<Schema extends HttpSchema>( interceptor: HttpInterceptorClient<Schema>, method: HttpMethod, path: string, createResponse: MSWHttpResponseFactory, ): PossiblePromise<void>; protected async logUnhandledRequestIfNecessary( request: Request, strategy: UnhandledRequestStrategy.Declaration | null, ) { if (strategy?.log) { await HttpInterceptorWorker.logUnhandledRequestWarning(request, strategy.action); return { wasLogged: true }; } return { wasLogged: false }; } protected async getUnhandledRequestStrategy(request: Request, interceptorType: HttpInterceptorType) { const candidates = await this.getUnhandledRequestStrategyCandidates(request, interceptorType); const strategy = this.reduceUnhandledRequestStrategyCandidates(candidates); return strategy; } private reduceUnhandledRequestStrategyCandidates(candidateStrategies: UnhandledRequestStrategy.Declaration[]) { if (candidateStrategies.length === 0) { return null; } // Prefer strategies from first to last, overriding undefined values with the next candidate. return candidateStrategies.reduce( (accumulatedStrategy, candidateStrategy): UnhandledRequestStrategy.Declaration => ({ action: accumulatedStrategy.action, log: accumulatedStrategy.log ?? candidateStrategy.log, }), ); } private async getUnhandledRequestStrategyCandidates( request: Request, interceptorType: HttpInterceptorType, ): Promise<UnhandledRequestStrategy.Declaration[]> { const globalDefaultStrategy = DEFAULT_UNHANDLED_REQUEST_STRATEGY[interceptorType]; try { const interceptor = this.findInterceptorByRequestBaseURL(request); if (!interceptor) { return []; } const requestClone = request.clone(); const interceptorStrategy = await this.getInterceptorUnhandledRequestStrategy(requestClone, interceptor); return [interceptorStrategy, globalDefaultStrategy].filter(isDefined); } catch (error) { console.error(error); return [globalDefaultStrategy]; } } registerRunningInterceptor(interceptor: AnyHttpInterceptorClient) { this.runningInterceptors.push(interceptor); } unregisterRunningInterceptor(interceptor: AnyHttpInterceptorClient) { removeArrayElement(this.runningInterceptors, interceptor); } private findInterceptorByRequestBaseURL(request: Request) { const interceptor = this.runningInterceptors.findLast((interceptor) => { return request.url.startsWith(interceptor.baseURLAsString); }); return interceptor; } private async getInterceptorUnhandledRequestStrategy(request: Request, interceptor: AnyHttpInterceptorClient) { if (typeof interceptor.onUnhandledRequest === 'function') { const parsedRequest = await HttpInterceptorWorker.parseRawUnhandledRequest(request); return interceptor.onUnhandledRequest(parsedRequest); } return interceptor.onUnhandledRequest; } abstract clearHandlers<Schema extends HttpSchema>(options?: { interceptor?: HttpInterceptorClient<Schema>; }): PossiblePromise<void>; abstract get interceptorsWithHandlers(): AnyHttpInterceptorClient[]; static createResponseFromDeclaration( request: Request, declaration: { status: number; headers?: HttpHeadersInit; body?: HttpBody }, ): HttpResponse { const headers = new HttpHeaders(declaration.headers); const status = declaration.status; const canHaveBody = methodCanHaveResponseBody(request.method as HttpMethod) && status !== 204; if (!canHaveBody) { return new Response(null, { headers, status }) as HttpResponse; } if ( typeof declaration.body === 'string' || declaration.body === null || declaration.body === undefined || declaration.body instanceof FormData || declaration.body instanceof URLSearchParams || declaration.body instanceof Blob || declaration.body instanceof ArrayBuffer || declaration.body instanceof ReadableStream ) { return new Response(declaration.body ?? null, { headers, status }) as HttpResponse; } return Response.json(declaration.body, { headers, status }) as HttpResponse; } static async parseRawUnhandledRequest(request: Request) { return this.parseRawRequest<UnhandledHttpInterceptorRequestPath, UnhandledHttpInterceptorRequestMethodSchema>( request, ); } static async parseRawRequest<Path extends string, MethodSchema extends HttpMethodSchema>( originalRawRequest: Request, options?: { baseURL: string; pathRegex: RegExp }, ): Promise<HttpInterceptorRequest<Path, MethodSchema>> { const rawRequest = originalRawRequest.clone(); const rawRequestClone = rawRequest.clone(); type BodySchema = Default<Default<MethodSchema['request']>['body']>; const parsedBody = await parseHttpBody<BodySchema>(rawRequest).catch((error: unknown) => { logger.error('Failed to parse request body:', error); return null; }); type HeadersSchema = Default<Default<MethodSchema['request']>['headers']>; const headers = new HttpHeaders<HeadersSchema>(rawRequest.headers); const pathParams = this.parseRawPathParams<Path>(rawRequest, options); const parsedURL = new URL(rawRequest.url); type SearchParamsSchema = Default<Default<MethodSchema['request']>['searchParams']>; const searchParams = new HttpSearchParams<SearchParamsSchema>(parsedURL.searchParams); const parsedRequest = new Proxy(rawRequest as unknown as HttpInterceptorRequest<Path, MethodSchema>, { has(target, property: keyof HttpInterceptorRequest<Path, MethodSchema>) { if (HttpInterceptorWorker.isHiddenRequestProperty(property)) { return false; } return Reflect.has(target, property); }, get(target, property: keyof HttpInterceptorRequest<Path, MethodSchema>) { if (HttpInterceptorWorker.isHiddenRequestProperty(property)) { return undefined; } return Reflect.get(target, property, target) as unknown; }, }); Object.defineProperty(parsedRequest, 'body', { value: parsedBody, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'headers', { value: headers, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'pathParams', { value: pathParams, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'searchParams', { value: searchParams, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'raw', { value: rawRequestClone, enumerable: true, configurable: false, writable: false, }); return parsedRequest; } private static isHiddenRequestProperty(property: string) { return HTTP_INTERCEPTOR_REQUEST_HIDDEN_PROPERTIES.has(property as never); } static async parseRawResponse<MethodSchema extends HttpMethodSchema, StatusCode extends HttpStatusCode>( originalRawResponse: Response, ): Promise<HttpInterceptorResponse<MethodSchema, StatusCode>> { const rawResponse = originalRawResponse.clone(); const rawResponseClone = rawResponse.clone(); type BodySchema = Default<Default<Default<MethodSchema['response']>[StatusCode]>['body']>; const parsedBody = await parseHttpBody<BodySchema>(rawResponse).catch((error: unknown) => { logger.error('Failed to parse response body:', error); return null; }); type HeadersSchema = Default<Default<Default<MethodSchema['response']>[StatusCode]>['headers']>; const headers = new HttpHeaders<HeadersSchema>(rawResponse.headers); const parsedRequest = new Proxy(rawResponse as unknown as HttpInterceptorResponse<MethodSchema, StatusCode>, { has(target, property: keyof HttpInterceptorResponse<MethodSchema, StatusCode>) { if (HttpInterceptorWorker.isHiddenResponseProperty(property)) { return false; } return Reflect.has(target, property); }, get(target, property: keyof HttpInterceptorResponse<MethodSchema, StatusCode>) { if (HttpInterceptorWorker.isHiddenResponseProperty(property)) { return undefined; } return Reflect.get(target, property, target) as unknown; }, }); Object.defineProperty(parsedRequest, 'body', { value: parsedBody, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'headers', { value: headers, enumerable: true, configurable: false, writable: false, }); Object.defineProperty(parsedRequest, 'raw', { value: rawResponseClone, enumerable: true, configurable: false, writable: false, }); return parsedRequest; } private static isHiddenResponseProperty(property: string) { return HTTP_INTERCEPTOR_RESPONSE_HIDDEN_PROPERTIES.has(property as never); } static parseRawPathParams<Path extends string>( request: Request, options?: { baseURL: string; pathRegex: RegExp }, ): InferPathParams<Path> { const requestPath = request.url.replace(options?.baseURL ?? '', ''); const paramsMatch = options?.pathRegex.exec(requestPath); const params: Record<string, string | undefined> = {}; for (const [paramName, paramValue] of Object.entries(paramsMatch?.groups ?? {})) { params[paramName] = typeof paramValue === 'string' ? decodeURIComponent(paramValue) : undefined; } return params as InferPathParams<Path>; } static async logUnhandledRequestWarning(rawRequest: Request, action: UnhandledRequestStrategy.Action) { const request = await this.parseRawRequest(rawRequest); const [formattedHeaders, formattedSearchParams, formattedBody] = await Promise.all([ formatValueToLog(request.headers.toObject()), formatValueToLog(request.searchParams.toObject()), formatValueToLog(request.body), ]); logger[action === 'bypass' ? 'warn' : 'error']( `${action === 'bypass' ? 'Warning:' : 'Error:'} Request was not handled and was ` + `${action === 'bypass' ? color.yellow('bypassed') : color.red('rejected')}.\n\n `, `${request.method} ${request.url}`, '\n Headers:', formattedHeaders, '\n Search params:', formattedSearchParams, '\n Body:', formattedBody, '\n\nLearn more: https://zimic.dev/docs/interceptor/guides/http/unhandled-requests', ); } } export default HttpInterceptorWorker;