JavaRush /Курсы /Spring REST & MVC /Единый ответ для ошибок bo...

Единый ответ для ошибок body/ query/ path

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

1. Три входа — один формат ошибок

Если смотреть глазами разработчика API, body, query и path — это разные места в HTTP-запросе, разные аннотации в контроллере и даже разные моменты, когда Spring MVC превращает вход в Java-значения. Но если смотреть глазами клиента (например, фронтенда, мобильного приложения или другого сервиса), всё это — просто “я отправил запрос, а мне сказали: вход не подходит”. И клиенту всё равно, где именно вы поймали проблему — в JSON-теле или в параметре size.

Верхний уровень ответа уже зафиксирован, path умеет точно адресовать проблему, а code отделён от человеческого message. Теперь нужен следующий шаг: показать, что один и тот же контракт выдерживает все обычные входы API — JSON body, query-параметры и path variables.

Самая частая боль появляется не на первом запросе, а на третьем-четвёртом. У клиента уже есть общий обработчик ошибок: он умеет показать красный текст под полем, подсветить группу полей, вывести toast “проверьте ввод”. И тут внезапно на POST /tasks приходит JSON формата A, на GET /tasks — формата B, а на GET /tasks/{taskId} — формата C. В этот момент клиентский разработчик начинает подозревать, что вы не REST API делаете, а коллекционируете форматы, как покемонов.

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

Единая форма ошибок валидации

Когда мы говорим “одна форма”, это не лозунг “давайте всё запихнём в одну строку” (мы как раз от этого убегали). Это означает, что у клиента есть один стабильный JSON-shape, который он может разобрать одинаково для POST, GET и любых других endpoint’ов. В этом shape всегда есть верхний уровень с общим кодом ошибки (например, INVALID_INPUT) и всегда есть список деталей, даже если ошибка одна.

При этом “одна форма” вовсе не требует, чтобы все детали были одинаковыми по содержанию. Они и не будут одинаковыми, потому что у body, query и path разные “адреса” проблем. В body мы обычно указываем путь до поля DTO (например, title или tags[1]). В query мы указываем имя query-параметра или поля criteria DTO (например, size или dueAfter). В path мы указываем имя path variable (например, taskId). Это нормально: shape один, значения разные.

Чтобы клиент мог отличить “ошибка в теле” от “ошибка в query”, нам нужен явный маркер источника. И вот здесь появляется простой, но очень полезный элемент: поле source внутри каждой детали. Оно не меняет форму ответа — оно просто делает детали самодокументируемыми.

2. Поле source

Поле source отвечает на вопрос “откуда пришла эта конкретная ошибка”. И это не философский вопрос — это вопрос UX и поддержки. Представьте, что у вас есть path = "id". Для body это может быть id внутри JSON. Для path это может быть {id} в URL. Для query это может быть ?id=.... Клиенту лучше не гадать.

В нашем учебном API достаточно трёх значений: body, query, path. Мы сознательно не добавляем сюда header, cookie и другие источники, потому что у нас фокус на основных каналах и на валидации, а не на превращении error payload в энциклопедию HTTP.

Важно, что source живёт на уровне детали, а не на верхнем уровне ответа. Почему так? Потому что один запрос может породить ошибки из разных мест. Например, у вас может быть невалидный size в query и одновременно невалидный taskId в path (если вы делаете странный запрос). Да, в реальной жизни такие комбинации редки, но формат лучше сделать “способным”, чем “случайно работающим”.

Чтобы source был стабилен, мы держим его как короткую строку в ответе. В Java-коде вы можете захотеть использовать enum, но наружу всё равно отдавайте ровно эти строковые значения. Они и читаемы человеком, и легко матчятся в клиентском коде.

Небольшая опорная таблица, чтобы закрепить семантику:

source Где был вход Как обычно выглядит path Пример
body JSON request body (@RequestBody) Путь внутри JSON/DTO title, tags[1], metadata.description
query Query string (@RequestParam, @ModelAttribute) Имя query-параметра или поля criteria size, page, dueAfter, dateRange
path Path variables (@PathVariable) Имя переменной ресурса taskId, commentId

3. path: смысл и география

Если source отвечает “откуда”, то path отвечает “где именно”. И здесь легко попасть в ловушку: начать смешивать “путь до endpoint’а” и “путь до ошибки внутри входа”. Например, кто-то пишет в detail что-то вроде path="/api/v1/tasks/123". Это выглядит красиво… пока вы не понимаете, что для клиента это вообще не адрес ошибки. Это адрес запроса. Клиент и так знает, куда он стучался.

