import {Awaitable, Collection, Either, ErrorWithContext, Maybe, Pipeline, SuffixTypeArray, right} from '../../util' import {ResponseFactory} from '../response/ResponseFactory' import {HTTPMethod, Request} from '../lifecycle/Request' import {constructable, Constructable, Container, Instantiable, isInstantiableOf, TypedDependencyKey} from '../../di' import {Middleware, ParameterMiddleware} from './Middleware' import {Valid, Validator, ValidatorFactory} from '../../validation/Validator' import {validateMiddleware} from '../../validation/middleware' import {RouteGroup} from './RouteGroup' import {Config} from '../../service/Config' import {Application} from '../../lifecycle/Application' import {Logging} from '../../service/Logging' import {WebSocketBus} from '../../support/bus/WebSocketBus' import {SocketRouteBuilder} from './SocketRouteBuilder' /** * Type alias for an item that is a valid response object, or lack thereof. */ export type ResponseObject = ResponseFactory | string | number | void | any | Promise /** * Type alias for a function that applies a route handler to the request. * The goal is to transform RouteHandlers to ResolvedRouteHandler. */ export type ResolvedRouteHandler = (request: Request) => ResponseObject export type ParameterProvidingMiddleware = (request: Request) => Awaitable> export interface HandledRoute { handler: Constructable<(...x: THandlerParams) => TReturn> /** * Set a programmatic name for this route. * @param name */ alias(name: string): this } export class Route { /** Routes that have been created and registered in the application. */ private static registeredRoutes: Route[] = [] /** Groups of routes that have been registered with the application. */ private static registeredGroups: RouteGroup[] = [] /** * The current nested group stack. This is used internally when compiling the routes by nested group. * @private */ private static compiledGroupStack: RouteGroup[] = [] /** Register a route group handler. */ public static registerGroup(group: RouteGroup): void { this.registeredGroups.push(group) } /** * Load and compile all the registered routes and their groups, accounting * for nested groups and resolving handlers. * * This function attempts to resolve the route handlers ahead of time to cache * them and also expose any handler resolution errors that might happen at runtime. */ public static async compile(): Promise[]> { let registeredRoutes = this.registeredRoutes const registeredGroups = this.registeredGroups this.registeredRoutes = [] this.registeredGroups = [] const configService = Container.getContainer().make(Config) const globalMiddleware = configService.get('server.middleware.global', {}) const stack = [...this.compiledGroupStack].reverse() for ( const route of registeredRoutes ) { for ( const group of stack ) { route.prepend(group.prefix) group.getPreflight() .each(def => route.preflight.prepend( request => request.make(def, request).apply(), )) } for ( const group of this.compiledGroupStack ) { group.getPostflight() .each(def => route.postflight.push( request => request.make(def, request).apply(), )) } // Add the global pre- and post- middleware if ( Array.isArray(globalMiddleware?.pre) ) { const globalPre = [...globalMiddleware.pre].reverse() for ( const item of globalPre ) { if ( !isInstantiableOf(item, Middleware) ) { throw new ErrorWithContext(`Invalid global pre-middleware definition. Global middleware must be static references to Middleware implementations.`, { configKey: 'server.middleware.global.pre', }) } route.preflight.prepend(request => request.make(item, request).apply()) } } if ( Array.isArray(globalMiddleware?.post) ) { const globalPost = [...globalMiddleware.post] for ( const item of globalPost ) { if ( !isInstantiableOf(item, Middleware) ) { throw new ErrorWithContext(`Invalid global post-middleware definition. Global middleware must be static references to Middleware implementations.`, { configKey: 'server.middleware.global.post', }) } route.postflight.push(request => request.make(item, request).apply()) } } } for ( const group of registeredGroups ) { this.compiledGroupStack.push(group) await group.group() const childCompilation = await this.compile() registeredRoutes = registeredRoutes.concat(childCompilation) this.compiledGroupStack.pop() } return registeredRoutes } /** * Create a new route on the given endpoint for the given HTTP verb. * @param method * @param endpoint */ public static endpoint(method: HTTPMethod | HTTPMethod[], endpoint: string): Route { return new Route(method, endpoint) } /** * Create a new WebSocket route on the given endpoint. * @param endpoint */ public static socket(endpoint: string): SocketRouteBuilder { const builder = SocketRouteBuilder.get() ;(new Route, [WebSocketBus]>('ws', endpoint)) .passingRequest() .handledBy(async (ws: WebSocketBus, request: Request) => { await builder.build(request, ws) }) return builder } /** * Create a new GET route on the given endpoint. */ public static get(endpoint: string): Route { return this.endpoint('get', endpoint) } /** Create a new POST route on the given endpoint. */ public static post(endpoint: string): Route { return this.endpoint('post', endpoint) } /** Create a new PUT route on the given endpoint. */ public static put(endpoint: string): Route { return this.endpoint('put', endpoint) } /** Create a new PATCH route on the given endpoint. */ public static patch(endpoint: string): Route { return this.endpoint('patch', endpoint) } /** Create a new DELETE route on the given endpoint. */ public static delete(endpoint: string): Route { return this.endpoint('delete', endpoint) } /** Create a new route on all HTTP verbs, on the given endpoint. */ public static any(endpoint: string): Route { return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], endpoint) } /** Create a new route group with the given prefix. */ public static group(prefix: string, group: () => void | Promise): RouteGroup { const grp = Application.getApplication().make(RouteGroup, group, prefix) this.registeredGroups.push(grp) return grp } protected preflight: Collection = new Collection() protected parameters: Collection> = new Collection>() protected postflight: Collection = new Collection() protected aliases: Collection = new Collection() handler?: Constructable<(...x: THandlerParams) => TReturn> protected displays: Collection<{stage: 'pre'|'post'|'handler', display: string}> = new Collection() constructor( protected method: 'ws' | HTTPMethod | HTTPMethod[], protected route: string, ) {} public isForWebSocket(): boolean { return this.method === 'ws' } /** * Set a programmatic name for this route. * @param name */ public alias(name: string): this { this.aliases.push(name) return this } /** * Get the string-form of the route. */ public getRoute(): string { return this.route } /** * Get the string-form methods supported by the route. */ public getMethods(): HTTPMethod[] { if ( this.method === 'ws' ) { return [] } if ( !Array.isArray(this.method) ) { return [this.method] } return this.method } /** * Get preflight middleware for this route. */ public getPreflight(): Collection { return this.preflight.clone() } /** * Get postflight middleware for this route. */ public getPostflight(): Collection { return this.postflight.clone() } public getParameters(): Collection> { return this.parameters.clone() } public getDisplays(): Collection<{ stage: 'pre'|'handler'|'post', display: string }> { return this.displays.clone() } public getHandlerDisplay(): Maybe { return this.displays.firstWhere('stage', '=', 'handler')?.display } /** * Returns true if this route matches the given HTTP verb and request path. * @param method * @param potential */ public match(method: 'ws' | HTTPMethod, potential: string): boolean { if ( method === 'ws' && !this.isForWebSocket() ) { return false } else if ( method !== 'ws' && this.isForWebSocket() ) { return false } else if ( method !== 'ws' && Array.isArray(this.method) && !this.method.includes(method) ) { return false } else if ( method !== 'ws' && !Array.isArray(this.method) && this.method !== method ) { return false } return Boolean(this.extract(potential)) } /** * Given a request path, try to extract this route's paramters from the path string. * * @example * For route `/foo/:bar/baz` and input `/foo/bob/baz`, extracts: * * ```typescript * { * bar: 'bob' * } * ``` * * @param potential */ public extract(potential: string): {[key: string]: string} | undefined { const routeParts = (this.route.startsWith('/') ? this.route.substr(1) : this.route).split('/').filter(Boolean) const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/').filter(Boolean) Application.getApplication().make(Logging) .trace(`Extracting route - (potential: ${potential}, rP: ${JSON.stringify(routeParts)}, pP: ${JSON.stringify(potentialParts)})`) const params: any = {} let wildcardIdx = 0 for ( let i = 0; i < routeParts.length; i += 1 ) { const part = routeParts[i] if ( part === '**' ) { params[wildcardIdx] = potentialParts.slice(i).join('/') return params } if ( (potentialParts.length - 1) < i ) { return } if ( part === '*' ) { params[wildcardIdx] = potentialParts[i] wildcardIdx += 1 } else if ( part.startsWith(':') ) { params[part.substr(1)] = potentialParts[i] } else if ( potentialParts[i] !== part ) { return } } // If we got here, we didn't find a ** // So, if the lengths are different, fail if ( routeParts.length !== potentialParts.length ) { return } return params } public parameterMiddleware( handler: ParameterProvidingMiddleware | Instantiable>, ...handlerArgs: THandlerArgs ): Route> { const route = new Route>( this.method, this.route, ) route.copyFrom(this) if ( handler.prototype instanceof ParameterMiddleware ) { route.parameters.push(req => req.make>(handler, req).handle(...handlerArgs)) } else { route.parameters.push(handler as ParameterProvidingMiddleware) } return route } private copyFrom(other: Route) { this.preflight = other.preflight.clone() this.postflight = other.postflight.clone() this.aliases = other.aliases.clone() this.displays = other.displays.clone() this.parameters = other.parameters.clone() } public calls( key: TypedDependencyKey, selector: (x: TKey) => (...params: THandlerParams) => TReturn, ): HandledRoute { this.handler = constructable(key) .tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((...params: THandlerParams) => TReturn)) this.displays.push({ stage: 'handler', display: `${key.name}(${selector})`, }) Route.registeredRoutes.push(this as unknown as Route) // man this is stupid return this as HandledRoute } public handledBy( handler: (...params: THandlerParams) => TReturn, ): HandledRoute { this.handler = Pipeline.id() .tap(() => handler) this.displays.push({ stage: 'handler', display: `(closure)`, }) Route.registeredRoutes.push(this as unknown as Route) return this as HandledRoute } public pre(middleware: Instantiable|Constructable): this { let name: string if ( middleware instanceof Pipeline ) { this.preflight.prepend(request => middleware.apply(request).apply()) name = '(unknown pipeline)' } else { this.preflight.prepend(request => request.make(middleware, request).apply()) name = middleware.name } this.displays.push({ stage: 'pre', display: name, }) return this } public post(middleware: Instantiable|Constructable): this { let name: string if ( middleware instanceof Pipeline ) { this.postflight.push(request => middleware.apply(request).apply()) name = '(unknown pipeline)' } else { this.preflight.push(request => request.make(middleware, request).apply()) name = middleware.name } this.displays.push({ stage: 'post', display: name, }) return this } public input(validator: ValidatorFactory): Route>> { if ( !(validator instanceof Validator) ) { validator = validator() } this.displays.push({ stage: 'pre', display: `input(${validator.constructor.name})`, }) return this.parameterMiddleware(validateMiddleware(validator)) } public passingRequest(): Route> { return this.parameterMiddleware(request => right(request)) } hasAlias(name: string): boolean { return this.aliases.includes(name) } getAlias(): Maybe { return this.aliases.first() } isHandled(): this is HandledRoute { return Boolean(this.handler) } /** Cast the route to an intelligible string. */ toString(): string { const method = Array.isArray(this.method) ? this.method : [this.method] return `${method.join('|')} -> ${this.route}` } /** * Return a new Pipe of this collection. */ pipeTo(pipeline: Pipeline): TOut { return pipeline.apply(this) } /** Build and apply a pipeline. */ pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { return builder(Pipeline.id()).apply(this) } /** Prefix the route's path with the given prefix, normalizing `/` characters. */ private prepend(prefix: string): this { if ( !prefix.endsWith('/') ) { prefix = `${prefix}/` } if ( this.route.startsWith('/') ) { this.route = this.route.substring(1) } this.route = `${prefix}${this.route}` return this } }