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-структура, яку він може розібрати однаково для POST, GET і будь-яких інших кінцевих точок. У цій структурі завжди є верхній рівень із загальним кодом помилки (наприклад, 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-запиту (@RequestBody) | Шлях усередині JSON або DTO | title, tags[1], metadata.description |
| query | Рядок запиту (@RequestParam, @ModelAttribute) | Імʼя query-параметра або поля criteria | size, page, dueAfter, dateRange |
| path | Path variables (@PathVariable) | Імʼя змінної ресурсу | taskId, commentId |
3. path: зміст і розташування
Якщо source відповідає на запитання «звідки», то path відповідає на запитання «де саме». І тут легко потрапити в пастку: почати змішувати «шлях до кінцевої точки» і «шлях до помилки всередині входу». Наприклад, хтось пише в detail щось на кшталт path="/api/v1/tasks/123". Це виглядає красиво… поки ви не розумієте, що для клієнта це взагалі не адреса помилки. Це адреса запиту. Клієнт і так знає, куди він звертався.
У нашому форматі path завжди вказує на місце у вхідних даних, а не на URL кінцевої точки.
Для 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 чого?». А це вже не валідація, а квест.
Ще одна важлива деталь: помилки рівня об’єкта, наприклад неправильний діапазон дат, мають жити в тому самому списку errors, але path у них буде логічним, на кшталт dateRange. І саме тут особливо важливо, щоб такі помилки були розрізнювані за code, а path показував, який блок входу зламано, а не намагався вибрати одне конкретне поле.
4. Приклади однієї JSON-структури
Форму відповіді ми вже узгодили: вгорі status, code, summary, errors, а кожна деталь несе source, path, code, message. Нижче немає нового DTO і нової гілки правил — ми просто підставимо в знайому структуру помилки з 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 (структура не змінюється):
{
"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 занадто великий, а діапазон дат некоректний. Структура та сама — змінюються лише 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"),
// Помилка рівня об'єкта (логічний "блок" входу), але все одно живе в 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 неправильний» — це якраз валідація/невалідні вхідні дані.
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-формат:
{
"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
);
}
}
Далі в будь-яких місцях, де ви формуєте відповідь валідації (неважливо, для 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), а не на кінцеву точку (/api/v1/tasks/123). URL — це місце виклику, а не місце помилки. Змішування цих понять робить відповідь менш корисною для клієнта.
Помилка №4: робити окремі структури для помилок рівня об’єкта.
Створення globalErrors або окремого масиву під «загальні помилки» руйнує ідею єдиного формату. Набагато простіше й надійніше тримати всі помилки в одному errors[], використовуючи логічні шляхи (dateRange) і коди, щоб розрізняти тип проблеми.
Помилка №5: дозволяти формату «розповзатися» від випадку до випадку.
Найнебезпечніша річ — маленькі відхилення «тут трохи інакше». З часом вони накопичуються, і формат відповіді перестає бути передбачуваним. Єдина структура має дотримуватися строго, інакше клієнт починає залежати не від контракту, а від випадкових реалізацій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