При разработке серверных приложений проверка входящих данных становится одной из ключевых задач. API принимает данные из внешних источников:
Любые входящие данные считаются потенциально некорректными. Ошибки валидации приводят к:
Библиотека Vest предоставляет декларативный подход к валидации, напоминающий unit-тестирование. Это особенно удобно для сложных API, где требуется:
Типичный pipeline обработки запроса:
HTTP Request
↓
Middleware
↓
Vest Validation Suite
↓
Обработка ошибок
↓
Controller / Service
↓
Database
Vest не зависит от Express, Fastify, Koa или NestJS. Библиотека работает как независимый слой проверки данных.
npm install vest
Для удобной проверки значений часто используется библиотека validator:
npm install validator
Основой Vest является create.
import { create, test, enforce } fr om 'vest';
const validateUser = create((data = {}) => {
test('email', 'Некорректный email', () => {
enforce(data.email).isString();
enforce(data.email).matches(/@/);
});
test('password', 'Пароль слишком короткий', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Запуск проверки:
const result = validateUser({
email: 'admin@mail.com',
password: '12345678'
});
Проверка ошибок:
result.hasErrors(); // false
Получение ошибок поля:
result.getErrors('email');
import express fr om 'express';
import { create, test, enforce } fr om 'vest';
const app = express();
app.use(express.json());
const validateRegister = create((data = {}) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test('password', 'Минимум 8 символов', () => {
enforce(data.password).longerThanOrEquals(8);
});
});
Middleware:
function validateRequest(suite) {
return (req, res, next) => {
const result = suite(req.body);
if (result.hasErrors()) {
return res.status(400).json({
success: false,
errors: result.getErrors()
});
}
next();
};
}
Использование:
app.post(
'/register',
validateRequest(validateRegister),
(req, res) => {
res.json({
success: true
});
}
);
test('name', 'Имя обязательно', () => {
enforce(data.name).isNotBlank();
});
test('age', 'Возраст должен быть числом', () => {
enforce(data.age).isNumber();
});
test('tags', 'Должен быть массив', () => {
enforce(data.tags).isArray();
});
test('profile', 'Профиль обязателен', () => {
enforce(data.profile).isObject();
});
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});
import isEmail fr om 'validator/lib/isEmail.js';
test('email', 'Некорректный email', () => {
enforce(isEmail(data.email)).isTruthy();
});
test('password', 'Минимум 8 символов', () => {
enforce(data.password).longerThanOrEquals(8);
});
test('password', 'Пароль слишком простой', () => {
enforce(data.password).matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/
);
});
test('price', 'Цена должна быть больше 0', () => {
enforce(data.price).greaterThan(0);
});
Максимальное значение:
test('discount', 'Скидка не может превышать 100%', () => {
enforce(data.discount).lessThanOrEquals(100);
});
import isURL from 'validator/lib/isURL.js';
test('website', 'Некорректный URL', () => {
enforce(isURL(data.website)).isTruthy();
});
test('id', 'Некорректный UUID', () => {
enforce(data.id).matches(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
{
"user": {
"name": "Alex",
"contacts": {
"email": "alex@mail.com"
}
}
}
Валидация:
const validateUser = create((data = {}) => {
test('user.name', 'Имя обязательно', () => {
enforce(data.user?.name).isNotBlank();
});
test('user.contacts.email', 'Email обязателен', () => {
enforce(data.user?.contacts?.email).isNotBlank();
});
});
{
"items": [
{
"title": "Book",
"price": 100
}
]
}
Валидация:
const validateOrder = create((data = {}) => {
data.items?.forEach((item, index) => {
test(`items.${index}.title`, 'Название обязательно', () => {
enforce(item.title).isNotBlank();
});
test(`items.${index}.price`, 'Цена должна быть больше 0', () => {
enforce(item.price).greaterThan(0);
});
});
});
if (data.phone) {
test('phone', 'Некорректный телефон', () => {
enforce(data.phone).matches(/^\+7\d{10}$/);
});
}
const validateUser = create((data = {}) => {
if (data.role === 'admin') {
test('accessKey', 'Access key обязателен', () => {
enforce(data.accessKey).isNotBlank();
});
}
});
function validateEmail(email) {
test('email', 'Email обязателен', () => {
enforce(email).isNotBlank();
});
test('email', 'Некорректный email', () => {
enforce(email).matches(/@/);
});
}
Использование:
const suite = create((data = {}) => {
validateEmail(data.email);
});
Vest поддерживает async-проверки.
const validateUser = create(async (data = {}) => {
test(
'email',
'Email уже используется',
async () => {
const exists = await usersRepository.existsByEmail(
data.email
);
enforce(exists).isFalsy();
}
);
});
const result = await validateUser(req.body);
if (result.hasErrors()) {
return res.status(400).json({
errors: result.getErrors()
});
}
const validateUser = create(async (data = {}) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test(
'email',
'Email уже существует',
async () => {
const exists = await repository.exists(
data.email
);
enforce(exists).isFalsy();
}
);
});
Иногда требуется остановить валидацию после первой ошибки.
import { create, test, enforce, skipWhen } from 'vest';
Пример:
const suite = create((data = {}) => {
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
skipWhen(res => res.hasErrors('email'), () => {
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/@/);
});
});
});
const validateQuery = create((query = {}) => {
test('page', 'Page должен быть числом', () => {
enforce(Number(query.page)).isNumber();
});
test('lim it', 'Lim it должен быть числом', () => {
enforce(Number(query.lim it)).isNumber();
});
});
const validateParams = create((params = {}) => {
test('id', 'Некорректный ID', () => {
enforce(params.id).matches(/^\d+$/);
});
});
const validateHeaders = create((headers = {}) => {
test('authorization', 'Authorization обязателен', () => {
enforce(headers.authorization).isNotBlank();
});
});
function validate({ body, query, params, headers }) {
return async (req, res, next) => {
const results = await Promise.all([
body ? body(req.body) : null,
query ? query(req.query) : null,
params ? params(req.params) : null,
headers ? headers(req.headers) : null
]);
const hasErrors = results.some(
result => result?.hasErrors()
);
if (hasErrors) {
return res.status(400).json({
body: results[0]?.getErrors(),
query: results[1]?.getErrors(),
params: results[2]?.getErrors(),
headers: results[3]?.getErrors()
});
}
next();
};
}
Использование:
app.post(
'/users/:id',
validate({
body: validateBody,
query: validateQuery,
params: validateParams,
headers: validateHeaders
}),
controller
);
API обычно возвращает единый формат ошибок.
{
"success": false,
"errors": {
"email": [
"Некорректный email"
]
}
}
Middleware:
function formatVestErrors(result) {
return {
success: false,
errors: result.getErrors()
};
}
function isKazakhstanPhone(phone) {
return /^\+7\d{10}$/.test(phone);
}
Использование:
test('phone', 'Некорректный номер', () => {
enforce(isKazakhstanPhone(data.phone))
.isTruthy();
});
import { test, enforce } from 'vest';
export function validateEmail(email) {
test('email', 'Email обязателен', () => {
enforce(email).isNotBlank();
});
test('email', 'Некорректный email', () => {
enforce(email).matches(/@/);
});
}
import { create } from 'vest';
import { validateEmail } from './email.validator.js';
export const validateUser = create((data = {}) => {
validateEmail(data.email);
});
PATCH-запросы обычно содержат только изменяемые поля.
{
"name": "Alex"
}
Валидация:
const validatePatchUser = create((data = {}) => {
if ('name' in data) {
test('name', 'Имя не может быть пустым', () => {
enforce(data.name).isNotBlank();
});
}
if ('email' in data) {
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/@/);
});
}
});
Vest хорошо сочетается с DTO-подходом.
class CreateUserDto {
constructor(data) {
this.name = data.name;
this.email = data.email;
this.password = data.password;
}
}
const validateCreateUser = create((dto) => {
test('name', 'Имя обязательно', () => {
enforce(dto.name).isNotBlank();
});
test('email', 'Email обязателен', () => {
enforce(dto.email).isNotBlank();
});
test('password', 'Пароль слишком короткий', () => {
enforce(dto.password).longerThanOrEquals(8);
});
});
app.post('/users', async (req, res) => {
const result = validateUser(req.body);
if (result.hasErrors()) {
return res.status(400).json({
errors: result.getErrors()
});
}
const user = await repository.create(req.body);
res.json(user);
});
Валидация API не заменяет бизнес-валидацию.
test(
'balance',
'Недостаточно средств',
async () => {
const balance = await wallet.getBalance(
data.userId
);
enforce(balance >= data.amount).isTruthy();
}
);
Проверяет:
Проверяет:
const validateUpload = create((data = {}) => {
test('file', 'Файл обязателен', () => {
enforce(data.file).isNotBlank();
});
test('fileSize', 'Файл слишком большой', () => {
enforce(data.file.size)
.lessThanOrEquals(5 * 1024 * 1024);
});
});
const validateHeaders = create((headers = {}) => {
test(
'content-type',
'Требуется application/json',
() => {
enforce(headers['content-type'])
.matches(/application\/json/);
}
);
});
validators/
├── auth/
│ ├── login.validator.js
│ └── register.validator.js
├── user/
│ ├── create.validator.js
│ └── update.validator.js
├── shared/
│ ├── email.validator.js
│ └── password.validator.js
import { validateUser } from './user.validator.js';
describe('User validation', () => {
test('should validate valid payload', () => {
const result = validateUser({
email: 'admin@mail.com',
password: '12345678'
});
expect(result.hasErrors()).toBe(false);
});
test('should fail invalid email', () => {
const result = validateUser({
email: 'wrong-email',
password: '12345678'
});
expect(result.hasErrors('email'))
.toBe(true);
});
});
Vest эффективен для сложных сценариев благодаря:
Для высоконагруженных API рекомендуется:
Плохо:
test('role', 'Недостаточно прав', async () => {
const permissions = await auth.getPermissions();
enforce(
permissions.includes('ADMIN')
).isTruthy();
});
Transport validation должна проверять только структуру запроса.
Клиентская валидация никогда не заменяет серверную.
Все данные API должны валидироваться повторно на сервере.
Нежелательно:
{
"email": [
"error"
]
}
Лучше:
{
"success": false,
"code": "VALIDATION_ERROR",
"errors": {
"email": [
"Некорректный email"
]
}
}
import express from 'express';
import {
create,
test,
enforce
} from 'vest';
const app = express();
app.use(express.json());
const validateRegister = create(async (data = {}) => {
test('name', 'Имя обязательно', () => {
enforce(data.name).isNotBlank();
});
test('email', 'Email обязателен', () => {
enforce(data.email).isNotBlank();
});
test('email', 'Некорректный email', () => {
enforce(data.email).matches(/@/);
});
test(
'password',
'Минимум 8 символов',
() => {
enforce(data.password)
.longerThanOrEquals(8);
}
);
test(
'email',
'Email уже существует',
async () => {
const exists =
await repository.existsByEmail(
data.email
);
enforce(exists).isFalsy();
}
);
});
function validate(suite) {
return async (req, res, next) => {
const result = await suite(req.body);
if (result.hasErrors()) {
return res.status(400).json({
success: false,
errors: result.getErrors()
});
}
next();
};
}
app.post(
'/register',
validate(validateRegister),
async (req, res) => {
const user = await repository.create(
req.body
);
res.status(201).json(user);
}
);