JavaRush /Курсы /Spring REST & MVC /Адрес ошибки: path ...

Адрес ошибки: path и индексы

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

1. Ошибка без адреса: роль path

Если задуматься, валидационная ошибка — это не «наказание» клиента за плохой запрос, а подсказка: что именно нужно исправить. И вот тут возникает простая, но болезненная проблема: без адреса ошибка превращается в загадку уровня “где-то в JSON что-то не так”. В небольшом payload это ещё терпимо, но как только появляются вложенные структуры, списки, фильтры и cross-field правила, без точного указания места вы превращаете клиента в археолога.

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

Path в деталях ошибки отвечает на вопрос «где именно проблема?». Он должен быть достаточно простым, чтобы клиент мог на него опереться, и достаточно точным, чтобы можно было подсветить конкретное поле в UI, написать понятный лог или показать пользователю точную причину. Важно, что path — это не “красивый текст”, а часть контракта: вы договорились, что так адресуются данные во входе, и дальше держите этот стиль везде.

Представьте себе запрос на создание задачи, где клиент прислал десять полей, список тегов и ещё пару вложенных объектов. Если вы вернёте только «title обязателен» — клиент всё ещё может понять. Но если вы вернёте «есть ошибка в tags» — клиенту придётся гадать, какой именно тег сломан, особенно если их много. А если вы вернёте tags[3] — всё становится очевидно: поправь четвёртый элемент списка (и да, индексы — отдельная тема, мы ещё дойдём до неё).

Чтобы не быть голословными, зафиксируем только ту часть детали, которая нужна для разговора про адрес ошибки. Полный канонический detail-объект у нас шире, но сейчас нас интересует именно path.

// Упрощённая схема детали только для разговора про path.
// Полный канонический detail-объект шире, но сейчас нас интересует адрес ошибки.
record ValidationErrorDetail(
    String source, // откуда пришла ошибка: body/query/path
    String path,   // адрес проблемного места во входных данных
    String message // человекочитаемое сообщение для клиента/пользователя
) {}

Здесь path — тот самый “адрес”. В следующих разделах мы договоримся о грамматике адреса: как он выглядит для простого поля, для вложенного объекта, для списка и для group-ошибок.

2. Field path: ошибка одного поля

Самый частый случай валидации — нарушение правила у одного поля: пустой title, слишком длинный assigneeName, отрицательный size в пагинации и так далее. В таких ситуациях path должен указывать на конкретное поле так, чтобы клиенту не пришлось «додумывать», о чём вы говорите. Чем меньше магии, тем лучше: если в запросе есть поле title, то путь ошибки должен быть title. Не task.title, не request.title, не field_1_title, а ровно то имя, которое клиент видит в публичном контракте.

Давайте на примерах из Task Tracker API. Для body-сценария с TaskCreateRequest самый прямолинейный случай выглядит так:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",                // ошибка относится к JSON-телу запроса
    "title",               // конкретное поле во входном JSON
    "Поле title обязательно" // текст, который можно показать пользователю
);

Здесь source="body" просто говорит, что ошибка относится к JSON-телу запроса, а path="title" — к конкретному полю. Это поле можно подсветить в форме, можно зацепиться в тестах, можно собрать статистику по ошибкам — и всё это без парсинга текста сообщения.

Теперь query-параметр. В GET /api/v1/tasks у нас, например, есть size (размер страницы). Если клиент прислал size=200, а мы допускаем максимум 100, то мы можем указать путь просто как size. Не нужно придумывать criteria.size, если клиент не видит никакого criteria — он видит query string.

ValidationErrorDetail error = new ValidationErrorDetail(
    "query",                  // ошибка относится к query string
    "size",                   // имя query-параметра из публичного API
    "size должен быть не больше 100"
);

И третий тип — path-параметр (например, taskId). Если taskId не похож на UUID (или не проходит вашу валидацию), то путь логично делать taskId. Опять же: это имя параметра контракта, а не имя переменной в Java-коде.

ValidationErrorDetail error = new ValidationErrorDetail(
    "path",                   // ошибка относится к path-параметрам URL
    "taskId",                 // имя параметра в контракте: /tasks/{taskId}
    "taskId имеет неверный формат"
);

Обратите внимание на тонкий момент. Мы не делаем путь вида /api/v1/tasks/{taskId}. Это было бы “где случилось”, но не “что исправить”. Путь ошибки валидации должен ссылаться на входные данные, а не на endpoint.

