diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index a8d2945..dfcee35 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -204,6 +204,89 @@ test('POST: 400', async () => { ).rejects.toHaveProperty('response.status', 400) }) +test('POST: nested validation', async () => { + const res1 = await client.users.post({ + body: { + id: 123, + name: 'foo', + location: { + country: 'JP', + stateProvince: 'Tokyo' + } + } + }) + expect(res1.status).toBe(204) + + // Note that extraneous properties are allowed by default + const res2 = await client.users.post({ + body: { + id: 123, + name: 'foo', + location: { + country: 'JP', + stateProvince: 'Tokyo', + extra1: { + extra1a: 'bar', + extra1b: 'baz' + } + }, + extra2: 'qux' + } as any + }) + expect(res2.status).toBe(204) +}) + +test('POST: 400 (nested validation)', async () => { + // id is not a number + await expect( + client.users.post({ + body: { + id: '123', + name: 'foo', + location: { + country: 'JP', + stateProvince: 'Tokyo' + } + } as any + }) + ).rejects.toHaveProperty('response.status', 400) + + // location is missing + await expect( + client.users.post({ + body: { id: 123, name: 'foo' } as any + }) + ).rejects.toHaveProperty('response.status', 400) + + // country is not a valid 2-letter country code + await expect( + client.users.post({ + body: { + id: 123, + name: 'foo', + location: { + country: 'XX', + stateProvince: 'Tokyo' + } + } as any + }) + ).rejects.toHaveProperty('response.status', 400) + + // stateProvince is not a string + await expect( + client.users.post({ + body: { + id: 123, + name: 'foo', + location: { + country: 'JP', + stateProvince: 1234 + } + } as any + }) + ).rejects.toHaveProperty('response.status', 400) +}) + test('controller dependency injection', async () => { let val = 0 const id = '5' diff --git a/package.json b/package.json index 364e808..7938177 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.1", "@typescript-eslint/parser": "^4.28.1", "axios": "^0.21.1", + "class-transformer": "^0.5.1", "class-validator": "^0.13.1", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", @@ -109,6 +110,7 @@ "jest": "^27.0.6", "node-fetch": "^2.6.1", "prettier": "^2.3.2", + "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "standard-version": "^9.3.0", "ts-jest": "^27.0.3", diff --git a/servers/all/$server.ts b/servers/all/$server.ts index 88abc85..e8cc420 100644 --- a/servers/all/$server.ts +++ b/servers/all/$server.ts @@ -1,9 +1,13 @@ /* eslint-disable */ // prettier-ignore -import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart' +import 'reflect-metadata' +// prettier-ignore +import { ClassTransformOptions, plainToInstance } from 'class-transformer' // prettier-ignore import { validateOrReject, ValidatorOptions } from 'class-validator' // prettier-ignore +import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart' +// prettier-ignore import * as Validators from './validators' // prettier-ignore import hooksFn0 from './api/hooks' @@ -43,6 +47,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas // prettier-ignore export type FrourioOptions = { basePath?: string + transformer?: ClassTransformOptions validator?: ValidatorOptions multipart?: FastifyMultipartAttactFieldsToBodyOptions } @@ -260,6 +265,7 @@ const asyncMethodToHandler = ( // prettier-ignore export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { const basePath = options.basePath ?? '' + const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer } const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator } const hooks0 = hooksFn0(fastify) const hooks1 = hooksFn1(fastify) @@ -292,7 +298,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { callParserIfExistsQuery(parseBooleanTypeQueryParams([['bool', false, false], ['optionalBool', true, false], ['boolArray', false, true], ['optionalBoolArray', true, true]])), normalizeQuery, createValidateHandler(req => [ - Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null + Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null ]) ] }, @@ -310,8 +316,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { formatMultipartData([]), normalizeQuery, createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions), - validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions), + validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions) ]) ] }, @@ -344,7 +350,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { preValidation: [ formatMultipartData([['requiredArr', false], ['optionalArr', true], ['empty', true], ['vals', false], ['files', false]]), createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.MultiForm(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.MultiForm, req.body as any, transformerOptions), validatorOptions) ]) ] }, @@ -404,7 +410,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { onRequest: [...hooks0.onRequest, hooks2.onRequest], preParsing: hooks0.preParsing, preValidation: createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions) ]), preHandler: ctrlHooks1.preHandler } as RouteShorthandOptions, diff --git a/servers/all/api/users/_userId@number/controller.ts b/servers/all/api/users/_userId@number/controller.ts index c6acbb2..5889807 100644 --- a/servers/all/api/users/_userId@number/controller.ts +++ b/servers/all/api/users/_userId@number/controller.ts @@ -5,5 +5,15 @@ export type AdditionalRequest = { } export default defineController(() => ({ - get: ({ params }) => ({ status: 200, body: { id: params.userId, name: 'bbb' } }) + get: ({ params }) => ({ + status: 200, + body: { + id: params.userId, + name: 'bbb', + location: { + country: 'JP', + stateProvince: 'Tokyo' + } + } + }) })) diff --git a/servers/all/api/users/controller.ts b/servers/all/api/users/controller.ts index 31aef00..040c344 100644 --- a/servers/all/api/users/controller.ts +++ b/servers/all/api/users/controller.ts @@ -16,6 +16,18 @@ const hooks = defineHooks(() => ({ export { hooks, AdditionalRequest } export default defineController(() => ({ - get: async () => ({ status: 200, body: [{ id: 1, name: 'aa' }] }), + get: async () => ({ + status: 200, + body: [ + { + id: 1, + name: 'aa', + location: { + country: 'JP', + stateProvince: 'Tokyo' + } + } + ] + }), post: () => ({ status: 204 }) })) diff --git a/servers/all/validators/index.ts b/servers/all/validators/index.ts index 2397f34..68e5116 100644 --- a/servers/all/validators/index.ts +++ b/servers/all/validators/index.ts @@ -1,3 +1,4 @@ +import { Type } from 'class-transformer' import { IsNumberString, IsBooleanString, @@ -8,7 +9,10 @@ import { IsString, Allow, IsOptional, - ArrayNotEmpty + ArrayNotEmpty, + IsISO31661Alpha2, + ValidateNested, + IsObject } from 'class-validator' import type { ReadStream } from 'fs' @@ -52,12 +56,27 @@ export class Body { file: File | ReadStream } +export class UserInfoLocation { + @IsISO31661Alpha2() + country: string + + @IsString() + stateProvince: string +} + export class UserInfo { @IsInt() id: number @MaxLength(20) name: string + + // @Type decorator is required to validate nested object properly + // @IsObject decorator is required or class-validator will not throw an error when the property is missing + @ValidateNested() + @IsObject() + @Type(() => UserInfoLocation) + location: UserInfoLocation } export class MultiForm { diff --git a/servers/noMulter/$server.ts b/servers/noMulter/$server.ts index a0e2bd9..ccfb92c 100644 --- a/servers/noMulter/$server.ts +++ b/servers/noMulter/$server.ts @@ -1,5 +1,9 @@ /* eslint-disable */ // prettier-ignore +import 'reflect-metadata' +// prettier-ignore +import { ClassTransformOptions, plainToInstance } from 'class-transformer' +// prettier-ignore import { validateOrReject, ValidatorOptions } from 'class-validator' // prettier-ignore import * as Validators from './validators' @@ -27,6 +31,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas // prettier-ignore export type FrourioOptions = { basePath?: string + transformer?: ClassTransformOptions validator?: ValidatorOptions } @@ -122,6 +127,7 @@ const asyncMethodToHandler = ( // prettier-ignore export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { const basePath = options.basePath ?? '' + const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer } const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator } const hooks0 = hooksFn0(fastify) const hooks1 = hooksFn1(fastify) @@ -139,7 +145,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { { onRequest: [hooks0.onRequest, ctrlHooks0.onRequest], preValidation: createValidateHandler(req => [ - Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null + Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null ]) }, asyncMethodToHandler(controller0.get) @@ -150,8 +156,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { { onRequest: [hooks0.onRequest, ctrlHooks0.onRequest], preValidation: createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions), - validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions), + validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions) ]) }, methodToHandler(controller0.post) @@ -203,7 +209,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { { onRequest: [hooks0.onRequest, hooks1.onRequest], preValidation: createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions) ]), preHandler: ctrlHooks1.preHandler } as RouteShorthandOptions, diff --git a/servers/noTypedParams/$server.ts b/servers/noTypedParams/$server.ts index 0e1a3f9..f3eb808 100644 --- a/servers/noTypedParams/$server.ts +++ b/servers/noTypedParams/$server.ts @@ -1,9 +1,13 @@ /* eslint-disable */ // prettier-ignore -import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart' +import 'reflect-metadata' +// prettier-ignore +import { ClassTransformOptions, plainToInstance } from 'class-transformer' // prettier-ignore import { validateOrReject, ValidatorOptions } from 'class-validator' // prettier-ignore +import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart' +// prettier-ignore import * as Validators from './validators' // prettier-ignore import hooksFn0 from './api/hooks' @@ -31,6 +35,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas // prettier-ignore export type FrourioOptions = { basePath?: string + transformer?: ClassTransformOptions validator?: ValidatorOptions multipart?: FastifyMultipartAttactFieldsToBodyOptions } @@ -146,6 +151,7 @@ const asyncMethodToHandler = ( // prettier-ignore export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { const basePath = options.basePath ?? '' + const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer } const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator } const hooks0 = hooksFn0(fastify) const hooks1 = hooksFn1(fastify) @@ -165,7 +171,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { { onRequest: [hooks0.onRequest, ctrlHooks0.onRequest], preValidation: createValidateHandler(req => [ - Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null + Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null ]) }, asyncMethodToHandler(controller0.get) @@ -178,8 +184,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { preValidation: [ formatMultipartData([]), createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions), - validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions), + validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions) ]) ] }, @@ -201,7 +207,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { preValidation: [ formatMultipartData([['empty', false], ['vals', false], ['files', false]]), createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.MultiForm(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.MultiForm, req.body as any, transformerOptions), validatorOptions) ]) ] }, @@ -246,7 +252,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { { onRequest: [hooks0.onRequest, hooks1.onRequest], preValidation: createValidateHandler(req => [ - validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions) + validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions) ]), preHandler: ctrlHooks1.preHandler } as RouteShorthandOptions, diff --git a/src/buildServerFile.ts b/src/buildServerFile.ts index f638d0c..d358d29 100644 --- a/src/buildServerFile.ts +++ b/src/buildServerFile.ts @@ -29,10 +29,16 @@ export default (input: string, project?: string) => { return { text: addPrettierIgnore(`/* eslint-disable */${ + hasValidator + ? "\nimport 'reflect-metadata'" + + "\nimport { ClassTransformOptions, plainToInstance } from 'class-transformer'" + + "\nimport { validateOrReject, ValidatorOptions } from 'class-validator'" + : '' + }${ hasMultipart ? "\nimport multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart'" : '' - }${hasValidator ? "\nimport { validateOrReject, ValidatorOptions } from 'class-validator'" : ''} + } ${hasValidator ? "import * as Validators from './validators'\n" : ''}${imports}${ hasMultipart ? "import type { ReadStream } from 'fs'\n" : '' }import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams } from 'aspida' @@ -46,7 +52,7 @@ import type { FastifyInstance, RouteHandlerMethod${ export type FrourioOptions = { basePath?: string -${hasValidator ? ' validator?: ValidatorOptions\n' : ''}${ +${hasValidator ? ' transformer?: ClassTransformOptions\n validator?: ValidatorOptions\n' : ''}${ hasMultipart ? ' multipart?: FastifyMultipartAttactFieldsToBodyOptions\n' : '' }} @@ -262,7 +268,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => { const basePath = options.basePath ?? '' ${ hasValidator - ? ' const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }\n' + ? ' const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }\n' + + ' const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }\n' : '' }${consts} ${ diff --git a/src/createControllersText.ts b/src/createControllersText.ts index 2588053..3dae9ef 100644 --- a/src/createControllersText.ts +++ b/src/createControllersText.ts @@ -482,9 +482,9 @@ ${validateInfo v.type ? ` ${ v.hasQuestion ? `Object.keys(req.${v.name} as any).length ? ` : '' - }validateOrReject(Object.assign(new Validators.${checker.typeToString(v.type)}(), req.${ + }validateOrReject(plainToInstance(Validators.${checker.typeToString(v.type)}, req.${ v.name - } as any), validatorOptions)${v.hasQuestion ? ' : null' : ''}` + } as any, transformerOptions), validatorOptions)${v.hasQuestion ? ' : null' : ''}` : '' ) .join(',\n')}\n ])` diff --git a/yarn.lock b/yarn.lock index 1c3a25a..a8edb65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1555,6 +1555,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.1.tgz#2fd46d9906a126965aa541345c499aaa18e8cd73" integrity sha512-jVamGdJPDeuQilKhvVn1h3knuMOZzr8QDnpk+M9aMlCaMkTDd6fBWPhiDqFvFZ07pL0liqabAiuy8SY4jGHeaw== +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + class-validator@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.13.1.tgz#381b2001ee6b9e05afd133671fbdf760da7dec67" @@ -4636,6 +4641,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + regexpp@^3.0.0, regexpp@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"