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