Если хочется увидеть это “глазами JSON”, можно мысленно держать такую картинку:

{
  "title": "",
  "description": "ok",
  "tags": ["java"]
}

Поле title — это и есть path="title". Никакой мистики: просто адрес внутри входа.

3. Вложенные структуры: нотация parent.child

Как только у вас появляется вложенная структура, простого имени поля уже мало. И тут важно договориться об одном стиле. Самый понятный для человека и довольно универсальный — точечная нотация, где вложенность задаётся точкой: assignee.email, metadata.description, contact.phone. Это напоминает обращение к полям объекта в коде и легко читается глазами.

Даже если в текущей версии Task Tracker API большинство DTO плоские, вложенные DTO мы уже обсуждали на уровне валидации, и такие структуры вполне могут появляться в других запросах (или в будущих расширениях проекта). Важно не то, есть ли прямо сейчас вложенность в каждом endpoint, а то, что вы выбираете грамматику, которая не развалится при первом же вложенном объекте.

Небольшой учебный пример (чтобы увидеть идею на коде). Представим, что у запроса на создание задачи есть вложенный блок исполнителя:

// Вложенный объект запроса: у него свои поля и своя валидация
record AssigneeRequest(String name, String email) {}

record TaskCreateRequest(
    String title,
    AssigneeRequest assignee // вложенный объект в JSON: { "assignee": { ... } }
) {}

Если у assignee.email неправильный формат, то path должен показать полный маршрут:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",                        // JSON body
    "assignee.email",              // путь до вложенного поля через точку
    "Email имеет неверный формат"
);

А вот как это выглядит в JSON (для связи с реальностью, а не с абстракцией):

{
  "title": "Fix bug",
  "assignee": {
    "name": "Ivan",
    "email": "not-an-email"
  }
}

Точечная нотация здесь читается почти как инструкция: “зайди в assignee, потом в email”. И это ровно то, что нужно клиенту: он знает, какое поле подсветить в форме и где искать проблему.

Есть соблазн сделать путь в стиле JSON Pointer (/assignee/email) или “как в Java” (assigneeEmail). Но чем больше вариантов вы допускаете, тем быстрее клиент начнёт путаться. Поэтому в рамках курса мы выбираем один формат и держим его стабильно. Для вложенности — точка.

4. Коллекции и индексы: tags[0] и tags[1]

Как только во входных данных появляется список, путь ошибки без индекса становится слишком общим. Ошибка “в tags” — это как сообщение “у вас проблема в массиве” в Java: спасибо, конечно, но где именно? Валидация коллекции часто бывает двух типов: ограничения на сам список (например, “не больше 10 тегов”) и ограничения на элементы (например, “каждый тег длиной 1..30”). И ещё отдельно стоит уникальность тегов, где вообще появляется “конфликт элементов”.

Для элементных ошибок нам нужен индекс. В привычной для Java-мира форме — tags[0], tags[1] и так далее. Мы будем считать индексацию 0-based, потому что это естественная модель для Java-коллекций и для большинства технических форматов. И да, это тот редкий момент, когда «как в программировании» удобно и клиенту: разработчикам на клиентской стороне тоже привычнее считать с нуля.

Сначала самый простой пример: второй тег пустой.

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[1]",              // индекс конкретного элемента списка (0-based)
    "Тег не должен быть пустым"
);

Если вы вернёте просто tags, клиенту придётся перебирать массив и угадывать, где пусто. Если вы вернёте tags[1], он сразу понимает: “сломался элемент с индексом 1”.

Теперь пример из реального правила проекта: теги должны быть уникальны без учёта регистра. Пусть клиент прислал:

{
  "title": "Write tests",
  "tags": ["java", "Java"]
}

Формально проблема “между элементами”, но пользователю всё равно нужно подсветить конкретное место. В учебном API мы можем привязать ошибку ко второму повторяющемуся элементу:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[1]", // подсвечиваем «вторую попытку» повторить уже существующий тег
    "Теги должны быть уникальны (без учета регистра)"
);

Иногда делают иначе: привязывают ошибку к обоим элементам (tags[0] и tags[1]), или делают object path вроде tags, или даже tagsUnique. Но главное — индекс помогает вам не терять точность. Если в списке десять тегов, и два из них повторяются, клиенту очень сложно показать “какой именно повтор”. Индекс — ваш friend.

