Skip to content

Commit 9fc52b9

Browse files
authored
Merge pull request #188 from SegaraRai/pr-ct
feat(validation): use class-transformer to support validation of nested objects
2 parents bcb6d0d + 7c19ac5 commit 9fc52b9

File tree

11 files changed

+185
-24
lines changed

11 files changed

+185
-24
lines changed

__test__/index.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,89 @@ test('POST: 400', async () => {
204204
).rejects.toHaveProperty('response.status', 400)
205205
})
206206

207+
test('POST: nested validation', async () => {
208+
const res1 = await client.users.post({
209+
body: {
210+
id: 123,
211+
name: 'foo',
212+
location: {
213+
country: 'JP',
214+
stateProvince: 'Tokyo'
215+
}
216+
}
217+
})
218+
expect(res1.status).toBe(204)
219+
220+
// Note that extraneous properties are allowed by default
221+
const res2 = await client.users.post({
222+
body: {
223+
id: 123,
224+
name: 'foo',
225+
location: {
226+
country: 'JP',
227+
stateProvince: 'Tokyo',
228+
extra1: {
229+
extra1a: 'bar',
230+
extra1b: 'baz'
231+
}
232+
},
233+
extra2: 'qux'
234+
} as any
235+
})
236+
expect(res2.status).toBe(204)
237+
})
238+
239+
test('POST: 400 (nested validation)', async () => {
240+
// id is not a number
241+
await expect(
242+
client.users.post({
243+
body: {
244+
id: '123',
245+
name: 'foo',
246+
location: {
247+
country: 'JP',
248+
stateProvince: 'Tokyo'
249+
}
250+
} as any
251+
})
252+
).rejects.toHaveProperty('response.status', 400)
253+
254+
// location is missing
255+
await expect(
256+
client.users.post({
257+
body: { id: 123, name: 'foo' } as any
258+
})
259+
).rejects.toHaveProperty('response.status', 400)
260+
261+
// country is not a valid 2-letter country code
262+
await expect(
263+
client.users.post({
264+
body: {
265+
id: 123,
266+
name: 'foo',
267+
location: {
268+
country: 'XX',
269+
stateProvince: 'Tokyo'
270+
}
271+
} as any
272+
})
273+
).rejects.toHaveProperty('response.status', 400)
274+
275+
// stateProvince is not a string
276+
await expect(
277+
client.users.post({
278+
body: {
279+
id: 123,
280+
name: 'foo',
281+
location: {
282+
country: 'JP',
283+
stateProvince: 1234
284+
}
285+
} as any
286+
})
287+
).rejects.toHaveProperty('response.status', 400)
288+
})
289+
207290
test('controller dependency injection', async () => {
208291
let val = 0
209292
const id = '5'

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@typescript-eslint/eslint-plugin": "^4.28.1",
9696
"@typescript-eslint/parser": "^4.28.1",
9797
"axios": "^0.21.1",
98+
"class-transformer": "^0.5.1",
9899
"class-validator": "^0.13.1",
99100
"eslint": "^7.30.0",
100101
"eslint-config-prettier": "^8.3.0",
@@ -109,6 +110,7 @@
109110
"jest": "^27.0.6",
110111
"node-fetch": "^2.6.1",
111112
"prettier": "^2.3.2",
113+
"reflect-metadata": "^0.1.13",
112114
"rimraf": "^3.0.2",
113115
"standard-version": "^9.3.0",
114116
"ts-jest": "^27.0.3",

servers/all/$server.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-disable */
22
// prettier-ignore
3-
import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart'
3+
import 'reflect-metadata'
4+
// prettier-ignore
5+
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
46
// prettier-ignore
57
import { validateOrReject, ValidatorOptions } from 'class-validator'
68
// prettier-ignore
9+
import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart'
10+
// prettier-ignore
711
import * as Validators from './validators'
812
// prettier-ignore
913
import hooksFn0 from './api/hooks'
@@ -43,6 +47,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
4347
// prettier-ignore
4448
export type FrourioOptions = {
4549
basePath?: string
50+
transformer?: ClassTransformOptions
4651
validator?: ValidatorOptions
4752
multipart?: FastifyMultipartAttactFieldsToBodyOptions
4853
}
@@ -260,6 +265,7 @@ const asyncMethodToHandler = (
260265
// prettier-ignore
261266
export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
262267
const basePath = options.basePath ?? ''
268+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
263269
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
264270
const hooks0 = hooksFn0(fastify)
265271
const hooks1 = hooksFn1(fastify)
@@ -292,7 +298,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
292298
callParserIfExistsQuery(parseBooleanTypeQueryParams([['bool', false, false], ['optionalBool', true, false], ['boolArray', false, true], ['optionalBoolArray', true, true]])),
293299
normalizeQuery,
294300
createValidateHandler(req => [
295-
Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null
301+
Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null
296302
])
297303
]
298304
},
@@ -310,8 +316,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
310316
formatMultipartData([]),
311317
normalizeQuery,
312318
createValidateHandler(req => [
313-
validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions),
314-
validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions)
319+
validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions),
320+
validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions)
315321
])
316322
]
317323
},
@@ -344,7 +350,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
344350
preValidation: [
345351
formatMultipartData([['requiredArr', false], ['optionalArr', true], ['empty', true], ['vals', false], ['files', false]]),
346352
createValidateHandler(req => [
347-
validateOrReject(Object.assign(new Validators.MultiForm(), req.body as any), validatorOptions)
353+
validateOrReject(plainToInstance(Validators.MultiForm, req.body as any, transformerOptions), validatorOptions)
348354
])
349355
]
350356
},
@@ -404,7 +410,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
404410
onRequest: [...hooks0.onRequest, hooks2.onRequest],
405411
preParsing: hooks0.preParsing,
406412
preValidation: createValidateHandler(req => [
407-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions)
413+
validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions)
408414
]),
409415
preHandler: ctrlHooks1.preHandler
410416
} as RouteShorthandOptions,

