From a9ffa771dc13bc38b0b38862a35dd6295eca8e35 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Mon, 8 Mar 2021 10:07:10 -0600 Subject: [PATCH] Add basic response factories and helpers --- package-lock.json | 10 +++ package.json | 2 + src/http/HTTPError.ts | 11 +++ src/http/lifecycle/Request.ts | 38 ++++++-- .../DehydratedStateResponseFactory.ts | 20 +++++ src/http/response/ErrorResponseFactory.ts | 90 +++++++++++++++++++ src/http/response/HTMLResponseFactory.ts | 19 ++++ src/http/response/HTTPErrorResponseFactory.ts | 16 ++++ src/http/response/JSONResponseFactory.ts | 19 ++++ src/http/response/ResponseFactory.ts | 17 ++++ src/http/response/StringResponseFactory.ts | 19 ++++ .../TemporaryRedirectResponseFactory.ts | 21 +++++ src/http/response/api.ts | 76 ++++++++++++++++ src/index.ts | 13 +++ src/service/HTTPServer.ts | 4 +- 15 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 src/http/HTTPError.ts create mode 100644 src/http/response/DehydratedStateResponseFactory.ts create mode 100644 src/http/response/ErrorResponseFactory.ts create mode 100644 src/http/response/HTMLResponseFactory.ts create mode 100644 src/http/response/HTTPErrorResponseFactory.ts create mode 100644 src/http/response/JSONResponseFactory.ts create mode 100644 src/http/response/ResponseFactory.ts create mode 100644 src/http/response/StringResponseFactory.ts create mode 100644 src/http/response/TemporaryRedirectResponseFactory.ts create mode 100644 src/http/response/api.ts diff --git a/package-lock.json b/package-lock.json index 370f9c8..f899114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,11 +98,21 @@ } } }, + "@types/negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha512-c4mvXFByghezQ/eVGN5HvH/jI63vm3B7FiE81BUzDAWmuiohRecCO6ddU60dfq29oKUMiQujsoB2h0JQC7JHKA==" + }, "dotenv": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, "typescript": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", diff --git a/package.json b/package.json index 0904b62..92729d3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "dependencies": { "@extollo/di": "file:../di", "@extollo/util": "file:../util", + "@types/negotiator": "^0.6.1", "dotenv": "^8.2.0", + "negotiator": "^0.6.2", "typescript": "^4.1.3" }, "devDependencies": {}, diff --git a/src/http/HTTPError.ts b/src/http/HTTPError.ts new file mode 100644 index 0000000..77a4afe --- /dev/null +++ b/src/http/HTTPError.ts @@ -0,0 +1,11 @@ +import {ErrorWithContext, HTTPStatus, HTTPMessage} from "@extollo/util" + +export class HTTPError extends ErrorWithContext { + constructor( + public readonly status: HTTPStatus = 500, + public readonly message: string = '' + ) { + super(message || HTTPMessage[status]) + this.message = message || HTTPMessage[status] + } +} diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index c03f293..e1b850a 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -5,7 +5,7 @@ import {HTTPCookieJar} from "../kernel/HTTPCookieJar"; import {TLSSocket} from "tls"; import * as url from "url"; import {Response} from "./Response"; -import {ActivatedRoute} from "../routing/ActivatedRoute"; +import * as Negotiator from "negotiator"; // FIXME - add others? export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown'; @@ -41,6 +41,7 @@ export class Request extends ScopedContainer { public readonly isXHR: boolean; public readonly address: HTTPSourceAddress; public readonly response: Response; + public readonly mediaTypes: string[]; constructor( protected clientRequest: IncomingMessage, @@ -98,6 +99,7 @@ export class Request extends ScopedContainer { port } + this.mediaTypes = (new Negotiator(clientRequest)).mediaTypes() this.response = new Response(this, serverResponse) } @@ -115,10 +117,33 @@ export class Request extends ScopedContainer { } } - // session - // route - // respond - // body + accepts(type: string) { + if ( type === 'json' ) type = 'application/json' + else if ( type === 'xml' ) type = 'application/xml' + else if ( type === 'html' ) type = 'text/html' + + type = type.toLowerCase() + + const possible = [ + type, + type.split('/')[0] + '/*', + '*/*' + ] + + return this.mediaTypes.some(media => possible.includes(media.toLowerCase())) + } + + 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('*/*') + const htmlIdx = this.mediaTypes.indexOf('text/html') ?? this.mediaTypes.indexOf('text/*') ?? this.mediaTypes.indexOf('*/*') + + if ( htmlIdx >= 0 && htmlIdx <= jsonIdx && htmlIdx <= xmlIdx ) return 'html' + if ( jsonIdx >= 0 && jsonIdx <= htmlIdx && jsonIdx <= xmlIdx ) return 'json' + if ( xmlIdx >= 0 && xmlIdx <= jsonIdx && xmlIdx <= htmlIdx ) return 'xml' + return 'unknown' + } + // hostname /* @@ -127,8 +152,7 @@ export class Request extends ScopedContainer { fresh/stale - cache remote ips (proxy) signedCookies - accepts content type, charsets, encodings, languages - is content type (wants) + accepts charsets, encodings, languages range header parser */ } diff --git a/src/http/response/DehydratedStateResponseFactory.ts b/src/http/response/DehydratedStateResponseFactory.ts new file mode 100644 index 0000000..9318ae5 --- /dev/null +++ b/src/http/response/DehydratedStateResponseFactory.ts @@ -0,0 +1,20 @@ +import {ResponseFactory} from "./ResponseFactory" +import {Rehydratable} from "@extollo/util" +import {Request} from "../lifecycle/Request"; + +export function dehydrate(value: Rehydratable): DehydratedStateResponseFactory { + return new DehydratedStateResponseFactory(value) +} + +export class DehydratedStateResponseFactory extends ResponseFactory { + constructor( + public readonly rehydratable: Rehydratable + ) { super() } + + public async write(request: Request) { + request = await super.write(request) + request.response.body = JSON.stringify(this.rehydratable.dehydrate()) + request.response.setHeader('Content-Type', 'application/json') + return request + } +} diff --git a/src/http/response/ErrorResponseFactory.ts b/src/http/response/ErrorResponseFactory.ts new file mode 100644 index 0000000..fd06d67 --- /dev/null +++ b/src/http/response/ErrorResponseFactory.ts @@ -0,0 +1,90 @@ +import {ResponseFactory} from "./ResponseFactory" +import {ErrorWithContext, HTTPStatus} from "@extollo/util" +import {Request} from "../lifecycle/Request"; +import * as api from "./api" + +export function error( + error: Error | string, + status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, + output: 'json' | 'html' | 'auto' = 'auto' +): ErrorResponseFactory { + if ( typeof error === 'string' ) error = new Error(error) + return new ErrorResponseFactory(error, status, output) +} + +export class ErrorResponseFactory extends ResponseFactory { + protected targetMode: 'json' | 'html' | 'auto' = 'auto' + + constructor( + public readonly error: Error, + status: HTTPStatus, + output: 'json' | 'html' | 'auto' = 'auto' + ) { + super() + this.status(status) + this.mode(output) + } + + public mode(output: 'json' | 'html' | 'auto'): ErrorResponseFactory { + this.targetMode = output + return this + } + + public async write(request: Request) { + request = await super.write(request) + const wants = request.wants() + + if ( this.targetMode === 'json' || (this.targetMode === 'auto' && wants === 'json') ) { + request.response.setHeader('Content-Type', 'application/json') + request.response.body = this.buildJSON(this.error) + } else if ( this.targetMode === 'html' || (this.targetMode === 'auto' && (wants === 'html' || wants === 'unknown')) ) { + request.response.setHeader('Content-Type', 'text/html') + request.response.body = this.buildHTML(this.error) + } + + // FIXME XML support + + return request + } + + /** + * Build the HTML display for the given error. + * @param {Error} error + * @return string + */ + protected buildHTML(error: Error) { + let context: any + if ( error instanceof ErrorWithContext ) { + context = error.context + if ( error.originalError ) { + error = error.originalError + } + } + + let str = ` + Sorry, an unexpected error occurred while processing your request. +
+

