JavaRush /Курсы /Spring REST & MVC /Минимальный ответ об ошибке валидации

Минимальный ответ об ошибке валидации

Spring REST & MVC
18 уровень , 0 лекция
Открыта

1. Validation failed как загадка

Если вы когда-нибудь писали клиент к чужому API — даже самому «дружелюбному» — вы знаете это ощущение: вы отправляете запрос, получаете 400, а внутри ответа — что-то вроде "Validation failed". И вот вы сидите, смотрите на эту строку и думаете: «Окей… а что именно не так?». Это похоже на ситуацию, когда врач говорит: «Ну, вы болеете», и уходит в закат. Формально — правда. Практически — бесполезно.

Валидация в REST API — не каприз и не «добавим потом, когда будет время». Это механизм, который помогает клиенту быстрее исправить запрос, а серверу — не тащить мусор дальше по слоям приложения. Но вот парадокс: если валидировать мы научились, а отвечать нормально на ошибки — нет, то клиент всё равно остаётся в темноте. Он будет либо показывать пользователю странное «что-то пошло не так», либо начнёт парсить текст ошибки регулярками, а это уже не программирование, а гадание.

Мы уже умеем ловить ошибки входного контракта и отделять их от бизнес-правил. Теперь нужна вторая половина работы: сделать ответ таким, чтобы клиент сразу понял, что исправлять и где искать проблему.

Самый слабый, но удивительно популярный вариант выглядит примерно так:

// Плохо: клиенту непонятно, какое правило нарушено и где искать проблему
String response = "Validation failed";

Проблема не в том, что строка «плохая». Проблема в том, что она не отвечает на три базовых вопроса, которые нужны любому клиенту — будь то фронтенд с формой, мобильное приложение или другой бэкенд, который интегрируется с вашим сервисом. Клиент хочет понимать, какое правило нарушено, где именно в данных проблема, и как отличить эту ошибку от других программно, а не «по настроению текста».

Есть ещё один важный практический момент: почти всегда ошибок бывает несколько. Пользователь или клиентский код может одновременно забыть title, превысить длину description и передать слишком длинное имя исполнителя. Если API отвечает одной строкой, то клиент вынужден исправлять ошибки по очереди, как будто играет в «угадайку»: отправил — получил одну проблему — исправил — отправил — получил следующую. Валидация превращается в пинг-понг, где мячик — это ваши HTTP-запросы.

Именно поэтому в production-like API ошибка валидации — это не «сообщение для человека», а структурированный ответ, который одинаково хорошо читается и человеком, и программой.

2. Минимальный контракт ошибки валидации

Когда мы проектировали DTO, мы говорили: внешний JSON — это контракт, его нельзя делать «как получится». С ошибками ровно та же история. Если формат ошибки случайный, то любой клиент обречён либо на хаос, либо на костыли. Поэтому нам нужен минимальный, но устойчивый формат, который можно применять везде, независимо от того, где именно возникла validation-проблема.

Давайте введём понятие validation error payload — это тело HTTP-ответа, которое возвращается при ошибке валидации. Важно: это не «как Spring по умолчанию решил ответить», а то, как мы хотим, чтобы отвечал наш API как продукт. И у такого payload есть две логические части: верхний уровень, который описывает ошибку в целом, и уровень деталей, который перечисляет конкретные нарушения.

Минимальная практичная идея выглядит так: на верхнем уровне мы держим status, общий code и краткий summary, а в поле errors — список конкретных деталей. Даже если ошибка всего одна, errors остаётся списком: контракт должен быть стабильным, а не «сюрпризом дня».

В виде таблицы — чтобы глаз отдохнул от текста и мозг успел согласиться:

Поле Где живёт Зачем нужно
status верхний уровень Чтобы тело ответа и HTTP-статус говорили одно и то же, и чтобы клиенту было проще логировать и отлаживать.
code верхний уровень Чтобы программно отличать класс ошибки (INVALID_INPUT) от других классов.
summary верхний уровень Короткое человеческое объяснение «что в целом произошло», без деталей по полям.
errors верхний уровень Список конкретных нарушений, чтобы клиент мог показать их в форме и обработать автоматически.
source detail Откуда пришла ошибка, например из body, query или path.
path detail Куда именно «тыкать пальцем» в данных клиента, например title.
code detail Какое именно правило нарушено — коротко и стабильно.
message detail Текст для человека, который может меняться, локализоваться и переписываться.

И вот ключевая мысль лекции: минимальная полезная структура начинается с того, что у нас есть envelope — верхний уровень — и details — список ошибок. Пока нет этих двух уровней, ошибка остаётся не контрактом, а художественным произведением.

Простейший скелет без полезных деталей уже может выглядеть так:

import java.util.List;

record ValidationErrorDetail(
    String source,  // Откуда пришла ошибка: body/query/path
    String path,    // Путь до проблемного поля, например title
    String code,    // Стабильный код нарушенного правила, например REQUIRED
    String message  // Сообщение для человека, может меняться и локализоваться
) {}