В нашем формате path всегда указывает на место во входных данных, а не на URL endpoint’а.

Для body путь чаще всего совпадает с публичным именем JSON-поля. В идеальном мире это имя совпадает и с именем Java-поля в DTO, потому что так проще и для команды, и для документации. Если вы делали переименования через Jackson-аннотации, то path всё равно должен соответствовать внешнему имени, иначе клиент увидит dueDate, а в запросе отправлял due_date, и будет считать, что сервер издевается.

Для query путь обычно равен имени query-параметра. Если у нас criteria DTO TaskSearchCriteria, то его поля стоит называть так же, как query-параметры (page, size, dueAfter, dueBefore). Тогда path получается естественным, и не нужно придумывать слой “перевода”.

Для path путь — это имя переменной. И здесь важный практический совет: закрепляйте имя параметра явно в аннотации, особенно если вы любите короткие имена переменных в Java. В противном случае у вас в коде будет @PathVariable String id, а в контракте вы хотели taskId. Клиент потом получит ошибку по id и будет гадать “id чего?”. А это уже не валидация, а квест.

Ещё одна важная деталь: object-level ошибки (например, неправильный диапазон дат) должны жить в том же списке errors, но path у них будет логическим, вроде dateRange. И тут как раз особенно важно, чтобы такие ошибки были различимы по code, а path показывал “какой блок входа сломан”, а не пытался выбрать одно конкретное поле.

4. Примеры одного JSON-shape

Форму ответа мы уже согласовали: наверху status, code, summary, errors, а каждая деталь несёт source, path, code, message. Ниже нет нового DTO и новой ветки правил — мы просто подставим в знакомый shape ошибки из body, query и path.

Верхний уровень остаётся тем же: 400, INVALID_INPUT, короткий summary. Меняются только source, path, detail code и message внутри errors.

Пример A: ошибка в body (создание задачи)

Представим POST /api/v1/tasks, где title пустой, а tags содержит повтор. Мы хотим тот же самый ответ, что и для query/path, только source="body".

import java.util.List;

// Пример: заранее собранный ответ, который вы вернёте клиенту при ошибке валидации body
ValidationErrorResponse response = new ValidationErrorResponse(
    400,
    ApiErrorCodes.INVALID_INPUT,
    "Проверка входных данных не пройдена",
    List.of(
        // Ошибка конкретного поля в JSON-теле
        new ValidationErrorDetail("body", "title", "REQUIRED", "Поле title обязательно"),
        // Ошибка элемента массива/списка в JSON-теле (индекс — часть path)
        new ValidationErrorDetail("body", "tags[1]", "DUPLICATE", "Теги должны быть уникальны")
    )
);

И тот же ответ в JSON (shape не меняется):

{
  "status": 400,
  "code": "INVALID_INPUT",
  "summary": "Проверка входных данных не пройдена",
  "errors": [
    { "source": "body", "path": "title", "code": "REQUIRED", "message": "Поле title обязательно" },
    { "source": "body", "path": "tags[1]", "code": "DUPLICATE", "message": "Теги должны быть уникальны" }
  ]
}

Пример B: ошибки в query (поиск/листинг задач)

Теперь GET /api/v1/tasks?page=0&size=200&dueAfter=2026-03-20&dueBefore=2026-03-10. Тут две ошибки: size слишком большой и диапазон дат некорректный. Shape тот же, меняются только source и path.

import java.util.List;

// Пример: ошибки пришли из query string (не из JSON-тела)
ValidationErrorResponse response = new ValidationErrorResponse(
    400,
    ApiErrorCodes.INVALID_INPUT,
    "Проверка входных данных не пройдена",
    List.of(
        // Проблема конкретного query-параметра
        new ValidationErrorDetail("query", "size", "TOO_LARGE", "size должен быть не больше 100"),
        // Object-level ошибка (логический "блок" входа), но всё равно живёт в errors[]
        new ValidationErrorDetail("query", "dateRange", "INVALID_RANGE", "dueAfter должен быть раньше dueBefore")
    )
);

JSON снова такой же по форме:

{
  "status": 400,
  "code": "INVALID_INPUT",
  "summary": "Проверка входных данных не пройдена",
  "errors": [
    { "source": "query", "path": "size", "code": "TOO_LARGE", "message": "size должен быть не больше 100" },
    { "source": "query", "path": "dateRange", "code": "INVALID_RANGE", "message": "dueAfter должен быть раньше dueBefore" }
  ]
}

