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