diff --git a/src/cli/directive/RouteDirective.ts b/src/cli/directive/RouteDirective.ts index d149101..5cc281d 100644 --- a/src/cli/directive/RouteDirective.ts +++ b/src/cli/directive/RouteDirective.ts @@ -2,7 +2,6 @@ import {Directive, OptionDefinition} from '../Directive' import {Inject, Injectable} from '../../di' import {Routing} from '../../service/Routing' import Table = require('cli-table') -import {RouteHandler} from '../../http/routing/Route' @Injectable() export class RouteDirective extends Directive { @@ -33,7 +32,7 @@ export class RouteDirective extends Directive { .toLowerCase() .trim() - this.routing.getCompiled() + /* this.routing.getCompiled() .filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method)) .tap(matches => { if ( !matches.length ) { @@ -42,8 +41,7 @@ export class RouteDirective extends Directive { } }) .each(match => { - const pre = match.getMiddlewares() - .where('stage', '=', 'pre') + const pre = match.getPreflight() .map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)]) const post = match.getMiddlewares() @@ -62,10 +60,6 @@ export class RouteDirective extends Directive { table.push(...post.toArray()) this.info(`\nRoute: ${match}\n\n${table}`) - }) - } - - protected handlerToString(handler: RouteHandler): string { - return typeof handler === 'string' ? handler : '(anonymous function)' + })*/ } } diff --git a/src/cli/directive/RoutesDirective.ts b/src/cli/directive/RoutesDirective.ts index 2e0f611..55309bb 100644 --- a/src/cli/directive/RoutesDirective.ts +++ b/src/cli/directive/RoutesDirective.ts @@ -17,7 +17,7 @@ export class RoutesDirective extends Directive { } async handle(): Promise { - const maxRouteLength = this.routing.getCompiled().max(route => String(route).length) + /* const maxRouteLength = this.routing.getCompiled().max(route => String(route).length) const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length) const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()]) @@ -28,6 +28,6 @@ export class RoutesDirective extends Directive { table.push(...rows.toArray()) - this.info('\n' + table) + this.info('\n' + table)*/ } } diff --git a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts index 82e2b51..b52d85a 100644 --- a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts @@ -1,7 +1,6 @@ import {HTTPKernel} from '../HTTPKernel' import {Request} from '../../lifecycle/Request' import {ActivatedRoute} from '../../routing/ActivatedRoute' -import {ResponseObject} from '../../routing/Route' import {http} from '../../response/HTTPErrorResponseFactory' import {HTTPStatus} from '../../../util' import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule' @@ -18,10 +17,17 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRoute public async apply(request: Request): Promise { if ( request.hasInstance(ActivatedRoute) ) { - const route = request.make(ActivatedRoute) - const object: ResponseObject = await route.handler(request) + const route = > request.make(ActivatedRoute) + const params = route.resolvedParameters + if ( !params ) { + throw new Error('Attempted to call route handler without resolved parameters.') + } - await this.applyResponseObject(object, request) + const result = await route.handler + .tap(handler => handler(...params)) + .apply(request) + + await this.applyResponseObject(result, request) } else { await http(HTTPStatus.NOT_FOUND).write(request) request.response.blockingWriteback(true) diff --git a/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts index 8fca237..3598c29 100644 --- a/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts @@ -17,7 +17,7 @@ export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRo public async apply(request: Request): Promise { if ( request.hasInstance(ActivatedRoute) ) { - const route = request.make(ActivatedRoute) + const route = > request.make(ActivatedRoute) const postflight = route.postflight for ( const handler of postflight ) { diff --git a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts index 3426bfe..8ccafa9 100644 --- a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts @@ -4,6 +4,7 @@ import {Request} from '../../lifecycle/Request' import {ActivatedRoute} from '../../routing/ActivatedRoute' import {ResponseObject} from '../../routing/Route' import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule' +import {collect, isLeft, unleft, unright} from '../../../util' /** * HTTP Kernel module that executes the preflight handlers for the route. @@ -17,7 +18,7 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou public async apply(request: Request): Promise { if ( request.hasInstance(ActivatedRoute) ) { - const route = request.make(ActivatedRoute) + const route = > request.make(ActivatedRoute) const preflight = route.preflight for ( const handler of preflight ) { @@ -27,6 +28,16 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou request.response.blockingWriteback(true) } } + + const parameters = route.parameters + const resolveResult = await collect(parameters) + .asyncMapRight(handler => handler(request)) + + if ( isLeft(resolveResult) ) { + return unleft(resolveResult) + } + + route.resolvedParameters = unright(resolveResult).toArray() } return request diff --git a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts index ab833a6..5306cd5 100644 --- a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts +++ b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts @@ -28,8 +28,8 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule { const route = this.routing.match(request.method, request.path) if ( route ) { this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`) - const activated = request.make(ActivatedRoute, route, request.path) - request.registerSingletonInstance(ActivatedRoute, activated) + const activated = > request.make(ActivatedRoute, route, request.path) + request.registerSingletonInstance>(ActivatedRoute, activated) } else { this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`) } diff --git a/src/http/routing/ActivatedRoute.ts b/src/http/routing/ActivatedRoute.ts index d9e9ddf..2ede54e 100644 --- a/src/http/routing/ActivatedRoute.ts +++ b/src/http/routing/ActivatedRoute.ts @@ -1,12 +1,16 @@ import {ErrorWithContext} from '../../util' -import {ResolvedRouteHandler, Route} from './Route' -import {Injectable} from '../../di' +import {ParameterProvidingMiddleware, ResolvedRouteHandler, Route} from './Route' +import {Constructable, Injectable} from '../../di' + +export type HandlerParamProviders = { + [Index in keyof THandlerParams]: ParameterProvidingMiddleware +} & {length: THandlerParams['length']} /** * Class representing a resolved route that a request is mounted to. */ @Injectable() -export class ActivatedRoute { +export class ActivatedRoute { /** * The parsed params from the route definition. * @@ -27,7 +31,7 @@ export class ActivatedRoute { /** * The resolved function that should handle the request for this route. */ - public readonly handler: ResolvedRouteHandler + public readonly handler: Constructable<(...x: THandlerParams) => TReturn> /** * Pre-middleware that should be applied to the request on this route. @@ -39,9 +43,13 @@ export class ActivatedRoute { */ public readonly postflight: ResolvedRouteHandler[] + public readonly parameters: HandlerParamProviders + + public resolvedParameters?: THandlerParams + constructor( /** The route this ActivatedRoute refers to. */ - public readonly route: Route, + public readonly route: Route, /** The request path that activated that route. */ public readonly path: string, @@ -56,9 +64,17 @@ export class ActivatedRoute { throw error } + if ( !route.handler ) { + throw new ErrorWithContext('Cannot instantiate ActivatedRoute. Matched route is not handled.', { + matchedRoute: String(route), + requestPath: path, + }) + } + this.params = params - this.preflight = route.resolvePreflight() - this.handler = route.resolveHandler() - this.postflight = route.resolvePostflight() + this.preflight = route.getPreflight().toArray() + this.handler = route.handler + this.postflight = route.getPostflight().toArray() + this.parameters = route.getParameters().toArray() as HandlerParamProviders } } diff --git a/src/http/routing/Middleware.ts b/src/http/routing/Middleware.ts index 5376822..b0ed7e3 100644 --- a/src/http/routing/Middleware.ts +++ b/src/http/routing/Middleware.ts @@ -12,6 +12,9 @@ export abstract class Middleware extends CanonicalItemClass { protected readonly request: Request, ) { super() + if ( !request ) { + throw new Error('Middleware constructed without request') + } } protected container(): Container { diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 07a3fd5..03a52fc 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -1,56 +1,40 @@ -import {AppClass} from '../../lifecycle/AppClass' -import {HTTPMethod, Request} from '../lifecycle/Request' -import {Application} from '../../lifecycle/Application' -import {RouteGroup} from './RouteGroup' +import {Collection, Either, ErrorWithContext, Pipeline, PrefixTypeArray, right} from '../../util' 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 {HTTPMethod, Request} from '../lifecycle/Request' +import {constructable, Constructable, Container, Instantiable, isInstantiableOf, TypedDependencyKey} from '../../di' import {Middleware} from './Middleware' +import {Valid, Validator, ValidatorFactory} from '../../validation/Validator' +import {validateMiddleware} from '../../validation/middleware' +import {RouteGroup} from './RouteGroup' import {Config} from '../../service/Config' -import {Validator} from '../../validation/Validator' +import {Application} from '../../lifecycle/Application' /** * 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 +export type ParameterProvidingMiddleware = (request: Request) => Either -// TODO domains, named routes - support this on groups as well +export interface HandledRoute { + handler: Constructable<(...x: THandlerParams) => TReturn> -/** - * 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 { + /** + * 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[] = [] + private static registeredRoutes: Route[] = [] /** Groups of routes that have been registered with the application. */ private static registeredGroups: RouteGroup[] = [] @@ -73,7 +57,7 @@ export class Route extends AppClass { * 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 { + public static async compile(): Promise[]> { let registeredRoutes = this.registeredRoutes const registeredGroups = this.registeredGroups @@ -87,55 +71,41 @@ export class Route extends AppClass { for ( const route of registeredRoutes ) { for ( const group of stack ) { route.prepend(group.prefix) - group.getGroupMiddlewareDefinitions() - .where('stage', '=', 'pre') - .each(def => { - route.prependMiddleware(def) - }) + group.getPreflight() + .each(def => route.preflight.prepend(def)) } for ( const group of this.compiledGroupStack ) { - group.getGroupMiddlewareDefinitions() - .where('stage', '=', 'post') - .each(def => route.appendMiddleware(def)) + group.getPostflight() + .each(def => route.postflight.push(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.`, { + 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.prependMiddleware({ - stage: 'pre', - handler: item, - }) + route.preflight.prepend(request => request.make(item, request).apply()) } } 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.`, { + 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.appendMiddleware({ - stage: 'post', - handler: item, - }) + route.postflight.push(request => request.make(item, request).apply()) } } - - 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 ) { @@ -151,50 +121,46 @@ export class Route extends AppClass { return registeredRoutes } + /** * Create a new route on the given endpoint for the given HTTP verb. * @param method - * @param definition - * @param handler + * @param endpoint */ - public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler): Route { - const route = new Route(method, handler, definition) - this.registeredRoutes.push(route) - return route + public static endpoint(method: HTTPMethod | HTTPMethod[], endpoint: string): Route { + return new Route(method, endpoint) } /** * 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) + public static get(endpoint: string): Route { + return this.endpoint('get', endpoint) } /** Create a new POST route on the given endpoint. */ - public static post(definition: string, handler: RouteHandler): Route { - return this.endpoint('post', definition, handler) + public static post(endpoint: string): Route { + return this.endpoint('post', endpoint) } /** Create a new PUT route on the given endpoint. */ - public static put(definition: string, handler: RouteHandler): Route { - return this.endpoint('put', definition, handler) + public static put(endpoint: string): Route { + return this.endpoint('put', endpoint) } /** Create a new PATCH route on the given endpoint. */ - public static patch(definition: string, handler: RouteHandler): Route { - return this.endpoint('patch', definition, handler) + public static patch(endpoint: string): Route { + return this.endpoint('patch', endpoint) } /** Create a new DELETE route on the given endpoint. */ - public static delete(definition: string, handler: RouteHandler): Route { - return this.endpoint('delete', definition, handler) + 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(definition: string, handler: RouteHandler): Route { - return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler) + public static any(endpoint: string): Route { + return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], endpoint) } /** Create a new route group with the given prefix. */ @@ -204,35 +170,20 @@ export class Route extends AppClass { 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}>() + protected preflight: Collection = new Collection() - /** Pre-compiled route handlers for the pre-middleware for this route. */ - protected compiledPreflight?: ResolvedRouteHandler[] + protected parameters: Collection> = new Collection>() - /** Pre-compiled route handlers for the post-middleware for this route. */ - protected compiledHandler?: ResolvedRouteHandler + protected postflight: Collection = new Collection() - /** Pre-compiled route handler for the main route handler for this route. */ - protected compiledPostflight?: ResolvedRouteHandler[] + protected aliases: Collection = new Collection() - protected validator?: Validator - - /** Programmatic aliases of this route. */ - public aliases: string[] = [] + handler?: Constructable<(...x: THandlerParams) => TReturn> 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. @@ -251,24 +202,32 @@ export class Route extends AppClass { } /** - * Get the string-form method of the route. + * Get the string-form methods supported by the route. */ - public getMethod(): HTTPMethod | HTTPMethod[] { + public getMethods(): HTTPMethod[] { + if ( !Array.isArray(this.method) ) { + return [this.method] + } + return this.method } /** - * Get collection of applied middlewares. + * Get preflight middleware for this route. */ - public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> { - return this.middlewares.clone() + public getPreflight(): Collection { + return this.preflight.clone() } /** - * Get the string-form of the route handler. + * Get postflight middleware for this route. */ - public getDisplayableHandler(): string { - return typeof this.handler === 'string' ? this.handler : '(anonymous function)' + public getPostflight(): Collection { + return this.postflight.clone() + } + + public getParameters(): Collection> { + return this.parameters.clone() } /** @@ -337,179 +296,74 @@ export class Route extends AppClass { 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') - } + public parameterMiddleware( + handler: ParameterProvidingMiddleware, + ): Route> { + const route = new Route>( + this.method, + this.route, + ) - return this.compiledPreflight + route.copyFrom(this) + route.parameters.push(handler) + return route } - /** - * 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 + private copyFrom(other: Route) { + this.preflight = other.preflight.clone() + this.postflight = other.postflight.clone() + this.aliases = other.aliases.clone() } - /** - * Try to pre-compile and return the main handler for this route. - */ - public resolveHandler(): ResolvedRouteHandler { - if ( !this.compiledHandler ) { - this.compiledHandler = this.compileResolvedHandler() - } + 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)) - return this.compiledHandler + Route.registeredRoutes.push(this as unknown as Route) // man this is stupid + return this as HandledRoute } - /** Register the given middleware as a preflight handler for this route. */ - pre(middleware: RouteHandler): this { - this.middlewares.push({ - stage: 'pre', - handler: middleware, - }) + public handledBy( + handler: (...params: THandlerParams) => TReturn, + ): HandledRoute { + this.handler = Pipeline.id() + .tap(() => handler) - return this + Route.registeredRoutes.push(this as unknown as Route) + return this as HandledRoute } - /** Register the given middleware as a postflight handler for this route. */ - post(middleware: RouteHandler): this { - this.middlewares.push({ - stage: 'post', - handler: middleware, - }) - + public pre(middleware: Instantiable): this { + this.preflight.push(request => request.make(middleware, request).apply()) return this } - input(validator: Validator): this { - if ( !this.validator ) { - // - } - - this.validator = validator + public post(middleware: Instantiable): this { + this.postflight.push(request => request.make(middleware, request).apply()) 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) + public input>(validator: ValidatorFactory): Route, THandlerParams>> { + if ( !(validator instanceof Validator) ) { + validator = validator() } - 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) + return this.parameterMiddleware(validateMiddleware(validator)) } - /** 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) + public passingRequest(): Route> { + return this.parameterMiddleware(request => right(request)) } - /** - * 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() - } - } + hasAlias(name: string): boolean { + return this.aliases.includes(name) } - /** - * 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() + isHandled(): this is HandledRoute { + return Boolean(this.handler) } /** Cast the route to an intelligible string. */ @@ -517,4 +371,16 @@ export class Route extends AppClass { const method = Array.isArray(this.method) ? this.method : [this.method] return `${method.join('|')} -> ${this.route}` } + + /** 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 + } } diff --git a/src/http/routing/Route2.ts b/src/http/routing/Route2.ts deleted file mode 100644 index f5dff66..0000000 --- a/src/http/routing/Route2.ts +++ /dev/null @@ -1,209 +0,0 @@ -import {Collection, Either, PrefixTypeArray} from '../../util' -import {ResponseFactory} from '../response/ResponseFactory' -import {HTTPMethod, Request} from '../lifecycle/Request' -import {TypedDependencyKey, constructable, Constructable, Instantiable} from '../../di' -import {Middleware} from './Middleware' - -/** - * 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) => Either - -export interface HandledRoute { - /** - * Set a programmatic name for this route. - * @param name - */ - alias(name: string): this -} - -export class Route { - protected preflight: Collection = new Collection() - - protected parameters: Collection> = new Collection>() - - protected postflight: Collection = new Collection() - - protected aliases: Collection = new Collection() - - protected handler?: Constructable<(...x: THandlerParams) => TReturn> - - constructor( - protected method: HTTPMethod | HTTPMethod[], - protected route: string, - ) {} - - /** - * 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 ( !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() - } - - /** - * 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 - } - - public parameterMiddleware( - handler: ParameterProvidingMiddleware, - ): Route> { - const route = new Route>( - this.method, - this.route, - ) - - route.copyFrom(this) - route.parameters.push(handler) - return route - } - - private copyFrom(other: Route) { - this.preflight = other.preflight.clone() - this.postflight = other.postflight.clone() - this.aliases = other.aliases.clone() - } - - public calls( - key: TypedDependencyKey, - selector: (x: TKey) => (...params: THandlerParams) => TReturn, - ): HandledRoute { - this.handler = constructable(key) - .tap(inst => Function.prototype.bind.call(inst as any, selector(inst)) as ((...params: THandlerParams) => TReturn)) - - return this - } - - public pre(middleware: Instantiable): this { - this.preflight.push(request => request.make(middleware).apply()) - return this - } - - public post(middleware: Instantiable): this { - this.postflight.push(request => request.make(middleware).apply()) - return this - } - - // validator - - /** Cast the route to an intelligible string. */ - toString(): string { - const method = Array.isArray(this.method) ? this.method : [this.method] - return `${method.join('|')} -> ${this.route}` - } - - /** 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 - } -} diff --git a/src/http/routing/RouteGroup.ts b/src/http/routing/RouteGroup.ts index 48a860a..2da2ecf 100644 --- a/src/http/routing/RouteGroup.ts +++ b/src/http/routing/RouteGroup.ts @@ -1,6 +1,6 @@ import {Collection, ErrorWithContext} from '../../util' import {AppClass} from '../../lifecycle/AppClass' -import {RouteHandler} from './Route' +import {ResolvedRouteHandler} from './Route' import {Container} from '../../di' import {Logging} from '../../service/Logging' @@ -8,6 +8,10 @@ import {Logging} from '../../service/Logging' * Class that defines a group of Routes in the application, with a prefix. */ export class RouteGroup extends AppClass { + protected preflight: Collection = new Collection() + + protected postflight: Collection = new Collection() + /** * The current set of nested groups. This is used when compiling route groups. * @private @@ -20,12 +24,6 @@ export class RouteGroup extends AppClass { */ protected static namedGroups: {[key: string]: () => void } = {} - /** - * Array of middlewares that should apply to all routes in this group. - * @protected - */ - protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: 'pre' | 'post'; handler: RouteHandler}>() - /** * Get the current group nesting. */ @@ -89,27 +87,23 @@ export class RouteGroup extends AppClass { } /** Register the given middleware to be applied before all routes in this group. */ - pre(middleware: RouteHandler): this { - this.middlewares.push({ - stage: 'pre', - handler: middleware, - }) - + pre(middleware: ResolvedRouteHandler): this { + this.preflight.push(middleware) return this } /** Register the given middleware to be applied after all routes in this group. */ - post(middleware: RouteHandler): this { - this.middlewares.push({ - stage: 'post', - handler: middleware, - }) - + post(middleware: ResolvedRouteHandler): this { + this.postflight.push(middleware) return this } - /** Return the middlewares that apply to this group. */ - getGroupMiddlewareDefinitions(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> { - return this.middlewares + getPreflight(): Collection { + return this.preflight + } + + getPostflight(): Collection { + return this.postflight } } + diff --git a/src/http/servers/static.ts b/src/http/servers/static.ts index ac497ff..2d7d87c 100644 --- a/src/http/servers/static.ts +++ b/src/http/servers/static.ts @@ -7,7 +7,8 @@ import {HTTPError} from '../HTTPError' import {view, ViewResponseFactory} from '../response/ViewResponseFactory' import {redirect} from '../response/RedirectResponseFactory' import {file} from '../response/FileResponseFactory' -import {RouteHandler} from '../routing/Route' +import {ResponseObject} from '../routing/Route' +import {Logging} from '../../service/Logging' /** * Defines the behavior of the static server. @@ -109,11 +110,12 @@ function getBasePath(appPath: UniversalPath, basePath?: string | string[] | Univ * Get a route handler that serves a directory as static files. * @param options */ -export function staticServer(options: StaticServerOptions = {}): RouteHandler { +export function staticServer(options: StaticServerOptions = {}): (request: Request) => Promise { return async (request: Request) => { const config = request.make(Config) - const route = request.make(ActivatedRoute) + const route = > request.make(ActivatedRoute) const app = request.make(Application) + const logging = request.make(Logging) const staticConfig = config.get('server.builtIns.static', {}) const mergedOptions = { @@ -183,6 +185,7 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler { } // Otherwise, just send the file as the response body + logging.verbose(`Sending file: ${filePath}`) return file(filePath) } } diff --git a/src/service/Routing.ts b/src/service/Routing.ts index bd56ce7..6affbf4 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -26,7 +26,7 @@ export class Routing extends Unit { @Inject() protected readonly bus!: EventBus - protected compiledRoutes: Collection = new Collection() + protected compiledRoutes: Collection> = new Collection>() public async up(): Promise { this.app().registerFactory(new ViewEngineFactory()) @@ -47,7 +47,7 @@ export class Routing extends Unit { await this.registerBuiltIns() this.logging.info('Compiling routes...') - this.compiledRoutes = new Collection(await Route.compile()) + this.compiledRoutes = new Collection>(await Route.compile()) this.logging.info(`Compiled ${this.compiledRoutes.length} route(s).`) this.compiledRoutes.each(route => { @@ -85,7 +85,7 @@ export class Routing extends Unit { */ public async recompile(): Promise { this.logging.debug('Recompiling routes...') - this.compiledRoutes = this.compiledRoutes.concat(new Collection(await Route.compile())) + this.compiledRoutes = this.compiledRoutes.concat(new Collection>(await Route.compile())) this.logging.debug(`Re-compiled ${this.compiledRoutes.length} route(s).`) this.compiledRoutes.each(route => { @@ -99,7 +99,7 @@ export class Routing extends Unit { * @param method * @param path */ - public match(method: HTTPMethod, path: string): Route | undefined { + public match(method: HTTPMethod, path: string): Route | undefined { return this.compiledRoutes.firstWhere(route => { return route.match(method, path) }) @@ -115,7 +115,7 @@ export class Routing extends Unit { /** * Get the collection of compiled routes. */ - public getCompiled(): Collection { + public getCompiled(): Collection> { return this.compiledRoutes } @@ -158,9 +158,9 @@ export class Routing extends Unit { return Boolean(this.getByName(name)) } - public getByName(name: string): Maybe { + public getByName(name: string): Maybe> { return this.compiledRoutes - .firstWhere(route => route.aliases.includes(name)) + .firstWhere(route => route.hasAlias(name)) } public getAppUrl(): UniversalPath { @@ -204,10 +204,12 @@ export class Routing extends Unit { const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor') Route.group(prefix, () => { Route.group(packageName, () => { - Route.get('/**', staticServer({ - basePath, - directoryListing: false, - })) + Route.get('/**') + .passingRequest() + .handledBy(staticServer({ + basePath, + directoryListing: false, + })) }) }) }) @@ -235,10 +237,12 @@ export class Routing extends Unit { this.logging.debug(`Registering built-in assets server with prefix: ${prefix}`) await this.registerRoutes(() => { Route.group(prefix, () => { - Route.get('/**', staticServer({ - directoryListing: false, - basePath: ['resources', 'assets'], - })) + Route.get('/**') + .passingRequest() + .handledBy(staticServer({ + directoryListing: false, + basePath: ['resources', 'assets'], + })) }) }) } diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index b9d44a8..bcd1138 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -14,6 +14,7 @@ type MaybeCollectionIndex = CollectionIndex | undefined type ComparisonFunction = (item: CollectionItem, otherItem: CollectionItem) => number import { WhereOperator, applyWhere, whereMatch } from './where' +import {Awaitable, Either, isLeft, right, unright} from '../support/types' const collect = (items: CollectionItem[]): Collection => Collection.collect(items) const toString = (item: unknown): string => String(item) @@ -316,6 +317,44 @@ class Collection { return new Collection(newItems) } + /** + * Create a new collection by mapping the items in this collection using the given function + * where the function returns an Either. The collection is all Right instances. If a Left + * is encountered, that value is returned. + * @param func + */ + mapRight(func: KeyFunction>): Either> { + const newItems: CollectionItem[] = [] + for ( let i = 0; i < this.length; i += 1 ) { + const result = func(this.storedItems[i], i) + if ( isLeft(result) ) { + return result + } + + newItems.push(unright(result)) + } + return right(new Collection(newItems)) + } + + /** + * Create a new collection by mapping the items in this collection using the given function + * where the function returns an Either. The collection is all Right instances. If a Left + * is encountered, that value is returned. + * @param func + */ + async asyncMapRight(func: KeyFunction>>): Promise>> { + const newItems: CollectionItem[] = [] + for ( let i = 0; i < this.length; i += 1 ) { + const result = await func(this.storedItems[i], i) + if ( isLeft(result) ) { + return result + } + + newItems.push(unright(result)) + } + return right(new Collection(newItems)) + } + /** * Create a new collection by mapping the items in this collection using the given function, * excluding any for which the function returns undefined. diff --git a/src/util/support/types.ts b/src/util/support/types.ts index 6d9a686..fe70768 100644 --- a/src/util/support/types.ts +++ b/src/util/support/types.ts @@ -4,6 +4,10 @@ export type Awaitable = T | Promise /** Type alias for something that may be undefined. */ export type Maybe = T | undefined +export type MaybeArr = { + [Index in keyof T]: Maybe +} & {length: T['length']} + export type Either = Left | Right export type Left = [T, undefined] @@ -26,6 +30,14 @@ export function right(what: T): Right { return [undefined, what] } +export function unleft(what: Left): T { + return what[0] +} + +export function unright(what: Right): T { + return what[1] +} + /** Type alias for a callback that accepts a typed argument. */ export type ParameterizedCallback = ((arg: T) => any) diff --git a/src/validation/Validator.ts b/src/validation/Validator.ts index 5080ba4..7d2b4ed 100644 --- a/src/validation/Validator.ts +++ b/src/validation/Validator.ts @@ -8,6 +8,8 @@ import {Logging} from '../service/Logging' /** Type tag for a validated runtime type. */ export type Valid = TypeTag<'@extollo/lib:Valid'> & T +export type ValidatorFactory> = T | (() => T) + /** * Error thrown if the schema for a validator cannot be located. */ diff --git a/src/validation/middleware.ts b/src/validation/middleware.ts new file mode 100644 index 0000000..8b8abc4 --- /dev/null +++ b/src/validation/middleware.ts @@ -0,0 +1,22 @@ +import {Request} from '../http/lifecycle/Request' +import {Validator} from './Validator' +import {ZodError} from 'zod' +import {HTTPStatus, left, right} from '../util' +import {json} from '../http/response/JSONResponseFactory' + +export function validateMiddleware(validator: Validator) { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + return (request: Request) => { + try { + const data = validator.parse(request.parsedInput) + return right(data) + } catch (e) { + if ( e instanceof ZodError ) { + // FIXME render this better + return left(json(e.formErrors).status(HTTPStatus.BAD_REQUEST)) + } + + throw e + } + } +}