UNPKG

mappersmith

Version:

It is a lightweight rest client for node.js and the browser

196 lines (167 loc) 7.23 kB
import { Manifest, ManifestOptions, GlobalConfigs, Method, ResourceTypeConstraint, } from './manifest' import { Response } from './response' import { Request, RequestContext } from './request' import type { MiddlewareDescriptor, RequestGetter, ResponseGetter } from './middleware/index' import { Gateway } from './gateway/index' import type { Params } from './types' export type AsyncFunction = (params?: Params, context?: RequestContext) => Promise<Response> export type AsyncFunctions<HashType> = { [Key in keyof HashType]: AsyncFunction } export type Client<ResourcesType> = { [ResourceKey in keyof ResourcesType]: AsyncFunctions<ResourcesType[ResourceKey]> } interface RequestPhaseFailureContext { middleware: string | null returnedInvalidRequest: boolean abortExecution: boolean } const isFactoryConfigured = <T>(factory: () => T | null): factory is () => T => { if (!factory || !factory()) { return false } return true } /** * @typedef ClientBuilder * @param {Object} manifestDefinition - manifest definition with at least the `resources` key * @param {Function} GatewayClassFactory - factory function that returns a gateway class */ export class ClientBuilder<Resources extends ResourceTypeConstraint> { public Promise: PromiseConstructor public manifest: Manifest<Resources> public GatewayClassFactory: () => typeof Gateway public maxMiddlewareStackExecutionAllowed: number constructor( manifestDefinition: ManifestOptions<Resources>, GatewayClassFactory: () => typeof Gateway | null, configs: GlobalConfigs ) { if (!manifestDefinition) { throw new Error(`[Mappersmith] invalid manifest (${manifestDefinition})`) } if (!isFactoryConfigured(GatewayClassFactory)) { throw new Error('[Mappersmith] gateway class not configured (configs.gateway)') } if (!configs.Promise) { throw new Error('[Mappersmith] Promise not configured (configs.Promise)') } this.Promise = configs.Promise this.manifest = new Manifest(manifestDefinition, configs) this.GatewayClassFactory = GatewayClassFactory this.maxMiddlewareStackExecutionAllowed = configs.maxMiddlewareStackExecutionAllowed } public build() { const client: Client<Resources> = { _manifest: this.manifest } as never this.manifest.eachResource((resourceName: keyof Resources, methods) => { client[resourceName] = this.buildResource(resourceName, methods) }) return client } private buildResource<T, K extends keyof T = keyof T>(resourceName: K, methods: Method[]) { type Resource = AsyncFunctions<T[K]> const initialResourceValue: Partial<Resource> = {} const resource = methods.reduce((resource, method) => { const resourceMethod = (requestParams?: Params, context?: RequestContext) => { const request = new Request(method.descriptor, requestParams, context) // `resourceName` can be `PropertyKey`, making this `string | number | Symbol`, therefore the string conversion // to stop type bleeding. return this.invokeMiddlewares(String(resourceName), method.name, request) } return { ...resource, [method.name]: resourceMethod, } }, initialResourceValue) // @hint: This type assert is needed as the compiler cannot be made to understand that the reduce produce a // non-partial result on a partial input. This is due to a shortcoming of the type signature for Array<T>.reduce(). // @link: https://github.com/microsoft/TypeScript/blob/v3.7.2/lib/lib.es5.d.ts#L1186 return resource as Resource } private invokeMiddlewares(resourceName: string, resourceMethod: string, initialRequest: Request) { const middleware = this.manifest.createMiddleware({ resourceName, resourceMethod }) const GatewayClass = this.GatewayClassFactory() const gatewayConfigs = this.manifest.gatewayConfigs const requestPhaseFailureContext: RequestPhaseFailureContext = { middleware: null, returnedInvalidRequest: false, abortExecution: false, } const getInitialRequest = () => this.Promise.resolve(initialRequest) const chainRequestPhase = (next: RequestGetter, middleware: MiddlewareDescriptor) => () => { const abort = (error: Error) => { requestPhaseFailureContext.abortExecution = true throw error } return this.Promise.resolve() .then(() => middleware.prepareRequest(next, abort)) .then((request: unknown) => { if (request instanceof Request) { return request } // FIXME: Here be dragons: prepareRequest is typed as Promise<Response | void> // but this code clearly expects it can be something else... anything. // Hence manual cast to `unknown` above. requestPhaseFailureContext.returnedInvalidRequest = true const typeValue = typeof request const prettyType = typeValue === 'object' || typeValue === 'function' ? // eslint-disable-next-line @typescript-eslint/no-explicit-any (request as any).name || typeValue : typeValue throw new Error( `[Mappersmith] middleware "${middleware.__name}" should return "Request" but returned "${prettyType}"` ) }) .catch((e) => { requestPhaseFailureContext.middleware = middleware.__name || null throw e }) } const prepareRequest = middleware.reduce(chainRequestPhase, getInitialRequest) let executions = 0 const executeMiddlewareStack = () => prepareRequest() .catch((e) => { const { returnedInvalidRequest, abortExecution, middleware } = requestPhaseFailureContext if (returnedInvalidRequest || abortExecution) { throw e } const error = new Error( `[Mappersmith] middleware "${middleware}" failed in the request phase: ${e.message}` ) error.stack = e.stack throw error }) .then((finalRequest) => { executions++ if (executions > this.maxMiddlewareStackExecutionAllowed) { throw new Error( `[Mappersmith] infinite loop detected (middleware stack invoked ${executions} times). Check the use of "renew" in one of the middleware.` ) } const renew = executeMiddlewareStack const chainResponsePhase = (previousValue: ResponseGetter, currentValue: MiddlewareDescriptor) => () => { // Deliberately putting this on two separate lines - to get typescript to not return "any" const nextValue = currentValue.response(previousValue, renew, finalRequest) return nextValue } const callGateway = () => new GatewayClass(finalRequest, gatewayConfigs).call() const execute = middleware.reduce(chainResponsePhase, callGateway) return execute() }) return new this.Promise<Response>((resolve, reject) => { executeMiddlewareStack() .then((response) => resolve(response)) .catch(reject) }) } } export default ClientBuilder