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

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

Создание базового Vest Suite

Простейший набор правил:

// 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;
  • suite возвращает объект результата.

Подключение Vest в маршруте Fastify

Простейшая интеграция:

// 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": [
      "Имя обязательно"
    ]
  }
}

Выделение middleware-подобного валидатора

В больших приложениях логику валидации лучше выносить отдельно.

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

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

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

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

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

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

Создание validation plugin

В крупных проектах удобно оформлять Vest как Fastify plugin.

Базовый 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(),
  });
}

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

Vest подходит не только для body, но и для:

  • params;
  • query;
  • headers.

Проверка route params

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

Валидация query string

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

Такая логика часто встречается в:

  • checkout API;
  • multi-step формах;
  • динамических настройках;
  • административных панелях.

Группировка правил

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

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

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

Типизация request body

interface CreateUserDto {
  email: string;
  password: string;
  age: number;
}

Типизированный suite

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 route

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

Helper

export function formatVestErrors(result) {
  return {
    statusCode: 400,
    error: 'Validation Error',
    fields: result.getErrors(),
  };
}

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

if (result.hasErrors()) {
  return reply
    .code(400)
    .send(formatVestErrors(result));
}

Комбинирование JSON Schema и Vest

Наиболее эффективный подход:

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-сервисов, однако при высокой нагрузке желательно:

  • избегать тяжёлых async-операций;
  • минимизировать обращения к БД;
  • кешировать lookup-данные;
  • разделять быстрые и медленные проверки;
  • использовать 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);
      }
    );
  });
});

Создание универсального route wrapper

В больших системах часто создают 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,
  }
);

Такой подход:

  • уменьшает связанность;
  • упрощает тестирование;
  • позволяет внедрять mock-объекты.

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

Vest suites удобно тестировать отдельно от Fastify.

Пример unit-теста

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

Организация больших validation-модулей

Для крупных API полезна модульная структура.

validation/
├── common/
│   ├── email.js
│   ├── password.js
│   └── phone.js
├── user/
│   ├── createUserSuite.js
│   ├── updateUserSuite.js
│   └── loginSuite.js
└── product/
    ├── createProductSuite.js
    └── updateProductSuite.js

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

  • переиспользование;
  • единообразие;
  • удобство тестирования;
  • независимость модулей.

Распространённые ошибки

Смешивание schema validation и business validation

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

test('email', 'Должен быть string', () => {
  enforce(data.email).isString();
});

Такие проверки лучше оставлять Fastify schema.


Избыточные async-проверки

Плохо:

test('username', async () => {
  await db.check();
});

Если проверка не нужна для конкретного сценария — её следует отключать условно.


Монолитные suites

Плохо:

create(() => {
  // 1000 строк validation logic
});

Лучше разбивать правила на отдельные модули.


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

Validation suite

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

});

Route

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

Преимущества такого подхода

  • независимость validation logic;
  • удобное тестирование;
  • повторное использование;
  • чистые route handlers;
  • гибкая условная логика;
  • масштабируемая архитектура;
  • поддержка async-сценариев;
  • совместимость с Fastify hooks и plugins.