Skip to content

Commit 33a3d20

Browse files
authored
Merge pull request #1578 from friedow/feat/configure-implicit-body-coercion
feat: make coercion of body values configurable
2 parents efdb468 + c4821a3 commit 33a3d20

File tree

18 files changed

+921
-393
lines changed

18 files changed

+921
-393
lines changed

packages/cli/src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ type RouteGeneratorImpl = new (metadata: Tsoa.Metadata, options: ExtendedRoutesC
176176
export interface ExtendedRoutesConfig extends RoutesConfig {
177177
entryFile: Config['entryFile'];
178178
noImplicitAdditionalProperties: Exclude<Config['noImplicitAdditionalProperties'], undefined>;
179+
bodyCoercion: Exclude<RoutesConfig['bodyCoercion'], undefined>;
179180
controllerPathGlobs?: Config['controllerPathGlobs'];
180181
multerOpts?: Config['multerOpts'];
181182
rootSecurity?: Config['spec']['rootSecurity'];
@@ -202,12 +203,16 @@ const validateRoutesConfig = async (config: Config): Promise<ExtendedRoutesConfi
202203
}
203204

204205
const noImplicitAdditionalProperties = determineNoImplicitAdditionalSetting(config.noImplicitAdditionalProperties);
206+
207+
const bodyCoercion = config.routes.bodyCoercion ?? true;
208+
205209
config.routes.basePath = config.routes.basePath || '/';
206210

207211
return {
208212
...config.routes,
209213
entryFile: config.entryFile,
210214
noImplicitAdditionalProperties,
215+
bodyCoercion,
211216
controllerPathGlobs: config.controllerPathGlobs,
212217
multerOpts: config.multerOpts,
213218
rootSecurity: config.spec.rootSecurity,

packages/cli/src/routeGeneration/routeGenerator.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { convertBracesPathParams, normalisePath } from '../utils/pathUtils';
66
import { fsExists, fsReadFile } from '../utils/fs';
77

88
export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig> {
9-
constructor(protected readonly metadata: Tsoa.Metadata, protected readonly options: Config) {}
9+
constructor(
10+
protected readonly metadata: Tsoa.Metadata,
11+
protected readonly options: Config,
12+
) {}
1013

1114
/**
1215
* This is the entrypoint for a generator to create a custom set of routes
@@ -103,8 +106,8 @@ export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig
103106
parameters: parameterObjs,
104107
path: normalisedMethodPath,
105108
uploadFile: uploadFilesWithDifferentFieldParameter.length > 0,
106-
uploadFileName: uploadFilesWithDifferentFieldParameter.map((parameter) => ({
107-
'name': parameter.name,
109+
uploadFileName: uploadFilesWithDifferentFieldParameter.map(parameter => ({
110+
name: parameter.name,
108111
})),
109112
uploadFiles: !!uploadFilesParameter,
110113
uploadFilesName: uploadFilesParameter?.name,
@@ -119,7 +122,7 @@ export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig
119122
}),
120123
environment: process.env,
121124
iocModule,
122-
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties },
125+
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties, bodyCoercion: this.options.bodyCoercion },
123126
models: this.buildModels(),
124127
useFileUploads: this.metadata.controllers.some(controller =>
125128
controller.methods.some(

packages/runtime/src/config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,11 @@ export interface Config {
5050
*/
5151
multerOpts?: MulterOpts;
5252

53-
5453
/*
5554
* OpenAPI number type to be used for TypeScript's 'number', when there isn't a type annotation
5655
* @default double
5756
*/
58-
defaultNumberType?: 'double' | 'float' | 'integer' | 'long'
57+
defaultNumberType?: 'double' | 'float' | 'integer' | 'long';
5958
}
6059

6160
/**
@@ -258,4 +257,11 @@ export interface RoutesConfig {
258257
* @default false
259258
*/
260259
esm?: boolean;
260+
261+
/*
262+
* Whether to implicitly coerce body parameters into an accepted type.
263+
*
264+
* @default true
265+
*/
266+
bodyCoercion?: boolean;
261267
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Config } from '../config';
1+
import { Config, RoutesConfig } from '../config';
22

33
export interface AdditionalProps {
44
noImplicitAdditionalProperties: Exclude<Config['noImplicitAdditionalProperties'], undefined>;
5+
bodyCoercion: Exclude<RoutesConfig['bodyCoercion'], undefined>;
56
}

packages/runtime/src/routeGeneration/templateHelpers.ts

Lines changed: 85 additions & 62 deletions
Large diffs are not rendered by default.

packages/runtime/src/routeGeneration/templates/express/expressTemplateService.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,6 @@ type ExpressReturnHandlerParameters = {
2929
};
3030

3131
export class ExpressTemplateService extends TemplateService<ExpressApiHandlerParameters, ExpressValidationArgsParameters, ExpressReturnHandlerParameters> {
32-
constructor(
33-
readonly models: any,
34-
private readonly minimalSwaggerConfig: any,
35-
) {
36-
super(models);
37-
}
38-
3932
async apiHandler(params: ExpressApiHandlerParameters) {
4033
const { methodName, controller, response, validatedArgs, successStatus, next } = params;
4134
const promise = this.buildPromise(methodName, controller, validatedArgs);
@@ -67,20 +60,20 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
6760
case 'request-prop': {
6861
const descriptor = Object.getOwnPropertyDescriptor(request, name);
6962
const value = descriptor ? descriptor.value : undefined;
70-
return this.validationService.ValidateParam(param, value, name, fieldErrors, undefined, this.minimalSwaggerConfig);
63+
return this.validationService.ValidateParam(param, value, name, fieldErrors, false, undefined);
7164
}
7265
case 'query':
73-
return this.validationService.ValidateParam(param, request.query[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
66+
return this.validationService.ValidateParam(param, request.query[name], name, fieldErrors, false, undefined);
7467
case 'queries':
75-
return this.validationService.ValidateParam(param, request.query, name, fieldErrors, undefined, this.minimalSwaggerConfig);
68+
return this.validationService.ValidateParam(param, request.query, name, fieldErrors, false, undefined);
7669
case 'path':
77-
return this.validationService.ValidateParam(param, request.params[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
70+
return this.validationService.ValidateParam(param, request.params[name], name, fieldErrors, false, undefined);
7871
case 'header':
79-
return this.validationService.ValidateParam(param, request.header(name), name, fieldErrors, undefined, this.minimalSwaggerConfig);
72+
return this.validationService.ValidateParam(param, request.header(name), name, fieldErrors, false, undefined);
8073
case 'body':
81-
return this.validationService.ValidateParam(param, request.body, name, fieldErrors, undefined, this.minimalSwaggerConfig);
74+
return this.validationService.ValidateParam(param, request.body, name, fieldErrors, true, undefined);
8275
case 'body-prop':
83-
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, 'body.', this.minimalSwaggerConfig);
76+
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, true, 'body.');
8477
case 'formData': {
8578
const files = Object.values(args).filter(param => param.dataType === 'file');
8679
if (param.dataType === 'file' && files.length > 0) {
@@ -89,12 +82,12 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
8982
return undefined;
9083
}
9184

92-
const fileArgs = this.validationService.ValidateParam(param, requestFiles[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
85+
const fileArgs = this.validationService.ValidateParam(param, requestFiles[name], name, fieldErrors, false, undefined);
9386
return fileArgs.length === 1 ? fileArgs[0] : fileArgs;
9487
} else if (param.dataType === 'array' && param.array && param.array.dataType === 'file') {
95-
return this.validationService.ValidateParam(param, request.files, name, fieldErrors, undefined, this.minimalSwaggerConfig);
88+
return this.validationService.ValidateParam(param, request.files, name, fieldErrors, false, undefined);
9689
}
97-
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
90+
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, false, undefined);
9891
}
9992
case 'res':
10093
return (status: number | undefined, data: any, headers: any) => {
@@ -129,5 +122,4 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
129122
response.status(statusCode || 204).end();
130123
}
131124
}
132-
133125
}

packages/runtime/src/routeGeneration/templates/hapi/hapiTemplateService.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FieldErrors } from '../../templateHelpers';
66
import { TsoaRoute } from '../../tsoa-route';
77
import { ValidateError } from '../../templateHelpers';
88
import { TemplateService } from '../templateService';
9+
import { AdditionalProps } from '../../additionalProps';
910

1011
const hapiTsoaResponsed = Symbol('@tsoa:template_service:hapi:responsed');
1112

@@ -32,14 +33,14 @@ type HapiReturnHandlerParameters = {
3233

3334
export class HapiTemplateService extends TemplateService<HapiApiHandlerParameters, HapiValidationArgsParameters, HapiReturnHandlerParameters> {
3435
constructor(
35-
readonly models: any,
36-
private readonly minimalSwaggerConfig: any,
36+
protected readonly models: TsoaRoute.Models,
37+
protected readonly config: AdditionalProps,
3738
private readonly hapi: {
3839
boomify: Function;
3940
isBoom: Function;
4041
},
4142
) {
42-
super(models);
43+
super(models, config);
4344
}
4445

4546
async apiHandler(params: HapiApiHandlerParameters) {
@@ -83,27 +84,27 @@ export class HapiTemplateService extends TemplateService<HapiApiHandlerParameter
8384
case 'request-prop': {
8485
const descriptor = Object.getOwnPropertyDescriptor(request, name);
8586
const value = descriptor ? descriptor.value : undefined;
86-
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
87+
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
8788
}
8889
case 'query':
89-
return this.validationService.ValidateParam(param, request.query[name], name, errorFields, undefined, this.minimalSwaggerConfig);
90+
return this.validationService.ValidateParam(param, request.query[name], name, errorFields, false, undefined);
9091
case 'queries':
91-
return this.validationService.ValidateParam(param, request.query, name, errorFields, undefined, this.minimalSwaggerConfig);
92+
return this.validationService.ValidateParam(param, request.query, name, errorFields, false, undefined);
9293
case 'path':
93-
return this.validationService.ValidateParam(param, request.params[name], name, errorFields, undefined, this.minimalSwaggerConfig);
94+
return this.validationService.ValidateParam(param, request.params[name], name, errorFields, false, undefined);
9495
case 'header':
95-
return this.validationService.ValidateParam(param, request.headers[name], name, errorFields, undefined, this.minimalSwaggerConfig);
96+
return this.validationService.ValidateParam(param, request.headers[name], name, errorFields, false, undefined);
9697
case 'body':
97-
return this.validationService.ValidateParam(param, request.payload, name, errorFields, undefined, this.minimalSwaggerConfig);
98+
return this.validationService.ValidateParam(param, request.payload, name, errorFields, true, undefined);
9899
case 'body-prop': {
99100
const descriptor = Object.getOwnPropertyDescriptor(request.payload, name);
100101
const value = descriptor ? descriptor.value : undefined;
101-
return this.validationService.ValidateParam(param, value, name, errorFields, 'body.', this.minimalSwaggerConfig);
102+
return this.validationService.ValidateParam(param, value, name, errorFields, true, 'body.');
102103
}
103104
case 'formData': {
104105
const descriptor = Object.getOwnPropertyDescriptor(request.payload, name);
105106
const value = descriptor ? descriptor.value : undefined;
106-
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
107+
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
107108
}
108109
case 'res':
109110
return (status: number | undefined, data: any, headers: any) => {

packages/runtime/src/routeGeneration/templates/koa/koaTemplateService.ts

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ type KoaReturnHandlerParameters = {
3131
};
3232

3333
export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters, KoaValidationArgsParameters, KoaReturnHandlerParameters> {
34-
constructor(
35-
readonly models: any,
36-
private readonly minimalSwaggerConfig: any,
37-
) {
38-
super(models);
39-
}
40-
4134
async apiHandler(params: KoaApiHandlerParameters) {
4235
const { methodName, controller, context, validatedArgs, successStatus } = params;
4336
const promise = this.buildPromise(methodName, controller, validatedArgs);
@@ -66,29 +59,29 @@ export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters,
6659
const name = param.name;
6760
switch (param.in) {
6861
case 'request':
69-
return context.request;
62+
return context.request;
7063
case 'request-prop': {
7164
const descriptor = Object.getOwnPropertyDescriptor(context.request, name);
7265
const value = descriptor ? descriptor.value : undefined;
73-
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
66+
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
7467
}
7568
case 'query':
76-
return this.validationService.ValidateParam(param, context.request.query[name], name, errorFields, undefined, this.minimalSwaggerConfig);
69+
return this.validationService.ValidateParam(param, context.request.query[name], name, errorFields, false, undefined);
7770
case 'queries':
78-
return this.validationService.ValidateParam(param, context.request.query, name, errorFields, undefined, this.minimalSwaggerConfig);
71+
return this.validationService.ValidateParam(param, context.request.query, name, errorFields, false, undefined);
7972
case 'path':
80-
return this.validationService.ValidateParam(param, context.params[name], name, errorFields, undefined, this.minimalSwaggerConfig);
73+
return this.validationService.ValidateParam(param, context.params[name], name, errorFields, false, undefined);
8174
case 'header':
82-
return this.validationService.ValidateParam(param, context.request.headers[name], name, errorFields, undefined, this.minimalSwaggerConfig);
75+
return this.validationService.ValidateParam(param, context.request.headers[name], name, errorFields, false, undefined);
8376
case 'body': {
8477
const descriptor = Object.getOwnPropertyDescriptor(context.request, 'body');
8578
const value = descriptor ? descriptor.value : undefined;
86-
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
79+
return this.validationService.ValidateParam(param, value, name, errorFields, true, undefined);
8780
}
8881
case 'body-prop': {
8982
const descriptor = Object.getOwnPropertyDescriptor(context.request, 'body');
9083
const value = descriptor ? descriptor.value[name] : undefined;
91-
return this.validationService.ValidateParam(param, value, name, errorFields, 'body.', this.minimalSwaggerConfig);
84+
return this.validationService.ValidateParam(param, value, name, errorFields, true, 'body.');
9285
}
9386
case 'formData': {
9487
const files = Object.values(args).filter(param => param.dataType === 'file');
@@ -98,12 +91,12 @@ export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters,
9891
return undefined;
9992
}
10093

101-
const fileArgs = this.validationService.ValidateParam(param, contextRequest.files[name], name, errorFields, undefined, this.minimalSwaggerConfig);
94+
const fileArgs = this.validationService.ValidateParam(param, contextRequest.files[name], name, errorFields, false, undefined);
10295
return fileArgs.length === 1 ? fileArgs[0] : fileArgs;
10396
} else if (param.dataType === 'array' && param.array && param.array.dataType === 'file') {
104-
return this.validationService.ValidateParam(param, contextRequest.files, name, errorFields, undefined, this.minimalSwaggerConfig);
97+
return this.validationService.ValidateParam(param, contextRequest.files, name, errorFields, false, undefined);
10598
}
106-
return this.validationService.ValidateParam(param, contextRequest.body[name], name, errorFields, undefined, this.minimalSwaggerConfig);
99+
return this.validationService.ValidateParam(param, contextRequest.body[name], name, errorFields, false, undefined);
107100
}
108101
case 'res':
109102
return async (status: number | undefined, data: any, headers: any): Promise<void> => {

packages/runtime/src/routeGeneration/templates/templateService.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { Controller } from '../../interfaces/controller';
22
import { TsoaRoute } from '../tsoa-route';
33
import { ValidationService } from '../templateHelpers';
4+
import { AdditionalProps } from '../additionalProps';
45

56
export abstract class TemplateService<ApiHandlerParameters, ValidationArgsParameters, ReturnHandlerParameters> {
67
protected validationService: ValidationService;
78

89
constructor(
910
protected readonly models: TsoaRoute.Models,
11+
protected readonly config: AdditionalProps,
1012
) {
11-
this.validationService = new ValidationService(models);
13+
this.validationService = new ValidationService(models, config);
1214
}
1315

1416
abstract apiHandler(params: ApiHandlerParameters): Promise<any>;

tests/esm/prepare.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const log = async <T>(label: string, fn: () => Promise<T>) => {
2929
generateRoutes(
3030
{
3131
noImplicitAdditionalProperties: 'silently-remove-extras',
32+
bodyCoercion: true,
3233
basePath: '/v1',
3334
entryFile: './fixtures/express/server.ts',
3435
middleware: 'express',

tests/esm/unit/templating/routeGenerator.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('RouteGenerator', () => {
1414
const routesConfig: ExtendedRoutesConfig = {
1515
entryFile: 'index.ts',
1616
noImplicitAdditionalProperties: 'silently-remove-extras',
17+
bodyCoercion: true,
1718
routesDir: 'dist/routes',
1819
controllerPathGlobs: ['fixtures/controllers/*.ts'],
1920
routeGenerator: DummyRouteGenerator,

tests/fixtures/custom/custom-route-generator/serverlessRouteGenerator.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,19 @@ export default class ServerlessRouteGenerator extends AbstractRouteGenerator<Ser
7171

7272
/**
7373
* Generate the CDK infrastructure stack that ties API Gateway to generated Handlers
74-
* @returns
74+
* @returns
7575
*/
7676
async generateStack(): Promise<void> {
7777
// This would need to generate a CDK "Stack" that takes the tsoa metadata as input and generates a valid serverless CDK infrastructure stack from template
7878
const templateFileName = this.options.stackTemplate;
7979
const fileName = `${this.options.routesDir}/stack.ts`;
8080
const context = this.buildContext() as unknown as any;
81-
context.controllers = context.controllers.map((controller) => {
82-
controller.actions = controller.actions.map((action) => {
81+
context.controllers = context.controllers.map(controller => {
82+
controller.actions = controller.actions.map(action => {
8383
return {
8484
...action,
85-
handlerFolderName:`${this.options.routesDir}/${controller.name}`
86-
}
85+
handlerFolderName: `${this.options.routesDir}/${controller.name}`,
86+
};
8787
});
8888
return controller;
8989
});
@@ -95,7 +95,7 @@ export default class ServerlessRouteGenerator extends AbstractRouteGenerator<Ser
9595
const fileName = `${this.options.routesDir}/${this.options.modelsFileName || 'models.ts'}`;
9696
const context = {
9797
models: this.buildModels(),
98-
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties },
98+
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties, bodyCoercion: this.options.bodyCoercion },
9999
};
100100
await this.generateFileFromTemplate(templateFileName, context, fileName);
101101
}

0 commit comments

Comments
 (0)