Skip to content

Commit

Permalink
Merge pull request #1578 from friedow/feat/configure-implicit-body-co…
Browse files Browse the repository at this point in the history
…ercion

feat: make coercion of body values configurable
  • Loading branch information
WoH authored Mar 24, 2024
2 parents efdb468 + c4821a3 commit 33a3d20
Show file tree
Hide file tree
Showing 18 changed files with 921 additions and 393 deletions.
5 changes: 5 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ type RouteGeneratorImpl = new (metadata: Tsoa.Metadata, options: ExtendedRoutesC
export interface ExtendedRoutesConfig extends RoutesConfig {
entryFile: Config['entryFile'];
noImplicitAdditionalProperties: Exclude<Config['noImplicitAdditionalProperties'], undefined>;
bodyCoercion: Exclude<RoutesConfig['bodyCoercion'], undefined>;
controllerPathGlobs?: Config['controllerPathGlobs'];
multerOpts?: Config['multerOpts'];
rootSecurity?: Config['spec']['rootSecurity'];
Expand All @@ -202,12 +203,16 @@ const validateRoutesConfig = async (config: Config): Promise<ExtendedRoutesConfi
}

const noImplicitAdditionalProperties = determineNoImplicitAdditionalSetting(config.noImplicitAdditionalProperties);

const bodyCoercion = config.routes.bodyCoercion ?? true;

config.routes.basePath = config.routes.basePath || '/';

return {
...config.routes,
entryFile: config.entryFile,
noImplicitAdditionalProperties,
bodyCoercion,
controllerPathGlobs: config.controllerPathGlobs,
multerOpts: config.multerOpts,
rootSecurity: config.spec.rootSecurity,
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/routeGeneration/routeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { convertBracesPathParams, normalisePath } from '../utils/pathUtils';
import { fsExists, fsReadFile } from '../utils/fs';

export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig> {
constructor(protected readonly metadata: Tsoa.Metadata, protected readonly options: Config) {}
constructor(
protected readonly metadata: Tsoa.Metadata,
protected readonly options: Config,
) {}

/**
* This is the entrypoint for a generator to create a custom set of routes
Expand Down Expand Up @@ -103,8 +106,8 @@ export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig
parameters: parameterObjs,
path: normalisedMethodPath,
uploadFile: uploadFilesWithDifferentFieldParameter.length > 0,
uploadFileName: uploadFilesWithDifferentFieldParameter.map((parameter) => ({
'name': parameter.name,
uploadFileName: uploadFilesWithDifferentFieldParameter.map(parameter => ({
name: parameter.name,
})),
uploadFiles: !!uploadFilesParameter,
uploadFilesName: uploadFilesParameter?.name,
Expand All @@ -119,7 +122,7 @@ export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig
}),
environment: process.env,
iocModule,
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties },
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties, bodyCoercion: this.options.bodyCoercion },
models: this.buildModels(),
useFileUploads: this.metadata.controllers.some(controller =>
controller.methods.some(
Expand Down
10 changes: 8 additions & 2 deletions packages/runtime/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,11 @@ export interface Config {
*/
multerOpts?: MulterOpts;


/*
* OpenAPI number type to be used for TypeScript's 'number', when there isn't a type annotation
* @default double
*/
defaultNumberType?: 'double' | 'float' | 'integer' | 'long'
defaultNumberType?: 'double' | 'float' | 'integer' | 'long';
}

/**
Expand Down Expand Up @@ -258,4 +257,11 @@ export interface RoutesConfig {
* @default false
*/
esm?: boolean;

/*
* Whether to implicitly coerce body parameters into an accepted type.
*
* @default true
*/
bodyCoercion?: boolean;
}
3 changes: 2 additions & 1 deletion packages/runtime/src/routeGeneration/additionalProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Config } from '../config';
import { Config, RoutesConfig } from '../config';

export interface AdditionalProps {
noImplicitAdditionalProperties: Exclude<Config['noImplicitAdditionalProperties'], undefined>;
bodyCoercion: Exclude<RoutesConfig['bodyCoercion'], undefined>;
}
147 changes: 85 additions & 62 deletions packages/runtime/src/routeGeneration/templateHelpers.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,6 @@ type ExpressReturnHandlerParameters = {
};

