TypeDoc all the thngs

r0.1.5
Garrett Mills 3 years ago
parent 7cb0546b01
commit fad1184afe
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246

@ -1,6 +1,10 @@
import {AppClass} from "../lifecycle/AppClass";
import {Request} from "./lifecycle/Request";
/**
* Base class for controllers that define methods that
* handle HTTP requests.
*/
export class Controller extends AppClass {
constructor(
protected readonly request: Request

@ -1,5 +1,11 @@
import {ErrorWithContext, HTTPStatus, HTTPMessage} from "@extollo/util"
/**
* An error class that has an associated HTTP status.
*
* When thrown inside the request lifecycle, this will result in the HTTP
* status code being applied to the response.
*/
export class HTTPError extends ErrorWithContext {
constructor(
public readonly status: HTTPStatus = 500,

@ -12,8 +12,14 @@ export interface HTTPCookie {
options?: HTTPCookieOptions,
}
/**
* Type alias for something that is either an HTTP cookie, or undefined.
*/
export type MaybeHTTPCookie = HTTPCookie | undefined;
/**
* Interface describing the available cookie options.
*/
export interface HTTPCookieOptions {
domain?: string,
expires?: Date, // encodeURIComponent
@ -25,21 +31,36 @@ export interface HTTPCookieOptions {
sameSite?: 'strict' | 'lax' | 'none-secure',
}
/**
* Class for accessing and managing cookies in the associated request.
*/
export class HTTPCookieJar {
/** The cookies parsed from the request. */
protected parsed: {[key: string]: HTTPCookie} = {}
constructor(
/** The request whose cookies should be loaded. */
protected request: Request,
) {
this.parseCookies()
}
/**
* Gets the HTTPCookie by name, if it exists.
* @param name
*/
get(name: string): MaybeHTTPCookie {
if ( name in this.parsed ) {
return this.parsed[name]
}
}
/**
* Set a new cookie using the specified options.
* @param name
* @param value
* @param options
*/
set(name: string, value: any, options?: HTTPCookieOptions) {
this.parsed[name] = {
key: name,
@ -50,10 +71,23 @@ export class HTTPCookieJar {
}
}
/**
* Returns true if a cookie exists with the given name.
* @param name
*/
has(name: string) {
return !!this.parsed[name]
}
/**
* Clears the given cookie.
*
* Important: if the cookie was set with any `options`, the SAME options
* must be provided here in order for the cookie to be cleared on the client.
*
* @param name
* @param options
*/
clear(name: string, options?: HTTPCookieOptions) {
if ( !options ) options = {}
options.expires = new Date(0)
@ -67,6 +101,9 @@ export class HTTPCookieJar {
}
}
/**
* Get an array of `Set-Cookie` headers to include in the response.
*/
getSetCookieHeaders(): string[] {
const headers: string[] = []
@ -119,6 +156,7 @@ export class HTTPCookieJar {
return headers
}
/** Parse the cookies from the request. */
private parseCookies() {
const cookies = String(this.request.getHeader('cookie'))
cookies.split(';').forEach(cookie => {

@ -10,10 +10,27 @@ import {error} from "../response/ErrorResponseFactory";
* Interface for fluently registering kernel modules into the kernel.
*/
export interface ModuleRegistrationFluency {
/**
* If no argument is provided, the module will be registered before the core module.
* If an argument is provided, the module will be registered before the other specified module.
* @param other
*/
before: (other?: Instantiable<HTTPKernelModule>) => HTTPKernel,
/**
* If no argument is provided, the module will be registered after the core module.
* If an argument is provided, the module will be registered after the other specified module.
* @param other
*/
after: (other?: Instantiable<HTTPKernelModule>) => HTTPKernel,
/** The module will be registered as the first module in the preflight. */
first: () => HTTPKernel,
/** The module will be registered as the last module in the postflight. */
last: () => HTTPKernel,
/** The module will be registered as the core handler for the request. */
core: () => HTTPKernel,
}
@ -27,6 +44,9 @@ export class KernelModuleNotFoundError extends Error {
}
}
/**
* A singleton class that handles requests, applying logic in modular layers.
*/
@Singleton()
export class HTTPKernel extends AppClass {
@Inject()

@ -3,8 +3,19 @@ import {AppClass} from "../../lifecycle/AppClass";
import {HTTPKernel} from "./HTTPKernel";
import {Request} from "../lifecycle/Request";
/**
* Base class for modules that define logic that is applied to requests
* handled by the HTTP kernel.
*/
@Injectable()
export class HTTPKernelModule extends AppClass {
/**
* By default, if a kernel module interrupts the request flow to send a response
* (for example, if an error occurs), subsequent modules are skipped.
*
* However, a module can override this property to be true and its logic will still
* be applied, even after a module has interrupted the request flow.
*/
public readonly executeWithBlockingWriteback: boolean = false
/**

@ -5,7 +5,16 @@ import {plaintext} from "../../response/StringResponseFactory";
import {ResponseFactory} from "../../response/ResponseFactory";
import {json} from "../../response/JSONResponseFactory";
/**
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
*/
export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelModule {
/**
* Given a response object, write the response to the request in the appropriate format.
* @param object
* @param request
* @protected
*/
protected async applyResponseObject(object: ResponseObject, request: Request) {
if ( (typeof object === 'string') || (typeof object === 'number') ) {
object = plaintext(String(object))

@ -6,6 +6,11 @@ import {http} from "../../response/HTTPErrorResponseFactory";
import {HTTPStatus} from "@extollo/util";
import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule";
/**
* HTTP kernel module that runs the handler for the request's route.
*
* In most cases, this is the controller method defined by the route.
*/
export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
public static register(kernel: HTTPKernel) {
kernel.register(this).core()

@ -5,6 +5,11 @@ import {ResponseObject} from "../../routing/Route";
import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule";
import {PersistSessionHTTPModule} from "./PersistSessionHTTPModule";
/**
* HTTP kernel module that executes the postflight handlers for the route.
*
* Usually, this is post middleware.
*/
export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
public static register(kernel: HTTPKernel) {
kernel.register(this).before(PersistSessionHTTPModule)

@ -5,6 +5,11 @@ import {ActivatedRoute} from "../../routing/ActivatedRoute";
import {ResponseObject} from "../../routing/Route";
import {AbstractResolvedRouteHandlerHTTPModule} from "./AbstractResolvedRouteHandlerHTTPModule";
/**
* HTTP Kernel module that executes the preflight handlers for the route.
*
* Usually, this is the pre middleware.
*/
export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
public static register(kernel: HTTPKernel) {
kernel.register(this).after(MountActivatedRouteHTTPModule)

@ -7,6 +7,10 @@ import {SetSessionCookieHTTPModule} from "./SetSessionCookieHTTPModule";
import {SessionFactory} from "../../session/SessionFactory";
import {Session} from "../../session/Session";
/**
* HTTP kernel middleware that creates the session using the configured driver
* and loads its data using the request's session cookie.
*/
@Injectable()
export class InjectSessionHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true

@ -6,6 +6,10 @@ import {Routing} from "../../../service/Routing";
import {ActivatedRoute} from "../../routing/ActivatedRoute";
import {Logging} from "../../../service/Logging";
/**
* HTTP kernel middleware that tries to find a registered route matching the request's
* path and creates an ActivatedRoute instance from it.
*/
@Injectable()
export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true

@ -4,6 +4,10 @@ import {HTTPKernel} from "../HTTPKernel";
import {Request} from "../../lifecycle/Request";
import {Session} from "../../session/Session";
/**
* HTTP kernel module that runs after the main logic in the request to persist
* the session data to the driver's backend.
*/
@Injectable()
export class PersistSessionHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true

@ -4,6 +4,9 @@ import {Injectable, Inject} from "@extollo/di"
import {HTTPKernel} from "../HTTPKernel";
import {Config} from "../../../service/Config";
/**
* HTTP kernel middleware that sets the `X-Powered-By` header.
*/
@Injectable()
export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true

@ -5,6 +5,10 @@ import {HTTPKernel} from "../HTTPKernel";
import {Request} from "../../lifecycle/Request";
import {Logging} from "../../../service/Logging";
/**
* HTTP kernel middleware that tries to look up the session ID from the request.
* If none exists, generates a new one and sets the cookie.
*/
@Injectable()
export class SetSessionCookieHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true

@ -7,44 +7,88 @@ import * as url from "url";
import {Response} from "./Response";
import * as Negotiator from "negotiator";
// FIXME - add others?
/**
* Enumeration of different HTTP verbs.
* @todo add others?
*/
export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown';
/**
* Returns true if the given item is a valid HTTP verb.
* @param what
*/
export function isHTTPMethod(what: any): what is HTTPMethod {
return ['post', 'get', 'patch', 'put', 'delete'].includes(what)
}
/**
* Interface that describes the HTTP protocol version.
*/
export interface HTTPProtocol {
string: string,
major: number,
minor: number,
}
/**
* Interface that describes the origin IP address of a request.
*/
export interface HTTPSourceAddress {
address: string;
family: 'IPv4' | 'IPv6';
port: number;
}
/**
* A class that represents an HTTP request from a client.
*/
@Injectable()
export class Request extends ScopedContainer {
/** The cookie manager for the request. */
public readonly cookies: HTTPCookieJar;
/** The URL suffix of the request. */
public readonly url: string;
/** The fully-qualified URL of the request. */
public readonly fullUrl: string;
/** The HTTP verb of the request. */
public readonly method: HTTPMethod;
/** True if the request was made via TLS. */
public readonly secure: boolean;
/** The request HTTP protocol version. */
public readonly protocol: HTTPProtocol;
/** The URL path, stripped of query params. */
public readonly path: string;
/** The raw parsed query data from the request. */
public readonly rawQueryData: {[key: string]: string | string[] | undefined};
/** The inferred query data. */
public readonly query: {[key: string]: any};
/** True if the request was made via XMLHttpRequest. */
public readonly isXHR: boolean;
/** The origin IP address of the request. */
public readonly address: HTTPSourceAddress;
/** The associated response. */
public readonly response: Response;
/** The media types accepted by the client. */
public readonly mediaTypes: string[];
constructor(
/** The native Node.js request. */
protected clientRequest: IncomingMessage,
/** The native Node.js response. */
protected serverResponse: ServerResponse,
) {
super(Container.getContainer())
@ -103,20 +147,30 @@ export class Request extends ScopedContainer {
this.response = new Response(this, serverResponse)
}
/** Get the value of a header, if it exists. */
public getHeader(name: string) {
return this.clientRequest.headers[name.toLowerCase()]
}
/** Get the native Node.js IncomingMessage object. */
public toNative() {
return this.clientRequest
}
/**
* Get the value of an input field on the request. Spans multiple input sources.
* @param key
*/
public input(key: string) {
if ( key in this.query ) {
return this.query[key]
}
}
/**
* Returns true if the request accepts the given media type.
* @param type - a mimetype, or the short forms json, xml, or html
*/
accepts(type: string) {
if ( type === 'json' ) type = 'application/json'
else if ( type === 'xml' ) type = 'application/xml'
@ -133,6 +187,9 @@ export class Request extends ScopedContainer {
return this.mediaTypes.some(media => possible.includes(media.toLowerCase()))
}
/**
* Returns the short form of the content type the client has requested.
*/
wants(): 'html' | 'json' | 'xml' | 'unknown' {
const jsonIdx = this.mediaTypes.indexOf('application/json') ?? this.mediaTypes.indexOf('application/*') ?? this.mediaTypes.indexOf('*/*')
const xmlIdx = this.mediaTypes.indexOf('application/xml') ?? this.mediaTypes.indexOf('application/*') ?? this.mediaTypes.indexOf('*/*')

@ -2,6 +2,9 @@ import {Request} from "./Request";
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from "@extollo/util"
import {ServerResponse} from "http"
/**
* Error thrown when the server tries to re-send headers after they have been sent once.
*/
export class HeadersAlreadySentError extends ErrorWithContext {
constructor(response: Response, headerName?: string) {
super(`Cannot modify or re-send headers for this request as they have already been sent.`);
@ -9,56 +12,103 @@ export class HeadersAlreadySentError extends ErrorWithContext {
}
}
/**
* Error thrown when the server tries to re-send a response that has already been sent.
*/
export class ResponseAlreadySentError extends ErrorWithContext {
constructor(response: Response) {
super(`Cannot modify or re-send response as it has already ended.`);
}
}
/**
* A class representing an HTTP response to a client.
*/
export class Response {
/** Mapping of headers that should be sent back to the client. */
private headers: {[key: string]: string | string[]} = {}
/** True if the headers have been sent. */
private _sentHeaders: boolean = false
/** True if the response has been sent and closed. */
private _responseEnded: boolean = false
/** The HTTP status code that should be sent to the client. */
private _status: HTTPStatus = HTTPStatus.OK
/**
* If this is true, then some module in the kernel has flagged the response
* as being interrupted and handled. Subsequent modules should NOT overwrite
* the response.
* @private
*/
private _blockingWriteback: boolean = false
/** The body contents that should be written to the response. */
public body: string = ''
/**
* Behavior subject fired right before the response content is written.
*/
public readonly sending$: BehaviorSubject<Response> = new BehaviorSubject<Response>()
/**
* Behavior subject fired right after the response content is written.
*/
public readonly sent$: BehaviorSubject<Response> = new BehaviorSubject<Response>()
constructor(
/** The associated request object. */
public readonly request: Request,
/** The native Node.js ServerResponse. */
protected readonly serverResponse: ServerResponse,
) { }
/** Get the currently set response status. */
public getStatus() {
return this._status
}
/** Set a new response status. */
public setStatus(status: HTTPStatus) {
if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, 'status')
this._status = status
}
/** Get the HTTPCookieJar for the client. */
public get cookies() {
return this.request.cookies
}
/** Get the value of the response header, if it exists. */
public getHeader(name: string): string | string[] | undefined {
return this.headers[name]
}
/** Set the value of the response header. */
public setHeader(name: string, value: string | string[]) {
if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, name)
this.headers[name] = value
return this
}
/**
* Bulk set the specified headers in the response.
* @param data
*/
public setHeaders(data: {[name: string]: string | string[]}) {
if ( this._sentHeaders ) throw new HeadersAlreadySentError(this)
this.headers = {...this.headers, ...data}
return this
}
/**
* Add the given value as a header, appending it to an existing header if one exists.
* @param name
* @param value
*/
public appendHeader(name: string, value: string | string[]) {
if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, name)
if ( !Array.isArray(value) ) value = [value]
@ -70,6 +120,9 @@ export class Response {
this.headers[name] = existing
}
/**
* Write the headers to the client.
*/
public sendHeaders() {
const headers = {} as any
@ -85,14 +138,20 @@ export class Response {
this._sentHeaders = true
}
/** Returns true if the headers have been sent. */
public hasSentHeaders() {
return this._sentHeaders
}
/** Returns true if a body has been set in the response. */
public hasBody() {
return !!this.body
}
/**
* Get or set the flag for whether the writeback should be blocked.
* @param set - if this is specified, the value will be set.
*/
public blockingWriteback(set?: boolean) {
if ( typeof set !== 'undefined' ) {
this._blockingWriteback = set
@ -101,6 +160,10 @@ export class Response {
return this._blockingWriteback
}
/**
* Write the headers and specified data to the client.
* @param data
*/
public async write(data: any) {
return new Promise<void>((res, rej) => {
if ( !this._sentHeaders ) this.sendHeaders()
@ -111,6 +174,9 @@ export class Response {
})
}
/**
* Send the response to the client, writing the headers and configured body.
*/
public async send() {
await this.sending$.next(this)
this.setHeader('Content-Length', String(this.body?.length ?? 0))
@ -119,6 +185,9 @@ export class Response {
await this.sent$.next(this)
}
/**
* Mark the response as ended and close the socket.
*/
public end() {
if ( this._responseEnded ) throw new ResponseAlreadySentError(this)
this._sentHeaders = true

@ -2,10 +2,17 @@ import {ResponseFactory} from "./ResponseFactory"
import {Rehydratable} from "@extollo/util"
import {Request} from "../lifecycle/Request";
/**
* Helper function that creates a DehydratedStateResponseFactory.
* @param value
*/
export function dehydrate(value: Rehydratable): DehydratedStateResponseFactory {
return new DehydratedStateResponseFactory(value)
}
/**
* Response factor that sends a Rehydratable class' data as JSON.
*/
export class DehydratedStateResponseFactory extends ResponseFactory {
constructor(
public readonly rehydratable: Rehydratable

@ -3,6 +3,12 @@ import {ErrorWithContext, HTTPStatus} from "@extollo/util"
import {Request} from "../lifecycle/Request";
import * as api from "./api"
/**
* Helper to create a new ErrorResponseFactory, with the given HTTP status and output format.
* @param error
* @param status
* @param output
*/
export function error(
error: Error | string,
status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
@ -12,6 +18,9 @@ export function error(
return new ErrorResponseFactory(error, status, output)
}
/**
* Response factory that renders an Error object to the client in a specified format.
*/
export class ErrorResponseFactory extends ResponseFactory {
protected targetMode: 'json' | 'html' | 'auto' = 'auto'

@ -1,10 +1,17 @@
import {ResponseFactory} from "./ResponseFactory";
import {Request} from "../lifecycle/Request";
/**
* Helper function that creates a new HTMLResponseFactory.
* @param value
*/
export function html(value: string): HTMLResponseFactory {
return new HTMLResponseFactory(value)
}
/**
* Response factory that writes a string to the response as HTML.
*/
export class HTMLResponseFactory extends ResponseFactory {
constructor(
public readonly value: string,

@ -2,10 +2,19 @@ import {ErrorResponseFactory} from "./ErrorResponseFactory";
import {HTTPError} from "../HTTPError";
import {HTTPStatus} from "@extollo/util"
/**
* Helper that generates a new HTTPErrorResponseFactory given the HTTP status and message.
* @param status
* @param message
* @param output
*/
export function http(status: HTTPStatus, message?: string, output: 'json' | 'html' | 'auto' = 'auto'): HTTPErrorResponseFactory {
return new HTTPErrorResponseFactory(new HTTPError(status, message), output)
}
/**
* Response factory that renders the given HTTPError in the specified output format.
*/
export class HTTPErrorResponseFactory extends ErrorResponseFactory {
constructor(
public readonly error: HTTPError,

@ -1,10 +1,17 @@
import {ResponseFactory} from "./ResponseFactory";
import {Request} from "../lifecycle/Request";
/**
* Helper function to create a new JSONResponseFactory of the given value.
* @param value
*/
export function json(value: any): JSONResponseFactory {
return new JSONResponseFactory(value)
}
/**
* Response factory that writes the given object as JSON to the response.
*/
export class JSONResponseFactory extends ResponseFactory {
constructor(
public readonly value: any

@ -1,14 +1,24 @@
import {HTTPStatus} from "@extollo/util"
import {Request} from "../lifecycle/Request"
/**
* Abstract class that defines "factory" that knows how to write a particular
* response to the response object.
*/
export abstract class ResponseFactory {
/** The status that should be set on the response. */
protected targetStatus: HTTPStatus = HTTPStatus.OK
/**
* Called to write the response data to the HTTP response object.
* @param request
*/
public async write(request: Request): Promise<Request> {
request.response.setStatus(this.targetStatus)
return request
}
/** Set the target status of this factory. */
public status(status: HTTPStatus) {
this.targetStatus = status
return this

@ -1,12 +1,20 @@
import {ResponseFactory} from "./ResponseFactory";
import {Request} from "../lifecycle/Request";
/**
* Helper function that creates a new StringResponseFactory for the given string value.
* @param value
*/
export function plaintext(value: string): StringResponseFactory {
return new StringResponseFactory(value)
}
/**
* Response factory that renders a given string as the response in plaintext.
*/
export class StringResponseFactory extends ResponseFactory {
constructor(
/** The string to write as the body. */
public readonly value: string,
) { super() }

@ -2,14 +2,22 @@ import {ResponseFactory} from "./ResponseFactory";
import {HTTPStatus} from "@extollo/util";
import {Request} from "../lifecycle/Request";
/**
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
* @param destination
*/
export function redirect(destination: string): TemporaryRedirectResponseFactory {
return new TemporaryRedirectResponseFactory(destination)
}
/**
* Response factory that sends an HTTP redirect to the given destination.
*/
export class TemporaryRedirectResponseFactory extends ResponseFactory {
protected targetStatus: HTTPStatus = HTTPStatus.TEMPORARY_REDIRECT
constructor(
/** THe URL where the client should redirect to. */
public readonly destination: string
) { super() }

@ -3,13 +3,25 @@ import {ResponseFactory} from "./ResponseFactory";
import {Request} from "../lifecycle/Request";
import {ViewEngine} from "../../views/ViewEngine";
/**
* Helper function that creates a new ViewResponseFactory to render the given view
* with the specified data.
* @param name
* @param data
*/
export function view(name: string, data?: {[key: string]: any}): ViewResponseFactory {
return new ViewResponseFactory(name, data)
}
/**
* HTTP response factory that uses the ViewEngine service to render a view
* and send it as HTML.
*/
export class ViewResponseFactory extends ResponseFactory {
constructor(
/** The name of the view to render. */
public readonly viewName: string,
/** Optional data that should be passed to the view engine as params. */
public readonly data?: {[key: string]: any}
) { super() }

@ -1,14 +1,47 @@
import {ErrorWithContext} from "@extollo/util";
import {ResolvedRouteHandler, Route} from "./Route";
/**
* Class representing a resolved route that a request is mounted to.
*/
export class ActivatedRoute {
/**
* The parsed params from the route definition.
*
* @example
* If the route definition is like `/something/something/:paramName1/:paramName2/etc`
* and the request came in on `/something/something/foo/bar/etc`, then the params
* would be:
*
* ```typescript
* {
* paramName1: 'foo',
* paramName2: 'bar',
* }
* ```
*/
public readonly params: {[key: string]: string}
/**
* The resolved function that should handle the request for this route.
*/
public readonly handler: ResolvedRouteHandler
/**
* Pre-middleware that should be applied to the request on this route.
*/
public readonly preflight: ResolvedRouteHandler[]
/**
* Post-middleware that should be applied to the request on this route.
*/
public readonly postflight: ResolvedRouteHandler[]
constructor(
/** The route this ActivatedRoute refers to. */
public readonly route: Route,
/** The request path that activated that route. */
public readonly path: string
) {
const params = route.extract(path)

@ -2,8 +2,12 @@ import {AppClass} from "../../lifecycle/AppClass"
import {Request} from "../lifecycle/Request"
import {ResponseObject} from "./Route"
/**
* Base class representing a middleware handler that can be applied to routes.
*/
export abstract class Middleware extends AppClass {
constructor(
/** The request that will be handled by this middleware. */
protected readonly request: Request
) { super() }
@ -11,6 +15,13 @@ export abstract class Middleware extends AppClass {
return this.request
}
// Return void | Promise<void> to continue request
/**
* Apply the middleware to the request.
* If this returns a response factory or similar item, that will be sent
* as a response.
*
* If this returns `void | Promise<void>`, the request will continue to the
* next handler.
*/
public abstract apply(): ResponseObject
}

@ -12,23 +12,66 @@ import {Middlewares} from "../../service/Middlewares";
import {Middleware} from "./Middleware";
import {Config} from "../../service/Config";
/**
* Type alias for an item that is a valid response object, or lack thereof.
*/
export type ResponseObject = ResponseFactory | string | number | void | any | Promise<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
// TODO domains, named routes - support this on groups as well
/**
* A class that can be used to build and reference dynamic routes in the application.
*
* Routes can be defined in nested groups, with prefixes and middleware handlers.
*
* @example
* ```typescript
* Route.post('/api/v1/ping', (request: Request) => {
* return 'pong!'
* })
*
* Route.group('/api/v2', () => {
* Route.get('/status', 'controller::api:v2:Status.getStatus').pre('auth:UserOnly')
* })
* ```
*/
export class Route extends AppClass {
/** Routes that have been created and registered in the application. */
private static registeredRoutes: Route[] = []
/** Groups of routes that have been registered with the application. */
private static registeredGroups: RouteGroup[] = []
/**
* The current nested group stack. This is used internally when compiling the routes by nested group.
* @private
*/
private static compiledGroupStack: RouteGroup[] = []
/** Register a route group handler. */
public static registerGroup(group: RouteGroup) {
this.registeredGroups.push(group)
}
/**
* Load and compile all of the registered routes and their groups, accounting
* for nested groups and resolving handlers.
*
* This function attempts to resolve the route handlers ahead of time to cache
* them and also expose any handler resolution errors that might happen at runtime.
*/
public static async compile(): Promise<Route[]> {
let registeredRoutes = this.registeredRoutes
const registeredGroups = this.registeredGroups
@ -103,53 +146,87 @@ 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
*/
public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler) {
const route = new Route(method, handler, definition)
this.registeredRoutes.push(route)
return route
}
/**
* Create a new GET route on the given endpoint.
* @param definition
* @param handler
*/
public static get(definition: string, handler: RouteHandler) {
return this.endpoint('get', definition, handler)
}
/** Create a new POST route on the given endpoint. */
public static post(definition: string, handler: RouteHandler) {
return this.endpoint('post', definition, handler)
}
/** Create a new PUT route on the given endpoint. */
public static put(definition: string, handler: RouteHandler) {
return this.endpoint('put', definition, handler)
}
/** Create a new PATCH route on the given endpoint. */
public static patch(definition: string, handler: RouteHandler) {
return this.endpoint('patch', definition, handler)
}
/** Create a new DELETE route on the given endpoint. */
public static delete(definition: string, handler: RouteHandler) {
return this.endpoint('delete', definition, handler)
}
/** Create a new route on all HTTP verbs, on the given endpoint. */
public static any(definition: string, handler: RouteHandler) {
return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler)
}
/** Create a new route group with the given prefix. */
public static group(prefix: string, group: () => void | Promise<void>) {
const grp = <RouteGroup> Application.getApplication().make(RouteGroup, group, prefix)
this.registeredGroups.push(grp)
return grp
}
/** Middlewares that should be applied to this route. */
protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: "pre" | "post"; handler: RouteHandler}>()
/** Pre-compiled route handlers for the pre-middleware for this route. */
protected _compiledPreflight?: ResolvedRouteHandler[]
/** Pre-compiled route handlers for the post-middleware for this route. */
protected _compiledHandler?: ResolvedRouteHandler
/** Pre-compiled route handler for the main route handler for this route. */
protected _compiledPostflight?: ResolvedRouteHandler[]
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() }
/**
* 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
@ -157,6 +234,20 @@ export class Route extends AppClass {
return !!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('/')
@ -192,6 +283,9 @@ 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')
@ -200,6 +294,9 @@ export class Route extends AppClass {
return this._compiledPreflight
}
/**
* Try to pre-compile and return the postflight handlers for this route.
*/
public resolvePostflight(): ResolvedRouteHandler[] {
if ( !this._compiledPostflight ) {
this._compiledPostflight = this.resolveMiddlewareHandlersForStage('post')
@ -208,6 +305,9 @@ export class Route extends AppClass {
return this._compiledPostflight
}
/**
* Try to pre-compile and return the main handler for this route.
*/
public resolveHandler(): ResolvedRouteHandler {
if ( !this._compiledHandler ) {
this._compiledHandler = this._resolveHandler()
@ -216,6 +316,7 @@ export class Route extends AppClass {
return this._compiledHandler
}
/** Register the given middleware as a preflight handler for this route. */
pre(middleware: RouteHandler) {
this.middlewares.push({
stage: 'pre',
@ -225,6 +326,7 @@ export class Route extends AppClass {
return this
}
/** Register the given middleware as a postflight handler for this route. */
post(middleware: RouteHandler) {
this.middlewares.push({
stage: 'post',
@ -234,20 +336,27 @@ export class Route extends AppClass {
return this
}
/** Prefix the route's path with the given prefix, normalizing `/` characters. */
private prepend(prefix: string) {
if ( !prefix.endsWith('/') ) prefix = `${prefix}/`
if ( this.route.startsWith('/') ) this.route = this.route.substring(1)
this.route = `${prefix}${this.route}`
}
/** Add the given middleware item to the beginning of the preflight handlers. */
private prependMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }) {
this.middlewares.prepend(def)
}
/** Add the given middleware item to the end of the postflight handlers. */
private appendMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }) {
this.middlewares.push(def)
}
/**
* Resolve and return the route handler for this route.
* @private
*/
private _resolveHandler(): ResolvedRouteHandler {
if ( typeof this.handler !== 'string' ) {
return (request: Request) => {
@ -289,6 +398,11 @@ export class Route extends AppClass {
}
}
/**
* 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 => {
@ -330,6 +444,7 @@ export class Route extends AppClass {
.toArray()
}
/** Cast the route to an intelligible string. */
toString() {
const method = Array.isArray(this.method) ? this.method : [this.method]
return `${method.join('|')} -> ${this.route}`

@ -4,17 +4,50 @@ import {RouteHandler} from "./Route"
import {Container} from "@extollo/di"
import {Logging} from "../../service/Logging";
/**
* Class that defines a group of Routes in the application, with a prefix.
*/
export class RouteGroup extends AppClass {
/**
* The current set of nested groups. This is used when compiling route groups.
* @private
*/
private static currentGroupNesting: RouteGroup[] = []
/**
* Mapping of group names to group registration functions.
* @protected
*/
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.
*/
public static getCurrentGroupHierarchy(): RouteGroup[] {
return [...this.currentGroupNesting]
}
/**
* Create a new named group that can be registered at a later time, by name.
*
* @example
* ```typescript
* RouteGroup.named('auth', () => {
* Route.group('/auth', () => {
* Route.get('/login', 'auth:Forms.getLogin')
* })
* })
* ```
*
* @param name
* @param define
*/
public static named(name: string, define: () => void) {
if ( this.namedGroups[name] ) {
Container.getContainer()
@ -25,6 +58,17 @@ export class RouteGroup extends AppClass {
this.namedGroups[name] = define
}
/**
* Register the routes from a named group by calling its registration function.
*
* @example
* From the example above, we can register the auth `/auth/*` routes, like so:
* ```typescript
* RouteGroup.include('auth')
* ```
*
* @param name
*/
public static include(name: string) {
if (!this.namedGroups[name]) {
throw new ErrorWithContext(`No route group exists with name: ${name}`, {name})
@ -35,10 +79,14 @@ export class RouteGroup extends AppClass {
constructor(
/** Function to register routes for this group. */
public readonly group: () => void | Promise<void>,
/** The route prefix of this group. */
public readonly prefix: string
) { super() }
/** Register the given middleware to be applied before all routes in this group. */
pre(middleware: RouteHandler) {
this.middlewares.push({
stage: 'pre',
@ -48,6 +96,7 @@ export class RouteGroup extends AppClass {
return this
}
/** Register the given middleware to be applied after all routes in this group. */
post(middleware: RouteHandler) {
this.middlewares.push({
stage: 'post',
@ -57,6 +106,7 @@ export class RouteGroup extends AppClass {
return this
}
/** Return the middlewares that apply to this group. */
getGroupMiddlewareDefinitions() {
return this.middlewares
}

@ -1,10 +1,17 @@
import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from "./Session";
import {Injectable} from "@extollo/di";
/**
* Implementation of the session driver that stores session data in memory.
* This is the default, for compatibility, but it is recommended that you replace
* this driver with one with a persistent backend.
*/
@Injectable()
export class MemorySession extends Session {
/** Mapping of session key to session data object. */
private static sessionsByID: {[key: string]: SessionData} = {}
/** Get a particular session by ID. */
private static getSession(id: string