Skip to content

Commit

Permalink
feat(validation): use class-transformer to support validation of nest…
Browse files Browse the repository at this point in the history
…ed objects
  • Loading branch information
SegaraRai committed Jan 31, 2022
1 parent 6671c20 commit 7c19ac5
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 24 deletions.
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

0 comments on commit 7c19ac5

Please sign in to comment.