record ValidationErrorResponse(
    int status,                       // HTTP-статус, продублированный в payload
    String code,                      // Код класса ошибки, например INVALID_INPUT
    String summary,                   // Короткое описание «в целом»
    List<ValidationErrorDetail> errors // Список конкретных нарушений, всегда массив
) {}

Да, такой объект ещё можно заполнить пустым errors, но в реальном API это почти всегда будет «почти полезно». Сегодня нам важно именно зафиксировать форму: она должна быть одинаковой и в случае одной ошибки, и в случае пяти, и в случае, когда клиент прислал корректный JSON, но нарушил ограничения.

3. Верхний уровень: status, code, summary

Верхний уровень ответа — это как заголовок и оглавление книги. Он не заменяет содержание, но даёт быстрый ответ: «что это вообще такое?». В REST API это особенно важно, потому что клиент может обрабатывать ошибки не только как UI-события, но и как бизнес-ветки логики: например, показать форму с подсветкой полей или просто залогировать проблему и завершиться.

Поле status на верхнем уровне кажется избыточным: ведь у нас уже есть HTTP-статус. Но на практике это полезная страховка. Во-первых, тело ответа может быть сохранено в логах или в тестовых артефактах, где сам HTTP-контекст потеряется. Во-вторых, иногда клиентский код работает с ошибками через общий обработчик, который читает JSON и хочет видеть статус прямо внутри payload. И, наконец, это дисциплина: если вы однажды вернули 400 при реальном HTTP 422 или наоборот, вы сами себе устроили весёлую охоту за багами. Поэтому правило простое: если status есть в теле, он обязан совпадать с HTTP-статусом.

Поле code верхнего уровня — это ваш ярлык класса ошибки. Для валидации в рамках проекта Task Tracker API разумно иметь один общий код, например INVALID_INPUT. Это не код конкретной ошибки поля, а именно код категории: «входные данные не проходят правила входного контракта». Он помогает клиенту не разбирать детали, если ему нужно решить вопрос на верхнем уровне: «это точно ошибка входа, можно показать пользователю подсказки».

Поле summary — это короткая человекочитаемая формулировка. Обычно она одна и та же во всех validation-ошибках: что-то вроде «Проверка входных данных не пройдена». Здесь важно не скатиться в энциклопедию. summary не должен перечислять поля, не должен содержать технические детали и не должен превращаться в длинную строку. Для перечисления конкретики у нас есть список errors.

Чтобы почувствовать разницу, сравним два варианта заполнения верхнего уровня. Первый — «вроде структурно, но всё ещё пусто»:

import java.util.List;

// Верхний уровень есть, но деталей нет: клиент не поймёт, что именно исправлять
ValidationErrorResponse response = new ValidationErrorResponse(
    400, // Должно совпадать с HTTP-статусом ответа
    "INVALID_INPUT", // Класс ошибки (валидация входа)
    "Проверка входных данных не пройдена", // Короткое резюме без перечисления полей
    List.of() // Плохо: пустой список ошибок делает ответ почти бесполезным
);

Такой ответ формально честный: он сообщает класс проблемы. Но клиенту он почти не помогает исправить запрос. Он примерно на уровне «вы ошиблись где-то… удачи». Поэтому верхний уровень — это только половина смысла. Вторая половина — это детали.

4. Детали: список errors и его правила

Как только вы начинаете валидировать реальные DTO, вы быстро обнаружите: ошибки не ходят по одной. Особенно в create/update сценариях, где пользователь вводит несколько полей. И если API возвращает только первую попавшуюся проблему, клиенту приходится проходить «квест исправления» в несколько шагов. В лучшем случае это раздражает пользователя. В худшем — ломает интеграцию, потому что клиентская система ожидает получить список всех нарушений сразу и сформировать единый отчёт.

Поэтому errors — это всегда список. Даже если в списке один элемент. Даже если сегодня в вашем DTO всего одно поле. Даже если «пока не нужно». Контракт — это про стабильность, а стабильность начинается с того, что клиент знает: errors — это массив, который можно безопасно обходить циклом, не проверяя каждый раз, а это массив или объект.

Минимальная деталь ошибки, которую мы кладём в список, содержит четыре вещи. Она говорит, откуда пришла ошибка (source), где она произошла (path), какое правило нарушено (code) и как это объяснить человеку (message). Мы сейчас не углубляемся в то, как именно строится path или как выбирать коды — это отдельные темы дня. Но уже на этом уровне важно, что деталь — это не просто строка. Это маленький объект, пригодный для автоматической обработки.

Вот пример минимально рабочего ответа с двумя ошибками, которые относятся к телу запроса:

import java.util.List;

// Верхний уровень остаётся тем же, меняется только список errors
ValidationErrorResponse response = new ValidationErrorResponse(
    400,
    "INVALID_INPUT",
    "Проверка входных данных не пройдена",
    List.of(
        // Ошибка №1: не передали обязательное поле
        new ValidationErrorDetail("body", "title", "REQUIRED", "Поле title обязательно"),
        // Ошибка №2: нарушили ограничение длины
        new ValidationErrorDetail("body", "description", "TOO_LONG", "Описание слишком длинное")
    )
);

