Skip to content
Permalink
Browse files Browse the repository at this point in the history
feat(validation): use class-transformer to support validation of nest…
…ed objects

Co-authored-by: SegaraRai <SegaraRai@users.noreply.github.com>
  • Loading branch information
LumaKernel and SegaraRai committed Feb 4, 2022
1 parent 6818a9f commit 73ded5c
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 29 deletions.
83 changes: 83 additions & 0 deletions __test__/index.spec.ts
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
Expand Up @@ -97,6 +97,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 @@ -111,6 +112,7 @@
"multer": "^1.4.2",
"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
20 changes: 13 additions & 7 deletions servers/all/$server.ts
@@ -1,13 +1,17 @@
/* 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 path from 'path'
// prettier-ignore
import express, { Express, RequestHandler, Request } from 'express'
// prettier-ignore
import multer, { Options } from 'multer'
// prettier-ignore
import { validateOrReject, ValidatorOptions } from 'class-validator'
// prettier-ignore
import fastJson, { Schema } from 'fast-json-stringify'
// prettier-ignore
import * as Validators from './validators'
Expand Down Expand Up @@ -47,6 +51,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
multer?: Options
}
Expand Down Expand Up @@ -305,6 +310,7 @@ const asyncMethodToHandlerWithSchema = (
// prettier-ignore
export default (app: Express, options: FrourioOptions = {}) => {
const basePath = options.basePath ?? ''
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
const hooks0 = hooksFn0(app)
const hooks1 = hooksFn1(app)
Expand Down Expand Up @@ -332,7 +338,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
callParserIfExistsQuery(parseNumberTypeQueryParams([['requiredNum', false, false], ['optionalNum', true, false], ['optionalNumArr', true, true], ['emptyNum', true, false], ['requiredNumArr', false, true]])),
callParserIfExistsQuery(parseBooleanTypeQueryParams([['bool', false, false], ['optionalBool', true, false], ['boolArray', false, true], ['optionalBoolArray', true, true]])),
createValidateHandler(req => [
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
]),
asyncMethodToHandlerWithSchema(controller0.get, responseSchema0.get)
])
Expand All @@ -346,8 +352,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
uploader,
formatMulterData([]),
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
]),
methodToHandler(controller0.post)
])
Expand All @@ -372,7 +378,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
uploader,
formatMulterData([['requiredArr', false], ['optionalArr', true], ['empty', true], ['vals', false], ['files', false]]),
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.MultiForm(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.MultiForm, req.body, transformerOptions), validatorOptions)
]),
methodToHandler(controller3.post)
])
Expand Down Expand Up @@ -417,7 +423,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
hooks0.preParsing,
parseJSONBoby,
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
]),
...ctrlHooks1.preHandler,
methodToHandler(controller7.post)
Expand Down
12 changes: 11 additions & 1 deletion servers/all/api/users/_userId@number/controller.ts
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
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
@@ -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
16 changes: 11 additions & 5 deletions servers/noMulter/$server.ts
@@ -1,9 +1,13 @@
/* eslint-disable */
// prettier-ignore
import express, { Express, RequestHandler, Request } from 'express'
import 'reflect-metadata'
// prettier-ignore
import { ClassTransformOptions, plainToInstance } from 'class-transformer'
// prettier-ignore
import { validateOrReject, ValidatorOptions } from 'class-validator'
// prettier-ignore
import express, { Express, RequestHandler, Request } from 'express'
// prettier-ignore
import * as Validators from './validators'
// prettier-ignore
import hooksFn0 from './api/hooks'
Expand All @@ -27,6 +31,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
}

Expand Down Expand Up @@ -144,6 +149,7 @@ const asyncMethodToHandler = (
// prettier-ignore
export default (app: Express, options: FrourioOptions = {}) => {
const basePath = options.basePath ?? ''
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
const hooks0 = hooksFn0(app)
const hooks1 = hooksFn1(app)
Expand All @@ -160,7 +166,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
hooks0.onRequest,
ctrlHooks0.onRequest,
createValidateHandler(req => [
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
]),
asyncMethodToHandler(controller0.get)
])
Expand All @@ -170,8 +176,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
ctrlHooks0.onRequest,
parseJSONBoby,
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
]),
methodToHandler(controller0.post)
])
Expand Down Expand Up @@ -209,7 +215,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
hooks1.onRequest,
parseJSONBoby,
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
]),
...ctrlHooks1.preHandler,
methodToHandler(controller4.post)
Expand Down
20 changes: 13 additions & 7 deletions servers/noTypedParams/$server.ts
@@ -1,13 +1,17 @@
/* 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 path from 'path'
// prettier-ignore
import express, { Express, RequestHandler, Request } from 'express'
// prettier-ignore
import multer, { Options } from 'multer'
// prettier-ignore
import { validateOrReject, ValidatorOptions } from 'class-validator'
// prettier-ignore
import * as Validators from './validators'
// prettier-ignore
import hooksFn0 from './api/hooks'
Expand All @@ -33,6 +37,7 @@ import type { LowerHttpMethod, AspidaMethods, HttpStatusOk, AspidaMethodParams }
// prettier-ignore
export type FrourioOptions = {
basePath?: string
transformer?: ClassTransformOptions
validator?: ValidatorOptions
multer?: Options
}
Expand Down Expand Up @@ -174,6 +179,7 @@ const asyncMethodToHandler = (
// prettier-ignore
export default (app: Express, options: FrourioOptions = {}) => {
const basePath = options.basePath ?? ''
const transformerOptions: ClassTransformOptions = { enableCircularCheck: true, ...options.transformer }
const validatorOptions: ValidatorOptions = { validationError: { target: false }, ...options.validator }
const hooks0 = hooksFn0(app)
const hooks1 = hooksFn1(app)
Expand All @@ -191,7 +197,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
hooks0.onRequest,
ctrlHooks0.onRequest,
createValidateHandler(req => [
Object.keys(req.query).length ? validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions) : null
Object.keys(req.query).length ? validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions) : null
]),
asyncMethodToHandler(controller0.get)
])
Expand All @@ -202,8 +208,8 @@ export default (app: Express, options: FrourioOptions = {}) => {
uploader,
formatMulterData([]),
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.Query(), req.query), validatorOptions),
validateOrReject(Object.assign(new Validators.Body(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.Query, req.query, transformerOptions), validatorOptions),
validateOrReject(plainToInstance(Validators.Body, req.body, transformerOptions), validatorOptions)
]),
methodToHandler(controller0.post)
])
Expand All @@ -218,7 +224,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
uploader,
formatMulterData([['empty', false], ['vals', false], ['files', false]]),
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.MultiForm(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.MultiForm, req.body, transformerOptions), validatorOptions)
]),
methodToHandler(controller2.post)
])
Expand Down Expand Up @@ -251,7 +257,7 @@ export default (app: Express, options: FrourioOptions = {}) => {
hooks1.onRequest,
parseJSONBoby,
createValidateHandler(req => [
validateOrReject(Object.assign(new Validators.UserInfo(), req.body), validatorOptions)
validateOrReject(plainToInstance(Validators.UserInfo, req.body, transformerOptions), validatorOptions)
]),
...ctrlHooks1.preHandler,
methodToHandler(controller5.post)
Expand Down

0 comments on commit 73ded5c

Please sign in to comment.