From 25462cb36a7f1ce89aca6bfbfe1a1b746ca1b47b Mon Sep 17 00:00:00 2001 From: Leandro Costa Date: Tue, 2 Jun 2026 18:05:54 -0300 Subject: [PATCH] feat: @RawBody decorator --- README.md | 20 +++++++++++ src/ActionParameterHandler.ts | 14 ++++---- src/decorator/RawBody.ts | 21 ++++++++++++ src/driver/express/ExpressDriver.ts | 49 +++++++++++++++++++++------ src/driver/koa/KoaDriver.ts | 19 ++++++----- src/error/ParamRequiredError.ts | 3 +- src/index.ts | 21 ++++++------ src/metadata/ActionMetadata.ts | 14 ++++---- src/metadata/types/ParamType.ts | 1 + test/functional/action-params.spec.ts | 25 ++++++++++++-- 10 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 src/decorator/RawBody.ts diff --git a/README.md b/README.md index 8ececa1c..db344fa1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ You can use routing-controllers with [express.js][1] or [koa.js][2]. - [Inject routing parameters](#inject-routing-parameters) - [Inject query parameters](#inject-query-parameters) - [Inject request body](#inject-request-body) + - [Inject raw request body](#inject-raw-request-body) - [Inject request body parameters](#inject-request-body-parameters) - [Inject request header parameters](#inject-request-header-parameters) - [Inject cookie parameters](#inject-cookie-parameters) @@ -452,6 +453,24 @@ If you specify a class type to parameter that is decorated with `@Body()`, routing-controllers will use [class-transformer][4] to create instance of the given class type from the data received in request body. To disable this behaviour you need to specify a `{ classTransformer: false }` in RoutingControllerOptions when creating a server. +#### Inject raw request body + +To inject the raw request body before routing-controllers parses or transforms it, use `@RawBody` decorator: + +```typescript +@JsonController() +export class WebhookController { + @Post('/webhooks') + handleWebhook(@RawBody() rawBody: string, @Body() payload: any) { + // rawBody contains the original request payload as a string + // payload contains the parsed body as usual + return { rawBody, payload }; + } +} +``` + +This is useful for cases like webhook signature verification, where you need access to the exact body content received by the server. + #### Inject request body parameters To inject request body parameter, use `@BodyParam` decorator: @@ -1550,6 +1569,7 @@ export class QuestionController { | `@SessionParam(name: string)` | `get(@SessionParam("user") user: User)` | Injects an object from session property. | `request.session.user` | | `@State(name?: string)` | `get(@State() session: StateType)` | Injects an object from the state (or the whole state). | `ctx.state` (koa-analogue) | | `@Body(options?: BodyOptions)` | `post(@Body() body: any)` | Injects a body. In parameter options you can specify body parser middleware options. | `request.body` | +| `@RawBody(options?: BodyOptions)` | `post(@RawBody() rawBody: string)` | Injects the raw request body before parsing or transformation. Useful for signature verification and similar use cases. | `request.rawBody` | | `@BodyParam(name: string, options?: ParamOptions)` | `post(@BodyParam("name") name: string)` | Injects a body parameter. | `request.body.name` | | `@UploadedFile(name: string, options?: UploadOptions)` | `post(@UploadedFile("filename") file: any)` | Injects uploaded file from the response. In parameter options you can specify underlying uploader middleware options. | `request.file.file` (using multer) | | `@UploadedFiles(name: string, options?: UploadOptions)` | `post(@UploadedFiles("filename") files: any[])` | Injects all uploaded files from the response. In parameter options you can specify underlying uploader middleware options. | `request.files` (using multer) | diff --git a/src/ActionParameterHandler.ts b/src/ActionParameterHandler.ts index 9f09851e..abb164f5 100644 --- a/src/ActionParameterHandler.ts +++ b/src/ActionParameterHandler.ts @@ -1,15 +1,15 @@ import { plainToInstance } from 'class-transformer'; import { validateOrReject as validate, ValidationError } from 'class-validator'; import { Action } from './Action'; -import { BadRequestError } from './http-error/BadRequestError'; import { BaseDriver } from './driver/BaseDriver'; -import { ParameterParseJsonError } from './error/ParameterParseJsonError'; -import { ParamMetadata } from './metadata/ParamMetadata'; -import { ParamRequiredError } from './error/ParamRequiredError'; import { AuthorizationRequiredError } from './error/AuthorizationRequiredError'; import { CurrentUserCheckerNotDefinedError } from './error/CurrentUserCheckerNotDefinedError'; -import { isPromiseLike } from './util/isPromiseLike'; +import { ParameterParseJsonError } from './error/ParameterParseJsonError'; import { InvalidParamError } from './error/ParamNormalizationError'; +import { ParamRequiredError } from './error/ParamRequiredError'; +import { BadRequestError } from './http-error/BadRequestError'; +import { ParamMetadata } from './metadata/ParamMetadata'; +import { isPromiseLike } from './util/isPromiseLike'; /** * Handles action parameter. @@ -66,7 +66,7 @@ export class ActionParameterHandler { const isValueEmpty = value === null || value === undefined || value === ''; const isValueEmptyObject = typeof value === 'object' && value !== null && Object.keys(value).length === 0; - if (param.type === 'body' && !param.name && (isValueEmpty || isValueEmptyObject)) { + if ((param.type === 'body' || param.type === 'raw-body') && !param.name && (isValueEmpty || isValueEmptyObject)) { // body has a special check and error message return Promise.reject(new ParamRequiredError(action, param)); } else if (param.type === 'current-user') { @@ -99,7 +99,7 @@ export class ActionParameterHandler { const isNormalizationNeeded = typeof value === 'object' && ['queries', 'headers', 'params', 'cookies'].includes(param.type); const isTargetPrimitive = ['number', 'string', 'boolean'].includes(param.targetName); - const isTransformationNeeded = (param.parse || param.isTargetObject) && param.type !== 'param'; + const isTransformationNeeded = (param.parse || param.isTargetObject) && !['param', 'raw-body'].includes(param.type); // if param value is an object and param type match, normalize its string properties if (isNormalizationNeeded) { diff --git a/src/decorator/RawBody.ts b/src/decorator/RawBody.ts new file mode 100644 index 00000000..07424991 --- /dev/null +++ b/src/decorator/RawBody.ts @@ -0,0 +1,21 @@ +import { BodyOptions } from '../decorator-options/BodyOptions'; +import { getMetadataArgsStorage } from '../index'; + +/** + * Allows to inject the raw request body value to the controller action parameter, + * before routing-controllers parses or transforms it. + * Must be applied on a controller action parameter. + */ +export function RawBody(options?: BodyOptions): Function { + return function (object: Object, methodName: string, index: number) { + getMetadataArgsStorage().params.push({ + type: 'raw-body', + object: object, + method: methodName, + index: index, + parse: false, + required: options ? options.required || false : false, + extraOptions: options ? options.options : undefined, + }); + }; +} diff --git a/src/driver/express/ExpressDriver.ts b/src/driver/express/ExpressDriver.ts index d7547dd8..325b5f73 100644 --- a/src/driver/express/ExpressDriver.ts +++ b/src/driver/express/ExpressDriver.ts @@ -1,17 +1,17 @@ -import { UseMetadata } from '../../metadata/UseMetadata'; -import { MiddlewareMetadata } from '../../metadata/MiddlewareMetadata'; -import { ActionMetadata } from '../../metadata/ActionMetadata'; import { Action } from '../../Action'; -import { ParamMetadata } from '../../metadata/ParamMetadata'; -import { BaseDriver } from '../BaseDriver'; -import { ExpressMiddlewareInterface } from './ExpressMiddlewareInterface'; -import { ExpressErrorMiddlewareInterface } from './ExpressErrorMiddlewareInterface'; +import { getFromContainer } from '../../container'; import { AccessDeniedError } from '../../error/AccessDeniedError'; import { AuthorizationCheckerNotDefinedError } from '../../error/AuthorizationCheckerNotDefinedError'; -import { isPromiseLike } from '../../util/isPromiseLike'; -import { getFromContainer } from '../../container'; import { AuthorizationRequiredError } from '../../error/AuthorizationRequiredError'; import { NotFoundError, RoutingControllersOptions } from '../../index'; +import { ActionMetadata } from '../../metadata/ActionMetadata'; +import { MiddlewareMetadata } from '../../metadata/MiddlewareMetadata'; +import { ParamMetadata } from '../../metadata/ParamMetadata'; +import { UseMetadata } from '../../metadata/UseMetadata'; +import { isPromiseLike } from '../../util/isPromiseLike'; +import { BaseDriver } from '../BaseDriver'; +import { ExpressErrorMiddlewareInterface } from './ExpressErrorMiddlewareInterface'; +import { ExpressMiddlewareInterface } from './ExpressMiddlewareInterface'; // eslint-disable-next-line @typescript-eslint/no-var-requires const cookie = require('cookie'); @@ -100,10 +100,11 @@ export class ExpressDriver extends BaseDriver { const defaultMiddlewares: any[] = []; if (actionMetadata.isBodyUsed) { + const bodyParserOptions = this.createBodyParserOptions(actionMetadata.bodyExtraOptions); if (actionMetadata.isJsonTyped) { - defaultMiddlewares.push(this.loadBodyParser().json(actionMetadata.bodyExtraOptions)); + defaultMiddlewares.push(this.loadBodyParser().json(bodyParserOptions)); } else { - defaultMiddlewares.push(this.loadBodyParser().text(actionMetadata.bodyExtraOptions)); + defaultMiddlewares.push(this.loadBodyParser().text(bodyParserOptions)); } } @@ -201,6 +202,9 @@ export class ExpressDriver extends BaseDriver { case 'body': return request.body; + case 'raw-body': + return request.rawBody; + case 'body-param': return request.body[param.name]; @@ -455,6 +459,29 @@ export class ExpressDriver extends BaseDriver { } } + protected createBodyParserOptions(options: any): any { + const rawBodyCapture = (request: any, _response: any, buffer: Buffer, encoding: string) => { + const resolvedEncoding = encoding && Buffer.isEncoding(encoding) ? encoding : 'utf8'; + request.rawBody = buffer.toString(resolvedEncoding); + }; + + if (!options) { + return { verify: rawBodyCapture }; + } + + if (!options.verify) { + return { ...options, verify: rawBodyCapture }; + } + + return { + ...options, + verify: (request: any, response: any, buffer: Buffer, encoding: string) => { + rawBodyCapture(request, response, buffer, encoding); + options.verify(request, response, buffer, encoding); + }, + }; + } + /** * Dynamically loads multer module. */ diff --git a/src/driver/koa/KoaDriver.ts b/src/driver/koa/KoaDriver.ts index 5d807022..394372bf 100644 --- a/src/driver/koa/KoaDriver.ts +++ b/src/driver/koa/KoaDriver.ts @@ -1,17 +1,17 @@ import { Action } from '../../Action'; +import { getFromContainer } from '../../container'; +import { AccessDeniedError } from '../../error/AccessDeniedError'; +import { AuthorizationCheckerNotDefinedError } from '../../error/AuthorizationCheckerNotDefinedError'; +import { AuthorizationRequiredError } from '../../error/AuthorizationRequiredError'; +import { HttpError, NotFoundError } from '../../index'; import { ActionMetadata } from '../../metadata/ActionMetadata'; -import { BaseDriver } from '../BaseDriver'; import { MiddlewareMetadata } from '../../metadata/MiddlewareMetadata'; import { ParamMetadata } from '../../metadata/ParamMetadata'; import { UseMetadata } from '../../metadata/UseMetadata'; -import { KoaMiddlewareInterface } from './KoaMiddlewareInterface'; -import { AuthorizationCheckerNotDefinedError } from '../../error/AuthorizationCheckerNotDefinedError'; -import { AccessDeniedError } from '../../error/AccessDeniedError'; -import { isPromiseLike } from '../../util/isPromiseLike'; -import { getFromContainer } from '../../container'; import { RoleChecker } from '../../RoleChecker'; -import { AuthorizationRequiredError } from '../../error/AuthorizationRequiredError'; -import { HttpError, NotFoundError } from '../../index'; +import { isPromiseLike } from '../../util/isPromiseLike'; +import { BaseDriver } from '../BaseDriver'; +import { KoaMiddlewareInterface } from './KoaMiddlewareInterface'; // eslint-disable-next-line @typescript-eslint/no-var-requires const cookie = require('cookie'); @@ -179,6 +179,9 @@ export class KoaDriver extends BaseDriver { case 'body': return request.body; + case 'raw-body': + return request.rawBody; + case 'body-param': return request.body[param.name]; diff --git a/src/error/ParamRequiredError.ts b/src/error/ParamRequiredError.ts index b21b7f1c..eec8f927 100644 --- a/src/error/ParamRequiredError.ts +++ b/src/error/ParamRequiredError.ts @@ -1,6 +1,6 @@ +import { Action } from '../Action'; import { BadRequestError } from '../http-error/BadRequestError'; import { ParamMetadata } from '../metadata/ParamMetadata'; -import { Action } from '../Action'; /** * Thrown when parameter is required, but was missing in a user request. @@ -19,6 +19,7 @@ export class ParamRequiredError extends BadRequestError { break; case 'body': + case 'raw-body': paramName = 'Request body is'; break; diff --git a/src/index.ts b/src/index.ts index 2f9bc20e..983ef745 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { ValidationOptions } from 'class-validator'; import { CustomParameterDecorator } from './CustomParameterDecorator'; import { BaseDriver } from './driver/BaseDriver'; import { ExpressDriver } from './driver/express/ExpressDriver'; @@ -5,7 +6,6 @@ import { KoaDriver } from './driver/koa/KoaDriver'; import { MetadataArgsStorage } from './metadata-builder/MetadataArgsStorage'; import { RoutingControllers } from './RoutingControllers'; import { RoutingControllersOptions } from './RoutingControllersOptions'; -import { ValidationOptions } from 'class-validator'; import { importClassesFromDirectories } from './util/importClassesFromDirectories'; // ------------------------------------------------------------------------- @@ -45,6 +45,7 @@ export * from './decorator/Post'; export * from './decorator/Put'; export * from './decorator/QueryParam'; export * from './decorator/QueryParams'; +export * from './decorator/RawBody'; export * from './decorator/Redirect'; export * from './decorator/Render'; export * from './decorator/Req'; @@ -63,18 +64,18 @@ export * from './decorator-options/BodyOptions'; export * from './decorator-options/ParamOptions'; export * from './decorator-options/UploadOptions'; -export * from './http-error/HttpError'; -export * from './http-error/InternalServerError'; export * from './http-error/BadRequestError'; export * from './http-error/ForbiddenError'; -export * from './http-error/NotAcceptableError'; +export * from './http-error/HttpError'; +export * from './http-error/InternalServerError'; export * from './http-error/MethodNotAllowedError'; +export * from './http-error/NotAcceptableError'; export * from './http-error/NotFoundError'; export * from './http-error/UnauthorizedError'; export * from './http-error/UnprocessableEntityError'; -export * from './driver/express/ExpressMiddlewareInterface'; export * from './driver/express/ExpressErrorMiddlewareInterface'; +export * from './driver/express/ExpressMiddlewareInterface'; export * from './driver/koa/KoaMiddlewareInterface'; export * from './metadata-builder/MetadataArgsStorage'; export * from './metadata/ActionMetadata'; @@ -85,13 +86,13 @@ export * from './metadata/ParamMetadata'; export * from './metadata/ResponseHandleMetadata'; export * from './metadata/UseMetadata'; -export * from './RoutingControllersOptions'; -export * from './CustomParameterDecorator'; -export * from './RoleChecker'; export * from './Action'; -export * from './InterceptorInterface'; -export * from './CurrentUserChecker'; export * from './AuthorizationChecker'; +export * from './CurrentUserChecker'; +export * from './CustomParameterDecorator'; +export * from './InterceptorInterface'; +export * from './RoleChecker'; +export * from './RoutingControllersOptions'; export * from './driver/BaseDriver'; export * from './driver/express/ExpressDriver'; diff --git a/src/metadata/ActionMetadata.ts b/src/metadata/ActionMetadata.ts index c0fbc17a..d55eef00 100644 --- a/src/metadata/ActionMetadata.ts +++ b/src/metadata/ActionMetadata.ts @@ -1,13 +1,13 @@ +import { ClassTransformOptions } from 'class-transformer'; import { Action } from '../Action'; +import { HandlerOptions } from '../decorator-options/HandlerOptions'; +import { RoutingControllersOptions } from '../RoutingControllersOptions'; import { ActionMetadataArgs } from './args/ActionMetadataArgs'; -import { ActionType } from './types/ActionType'; -import { ClassTransformOptions } from 'class-transformer'; import { ControllerMetadata } from './ControllerMetadata'; import { InterceptorMetadata } from './InterceptorMetadata'; import { ParamMetadata } from './ParamMetadata'; import { ResponseHandlerMetadata } from './ResponseHandleMetadata'; -import { HandlerOptions } from '../decorator-options/HandlerOptions'; -import { RoutingControllersOptions } from '../RoutingControllersOptions'; +import { ActionType } from './types/ActionType'; import { UseMetadata } from './UseMetadata'; /** @@ -205,7 +205,7 @@ export class ActionMetadata { const renderedTemplateHandler = responseHandlers.find(handler => handler.type === 'rendered-template'); const authorizedHandler = responseHandlers.find(handler => handler.type === 'authorized'); const contentTypeHandler = responseHandlers.find(handler => handler.type === 'content-type'); - const bodyParam = this.params.find(param => param.type === 'body'); + const bodyParam = this.params.find(param => param.type === 'body' || param.type === 'raw-body'); if (classTransformerResponseHandler) this.responseClassTransformOptions = classTransformerResponseHandler.value; @@ -222,7 +222,9 @@ export class ActionMetadata { if (renderedTemplateHandler) this.renderedTemplate = renderedTemplateHandler.value; this.bodyExtraOptions = bodyParam ? bodyParam.extraOptions : undefined; - this.isBodyUsed = !!this.params.find(param => param.type === 'body' || param.type === 'body-param'); + this.isBodyUsed = !!this.params.find( + param => param.type === 'body' || param.type === 'raw-body' || param.type === 'body-param' + ); this.isFilesUsed = !!this.params.find(param => param.type === 'files'); this.isFileUsed = !!this.params.find(param => param.type === 'file'); this.isJsonTyped = diff --git a/src/metadata/types/ParamType.ts b/src/metadata/types/ParamType.ts index b37959a1..41690f89 100644 --- a/src/metadata/types/ParamType.ts +++ b/src/metadata/types/ParamType.ts @@ -3,6 +3,7 @@ */ export type ParamType = | 'body' + | 'raw-body' | 'body-param' | 'query' | 'queries' diff --git a/test/functional/action-params.spec.ts b/test/functional/action-params.spec.ts index 8cf1b920..3ffd0694 100644 --- a/test/functional/action-params.spec.ts +++ b/test/functional/action-params.spec.ts @@ -1,5 +1,6 @@ import bodyParser from 'body-parser'; -import { IsBoolean, IsString, MaxLength, Min, ValidateNested, IsArray, IsNumber, IsDate } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsDate, IsNumber, IsString, MaxLength, Min, ValidateNested } from 'class-validator'; import express from 'express'; import FormData from 'form-data'; import fs from 'fs'; @@ -18,6 +19,7 @@ import { Param } from '../../src/decorator/Param'; import { Post } from '../../src/decorator/Post'; import { QueryParam } from '../../src/decorator/QueryParam'; import { QueryParams } from '../../src/decorator/QueryParams'; +import { RawBody } from '../../src/decorator/RawBody'; import { Req } from '../../src/decorator/Req'; import { Res } from '../../src/decorator/Res'; import { Session } from '../../src/decorator/Session'; @@ -29,7 +31,6 @@ import { createExpressServer, getMetadataArgsStorage } from '../../src/index'; import { SessionMiddleware } from '../fakes/global-options/SessionMiddleware'; import { axios } from '../utilities/axios'; import DoneCallback = jest.DoneCallback; -import { Type, Transform } from 'class-transformer'; describe(``, () => { let expressServer: HttpServer; @@ -55,6 +56,7 @@ describe(``, () => { cookieParamShowAll: boolean | undefined, cookieParamFilter: Record | undefined; let body: string | undefined; + let rawBody: string | undefined; let bodyParamName: string | undefined, bodyParamAge: number | undefined, bodyParamIsActive: boolean | undefined; let expressRequest: express.Request | undefined, expressResponse: express.Response | undefined; const urlencodedParser: any = bodyParser.urlencoded({ extended: true }); @@ -83,6 +85,7 @@ describe(``, () => { cookieParamLimit = undefined; cookieParamFilter = undefined; body = undefined; + rawBody = undefined; bodyParamName = undefined; bodyParamAge = undefined; bodyParamIsActive = undefined; @@ -416,6 +419,13 @@ describe(``, () => { return body; } + @Post('/raw-posts') + postRawPost(@RawBody() requestBody: any, @Body() parsedBody: any): any { + rawBody = requestBody; + body = parsedBody; + return { rawBody: requestBody, parsedBody }; + } + @Post('/posts-with-required') postRequiredPost(@Body({ required: true }) post: string): any { body = post; @@ -528,6 +538,17 @@ describe(``, () => { expect(sessionTestElement).toEqual('@Session test'); }); + it('@RawBody should provide the raw request body before parsing', async () => { + expect.assertions(5); + const response = await axios.post('/raw-posts', { hello: 'world' }); + + expect(rawBody).toEqual('{"hello":"world"}'); + expect(body).toEqual({ hello: 'world' }); + expect(response.status).toEqual(HttpStatusCodes.OK); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.data).toEqual({ rawBody: '{"hello":"world"}', parsedBody: { hello: 'world' } }); + }); + it('@Session(param) should allow to inject empty property', async () => { const response = await axios.get('/session-param-empty'); expect(response.status).toEqual(HttpStatusCodes.OK);