You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/http/routing/Route.ts

338 lines
12 KiB

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 "@extollo/util";
import {Container} from "@extollo/di";
import {Controller} from "../Controller";
import {Middlewares} from "../../service/Middlewares";
import {Middleware} from "./Middleware";
import {Config} from "../../service/Config";
export type ResponseObject = ResponseFactory | string | number | void | any | Promise<ResponseObject>
export type RouteHandler = ((request: Request, response: Response) => ResponseObject) | ((request: Request) => ResponseObject) | (() => ResponseObject) | string
export type ResolvedRouteHandler = (request: Request) => ResponseObject
// TODO domains, named routes - support this on groups as well
export class Route extends AppClass {
private static registeredRoutes: Route[] = []
private static registeredGroups: RouteGroup[] = []
private static compiledGroupStack: RouteGroup[] = []
public static registerGroup(group: RouteGroup) {
this.registeredGroups.push(group)
}
public static async compile(): Promise<Route[]> {
let registeredRoutes = this.registeredRoutes
const registeredGroups = this.registeredGroups
this.registeredRoutes = []
this.registeredGroups = []
const configService = <Config> 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()
.each(def => route.prependMiddleware(def))
}
for ( const group of this.compiledGroupStack ) {
group.getGroupMiddlewareDefinitions()
.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
}
public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler) {
const route = new Route(method, handler, definition)
this.registeredRoutes.push(route)
return route
}
public static get(definition: string, handler: RouteHandler) {
return this.endpoint('get', definition, handler)
}
public static post(definition: string, handler: RouteHandler) {
return this.endpoint('post', definition, handler)
}
public static put(definition: string, handler: RouteHandler) {
return this.endpoint('put', definition, handler)
}
public static patch(definition: string, handler: RouteHandler) {
return this.endpoint('patch', definition, handler)
}
public static delete(definition: string, handler: RouteHandler) {
return this.endpoint('delete', definition, handler)
}
public static any(definition: string, handler: RouteHandler) {
return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler)
}
public static group(prefix: string, group: () => void | Promise<void>) {
const grp = <RouteGroup> Application.getApplication().make(RouteGroup, group, prefix)
this.registeredGroups.push(grp)
return grp
}
protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: "pre" | "post"; handler: RouteHandler}>()
protected _compiledPreflight?: ResolvedRouteHandler[]
protected _compiledHandler?: ResolvedRouteHandler
protected _compiledPostflight?: ResolvedRouteHandler[]
constructor(
protected method: HTTPMethod | HTTPMethod[],
protected readonly handler: RouteHandler,
protected route: string
) { super() }
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 !!this.extract(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
}
public resolvePreflight(): ResolvedRouteHandler[] {
if ( !this._compiledPreflight ) {
this._compiledPreflight = this.resolveMiddlewareHandlersForStage('pre')
}
return this._compiledPreflight
}
public resolvePostflight(): ResolvedRouteHandler[] {
if ( !this._compiledPostflight ) {
this._compiledPostflight = this.resolveMiddlewareHandlersForStage('post')
}
return this._compiledPostflight
}
public resolveHandler(): ResolvedRouteHandler {
if ( !this._compiledHandler ) {
this._compiledHandler = this._resolveHandler()
}
return this._compiledHandler
}
pre(middleware: RouteHandler) {
this.middlewares.push({
stage: 'pre',
handler: middleware
})
return this
}
post(middleware: RouteHandler) {
this.middlewares.push({
stage: 'post',
handler: middleware,
})
return this
}
private prepend(prefix: string) {
if ( !prefix.endsWith('/') ) prefix = `${prefix}/`
if ( this.route.startsWith('/') ) this.route = this.route.substring(1)
this.route = `${prefix}${this.route}`
}
private prependMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }) {
this.middlewares.prepend(def)
}
private appendMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }) {
this.middlewares.push(def)
}
private _resolveHandler(): ResolvedRouteHandler {
if ( typeof this.handler !== 'string' ) {
return (request: Request) => {
// @ts-ignore
return this.handler(request, request.response)
}
} else {
const parts = this.handler.split('.')
if ( parts.length < 2 ) {
const e = new ErrorWithContext('Route handler does not specify a method name.')
e.context = {
handler: this.handler
}
throw e
}
const [controllerName, methodName] = parts
const controllersService = <Controllers> this.make(Controllers)
const controllerClass = controllersService.get(controllerName)
if ( !controllerClass ) {
const e = new ErrorWithContext('Controller not found for route handler.')
e.context = {
handler: this.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 = <Controller> request.make(controllerClass, request)
const method = controller.getBoundMethod(methodName)
return method()
}
}
}
private resolveMiddlewareHandlersForStage(stage: 'pre' | 'post'): ResolvedRouteHandler[] {
return this.middlewares.where('stage', '=', stage)
.map<ResolvedRouteHandler>(def => {
if ( typeof def.handler !== 'string' ) {
return (request: Request) => {
// @ts-ignore
return def.handler(request, request.response)
}
} else {
const parts = def.handler.split('.')
if ( parts.length < 2 ) {
parts.push('apply') // default middleware method name, if none provided
}
const [middlewareName, methodName] = parts
const middlewaresService = <Middlewares> this.make(Middlewares)
const middlewareClass = middlewaresService.get(middlewareName)
if ( !middlewareClass ) {
const e = new ErrorWithContext('Middleware not found for route handler.')
e.context = {
handler: def.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 = <Middleware> request.make(middlewareClass, request)
const method = middleware.getBoundMethod(methodName)
return method()
}
}
})
.toArray()
}
toString() {
const method = Array.isArray(this.method) ? this.method : [this.method]
return `${method.join('|')} -> ${this.route}`
}
}