lib/src/http/lifecycle/Request.ts

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
*/
}