Кастомные error handler

По умолчанию Ajv возвращает массив ошибок в свойстве validate.errors. Каждая ошибка содержит техническую информацию:

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

Пример стандартной ошибки:

[
  {
    instancePath: "/age",
    schemaPath: "#/properties/age/minimum",
    keyword: "minimum",
    params: {
      comparison: ">=",
      limit: 18
    },
    message: "must be >= 18"
  }
]

Для внутренних сервисов такого формата часто достаточно. Однако в пользовательских API, формах регистрации, административных панелях и публичных REST-интерфейсах обычно требуются:

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

Именно для этого используются кастомные error handler.


Структура объекта ошибки

Каждая ошибка Ajv содержит набор полей.

Основные свойства

{
  keyword: "required",
  instancePath: "",
  schemaPath: "#/required",
  params: {
    missingProperty: "email"
  },
  message: "must have required property 'email'"
}

Значение полей

Поле Назначение
keyword Тип ошибки
instancePath Путь к данным
schemaPath Путь к правилу схемы
params Дополнительные параметры
message Текст ошибки

Базовый кастомный обработчик ошибок

Простое преобразование ошибок

const Ajv = require("ajv")

const ajv = new Ajv()

const schema = {
  type: "object",
  required: ["email"],
  properties: {
    email: {
      type: "string",
      format: "email"
    }
  }
}

const validate = ajv.compile(schema)

const data = {}

const valid = validate(data)

if (!valid) {
  const errors = validate.errors.map(error => ({
    field: error.instancePath,
    type: error.keyword,
    message: error.message
  }))

  console.log(errors)
}

Результат:

[
  {
    field: "",
    type: "required",
    message: "must have required property 'email'"
  }
]

Формирование пользовательских сообщений

Замена стандартных сообщений

function formatError(error) {
  switch (error.keyword) {
    case "required":
      return "Обязательное поле отсутствует"

    case "type":
      return "Неверный тип данных"

    case "minimum":
      return "Значение слишком маленькое"

    default:
      return "Ошибка валидации"
  }
}

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

const formatted = validate.errors.map(error => ({
  field: error.instancePath,
  message: formatError(error)
}))

Использование params для динамических сообщений

Поле params содержит дополнительную информацию о нарушении.

Пример для minimum

{
  keyword: "minimum",
  params: {
    comparison: ">=",
    limit: 18
  }
}

Формирование сообщения:

function formatError(error) {
  switch (error.keyword) {
    case "minimum":
      return `Минимальное значение: ${error.params.limit}`

    case "maximum":
      return `Максимальное значение: ${error.params.limit}`

    default:
      return error.message
  }
}

Обработка required

Для required путь к отсутствующему полю хранится отдельно.

Особенность required

{
  keyword: "required",
  instancePath: "",
  params: {
    missingProperty: "email"
  }
}

Получение имени поля:

function formatRequired(error) {
  return `Поле ${error.params.missingProperty} обязательно`
}

Универсальный formatter ошибок

Полноценная система форматирования

function formatAjvErrors(errors) {
  return errors.map(error => {
    let message

    switch (error.keyword) {
      case "required":
        message =
          `Поле ${error.params.missingProperty} обязательно`
        break

      case "type":
        message =
          `Ожидается тип ${error.params.type}`
        break

      case "minLength":
        message =
          `Минимальная длина: ${error.params.limit}`
        break

      case "minimum":
        message =
          `Минимальное значение: ${error.params.limit}`
        break

      default:
        message = error.message
    }

    return {
      field: error.instancePath || "/",
      code: error.keyword,
      message
    }
  })
}

Формирование структуры ошибок для API

Стандартный JSON-ответ

app.post("/users", (req, res) => {
  const valid = validate(req.body)

  if (!valid) {
    return res.status(400).json({
      status: "error",
      errors: formatAjvErrors(validate.errors)
    })
  }

  res.json({
    status: "ok"
  })
})

Ответ:

{
  "status": "error",
  "errors": [
    {
      "field": "/age",
      "code": "minimum",
      "message": "Минимальное значение: 18"
    }
  ]
}

Преобразование путей полей

