Skip to content

Instantly share code, notes, and snippets.

@Roms1383
Created March 27, 2020 17:09
Show Gist options
  • Save Roms1383/a2d2bb61c2522e12997465beb664a40c to your computer and use it in GitHub Desktop.
Save Roms1383/a2d2bb61c2522e12997465beb664a40c to your computer and use it in GitHub Desktop.
Medium - Easy validation with Nest.js and Joi
import * as Joi from '@hapi/joi'
import { Body, Controller, Module, NotImplementedException, Post, UsePipes } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import axios from 'axios'
import * as Joiful from 'joiful'
import { ValidationPipe } from './validation.pipe'
class Implicit {
@Joiful.string().required()
mandatory: string
@Joiful.string().optional()
optional?: string
}
const additional = Joi.object({
additional: Joi.string().required()
})
@Controller()
class Routes {
@UsePipes(ValidationPipe)
@Post('incorrect')
incorrect(@Body() body: any) { return true }
@UsePipes(ValidationPipe)
@Post('implicit')
implicit(@Body() body: Implicit) { return true }
@UsePipes(ValidationPipe)
@Post('implicits')
implicits(@Body() body: Implicit) { return true }
@UsePipes(new ValidationPipe([Implicit, additional]))
@Post('explicit')
explicit(@Body() body: any) { return true }
@UsePipes(new ValidationPipe([Implicit, additional], true))
@Post('explicits')
explicits(@Body() body: any) { return true }
}
@Module({
controllers: [Routes]
})
class MainModule {}
const bootstrap = async () => {
const app = await NestFactory.create(MainModule, { logger: false })
await app.listen(3000)
return app
}
const teardown = async app => {
await app.close()
app = undefined
return true
}
describe('ValidationPipe', () => {
let app = undefined
beforeAll(async () => {
app = await bootstrap()
})
afterAll(async () => {
await teardown(app)
})
describe('incorrectly implemented', () => {
it('should fail if not implemented correctly on Controller', async () => {
expect(axios.post('http://localhost:3000/incorrect', {}))
.rejects
.toThrow('Request failed with status code 500')
})
})
describe('implicit validation from decorated class', () => {
it('should fail with empty payload', async () => {
const payload = {}
expect(axios.post('http://localhost:3000/implicit', payload))
.rejects
.toThrow()
})
it('should fail with missing mandatory parameter in payload', async () => {
const payload = { optional: 'some optional parameter' }
expect(axios.post('http://localhost:3000/implicit', payload))
.rejects
.toThrow()
})
it('should succeed with valid payload', async () => {
const payload = { mandatory: 'some mandatory parameter', optional: 'some optional parameter' }
const { data } = await axios.post('http://localhost:3000/implicit', payload)
expect(data)
.toBe(true)
})
})
describe('implicit validation from decorated class with an array as payload', () => {
it('should fail with at least one empty payload item', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter' },
{},
]
expect(axios.post('http://localhost:3000/implicits', payload))
.rejects
.toThrow()
})
it('should fail with at least one missing mandatory parameter in payload item', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter' },
{ optional: 'another optional parameter' },
]
expect(axios.post('http://localhost:3000/implicits', payload))
.rejects
.toThrow()
})
it('should succeed with valid payload for all items', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter' },
{ mandatory: 'another mandatory parameter', optional: 'another optional parameter' },
]
const { data } = await axios.post('http://localhost:3000/implicits', payload)
expect(data)
.toBe(true)
})
})
describe('validation from mix of decorated class(es) and schema(s)', () => {
it('should fail with empty payload', async () => {
const payload = {}
expect(axios.post('http://localhost:3000/explicit', payload))
.rejects
.toThrow()
})
it('should fail with missing required parameter in payload', async () => {
const payload = { mandatory: 'some mandatory parameter', optional: 'some optional parameter' }
expect(axios.post('http://localhost:3000/explicit', payload))
.rejects
.toThrow()
})
it('should succeed with valid payload', async () => {
const payload = { mandatory: 'some mandatory parameter', optional: 'some optional parameter', additional: 'some additional required parameter' }
const { data } = await axios.post('http://localhost:3000/explicit', payload)
expect(data)
.toBe(true)
})
})
describe('validation from mix of decorated class(es) and schema(s) with array as payload', () => {
it('should fail with at least one empty payload item', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter', additional: 'some additional required parameter' },
{},
]
expect(axios.post('http://localhost:3000/explicits', payload))
.rejects
.toThrow()
})
it('should fail with at least one missing mandatory parameter in payload item', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter', additional: 'some additional required parameter' },
{ optional: 'another optional parameter' },
]
expect(axios.post('http://localhost:3000/explicits', payload))
.rejects
.toThrow()
})
it('should succeed with valid payload for all items', async () => {
const payload = [
{ mandatory: 'some mandatory parameter', optional: 'some optional parameter', additional: 'some additional required parameter' },
{ mandatory: 'another mandatory parameter', optional: 'another optional parameter', additional: 'another additional required parameter' },
]
const { data } = await axios.post('http://localhost:3000/explicits', payload)
expect(data)
.toBe(true)
})
})
})
import * as Joi from '@hapi/joi'
import {
ArgumentMetadata,
BadRequestException,
Injectable,
NotImplementedException,
Optional,
PipeTransform,
} from '@nestjs/common'
import * as Joiful from 'joiful'
import { Constructor, getJoiSchema } from 'joiful/core'
type Mergeable = Constructor<any>|Joi.AnySchema
@Injectable()
export class ValidationPipe implements PipeTransform {
constructor(@Optional() private schemas?: Mergeable[], @Optional() private wrapSchemaAsArray?: boolean) {}
mergeSchemas (): Joi.AnySchema {
return this.schemas
.reduce((merged: Joi.AnySchema, current) => {
const schema = current.hasOwnProperty('isJoi') && current['isJoi']
? current as Joi.AnySchema
: getJoiSchema(current as Constructor<any>, Joi)
return merged
? merged.concat(schema)
: schema
}, undefined) as Joi.Schema
}
validateAsSchema (value: any) {
const { error } = Array.isArray(value) && this.wrapSchemaAsArray
? Joi.array().items(this.mergeSchemas()).validate(value)
: this.mergeSchemas().validate(value)
if (error) throw new BadRequestException('Validation failed')
}
validateAsClass (value: any, metadata: ArgumentMetadata): void|never {
const { error } = Array.isArray(value)
? Joiful.validateArrayAsClass(value, metadata.metatype as Constructor<any>)
: Joiful.validateAsClass(value, metadata.metatype as Constructor<any>)
if (error) throw new BadRequestException('Validation failed')
}
transform(value: any, metadata: ArgumentMetadata) {
if (!metadata?.metatype && !this.schemas) throw new NotImplementedException('Missing validation schema')
if (this.schemas) this.validateAsSchema(value)
else this.validateAsClass(value, metadata)
return value
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment