Библиотека Vest предназначена для декларативной валидации данных и особенно хорошо подходит для сложных форм, сценариев пошаговой проверки и переиспользуемых наборов правил. В серверных приложениях на Fastify Vest часто используется как слой бизнес-валидации поверх встроенных механизмов схем и сериализации.
Типичная архитектура выглядит следующим образом:
HTTP Request
↓
Fastify Route
↓
PreValidation Hook / Handler
↓
Vest Suite
↓
Validation Result
↓
Business Logic
↓
Response
Vest не заменяет встроенные JSON Schema-механизмы Fastify, а дополняет их:
Fastify хорошо подходит для:
Vest удобен для:
Базовая установка:
npm install fastify vest
Для TypeScript:
npm install -D typescript @types/node
Минимальная структура проекта:
project/
├── server.js
├── validation/
│ ├── userSuite.js
│ └── productSuite.js
└── routes/
└── users.js
Простейший набор правил:
// validation/userSuite.js
import { create, test, enforce } fr om 'vest';
export const userSuite = create((data = {}) => {
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('password', 'Минимум 8 символов', () => {
enforce(data.password).longerThanOrEquals(8);
});
test('name', 'Имя обязательно', () => {
enforce(data.name).isNotBlank();
});
});
Особенности:
create() создаёт validation suite;test() описывает отдельную проверку;enforce() предоставляет fluent API;Простейшая интеграция:
// server.js
import Fastify fr om 'fastify';
import { userSuite } fr om './validation/userSuite.js';
const fastify = Fastify({
logger: true,
});
fastify.post('/users', async (request, reply) => {
const result = userSuite(request.body);
if (result.hasErrors()) {
return reply.status(400).send({
errors: result.getErrors(),
});
}
return {
success: true,
};
});
fastify.listen({
port: 3000,
});
Запрос:
{
"email": "wrong",
"password": "123"
}
Ответ:
{
"errors": {
"email": [
"Некорректный email"
],
"password": [
"Минимум 8 символов"
],
"name": [
"Имя обязательно"
]
}
}
В больших приложениях логику валидации лучше выносить отдельно.
// validation/validate.js
export async function validate(suite, data) {
const result = suite(data);
if (result.hasErrors()) {
return {
valid: false,
errors: result.getErrors(),
};
}
return {
valid: true,
errors: null,
};
}
Использование:
import { validate } from '../validation/validate.js';
import { userSuite } from '../validation/userSuite.js';
fastify.post('/users', async (request, reply) => {
const validation = await validate(userSuite, request.body);
if (!validation.valid) {
return reply.status(400).send(validation);
}
return {
created: true,
};
});
Fastify поддерживает lifecycle hooks. Vest удобно интегрировать через
preValidation.
fastify.route({
method: 'POST',
url: '/users',
preValidation: async (request, reply) => {
const result = userSuite(request.body);
if (result.hasErrors()) {
return reply.status(400).send({
errors: result.getErrors(),
});
}
},
handler: async (request, reply) => {
return {
ok: true,
};
},
});
Преимущества:
В крупных проектах удобно оформлять Vest как Fastify plugin.
// plugins/validation.js
export async function validationPlugin(fastify) {
fastify.decorate('validateVest', async function (suite, data) {
const result = suite(data);
if (result.hasErrors()) {
return {
valid: false,
errors: result.getErrors(),
};
}
return {
valid: true,
errors: {},
};
});
}
Регистрация:
import Fastify from 'fastify';
import { validationPlugin } from './plugins/validation.js';
const fastify = Fastify();
await fastify.register(validationPlugin);
Использование:
fastify.post('/users', async (request, reply) => {
const validation = await fastify.validateVest(
userSuite,
request.body
);
if (!validation.valid) {
return reply.status(400).send(validation);
}
return {
ok: true,
};
});
Vest поддерживает async-проверки, что особенно важно для Fastify API.
Пример проверки уникальности email:
import { create, test, enforce } from 'vest';
async function emailExists(email) {
return email === 'admin@example.com';
}
export const userSuite = create(async (data = {}) => {
test('email', 'Email уже используется', async () => {
const exists = await emailExists(data.email);
enforce(exists).isFalsy();
});
test('password', 'Пароль слишком короткий', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Использование:
const result = await userSuite(request.body);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
Vest подходит не только для body, но и для:
params;query;headers.import { create, test, enforce } from 'vest';
export const paramsSuite = create((data = {}) => {
test('id', 'ID должен быть числом', () => {
enforce(Number(data.id)).isNumber();
});
});
Маршрут:
fastify.get('/users/:id', async (request, reply) => {
const result = paramsSuite(request.params);
if (result.hasErrors()) {
return reply.status(400).send({
errors: result.getErrors(),
});
}
return {
userId: request.params.id,
};
});
export const querySuite = create((query = {}) => {
test('page', 'page должен быть положительным числом', () => {
enforce(Number(query.page)).greaterThan(0);
});
test('lim it', 'lim it превышает максимум', () => {
enforce(Number(query.lim it)).lessThanOrEquals(100);
});
});
Использование:
fastify.get('/products', async (request, reply) => {
const result = querySuite(request.query);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
return [];
});
Одна из сильнейших сторон Vest — сложные условия.
import { create, test, enforce, only } from 'vest';
export const paymentSuite = create((data = {}) => {
test('type', 'Тип обязателен', () => {
enforce(data.type).isNotBlank();
});
if (data.type === 'card') {
test('cardNumber', 'Неверный номер карты', () => {
enforce(data.cardNumber).longerThanOrEquals(16);
});
test('cvv', 'CVV обязателен', () => {
enforce(data.cvv).longerThanOrEquals(3);
});
}
if (data.type === 'paypal') {
test('paypalEmail', 'Email обязателен', () => {
enforce(data.paypalEmail).isNotBlank();
});
}
});
Такая логика часто встречается в:
Vest позволяет строить композицию validation suites.
// validation/common.js
import { test, enforce } from 'vest';
export function emailRule(data) {
test('email', 'Неверный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
}
Использование:
import { create } from 'vest';
import { emailRule } from './common.js';
export const registerSuite = create((data = {}) => {
emailRule(data);
});
omitWhen позволяет отключать часть проверок.
import { create, test, omitWhen, enforce } from 'vest';
export const profileSuite = create((data = {}) => {
omitWhen(data.isGuest, () => {
test('address', 'Адрес обязателен', () => {
enforce(data.address).isNotBlank();
});
test('phone', 'Телефон обязателен', () => {
enforce(data.phone).isNotBlank();
});
});
});
Если isGuest === true, проверки не выполняются.
Иногда требуется валидировать только изменённые поля.
import { create, only, test, enforce } from 'vest';
export const userSuite = create((data = {}, fieldName) => {
only(fieldName);
test('email', 'Неверный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('password', 'Короткий пароль', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Использование:
const result = userSuite(
request.body,
'email'
);
interface CreateUserDto {
email: string;
password: string;
age: number;
}
import { create, test, enforce } from 'vest';
export const userSuite = create(
(data: CreateUserDto) => {
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('age', 'Возраст должен быть больше 18', () => {
enforce(data.age).greaterThan(17);
});
}
);
fastify.post<{
Body: CreateUserDto;
}>('/users', async (request, reply) => {
const result = userSuite(request.body);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
return {
success: true,
};
});
Хорошая практика — унифицировать формат validation errors.
{
"statusCode": 400,
"error": "Validation Error",
"fields": {
"email": [
"Некорректный email"
]
}
}
export function formatVestErrors(result) {
return {
statusCode: 400,
error: 'Validation Error',
fields: result.getErrors(),
};
}
Использование:
if (result.hasErrors()) {
return reply
.code(400)
.send(formatVestErrors(result));
}
Наиболее эффективный подход:
fastify.post('/users', {
schema: {
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
}, async (request, reply) => {
const result = userSuite(request.body);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
return {};
});
Разделение ответственности:
| Инструмент | Назначение |
|---|---|
| Fastify Schema | Структура и типы |
| Vest | Бизнес-логика |
Vest достаточно лёгкий для API-сервисов, однако при высокой нагрузке желательно:
only() для partial validation.test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('email', 'Email уже существует', async () => {
const exists = await db.userExists(data.email);
enforce(exists).isFalsy();
});
export const cartSuite = create((data = {}) => {
test('items', 'Корзина пуста', () => {
enforce(data.items.length).greaterThan(0);
});
data.items.forEach((item, index) => {
test(
`items[${index}].quantity`,
'Количество должно быть больше 0',
() => {
enforce(item.quantity).greaterThan(0);
}
);
});
});
В больших системах часто создают abstraction layer.
function withValidation(suite, handler) {
return async (request, reply) => {
const result = await suite(request.body);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
return handler(request, reply);
};
}
Использование:
fastify.post(
'/users',
withValidation(userSuite, async () => {
return {
created: true,
};
})
);
Пример проверки уникальности пользователя через repository layer:
export const userSuite = create((data, deps) => {
test('email', 'Email уже занят', async () => {
const user = await deps.userRepository
.findByEmail(data.email);
enforce(user).isNull();
});
});
Использование:
const result = await userSuite(
request.body,
{
userRepository,
}
);
Такой подход:
Vest suites удобно тестировать отдельно от Fastify.
import { userSuite } from './userSuite.js';
describe('User validation', () => {
test('invalid email', () => {
const result = userSuite({
email: 'wrong',
password: '12345678',
});
expect(result.hasErrors('email')).toBe(true);
});
});
expect(
result.getErrors('email')
).toContain('Некорректный email');
Для крупных API полезна модульная структура.
validation/
├── common/
│ ├── email.js
│ ├── password.js
│ └── phone.js
├── user/
│ ├── createUserSuite.js
│ ├── updateUserSuite.js
│ └── loginSuite.js
└── product/
├── createProductSuite.js
└── updateProductSuite.js
Преимущества:
Неправильно:
test('email', 'Должен быть string', () => {
enforce(data.email).isString();
});
Такие проверки лучше оставлять Fastify schema.
Плохо:
test('username', async () => {
await db.check();
});
Если проверка не нужна для конкретного сценария — её следует отключать условно.
Плохо:
create(() => {
// 1000 строк validation logic
});
Лучше разбивать правила на отдельные модули.
// validation/registerSuite.js
import { create, test, enforce } from 'vest';
export const registerSuite = create(async (data, deps) => {
test('email', 'Неверный email', () => {
enforce(data.email).matches(/^\S+@\S+\.\S+$/);
});
test('password', 'Пароль слишком короткий', () => {
enforce(data.password).longerThanOrEquals(8);
});
test('email', 'Email уже зарегистрирован', async () => {
const exists = await deps.userService
.emailExists(data.email);
enforce(exists).isFalsy();
});
});
fastify.post('/register', async (request, reply) => {
const result = await registerSuite(
request.body,
{
userService,
}
);
if (result.hasErrors()) {
return reply.code(400).send({
errors: result.getErrors(),
});
}
const user = await userService.create(
request.body
);
return {
id: user.id,
};
});