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

При разработке серверных приложений проверка входящих данных становится одной из ключевых задач. API принимает данные из внешних источников:

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

Любые входящие данные считаются потенциально некорректными. Ошибки валидации приводят к:

  • повреждению данных;
  • уязвимостям;
  • неконсистентности базы данных;
  • ошибкам бизнес-логики;
  • падению приложения.

Библиотека Vest предоставляет декларативный подход к валидации, напоминающий unit-тестирование. Это особенно удобно для сложных API, где требуется:

  • условная проверка;
  • группировка ошибок;
  • асинхронная валидация;
  • переиспользование правил;
  • частичная проверка payload.

Архитектура валидации API через Vest

Типичный pipeline обработки запроса:

HTTP Request
    ↓
Middleware
    ↓
Vest Validation Suite
    ↓
Обработка ошибок
    ↓
Controller / Service
    ↓
Database

Vest не зависит от Express, Fastify, Koa или NestJS. Библиотека работает как независимый слой проверки данных.


Установка

npm install vest

Для удобной проверки значений часто используется библиотека validator:

npm install validator

Базовая структура validation suite

Основой 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');

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

Middleware валидации

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

Проверка email

Через регулярное выражение

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

Через validator.js

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

Валидация URL

import isURL from 'validator/lib/isURL.js';

test('website', 'Некорректный URL', () => {
  enforce(isURL(data.website)).isTruthy();
});

Валидация UUID

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

Проверка вложенных объектов

Пример payload

{
  "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-проверки.

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

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

      enforce(exists).isFalsy();
    }
  );
});

Обработка async validation

const result = await validateUser(req.body);

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

Комбинация sync и async правил

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

Проверка query-параметров

GET /users?page=1&limit=20

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

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

Проверка route params

GET /users/:id

const validateParams = create((params = {}) => {
  test('id', 'Некорректный ID', () => {
    enforce(params.id).matches(/^\d+$/);
  });
});

Валидация headers

const validateHeaders = create((headers = {}) => {
  test('authorization', 'Authorization обязателен', () => {
    enforce(headers.authorization).isNotBlank();
  });
});

Комплексная проверка запроса

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

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

Переиспользуемые validation modules

email.validator.js

import { test, enforce } from 'vest';

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

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

user.validator.js

import { create } from 'vest';
import { validateEmail } from './email.validator.js';

export const validateUser = create((data = {}) => {
  validateEmail(data.email);
});

Частичная валидация PATCH-запросов

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

Валидация DTO

Vest хорошо сочетается с DTO-подходом.

DTO

class CreateUserDto {
  constructor(data) {
    this.name = data.name;
    this.email = data.email;
    this.password = data.password;
  }
}

Validation Suite

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

Разделение validation layers

Транспортный уровень

Проверяет:

  • типы;
  • обязательность;
  • формат;
  • диапазоны.

Бизнес-уровень

Проверяет:

  • уникальность;
  • права доступа;
  • состояние сущностей;
  • ограничения домена.

Валидация multipart/form-data

Проверка загруженных файлов

const validateUpload = create((data = {}) => {
  test('file', 'Файл обязателен', () => {
    enforce(data.file).isNotBlank();
  });

  test('fileSize', 'Файл слишком большой', () => {
    enforce(data.file.size)
      .lessThanOrEquals(5 * 1024 * 1024);
  });
});

Валидация JSON API

Проверка content-type

const validateHeaders = create((headers = {}) => {
  test(
    'content-type',
    'Требуется application/json',
    () => {
      enforce(headers['content-type'])
        .matches(/application\/json/);
    }
  );
});

Централизованная структура validators

validators/
├── auth/
│   ├── login.validator.js
│   └── register.validator.js
├── user/
│   ├── create.validator.js
│   └── update.validator.js
├── shared/
│   ├── email.validator.js
│   └── password.validator.js

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

Unit-тест

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 рекомендуется:

  • избегать тяжёлых async-запросов;
  • кэшировать результаты;
  • выносить бизнес-проверки в сервисный слой;
  • минимизировать обращения к БД.

Типичные ошибки

Смешивание бизнес-логики и transport validation

Плохо:

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

Пример полноценной API-валидации

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