+Name: ${error.name}
+Message: ${error.message}
+Stack trace:
+    - ${error.stack ? error.stack.split(/\s+at\s+/).slice(1).join('
- ') : 'none'} +
+ ` + + if ( context && typeof context === 'object' ) { + str += ` +

+Context:
+${Object.keys(context).map(key => `    - ${key} : ${context[key]}`)}
+                
+ ` + } + + return str + } + + protected buildJSON(error: Error) { + return JSON.stringify(api.error(error)) + } +} diff --git a/src/http/response/HTMLResponseFactory.ts b/src/http/response/HTMLResponseFactory.ts new file mode 100644 index 0000000..51e55ce --- /dev/null +++ b/src/http/response/HTMLResponseFactory.ts @@ -0,0 +1,19 @@ +import {ResponseFactory} from "./ResponseFactory"; +import {Request} from "../lifecycle/Request"; + +export function html(value: string): HTMLResponseFactory { + return new HTMLResponseFactory(value) +} + +export class HTMLResponseFactory extends ResponseFactory { + constructor( + public readonly value: string, + ) { super() } + + public async write(request: Request) { + request = await super.write(request) + request.response.setHeader('Content-Type', 'text/html; charset=utf-8') + request.response.body = this.value + return request + } +} diff --git a/src/http/response/HTTPErrorResponseFactory.ts b/src/http/response/HTTPErrorResponseFactory.ts new file mode 100644 index 0000000..76a9060 --- /dev/null +++ b/src/http/response/HTTPErrorResponseFactory.ts @@ -0,0 +1,16 @@ +import {ErrorResponseFactory} from "./ErrorResponseFactory"; +import {HTTPError} from "../HTTPError"; +import {HTTPStatus} from "@extollo/util" + +export function http(status: HTTPStatus, message?: string, output: 'json' | 'html' | 'auto' = 'auto'): HTTPErrorResponseFactory { + return new HTTPErrorResponseFactory(new HTTPError(status, message), output) +} + +export class HTTPErrorResponseFactory extends ErrorResponseFactory { + constructor( + public readonly error: HTTPError, + output: 'json' | 'html' | 'auto' = 'auto', // FIXME xml support + ) { + super(error, error.status, output) + } +} diff --git a/src/http/response/JSONResponseFactory.ts b/src/http/response/JSONResponseFactory.ts new file mode 100644 index 0000000..db4a300 --- /dev/null +++ b/src/http/response/JSONResponseFactory.ts @@ -0,0 +1,19 @@ +import {ResponseFactory} from "./ResponseFactory"; +import {Request} from "../lifecycle/Request"; + +export function json(value: any): JSONResponseFactory { + return new JSONResponseFactory(value) +} + +export class JSONResponseFactory extends ResponseFactory { + constructor( + public readonly value: any + ) { super() } + + public async write(request: Request) { + request = await super.write(request) + request.response.setHeader('Content-Type', 'application/json') + request.response.body = JSON.stringify(this.value) + return request + } +} diff --git a/src/http/response/ResponseFactory.ts b/src/http/response/ResponseFactory.ts new file mode 100644 index 0000000..5f44988 --- /dev/null +++ b/src/http/response/ResponseFactory.ts @@ -0,0 +1,17 @@ +import {HTTPStatus} from "@extollo/util" +import {Instantiable} from "@extollo/di" +import {Request} from "../lifecycle/Request" + +export abstract class ResponseFactory { + protected targetStatus: HTTPStatus = HTTPStatus.OK + + public async write(request: Request): Promise { + request.response.setStatus(this.targetStatus) + return request + } + + public status(status: HTTPStatus) { + this.targetStatus = status + return this + } +} diff --git a/src/http/response/StringResponseFactory.ts b/src/http/response/StringResponseFactory.ts new file mode 100644 index 0000000..ca54d98 --- /dev/null +++ b/src/http/response/StringResponseFactory.ts @@ -0,0 +1,19 @@ +import {ResponseFactory} from "./ResponseFactory"; +import {Request} from "../lifecycle/Request"; + +export function plaintext(value: string): StringResponseFactory { + return new StringResponseFactory(value) +} + +export class StringResponseFactory extends ResponseFactory { + constructor( + public readonly value: string, + ) { super() } + + public async write(request: Request) { + request = await super.write(request) + request.response.setHeader('Content-Type', 'text/plain') + request.response.body = this.value + return request + } +} diff --git a/src/http/response/TemporaryRedirectResponseFactory.ts b/src/http/response/TemporaryRedirectResponseFactory.ts new file mode 100644 index 0000000..59140f8 --- /dev/null +++ b/src/http/response/TemporaryRedirectResponseFactory.ts @@ -0,0 +1,21 @@ +import {ResponseFactory} from "./ResponseFactory"; +import {HTTPStatus} from "@extollo/util"; +import {Request} from "../lifecycle/Request"; + +export function redirect(destination: string): TemporaryRedirectResponseFactory { + return new TemporaryRedirectResponseFactory(destination) +} + +export class TemporaryRedirectResponseFactory extends ResponseFactory { + protected targetStatus: HTTPStatus = HTTPStatus.TEMPORARY_REDIRECT + + constructor( + public readonly destination: string + ) { super() } + + public async write(request: Request) { + request = await super.write(request) + request.response.setHeader('Location', this.destination) + return request + } +} diff --git a/src/http/response/api.ts b/src/http/response/api.ts new file mode 100644 index 0000000..1397f8e --- /dev/null +++ b/src/http/response/api.ts @@ -0,0 +1,76 @@ +/** + * Base type for an API response format. + */ +export interface APIResponse { + success: boolean, + message?: string, + data?: any, + error?: { + name: string, + message: string, + stack?: string[], + } +} + +/** + * Formats a mesage as a successful API response. + * @param {string} message + * @return APIResponse + */ +export function message(message: string): APIResponse { + return { + success: true, + message, + } +} + +/** + * Formats a single record as a successful API response. + * @param record + * @return APIResponse + */ +export function one(record: any): APIResponse { + return { + success: true, + data: record, + } +} + +/** + * Formats an array of records as a successful API response. + * @param {array} records + * @return APIResponse + */ +export function many(records: any[]): APIResponse { + return { + success: true, + data: { + records, + total: records.length, + }, + } +} + +/** + * Formats an error message or Error instance as an API response. + * @param {string|Error} error + * @return APIResponse + */ +export function error(error: string | Error): APIResponse { + if ( typeof error === 'string' ) { + return { + success: false, + message: error, + } + } else { + return { + success: false, + message: error.message, + error: { + name: error.name, + message: error.message, + stack: error.stack ? error.stack.split(/\s+at\s+/).slice(1) : [], + }, + } + } +} diff --git a/src/index.ts b/src/index.ts index 9c4d985..0a5e251 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from './lifecycle/AppClass' export * from './lifecycle/Unit' export * from './http/kernel/module/InjectSessionHTTPModule' +export * from './http/kernel/module/MountActivatedRouteHTTPModule' export * from './http/kernel/module/PersistSessionHTTPModule' export * from './http/kernel/module/PoweredByHeaderInjectionHTTPModule' export * from './http/kernel/module/SetSessionCookieHTTPModule' @@ -17,6 +18,17 @@ export * from './http/kernel/HTTPCookieJar' export * from './http/lifecycle/Request' export * from './http/lifecycle/Response' +export * as api from './http/response/api' +export * from './http/response/DehydratedStateResponseFactory' +export * from './http/response/ErrorResponseFactory' +export * from './http/response/HTMLResponseFactory' +export * from './http/response/HTTPErrorResponseFactory' +export * from './http/response/JSONResponseFactory' +export * from './http/response/ResponseFactory' +export * from './http/response/StringResponseFactory' +export * from './http/response/TemporaryRedirectResponseFactory' + +export * from './http/routing/ActivatedRoute' export * from './http/routing/Route' export * from './http/routing/RouteGroup' @@ -25,6 +37,7 @@ export * from './http/session/SessionFactory' export * from './http/session/MemorySession' export * from './http/Controller' +export * from './http/HTTPError' export * from './service/Canonical' export * from './service/CanonicalInstantiable' diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index c1a2d21..3741061 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -33,7 +33,7 @@ export class HTTPServer extends Unit { await new Promise((res, rej) => { this.server = createServer(this.handler) - this.server.listen(port, undefined, undefined, () => { + this.server.listen(port, () => { this.logging.success(`Server listening on port ${port}. Press ^C to stop.`) }) @@ -55,10 +55,8 @@ export class HTTPServer extends Unit { public get handler() { return async (request: IncomingMessage, response: ServerResponse) => { const extolloReq = new Request(request, response) - await this.kernel.handle(extolloReq) await extolloReq.response.send('Hi, from Extollo!!') } } - }