Пример C: ошибка в path (невалидный taskId)

Для GET /api/v1/tasks/{taskId} мы хотим уметь сказать клиенту: “да, вы обратились к задаче, но taskId не похож на UUID”. Здесь важно не путать: “нет такой задачи” — это не валидация. Это другой класс проблем. А вот “формат id неверный” — это как раз validation/invalid input.

import java.util.List;

// Пример: ошибка пришла из path variable (часть URL)
ValidationErrorResponse response = new ValidationErrorResponse(
    400,
    ApiErrorCodes.INVALID_INPUT,
    "Проверка входных данных не пройдена",
    List.of(
        // В path указываем имя переменной, как она выглядит в контракте API
        new ValidationErrorDetail("path", "taskId", "INVALID_FORMAT", "taskId имеет неверный формат UUID")
    )
);

И снова тот же JSON-shape:

{
  "status": 400,
  "code": "INVALID_INPUT",
  "summary": "Проверка входных данных не пройдена",
  "errors": [
    { "source": "path", "path": "taskId", "code": "INVALID_FORMAT", "message": "taskId имеет неверный формат UUID" }
  ]
}

Заметьте важный эффект: клиент может написать один обработчик, который делает примерно так: берёт errors[], группирует по source, рисует ошибки рядом с полями формы (для body), рядом с фильтрами (для query) и рядом с “полем ввода id” (если у клиента есть UI, где вводят id руками). Клиенту не нужно знать, какая аннотация стояла в контроллере.

### Как аккуратно оформить в Task Tracker API

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

Самый простой и “учебно-честный” способ — сделать маленький фабричный метод, который создаёт ValidationErrorResponse из списка деталей. Без магии, без фреймворк-колдовства, просто чтобы вы один раз зафиксировали общую верхнюю часть.

import java.util.List;

// Утилитарный класс: централизует создание одинаковой "шапки" ошибки
public final class ValidationErrors {
    private ValidationErrors() {
        // Запрещаем создание экземпляров: это набор фабричных методов
    }

    public static ValidationErrorResponse invalidInput(List<ValidationErrorDetail> errors) {
        // В одном месте фиксируем status/code/summary, чтобы формат не "расползался"
        return new ValidationErrorResponse(
            400,
            ApiErrorCodes.INVALID_INPUT,
            "Проверка входных данных не пройдена",
            errors
        );
    }
}

Дальше в любых местах, где вы формируете validation response (неважно, для body/query/path), вы вызываете ValidationErrors.invalidInput(...), а детали собираете уже под конкретный сценарий. Это помогает держать контракт единым и не устраивать “а давайте тут summary чуть по-другому напишем, чтобы было красивее”.

По структуре проекта такие DTO логично жить в пакете com.example.tasktracker.api.dto.error. Это подчёркивает, что формат ошибок — часть публичного API, то есть относится к api-зоне, а не к доменной модели.

5. Типичные ошибки при едином формате ошибок

Ошибка №1: делать разные JSON-форматы для body и query.
Когда для тела возвращается структурированный fieldErrors, а для query — просто строка, формат перестаёт быть единым контрактом. Клиенту приходится писать отдельную логику под каждый случай. Валидационный ответ должен быть одинаковым независимо от источника данных.

Ошибка №2: не указывать source у ошибки.
Одного path часто недостаточно: поля вроде id или status могут приходить из разных мест. Без source (body, query, path) клиенту сложнее понять, где именно ошибка возникла. Это особенно критично в сложных формах с несколькими источниками данных.

Ошибка №3: путать путь ошибки с URL запроса.
path должен указывать на конкретное поле входных данных (title, limit, taskId), а не на endpoint (/api/v1/tasks/123). URL — это место вызова, а не место ошибки. Смешивание этих понятий делает ответ менее полезным для клиента.

Ошибка №4: делать отдельные структуры для object-level ошибок.
Создание globalErrors или отдельного массива под “общие ошибки” разрушает идею единого формата. Гораздо проще и надёжнее держать все ошибки в одном errors[], используя логические пути (dateRange) и коды, чтобы различать тип проблемы.

Ошибка №5: позволять формату “расползаться” от случая к случаю.
Самая опасная вещь — маленькие отклонения “здесь чуть иначе”. Со временем они накапливаются, и формат ответа перестаёт быть предсказуемым. Единый shape должен соблюдаться строго, иначе клиент начинает зависеть не от контракта, а от случайных реализаций.

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