Заметьте: верхний уровень не меняется. Он не раздувается от количества ошибок. Он всегда одинаковый: status, code, summary, errors. Меняется только содержимое списка деталей.

Если переложить это в JSON так, как это увидит клиент, получится очень понятная структура:

{
  // HTTP-статус, продублированный в payload, должен совпадать со статусом ответа
  "status": 400,
  // Код категории ошибки, чтобы отличать валидацию от других проблем
  "code": "INVALID_INPUT",
  // Короткое резюме без перечисления всех полей
  "summary": "Проверка входных данных не пройдена",
  "errors": [
    {
      // Источник данных, где искать проблему: body/query/path
      "source": "body",
      // Куда тыкать пальцем в данных клиента
      "path": "title",
      // Код конкретного нарушенного правила, стабильный
      "code": "REQUIRED",
      // Сообщение для человека, может меняться и локализоваться
      "message": "Поле title обязательно"
    },
    {
      "source": "body",
      "path": "description",
      "code": "TOO_LONG",
      "message": "Описание слишком длинное"
    }
  ]
}

Здесь всё держится на рельсах: клиент видит класс ошибки и список конкретных проблем. Даже новичок, который просто руками смотрит ответ в Postman или в .http файле, сразу понимает, что исправлять.

И ещё один важный момент, который часто упускают: форма ответа не должна зависеть от количества ошибок. Если при одной ошибке вы возвращаете error как объект, а при двух — errors как массив, вы почти гарантированно получите баги в клиентском коде. Клиенту придётся писать ветвление, а потом оно забудется в одном месте, и что-нибудь упадёт в самый неподходящий момент. Мы этого не хотим, поэтому «всегда список» — это не прихоть, а анти-баговая профилактика.

5. Task Tracker API: DTO для validation-ответа

Эта форма в проекте ляжет в обычные DTO ответа в пакете com.example.tasktracker.api.dto.error. Одному ответу нужен верхний уровень ValidationErrorResponse, одной конкретной проблеме — ValidationErrorDetail. Важно не то, в каком контроллере это будет собираться, а то, что create, update и search будут отдавать один и тот же shape.

По сути мы уже договорились о самом главном: верхний уровень описывает класс проблемы, а список errors — конкретные нарушения по полям, индексам и группам параметров. Дальше эту договорённость просто фиксируют кодом проекта, а сам формат перестаёт зависеть от отдельного endpoint’а.

6. Типичные ошибки при работе с validation payload

Ошибка №1: возвращать одну строку или один message без деталей.
Такой ответ может выглядеть «человечно», но он бесполезен для программной обработки и плохо работает даже для человека, который пытается исправить сразу несколько полей. Клиент не понимает, что именно сломано и где, а значит либо показывает общий алерт, либо начинает угадывать. Даже минимальный список деталей резко повышает практическую ценность ответа.

Ошибка №2: менять JSON-shape в зависимости от количества ошибок.
Сегодня вернули одну ошибку объектом, завтра две ошибки массивом — и вот клиент внезапно получает ответ другого типа. Это превращает обработку ошибок в цирк с проверками типов и ветвлениями. Гораздо стабильнее держать одну и ту же форму всегда: errors — это массив, который просто может быть длиной 1, 2 или 10.

Ошибка №3: писать status в теле, который не совпадает с HTTP-статусом.
Иногда это случается «по невнимательности»: поменяли HTTP-статус в одном месте, а поле status забыли обновить. Иногда — из-за копипаста. В итоге клиент видит противоречие и не понимает, чему верить. Если вы кладёте status в payload, воспринимайте его как часть контракта и синхронизируйте с реальным статусом ответа.

Ошибка №4: превращать summary в свалку деталей.
Очень хочется написать в summary что-то вроде «title обязателен, description слишком длинное, tags…». Но тогда вы дублируете список ошибок в непредсказуемой строке и заставляете клиента либо игнорировать summary, либо читать его как единственный источник истины. summary должен быть кратким описанием класса проблемы, а конкретика должна жить в errors.

Ошибка №5: утекать внутренними техническими подробностями в message или в верхнеуровневый code.
Например, вписать в текст что-то вроде MethodArgumentNotValidException или имя валидатора. Это удобно разработчику «прямо сейчас», но опасно для контракта. Технические детали нестабильны, их не должен видеть внешний клиент, и они легко превращаются в зависимость клиента от внутренней реализации. Для диагностики есть логи и дебаг, а внешний контракт должен оставаться чистым и понятным.

1
Задача
Spring REST & MVC, 18 уровень, 0 лекция
Недоступна
Фиксированный payload ошибки валидации
Фиксированный payload ошибки валидации
1
Задача
Spring REST & MVC, 18 уровень, 0 лекция
Недоступна
Минимальная структура ответа для ручной проверки body
Минимальная структура ответа для ручной проверки body
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