Add basic response factories and helpers

This commit is contained in:
Garrett Mills 2021-03-08 10:07:10 -06:00
parent 3acc1bc83e
commit a9ffa771dc
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
15 changed files with 365 additions and 10 deletions

10
package-lock.json generated
View File

@ -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": { "dotenv": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" "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": { "typescript": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz",

View File

@ -10,7 +10,9 @@
"dependencies": { "dependencies": {
"@extollo/di": "file:../di", "@extollo/di": "file:../di",
"@extollo/util": "file:../util", "@extollo/util": "file:../util",
"@types/negotiator": "^0.6.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"negotiator": "^0.6.2",
"typescript": "^4.1.3" "typescript": "^4.1.3"
}, },
"devDependencies": {}, "devDependencies": {},

11
src/http/HTTPError.ts Normal file
View File

@ -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]
}
}

View File

@ -5,7 +5,7 @@ import {HTTPCookieJar} from "../kernel/HTTPCookieJar";
import {TLSSocket} from "tls"; import {TLSSocket} from "tls";
import * as url from "url"; import * as url from "url";
import {Response} from "./Response"; import {Response} from "./Response";
import {ActivatedRoute} from "../routing/ActivatedRoute"; import * as Negotiator from "negotiator";
// FIXME - add others? // FIXME - add others?
export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown'; export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown';
@ -41,6 +41,7 @@ export class Request extends ScopedContainer {
public readonly isXHR: boolean; public readonly isXHR: boolean;
public readonly address: HTTPSourceAddress; public readonly address: HTTPSourceAddress;
public readonly response: Response; public readonly response: Response;
public readonly mediaTypes: string[];
constructor( constructor(
protected clientRequest: IncomingMessage, protected clientRequest: IncomingMessage,
@ -98,6 +99,7 @@ export class Request extends ScopedContainer {
port port
} }
this.mediaTypes = (new Negotiator(clientRequest)).mediaTypes()
this.response = new Response(this, serverResponse) this.response = new Response(this, serverResponse)
} }
@ -115,10 +117,33 @@ export class Request extends ScopedContainer {
} }
} }
// session accepts(type: string) {
// route if ( type === 'json' ) type = 'application/json'
// respond else if ( type === 'xml' ) type = 'application/xml'
// body 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 // hostname
/* /*
@ -127,8 +152,7 @@ export class Request extends ScopedContainer {
fresh/stale - cache fresh/stale - cache
remote ips (proxy) remote ips (proxy)
signedCookies signedCookies
accepts content type, charsets, encodings, languages accepts charsets, encodings, languages
is content type (wants)
range header parser range header parser
*/ */
} }

View File

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

View File

@ -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 = `
<b>Sorry, an unexpected error occurred while processing your request.</b>
<br>
<pre><code>
Name: ${error.name}
Message: ${error.message}
Stack trace:
- ${error.stack ? error.stack.split(/\s+at\s+/).slice(1).join('<br> - ') : 'none'}
</code></pre>
`
if ( context && typeof context === 'object' ) {
str += `
<pre><code>
Context:
${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)}
</code></pre>
`
}
return str
}
protected buildJSON(error: Error) {
return JSON.stringify(api.error(error))
}
}

View File

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

View File

@ -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)
}
}

View File

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

View File

@ -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> {
request.response.setStatus(this.targetStatus)
return request
}
public status(status: HTTPStatus) {
this.targetStatus = status
return this
}
}

View File

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

View File

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

76
src/http/response/api.ts Normal file
View File

@ -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) : [],
},
}
}
}

View File

@ -6,6 +6,7 @@ export * from './lifecycle/AppClass'
export * from './lifecycle/Unit' export * from './lifecycle/Unit'
export * from './http/kernel/module/InjectSessionHTTPModule' export * from './http/kernel/module/InjectSessionHTTPModule'
export * from './http/kernel/module/MountActivatedRouteHTTPModule'
export * from './http/kernel/module/PersistSessionHTTPModule' export * from './http/kernel/module/PersistSessionHTTPModule'
export * from './http/kernel/module/PoweredByHeaderInjectionHTTPModule' export * from './http/kernel/module/PoweredByHeaderInjectionHTTPModule'
export * from './http/kernel/module/SetSessionCookieHTTPModule' export * from './http/kernel/module/SetSessionCookieHTTPModule'
@ -17,6 +18,17 @@ export * from './http/kernel/HTTPCookieJar'
export * from './http/lifecycle/Request' export * from './http/lifecycle/Request'
export * from './http/lifecycle/Response' 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/Route'
export * from './http/routing/RouteGroup' export * from './http/routing/RouteGroup'
@ -25,6 +37,7 @@ export * from './http/session/SessionFactory'
export * from './http/session/MemorySession' export * from './http/session/MemorySession'
export * from './http/Controller' export * from './http/Controller'
export * from './http/HTTPError'
export * from './service/Canonical' export * from './service/Canonical'
export * from './service/CanonicalInstantiable' export * from './service/CanonicalInstantiable'

View File

@ -33,7 +33,7 @@ export class HTTPServer extends Unit {
await new Promise<void>((res, rej) => { await new Promise<void>((res, rej) => {
this.server = createServer(this.handler) 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.`) this.logging.success(`Server listening on port ${port}. Press ^C to stop.`)
}) })
@ -55,10 +55,8 @@ export class HTTPServer extends Unit {
public get handler() { public get handler() {
return async (request: IncomingMessage, response: ServerResponse) => { return async (request: IncomingMessage, response: ServerResponse) => {
const extolloReq = new Request(request, response) const extolloReq = new Request(request, response)
await this.kernel.handle(extolloReq) await this.kernel.handle(extolloReq)
await extolloReq.response.send('Hi, from Extollo!!') await extolloReq.response.send('Hi, from Extollo!!')
} }
} }
} }