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

Библиотека Vest предназначена для декларативной валидации данных и особенно хорошо подходит для серверных приложений, где требуется сложная логика проверки форм, API-запросов и бизнес-правил. В связке с Express Vest позволяет:

  • централизовать правила валидации;
  • повторно использовать схемы;
  • отделить бизнес-логику от HTTP-слоя;
  • формировать структурированные ошибки;
  • выполнять асинхронные проверки;
  • валидировать отдельные поля без полной проверки объекта.

Типичная архитектура:

HTTP Request
     ↓
Express Middleware
     ↓
Vest Suite
     ↓
Validation Result
     ↓
Controller / Error Response

Установка зависимостей

npm install vest express

Для современных проектов также часто устанавливаются:

npm install express-async-handler

Если используется TypeScript:

npm install -D typescript @types/express

Базовая структура проекта

project/
├── app.js
├── routes/
│   └── users.js
├── validation/
│   └── userValidation.js
├── middleware/
│   └── validate.js
└── controllers/
    └── userController.js

Создание validation suite

Файл validation/userValidation.js:

import { create, test, enforce } fr om 'vest';

export const userSuite = create((data = {}) => {
    test('email', 'Email обязателен', () => {
        enforce(data.email).isNotBlank();
    });

    test('email', 'Некорректный email', () => {
        enforce(data.email).matches(/^\S+@\S+\.\S+$/);
    });

    test('password', 'Минимум 8 символов', () => {
        enforce(data.password).longerThanOrEquals(8);
    });

    test('age', 'Возраст должен быть больше 18', () => {
        enforce(data.age).greaterThan(18);
    });
});

Внутри suite описываются независимые проверки.

Каждый вызов test():

  • принимает имя поля;
  • сообщение ошибки;
  • функцию проверки.

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

Express middleware — наиболее удобный способ подключения Vest.

Файл middleware/validate.js:

export function validate(suite) {
    return (req, res, next) => {
        const result = suite(req.body);

        if (result.hasErrors()) {
            return res.status(400).json({
                success: false,
                errors: result.getErrors(),
            });
        }

        next();
    };
}

Использование middleware в маршрутах

Файл routes/users.js:

import express fr om 'express';
import { validate } fr om '../middleware/validate.js';
import { userSuite } from '../validation/userValidation.js';

const router = express.Router();

router.post(
    '/register',
    validate(userSuite),
    (req, res) => {
        res.json({
            success: true,
            message: 'Пользователь зарегистрирован',
        });
    }
);

export default router;

Инициализация Express-приложения

Файл app.js:

import express from 'express';
import userRoutes from './routes/users.js';

const app = express();

app.use(express.json());

app.use('/users', userRoutes);

app.listen(3000, () => {
    console.log('Server started');
});

Формат ошибок Vest

Метод getErrors() возвращает объект:

{
  "email": [
    "Email обязателен",
    "Некорректный email"
  ],
  "password": [
    "Минимум 8 символов"
  ]
}

Такой формат идеально подходит для REST API.


Создание единого формата ответа

Часто используется собственный error formatter.

export function validate(suite) {
    return (req, res, next) => {
        const result = suite(req.body);

        if (!result.hasErrors()) {
            return next();
        }

        const errors = Object.entries(result.getErrors())
            .map(([field, messages]) => ({
                field,
                messages,
            }));

        return res.status(422).json({
            success: false,
            errors,
        });
    };
}

Ответ:

{
  "success": false,
  "errors": [
    {
      "field": "email",
      "messages": [
        "Некорректный email"
      ]
    }
  ]
}

Валидация query-параметров

Vest можно применять не только к req.body.

export const searchSuite = create((query = {}) => {
    test('page', 'Page должен быть числом', () => {
        enforce(Number(query.page)).isNumber();
    });

    test('lim it', 'Lim it должен быть меньше 100', () => {
        enforce(Number(query.lim it)).lessThanOrEquals(100);
    });
});

Middleware:

router.get(
    '/search',
    validate(searchSuite),
    controller
);

Передача query:

validate((data) => searchSuite(req.query))

Более удобный вариант — универсальный middleware.


Универсальный validation middleware

export function validate(suite, selector = (req) => req.body) {
    return (req, res, next) => {
        const data = selector(req);

        const result = suite(data);

        if (result.hasErrors()) {
            return res.status(400).json({
                errors: result.getErrors(),
            });
        }

        next();
    };
}

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

router.get(
    '/search',
    validate(searchSuite, req => req.query),
    controller
);

Валидация route params