Проблема instancePath

Ajv использует JSON Pointer:

/address/street

Во многих приложениях требуется:

address.street

Конвертация путей

function normalizePath(path) {
  return path
    .replace(/\//g, ".")
    .replace(/^\./, "")
}

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

const field = normalizePath(error.instancePath)

Результат:

address.street

Группировка ошибок по полям

Формат для frontend

Многие UI-библиотеки ожидают:

{
  email: ["Неверный email"],
  password: ["Слишком короткий пароль"]
}

Реализация

function groupErrors(errors) {
  return errors.reduce((acc, error) => {
    const field = normalizePath(error.instancePath)

    if (!acc[field]) {
      acc[field] = []
    }

    acc[field].push(error.message)

    return acc
  }, {})
}

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

Пример схемы

const schema = {
  type: "object",
  properties: {
    profile: {
      type: "object",
      properties: {
        age: {
          type: "integer",
          minimum: 18
        }
      }
    }
  }
}

Ошибка:

{
  instancePath: "/profile/age",
  keyword: "minimum"
}

После нормализации:

profile.age

Работа с массивами

Ошибки в элементах массива

const schema = {
  type: "array",
  items: {
    type: "string",
    minLength: 3
  }
}

Ошибка:

{
  instancePath: "/0",
  keyword: "minLength"
}

Для вложенных структур:

/users/0/email

Преобразование:

users.0.email

Кастомные коды ошибок

Зачем нужны codes

Текст сообщения может меняться:

  • локализация;
  • редизайн интерфейса;
  • изменение wording.

Код ошибки должен оставаться стабильным.

Пример

function buildError(error) {
  const map = {
    required: "ERR_REQUIRED",
    type: "ERR_INVALID_TYPE",
    minimum: "ERR_MINIMUM",
    format: "ERR_INVALID_FORMAT"
  }

  return {
    code: map[error.keyword] || "ERR_VALIDATION",
    message: error.message
  }
}

Локализация ошибок

Ручная локализация

const messages = {
  required: "Поле обязательно",
  type: "Неверный тип",
  minimum: "Слишком маленькое значение"
}

function translate(error) {
  return messages[error.keyword]
}

Пакет ajv-i18n

Для автоматической локализации используется пакет:

npm install ajv-i18n

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

const Ajv = require("ajv")
const localize = require("ajv-i18n")

const ajv = new Ajv()

const validate = ajv.compile(schema)

validate(data)

if (validate.errors) {
  localize.ru(validate.errors)

  console.log(validate.errors)
}

После локализации:

[
  {
    message: "должно быть не меньше 18"
  }
]

Полностью кастомные сообщения через ajv-errors

Установка

npm install ajv-errors

Подключение ajv-errors

const Ajv = require("ajv")
const ajvErrors = require("ajv-errors")

const ajv = new Ajv({
  allErrors: true
})

ajvErrors(ajv)

Ключевое слово errorMessage

Пример

const schema = {
  type: "object",

  required: ["email"],

  properties: {
    email: {
      type: "string",
      format: "email"
    }
  },

  errorMessage: {
    required: {
      email: "Email обязателен"
    },

    properties: {
      email: "Некорректный email"
    }
  }
}

Результат работы ajv-errors

Без ajv-errors:

must have required property 'email'

С ajv-errors:

Email обязателен

Сообщения для конкретных полей

Пример

const schema = {
  type: "object",

  properties: {
    password: {
      type: "string",
      minLength: 8
    }
  },

  errorMessage: {
    properties: {
      password:
        "Пароль должен содержать минимум 8 символов"
    }
  }
}

Сообщения для keyword

Глобальная настройка

const schema = {
  type: "string",
  minLength: 5,

  errorMessage: {
    type: "Должна быть строка",
    minLength: "Минимум 5 символов"
  }
}

Общие сообщения схемы

Единая ошибка

const schema = {
  type: "object",

  properties: {
    age: {
      type: "number",
      minimum: 18
    }
  },

  errorMessage:
    "Данные пользователя заполнены некорректно"
}

allErrors и кастомные handlers

Особенность allErrors

По умолчанию Ajv останавливается после первой ошибки.

const ajv = new Ajv({
  allErrors: true
})

Теперь собираются все ошибки.

Это особенно важно для:

  • frontend-форм;
  • REST API;
  • генерации пользовательских сообщений;
  • систем локализации.

Обработка ошибок формата

Пример format

const schema = {
  type: "string",
  format: "email"
}

Ошибка:

{
  keyword: "format",
  params: {
    format: "email"
  }
}

Кастомизация:

function formatError(error) {
  if (error.keyword === "format") {
    return `Некорректный формат: ${error.params.format}`
  }
}

Обработка enum

Ошибка enum

{
  keyword: "enum",
  params: {
    allowedValues: ["admin", "user"]
  }
}

Сообщение:

function formatEnum(error) {
  return (
    "Допустимые значения: " +
    error.params.allowedValues.join(", ")
  )
}

Обработка additionalProperties

Запрет лишних полей

const schema = {
  type: "object",

  additionalProperties: false
}

Ошибка:

{
  keyword: "additionalProperties",
  params: {
    additionalProperty: "unknownField"
  }
}

Сообщение:

function formatAdditional(error) {
  return (
    `Поле ${error.params.additionalProperty} запрещено`
  )
}

Централизованный middleware для Express

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

function validateBody(schema) {
  const validate = ajv.compile(schema)

  return (req, res, next) => {
    const valid = validate(req.body)

    if (!valid) {
      return res.status(400).json({
        errors: formatAjvErrors(validate.errors)
      })
    }

    next()
  }
}

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

app.post(
  "/users",
  validateBody(userSchema),
  controller
)

Создание собственного класса ValidationError

Объект доменной ошибки

class ValidationError extends Error {
  constructor(errors) {
    super("Validation failed")

    this.name = "ValidationError"
    this.errors = errors
  }
}

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

if (!valid) {
  throw new ValidationError(
    formatAjvErrors(validate.errors)
  )
}

Интеграция с глобальным error handler

Express error middleware

app.use((err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(400).json({
      type: "validation_error",
      errors: err.errors
    })
  }

  res.status(500).json({
    error: "Internal Server Error"
  })
})