Полезно также отличать ошибку коллекции целиком от ошибки элемента. Если клиент прислал 25 тегов, а максимум 10, то ошибка относится к “контейнеру”, и путь может быть просто tags:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags", // ошибка про список целиком, а не про конкретный элемент
    "Количество тегов не должно превышать 10"
);

А вот если в списке всё хорошо по размеру, но один элемент слишком длинный — путь должен быть с индексом:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[3]", // ошибка в конкретном элементе
    "Длина тега не должна превышать 30 символов"
);

Чтобы грамматика не расползалась по проекту (и чтобы вы не собирали строку руками в трёх разных местах, в одном забыв ]), можно завести маленький утилитарный помощник. Он не обязателен, но хорошо показывает идею:

final class Path {
    static String field(String name) {
        // Простое поле: "title"
        return name;
    }

    static String nested(String parent, String child) {
        // Вложенное поле: "assignee.email"
        return parent + "." + child;
    }

    static String index(String list, int i) {
        // Элемент списка: "tags[1]"
        return list + "[" + i + "]";
    }
}

И использовать его так:

String p1 = Path.field("title");              // "title"
String p2 = Path.index("tags", 1);            // "tags[1]"
String p3 = Path.nested("assignee", "email"); // "assignee.email"

Да, это выглядит «слишком просто». Но именно такие простые договорённости внезапно спасают проект от хаоса, когда количество endpoint’ов растёт, а ошибок становится много.

5. Object path: ошибка группы полей

Не все правила валидации можно привязать к одному полю. Иногда ошибка относится к двум (или больше) значениям сразу, потому что проблема в их сочетании. Это классический cross-field сценарий: например, в поиске dueAfter должен быть раньше dueBefore. Формально каждое поле по отдельности может быть валидным (оба даты корректные), но вместе они создают нелепый запрос.

В таких случаях мы используем object path — логический путь для “ошибки уровня объекта”. Он не обязан совпадать с реальным полем входного DTO. Это скорее «имя правила» или «имя группы данных». В нашем курсе хороший пример — dateRange. Клиенту не так важно, какое поле “виновато”, ему важно, что диапазон дат некорректен.

Пример детали ошибки:

ValidationErrorDetail error = new ValidationErrorDetail(
    "query",
    "dateRange", // логический путь: ошибка не одного поля, а их комбинации
    "dueAfter должен быть раньше dueBefore"
);

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

Ещё один пример object path — когда вы проверяете не конкретное поле, а согласованность структуры запроса. Допустим, у вас есть правило “должно быть передано либо q, либо tag” (это условный пример для демонстрации). Тогда логический путь может быть searchCriteria или query, но лучше выбрать что-то более предметное — например, searchMode. Главное, чтобы этот путь был стабильным и читаемым.

Почему мы не привязываем такие ошибки к dueAfter или dueBefore? Можно, но тогда вы получите “искусственную виновность” одного поля, хотя проблема в сочетании. Кроме того, вы рискуете получить две ошибки сразу, и пользователь увидит два красных сообщения, хотя ошибка по смыслу одна. Object path позволяет аккуратно держать “одну проблему” как “одну деталь”.

6. path должен говорить языком API, а не языком Java

Одна из самых неприятных ловушек валидационных ошибок — начать возвращать пути, которые понятны только серверу. Например, Java-поле называется assigneeName, а в JSON вы когда-то решили (или вас попросили) назвать его assignee_name. Или query-параметр в контракте называется dueBefore, а в методе контроллера вы назвали аргумент before. Клиент ваш Java-код не видел и видеть не обязан. Он видел контракт.

Поэтому правило простое: path должен использовать публичные имена, то есть те, которые фигурируют в API. Мы уже обсуждали в модуле про DTO/Jackson, что внешнее имя поля может отличаться от внутреннего имени в Java. Вот почему path должен быть “на стороне внешнего мира”.

Мини-демонстрация на коде. Пусть вы делаете DTO (мы не обсуждаем сейчас, надо ли snake_case — это не тема лекции; нам важен принцип):

import com.fasterxml.jackson.annotation.JsonProperty;

record TaskCreateRequest(
    String title,

    // Внешнее (публичное) имя поля в JSON — "assignee_name"
    @JsonProperty("assignee_name")
    String assigneeName // внутреннее имя в Java может отличаться
) {}