servers/all/api/users/_userId@number/controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,15 @@ export type AdditionalRequest = {
55
}
66

77
export default defineController(() => ({
8-
get: ({ params }) => ({ status: 200, body: { id: params.userId, name: 'bbb' } })
8+
get: ({ params }) => ({
9+
status: 200,
10+
body: {
11+
id: params.userId,
12+
name: 'bbb',
13+
location: {
14+
country: 'JP',
15+
stateProvince: 'Tokyo'
16+
}
17+
}
18+
})
919
}))

servers/all/api/users/controller.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ const hooks = defineHooks(() => ({
1616
export { hooks, AdditionalRequest }
1717

1818
export default defineController(() => ({
19-
get: async () => ({ status: 200, body: [{ id: 1, name: 'aa' }] }),
19+
get: async () => ({
20+
status: 200,
21+
body: [
22+
{
23+
id: 1,
24+
name: 'aa',
25+
location: {
26+
country: 'JP',
27+
stateProvince: 'Tokyo'
28+
}
29+
}
30+
]
31+
}),
2032
post: () => ({ status: 204 })
2133
}))

servers/all/validators/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Type } from 'class-transformer'
12
import {
23
IsNumberString,
34
IsBooleanString,
@@ -8,7 +9,10 @@ import {
89
IsString,
910
Allow,
1011
IsOptional,
11-
ArrayNotEmpty
12+
ArrayNotEmpty,
13+
IsISO31661Alpha2,
14+
ValidateNested,
15+
IsObject
1216
} from 'class-validator'
1317
import type { ReadStream } from 'fs'
1418

@@ -52,12 +56,27 @@ export class Body {
5256
file: File | ReadStream
5357
}
5458

59+
export class UserInfoLocation {
60+
@IsISO31661Alpha2()
61+
country: string
62+
63+
@IsString()
64+
stateProvince: string
65+
}
66+
5567
export class UserInfo {
5668
@IsInt()
5769
id: number
5870

5971
@MaxLength(20)
6072
name: string
73+
74+
// @Type decorator is required to validate nested object properly
75+
// @IsObject decorator is required or class-validator will not throw an error when the property is missing
76+
@ValidateNested()
77+
@IsObject()
78+
@Type(() => UserInfoLocation)
79+
location: UserInfoLocation
6180
}
6281

6382
export class MultiForm {

servers/noMulter/$server.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/* eslint-disable */
22
// prettier-ignore
3+
import 'reflect-metadata'
4+
// prettier-ignore
5+
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
6+
// prettier-ignore
37
import { validateOrReject, ValidatorOptions } from 'class-validator'
48
// prettier-ignore
59
import * as Validators from './validators'
@@ -27,6 +31,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
2731
// prettier-ignore
2832
export type FrourioOptions = {
2933
basePath?: string
34+
transformer?: ClassTransformOptions
3035
validator?: ValidatorOptions
3136
}
3237

@@ -122,6 +127,7 @@ const asyncMethodToHandler = (
122127
// prettier-ignore
123128
export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
124129
const basePath = options.basePath ?? ''
130+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
125131
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
126132
const hooks0 = hooksFn0(fastify)
127133
const hooks1 = hooksFn1(fastify)
@@ -139,7 +145,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
139145
{
140146
onRequest: [hooks0.onRequest, ctrlHooks0.onRequest],
141147
preValidation: createValidateHandler(req => [
142-
Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null
148+
Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null
143149
])
144150
},
145151
asyncMethodToHandler(controller0.get)
@@ -150,8 +156,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
150156
{
151157
onRequest: [hooks0.onRequest, ctrlHooks0.onRequest],
152158
preValidation: createValidateHandler(req => [
153-
validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions),
154-
validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions)
159+
validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions),
160+
validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions)
155161
])
156162
},
157163
methodToHandler(controller0.post)
@@ -203,7 +209,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
203209
{
204210
onRequest: [hooks0.onRequest, hooks1.onRequest],
205211
preValidation: createValidateHandler(req => [
206-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions)
212+
validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions)
207213
]),
208214
preHandler: ctrlHooks1.preHandler
209215
} as RouteShorthandOptions,

