Skip to content

Commit 73ded5c

Browse files
LumaKernelSegaraRai
andcommitted
feat(validation): use class-transformer to support validation of nested objects
Co-authored-by: SegaraRai <SegaraRai@users.noreply.github.com>
1 parent 6818a9f commit 73ded5c

File tree

12 files changed

+212
-29
lines changed

12 files changed

+212
-29
lines changed

Diff for: __test__/index.spec.ts

+83
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'

Diff for: package.json

+2
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"@typescript-eslint/eslint-plugin": "^4.28.1",
9898
"@typescript-eslint/parser": "^4.28.1",
9999
"axios": "^0.21.1",
100+
"class-transformer": "^0.5.1",
100101
"class-validator": "^0.13.1",
101102
"eslint": "^7.30.0",
102103
"eslint-config-prettier": "^8.3.0",
@@ -111,6 +112,7 @@
111112
"multer": "^1.4.2",
112113
"node-fetch": "^2.6.1",
113114
"prettier": "^2.3.2",
115+
"reflect-metadata": "^0.1.13",
114116
"rimraf": "^3.0.2",
115117
"standard-version": "^9.3.0",
116118
"ts-jest": "^27.0.3",

Diff for: servers/all/$server.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
/* eslint-disable */
22
// prettier-ignore
3+
import 'reflect-metadata'
4+
// prettier-ignore
5+
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
6+
// prettier-ignore
7+
import { validateOrReject, ValidatorOptions } from 'class-validator'
8+
// prettier-ignore
39
import path from 'path'
410
// prettier-ignore
511
import express, { Express, RequestHandler, Request } from 'express'
612
// prettier-ignore
713
import multer, { Options } from 'multer'
814
// prettier-ignore
9-
import { validateOrReject, ValidatorOptions } from 'class-validator'
10-
// prettier-ignore
1115
import fastJson, { Schema } from 'fast-json-stringify'
1216
// prettier-ignore
1317
import * as Validators from './validators'
@@ -47,6 +51,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
4751
// prettier-ignore
4852
export type FrourioOptions = {
4953
basePath?: string
54+
transformer?: ClassTransformOptions
5055
validator?: ValidatorOptions
5156
multer?: Options
5257
}
@@ -305,6 +310,7 @@ const asyncMethodToHandlerWithSchema = (
305310
// prettier-ignore
306311
export default (app: Express, options: FrourioOptions = {}) => {
307312
const basePath = options.basePath ?? ''
313+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
308314
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
309315
const hooks0 = hooksFn0(app)
310316
const hooks1 = hooksFn1(app)
@@ -332,7 +338,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
332338
callParserIfExistsQuery(parseNumberTypeQueryParams([['requiredNum', false, false], ['optionalNum', true, false], ['optionalNumArr', true, true], ['emptyNum', true, false], ['requiredNumArr', false, true]])),
333339
callParserIfExistsQuery(parseBooleanTypeQueryParams([['bool', false, false], ['optionalBool', true, false], ['boolArray', false, true], ['optionalBoolArray', true, true]])),
334340
createValidateHandler(req => [
335-
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
341+
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
336342
]),
337343
asyncMethodToHandlerWithSchema(controller0.get, responseSchema0.get)
338344
])
@@ -346,8 +352,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
346352
uploader,
347353
formatMulterData([]),
348354
createValidateHandler(req => [
349-
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
350-
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
355+
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
356+
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
351357
]),
352358
methodToHandler(controller0.post)
353359
])
@@ -372,7 +378,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
372378
uploader,
373379
formatMulterData([['requiredArr', false], ['optionalArr', true], ['empty', true], ['vals', false], ['files', false]]),
374380
createValidateHandler(req => [
375-
validateOrReject(Object.assign(new Validators.MultiForm(), req.body), validatorOptions)
381+
validateOrReject(plainToInstance(Validators.MultiForm, req.body, transformerOptions), validatorOptions)
376382
]),
377383
methodToHandler(controller3.post)
378384
])
@@ -417,7 +423,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
417423
hooks0.preParsing,
418424
parseJSONBoby,
419425
createValidateHandler(req => [
420-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
426+
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
421427
]),
422428
...ctrlHooks1.preHandler,
423429
methodToHandler(controller7.post)

Diff for: servers/all/api/users/_userId@number/controller.ts

+11-1
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
}))

Diff for: servers/all/api/users/controller.ts

+13-1
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
}))

