diff --git a/src/http/Controller.ts b/src/http/Controller.ts index f0aa014..4a62a3c 100644 --- a/src/http/Controller.ts +++ b/src/http/Controller.ts @@ -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 diff --git a/src/http/HTTPError.ts b/src/http/HTTPError.ts index e7af9f8..0d9b86a 100644 --- a/src/http/HTTPError.ts +++ b/src/http/HTTPError.ts @@ -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, diff --git a/src/http/kernel/HTTPCookieJar.ts b/src/http/kernel/HTTPCookieJar.ts index e66def6..b0e33f3 100644 --- a/src/http/kernel/HTTPCookieJar.ts +++ b/src/http/kernel/HTTPCookieJar.ts @@ -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 => { diff --git a/src/http/kernel/HTTPKernel.ts b/src/http/kernel/HTTPKernel.ts index 32408dd..e8763e4 100644 --- a/src/http/kernel/HTTPKernel.ts +++ b/src/http/kernel/HTTPKernel.ts @@ -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) => 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) => 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() diff --git a/src/http/kernel/HTTPKernelModule.ts b/src/http/kernel/HTTPKernelModule.ts index cf42b0e..a679815 100644 --- a/src/http/kernel/HTTPKernelModule.ts +++ b/src/http/kernel/HTTPKernelModule.ts @@ -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 /** diff --git a/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts index ac0e48b..36c019d 100644 --- a/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts @@ -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)) diff --git a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts index f0a341d..3b1fe14 100644 --- a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts @@ -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() diff --git a/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts index 403e23f..9b1abe1 100644 --- a/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule.ts @@ -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) diff --git a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts index b68e407..9c7b3c0 100644 --- a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts @@ -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) diff --git a/src/http/kernel/module/InjectSessionHTTPModule.ts b/src/http/kernel/module/InjectSessionHTTPModule.ts index 413aec6..0e28692 100644 --- a/src/http/kernel/module/InjectSessionHTTPModule.ts +++ b/src/http/kernel/module/InjectSessionHTTPModule.ts @@ -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 diff --git a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts index 486b9e0..31358c8 100644 --- a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts +++ b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts @@ -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 diff --git a/src/http/kernel/module/PersistSessionHTTPModule.ts b/src/http/kernel/module/PersistSessionHTTPModule.ts index f97ba38..6eacb9f 100644 --- a/src/http/kernel/module/PersistSessionHTTPModule.ts +++ b/src/http/kernel/module/PersistSessionHTTPModule.ts @@ -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 diff --git a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts index 2518476..3bdae5c 100644 --- a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts +++ b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts @@ -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 diff --git a/src/http/kernel/module/SetSessionCookieHTTPModule.ts b/src/http/kernel/module/SetSessionCookieHTTPModule.ts index 7aedb54..8fe33c5 100644 --- a/src/http/kernel/module/SetSessionCookieHTTPModule.ts +++ b/src/http/kernel/module/SetSessionCookieHTTPModule.ts @@ -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 diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index e1b850a..86ee1e1 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -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('*/*') diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index b4685b3..d22ecd4 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -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 = new BehaviorSubject() + + /** + * Behavior subject fired right after the response content is written. + */ public readonly sent$: BehaviorSubject = new BehaviorSubject() 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((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 diff --git a/src/http/response/DehydratedStateResponseFactory.ts b/src/http/response/DehydratedStateResponseFactory.ts index 9318ae5..37ea7bc 100644 --- a/src/http/response/DehydratedStateResponseFactory.ts +++ b/src/http/response/DehydratedStateResponseFactory.ts @@ -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 diff --git a/src/http/response/ErrorResponseFactory.ts b/src/http/response/ErrorResponseFactory.ts index d21c184..c09c49b 100644 --- a/src/http/response/ErrorResponseFactory.ts +++ b/src/http/response/ErrorResponseFactory.ts @@ -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' diff --git a/src/http/response/HTMLResponseFactory.ts b/src/http/response/HTMLResponseFactory.ts index 51e55ce..6141ff2 100644 --- a/src/http/response/HTMLResponseFactory.ts +++ b/src/http/response/HTMLResponseFactory.ts @@ -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, diff --git a/src/http/response/HTTPErrorResponseFactory.ts b/src/http/response/HTTPErrorResponseFactory.ts index 76a9060..5b65aa5 100644 --- a/src/http/response/HTTPErrorResponseFactory.ts +++ b/src/http/response/HTTPErrorResponseFactory.ts @@ -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, diff --git a/src/http/response/JSONResponseFactory.ts b/src/http/response/JSONResponseFactory.ts index db4a300..b9411ad 100644 --- a/src/http/response/JSONResponseFactory.ts +++ b/src/http/response/JSONResponseFactory.ts @@ -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 diff --git a/src/http/response/ResponseFactory.ts b/src/http/response/ResponseFactory.ts index 7d10b07..95d81e7 100644 --- a/src/http/response/ResponseFactory.ts +++ b/src/http/response/ResponseFactory.ts @@ -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.response.setStatus(this.targetStatus) return request } + /** Set the target status of this factory. */ public status(status: HTTPStatus) { this.targetStatus = status return this diff --git a/src/http/response/StringResponseFactory.ts b/src/http/response/StringResponseFactory.ts index ca54d98..f5ddc69 100644 --- a/src/http/response/StringResponseFactory.ts +++ b/src/http/response/StringResponseFactory.ts @@ -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() } diff --git a/src/http/response/TemporaryRedirectResponseFactory.ts b/src/http/response/TemporaryRedirectResponseFactory.ts index 59140f8..9a60087 100644 --- a/src/http/response/TemporaryRedirectResponseFactory.ts +++ b/src/http/response/TemporaryRedirectResponseFactory.ts @@ -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() } diff --git a/src/http/response/ViewResponseFactory.ts b/src/http/response/ViewResponseFactory.ts index f9e72a4..5552a99 100644 --- a/src/http/response/ViewResponseFactory.ts +++ b/src/http/response/ViewResponseFactory.ts @@ -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() } diff --git a/src/http/routing/ActivatedRoute.ts b/src/http/routing/ActivatedRoute.ts index 9fb9e12..cc94c6c 100644 --- a/src/http/routing/ActivatedRoute.ts +++ b/src/http/routing/ActivatedRoute.ts @@ -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) diff --git a/src/http/routing/Middleware.ts b/src/http/routing/Middleware.ts index b42f7cf..0f6de2e 100644 --- a/src/http/routing/Middleware.ts +++ b/src/http/routing/Middleware.ts @@ -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 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`, the request will continue to the + * next handler. + */ public abstract apply(): ResponseObject } diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 7a633c5..37aafd4 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -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 + +/** + * 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 { 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) { const grp = 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(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}` diff --git a/src/http/routing/RouteGroup.ts b/src/http/routing/RouteGroup.ts index 19a0d0d..b5ab5f4 100644 --- a/src/http/routing/RouteGroup.ts +++ b/src/http/routing/RouteGroup.ts @@ -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, + + /** 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 } diff --git a/src/http/session/MemorySession.ts b/src/http/session/MemorySession.ts index 63dbb17..5fcb6ad 100644 --- a/src/http/session/MemorySession.ts +++ b/src/http/session/MemorySession.ts @@ -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) { if ( !this.sessionsByID[id] ) { this.sessionsByID[id] = {} as SessionData @@ -13,11 +20,15 @@ export class MemorySession extends Session { return this.sessionsByID[id] } + /** Store the given session data by its ID. */ private static setSession(id: string, data: SessionData) { this.sessionsByID[id] = data } + /** The ID of this session. */ protected sessionID?: string + + /** The associated data for this session. */ protected data?: SessionData constructor() { super() } diff --git a/src/http/session/Session.ts b/src/http/session/Session.ts index 2c3c41d..ec00b1e 100644 --- a/src/http/session/Session.ts +++ b/src/http/session/Session.ts @@ -2,38 +2,59 @@ import {Injectable, Inject} from "@extollo/di" import {ErrorWithContext} from "@extollo/util" import {Request} from "../lifecycle/Request" +/** + * Type alias describing some inflated session data. + */ export type SessionData = {[key: string]: any} +/** + * Error thrown when a session is requested for a key that does not exist. + */ export class NoSessionKeyError extends ErrorWithContext { constructor() { super('No session ID has been set.') } } +/** + * Error thrown when a session operation is performed before the session has been loaded. + */ export class SessionNotLoadedError extends ErrorWithContext { constructor() { super('Cannot access session data; data is not loaded.') } } +/** + * An abstract class representing a session driver. + * Some implementation of this is injected into the request. + */ @Injectable() export abstract class Session { @Inject() protected readonly request!: Request + /** Get the unique key of this session. */ public abstract getKey(): string + /** Set a unique key of this session. */ public abstract setKey(key: string): void + /** Load the session data from the respective backend. */ public abstract load(): void | Promise + /** Save the session data into the respective backend. */ public abstract persist(): void | Promise + /** Get the loaded session data as an object. */ public abstract getData(): SessionData + /** Bulk set an object as the session data. */ public abstract setData(data: SessionData): void + /** Get a value from the session by key. */ public abstract get(key: string, fallback?: any): any + /** Set a value in the session by key. */ public abstract set(key: string, value: any): void } diff --git a/src/http/session/SessionFactory.ts b/src/http/session/SessionFactory.ts index 009280e..a5348ff 100644 --- a/src/http/session/SessionFactory.ts +++ b/src/http/session/SessionFactory.ts @@ -13,10 +13,15 @@ import {Session} from "./Session"; import {Logging} from "../../service/Logging"; import {Config} from "../../service/Config"; +/** + * A dependency injection factory that matches the abstract Session class + * and produces an instance of the configured session driver implementation. + */ export class SessionFactory extends AbstractFactory { protected readonly logging: Logging protected readonly config: Config + /** True if we have printed the memory session warning at least once. */ private static loggedMemorySessionWarningOnce = false constructor() { @@ -52,6 +57,11 @@ export class SessionFactory extends AbstractFactory { return meta } + /** + * Return the instantiable class of the configured session backend. + * @protected + * @return Instantiable + */ protected getSessionClass() { const SessionClass = this.config.get('server.session.driver', MemorySession) if ( SessionClass === MemorySession && !SessionFactory.loggedMemorySessionWarningOnce ) { diff --git a/src/lifecycle/AppClass.ts b/src/lifecycle/AppClass.ts index 5f4f769..22b8dae 100644 --- a/src/lifecycle/AppClass.ts +++ b/src/lifecycle/AppClass.ts @@ -22,21 +22,28 @@ export function isBindable(what: any): what is Bindable { ) } +/** + * Base for classes that gives access to the global application and container. + */ export class AppClass { + /** The global application instance. */ private readonly appClassApplication!: Application; constructor() { this.appClassApplication = Application.getApplication(); } + /** Get the global Application. */ protected app(): Application { return this.appClassApplication; } + /** Get the global Container. */ protected container(): Container { return this.appClassApplication; } + /** Call the `make()` method on the global container. */ protected make(target: DependencyKey, ...parameters: any[]): T { return this.container().make(target, ...parameters) } diff --git a/src/lifecycle/Application.ts b/src/lifecycle/Application.ts index 8e1d03e..8f49356 100644 --- a/src/lifecycle/Application.ts +++ b/src/lifecycle/Application.ts @@ -16,10 +16,21 @@ import {Unit, UnitStatus} from "./Unit"; import * as dotenv from 'dotenv'; import {CacheFactory} from "../support/cache/CacheFactory"; +/** + * Helper function that resolves and infers environment variable values. + * + * If none is found, returns `defaultValue`. + * + * @param key + * @param defaultValue + */ export function env(key: string, defaultValue?: any): any { return Application.getApplication().env(key, defaultValue) } +/** + * The main application container. + */ export class Application extends Container { public static getContainer(): Container { const existing = globalRegistry.getGlobal('extollo/injector') @@ -32,6 +43,9 @@ export class Application extends Container { return existing as Container } + /** + * Get the global application instance. + */ public static getApplication(): Application { const existing = globalRegistry.getGlobal('extollo/injector') if ( existing instanceof Application ) { @@ -49,11 +63,34 @@ export class Application extends Container { } } + /** + * The fully-qualified path to the base directory of the app. + * @protected + */ protected baseDir!: string + + /** + * Resolved universal path to the base directory of the app. + * @protected + */ protected basePath!: UniversalPath + + /** + * The Unit classes registered with the app. + * @protected + */ protected applicationUnits: (typeof Unit)[] = [] + + /** + * Instances of the units registered with this app. + * @protected + */ protected instantiatedUnits: Unit[] = [] + /** + * If true, the "Starting Extollo..." messages will always + * be logged. + */ public forceStartupMessage: boolean = true constructor() { @@ -72,36 +109,67 @@ export class Application extends Container { } } + /** + * Returns true if the given unit class is registered with the application. + * @param unitClass + */ public hasUnit(unitClass: typeof Unit) { return this.applicationUnits.includes(unitClass) } + /** + * Return a UniversalPath to the root of the application. + */ get root() { return this.basePath.concat() } + /** + * Returns a UniversalPath to the `app/` directory in the application. + */ get appRoot() { return this.basePath.concat('app') } + /** + * Resolve a path relative to the root of the application. + * @param parts + */ path(...parts: PathLike[]) { return this.basePath.concat(...parts) } + /** + * Resolve a path relative to the `app/` directory in the application. + * @param parts + */ appPath(...parts: PathLike[]) { return this.basePath.concat('app', ...parts) } + /** + * Get an instance of the RunLevelErrorHandler. + */ get errorHandler() { const rleh: RunLevelErrorHandler = this.make(RunLevelErrorHandler) return rleh.handle } + /** + * Wrap a base Error instance into an ErrorWithContext. + * @param e + * @param context + */ errorWrapContext(e: Error, context: {[key: string]: any}): ErrorWithContext { const rleh: RunLevelErrorHandler = this.make(RunLevelErrorHandler) return rleh.wrapContext(e, context) } + /** + * Set up the bare essentials to get the application up and running. + * @param absolutePathToApplicationRoot + * @param applicationUnits + */ scaffold(absolutePathToApplicationRoot: string, applicationUnits: (typeof Unit)[]) { this.baseDir = absolutePathToApplicationRoot this.basePath = universalPath(absolutePathToApplicationRoot) @@ -115,6 +183,10 @@ export class Application extends Container { this.make(Logging).debug(`Application root: ${this.baseDir}`) } + /** + * Initialize the logger and load the logging level from the environment. + * @protected + */ protected setupLogging() { const standard: StandardLogger = this.make(StandardLogger) const logging: Logging = this.make(Logging) @@ -134,16 +206,29 @@ export class Application extends Container { } catch(e) {} } + /** + * Initialize the environment variable library and read from the `.env` file. + * @protected + */ protected bootstrapEnvironment() { dotenv.config({ path: this.basePath.concat('.env').toLocal }) } + /** + * Get a value from the loaded environment variables. + * If no value could be found, the default value will be returned. + * @param key + * @param defaultValue + */ public env(key: string, defaultValue?: any): any { return infer(process.env[key] ?? '') ?? defaultValue } + /** + * Run the application by starting all units in order, then stopping them in reverse order. + */ async run() { try { await this.up() @@ -153,6 +238,9 @@ export class Application extends Container { } } + /** + * Start all units in the application, one at a time, in order. + */ async up() { const logging: Logging = this.make(Logging) @@ -164,6 +252,9 @@ export class Application extends Container { } } + /** + * Stop all units in the application, one at a time, in reverse order. + */ async down() { const logging: Logging = this.make(Logging) @@ -174,6 +265,10 @@ export class Application extends Container { } } + /** + * Start a single unit, setting its status. + * @param unit + */ public async startUnit(unit: Unit) { const logging: Logging = this.make(Logging) @@ -190,6 +285,10 @@ export class Application extends Container { } } + /** + * Stop a single unit, setting its status. + * @param unit + */ public async stopUnit(unit: Unit) { const logging: Logging = this.make(Logging) diff --git a/src/lifecycle/RunLevelErrorHandler.ts b/src/lifecycle/RunLevelErrorHandler.ts index 16f98b4..40170f6 100644 --- a/src/lifecycle/RunLevelErrorHandler.ts +++ b/src/lifecycle/RunLevelErrorHandler.ts @@ -3,6 +3,11 @@ import {Logging} from "../service/Logging"; import {Inject} from "@extollo/di"; import {ErrorWithContext} from "@extollo/util"; +/** + * Class with logic for handling errors that are thrown at the run-level of the application. + * + * Colloquially, these are errors thrown ourside the request-lifecycle that are not caught by a unit. + */ export class RunLevelErrorHandler { @Inject() protected logging!: Logging @@ -18,6 +23,11 @@ export class RunLevelErrorHandler { } } + /** + * Wrap the given base Error instance into an ErrorWithContext. + * @param e + * @param context + */ wrapContext(e: Error, context: {[key: string]: any}): ErrorWithContext { if ( e instanceof ErrorWithContext ) { e.context = {...e.context, ...context} diff --git a/src/lifecycle/Unit.ts b/src/lifecycle/Unit.ts index f1942e0..8b4a208 100644 --- a/src/lifecycle/Unit.ts +++ b/src/lifecycle/Unit.ts @@ -1,5 +1,8 @@ import {AppClass} from './AppClass'; +/** + * The various statuses of a Unit. + */ export enum UnitStatus { Starting, Started, @@ -8,8 +11,26 @@ export enum UnitStatus { Error, } +/** + * Base class for a service that can be registered with the application + * that is started and stopped during the application lifecycle. + */ export abstract class Unit extends AppClass { + /** The current status of the unit. */ public status: UnitStatus = UnitStatus.Stopped + + /** + * This method is called to start the unit when the application is booting. + * Here, you should do any setup required to get the package up and running. + */ public up(): Promise | void {} + + /** + * This method is called to stop the unit when the application is shutting down. + * Here, you should do any teardown required to stop the package cleanly. + * + * IN PARTICULAR take care to free blocking resources that could prevent the + * process from exiting without a kill. + */ public down(): Promise | void {} } diff --git a/src/service/Canonical.ts b/src/service/Canonical.ts index f0f3868..a32c8fb 100644 --- a/src/service/Canonical.ts +++ b/src/service/Canonical.ts @@ -8,12 +8,18 @@ import {Inject} from "@extollo/di"; import * as nodePath from 'path' import {Unit} from "../lifecycle/Unit"; +/** + * Interface describing a definition of a single canonical item loaded from the app. + */ export interface CanonicalDefinition { canonicalName: string, originalName: string, imported: any, } +/** + * Type alias for a function that resolves a canonical name to a canonical item, if one exists. + */ export type CanonicalResolver = (key: string) => T | undefined /** @@ -25,6 +31,19 @@ export interface CanonicalReference { particular?: string, } +/** + * Abstract unit type that loads items recursively from a directory structure, assigning + * them normalized names ("canonical names"), and providing a way to fetch the resources + * by name. + * + * @example + * The Config service is a Canonical derivative that loads files ending with `.config.js` + * from the `app/config` directory. + * + * If, for example, there is a config file `app/config/auth/Forms.config.js` (in the + * generated code), it can be loaded by the canonical name `auth:Forms`. + * + */ export abstract class Canonical extends Unit { @Inject() protected readonly logging!: Logging @@ -81,18 +100,26 @@ export abstract class Canonical extends Unit { } } + /** + * Return an array of all loaded canonical names. + */ public all(): string[] { return Object.keys(this.loadedItems) } + /** + * Get a Universal path to the base directory where this unit loads its canonical files from. + */ public get path(): UniversalPath { return this.app().appPath(...this.appPath) } + /** Get the plural name of the canonical items provided by this unit. */ public get canonicalItems() { return `${this.canonicalItem}s` } + /** Get a canonical item by key. */ public get(key: string): T | undefined { if ( key.startsWith('@') ) { const [namespace, ...rest] = key.split(':') @@ -112,6 +139,34 @@ export abstract class Canonical extends Unit { return this.loadedItems[key] } + /** + * Register a namespace resolver with the canonical unit. + * + * Namespaces are canonical names that start with a particular key, beginning with the `@` character, + * which resolve their resources using a resolver function. + * + * @example + * ```typescript + * const items = { + * 'foo:bar': 123, + * 'bob': 456, + * } + * + * const resolver = (key: string) => items[key] + * + * canonical.registerNamespace('@mynamespace', resolver) + * ``` + * + * Now, the items in the `@mynamespace` namespace can be accessed like so: + * + * ```typescript + * canonical.get('@mynamespace:foo:bar') // => 123 + * canonical.get('@mynamespace:bob') // => 456 + * ``` + * + * @param name + * @param resolver + */ public registerNamespace(name: string, resolver: CanonicalResolver) { if ( !name.startsWith('@') ) { throw new ErrorWithContext(`Canonical namespaces must start with @.`, { name }) @@ -139,10 +194,20 @@ export abstract class Canonical extends Unit { this.canon.registerCanonical(this) } + /** + * Called for each canonical item loaded from a file. This function should do any setup necessary and return the item + * that should be associated with the canonical name. + * @param definition + */ public async initCanonicalItem(definition: CanonicalDefinition): Promise { return definition.imported.default ?? definition.imported[definition.canonicalName.split(':').reverse()[0]] } + /** + * Given the path to a file in the canonical items directory, create a CanonicalDefinition record from that file. + * @param filePath + * @protected + */ protected async buildCanonicalDefinition(filePath: string): Promise { const originalName = filePath.replace(this.path.toLocal, '').substr(1) const pathRegex = new RegExp(nodePath.sep, 'g') diff --git a/src/service/CanonicalInstantiable.ts b/src/service/CanonicalInstantiable.ts index d887914..103da41 100644 --- a/src/service/CanonicalInstantiable.ts +++ b/src/service/CanonicalInstantiable.ts @@ -5,12 +5,18 @@ import {Canonical, CanonicalDefinition} from "./Canonical"; import {Instantiable, isInstantiable} from "@extollo/di"; +/** + * Error thrown when the export of a canonical file is determined to be invalid. + */ export class InvalidCanonicalExportError extends Error { constructor(name: string) { super(`Unable to import canonical item from "${name}". The default export of this file is invalid.`) } } +/** + * Variant of the Canonical unit whose files export classes which are instantiated using the global container. + */ export class CanonicalInstantiable extends Canonical> { public async initCanonicalItem(definition: CanonicalDefinition): Promise> { if ( isInstantiable(definition.imported.default) ) { @@ -23,4 +29,4 @@ export class CanonicalInstantiable extends Canonical> { throw new InvalidCanonicalExportError(definition.originalName) } -} \ No newline at end of file +} diff --git a/src/service/CanonicalRecursive.ts b/src/service/CanonicalRecursive.ts index a0d62e7..70b1c56 100644 --- a/src/service/CanonicalRecursive.ts +++ b/src/service/CanonicalRecursive.ts @@ -1,5 +1,27 @@ import {Canonical} from "./Canonical"; +/** + * Variant of the Canonical unit whose accessor allows accessing nested + * properties on the resolved objects. + * + * @example + * The Config unit is a CanonicalRecursive unit. So, once a config file is + * resolved, a particular value in the config file can be retrieved as well: + * + * ```typescript + * // app/config/my/config.config.ts + * { + * foo: { + * bar: 123 + * } + * } + * ``` + * + * This can be accessed as: + * ```typescript + * config.get('my:config.foo.bar') // => 123 + * ``` + */ export class CanonicalRecursive extends Canonical { public get(key: string, fallback?: any): any | undefined { const parts = key.split('.') diff --git a/src/service/CanonicalStatic.ts b/src/service/CanonicalStatic.ts index ffbfaed..2497fd0 100644 --- a/src/service/CanonicalStatic.ts +++ b/src/service/CanonicalStatic.ts @@ -2,6 +2,14 @@ import {Canonical, CanonicalDefinition} from "./Canonical"; import {isStaticClass, StaticClass} from "@extollo/di"; import {InvalidCanonicalExportError} from "./CanonicalInstantiable"; +/** + * Variant of the Canonical unit whose files export static classes, and these static classes + * are the exports of the class. + * + * @example + * The Controllers class is CanonicalStatic. The various `.controller.ts` files export static + * Controller classes, so the canonical items managed by the Controllers service are `Instantiable`. + */ export class CanonicalStatic extends Canonical> { public async initCanonicalItem(definition: CanonicalDefinition): Promise> { if ( isStaticClass(definition.imported.default) ) { diff --git a/src/service/Config.ts b/src/service/Config.ts index 1d8baf8..a9aa834 100644 --- a/src/service/Config.ts +++ b/src/service/Config.ts @@ -2,6 +2,9 @@ import {Singleton, Inject} from "@extollo/di"; import {CanonicalRecursive} from "./CanonicalRecursive"; import {Logging} from "./Logging"; +/** + * Canonical unit that loads configuration files from `app/configs`. + */ @Singleton() export class Config extends CanonicalRecursive { @Inject() @@ -10,7 +13,11 @@ export class Config extends CanonicalRecursive { protected appPath: string[] = ['configs'] protected suffix: string = '.config.js' protected canonicalItem: string = 'config' + + /** If true, all the unique configuration keys will be stored for debugging. */ protected recordConfigAccesses: boolean = false + + /** Array of all unique accessed config keys, if `recordConfigAccesses` is true. */ protected accessedKeys: string[] = [] public async up() { diff --git a/src/service/Controllers.ts b/src/service/Controllers.ts index bd76d19..71771a3 100644 --- a/src/service/Controllers.ts +++ b/src/service/Controllers.ts @@ -3,6 +3,9 @@ import {Singleton, Instantiable} from "@extollo/di"; import {Controller} from "../http/Controller"; import {CanonicalDefinition} from "./Canonical"; +/** + * A canonical unit that loads the controller classes from `app/http/controllers`. + */ @Singleton() export class Controllers extends CanonicalStatic, Controller> { protected appPath = ['http', 'controllers'] diff --git a/src/service/FakeCanonical.ts b/src/service/FakeCanonical.ts index 089b962..63537f8 100644 --- a/src/service/FakeCanonical.ts +++ b/src/service/FakeCanonical.ts @@ -1,5 +1,9 @@ import {Canonical} from "./Canonical"; +/** + * Canonical class used for faking canonical units. Here, the canonical resolver + * is registered with the global service, but no files are loaded from the filesystem. + */ export class FakeCanonical extends Canonical { public async up() { this.canon.registerCanonical(this) diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index d950c9b..b560e8a 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -15,6 +15,10 @@ import {error} from "../http/response/ErrorResponseFactory"; import {ExecuteResolvedRoutePreflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule"; import {ExecuteResolvedRoutePostflightHTTPModule} from "../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule"; +/** + * Application unit that starts the HTTP/S server, creates Request and Response objects + * for it, and handles those requests using the HTTPKernel. + */ @Singleton() export class HTTPServer extends Unit { @Inject() @@ -23,6 +27,7 @@ export class HTTPServer extends Unit { @Inject() protected readonly kernel!: HTTPKernel + /** The underlying native Node.js server. */ protected server?: Server public async up() { diff --git a/src/service/Logging.ts b/src/service/Logging.ts index 598c28e..c1d586a 100644 --- a/src/service/Logging.ts +++ b/src/service/Logging.ts @@ -1,53 +1,120 @@ import {Logger, LoggingLevel, LogMessage} from "@extollo/util"; import {Singleton} from "@extollo/di"; +/** + * A singleton service that manages loggers registered in the application, and + * can be used to log output to all of them based on the configured logging level. + * + * This should be used in place of `console.log` as it also supports logging to + * external locations. + * + * @example + * ```typescript + * logging.info('Info level!') + * logging.debug('Some debugging information...') + * logging.warn('A warning!', true) // true, to force it to show, regardless of logging level. + * ``` + */ @Singleton() export class Logging { + /** Array of Logger implementations that should be logged to. */ protected registeredLoggers: Logger[] = [] + + /** The currently configured logging level. */ protected currentLevel: LoggingLevel = LoggingLevel.Warning + /** Register a Logger implementation with this service. */ public registerLogger(logger: Logger) { if ( !this.registeredLoggers.includes(logger) ) { this.registeredLoggers.push(logger) } } + /** + * Remove a Logger implementation from this service, if it is registered. + * @param logger + */ public unregisterLogger(logger: Logger) { this.registeredLoggers = this.registeredLoggers.filter(x => x !== logger) } + /** + * Get the current logging level. + */ public get level(): LoggingLevel { return this.currentLevel } + /** + * Set the current logging level. + * @param level + */ public set level(level: LoggingLevel) { this.currentLevel = level } + /** + * Write a success-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public success(output: any, force = false) { this.writeLog(LoggingLevel.Success, output, force) } + /** + * Write an error-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public error(output: any, force = false) { this.writeLog(LoggingLevel.Error, output, force) } + /** + * Write a warning-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public warn(output: any, force = false) { this.writeLog(LoggingLevel.Warning, output, force) } + /** + * Write an info-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public info(output: any, force = false) { this.writeLog(LoggingLevel.Info, output, force) } + /** + * Write a debugging-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public debug(output: any, force = false) { this.writeLog(LoggingLevel.Debug, output, force) } + /** + * Write a verbose-level output to the logs. + * @param output + * @param force - if true, output even if outside the current logging level + */ public verbose(output: any, force = false) { this.writeLog(LoggingLevel.Verbose, output, force) } + /** + * Helper function to write the given output, at the given logging level, to + * all of the registered loggers. + * @param level + * @param output + * @param force - if true, output even if outside the current logging level + * @protected + */ protected writeLog(level: LoggingLevel, output: any, force = false) { const message = this.buildMessage(level, output) if ( this.currentLevel >= level || force ) { @@ -61,6 +128,12 @@ export class Logging { } } + /** + * Given a level and output item, build a formatted LogMessage with date and caller. + * @param level + * @param output + * @protected + */ protected buildMessage(level: LoggingLevel, output: any): LogMessage { return { level, @@ -70,6 +143,11 @@ export class Logging { } } + /** + * Get the name of the object that called the log method using error traces. + * @param level + * @protected + */ protected getCallerInfo(level = 5): string { const e = new Error() if ( !e.stack ) return 'Unknown' diff --git a/src/service/Middlewares.ts b/src/service/Middlewares.ts index f103efb..da2fd62 100644 --- a/src/service/Middlewares.ts +++ b/src/service/Middlewares.ts @@ -3,6 +3,9 @@ import {Singleton, Instantiable} from "@extollo/di"; import {CanonicalDefinition} from "./Canonical"; import {Middleware} from "../http/routing/Middleware"; +/** + * A canonical unit that loads the middleware classes from `app/http/middlewares`. + */ @Singleton() export class Middlewares extends CanonicalStatic, Middleware> { protected appPath = ['http', 'middlewares'] diff --git a/src/service/Routing.ts b/src/service/Routing.ts index b28c28d..7310535 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -6,6 +6,9 @@ import {Route} from "../http/routing/Route"; import {HTTPMethod} from "../http/lifecycle/Request"; import {ViewEngineFactory} from "../views/ViewEngineFactory"; +/** + * Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers. + */ @Singleton() export class Routing extends Unit { @Inject() @@ -35,12 +38,21 @@ export class Routing extends Unit { }) } + /** + * Given an HTTPMethod and route path, return the Route instance that matches them, + * if one exists. + * @param method + * @param path + */ public match(method: HTTPMethod, path: string): Route | undefined { return this.compiledRoutes.firstWhere(route => { return route.match(method, path) }) } + /** + * Get the universal path to the root directory of the route definitions. + */ public get path(): UniversalPath { return this.app().appPath('http', 'routes') } diff --git a/src/support/cache/CacheFactory.ts b/src/support/cache/CacheFactory.ts index da61609..0a6cb18 100644 --- a/src/support/cache/CacheFactory.ts +++ b/src/support/cache/CacheFactory.ts @@ -13,10 +13,15 @@ import {Config} from "../../service/Config"; import {Cache} from "./Cache" import {MemoryCache} from "./MemoryCache"; +/** + * Dependency container factory that matches the abstract Cache token, but + * produces an instance of whatever Cache driver is configured in the `server.cache.driver` config. + */ export class CacheFactory extends AbstractFactory { protected readonly logging: Logging protected readonly config: Config + /** true if we have printed the memory-based cache driver warning once. */ private static loggedMemoryCacheWarningOnce = false constructor() { @@ -52,6 +57,10 @@ export class CacheFactory extends AbstractFactory { return meta } + /** + * Get the configured cache driver and return some Instantiable. + * @protected + */ protected getCacheClass() { const CacheClass = this.config.get('server.cache.driver', MemoryCache) if ( CacheClass === MemoryCache && !CacheFactory.loggedMemoryCacheWarningOnce ) { diff --git a/src/support/cache/MemoryCache.ts b/src/support/cache/MemoryCache.ts index f175810..b7be709 100644 --- a/src/support/cache/MemoryCache.ts +++ b/src/support/cache/MemoryCache.ts @@ -1,7 +1,12 @@ import {Collection} from "@extollo/util" import {Cache} from "./Cache" +/** + * An in-memory implementation of the Cache. + * This is the default implementation for compatibility, but applications should switch to a persistent-backed cache driver. + */ export class MemoryCache extends Cache { + /** Static collection of in-memory cache items. */ private static cacheItems: Collection<{key: string, value: string, expires?: Date}> = new Collection<{key: string; value: string, expires?: Date}>() public fetch(key: string): string | Promise | undefined { diff --git a/src/views/PugViewEngine.ts b/src/views/PugViewEngine.ts index 8b8c1d7..7a6c1c2 100644 --- a/src/views/PugViewEngine.ts +++ b/src/views/PugViewEngine.ts @@ -2,8 +2,12 @@ import {ViewEngine} from "./ViewEngine" import {Injectable} from "@extollo/di" import * as pug from "pug" +/** + * Implementation of the ViewEngine class that renders Pug/Jade templates. + */ @Injectable() export class PugViewEngine extends ViewEngine { + /** A cache of compiled templates. */ protected compileCache: {[key: string]: ((locals?: pug.LocalsObject) => string)} = {} public renderString(templateString: string, locals: { [p: string]: any }): string | Promise { @@ -22,6 +26,10 @@ export class PugViewEngine extends ViewEngine { return compiled(locals) } + /** + * Get the object of options passed to Pug's compile methods. + * @protected + */ protected getOptions() { return { basedir: this.path.toLocal, diff --git a/src/views/ViewEngine.ts b/src/views/ViewEngine.ts index 9e13320..42e0e2e 100644 --- a/src/views/ViewEngine.ts +++ b/src/views/ViewEngine.ts @@ -3,6 +3,9 @@ import {Config} from "../service/Config" import {Container} from "@extollo/di" import {UniversalPath} from "@extollo/util" +/** + * Abstract base class for rendering views via different view engines. + */ export abstract class ViewEngine extends AppClass { protected readonly config: Config protected readonly debug: boolean @@ -14,10 +17,24 @@ export abstract class ViewEngine extends AppClass { || this.config.get('server.debug', false)) } + /** + * Get the UniversalPath to the base directory where views are loaded from. + */ public get path(): UniversalPath { return this.app().appPath(...['resources', 'views']) // FIXME allow configuring } + /** + * Given a template string and a set of variables for the view, render the string to HTML and return it. + * @param templateString + * @param locals + */ public abstract renderString(templateString: string, locals: {[key: string]: any}): string | Promise + + /** + * Given the canonical name of a template file, render the file using the provided variables. + * @param templateName + * @param locals + */ public abstract renderByName(templateName: string, locals: {[key: string]: any}): string | Promise } diff --git a/src/views/ViewEngineFactory.ts b/src/views/ViewEngineFactory.ts index a70a7f9..dd0f659 100644 --- a/src/views/ViewEngineFactory.ts +++ b/src/views/ViewEngineFactory.ts @@ -13,6 +13,10 @@ import {Config} from "../service/Config"; import {ViewEngine} from "./ViewEngine"; import {PugViewEngine} from "./PugViewEngine"; +/** + * Dependency factory whose token matches the abstract ViewEngine class, but produces + * a particular ViewEngine implementation based on the configuration. + */ export class ViewEngineFactory extends AbstractFactory { protected readonly logging: Logging protected readonly config: Config @@ -50,6 +54,10 @@ export class ViewEngineFactory extends AbstractFactory { return meta } + /** + * Using the config, get the implementation of the ViewEngine that should be used in the application. + * @protected + */ protected getViewEngineClass() { const ViewEngineClass = this.config.get('server.view_engine.driver', PugViewEngine)