Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) |
Expand Down
14 changes: 7 additions & 7 deletions src/ActionParameterHandler.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -66,7 +66,7 @@ export class ActionParameterHandler<T extends BaseDriver> {
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') {
Expand Down Expand Up @@ -99,7 +99,7 @@ export class ActionParameterHandler<T extends BaseDriver> {
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) {
Expand Down
21 changes: 21 additions & 0 deletions src/decorator/RawBody.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
}
49 changes: 38 additions & 11 deletions src/driver/express/ExpressDriver.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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.
*/
Expand Down
19 changes: 11 additions & 8 deletions src/driver/koa/KoaDriver.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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];

Expand Down
3 changes: 2 additions & 1 deletion src/error/ParamRequiredError.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,6 +19,7 @@ export class ParamRequiredError extends BadRequestError {
break;

case 'body':
case 'raw-body':
paramName = 'Request body is';
break;

Expand Down
21 changes: 11 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ValidationOptions } from 'class-validator';
import { CustomParameterDecorator } from './CustomParameterDecorator';
import { BaseDriver } from './driver/BaseDriver';
import { ExpressDriver } from './driver/express/ExpressDriver';
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';

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down
14 changes: 8 additions & 6 deletions src/metadata/ActionMetadata.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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;

Expand All @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions src/metadata/types/ParamType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
export type ParamType =
| 'body'
| 'raw-body'
| 'body-param'
| 'query'
| 'queries'
Expand Down
Loading