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

Koa не предоставляет встроенного механизма валидации входящих HTTP-запросов, поэтому проверка данных обычно выносится в отдельный слой. При использовании class-validator структура приложения начинает строиться вокруг DTO-классов (Data Transfer Objects), которые описывают форму входных данных, а middleware Koa отвечает за преобразование запроса и запуск процесса валидации.

Валидация в Koa при интеграции с class-validator опирается на три ключевых компонента:

  • DTO-классы — описывают структуру входных данных и правила проверки
  • class-transformer — преобразует plain-объекты в экземпляры классов
  • middleware Koa — связывает HTTP-запрос и слой валидации

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

Подготовка зависимостей

Для работы используется связка библиотек:

npm install class-validator class-transformer koa koa-router koa-bodyparser

koa-bodyparser необходим для разбора тела запроса, поскольку Koa по умолчанию не парсит JSON.

Базовый DTO-класс

DTO описывает правила проверки входных данных через декораторы:

import { IsEmail, IsString, MinLength, IsInt, Min } fr om "class-validator";

export class CreateUserDto {
  @IsEmail()
  email;

  @IsString()
  @MinLength(6)
  password;

  @IsString()
  name;

  @IsInt()
  @Min(0)
  age;
}

Каждое поле снабжается набором ограничений, которые будут проверяться во время валидации.

Преобразование входных данных

class-validator работает с экземплярами классов, поэтому входящий объект должен быть преобразован:

import { plainToInstance } from "class-transformer";

Без этого шага валидация не будет корректно обрабатывать декораторы.

Middleware валидации

Основной слой интеграции реализуется через middleware-фабрику. Она принимает DTO-класс и возвращает функцию Koa:

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

export const validateBody = (DtoClass) => {
  return async (ctx, next) => {
    const instance = plainToInstance(DtoClass, ctx.request.body);

    const errors = await validate(instance, {
      whitelist: true,
      forbidNonWhitelisted: true,
    });

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

    ctx.request.validatedBody = instance;
    await next();
  };
};

Ключевые параметры validate:

  • whitelist: true — удаляет поля, не описанные в DTO
  • forbidNonWhitelisted: true — вызывает ошибку при наличии лишних полей

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

Интеграция с koa-router выглядит следующим образом:

import Router from "koa-router";
import bodyParser from "koa-bodyparser";
import { validateBody } from "./middlewares/validateBody.js";
import { CreateUserDto } from "./dto/CreateUserDto.js";

const router = new Router();

router.post(
  "/users",
  bodyParser(),
  validateBody(CreateUserDto),
  async (ctx) => {
    const data = ctx.request.validatedBody;

    ctx.body = {
      message: "User created",
      user: data,
    };
  }
);

export default router;

Middleware выполняется последовательно: сначала парсинг тела, затем валидация, затем бизнес-логика.

Централизованная обработка ошибок валидации

Вместо обработки ошибок в каждом middleware можно использовать единый error handler Koa:

export const errorHandler = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.name === "ValidationError") {
      ctx.status = 400;
      ctx.body = {
        message: "Validation error",
        details: err.errors,
      };
      return;
    }

    ctx.status = 500;
    ctx.body = {
      message: "Internal server error",
    };
  }
};

Такой подход полезен при расширении логики валидации, когда ошибки начинают выбрасываться через исключения.

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

Аналогичный подход применяется для ctx.query, но с отдельным DTO:

import { IsOptional, IsInt } from "class-validator";

export class GetUsersQueryDto {
  @IsOptional()
  @IsInt()
  lim it;

  @IsOptional()
  @IsInt()
  offset;
}

Middleware:

export const validateQuery = (DtoClass) => {
  return async (ctx, next) => {
    const instance = plainToInstance(DtoClass, ctx.query);

    const errors = await validate(instance);

    if (errors.length > 0) {
      ctx.status = 400;
      ctx.body = { errors };
      return;
    }

    ctx.request.validatedQuery = instance;
    await next();
  };
};

Разделение DTO для разных частей запроса

В реальных приложениях часто используется несколько DTO для одного endpoint:

  • тело запроса (body)
  • query-параметры (query)
  • параметры маршрута (params)

Пример DTO для params:

import { IsUUID } fr om "class-validator";

export class UserParamsDto {
  @IsUUID()
  id;
}

Middleware аналогично адаптируется:

export const validateParams = (DtoClass) => {
  return async (ctx, next) => {
    const instance = plainToInstance(DtoClass, ctx.params);

    const errors = await validate(instance);

    if (errors.length > 0) {
      ctx.status = 400;
      ctx.body = { errors };
      return;
    }

    ctx.request.validatedParams = instance;
    await next();
  };
};

Композиция middleware

Koa позволяет комбинировать несколько уровней валидации:

router.post(
  "/users/:id",
  bodyParser(),
  validateParams(UserParamsDto),
  validateBody(UpdateUserDto),
  async (ctx) => {
    const { validatedParams, validatedBody } = ctx.request;

    ctx.body = {
      id: validatedParams.id,
      update: validatedBody,
    };
  }
);

Такой подход делает endpoint строго типизированным на уровне исполнения.

Особенности работы с числовыми значениями

HTTP-запросы передают все значения как строки, поэтому без преобразования class-validator может работать некорректно. Для этого используется class-transformer:

import { Type } from "class-transformer";
import { IsInt } from "class-validator";

export class PaginationDto {
  @Type(() => Number)
  @IsInt()
  page;

  @Type(() => Number)
  @IsInt()
  lim it;
}

Без @Type(() => Number) значения останутся строками и проверки типов будут давать неожиданный результат.

Глобальная стратегия повторного использования валидаторов

Для уменьшения дублирования middleware часто создаются универсальные фабрики:

export const createValidationMiddleware = (source, DtoClass) => {
  return async (ctx, next) => {
    const instance = plainToInstance(DtoClass, ctx[source]);

    const errors = await validate(instance, {
      whitelist: true,
      transform: true,
    });

    if (errors.length) {
      ctx.status = 400;
      ctx.body = {
        errors: errors.map(e => ({
          field: e.property,
          constraints: e.constraints,
        })),
      };
      return;
    }

    ctx.request.validated = instance;
    await next();
  };
};

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

validateBody(CreateUserDto)
createValidationMiddleware("query", GetUsersQueryDto)
createValidationMiddleware("params", UserParamsDto)

Интеграция с сервисным слоем

После валидации данные обычно передаются в сервисы без дополнительной проверки:

class UserService {
  async createUser(data) {
    return database.users.insert(data);
  }
}

Контроллер в Koa остаётся максимально тонким:

router.post("/users", bodyParser(), validateBody(CreateUserDto), async (ctx) => {
  const user = await userService.createUser(ctx.request.validatedBody);

  ctx.body = user;
});

Поведение при сложной вложенной структуре

class-validator поддерживает вложенные объекты, но требует дополнительной настройки:

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

class AddressDto {
  @IsString()
  city;

  @IsString()
  street;
}

export class CreateProfileDto {
  @IsString()
  username;

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

Без @ValidateNested вложенные правила не будут применяться.

Особенности интеграции с Koa контекстом

Koa использует единый объект ctx, поэтому важно избегать конфликтов между middleware. Обычно валидированные данные помещаются в отдельные поля:

  • ctx.request.validatedBody
  • ctx.request.validatedQuery
  • ctx.request.validatedParams

Такое разделение предотвращает перезапись исходных данных и сохраняет прозрачность обработки запроса.