servers/noTypedParams/$server.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-disable */
22
// prettier-ignore
3-
import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart'
3+
import 'reflect-metadata'
4+
// prettier-ignore
5+
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
46
// prettier-ignore
57
import { validateOrReject, ValidatorOptions } from 'class-validator'
68
// prettier-ignore
9+
import multipart, { FastifyMultipartAttactFieldsToBodyOptions, Multipart } from 'fastify-multipart'
10+
// prettier-ignore
711
import * as Validators from './validators'
812
// prettier-ignore
913
import hooksFn0 from './api/hooks'
@@ -31,6 +35,7 @@ import type { FastifyInstance, RouteHandlerMethod, preValidationHookHandler, Fas
3135
// prettier-ignore
3236
export type FrourioOptions = {
3337
basePath?: string
38+
transformer?: ClassTransformOptions
3439
validator?: ValidatorOptions
3540
multipart?: FastifyMultipartAttactFieldsToBodyOptions
3641
}
@@ -146,6 +151,7 @@ const asyncMethodToHandler = (
146151
// prettier-ignore
147152
export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
148153
const basePath = options.basePath ?? ''
154+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
149155
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
150156
const hooks0 = hooksFn0(fastify)
151157
const hooks1 = hooksFn1(fastify)
@@ -165,7 +171,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
165171
{
166172
onRequest: [hooks0.onRequest, ctrlHooks0.onRequest],
167173
preValidation: createValidateHandler(req => [
168-
Object.keys(req.query as any).length ? validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions) : null
174+
Object.keys(req.query as any).length ? validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions) : null
169175
])
170176
},
171177
asyncMethodToHandler(controller0.get)
@@ -178,8 +184,8 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
178184
preValidation: [
179185
formatMultipartData([]),
180186
createValidateHandler(req => [
181-
validateOrReject(Object.assign(new Validators.Query(), req.query as any), validatorOptions),
182-
validateOrReject(Object.assign(new Validators.Body(), req.body as any), validatorOptions)
187+
validateOrReject(plainToInstance(Validators.Query, req.query as any, transformerOptions), validatorOptions),
188+
validateOrReject(plainToInstance(Validators.Body, req.body as any, transformerOptions), validatorOptions)
183189
])
184190
]
185191
},
@@ -201,7 +207,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
201207
preValidation: [
202208
formatMultipartData([['empty', false], ['vals', false], ['files', false]]),
203209
createValidateHandler(req => [
204-
validateOrReject(Object.assign(new Validators.MultiForm(), req.body as any), validatorOptions)
210+
validateOrReject(plainToInstance(Validators.MultiForm, req.body as any, transformerOptions), validatorOptions)
205211
])
206212
]
207213
},
@@ -246,7 +252,7 @@ export default (fastify: FastifyInstance, options: FrourioOptions = {}) => {
246252
{
247253
onRequest: [hooks0.onRequest, hooks1.onRequest],
248254
preValidation: createValidateHandler(req => [
249-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body as any), validatorOptions)
255+
validateOrReject(plainToInstance(Validators.UserInfo, req.body as any, transformerOptions), validatorOptions)
250256
]),
251257
preHandler: ctrlHooks1.preHandler
252258
} as RouteShorthandOptions,

0 commit comments

Comments
 (0)