diff --git a/src/http/kernel/HTTPKernel.ts b/src/http/kernel/HTTPKernel.ts index 44b0c14..32408dd 100644 --- a/src/http/kernel/HTTPKernel.ts +++ b/src/http/kernel/HTTPKernel.ts @@ -58,18 +58,30 @@ export class HTTPKernel extends AppClass { public async handle(request: Request): Promise { try { for (const module of this.preflight.toArray()) { - this.logging.verbose(`Applying pre-flight HTTP kernel module: ${module.constructor.name}`) - request = await module.apply(request) + if ( !request.response.blockingWriteback() || module.executeWithBlockingWriteback ) { + this.logging.verbose(`Applying pre-flight HTTP kernel module: ${module.constructor.name}`) + request = await module.apply(request) + } else { + this.logging.verbose(`Skipping pre-flight HTTP kernel module because of blocking write-back: ${module.constructor.name}`) + } } if (this.inflight) { - this.logging.verbose(`Applying core HTTP kernel module: ${this.inflight.constructor.name}`) - request = await this.inflight.apply(request) + if ( !request.response.blockingWriteback() || this.inflight.executeWithBlockingWriteback ) { + this.logging.verbose(`Applying core HTTP kernel module: ${this.inflight.constructor.name}`) + request = await this.inflight.apply(request) + } else { + this.logging.verbose(`Skipping core HTTP kernel module because of blocking write-back: ${this.inflight.constructor.name}`) + } } for (const module of this.postflight.toArray()) { - this.logging.verbose(`Applying post-flight HTTP kernel module: ${module.constructor.name}`) - request = await module.apply(request) + if ( !request.response.blockingWriteback() || module.executeWithBlockingWriteback ) { + this.logging.verbose(`Applying post-flight HTTP kernel module: ${module.constructor.name}`) + request = await module.apply(request) + } else { + this.logging.verbose(`Skipping post-flight HTTP kernel module because of blocking write-back: ${module.constructor.name}`) + } } } catch (e: any) { this.logging.error(e) diff --git a/src/http/kernel/HTTPKernelModule.ts b/src/http/kernel/HTTPKernelModule.ts index 9a11e29..cf42b0e 100644 --- a/src/http/kernel/HTTPKernelModule.ts +++ b/src/http/kernel/HTTPKernelModule.ts @@ -5,6 +5,8 @@ import {Request} from "../lifecycle/Request"; @Injectable() export class HTTPKernelModule extends AppClass { + public readonly executeWithBlockingWriteback: boolean = false + /** * Returns true if the given module should be applied to the incoming request. * @param {Request} request diff --git a/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts new file mode 100644 index 0000000..ac0e48b --- /dev/null +++ b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts @@ -0,0 +1,22 @@ +import {HTTPKernelModule} from "../HTTPKernelModule"; +import {ResponseObject} from "../../routing/Route"; +import {Request} from "../../lifecycle/Request"; +import {plaintext} from "../../response/StringResponseFactory"; +import {ResponseFactory} from "../../response/ResponseFactory"; +import {json} from "../../response/JSONResponseFactory"; + +export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelModule { + protected async applyResponseObject(object: ResponseObject, request: Request) { + if ( (typeof object === 'string') || (typeof object === 'number') ) { + object = plaintext(String(object)) + } + + if ( object instanceof ResponseFactory ) { + await object.write(request) + } else if ( typeof object !== 'undefined' ) { + await json(object).write(request) + } else { + await plaintext('').write(request) + } + } +} diff --git a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts index 866f6d7..f0a341d 100644 --- a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts @@ -1,15 +1,12 @@ -import {HTTPKernelModule} from "../HTTPKernelModule"; import {HTTPKernel} from "../HTTPKernel"; import {Request} from "../../lifecycle/Request"; import {ActivatedRoute} from "../../routing/ActivatedRoute"; import {ResponseObject} from "../../routing/Route"; -import {plaintext} from "../../response/StringResponseFactory"; -import {ResponseFactory} from "../../response/ResponseFactory"; -import {json} from "../../response/JSONResponseFactory"; import {http} from "../../response/HTTPErrorResponseFactory"; import {HTTPStatus} from "@extollo/util"; +import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule"; -export class ExecuteResolvedRouteHandlerHTTPModule extends HTTPKernelModule { +export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRouteHandlerHTTPModule { public static register(kernel: HTTPKernel) { kernel.register(this).core() } @@ -19,19 +16,10 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends HTTPKernelModule { const route = request.make(ActivatedRoute) let object: ResponseObject = await route.handler(request) - if ( (typeof object === 'string') || (typeof object === 'number') ) { - object = plaintext(String(object)) - } - - if ( object instanceof ResponseFactory ) { - await object.write(request) - } else if ( typeof object !== 'undefined' ) { - await json(object).write(request) - } else { - await plaintext('').write(request) - } + await this.applyResponseObject(object, request) } else { await http(HTTPStatus.NOT_FOUND).write(request) + request.response.blockingWriteback(true) } return request diff --git a/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts new file mode 100644 index 0000000..403e23f --- /dev/null +++ b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts @@ -0,0 +1,29 @@ +import {HTTPKernel} from "../HTTPKernel"; +import {Request} from "../../lifecycle/Request"; +import {ActivatedRoute} from "../../routing/ActivatedRoute"; +import {ResponseObject} from "../../routing/Route"; +import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule"; +import {PersistSessionHTTPModule} from "./PersistSessionHTTPModule"; + +export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule { + public static register(kernel: HTTPKernel) { + kernel.register(this).before(PersistSessionHTTPModule) + } + + public async apply(request: Request) { + if ( request.hasInstance(ActivatedRoute) ) { + const route = request.make(ActivatedRoute) + const postflight = route.postflight + + for ( const handler of postflight ) { + const result: ResponseObject = await handler(request) + if ( typeof result !== "undefined" ) { + await this.applyResponseObject(result, request) + request.response.blockingWriteback(true) + } + } + } + + return request + } +} diff --git a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts new file mode 100644 index 0000000..b68e407 --- /dev/null +++ b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts @@ -0,0 +1,29 @@ +import {HTTPKernel} from "../HTTPKernel"; +import {MountActivatedRouteHTTPModule} from "./MountActivatedRouteHTTPModule"; +import {Request} from "../../lifecycle/Request"; +import {ActivatedRoute} from "../../routing/ActivatedRoute"; +import {ResponseObject} from "../../routing/Route"; +import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule"; + +export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule { + public static register(kernel: HTTPKernel) { + kernel.register(this).after(MountActivatedRouteHTTPModule) + } + + public async apply(request: Request) { + if ( request.hasInstance(ActivatedRoute) ) { + const route = request.make(ActivatedRoute) + const preflight = route.preflight + + for ( const handler of preflight ) { + const result: ResponseObject = await handler(request) + if ( typeof result !== "undefined" ) { + await this.applyResponseObject(result, request) + request.response.blockingWriteback(true) + } + } + } + + return request + } +} diff --git a/src/http/kernel/module/InjectSessionHTTPModule.ts b/src/http/kernel/module/InjectSessionHTTPModule.ts index bd92ab9..413aec6 100644 --- a/src/http/kernel/module/InjectSessionHTTPModule.ts +++ b/src/http/kernel/module/InjectSessionHTTPModule.ts @@ -9,6 +9,8 @@ import {Session} from "../../session/Session"; @Injectable() export class InjectSessionHTTPModule extends HTTPKernelModule { + public readonly executeWithBlockingWriteback = true + public static register(kernel: HTTPKernel) { kernel.register(this).after(SetSessionCookieHTTPModule) } diff --git a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts index 53c6b9f..486b9e0 100644 --- a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts +++ b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts @@ -8,6 +8,8 @@ import {Logging} from "../../../service/Logging"; @Injectable() export class MountActivatedRouteHTTPModule extends HTTPKernelModule { + public readonly executeWithBlockingWriteback = true + @Inject() protected readonly routing!: Routing diff --git a/src/http/kernel/module/PersistSessionHTTPModule.ts b/src/http/kernel/module/PersistSessionHTTPModule.ts index 6a8c711..f97ba38 100644 --- a/src/http/kernel/module/PersistSessionHTTPModule.ts +++ b/src/http/kernel/module/PersistSessionHTTPModule.ts @@ -6,6 +6,8 @@ import {Session} from "../../session/Session"; @Injectable() export class PersistSessionHTTPModule extends HTTPKernelModule { + public readonly executeWithBlockingWriteback = true + public static register(kernel: HTTPKernel) { kernel.register(this).last() } diff --git a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts index 63944f7..2518476 100644 --- a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts +++ b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts @@ -6,6 +6,8 @@ import {Config} from "../../../service/Config"; @Injectable() export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule { + public readonly executeWithBlockingWriteback = true + @Inject() protected readonly config!: Config; diff --git a/src/http/kernel/module/SetSessionCookieHTTPModule.ts b/src/http/kernel/module/SetSessionCookieHTTPModule.ts index b00ef59..7aedb54 100644 --- a/src/http/kernel/module/SetSessionCookieHTTPModule.ts +++ b/src/http/kernel/module/SetSessionCookieHTTPModule.ts @@ -7,6 +7,8 @@ import {Logging} from "../../../service/Logging"; @Injectable() export class SetSessionCookieHTTPModule extends HTTPKernelModule { + public readonly executeWithBlockingWriteback = true + @Inject() protected readonly logging!: Logging diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index e575506..b4685b3 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -20,6 +20,7 @@ export class Response { private _sentHeaders: boolean = false private _responseEnded: boolean = false private _status: HTTPStatus = HTTPStatus.OK + private _blockingWriteback: boolean = false public body: string = '' public readonly sending$: BehaviorSubject = new BehaviorSubject() public readonly sent$: BehaviorSubject = new BehaviorSubject() @@ -88,6 +89,18 @@ export class Response { return this._sentHeaders } + public hasBody() { + return !!this.body + } + + public blockingWriteback(set?: boolean) { + if ( typeof set !== 'undefined' ) { + this._blockingWriteback = set + } + + return this._blockingWriteback + } + public async write(data: any) { return new Promise((res, rej) => { if ( !this._sentHeaders ) this.sendHeaders() diff --git a/src/http/routing/ActivatedRoute.ts b/src/http/routing/ActivatedRoute.ts index a340f14..9fb9e12 100644 --- a/src/http/routing/ActivatedRoute.ts +++ b/src/http/routing/ActivatedRoute.ts @@ -4,6 +4,8 @@ import {ResolvedRouteHandler, Route} from "./Route"; export class ActivatedRoute { public readonly params: {[key: string]: string} public readonly handler: ResolvedRouteHandler + public readonly preflight: ResolvedRouteHandler[] + public readonly postflight: ResolvedRouteHandler[] constructor( public readonly route: Route, @@ -20,6 +22,8 @@ export class ActivatedRoute { } this.params = params + this.preflight = route.resolvePreflight() this.handler = route.resolveHandler() + this.postflight = route.resolvePostflight() } } diff --git a/src/http/routing/Middleware.ts b/src/http/routing/Middleware.ts new file mode 100644 index 0000000..b42f7cf --- /dev/null +++ b/src/http/routing/Middleware.ts @@ -0,0 +1,16 @@ +import {AppClass} from "../../lifecycle/AppClass" +import {Request} from "../lifecycle/Request" +import {ResponseObject} from "./Route" + +export abstract class Middleware extends AppClass { + constructor( + protected readonly request: Request + ) { super() } + + protected container() { + return this.request + } + + // Return void | Promise to continue request + public abstract apply(): ResponseObject +} diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index f120e06..ebf9fa8 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -5,15 +5,17 @@ import {RouteGroup} from "./RouteGroup"; import {ResponseFactory} from "../response/ResponseFactory"; import {Response} from "../lifecycle/Response"; import {Controllers} from "../../service/Controllers"; -import {ErrorWithContext} from "@extollo/util"; +import {ErrorWithContext, Collection} from "@extollo/util"; import {Controller} from "../Controller"; +import {Middlewares} from "../../service/Middlewares"; +import {Middleware} from "./Middleware"; export type ResponseObject = ResponseFactory | string | number | void | any | Promise export type RouteHandler = ((request: Request, response: Response) => ResponseObject) | ((request: Request) => ResponseObject) | (() => ResponseObject) | string export type ResolvedRouteHandler = (request: Request) => ResponseObject -// TODO middleware, domains, named routes - support this on groups as well +// TODO domains, named routes - support this on groups as well export class Route extends AppClass { private static registeredRoutes: Route[] = [] @@ -36,9 +38,18 @@ export class Route extends AppClass { for ( const route of registeredRoutes ) { for ( const group of stack ) { route.prepend(group.prefix) + group.getGroupMiddlewareDefinitions() + .each(def => route.prependMiddleware(def)) } - route.resolveHandler() // Try to resolve here to catch any errors at boot-time + for ( const group of this.compiledGroupStack ) { + group.getGroupMiddlewareDefinitions() + .each(def => route.appendMiddleware(def)) + } + + 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 ) { @@ -90,6 +101,11 @@ export class Route extends AppClass { 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, @@ -138,7 +154,63 @@ export class Route extends AppClass { 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 @@ -146,7 +218,7 @@ export class Route extends AppClass { } } else { const parts = this.handler.split('.') - if ( parts.length < 1 ) { + if ( parts.length < 2 ) { const e = new ErrorWithContext('Route handler does not specify a method name.') e.context = { handler: this.handler @@ -179,10 +251,45 @@ export class Route extends AppClass { } } - 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 resolveMiddlewareHandlersForStage(stage: 'pre' | 'post'): ResolvedRouteHandler[] { + return this.middlewares.where('stage', '=', stage) + .map(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 = 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 = request.make(middlewareClass, request) + const method = middleware.getBoundMethod(methodName) + return method() + } + } + }) + .toArray() } toString() { diff --git a/src/http/routing/RouteGroup.ts b/src/http/routing/RouteGroup.ts index f5aa4be..62c2836 100644 --- a/src/http/routing/RouteGroup.ts +++ b/src/http/routing/RouteGroup.ts @@ -1,8 +1,12 @@ -import {AppClass} from "../../lifecycle/AppClass"; +import {Collection} from "@extollo/util" +import {AppClass} from "../../lifecycle/AppClass" +import {RouteHandler} from "./Route" export class RouteGroup extends AppClass { private static currentGroupNesting: RouteGroup[] = [] + protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: "pre" | "post"; handler: RouteHandler}>() + public static getCurrentGroupHierarchy(): RouteGroup[] { return [...this.currentGroupNesting] } @@ -11,4 +15,26 @@ export class RouteGroup extends AppClass { public readonly group: () => void | Promise, public readonly prefix: string ) { super() } + + pre(middleware: RouteHandler) { + this.middlewares.push({ + stage: 'pre', + handler: middleware + }) + + return this + } + + post(middleware: RouteHandler) { + this.middlewares.push({ + stage: 'post', + handler: middleware, + }) + + return this + } + + getGroupMiddlewareDefinitions() { + return this.middlewares + } } diff --git a/src/index.ts b/src/index.ts index 6e280b5..d9b2d11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,10 @@ export * from './http/kernel/module/MountActivatedRouteHTTPModule' export * from './http/kernel/module/PersistSessionHTTPModule' export * from './http/kernel/module/PoweredByHeaderInjectionHTTPModule' export * from './http/kernel/module/SetSessionCookieHTTPModule' +export * from './http/kernel/module/AbstractResolvedRouteHandlerHTTPModule' +export * from './http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule' +export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule' +export * from './http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule' export * from './http/kernel/HTTPKernel' export * from './http/kernel/HTTPKernelModule' @@ -34,6 +38,7 @@ export * from './http/response/ViewResponseFactory' export * from './http/routing/ActivatedRoute' export * from './http/routing/Route' export * from './http/routing/RouteGroup' +export * from './http/routing/Middleware' export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule' @@ -52,6 +57,7 @@ export * from './service/Config' export * from './service/Controllers' export * from './service/HTTPServer' export * from './service/Routing' +export * from './service/Middlewares' export * from './views/ViewEngine' export * from './views/ViewEngineFactory' diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index 0a3b371..d950c9b 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -12,6 +12,8 @@ import {PersistSessionHTTPModule} from "../http/kernel/module/PersistSessionHTTP import {MountActivatedRouteHTTPModule} from "../http/kernel/module/MountActivatedRouteHTTPModule"; import {ExecuteResolvedRouteHandlerHTTPModule} from "../http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule"; import {error} from "../http/response/ErrorResponseFactory"; +import {ExecuteResolvedRoutePreflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule"; +import {ExecuteResolvedRoutePostflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule"; @Singleton() export class HTTPServer extends Unit { @@ -33,6 +35,8 @@ export class HTTPServer extends Unit { PersistSessionHTTPModule.register(this.kernel) MountActivatedRouteHTTPModule.register(this.kernel) ExecuteResolvedRouteHandlerHTTPModule.register(this.kernel) + ExecuteResolvedRoutePreflightHTTPModule.register(this.kernel) + ExecuteResolvedRoutePostflightHTTPModule.register(this.kernel) await new Promise((res, rej) => { this.server = createServer(this.handler) diff --git a/src/service/Middlewares.ts b/src/service/Middlewares.ts new file mode 100644 index 0000000..f103efb --- /dev/null +++ b/src/service/Middlewares.ts @@ -0,0 +1,20 @@ +import {CanonicalStatic} from "./CanonicalStatic"; +import {Singleton, Instantiable} from "@extollo/di"; +import {CanonicalDefinition} from "./Canonical"; +import {Middleware} from "../http/routing/Middleware"; + +@Singleton() +export class Middlewares extends CanonicalStatic, Middleware> { + protected appPath = ['http', 'middlewares'] + protected canonicalItem = 'middleware' + protected suffix = '.middleware.js' + + public async initCanonicalItem(definition: CanonicalDefinition) { + const item = await super.initCanonicalItem(definition) + if ( !(item.prototype instanceof Middleware) ) { + throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.http.routing.Middleware.`) + } + + return item + } +}