From 6101f830897e071e72b8e873bda6dbeee69cdc1e Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Tue, 6 Feb 2024 14:21:00 +0100 Subject: [PATCH] feat(http): add new HttpRequestParser injection token This allows to fetch arbitrary request data from header, body, and query at the same time in an async fashion. Necessary for reading request data in a 'http' scoped event listener, e.g. onAuth events. ``` type AuthData = { auth: string; userId?: number; } httpWorkflow.onAuth.listen(async (event, session: HttpSession, authParser: HttpRequestParser) => { const auth = await authParser(); session.auth = auth.auth; session.userId = auth.userId; }); `` --- packages/http/src/model.ts | 31 ++++++++++++++- packages/http/src/module.ts | 12 ++++-- packages/http/src/request-parser.ts | 45 +++++++++++++++++++--- packages/http/tests/router.spec.ts | 60 ++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/http/src/model.ts b/packages/http/src/model.ts index e966f269f..b71a08641 100644 --- a/packages/http/src/model.ts +++ b/packages/http/src/model.ts @@ -12,7 +12,7 @@ import { IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, ServerRespons import { UploadedFile } from './router.js'; import * as querystring from 'querystring'; import { Writable } from 'stream'; -import { metaAnnotation, ReflectionKind, Type, ValidationErrorItem, TypeAnnotation } from '@deepkit/type'; +import { metaAnnotation, ReflectionKind, Type, TypeAnnotation, ValidationErrorItem } from '@deepkit/type'; import { asyncOperation, isArray } from '@deepkit/core'; export class HttpResponse extends ServerResponse { @@ -134,6 +134,35 @@ export class ValidatedBody { export type HttpBody = T & TypeAnnotation<'httpBody'>; export type HttpBodyValidation = ValidatedBody & TypeAnnotation<'httpBodyValidation'>; +export interface HttpRequestParserOptions { + withBody?: boolean; + withQuery?: boolean; + withHeader?: boolean; +} + +/** + * Delays the parsing of the body/query/header to the very last moment, when the parameter is actually used. + * + * If no options are provided, the parser will receive data from header, body, and query, in this order. + * This basically allows to fetch data from all possible HTTP sources in one go. + * + * You can disable various sources by providing the options, e.g. `{withBody: false}` to disable body parsing. + * Or `{withQuery: false}` to disable query parsing. Or `{withHeader: false}` to disable header parsing. + * To only parse the body, use `{withQuery: false, withHeader: false}`. + * + * @example + * ```typescript + * async route(parser: HttpRequestParser<{authorization: string}>) { + * const data = await parser(); + * console.log(data.authorization); + * } + * ``` + * + * This is necessary in event listeners, since they are instantiated synchronously, + * but body is parsed asynchronously. So use in event listeners HttpRequestParser instead of HttpBody. + */ +export type HttpRequestParser = ((options?: HttpRequestParserOptions) => Promise) & TypeAnnotation<'httpRequestParser', T>; + /** * Marks a parameter as HTTP path and reads the value from the request path. * This is normally not requires since the parameter name automatically maps to the path parameter, diff --git a/packages/http/src/module.ts b/packages/http/src/module.ts index 69e0f7e90..25a547059 100644 --- a/packages/http/src/module.ts +++ b/packages/http/src/module.ts @@ -15,8 +15,12 @@ import { buildRequestParser } from './request-parser.js'; import { InjectorContext } from '@deepkit/injector'; function parameterRequiresRequest(parameter: ReflectionParameter): boolean { - return Boolean(metaAnnotation.getForName(parameter.type, 'httpQueries') || metaAnnotation.getForName(parameter.type, 'httpQuery') - || metaAnnotation.getForName(parameter.type, 'httpBody') || metaAnnotation.getForName(parameter.type, 'httpPath') || metaAnnotation.getForName(parameter.type, 'httpHeader')); + return Boolean(metaAnnotation.getForName(parameter.type, 'httpQueries') + || metaAnnotation.getForName(parameter.type, 'httpQuery') + || metaAnnotation.getForName(parameter.type, 'httpBody') + || metaAnnotation.getForName(parameter.type, 'httpRequestParser') + || metaAnnotation.getForName(parameter.type, 'httpPath') + || metaAnnotation.getForName(parameter.type, 'httpHeader')); } export class HttpModule extends createModule({ @@ -76,15 +80,15 @@ export class HttpModule extends createModule({ throw new Error(`Listener ${stringifyListener(listener)} requires async HttpBody. This is not yet supported. You have to parse the request manually by injecting HttpRequest.`); } + let build: Function; for (let index = 0; index < params.length; index++) { const parameter = params[index]; if (!parameterRequiresRequest(parameter)) continue; //change the reflection type so that we create a unique injection token for that type. - const unique = Symbol('unique'); + const unique = Symbol('event.parameter:' + parameter.name); const uniqueType: Type = { kind: ReflectionKind.literal, literal: unique }; metaAnnotation.registerType(parameter.type, { name: 'inject', options: [uniqueType] }); - let build: Function; let i = index; this.addProvider({ diff --git a/packages/http/src/request-parser.ts b/packages/http/src/request-parser.ts index ad324243b..168e1a990 100644 --- a/packages/http/src/request-parser.ts +++ b/packages/http/src/request-parser.ts @@ -89,11 +89,20 @@ export class ParameterForRequestParser { return metaAnnotation.getForName(this.parameter.type, 'httpBody') !== undefined; } + get requestParser() { + return metaAnnotation.getForName(this.parameter.type, 'httpRequestParser') !== undefined; + } + get bodyValidation() { return metaAnnotation.getForName(this.parameter.type, 'httpBodyValidation') !== undefined; } getType(): Type { + const parser = metaAnnotation.getForName(this.parameter.type, 'httpRequestParser'); + if (parser && parser[0]) { + return parser[0]; + } + if (this.bodyValidation) { assertType(this.parameter.type, ReflectionKind.class); const valueType = findMember('value', this.parameter.type.types); @@ -180,7 +189,7 @@ export function buildRequestParser(parseOptions: HttpParserOptions, parameters: compiler.context.set('ValidationError', ValidationError); compiler.context.set('qs', qs); - let needsQueryString = !!params.find(v => v.query || v.queries); + let needsQueryString = !!params.find(v => v.query || v.queries || v.requestParser); const query = needsQueryString ? '_qPosition === -1 ? {} : qs.parse(_url.substr(_qPosition + 1))' : '{}'; const regexVar = compiler.reserveVariable('regex', new RegExp('^' + pathRegex + '$')); @@ -219,14 +228,11 @@ export function getRequestParserCodeForParameters( let bodyValidationErrorHandling = `if (bodyErrors.length) throw ValidationError.from(bodyErrors);`; for (const parameter of parameters) { - if (parameter.body || parameter.bodyValidation) { + if (parameter.requestParser || parameter.body || parameter.bodyValidation) { const type = parameter.getType(); const validatorVar = compiler.reserveVariable('argumentValidator', getValidatorFunction(undefined, type)); const converterVar = compiler.reserveVariable('argumentConverter', getSerializeFunction(type, serializer.deserializeRegistry)); - enableParseBody = true; - setParameters.push(`parameters.${parameter.parameter.name} = ${converterVar}(bodyFields, {loosely: true});`); - parameterValidator.push(`${validatorVar}(parameters.${parameter.parameter.name}, {errors: bodyErrors});`); if (parameter.bodyValidation) { compiler.context.set('BodyValidation', ValidatedBody); compiler.context.set('BodyValidationError', BodyValidationError); @@ -235,6 +241,32 @@ export function getRequestParserCodeForParameters( } else { parameterNames.push(`parameters.${parameter.parameter.name}`); } + + if (parameter.requestParser) { + const parseOptionsVar = compiler.reserveVariable('parseOptions', parseOptions); + const parseBodyVar = compiler.reserveVariable('parseBody', parseBody); + setParameters.push(`parameters.${parameter.parameter.name} = async (options = {}) => { + let res = {}; + if (options.withHeader !== false) { + Object.assign(res, _headers); + } + if (options.withBody !== false) { + bodyFields = bodyFields || (await ${parseBodyVar}(${parseOptionsVar}, request, uploadedFiles)); + Object.assign(res, bodyFields); + } + if (options.withQuery !== false) { + Object.assign(res, _query); + } + res = ${converterVar}(res, {loosely: true}); + ${validatorVar}(res, {errors: bodyErrors}); + if (bodyErrors.length) throw ValidationError.from(bodyErrors); + return res; + }`); + } else { + enableParseBody = true; + setParameters.push(`parameters.${parameter.parameter.name} = ${converterVar}(bodyFields, {loosely: true});`); + parameterValidator.push(`${validatorVar}(parameters.${parameter.parameter.name}, {errors: bodyErrors});`); + } } else if (parameter.query || parameter.queries || parameter.header) { const converted = getSerializeFunction(parameter.parameter.parameter, serializer.deserializeRegistry, undefined, parameter.getName()); const validator = getValidatorFunction(undefined, parameter.parameter.parameter); @@ -343,7 +375,7 @@ export function getRequestParserCodeForParameters( const parseOptionsVar = compiler.reserveVariable('parseOptions', parseOptions); const parseBodyVar = compiler.reserveVariable('parseBody', parseBody); parseBodyLoading = ` - const bodyFields = (await ${parseBodyVar}(${parseOptionsVar}, request, uploadedFiles));`; + bodyFields = bodyFields || (await ${parseBodyVar}(${parseOptionsVar}, request, uploadedFiles));`; requiresAsyncParameters = true; } @@ -354,6 +386,7 @@ export function getRequestParserCodeForParameters( const validationErrors = []; const bodyErrors = []; const parameters = {}; + let bodyFields; ${setParametersFromPath} ${parseBodyLoading} ${setParameters.join('\n')} diff --git a/packages/http/tests/router.spec.ts b/packages/http/tests/router.spec.ts index e5de2907e..209eb6f80 100644 --- a/packages/http/tests/router.spec.ts +++ b/packages/http/tests/router.spec.ts @@ -3,7 +3,7 @@ import { dotToUrlPath, HttpRouter, RouteClassControllerAction, RouteParameterRes import { getActions, http, httpClass } from '../src/decorator.js'; import { HtmlResponse, HttpAccessDeniedError, HttpBadRequestError, HttpUnauthorizedError, httpWorkflow, JSONResponse, Response } from '../src/http.js'; import { eventDispatcher } from '@deepkit/event'; -import { HttpBody, HttpBodyValidation, HttpHeader, HttpPath, HttpQueries, HttpQuery, HttpRegExp, HttpRequest } from '../src/model.js'; +import { HttpBody, HttpBodyValidation, HttpHeader, HttpPath, HttpQueries, HttpQuery, HttpRegExp, HttpRequest, HttpRequestParser } from '../src/model.js'; import { getClassName, isObject, sleep } from '@deepkit/core'; import { createHttpKernel } from './utils.js'; import { Excluded, Group, integer, Maximum, metaAnnotation, MinLength, Positive, PrimaryKey, Reference, serializer, Type, typeSettings, UnpopulatedCheck } from '@deepkit/type'; @@ -1289,6 +1289,64 @@ test('queries parameter in class listener', async () => { expect((await httpKernel.request(HttpRequest.GET('/?userId=1'))).json.message).toEqual('Validation error:\nauth.auth(type): Not a string'); }); +test('body and queries in listener', async () => { + class HttpSession { + constructor(public auth: string = '', public userId: number = 0) { + } + } + + class Controller { + @http.POST('/1') + handle1(userId: HttpQuery, session: HttpSession) { + return [userId, session.userId, session.auth]; + } + + @http.GET('/2') + handle2(userId: HttpQuery, session: HttpSession) { + return [userId, session.userId, session.auth]; + } + + @http.POST('/3') + handle3({ userId }: HttpBody<{userId: number}>, session: HttpSession) { + return [userId, session.userId, session.auth]; + } + + @http.GET('/4') + async handle4(parser: HttpRequestParser) { + const data = await parser(); + return [data.auth, data.userId]; + } + } + + type AuthData = { + auth: string; + userId?: string; + } + + class Listener { + @eventDispatcher.listen(httpWorkflow.onAuth) + async handle(event: typeof httpWorkflow.onAuth.event, session: HttpSession, authParser: HttpRequestParser) { + const auth = await authParser(); + session.auth = auth.auth; + session.userId = auth.userId ? parseInt(auth.userId) : 0; + } + } + + httpWorkflow.onAuth.listen(async (event, session: HttpSession, authParser: HttpRequestParser) => { + const auth = await authParser(); + session.auth = auth.auth; + session.userId = auth.userId ? parseInt(auth.userId) : 0; + }); + + const httpKernel = createHttpKernel([Controller], [{ provide: HttpSession, scope: 'http' }], [Listener]); + expect((await httpKernel.request(HttpRequest.POST('/1?userId=1').json({auth: 'secretToken1'}))).json).toEqual([1, 1, 'secretToken1']); + expect((await httpKernel.request(HttpRequest.GET('/2?auth=secretToken1&userId=1'))).json).toEqual([1, 1, 'secretToken1']); + expect((await httpKernel.request(HttpRequest.GET('/2?userId=1'))).json.message).toEqual('Validation error:\nauth(type): Not a string'); + expect((await httpKernel.request(HttpRequest.POST('/3?auth=secretToken1').json({userId: '24'}))).json).toEqual([24, 24, 'secretToken1']); + expect((await httpKernel.request(HttpRequest.GET('/4?auth=secretToken1&userId=1'))).json).toEqual(['secretToken1', '1']); + expect((await httpKernel.request(HttpRequest.GET('/4?userId=1').header('auth', 'secretToken1'))).json).toEqual(['secretToken1', '1']); +}); + test('stream', async () => { class Controller { @http.GET()