import {AppClass} from '../../lifecycle/AppClass' import {HTTPMethod, Request} from '../lifecycle/Request' import {Application} from '../../lifecycle/Application' import {RouteGroup} from './RouteGroup' import {ResponseFactory} from '../response/ResponseFactory' import {Response} from '../lifecycle/Response' import {Controllers} from '../../service/Controllers' import {ErrorWithContext, Collection} from '../../util' import {Container} from '../../di' import {Controller} from '../Controller' import {Middlewares} from '../../service/Middlewares' import {Middleware} from './Middleware' import {Config} from '../../service/Config' /** * 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 an item that defines a direct route handler. */ export type RouteHandler = ((request: Request, response: Response) => ResponseObject) | ((request: Request) => ResponseObject) | (() => ResponseObject) | string /** * 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 // TODO domains, named routes - support this on groups as well /** * A class that can be used to build and reference dynamic routes in the application. * * Routes can be defined in nested groups, with prefixes and middleware handlers. * * @example * ```typescript * Route.post('/api/v1/ping', (request: Request) => { * return 'pong!' * }) * * Route.group('/api/v2', () => { * Route.get('/status', 'controller::api:v2:Status.getStatus').pre('auth:UserOnly') * }) * ``` */ export class Route extends AppClass { /** 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 of 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.getGroupMiddlewareDefinitions() .where('stage', '=', 'pre') .each(def => { route.prependMiddleware(def) }) } for ( const group of this.compiledGroupStack ) { group.getGroupMiddlewareDefinitions() .where('stage', '=', 'post') .each(def => route.appendMiddleware(def)) } // Add the global pre- and post- middleware if ( Array.isArray(globalMiddleware?.pre) ) { const globalPre = [...globalMiddleware.pre].reverse() for ( const item of globalPre ) { if ( typeof item !== 'string' ) { throw new ErrorWithContext(`Invalid global pre-middleware definition. Global middleware must be string-references.`, { configKey: 'server.middleware.global.pre', }) } route.prependMiddleware({ stage: 'pre', handler: item, }) } } if ( Array.isArray(globalMiddleware?.post) ) { const globalPost = [...globalMiddleware.post] for ( const item of globalPost ) { if ( typeof item !== 'string' ) { throw new ErrorWithContext(`Invalid global post-middleware definition. Global middleware must be string-references.`, { configKey: 'server.middleware.global.post', }) } route.appendMiddleware({ stage: 'post', handler: item, }) } } route.resolvePreflight() // Try to resolve here to catch any errors at boot-time and pre-compile route.resolveHandler() route.resolvePostflight() } 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 definition * @param handler */ public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler): Route { const route = new Route(method, handler, definition) this.registeredRoutes.push(route) return route } /** * Create a new GET route on the given endpoint. * @param definition * @param handler */ public static get(definition: string, handler: RouteHandler): Route { return this.endpoint('get', definition, handler) } /** Create a new POST route on the given endpoint. */ public static post(definition: string, handler: RouteHandler): Route { return this.endpoint('post', definition, handler) } /** Create a new PUT route on the given endpoint. */ public static put(definition: string, handler: RouteHandler): Route { return this.endpoint('put', definition, handler) } /** Create a new PATCH route on the given endpoint. */ public static patch(definition: string, handler: RouteHandler): Route { return this.endpoint('patch', definition, handler) } /** Create a new DELETE route on the given endpoint. */ public static delete(definition: string, handler: RouteHandler): Route { return this.endpoint('delete', definition, handler) } /** Create a new route on all HTTP verbs, on the given endpoint. */ public static any(definition: string, handler: RouteHandler): Route { return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler) } /** 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 } /** Middlewares that should be applied to this route. */ protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: 'pre' | 'post'; handler: RouteHandler}>() /** Pre-compiled route handlers for the pre-middleware for this route. */ protected compiledPreflight?: ResolvedRouteHandler[] /** Pre-compiled route handlers for the post-middleware for this route. */ protected compiledHandler?: ResolvedRouteHandler /** Pre-compiled route handler for the main route handler for this route. */ protected compiledPostflight?: ResolvedRouteHandler[] /** Programmatic aliases of this route. */ public aliases: string[] = [] constructor( /** The HTTP method(s) that this route listens on. */ protected method: HTTPMethod | HTTPMethod[], /** The primary handler of this route. */ protected readonly handler: RouteHandler, /** The route path this route listens on. */ protected route: string, ) { super() } /** * 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 method of the route. */ public getMethod(): HTTPMethod | HTTPMethod[] { return this.method } /** * Get collection of applied middlewares. */ public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> { return this.middlewares.clone() } /** * Get the string-form of the route handler. */ public getDisplayableHandler(): string { return typeof this.handler === 'string' ? this.handler : '(anonymous function)' } /** * Returns true if this route matches the given HTTP verb and request path. * @param method * @param potential */ public match(method: HTTPMethod, potential: string): boolean { if ( Array.isArray(this.method) && !this.method.includes(method) ) { return false } else if ( !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('/') const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/') 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 } /** * Try to pre-compile and return the preflight handlers for this route. */ public resolvePreflight(): ResolvedRouteHandler[] { if ( !this.compiledPreflight ) { this.compiledPreflight = this.resolveMiddlewareHandlersForStage('pre') } return this.compiledPreflight } /** * Try to pre-compile and return the postflight handlers for this route. */ public resolvePostflight(): ResolvedRouteHandler[] { if ( !this.compiledPostflight ) { this.compiledPostflight = this.resolveMiddlewareHandlersForStage('post') } return this.compiledPostflight } /** * Try to pre-compile and return the main handler for this route. */ public resolveHandler(): ResolvedRouteHandler { if ( !this.compiledHandler ) { this.compiledHandler = this.compileResolvedHandler() } return this.compiledHandler } /** Register the given middleware as a preflight handler for this route. */ pre(middleware: RouteHandler): this { this.middlewares.push({ stage: 'pre', handler: middleware, }) return this } /** Register the given middleware as a postflight handler for this route. */ post(middleware: RouteHandler): this { this.middlewares.push({ stage: 'post', handler: middleware, }) return 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 } /** Add the given middleware item to the beginning of the preflight handlers. */ private prependMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }): void { this.middlewares.prepend(def) } /** Add the given middleware item to the end of the postflight handlers. */ private appendMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }): void { this.middlewares.push(def) } /** * Resolve and return the route handler for this route. * @private */ private compileResolvedHandler(): ResolvedRouteHandler { const handler = this.handler if ( typeof handler !== 'string' ) { return (request: Request) => { return handler(request, request.response) } } else { const parts = handler.split('.') if ( parts.length < 2 ) { const e = new ErrorWithContext('Route handler does not specify a method name.') e.context = { handler, } throw e } const [controllerName, methodName] = parts const controllersService = this.make(Controllers) const controllerClass = controllersService.get(controllerName) if ( !controllerClass ) { const e = new ErrorWithContext('Controller not found for route handler.') e.context = { handler, controllerName, methodName, } throw e } return (request: Request) => { // If not a function, then we got a string reference to a controller method // So, we need to use the request container to instantiate the controller // and bind the method const controller = request.make(controllerClass, request) const method = controller.getBoundMethod(methodName) return method() } } } /** * Resolve and return the route handlers for the given pre- or post-flight stage. * @param stage * @private */ private resolveMiddlewareHandlersForStage(stage: 'pre' | 'post'): ResolvedRouteHandler[] { return this.middlewares.where('stage', '=', stage) .map(def => { const handler = def.handler if ( typeof handler !== 'string' ) { return (request: Request) => { return handler(request, request.response) } } else { const parts = handler.split('.') if ( parts.length < 2 ) { parts.push('apply') // default middleware method name, if none provided } const [middlewareName, methodName] = parts const middlewaresService = this.make(Middlewares) const middlewareClass = middlewaresService.get(middlewareName) if ( !middlewareClass ) { const e = new ErrorWithContext('Middleware not found for route handler.') e.context = { handler, middlewareName, methodName, } throw e } return (request: Request) => { // If not a function, then we got a string reference to a middleware method // So, we need to use the request container to instantiate the middleware // and bind the method const middleware = request.make(middlewareClass, request) const method = middleware.getBoundMethod(methodName) return method() } } }) .toArray() } /** Cast the route to an intelligible string. */ toString(): string { const method = Array.isArray(this.method) ? this.method : [this.method] return `${method.join('|')} -> ${this.route}` } }