Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 269 additions & 0 deletions integration/standard-schema/e2e/standard-schema.spec.ts
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);
});
});
7 changes: 7 additions & 0 deletions integration/standard-schema/src/app.module.ts
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 {}
114 changes: 114 additions & 0 deletions integration/standard-schema/src/dto.ts
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;
Comment on lines +97 to +106

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-validator is that it leads you to duplicate information. I wrote about this here: https://www.benlorantfy.com/blog/the-data-triangle-and-nestjs-zod-v5

Is there no way to avoid this? In nestjs-zod we avoid this by using a factory function:

image

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 by meta({ description: '' }) on the schema) and minimum (which might be set by gt())

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.

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?

Copy link

@BenLorantfy BenLorantfy Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zod also has a function called toJSONSchema that creates json schemas from zod schemas. This is how nestjs-zod is able to use description from meta({ description: '' }) without needing @ApiProperty

toJSONSchema also produces different results based off io: https://zod.dev/json-schema#io .

Is it up to 3rd party libraries, like nestjs-zod to implement _OPENAPI_METADATA_FACTORY ?

See: https://github.com/BenLorantfy/nestjs-zod/blob/61c59dde828b050f22e91a00bbde97024babd62f/packages/nestjs-zod/src/dto.ts#L60-L62


@ApiPropertyOptional({
description: 'Number of items to skip',
example: 0,
minimum: 0,
})
declare offset?: number;
}
3 changes: 3 additions & 0 deletions integration/standard-schema/src/index.ts
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';
Loading
Loading