export class ExpressTemplateService extends TemplateService<ExpressApiHandlerParameters, ExpressValidationArgsParameters, ExpressReturnHandlerParameters> {
constructor(
readonly models: any,
private readonly minimalSwaggerConfig: any,
) {
super(models);
}

async apiHandler(params: ExpressApiHandlerParameters) {
const { methodName, controller, response, validatedArgs, successStatus, next } = params;
const promise = this.buildPromise(methodName, controller, validatedArgs);
Expand Down Expand Up @@ -67,20 +60,20 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
case 'request-prop': {
const descriptor = Object.getOwnPropertyDescriptor(request, name);
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, fieldErrors, false, undefined);
}
case 'query':
return this.validationService.ValidateParam(param, request.query[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.query[name], name, fieldErrors, false, undefined);
case 'queries':
return this.validationService.ValidateParam(param, request.query, name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.query, name, fieldErrors, false, undefined);
case 'path':
return this.validationService.ValidateParam(param, request.params[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.params[name], name, fieldErrors, false, undefined);
case 'header':
return this.validationService.ValidateParam(param, request.header(name), name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.header(name), name, fieldErrors, false, undefined);
case 'body':
return this.validationService.ValidateParam(param, request.body, name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.body, name, fieldErrors, true, undefined);
case 'body-prop':
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, 'body.', this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, true, 'body.');
case 'formData': {
const files = Object.values(args).filter(param => param.dataType === 'file');
if (param.dataType === 'file' && files.length > 0) {
Expand All @@ -89,12 +82,12 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
return undefined;
}

const fileArgs = this.validationService.ValidateParam(param, requestFiles[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
const fileArgs = this.validationService.ValidateParam(param, requestFiles[name], name, fieldErrors, false, undefined);
return fileArgs.length === 1 ? fileArgs[0] : fileArgs;
} else if (param.dataType === 'array' && param.array && param.array.dataType === 'file') {
return this.validationService.ValidateParam(param, request.files, name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.files, name, fieldErrors, false, undefined);
}
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.body[name], name, fieldErrors, false, undefined);
}
case 'res':
return (status: number | undefined, data: any, headers: any) => {
Expand Down Expand Up @@ -129,5 +122,4 @@ export class ExpressTemplateService extends TemplateService<ExpressApiHandlerPar
response.status(statusCode || 204).end();
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FieldErrors } from '../../templateHelpers';
import { TsoaRoute } from '../../tsoa-route';
import { ValidateError } from '../../templateHelpers';
import { TemplateService } from '../templateService';
import { AdditionalProps } from '../../additionalProps';

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

Expand All @@ -32,14 +33,14 @@ type HapiReturnHandlerParameters = {

export class HapiTemplateService extends TemplateService<HapiApiHandlerParameters, HapiValidationArgsParameters, HapiReturnHandlerParameters> {
constructor(
readonly models: any,
private readonly minimalSwaggerConfig: any,
protected readonly models: TsoaRoute.Models,
protected readonly config: AdditionalProps,
private readonly hapi: {
boomify: Function;
isBoom: Function;
},
) {
super(models);
super(models, config);
}

async apiHandler(params: HapiApiHandlerParameters) {
Expand Down Expand Up @@ -83,27 +84,27 @@ export class HapiTemplateService extends TemplateService<HapiApiHandlerParameter
case 'request-prop': {
const descriptor = Object.getOwnPropertyDescriptor(request, name);
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
}
case 'query':
return this.validationService.ValidateParam(param, request.query[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.query[name], name, errorFields, false, undefined);
case 'queries':
return this.validationService.ValidateParam(param, request.query, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.query, name, errorFields, false, undefined);
case 'path':
return this.validationService.ValidateParam(param, request.params[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.params[name], name, errorFields, false, undefined);
case 'header':
return this.validationService.ValidateParam(param, request.headers[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.headers[name], name, errorFields, false, undefined);
case 'body':
return this.validationService.ValidateParam(param, request.payload, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, request.payload, name, errorFields, true, undefined);
case 'body-prop': {
const descriptor = Object.getOwnPropertyDescriptor(request.payload, name);
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, 'body.', this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, true, 'body.');
}
case 'formData': {
const descriptor = Object.getOwnPropertyDescriptor(request.payload, name);
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
}
case 'res':
return (status: number | undefined, data: any, headers: any) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,6 @@ type KoaReturnHandlerParameters = {
};

export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters, KoaValidationArgsParameters, KoaReturnHandlerParameters> {
constructor(
readonly models: any,
private readonly minimalSwaggerConfig: any,
) {
super(models);
}

async apiHandler(params: KoaApiHandlerParameters) {
const { methodName, controller, context, validatedArgs, successStatus } = params;
const promise = this.buildPromise(methodName, controller, validatedArgs);
Expand Down Expand Up @@ -66,29 +59,29 @@ export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters,
const name = param.name;
switch (param.in) {
case 'request':
return context.request;
return context.request;
case 'request-prop': {
const descriptor = Object.getOwnPropertyDescriptor(context.request, name);
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, false, undefined);
}
case 'query':
return this.validationService.ValidateParam(param, context.request.query[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, context.request.query[name], name, errorFields, false, undefined);
case 'queries':
return this.validationService.ValidateParam(param, context.request.query, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, context.request.query, name, errorFields, false, undefined);
case 'path':
return this.validationService.ValidateParam(param, context.params[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, context.params[name], name, errorFields, false, undefined);
case 'header':
return this.validationService.ValidateParam(param, context.request.headers[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, context.request.headers[name], name, errorFields, false, undefined);
case 'body': {
const descriptor = Object.getOwnPropertyDescriptor(context.request, 'body');
const value = descriptor ? descriptor.value : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, true, undefined);
}
case 'body-prop': {
const descriptor = Object.getOwnPropertyDescriptor(context.request, 'body');
const value = descriptor ? descriptor.value[name] : undefined;
return this.validationService.ValidateParam(param, value, name, errorFields, 'body.', this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, value, name, errorFields, true, 'body.');
}
case 'formData': {
const files = Object.values(args).filter(param => param.dataType === 'file');
Expand All @@ -98,12 +91,12 @@ export class KoaTemplateService extends TemplateService<KoaApiHandlerParameters,
return undefined;
}

const fileArgs = this.validationService.ValidateParam(param, contextRequest.files[name], name, errorFields, undefined, this.minimalSwaggerConfig);
const fileArgs = this.validationService.ValidateParam(param, contextRequest.files[name], name, errorFields, false, undefined);
return fileArgs.length === 1 ? fileArgs[0] : fileArgs;
} else if (param.dataType === 'array' && param.array && param.array.dataType === 'file') {
return this.validationService.ValidateParam(param, contextRequest.files, name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, contextRequest.files, name, errorFields, false, undefined);
}
return this.validationService.ValidateParam(param, contextRequest.body[name], name, errorFields, undefined, this.minimalSwaggerConfig);
return this.validationService.ValidateParam(param, contextRequest.body[name], name, errorFields, false, undefined);
}
case 'res':
return async (status: number | undefined, data: any, headers: any): Promise<void> => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Controller } from '../../interfaces/controller';
import { TsoaRoute } from '../tsoa-route';
import { ValidationService } from '../templateHelpers';
import { AdditionalProps } from '../additionalProps';

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

constructor(
protected readonly models: TsoaRoute.Models,
protected readonly config: AdditionalProps,
) {
this.validationService = new ValidationService(models);
this.validationService = new ValidationService(models, config);
}

abstract apiHandler(params: ApiHandlerParameters): Promise<any>;
Expand Down
1 change: 1 addition & 0 deletions tests/esm/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const log = async <T>(label: string, fn: () => Promise<T>) => {
generateRoutes(
{
noImplicitAdditionalProperties: 'silently-remove-extras',
bodyCoercion: true,
basePath: '/v1',
entryFile: './fixtures/express/server.ts',
middleware: 'express',
Expand Down
1 change: 1 addition & 0 deletions tests/esm/unit/templating/routeGenerator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('RouteGenerator', () => {
const routesConfig: ExtendedRoutesConfig = {
entryFile: 'index.ts',
noImplicitAdditionalProperties: 'silently-remove-extras',
bodyCoercion: true,
routesDir: 'dist/routes',
controllerPathGlobs: ['fixtures/controllers/*.ts'],
routeGenerator: DummyRouteGenerator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,19 @@ export default class ServerlessRouteGenerator extends AbstractRouteGenerator<Ser

/**
* Generate the CDK infrastructure stack that ties API Gateway to generated Handlers
* @returns
* @returns
*/
async generateStack(): Promise<void> {
// 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
const templateFileName = this.options.stackTemplate;
const fileName = `${this.options.routesDir}/stack.ts`;
const context = this.buildContext() as unknown as any;
context.controllers = context.controllers.map((controller) => {
controller.actions = controller.actions.map((action) => {
context.controllers = context.controllers.map(controller => {
controller.actions = controller.actions.map(action => {
return {
...action,
handlerFolderName:`${this.options.routesDir}/${controller.name}`
}
handlerFolderName: `${this.options.routesDir}/${controller.name}`,
};
});
return controller;
});
Expand All @@ -95,7 +95,7 @@ export default class ServerlessRouteGenerator extends AbstractRouteGenerator<Ser
const fileName = `${this.options.routesDir}/${this.options.modelsFileName || 'models.ts'}`;
const context = {
models: this.buildModels(),
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties },
minimalSwaggerConfig: { noImplicitAdditionalProperties: this.options.noImplicitAdditionalProperties, bodyCoercion: this.options.bodyCoercion },
};
await this.generateFileFromTemplate(templateFileName, context, fileName);
}
Expand Down
Loading

0 comments on commit 33a3d20

Please sign in to comment.