-
-
Notifications
You must be signed in to change notification settings - Fork 8.2k
feat(common): add standard-schema validation pipe #16120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
malkovitc
wants to merge
3
commits into
nestjs:master
Choose a base branch
from
malkovitc:feat/standard-schema-validation-pipe
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
269 changes: 269 additions & 0 deletions
269
integration/standard-schema/e2e/standard-schema.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| import { INestApplication } from '@nestjs/common'; | ||
| import { Test } from '@nestjs/testing'; | ||
| import { expect } from 'chai'; | ||
| import * as request from 'supertest'; | ||
| import { StandardSchemaValidationPipe } from '@nestjs/common'; | ||
| import { AppModule } from '../src/app.module'; | ||
|
|
||
| describe('StandardSchemaValidationPipe with Zod (e2e)', () => { | ||
| let app: INestApplication; | ||
| let server: any; | ||
|
|
||
| beforeEach(async () => { | ||
| const moduleRef = await Test.createTestingModule({ | ||
| imports: [AppModule], | ||
| }).compile(); | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| app.useGlobalPipes(new StandardSchemaValidationPipe()); | ||
| server = app.getHttpServer(); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await app.close(); | ||
| }); | ||
|
|
||
| describe('POST /users', () => { | ||
| it('should create user with valid data', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| age: 30, | ||
| }) | ||
| .expect(201) | ||
| .expect(res => { | ||
| expect(res.body.success).to.equal(true); | ||
| expect(res.body.data.name).to.equal('John Doe'); | ||
| expect(res.body.data.email).to.equal('[email protected]'); | ||
| expect(res.body.data.age).to.equal(30); | ||
| }); | ||
| }); | ||
|
|
||
| it('should create user without optional age', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'Jane Doe', | ||
| email: '[email protected]', | ||
| }) | ||
| .expect(201) | ||
| .expect(res => { | ||
| expect(res.body.success).to.equal(true); | ||
| expect(res.body.data.name).to.equal('Jane Doe'); | ||
| expect(res.body.data.email).to.equal('[email protected]'); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject invalid email', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: 'invalid-email', | ||
| }) | ||
| .expect(400) | ||
| .expect(res => { | ||
| expect(res.body.message).to.be.an('array'); | ||
| expect(res.body.message.some((m: string) => m.includes('email'))).to | ||
| .be.true; | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject name that is too short', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'J', | ||
| email: '[email protected]', | ||
| }) | ||
| .expect(400) | ||
| .expect(res => { | ||
| expect(res.body.message).to.be.an('array'); | ||
| expect(res.body.message.some((m: string) => m.includes('Name'))).to.be | ||
| .true; | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject missing required fields', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({}) | ||
| .expect(400) | ||
| .expect(res => { | ||
| expect(res.body.message).to.be.an('array'); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject invalid age type', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| age: 'thirty', | ||
| }) | ||
| .expect(400); | ||
| }); | ||
|
|
||
| it('should reject age out of range', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| age: 200, | ||
| }) | ||
| .expect(400); | ||
| }); | ||
|
|
||
| it('should create user with valid nested address', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| address: { | ||
| street: '123 Main St', | ||
| city: 'New York', | ||
| zipCode: '10001', | ||
| country: 'US', | ||
| }, | ||
| }) | ||
| .expect(201) | ||
| .expect(res => { | ||
| expect(res.body.success).to.equal(true); | ||
| expect(res.body.data.address.street).to.equal('123 Main St'); | ||
| expect(res.body.data.address.city).to.equal('New York'); | ||
| expect(res.body.data.address.zipCode).to.equal('10001'); | ||
| expect(res.body.data.address.country).to.equal('US'); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject invalid nested address zipCode', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| address: { | ||
| street: '123 Main St', | ||
| city: 'New York', | ||
| zipCode: 'invalid', | ||
| country: 'US', | ||
| }, | ||
| }) | ||
| .expect(400) | ||
| .expect(res => { | ||
| expect(res.body.message).to.be.an('array'); | ||
| expect(res.body.message.some((m: string) => m.includes('zip'))).to.be | ||
| .true; | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject incomplete nested address', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| address: { | ||
| street: '123 Main St', | ||
| // missing city, zipCode, country | ||
| }, | ||
| }) | ||
| .expect(400); | ||
| }); | ||
| }); | ||
|
|
||
| describe('POST /users/query', () => { | ||
| it('should handle valid query params with body', () => { | ||
| return request(server) | ||
| .post('/users/query?limit=10&offset=5') | ||
| .send({ | ||
| name: 'John Doe', | ||
| email: '[email protected]', | ||
| }) | ||
| .expect(201) | ||
| .expect(res => { | ||
| expect(res.body.success).to.equal(true); | ||
| expect(res.body.query.limit).to.equal(10); | ||
| expect(res.body.query.offset).to.equal(5); | ||
| expect(res.body.body.name).to.equal('John Doe'); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('StandardSchemaValidationPipe with disableErrorMessages (e2e)', () => { | ||
| let app: INestApplication; | ||
| let server: any; | ||
|
|
||
| beforeEach(async () => { | ||
| const moduleRef = await Test.createTestingModule({ | ||
| imports: [AppModule], | ||
| }).compile(); | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| app.useGlobalPipes( | ||
| new StandardSchemaValidationPipe({ | ||
| disableErrorMessages: true, | ||
| }), | ||
| ); | ||
| server = app.getHttpServer(); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await app.close(); | ||
| }); | ||
|
|
||
| it('should not include error details when disabled', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'J', | ||
| email: 'invalid', | ||
| }) | ||
| .expect(400) | ||
| .expect(res => { | ||
| expect(res.body.message).to.equal('Bad Request'); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('StandardSchemaValidationPipe with custom errorHttpStatusCode (e2e)', () => { | ||
| let app: INestApplication; | ||
| let server: any; | ||
|
|
||
| beforeEach(async () => { | ||
| const moduleRef = await Test.createTestingModule({ | ||
| imports: [AppModule], | ||
| }).compile(); | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| app.useGlobalPipes( | ||
| new StandardSchemaValidationPipe({ | ||
| errorHttpStatusCode: 422, | ||
| }), | ||
| ); | ||
| server = app.getHttpServer(); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await app.close(); | ||
| }); | ||
|
|
||
| it('should return custom status code on validation error', () => { | ||
| return request(server) | ||
| .post('/users') | ||
| .send({ | ||
| name: 'J', | ||
| email: 'invalid', | ||
| }) | ||
| .expect(422); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { UsersController } from './users.controller'; | ||
|
|
||
| @Module({ | ||
| controllers: [UsersController], | ||
| }) | ||
| export class AppModule {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; | ||
| import { z } from 'zod'; | ||
|
|
||
| /** | ||
| * Zod schema for AddressDto (nested) | ||
| */ | ||
| export const addressSchema = z.object({ | ||
| street: z.string().min(1, 'Street is required'), | ||
| city: z.string().min(1, 'City is required'), | ||
| zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid zip code format'), | ||
| country: z.string().min(2).max(2, 'Country must be ISO 3166-1 alpha-2 code'), | ||
| }); | ||
|
|
||
| /** | ||
| * Nested DTO for address with Swagger documentation. | ||
| * Uses `implements z.infer<typeof schema>` to ensure type safety. | ||
| */ | ||
| export class AddressDto implements z.infer<typeof addressSchema> { | ||
| static schema = addressSchema; | ||
|
|
||
| @ApiProperty({ description: 'Street address', example: '123 Main St' }) | ||
| declare street: string; | ||
|
|
||
| @ApiProperty({ description: 'City name', example: 'New York' }) | ||
| declare city: string; | ||
|
|
||
| @ApiProperty({ description: 'ZIP code (US format)', example: '10001' }) | ||
| declare zipCode: string; | ||
|
|
||
| @ApiProperty({ | ||
| description: 'Country code (ISO 3166-1 alpha-2)', | ||
| example: 'US', | ||
| minLength: 2, | ||
| maxLength: 2, | ||
| }) | ||
| declare country: string; | ||
| } | ||
|
|
||
| /** | ||
| * Zod schema for CreateUserDto with nested address | ||
| */ | ||
| export const createUserSchema = z.object({ | ||
| name: z.string().min(2, 'Name must be at least 2 characters'), | ||
| email: z.string().email('Invalid email format'), | ||
| age: z.number().int().min(0).max(150).optional(), | ||
| address: addressSchema.optional(), | ||
| }); | ||
|
|
||
| /** | ||
| * DTO with Zod schema and Swagger decorators. | ||
| * Uses `implements z.infer<typeof schema>` to ensure type safety | ||
| * between the Zod schema and class properties. | ||
| */ | ||
| export class CreateUserDto implements z.infer<typeof createUserSchema> { | ||
| static schema = createUserSchema; | ||
|
|
||
| @ApiProperty({ | ||
| description: 'User name', | ||
| example: 'John Doe', | ||
| minLength: 2, | ||
| }) | ||
| declare name: string; | ||
|
|
||
| @ApiProperty({ | ||
| description: 'User email address', | ||
| example: '[email protected]', | ||
| format: 'email', | ||
| }) | ||
| declare email: string; | ||
|
|
||
| @ApiPropertyOptional({ | ||
| description: 'User age', | ||
| example: 30, | ||
| minimum: 0, | ||
| maximum: 150, | ||
| }) | ||
| declare age?: number; | ||
|
|
||
| @ApiPropertyOptional({ | ||
| description: 'User address', | ||
| type: () => AddressDto, | ||
| }) | ||
| declare address?: AddressDto; | ||
| } | ||
|
|
||
| /** | ||
| * Zod schema for QueryDto | ||
| */ | ||
| export const querySchema = z.object({ | ||
| limit: z.coerce.number().int().min(1).max(100).optional(), | ||
| offset: z.coerce.number().int().min(0).optional(), | ||
| }); | ||
|
|
||
| /** | ||
| * DTO with Zod schema for query parameters | ||
| */ | ||
| export class QueryDto implements z.infer<typeof querySchema> { | ||
| static schema = querySchema; | ||
|
|
||
| @ApiPropertyOptional({ | ||
| description: 'Number of items to return', | ||
| example: 10, | ||
| minimum: 1, | ||
| maximum: 100, | ||
| }) | ||
| declare limit?: number; | ||
|
|
||
| @ApiPropertyOptional({ | ||
| description: 'Number of items to skip', | ||
| example: 0, | ||
| minimum: 0, | ||
| }) | ||
| declare offset?: number; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export * from './app.module'; | ||
| export * from './users.controller'; | ||
| export * from './dto'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So my biggest problem with
class-validatoris that it leads you to duplicate information. I wrote about this here: https://www.benlorantfy.com/blog/the-data-triangle-and-nestjs-zod-v5Is there no way to avoid this? In
nestjs-zodwe avoid this by using a factory function:I'm worried about not only the schema field names being duplicated by the class field names, but also other information like
description(which might be set bymeta({ description: '' })on the schema) andminimum(which might be set bygt())I'm also worried consumers might forget to add
implements z.infer<typeof createUserSchema>, in which case the duplication is not just extra characters but also caries risk of the type information drifting.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the idea that other 3rd party libraries (like
nestjs-zod) would build a factory function on-top of this work, to handle avoiding duplication like this?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
zodalso has a function calledtoJSONSchemathat creates json schemas from zod schemas. This is hownestjs-zodis able to usedescriptionfrommeta({ description: '' })without needing@ApiPropertytoJSONSchemaalso produces different results based offio: https://zod.dev/json-schema#io .Is it up to 3rd party libraries, like
nestjs-zodto implement_OPENAPI_METADATA_FACTORY?See: https://github.com/BenLorantfy/nestjs-zod/blob/61c59dde828b050f22e91a00bbde97024babd62f/packages/nestjs-zod/src/dto.ts#L60-L62