Diff for: servers/all/validators/index.ts

+20-1
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 {

Diff for: servers/noMulter/$server.ts

+11-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-disable */
22
// prettier-ignore
3-
import express, { Express, RequestHandler, Request } from 'express'
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 express, { Express, RequestHandler, Request } from 'express'
10+
// prettier-ignore
711
import * as Validators from './validators'
812
// prettier-ignore
913
import hooksFn0 from './api/hooks'
@@ -27,6 +31,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
2731
// prettier-ignore
2832
export type FrourioOptions = {
2933
basePath?: string
34+
transformer?: ClassTransformOptions
3035
validator?: ValidatorOptions
3136
}
3237

@@ -144,6 +149,7 @@ const asyncMethodToHandler = (
144149
// prettier-ignore
145150
export default (app: Express, options: FrourioOptions = {}) => {
146151
const basePath = options.basePath ?? ''
152+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
147153
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
148154
const hooks0 = hooksFn0(app)
149155
const hooks1 = hooksFn1(app)
@@ -160,7 +166,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
160166
hooks0.onRequest,
161167
ctrlHooks0.onRequest,
162168
createValidateHandler(req => [
163-
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
169+
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
164170
]),
165171
asyncMethodToHandler(controller0.get)
166172
])
@@ -170,8 +176,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
170176
ctrlHooks0.onRequest,
171177
parseJSONBoby,
172178
createValidateHandler(req => [
173-
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
174-
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
179+
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
180+
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
175181
]),
176182
methodToHandler(controller0.post)
177183
])
@@ -209,7 +215,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
209215
hooks1.onRequest,
210216
parseJSONBoby,
211217
createValidateHandler(req => [
212-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
218+
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
213219
]),
214220
...ctrlHooks1.preHandler,
215221
methodToHandler(controller4.post)

Diff for: servers/noTypedParams/$server.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
/* eslint-disable */
22
// prettier-ignore
3+
import 'reflect-metadata'
4+
// prettier-ignore
5+
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
6+
// prettier-ignore
7+
import { validateOrReject, ValidatorOptions } from 'class-validator'
8+
// prettier-ignore
39
import path from 'path'
410
// prettier-ignore
511
import express, { Express, RequestHandler, Request } from 'express'
612
// prettier-ignore
713
import multer, { Options } from 'multer'
814
// prettier-ignore
9-
import { validateOrReject, ValidatorOptions } from 'class-validator'
10-
// prettier-ignore
1115
import * as Validators from './validators'
1216
// prettier-ignore
1317
import hooksFn0 from './api/hooks'
@@ -33,6 +37,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
3337
// prettier-ignore
3438
export type FrourioOptions = {
3539
basePath?: string
40+
transformer?: ClassTransformOptions
3641
validator?: ValidatorOptions
3742
multer?: Options
3843
}
@@ -174,6 +179,7 @@ const asyncMethodToHandler = (
174179
// prettier-ignore
175180
export default (app: Express, options: FrourioOptions = {}) => {
176181
const basePath = options.basePath ?? ''
182+
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
177183
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
178184
const hooks0 = hooksFn0(app)
179185
const hooks1 = hooksFn1(app)
@@ -191,7 +197,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
191197
hooks0.onRequest,
192198
ctrlHooks0.onRequest,
193199
createValidateHandler(req => [
194-
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
200+
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
195201
]),
196202
asyncMethodToHandler(controller0.get)
197203
])
@@ -202,8 +208,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
202208
uploader,
203209
formatMulterData([]),
204210
createValidateHandler(req => [
205-
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
206-
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
211+
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
212+
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
207213
]),
208214
methodToHandler(controller0.post)
209215
])
@@ -218,7 +224,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
218224
uploader,
219225
formatMulterData([['empty', false], ['vals', false], ['files', false]]),
220226
createValidateHandler(req => [
221-
validateOrReject(Object.assign(new Validators.MultiForm(), req.body), validatorOptions)
227+
validateOrReject(plainToInstance(Validators.MultiForm, req.body, transformerOptions), validatorOptions)
222228
]),
223229
methodToHandler(controller2.post)
224230
])
@@ -251,7 +257,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
251257
hooks1.onRequest,
252258
parseJSONBoby,
253259
createValidateHandler(req => [
254-
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
260+
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
255261
]),
256262
...ctrlHooks1.preHandler,
257263
methodToHandler(controller5.post)

0 commit comments

Comments
 (0)