Логирование ошибок валидации

Отдельный логгер

function logValidationErrors(errors) {
  for (const error of errors) {
    console.error({
      field: error.instancePath,
      keyword: error.keyword,
      params: error.params
    })
  }
}

Скрытие внутренних деталей схемы

Почему это важно

Стандартные ошибки могут раскрывать:

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

Плохой пример:

must match pattern "^([A-Z]{3})$"

Безопаснее:

Неверный формат значения

Нормализация ошибок для frontend-framework

Формат для React Hook Form

{
  email: {
    type: "required",
    message: "Email обязателен"
  }
}

Преобразование:

function toReactHookForm(errors) {
  return errors.reduce((acc, error) => {
    const field = normalizePath(error.instancePath)

    acc[field] = {
      type: error.keyword,
      message: error.message
    }

    return acc
  }, {})
}

Формирование machine-readable ошибок

Подход enterprise API

{
  "errors": [
    {
      "code": "ERR_REQUIRED_EMAIL",
      "field": "email"
    }
  ]
}

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

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

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

Обработка nullable значений

Проблема null

{
  type: "string"
}

null вызовет ошибку type.

Кастомное сообщение:

function formatType(error) {
  if (error.params.type === "string") {
    return "Поле должно быть строкой"
  }

  return "Неверный тип"
}

Кастомный pipeline обработки ошибок

Полноценная архитектура

function processAjvErrors(errors) {
  return errors
    .map(normalizeError)
    .map(localizeError)
    .map(attachErrorCode)
    .map(hideInternalDetails)
}

Каждый этап отвечает только за одну задачу:

Этап Назначение
normalizeError Нормализация структуры
localizeError Перевод сообщений
attachErrorCode Добавление кодов
hideInternalDetails Сокрытие технических деталей

Такой подход особенно полезен в:

  • enterprise-приложениях;
  • микросервисах;
  • публичных API;
  • больших backend-платформах;
  • системах с многоязычным интерфейсом.