Создание middleware для валидации

Middleware в серверных JavaScript-приложениях выступает промежуточным слоем, который получает управление между получением HTTP-запроса и его обработкой конечным обработчиком. В контексте валидации данных этот слой позволяет централизованно проверять входящие данные до попадания их в бизнес-логику, снижая дублирование кода и повышая предсказуемость обработки запросов.

В экосистеме TypeScript и Node.js одним из наиболее устойчивых решений для декларативной валидации считается class-validator. В связке с ним часто используется class-transformer, обеспечивающий преобразование plain-объектов в экземпляры классов, что критично для корректной работы декораторов валидации.


Middleware для валидации выполняет несколько ключевых функций:

  • перехват входящих данных (body, query, params)
  • преобразование входных структур в экземпляры DTO
  • запуск синхронной или асинхронной валидации
  • формирование стандартизированного ответа об ошибках
  • передача управления следующему обработчику при успехе

Такой подход отделяет инфраструктурную логику от бизнес-логики и позволяет контролировать корректность данных на самом раннем этапе обработки запроса.


Базовый механизм работы class-validator

Библиотека работает на основе декораторов, которые навешиваются на свойства классов:

  • @IsString()
  • @IsNumber()
  • @IsEmail()
  • @IsOptional()
  • @ValidateNested()

Каждый декоратор добавляет метаданные, которые затем используются функцией validate() или validateOrReject() для проверки экземпляра класса.

Ключевой момент: валидация работает только с экземплярами классов, а не с обычными объектами. Поэтому middleware почти всегда включает этап трансформации данных.


Общая структура middleware для Express

В Express middleware представляет собой функцию с сигнатурой:

(req, res, next) => {}

Базовая идея middleware валидации:

  1. получить DTO-класс
  2. преобразовать req.body в экземпляр этого класса
  3. выполнить валидацию
  4. при ошибке вернуть 400
  5. при успехе вызвать next()

Простейшая реализация middleware

import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";

export function validationMiddleware(dtoClass) {
  return async (req, res, next) => {
    const instance = plainToInstance(dtoClass, req.body);

    const errors = await validate(instance);

    if (errors.length > 0) {
      return res.status(400).json({
        message: "Validation failed",
        errors: errors.map(err => ({
          property: err.property,
          constraints: err.constraints,
        })),
      });
    }

    req.body = instance;
    next();
  };
}

DTO как основа контрактов данных

DTO-класс определяет структуру входных данных:

import { IsString, IsEmail, IsOptional } from "class-validator";

export class CreateUserDto {
  @IsString()
  name;

  @IsEmail()
  email;

  @IsOptional()
  @IsString()
  nickname;
}

Middleware использует этот класс как контракт, гарантируя, что контроллер получит уже проверенную структуру.


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

import express from "express";
import { validationMiddleware } from "./validationMiddleware";
import { CreateUserDto } from "./dto/CreateUserDto";

const app = express();

app.post(
  "/users",
  validationMiddleware(CreateUserDto),
  (req, res) => {
    res.json({
      message: "User created",
      data: req.body,
    });
  }
);

Работа с вложенными объектами

Для сложных структур важно использовать ValidateNested и Type:

import { Type } from "class-transformer";
import { ValidateNested, IsString } from "class-validator";

class AddressDto {
  @IsString()
  city;
}

class UserDto {
  @IsString()
  name;

  @ValidateNested()
  @Type(() => AddressDto)
  address;
}

Middleware должен учитывать глубокую трансформацию:

const instance = plainToInstance(dtoClass, req.body, {
  enableImplicitConversion: true,
});

Расширенная обработка ошибок

Структура ошибок class-validator содержит вложенные данные, которые требуют нормализации.

function formatErrors(errors) {
  return errors.map(error => {
    return {
      field: error.property,
      messages: error.constraints
        ? Object.values(error.constraints)
        : [],
      children: error.children?.length
        ? formatErrors(error.children)
        : [],
    };
  });
}

Такой формат позволяет унифицировать ответ API независимо от глубины структуры.


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

Middleware может поддерживать валидацию разных источников данных:

  • req.body
  • req.query
  • req.params

Пример универсального middleware:

export function validateRequest(dtoClass, source = "body") {
  return async (req, res, next) => {
    const instance = plainToInstance(dtoClass, req[source]);

    const errors = await validate(instance);

    if (errors.length > 0) {
      return res.status(400).json({ errors });
    }

    req[source] = instance;
    next();
  };
}

Асинхронные правила валидации

Некоторые ограничения требуют обращения к базе данных или внешним сервисам:

import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";

@ValidatorConstraint({ async: true })
export class IsEmailUnique implements ValidatorConstraintInterface {
  async validate(email) {
    const user = await findUserByEmail(email);
    return !user;
  }

  defaultMessage() {
    return "Email already exists";
  }
}

Middleware не требует изменений, но должен поддерживать await validate() без блокировки потока.


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

При интенсивной нагрузке важны следующие аспекты:

  • переиспользование классов DTO
  • минимизация глубокой трансформации
  • отключение лишних проверок (skipMissingProperties)
  • использование validateOrReject для быстрого fail-fast режима

Пример оптимизированной валидации:

await validate(instance, {
  skipMissingProperties: false,
  whitelist: true,
  forbidNonWhitelisted: true,
});

Интеграция с NestJS-подобной архитектурой

В фреймворках вроде NestJS аналог middleware реализуется через pipes, но концептуально сохраняется тот же поток:

  • входящий запрос
  • трансформация DTO
  • валидация через class-validator
  • возврат ошибки или передача дальше

Middleware-реализация остаётся полезной в чистом Express-приложении, где отсутствует встроенная DI-система.


Типизация middleware в TypeScript

Типизированная версия повышает безопасность:

import { Request, Response, NextFunction } from "express";

type ClassConstructor<T> = {
  new (): T;
};

export function validationMiddleware<T>(dtoClass: ClassConstructor<T>) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const instance = plainToInstance(dtoClass, req.body);

    const errors = await validate(instance);

    if (errors.length) {
      return res.status(400).json(errors);
    }

    req.body = instance;
    next();
  };
}

Композиция нескольких middleware

Валидация часто комбинируется с другими слоями:

  • аутентификация
  • логирование
  • rate limiting

Порядок критичен: валидация должна происходить после аутентификации, но до бизнес-логики.

app.post(
  "/secure-route",
  authMiddleware,
  validationMiddleware(SecureDto),
  handler
);

Повторное использование и фабрики

Для масштабируемых систем middleware часто строится как фабрика с параметрами:

export function createValidationMiddleware(options) {
  return (dtoClass) => {
    return async (req, res, next) => {
      const instance = plainToInstance(dtoClass, req[options.source]);

      const errors = await validate(instance, options.validatorOptions);

      if (errors.length) {
        return res.status(options.errorCode || 400).json(errors);
      }

      req[options.source] = instance;
      next();
    };
  };
}

Практика строгой схемы данных

Комбинация декораторов позволяет формировать строгие контракты API:

  • обязательные поля
  • ограничения длины
  • числовые диапазоны
  • кастомные правила

Middleware становится точкой enforcement этих правил, гарантируя, что downstream-логика работает только с валидными данными.