export const idSuite = create((params = {}) => {
    test('id', 'ID должен быть числом', () => {
        enforce(Number(params.id)).isNumber();
    });
});

Маршрут:

router.get(
    '/:id',
    validate(idSuite, req => req.params),
    controller
);

Асинхронная валидация

Одно из ключевых преимуществ Vest — поддержка async-проверок.

Например, проверка существования email в базе.

import { create, test, enforce } from 'vest';

async function isEmailTaken(email) {
    return email === 'admin@example.com';
}

export const registerSuite = create(async (data = {}) => {
    test('email', 'Email обязателен', () => {
        enforce(data.email).isNotBlank();
    });

    test(
        'email',
        'Email уже используется',
        async () => {
            const exists = await isEmailTaken(data.email);

            enforce(exists).equals(false);
        }
    );
});

Middleware для async validation

export function validateAsync(suite) {
    return async (req, res, next) => {
        const result = await suite(req.body);

        if (result.hasErrors()) {
            return res.status(400).json({
                errors: result.getErrors(),
            });
        }

        next();
    };
}

Интеграция с базой данных

Часто проверки выносятся в сервисы.

import { UserRepository } from '../repositories/UserRepository.js';

test(
    'email',
    'Email уже зарегистрирован',
    async () => {
        const user = await UserRepository.findByEmail(data.email);

        enforce(user).isNull();
    }
);

Преимущества:

  • изоляция бизнес-логики;
  • тестируемость;
  • переиспользование;
  • отсутствие SQL внутри validation suite.

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

Метод skipWhen позволяет отключать проверки.

import { skipWhen } from 'vest';

skipWhen(
    !data.password,
    () => {
        test(
            'password',
            'Минимум 8 символов',
            () => {
                enforce(data.password)
                    .longerThanOrEquals(8);
            }
        );
    }
);

Полезно для:

  • optional fields;
  • PATCH-запросов;
  • динамических форм.

Валидация PATCH-запросов

При обновлении сущности проверяются только переданные поля.

export const updateUserSuite = create((data = {}) => {
    skipWhen(
        data.email === undefined,
        () => {
            test(
                'email',
                'Некорректный email',
                () => {
                    enforce(data.email)
                        .matches(/^\S+@\S+\.\S+$/);
                }
            );
        }
    );

    skipWhen(
        data.password === undefined,
        () => {
            test(
                'password',
                'Минимум 8 символов',
                () => {
                    enforce(data.password)
                        .longerThanOrEquals(8);
                }
            );
        }
    );
});

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

export const addressSuite = create((data = {}) => {
    test('address.city', 'Город обязателен', () => {
        enforce(data.address?.city).isNotBlank();
    });

    test('address.street', 'Улица обязательна', () => {
        enforce(data.address?.street).isNotBlank();
    });
});

Валидация массивов

export const cartSuite = create((data = {}) => {
    test('items', 'Корзина пуста', () => {
        enforce(data.items).isArray();
        enforce(data.items.length).greaterThan(0);
    });

    data.items?.forEach((item, index) => {
        test(
            `items[${index}].quantity`,
            'Количество должно быть больше 0',
            () => {
                enforce(item.quantity).greaterThan(0);
            }
        );
    });
});

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

Крупные приложения разделяют валидацию на модули.

export const emailSuite = create((data = {}) => {
    test('email', 'Некорректный email', () => {
        enforce(data.email)
            .matches(/^\S+@\S+\.\S+$/);
    });
});

export const passwordSuite = create((data = {}) => {
    test('password', 'Слабый пароль', () => {
        enforce(data.password)
            .longerThanOrEquals(8);
    });
});

Композиция:

export const registerSuite = create((data = {}) => {
    emailSuite(data);
    passwordSuite(data);
});

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

Vest поддерживает частичную валидацию.

const result = userSuite(data, fieldName);

Проверка только одного поля:

const result = userSuite(req.body, 'email');

Полезно для:

  • realtime validation;
  • AJAX-проверок;
  • wizard forms.

Валидация файлов

При использовании Multer:

export const uploadSuite = create((data = {}) => {
    test('avatar', 'Файл обязателен', () => {
        enforce(data.avatar).isNotNull();
    });

    test('avatar', 'Допустим только PNG', () => {
        enforce(data.avatar.mimetype)
            .equals('image/png');
    });

    test('avatar', 'Максимум 2MB', () => {
        enforce(data.avatar.size)
            .lessThanOrEquals(2 * 1024 * 1024);
    });
});

Обработка ошибок через централизованный handler

