279 lines
8.2 KiB
TypeScript
279 lines
8.2 KiB
TypeScript
import {Container, Injectable, ScopedContainer} from '../../di'
|
|
import {HTTPStatus, infer, Pipeline, Safe, UniversalPath} from '../../util'
|
|
import {IncomingMessage, ServerResponse} from 'http'
|
|
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
|
import {TLSSocket} from 'tls'
|
|
import * as url from 'url'
|
|
import {Response} from './Response'
|
|
import * as Negotiator from 'negotiator'
|
|
import {HTTPError} from '../HTTPError'
|
|
import {ActivatedRoute} from '../routing/ActivatedRoute'
|
|
|
|
/**
|
|
* 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: unknown): what is HTTPMethod {
|
|
return ['post', 'get', 'patch', 'put', 'delete'].includes(String(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;
|
|
}
|
|
|
|
/**
|
|
* Interface describing a container that holds user input data.
|
|
*/
|
|
export interface DataContainer {
|
|
input(key?: string): any
|
|
}
|
|
|
|
/**
|
|
* A class that represents an HTTP request from a client.
|
|
*/
|
|
@Injectable()
|
|
export class Request extends ScopedContainer implements DataContainer {
|
|
|
|
/** 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[];
|
|
|
|
/** Input parsed from the request */
|
|
public readonly parsedInput: {[key: string]: any} = {}
|
|
|
|
/** Files parsed from the request. */
|
|
public readonly uploadedFiles: {[key: string]: UniversalPath} = {}
|
|
|
|
/** If true, the response lifecycle will not time out and send errors. */
|
|
public bypassTimeout = false
|
|
|
|
constructor(
|
|
/** The native Node.js request. */
|
|
protected clientRequest: IncomingMessage,
|
|
|
|
/** The native Node.js response. */
|
|
protected serverResponse: ServerResponse,
|
|
) {
|
|
super(Container.getContainer())
|
|
this.registerSingletonInstance(Request, this)
|
|
|
|
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
|
|
|
this.cookies = new HTTPCookieJar(this)
|
|
this.url = String(clientRequest.url)
|
|
this.fullUrl = (this.secure ? 'https' : 'http') + `://${this.getHeader('host')}${this.url}`
|
|
|
|
const method = clientRequest.method?.toLowerCase()
|
|
this.method = isHTTPMethod(method) ? method : 'unknown'
|
|
|
|
this.protocol = {
|
|
string: clientRequest.httpVersion,
|
|
major: clientRequest.httpVersionMajor,
|
|
minor: clientRequest.httpVersionMinor,
|
|
}
|
|
|
|
const parts = url.parse(this.url, true)
|
|
|
|
this.path = parts.pathname ?? '/'
|
|
this.rawQueryData = parts.query
|
|
|
|
const query: {[key: string]: any} = {}
|
|
for ( const key in this.rawQueryData ) {
|
|
if ( !Object.prototype.hasOwnProperty.call(this.rawQueryData, key) ) {
|
|
continue
|
|
}
|
|
|
|
const value = this.rawQueryData[key]
|
|
|
|
if ( Array.isArray(value) ) {
|
|
query[key] = value.map(x => infer(x))
|
|
} else if ( value ) {
|
|
query[key] = infer(value)
|
|
} else {
|
|
query[key] = value
|
|
}
|
|
}
|
|
|
|
this.query = query
|
|
this.isXHR = String(this.clientRequest.headers['x-requested-with']).toLowerCase() === 'xmlhttprequest'
|
|
|
|
const {address = '0.0.0.0', family = 'IPv4', port = 0} = this.clientRequest.connection.address() as any
|
|
this.address = {
|
|
address,
|
|
family,
|
|
port,
|
|
}
|
|
|
|
this.mediaTypes = (new Negotiator(clientRequest)).mediaTypes()
|
|
this.response = new Response(this, serverResponse)
|
|
}
|
|
|
|
/** Get the value of a header, if it exists. */
|
|
public getHeader(name: string): string | string[] | undefined {
|
|
return this.clientRequest.headers[name.toLowerCase()]
|
|
}
|
|
|
|
/** Get the native Node.js IncomingMessage object. */
|
|
public toNative(): IncomingMessage {
|
|
return this.clientRequest
|
|
}
|
|
|
|
/**
|
|
* Get the value of an input field on the request. Spans multiple input sources.
|
|
* @param key
|
|
*/
|
|
public input(key?: string): unknown {
|
|
let sources = {
|
|
...this.parsedInput,
|
|
...this.query,
|
|
...this.uploadedFiles,
|
|
}
|
|
|
|
if ( this.hasKey(ActivatedRoute) ) {
|
|
sources = {
|
|
...sources,
|
|
...this.make<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute).params,
|
|
}
|
|
}
|
|
|
|
if ( !key ) {
|
|
return sources
|
|
}
|
|
|
|
if ( key in sources ) {
|
|
return sources[key]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Look up a field from the request and wrap it in a safe-value accessor.
|
|
* @param key
|
|
*/
|
|
public safe(key?: string): Safe {
|
|
return Pipeline.id()
|
|
.tap(val => new Safe(val))
|
|
.tap(safe => safe.onError(message => {
|
|
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid field (${key}): ${message}`)
|
|
}))
|
|
.apply(this.input(key))
|
|
}
|
|
|
|
/**
|
|
* Get the UniversalPath instance for a file uploaded in the given field on the request.
|
|
*/
|
|
public file(key: string): UniversalPath | undefined {
|
|
return this.uploadedFiles[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): boolean {
|
|
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()))
|
|
}
|
|
|
|
/**
|
|
* 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('*/*')
|
|
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
|
|
|
|
/*
|
|
param
|
|
json
|
|
fresh/stale - cache
|
|
remote ips (proxy)
|
|
signedCookies
|
|
accepts charsets, encodings, languages
|
|
range header parser
|
|
*/
|
|
}
|