Интеграция с NestJS

Библиотека 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

DTO используется только как описание формы данных:

export class CreateUserDto {
  email: string;
  password: string;
  age: number;
}

Vest не зависит от декораторов.


Создание validation suite

Файл:

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);
  });
});

Интеграция через Pipe

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;
  }
}

Использование Pipe в Controller

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"
}

Создание универсального Vest Pipe

Для крупных проектов удобнее использовать generic-реализацию.

Типизация suite

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 поддерживает асинхронные проверки.

Проверка уникальности email

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();
      },
    );
  },
);

Асинхронный Pipe

async transform(value: any) {
  const result = await this.suite(
    value,
    this.usersService,
  );

  if (result.hasErrors()) {
    throw new BadRequestException({
      errors: result.getErrors(),
    });
  }

  return value;
}

Dependency Injection внутри Pipe

Часто 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

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);
  });
});

Это особенно полезно:

  • в формах реального времени;
  • при PATCH-запросах;
  • в WebSocket-приложениях;
  • в GraphQL mutation;
  • в SPA-интерфейсах.

PATCH-валидация в NestJS

DTO

export class UpdateUserDto {
  email?: string;
  password?: string;
}

Suite

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();
      });
    },
  );
});

Валидация вложенных объектов

DTO

export class AddressDto {
  city: string;
  street: string;
}

export class CreateUserDto {
  email: string;
  address: AddressDto;
}

Suite

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);
      },
    );
  });
});

Интеграция с GraphQL

Vest хорошо подходит для GraphQL благодаря гибкости.

Resolver

@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);
}

Интеграция с WebSocket Gateway

@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 в микросервисах

Vest не зависит от HTTP-контекста и одинаково работает:

  • в TCP transport;
  • в RabbitMQ;
  • в Kafka;
  • в NATS;
  • в Redis transport.

Пример

@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);
});

Композиция validation suite

Базовая suite

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 не навязывает формат сообщений.

Пример i18n

test('email', i18n.t('errors.invalid_email'), () => {
  enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});

Централизованный Error Formatter

Formatter

export function formatVestErrors(result: any) {
  return Object.entries(result.getErrors()).map(
    ([field, errors]) => ({
      field,
      errors,
    }),
  );
}

Использование

throw new BadRequestException({
  errors: formatVestErrors(result),
});

Кастомные enforce-правила

Vest позволяет расширять API.

Пример

import { enforce } from 'vest';

enforce.extend({
  isPhone(value) {
    return /^\+?[0-9]{10,15}$/.test(value);
  },
});

Использование

test('phone', 'Некорректный телефон', () => {
  enforce(data.phone).isPhone();
});

Производительность и оптимизация

Vest выполняет проверки лениво и эффективно кэширует результаты.

Для оптимизации крупных систем рекомендуется:

  • избегать тяжёлых async-операций внутри suite;
  • выносить внешние запросы в сервисы;
  • использовать only;
  • минимизировать повторные проверки;
  • разбивать крупные suites на модули;
  • переиспользовать validation context.

Сравнение Vest и class-validator

Возможность Vest class-validator
Декораторы Нет Да
Условная логика Отлично Ограниченно
Асинхронность Гибко Поддерживается
Частичная валидация Да Сложно
Переиспользуемость Высокая Средняя
Runtime-композиция Да Ограниченно
Подходит для SPA Отлично Средне
Подходит для сложных правил Отлично Средне

Практический пример полноценной интеграции

Validation suite

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}$/);
    });
  },
);

Pipe

@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

@Controller('auth')
export class AuthController {
  @Post('register')
  @UsePipes(RegistrationPipe)
  async register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }
}