export class ValidationError extends Error {
    constructor(errors) {
        super('Validation failed');

        this.errors = errors;
        this.status = 422;
    }
}

Middleware:

export function validate(suite) {
    return (req, res, next) => {
        const result = suite(req.body);

        if (result.hasErrors()) {
            return next(
                new ValidationError(
                    result.getErrors()
                )
            );
        }

        next();
    };
}

Global handler:

app.use((err, req, res, next) => {
    res.status(err.status || 500).json({
        success: false,
        message: err.message,
        errors: err.errors || null,
    });
});

Интеграция с JWT-аутентификацией

Vest часто применяется после middleware авторизации.

router.post(
    '/profile',
    authMiddleware,
    validate(profileSuite),
    updateProfileController
);

Последовательность:

JWT Verify
    ↓
Validation
    ↓
Business Logic

Интеграция с express-async-handler

import asyncHandler from 'express-async-handler';

router.post(
    '/register',
    asyncHandler(async (req, res) => {
        const result = await registerSuite(req.body);

        if (result.hasErrors()) {
            return res.status(400).json({
                errors: result.getErrors(),
            });
        }

        res.json({
            success: true,
        });
    })
);

Создание reusable validation helpers

export function validateEmail(field, value) {
    test(field, 'Некорректный email', () => {
        enforce(value)
            .matches(/^\S+@\S+\.\S+$/);
    });
}

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

validateEmail('email', data.email);

Валидация ролей и прав

export const roleSuite = create((data = {}) => {
    test('role', 'Недопустимая роль', () => {
        enforce(data.role).inside([
            'admin',
            'moderator',
            'user',
        ]);
    });
});

Локализация сообщений

const messages = {
    required: 'Поле обязательно',
    invalidEmail: 'Некорректный email',
};
test(
    'email',
    messages.invalidEmail,
    () => {
        enforce(data.email)
            .matches(/^\S+@\S+\.\S+$/);
    }
);

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

Часто правила зависят от конфигурации приложения.

test(
    'password',
    'Пароль слишком короткий',
    () => {
        enforce(data.password)
            .longerThanOrEquals(
                Number(process.env.MIN_PASSWORD)
            );
    }
);

Тестирование validation suites

С использованием Jest:

import { registerSuite } from './registerSuite';

describe('Register validation', () => {
    test('invalid email', () => {
        const result = registerSuite({
            email: 'wrong',
            password: '12345678',
        });

        expect(
            result.hasErrors('email')
        ).toBe(true);
    });
});

Изоляция бизнес-валидации

Vest рекомендуется использовать только для:

  • проверки входящих данных;
  • проверки структуры;
  • проверки форматов;
  • бизнес-ограничений.

Не рекомендуется:

  • выполнять HTTP-запросы внутри test;
  • изменять данные;
  • выполнять запись в базу;
  • вызывать побочные эффекты.

Неправильно:

test('email', 'Ошибка', async () => {
    await db.users.insert(data);
});

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

Для высоконагруженных API важны:

Минимизация async-проверок

skipWhen(
    result.hasErrors('email'),
    () => {
        test(
            'email',
            'Email занят',
            async () => {
                // expensive query
            }
        );
    }
);

Разделение suite

Большие validation-файлы ухудшают поддержку.

Оптимально:

validation/
├── auth/
├── users/
├── products/
└── orders/

Кэширование справочных данных

const allowedRoles = ['admin', 'user'];

Вместо:

await loadRolesFromDatabase();

Типичный production pipeline

Request
  ↓
Helmet
  ↓
Rate Limiter
  ↓
JWT Middleware
  ↓
Vest Validation
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
Database

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

import express from 'express';
import { create, test, enforce } from 'vest';

const app = express();

app.use(express.json());

const registerSuite = create(async (data = {}) => {

    test(
        'username',
        'Имя обязательно',
        () => {
            enforce(data.username)
                .isNotBlank();
        }
    );

    test(
        'email',
        'Некорректный email',
        () => {
            enforce(data.email)
                .matches(/^\S+@\S+\.\S+$/);
        }
    );

    test(
        'password',
        'Минимум 8 символов',
        () => {
            enforce(data.password)
                .longerThanOrEquals(8);
        }
    );
});

async function validate(req, res, next) {

    const result = await registerSuite(req.body);

    if (result.hasErrors()) {
        return res.status(422).json({
            success: false,
            errors: result.getErrors(),
        });
    }

    next();
}

app.post(
    '/register',
    validate,
    async (req, res) => {

        res.json({
            success: true,
            user: {
                email: req.body.email,
            },
        });
    }
);

app.listen(3000);