Make new routing system the default
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2022-01-19 13:24:59 -06:00
parent 8cf19792a6
commit dc16dfdb81
17 changed files with 298 additions and 535 deletions

View File

@ -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)'
})*/
}
}

View File

@ -17,7 +17,7 @@ export class RoutesDirective extends Directive {
}
async handle(): Promise<void> {
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)*/
}
}

View File

@ -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<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const object: ResponseObject = await route.handler(request)
const route = <ActivatedRoute<unknown, unknown[]>> 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)

View File

@ -17,7 +17,7 @@ export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRo
public async apply(request: Request): Promise<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const postflight = route.postflight
for ( const handler of postflight ) {

View File

@ -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<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const route = <ActivatedRoute<unknown, unknown[]>> 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

View File

@ -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 = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
} else {
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
}

View File

@ -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<THandlerParams extends unknown[]> = {
[Index in keyof THandlerParams]: ParameterProvidingMiddleware<THandlerParams[Index]>
} & {length: THandlerParams['length']}
/**
* Class representing a resolved route that a request is mounted to.
*/
@Injectable()
export class ActivatedRoute {
export class ActivatedRoute<TReturn, THandlerParams extends unknown[]> {
/**
* 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<THandlerParams>
public resolvedParameters?: THandlerParams
constructor(
/** The route this ActivatedRoute refers to. */
public readonly route: Route,
public readonly route: Route<TReturn, THandlerParams>,
/** 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<THandlerParams>
}
}

View File

@ -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 {

View File

@ -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<ResponseObject>
/**
* 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<T> = (request: Request) => Either<ResponseObject, T>
// TODO domains, named routes - support this on groups as well
export interface HandledRoute<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
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')
* })
* ```
/**
* Set a programmatic name for this route.
* @param name
*/
export class Route extends AppClass {
alias(name: string): this
}
export class Route<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
/** Routes that have been created and registered in the application. */
private static registeredRoutes: Route[] = []
private static registeredRoutes: Route<unknown, unknown[]>[] = []
/** 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<Route[]> {
public static async compile(): Promise<Route<unknown, unknown[]>[]> {
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<Middleware>(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<Middleware>(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<ResponseObject> {
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<ResponseObject> {
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<ResponseObject> {
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<ResponseObject> {
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<ResponseObject> {
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<ResponseObject> {
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<ResponseObject> {
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<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
/** Pre-compiled route handlers for the pre-middleware for this route. */
protected compiledPreflight?: ResolvedRouteHandler[]
protected parameters: Collection<ParameterProvidingMiddleware<unknown>> = new Collection<ParameterProvidingMiddleware<unknown>>()
/** Pre-compiled route handlers for the post-middleware for this route. */
protected compiledHandler?: ResolvedRouteHandler
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
/** Pre-compiled route handler for the main route handler for this route. */
protected compiledPostflight?: ResolvedRouteHandler[]
protected aliases: Collection<string> = new Collection<string>()
protected validator?: Validator<unknown>
/** 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<ResolvedRouteHandler> {
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<ResolvedRouteHandler> {
return this.postflight.clone()
}
public getParameters(): Collection<ParameterProvidingMiddleware<unknown>> {
return this.parameters.clone()
}
/**
@ -337,66 +296,80 @@ 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<T>(
handler: ParameterProvidingMiddleware<T>,
): Route<TReturn, PrefixTypeArray<T, THandlerParams>> {
const route = new Route<TReturn, PrefixTypeArray<T, THandlerParams>>(
this.method,
this.route,
)
route.copyFrom(this)
route.parameters.push(handler)
return route
}
return this.compiledPreflight
private copyFrom(other: Route<TReturn, any>) {
this.preflight = other.preflight.clone()
this.postflight = other.postflight.clone()
this.aliases = other.aliases.clone()
}
/**
* Try to pre-compile and return the postflight handlers for this route.
*/
public resolvePostflight(): ResolvedRouteHandler[] {
if ( !this.compiledPostflight ) {
this.compiledPostflight = this.resolveMiddlewareHandlersForStage('post')
public calls<TKey>(
key: TypedDependencyKey<TKey>,
selector: (x: TKey) => (...params: THandlerParams) => TReturn,
): HandledRoute<TReturn, THandlerParams> {
this.handler = constructable<TKey>(key)
.tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((...params: THandlerParams) => TReturn))
Route.registeredRoutes.push(this as unknown as Route<unknown, unknown[]>) // man this is stupid
return this as HandledRoute<TReturn, THandlerParams>
}
return this.compiledPostflight
public handledBy(
handler: (...params: THandlerParams) => TReturn,
): HandledRoute<TReturn, THandlerParams> {
this.handler = Pipeline.id<Container>()
.tap(() => handler)
Route.registeredRoutes.push(this as unknown as Route<unknown, unknown[]>)
return this as HandledRoute<TReturn, THandlerParams>
}
/**
* 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,
})
public pre(middleware: Instantiable<Middleware>): this {
this.preflight.push(request => request.make<Middleware>(middleware, request).apply())
return this
}
/** Register the given middleware as a postflight handler for this route. */
post(middleware: RouteHandler): this {
this.middlewares.push({
stage: 'post',
handler: middleware,
})
public post(middleware: Instantiable<Middleware>): this {
this.postflight.push(request => request.make<Middleware>(middleware, request).apply())
return this
}
input(validator: Validator<any>): this {
if ( !this.validator ) {
//
public input<T extends Validator<T>>(validator: ValidatorFactory<T>): Route<TReturn, PrefixTypeArray<Valid<T>, THandlerParams>> {
if ( !(validator instanceof Validator) ) {
validator = validator()
}
this.validator = validator
return this
return this.parameterMiddleware(validateMiddleware(validator))
}
public passingRequest(): Route<TReturn, PrefixTypeArray<Request, THandlerParams>> {
return this.parameterMiddleware(request => right(request))
}
hasAlias(name: string): boolean {
return this.aliases.includes(name)
}
isHandled(): this is HandledRoute<TReturn, THandlerParams> {
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}`
}
/** Prefix the route's path with the given prefix, normalizing `/` characters. */
@ -410,111 +383,4 @@ export class Route extends AppClass {
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 = <Controllers> 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 = <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<ResolvedRouteHandler>(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 = <Middlewares> 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 = <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}`
}
}

View File

@ -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<ResponseObject>
/**
* 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<T> = (request: Request) => Either<ResponseObject, T>
export interface HandledRoute<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
/**
* Set a programmatic name for this route.
* @param name
*/
alias(name: string): this
}
export class Route<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
protected preflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
protected parameters: Collection<ParameterProvidingMiddleware<unknown>> = new Collection<ParameterProvidingMiddleware<unknown>>()
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
protected aliases: Collection<string> = new Collection<string>()
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<ResolvedRouteHandler> {
return this.preflight.clone()
}
/**
* Get postflight middleware for this route.
*/
public getPostflight(): Collection<ResolvedRouteHandler> {
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<T>(
handler: ParameterProvidingMiddleware<T>,
): Route<TReturn, PrefixTypeArray<T, THandlerParams>> {
const route = new Route<TReturn, PrefixTypeArray<T, THandlerParams>>(
this.method,
this.route,
)
route.copyFrom(this)
route.parameters.push(handler)
return route
}
private copyFrom(other: Route<TReturn, any>) {
this.preflight = other.preflight.clone()
this.postflight = other.postflight.clone()
this.aliases = other.aliases.clone()
}
public calls<TKey>(
key: TypedDependencyKey<TKey>,
selector: (x: TKey) => (...params: THandlerParams) => TReturn,
): HandledRoute<TReturn, THandlerParams> {
this.handler = constructable<TKey>(key)
.tap(inst => Function.prototype.bind.call(inst as any, selector(inst)) as ((...params: THandlerParams) => TReturn))
return this
}
public pre(middleware: Instantiable<Middleware>): this {
this.preflight.push(request => request.make<Middleware>(middleware).apply())
return this
}
public post(middleware: Instantiable<Middleware>): this {
this.postflight.push(request => request.make<Middleware>(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
}
}

View File

@ -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<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
/**
* 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<ResolvedRouteHandler> {
return this.preflight
}
getPostflight(): Collection<ResolvedRouteHandler> {
return this.postflight
}
}

View File

@ -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<ResponseObject> {
return async (request: Request) => {
const config = <Config> request.make(Config)
const route = <ActivatedRoute> request.make(ActivatedRoute)
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const app = <Application> request.make(Application)
const logging = <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)
}
}

View File

@ -26,7 +26,7 @@ export class Routing extends Unit {
@Inject()
protected readonly bus!: EventBus
protected compiledRoutes: Collection<Route> = new Collection<Route>()
protected compiledRoutes: Collection<Route<unknown, unknown[]>> = new Collection<Route<unknown, unknown[]>>()
public async up(): Promise<void> {
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<Route>(await Route.compile())
this.compiledRoutes = new Collection<Route<unknown, unknown[]>>(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<void> {
this.logging.debug('Recompiling routes...')
this.compiledRoutes = this.compiledRoutes.concat(new Collection<Route>(await Route.compile()))
this.compiledRoutes = this.compiledRoutes.concat(new Collection<Route<unknown, unknown[]>>(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<unknown, unknown[]> | 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<Route> {
public getCompiled(): Collection<Route<unknown, unknown[]>> {
return this.compiledRoutes
}
@ -158,9 +158,9 @@ export class Routing extends Unit {
return Boolean(this.getByName(name))
}
public getByName(name: string): Maybe<Route> {
public getByName(name: string): Maybe<Route<unknown, unknown[]>> {
return this.compiledRoutes
.firstWhere(route => route.aliases.includes(name))
.firstWhere(route => route.hasAlias(name))
}
public getAppUrl(): UniversalPath {
@ -204,7 +204,9 @@ export class Routing extends Unit {
const prefix = this.config.get('server.builtIns.vendor.prefix', '/vendor')
Route.group(prefix, () => {
Route.group(packageName, () => {
Route.get('/**', staticServer({
Route.get('/**')
.passingRequest()
.handledBy(staticServer({
basePath,
directoryListing: false,
}))
@ -235,7 +237,9 @@ 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({
Route.get('/**')
.passingRequest()
.handledBy(staticServer({
directoryListing: false,
basePath: ['resources', 'assets'],
}))

View File

@ -14,6 +14,7 @@ type MaybeCollectionIndex = CollectionIndex | undefined
type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => number
import { WhereOperator, applyWhere, whereMatch } from './where'
import {Awaitable, Either, isLeft, right, unright} from '../support/types'
const collect = <T>(items: CollectionItem<T>[]): Collection<T> => Collection.collect(items)
const toString = (item: unknown): string => String(item)
@ -316,6 +317,44 @@ class Collection<T> {
return new Collection<T2>(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<TLeft, TRight>(func: KeyFunction<T, Either<TLeft, TRight>>): Either<TLeft, Collection<TRight>> {
const newItems: CollectionItem<TRight>[] = []
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<TRight>(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<TLeft, TRight>(func: KeyFunction<T, Awaitable<Either<TLeft, TRight>>>): Promise<Either<TLeft, Collection<TRight>>> {
const newItems: CollectionItem<TRight>[] = []
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<TRight>(newItems))
}
/**
* Create a new collection by mapping the items in this collection using the given function,
* excluding any for which the function returns undefined.

View File

@ -4,6 +4,10 @@ export type Awaitable<T> = T | Promise<T>
/** Type alias for something that may be undefined. */
export type Maybe<T> = T | undefined
export type MaybeArr<T extends [...any[]]> = {
[Index in keyof T]: Maybe<T[Index]>
} & {length: T['length']}
export type Either<T1, T2> = Left<T1> | Right<T2>
export type Left<T> = [T, undefined]
@ -26,6 +30,14 @@ export function right<T>(what: T): Right<T> {
return [undefined, what]
}
export function unleft<T>(what: Left<T>): T {
return what[0]
}
export function unright<T>(what: Right<T>): T {
return what[1]
}
/** Type alias for a callback that accepts a typed argument. */
export type ParameterizedCallback<T> = ((arg: T) => any)

View File

@ -8,6 +8,8 @@ import {Logging} from '../service/Logging'
/** Type tag for a validated runtime type. */
export type Valid<T> = TypeTag<'@extollo/lib:Valid'> & T
export type ValidatorFactory<T extends Validator<T>> = T | (() => T)
/**
* Error thrown if the schema for a validator cannot be located.
*/

View File

@ -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<T>(validator: Validator<T>) {
// 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
}
}
}