Skip to content

feat(validation): use class-transformer to support validation of nested objects #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 4, 2022
Merged
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
83 changes: 83 additions & 0 deletions __test__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions servers/all/$server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -43,6 +47,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
multipart?: FastifyMultipartAttactFieldsToBodyOptions
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
])
]
},
Expand All @@ -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)
])
]
},
Expand Down Expand Up @@ -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)
])
]
},
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion servers/all/api/users/_userId@number/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
})
}))
14 changes: 13 additions & 1 deletion servers/all/api/users/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}))
21 changes: 20 additions & 1 deletion servers/all/validators/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Type } from 'class-transformer'
import {
IsNumberString,
IsBooleanString,
Expand All @@ -8,7 +9,10 @@ import {
IsString,
Allow,
IsOptional,
ArrayNotEmpty
ArrayNotEmpty,
IsISO31661Alpha2,
ValidateNested,
IsObject
} from 'class-validator'
import type { ReadStream } from 'fs'

Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions servers/noMulter/$server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -27,6 +31,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 12 additions & 6 deletions servers/noTypedParams/$server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,6 +35,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
multipart?: FastifyMultipartAttactFieldsToBodyOptions
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
])
]
},
Expand All @@ -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)
])
]
},
Expand Down Expand Up @@ -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,
Expand Down
Loading