Skip to content

Commit

Permalink
feat(http): add new HttpRequestParser<T> injection token
Browse files Browse the repository at this point in the history
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<AuthData>) => {
        const auth = await authParser();
        session.auth = auth.auth;
        session.userId = auth.userId;
    });
``
  • Loading branch information
marcj committed Feb 6, 2024
1 parent b7bc553 commit 6101f83
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 12 deletions.
31 changes: 30 additions & 1 deletion packages/http/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -134,6 +134,35 @@ export class ValidatedBody<T> {
export type HttpBody<T> = T & TypeAnnotation<'httpBody'>;
export type HttpBodyValidation<T> = ValidatedBody<T> & 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<T> = ((options?: HttpRequestParserOptions) => Promise<T>) & 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,
Expand Down
12 changes: 8 additions & 4 deletions packages/http/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
45 changes: 39 additions & 6 deletions packages/http/src/request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 + '$'));
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand All @@ -354,6 +386,7 @@ export function getRequestParserCodeForParameters(
const validationErrors = [];
const bodyErrors = [];
const parameters = {};
let bodyFields;
${setParametersFromPath}
${parseBodyLoading}
${setParameters.join('\n')}
Expand Down
60 changes: 59 additions & 1 deletion packages/http/tests/router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<number>, session: HttpSession) {
return [userId, session.userId, session.auth];
}

@http.GET('/2')
handle2(userId: HttpQuery<number>, 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<AuthData>) {
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<AuthData>) {
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<AuthData>) => {
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()
Expand Down

0 comments on commit 6101f83

Please sign in to comment.