Библиотека Vest предназначена для декларативной организации валидации
данных с использованием подхода, напоминающего unit-тестирование. В
экосистеме NestJS Vest может использоваться как альтернатива
class-validator, особенно в случаях, когда требуется:
В отличие от стандартного пайплайна NestJS, основанного на
декораторах DTO-классов, Vest строит валидацию через сценарии
(suite), внутри которых явно описываются тесты.
Базовая идея:
import { create, test, enforce } from 'vest';
export const userSuite = create((data = {}) => {
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/.+@.+\..+/);
});
test('password', 'Минимум 8 символов', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Результатом выполнения является объект состояния, содержащий ошибки, предупреждения и информацию о валидности.
Установка Vest:
npm install vest
Для NestJS дополнительных адаптеров не требуется.
При необходимости можно установить:
npm install vest vest-utils
Типичная структура:
src/
├── users/
│ ├── dto/
│ │ └── create-user.dto.ts
│ ├── validation/
│ │ └── user.validation.ts
│ ├── pipes/
│ │ └── vest-validation.pipe.ts
│ ├── users.controller.ts
│ └── users.service.ts
DTO используется только как описание формы данных:
export class CreateUserDto {
email: string;
password: string;
age: number;
}
Vest не зависит от декораторов.
Файл:
src/users/validation/user.validation.ts
Пример:
import { create, test, enforce } from 'vest';
export const createUserSuite = create((data: any) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('password', 'Пароль слишком короткий', () => {
enforce(data.password).longerThanOrEquals(8);
});
test('age', 'Возраст должен быть больше 18', () => {
enforce(data.age).greaterThan(18);
});
});
NestJS предоставляет механизм Pipe, позволяющий внедрять
пользовательскую логику валидации перед обработкой запроса.
Создание пайпа:
import {
Injectable,
PipeTransform,
BadRequestException,
} from '@nestjs/common';
@Injectable()
export class VestValidationPipe implements PipeTransform {
constructor(private readonly suite: any) {}
transform(value: any) {
const result = this.suite(value);
if (result.hasErrors()) {
throw new BadRequestException({
errors: result.getErrors(),
});
}
return value;
}
}
import {
Body,
Controller,
Post,
UsePipes,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { VestValidationPipe } from './pipes/vest-validation.pipe';
import { createUserSuite } from './validation/user.validation';
@Controller('users')
export class UsersController {
@Post()
@UsePipes(new VestValidationPipe(createUserSuite))
create(@Body() dto: CreateUserDto) {
return dto;
}
}
При ошибках NestJS вернёт:
{
"statusCode": 400,
"message": {
"errors": {
"email": [
"Некорректный email"
],
"password": [
"Пароль слишком короткий"
]
}
},
"error": "Bad Request"
}
Для крупных проектов удобнее использовать generic-реализацию.
import { Suite } from 'vest';
export class VestValidationPipe<T> {
constructor(private readonly suite: Suite<T>) {}
}
import {
PipeTransform,
Injectable,
BadRequestException,
} from '@nestjs/common';
import { Suite } from 'vest';
@Injectable()
export class VestValidationPipe<T>
implements PipeTransform
{
constructor(private readonly suite: Suite<T>) {}
transform(value: T): T {
const result = this.suite(value);
if (result.hasErrors()) {
throw new BadRequestException({
errors: result.getErrors(),
});
}
return value;
}
}
Vest поддерживает асинхронные проверки.
import { create, test, enforce } from 'vest';
export const createUserSuite = create(
async (data, usersService) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test(
'email',
'Пользователь уже существует',
async () => {
const exists =
await usersService.existsByEmail(data.email);
enforce(exists).isFalsy();
},
);
},
);
async transform(value: any) {
const result = await this.suite(
value,
this.usersService,
);
if (result.hasErrors()) {
throw new BadRequestException({
errors: result.getErrors(),
});
}
return value;
}
Часто validation suite требует сервисы NestJS.
@Injectable()
export class CreateUserValidationPipe
implements PipeTransform
{
constructor(
private readonly usersService: UsersService,
) {}
async transform(value: any) {
const result = await createUserSuite(
value,
this.usersService,
);
if (result.hasErrors()) {
throw new BadRequestException({
errors: result.getErrors(),
});
}
return value;
}
}
Pipe можно зарегистрировать глобально:
import { APP_PIPE } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: VestValidationPipe,
},
],
})
export class AppModule {}
Однако для Vest чаще применяется локальная регистрация, поскольку разные маршруты используют разные suites.
Одно из ключевых преимуществ Vest — возможность проверять только изменённые поля.
import { only, create, test, enforce } from 'vest';
export const profileSuite = create((data) => {
only(data.changedField);
test('username', 'Имя слишком короткое', () => {
enforce(data.username).longerThan(3);
});
test('bio', 'Bio слишком длинное', () => {
enforce(data.bio).shorterThan(300);
});
});
Это особенно полезно:
export class UpdateUserDto {
email?: string;
password?: string;
}
import { create, test, enforce, optional } from 'vest';
export const updateUserSuite = create((data) => {
optional('email');
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
optional('password');
test('password', 'Минимум 8 символов', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Vest поддерживает сложную бизнес-логику.
import { create, test, enforce, skipWhen } from 'vest';
export const paymentSuite = create((data) => {
skipWhen(
data.paymentMethod !== 'card',
() => {
test('cardNumber', 'Номер карты обязателен', () => {
enforce(data.cardNumber).isNotBlank();
});
},
);
});
export class AddressDto {
city: string;
street: string;
}
export class CreateUserDto {
email: string;
address: AddressDto;
}
import { create, test, enforce } from 'vest';
export const userSuite = create((data) => {
test('address.city', 'Город обязателен', () => {
enforce(data.address.city).isNotBlank();
});
test('address.street', 'Улица обязательна', () => {
enforce(data.address.street).isNotBlank();
});
});
import { each, create, test, enforce } from 'vest';
export const tagsSuite = create((data) => {
each(data.tags, (tag, index) => {
test(
`tags[${index}]`,
'Тег слишком короткий',
() => {
enforce(tag).longerThan(2);
},
);
});
});
Vest хорошо подходит для GraphQL благодаря гибкости.
@Mutation(() => User)
async createUser(
@Args('input') input: CreateUserInput,
) {
const result = createUserSuite(input);
if (result.hasErrors()) {
throw new BadRequestException(
result.getErrors(),
);
}
return this.usersService.create(input);
}
@WebSocketGateway()
export class ChatGateway {
@SubscribeMessage('message')
async handleMessage(
@MessageBody() payload: any,
) {
const result = messageSuite(payload);
if (result.hasErrors()) {
throw new WsException(
result.getErrors(),
);
}
return payload;
}
}
Vest не зависит от HTTP-контекста и одинаково работает:
@MessagePattern('user.create')
async createUser(data: any) {
const result = createUserSuite(data);
if (result.hasErrors()) {
throw new RpcException(
result.getErrors(),
);
}
return this.usersService.create(data);
}
import { enforce } from 'vest';
export function validatePassword(password: string) {
enforce(password).longerThanOrEquals(8);
enforce(password).matches(/[A-Z]/);
enforce(password).matches(/[0-9]/);
}
test('password', 'Слабый пароль', () => {
validatePassword(data.password);
});
export const baseUserSuite = create((data) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
});
export const adminSuite = create((data) => {
baseUserSuite(data);
test('role', 'Роль обязательна', () => {
enforce(data.role).equals('admin');
});
});
Vest не навязывает формат сообщений.
test('email', i18n.t('errors.invalid_email'), () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
export function formatVestErrors(result: any) {
return Object.entries(result.getErrors()).map(
([field, errors]) => ({
field,
errors,
}),
);
}
throw new BadRequestException({
errors: formatVestErrors(result),
});
Vest позволяет расширять API.
import { enforce } from 'vest';
enforce.extend({
isPhone(value) {
return /^\+?[0-9]{10,15}$/.test(value);
},
});
test('phone', 'Некорректный телефон', () => {
enforce(data.phone).isPhone();
});
Vest выполняет проверки лениво и эффективно кэширует результаты.
Для оптимизации крупных систем рекомендуется:
only;| Возможность | Vest | class-validator |
|---|---|---|
| Декораторы | Нет | Да |
| Условная логика | Отлично | Ограниченно |
| Асинхронность | Гибко | Поддерживается |
| Частичная валидация | Да | Сложно |
| Переиспользуемость | Высокая | Средняя |
| Runtime-композиция | Да | Ограниченно |
| Подходит для SPA | Отлично | Средне |
| Подходит для сложных правил | Отлично | Средне |
import {
create,
test,
enforce,
optional,
} from 'vest';
export const registrationSuite = create(
async (data, usersService) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test(
'email',
'Email уже используется',
async () => {
const exists =
await usersService.existsByEmail(
data.email,
);
enforce(exists).isFalsy();
},
);
test('password', 'Пароль слишком короткий', () => {
enforce(data.password)
.longerThanOrEquals(8);
});
optional('phone');
test('phone', 'Некорректный телефон', () => {
enforce(data.phone)
.matches(/^\+?[0-9]{10,15}$/);
});
},
);
@Injectable()
export class RegistrationPipe
implements PipeTransform
{
constructor(
private readonly usersService: UsersService,
) {}
async transform(value: any) {
const result = await registrationSuite(
value,
this.usersService,
);
if (result.hasErrors()) {
throw new BadRequestException({
errors: result.getErrors(),
});
}
return value;
}
}
@Controller('auth')
export class AuthController {
@Post('register')
@UsePipes(RegistrationPipe)
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
}