Если валидация ругнулась на assigneeName, то с точки зрения клиента ошибка должна прийти по пути assignee_name, потому что именно так поле называется в JSON. Иначе клиент будет смотреть на ответ и думать: “Что за assigneeName? Я такого не отправлял”. А дальше начинается любимая игра всех разработчиков: “сервер говорит одно, клиент видит другое”.

То же касается query и path. Если в контракте параметр называется taskId, то и в ответе при ошибке должно быть taskId, даже если вы в коде назвали переменную id. Наша цель — сделать так, чтобы ответ можно было понять, не открывая исходники сервера.

7. Грамматика path в Task Tracker API

Чтобы не превращать path в зоопарк форматов, полезно один раз зафиксировать правила. И дальше — просто не нарушать их. Это звучит скучно, как “заполните форму 3-Б”, но на практике это убирает целый класс багов: разные контроллеры возвращают разные пути, клиенты ломаются, тесты живут отдельно от реальности, а потом кто-то добавляет костыль “если path начинается с /, то…”.

Ниже — компактная «конституция путей» для нашего учебного проекта. Она не единственно возможная в мире, но она достаточно простая, чтобы держать в голове, и достаточно выразительная, чтобы покрыть body/query/path и вложенность.

Ситуация во входе Как выглядит path Пример
Простое поле (body) имя JSON-поля title
Простое поле (query) имя query-параметра size
Простое поле (path) имя path-параметра taskId
Вложенное поле parent.child assignee.email
Элемент списка list[index] (0-based) tags[1]
Ошибка уровня группы полей логическое имя dateRange

И ещё одна таблица, чтобы закрепить именно “грамматику”, а не примеры:

Что выражаем Символ Почему так
Вложенность . легко читается и привычно по коду
Индекс элемента списка [n] привычно по коллекциям и property-path
«Группа полей» / object-level отдельное имя позволяет не назначать “виноватое поле” искусственно

Из этих правил следует практический вывод: path — это не URL, не JSONPath со спецсимволами, не внутреннее имя валидатора и не имя Java-поля “как получилось”. Это компактный и стабильный адрес во входных данных клиента.

Если держать договорённость, ошибки становятся очень предсказуемыми. И в .http-сценариях, и в Postman, и в тестах клиенту не нужно угадывать, как именно сервер “называет” проблему — он видит ровно то, что отправлял, плюс понятную навигацию до места поломки.

8. Типичные ошибки при проектировании path

Ошибка №1: слишком общий путь вроде request или field.
Такой path выглядит как попытка “что-то написать”, но не как помощь клиенту. В итоге UI не может подсветить конкретное место, а клиентский разработчик начинает печатать отладочный вывод и руками искать, что же не так. Лучше вернуть title, size, tags[1] или assignee.email и закончить эту драму до того, как она станет сериалом на 8 сезонов.

Ошибка №2: отсутствие индекса в списках.
Когда вы возвращаете tags вместо tags[3], вы прячете главную ценность списка деталей: точность. Особенно больно это в случаях уникальности или неправильного формата элемента. Клиент вынужден перебрать весь список, чтобы найти, что “имел в виду сервер”. А если вы уже знаете индекс — просто скажите его.

Ошибка №3: смешение грамматик в одном проекте (tags.1 и tags[1]).
Первые пару раз это “не страшно”. Потом появляются тесты, документация, фронтенд-валидация, и внезапно оказывается, что в одном месте индекс в квадратных скобках, в другом — через точку, в третьем — через подчёркивание. Клиенту приходится поддерживать несколько парсеров путей, а это уже уровень боли “мы случайно написали мини-язык, но забыли предупредить”.

Ошибка №4: использование внутренних Java-имён вместо публичных имён API.
Если вы возвращаете assigneeName, а в JSON оно assignee_name (или наоборот), клиент начинает думать, что сервер ругается “на что-то другое”. Path должен жить в логике публичного контракта, иначе вы неосознанно “подтекаете” внутренней моделью наружу. Это та же проблема, что “сериализовать доменную модель напрямую”, только в мире ошибок.

Ошибка №5: попытка “назначить виноватое поле” там, где ошибка на комбинации полей.
В cross-field правилах (например, диапазон дат) привязывать ошибку к одному полю — почти всегда спорно. Клиентский UI либо покажет две ошибки, либо покажет одну, но пользователь не поймёт, почему виновато именно dueAfter, а не dueBefore. Логический object path вроде dateRange позволяет сказать правду: проблема в сочетании, а не в одном поле.

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