JavaRush /Курси /Spring REST & MVC /Каркас ProblemDetail

Каркас ProblemDetail: пʼять полів

Spring REST & MVC
Рівень 21 , Лекція 0
Відкрита

1. 404 недостатньо: помилка теж є контрактом

Коли ви створюєте API, ви фактично підписуєте договір із клієнтом: «якщо ви надішлете ось такий запит — я відповім ось так». І цей договір має бути однаково зрозумілим і в успішному сценарії, і в ситуації помилки. Якщо сервер обмежується формулою «ну ось вам 404, а далі здогадуйтеся самі», клієнту доводиться вгадувати: це «не знайдено», «звернулися не туди», «неправильний формат» чи «сервер просто втомився».

У реальному житті клієнтський код рідко читає «Not Found» як людина з браузером і сумує над долею. Частіше це мобільний застосунок, фронтенд, інтеграція, тести або моніторинг. Їм потрібен не художній опис страждань, а структурована відповідь: що сталося, де саме і що з цим робити. Тож наше завдання — побудувати мінімальний, але самодостатній каркас помилки, який завжди матиме однакову форму.

І тут важливо зафіксувати просту думку: HTTP-статус — лише вершина айсберга. Він говорить про клас проблеми, але не про конкретику. А помилка без конкретики — це як баг-репорт «не працює» без кроків відтворення. Формально не брешете, але користі майже немає.

2. ProblemDetail у Spring MVC

ProblemDetail — це стандартна ідея: описувати помилку не одним текстом, а невеликим JSON-обʼєктом із фіксованими полями. У Spring MVC цю ідею втілено в класі ProblemDetail. Ми не вигадуємо власний формат помилок із полем message і success=false, а використовуємо зрозумілий каркас, який багато клієнтів уже вміють обробляти — або принаймні їх легко цьому навчити.

Якщо уявити обробку запиту як конвеєр, то при помилці він виглядає приблизно так: контролер або сервіс кидає виняток, @ControllerAdvice його ловить, перетворює на ProblemDetail, а далі Spring MVC серіалізує цей обʼєкт у JSON і виставляє Content-Type: application/problem+json. Жодної магії — лише нормальна, доросла дисципліна обробки негативних сценаріїв.

flowchart TD
    A[HTTP-запит] --> B[Контролер]
    B --> C[Сервіс]
    C --> D{Помилка?}
    D -->|ні| E[DTO відповіді]
    D -->|так| F[Виняток]
    F --> G["@ControllerAdvice / @ExceptionHandler"]
    G --> H[ProblemDetail]
    H --> I[HTTP-відповідь: application/problem+json]

Важливий нюанс для проєкту: Spring Boot уміє працювати з Problem Details «з коробки», але ви маєте бути послідовними. Якщо частину помилок ви повертаєте як ProblemDetail, а частину — як випадковий Map, клієнту буде боляче. Йому доведеться писати розгалуження за форматом відповіді, і це вже не REST API, а квест «вгадай JSON».

Ще одна практична деталь: навіть якщо клієнт і так знає, який URL він викликав, поле instance у відповіді робить помилку самодостатньою. Це особливо помітно, коли відповіді логуються, летять у трасування, тести падають на CI, і ви дивитеся на логи без повного контексту. Помилка, яка несе в собі мінімальні дані про запит, економить час на розслідування.

3. Мінімальний каркас ProblemDetail

Зараз ми зафіксуємо те, що має бути в кожному error response, якщо ми хочемо називати API production-like хоча б за манерами. У ProblemDetail є стандартні поля, і саме вони — наш мінімальний «скелет». Усе додаткове (code, fieldErrors, timestamp, requestId) — це вже надбудови, які ми будемо акуратно додавати пізніше, не руйнуючи базову форму.

Нижче — коротка «шпаргалка» по пʼяти полях. Не намагайтеся вивчити її як таблицю множення; краще сприймайте як відповіді на пʼять запитань клієнта: «що це?», «як це назвати?», «який статус?», «що конкретно сталося?» і «до якого запиту це стосується?».

Поле Запитання, на яке відповідає Що важливо памʼятати Приклад значення
type «Який це клас проблеми?» Це ідентифікатор типу проблеми (URI), а не текст помилки /problems/task-not-found
title «Як це коротко назвати?» Коротка й стабільна назва для людини Задачу не знайдено
status «Який HTTP-статус у помилки?» У тілі дублюємо статус, щоб відповідь була самодостатньою 404
detail «Що конкретно сталося в цьому запиті?» Тут доречна конкретика: id, поточний стан, значення параметра Задачу з id ... не знайдено
instance «До якого запиту це стосується?» URI запиту (або його path), щоб привʼязати помилку до конкретного виклику /api/v1/tasks/...

type: ідентифікатор класу проблеми

Поле type часто сприймають як «ну давайте туди покладемо щось». А потім туди кладуть текст повідомлення, і сенс стандарту ламається. Насправді type — це ідентифікатор типу проблеми, який має бути стабільним. Він не про конкретний випадок, а про категорію: «task not found», «malformed json», «unsupported media type».

Формально type — це URI (у стандарті допускається і відносний URI). В ідеальному світі це посилання на сторінку документації, де описано, що це за помилка і як її обробляти. У навчальному проєкті ми не будемо розгортати портал документації помилок — інакше курс перетвориться на “Spring + static hosting”, — але все одно можемо задати type як стабільний ідентифікатор, наприклад /problems/task-not-found.

Практичний критерій простий: якщо ви зміните текст detail — бо захотіли покращити формулювання, — type має лишитися тим самим. Це «імʼя папки», а detail — «імʼя файлу всередині папки». Якщо ви робите навпаки, ви вносите хаос.

Ще один важливий момент: не підміняйте type вашим application-specific code. type — це частина стандарту Problem Details і зазвичай виражає тип проблеми в термінах URI. code — це внутрішня, машиночитна константа застосунку. Ми до неї дійдемо далі, але зараз важливо не змішати ролі, щоб не вийшло два поля «про одне й те саме», але по-різному.

title: коротка назва проблеми

title — це не місце для роману. Це короткий підпис, який зручно показати людині або написати в лог, щоб одразу зрозуміти, про що йдеться. Він має бути достатньо стабільним: якщо сьогодні ви повертаєте title = "Задачу не знайдено", а завтра title = "Такої задачі немає", клієнту складно будувати будь-які користувацькі повідомлення або шукати по логах. Так, клієнт не має парсити title як код, але стабільність усе одно корисна.

Хороша практика така: title — це «заголовок категорії», а detail — «опис конкретного випадку». Тому title зазвичай не містить ID, поточного статусу чи параметрів запиту. Максимум — акуратне формулювання на кшталт «Task not found», «Invalid input», «Unsupported media type».

Є ще одна людська причина: title — це те, що ви побачите в Swagger UI, у Postman і в тестах. Якщо туди писати все підряд, через тиждень ви самі перестанете розуміти, де яка помилка. Стабільний title робить діагностику простішою. І так, це той рідкісний випадок, коли дисципліна — не нудьга, а економія часу.

status: статус і в HTTP, і в тілі

На перший погляд дублювання статусу виглядає дивно: «Але ж статус уже є в HTTP response!». Так, є. Але в контрактному мисленні це не зайві байти, а самодостатність. Помилки часто живуть довше, ніж одна конкретна HTTP-відповідь: їх логують, пересилають, зберігають у звітах, аналізують у тестах. І там не завжди зручно діставати статус із метаданих — інколи ви бачите лише JSON.

Крім того, наявність status у тілі допомагає швидко перевірити, що ваш @ControllerAdvice не помилився. Це як ремінь безпеки: наче й так обережно їздите, але добре, що він є. Якщо ви випадково повернули HTTP 404, а в тілі status: 500, ви одразу помітите неузгодженість. А неузгодженість статусу — це прямий шлях до недовіри клієнта: він уже не знає, чому вірити.

У Spring ProblemDetail.forStatusAndDetail(...) зазвичай виставляє статус автоматично. Це зручно, але не звільняє від відповідальності. Потрібно стежити, щоб реальний HTTP-статус відповіді збігався з тим, що ви серіалізуєте. Інакше ваш API починає говорити різними голосами одночасно — як чат, у якому один пише «все ок», а інший поруч кричить «ми всі загинемо».

detail: конкретика поточного випадку

detail — це місце, де ви описуєте конкретний інцидент. Якщо title — це «як називається проблема», то detail — «що саме сталося просто зараз». Саме сюди нормально включати taskId, значення неправильного параметра, поточний статус ресурсу, очікуваний формат тощо. Але тут важливо не перегнути палицю й не почати витікати внутрішніми подробицями.

Хороша формула для detail така: текст має бути зрозумілим зовнішньому клієнту, який не знає ваших Java-класів, пакетів і того, як називається ваш repository. Повідомлення "com.example.tasktracker.TaskServiceImpl threw NullPointerException" — це чудовий спосіб розповісти світові, що у вас відбувається всередині, і водночас чудовий спосіб зробити клієнта безпорадним: йому все одно незрозуміло, що робити.

Ще один нюанс: detail не має бути суперстабільним. Він може змінюватися від запиту до запиту, бо в ньому зʼявляються різні ID і різні значення. Це нормально. Стабільність ми забезпечимо пізніше через code, а зараз достатньо розуміти: detail — це корисна конкретика для людини і для швидкої діагностики.

instance: привʼязка до запиту

instance — це URI, який ідентифікує конкретний випадок проблеми. Найпростіше в REST API трактувати його як шлях запиту, наприклад /api/v1/tasks/{taskId}. Завдяки цьому клієнт і ви самі в логах бачите, до якої кінцевої точки (endpoint) належить помилка. І це допомагає навіть тоді, коли ви помилково надіслали запит не туди або коли один і той самий code/title може виникати в різних місцях.

У нашому навчальному проєкті instance часто закриває потребу в окремому полі path. Так, можна додати path як extension field, але це зайве дублювання, якщо instance уже показує URI запиту. Чим менше дублювальних полів, тим простіше підтримувати контракт: менше місць, де можна випадково розʼїхатися.

Практична порада: instance має бути максимально близьким до реального URI запиту. Якщо ви вручну збираєте рядок і помилилися, наприклад забули /api/v1 або переплутали taskId, ви отримаєте «помилку про помилку» — у відповіді буде неправильне посилання на запит. Тому в реальному коді часто беруть URI із запиту, наприклад через HttpServletRequest. Ми поки покажемо спрощений, навчальний варіант — вручну, але з правильною ідеєю.

4. Приклади та обмеження в Task Tracker API

Мінімальна реалізація: TaskNotFoundExceptionProblemDetail

Зараз зберемо мінімальний приклад на нашому проєкті Task Tracker API, не лізучи в додаткові поля і не ускладнюючи модель. У нас уже є глобальний шар обробки помилок на базі @ControllerAdvice. Ми додамо або уточнимо обробник TaskNotFoundException, який повертає ProblemDetail із заповненими type, title, status, detail і instance.

Нижче — саме мінімальний базовий набір: лише стандартні поля ProblemDetail. Зверху на нього можна навісити code, fieldErrors і діагностичні поля, але сам каркас type/title/status/detail/instance звідси вже не змінюється.

Почнемо з доменного винятку. Він живе в com.example.tasktracker.domain.exception і має вміти повідомити taskId, щоб @ControllerAdvice міг зібрати зрозумілий для людини detail. Зверніть увагу: ми зберігаємо taskId окремим полем, а не намагаємося парсити його з тексту повідомлення — парсити рядки завжди боляче.

public class TaskNotFoundException extends RuntimeException {

    // Ідентифікатор задачі, яку не знайшли, — потрібен для формування ProblemDetail.detail
    private final String taskId;

    public TaskNotFoundException(String taskId) {
        // Зберігаємо id окремим полем, щоб не парсити його з тексту повідомлення
        this.taskId = taskId;
    }

    public String getTaskId() {
        // Повертаємо id, щоб обробник помилок міг зібрати зрозумілий опис
        return taskId;
    }
}

Тепер обробник у нашому GlobalExceptionHandler — він у нас на web-рівні, наприклад у пакеті com.example.tasktracker.api.error. Тут ми створюємо ProblemDetail і заповнюємо поля. status і detail виставляються через forStatusAndDetail, а решту — вручну, щоб каркас був повністю визначений.

@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
    // Базу (status + detail) зручно виставляти через фабрику ProblemDetail
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "Задачу з id " + ex.getTaskId() + " не знайдено"
    );

    // type — стабільний ідентифікатор типу проблеми
    pd.setType(URI.create("/problems/task-not-found"));

    // title — коротка, стабільна назва для людини
    pd.setTitle("Задачу не знайдено");

    // instance — привʼязка до конкретного запиту або ресурсу
    pd.setInstance(URI.create("/api/v1/tasks/" + ex.getTaskId()));

    return pd;
}

Якщо тепер клієнт викличе GET /api/v1/tasks/{taskId} з неіснуючим id, він отримає відповідь приблизно такої форми — спрощений приклад JSON. І це вже виглядає як договір: зрозуміло, що сталося, до якого запиту належить помилка, який статус і який тип проблеми.

{
  "type": "/problems/task-not-found",
  "title": "Задачу не знайдено",
  "status": 404,
  "detail": "Задачу з id 7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12 не знайдено",
  "instance": "/api/v1/tasks/7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12"
}

І ще один маленький, але важливий штрих: клієнт має бачити, що це саме problem details. Тобто Content-Type відповіді буде application/problem+json. Spring MVC уміє це робити, якщо ви йдете правильним шляхом ProblemDetail і увімкнули підтримку problem details у конфігурації проєкту.

Один каркас для 404, 400 і 409

Зараз хочеться, звісно, одразу зробити «ідеальну модель помилок на всі випадки життя». Але сьогоднішня мета скромніша й важливіша: переконатися, що базовий каркас однаково підходить для різних типів проблем. І 404 Not Found, і 400 Bad Request, і 409 Conflict — це різні смисли, але форма відповіді має залишатися впізнаваною. Клієнт має впізнавати помилку за формою так само легко, як ви впізнаєте JSON-відповідь списку за полем items.

Тобто змінюються значення і додаткові extension fields, а базовий набір type/title/status/detail/instance лишається тим самим. Наприклад, той самий каркас можна використовувати і для конфлікту. Ми поки не розбираємо, коли саме потрібен 409, але показуємо, що структура не змінюється: змінюються лише status, type, title, detail і instance.

// Приклад: конфлікт бізнес-стану, коли операція недопустима в поточному статусі задачі
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT,
        "Поточний стан задачі не дозволяє цю операцію"
);

// Класифікуємо проблему стабільним type, щоб її можна було групувати
pd.setType(URI.create("/problems/state-conflict"));
pd.setTitle("Конфлікт стану");

// Привʼязуємо помилку до конкретного ресурсу або запиту
pd.setInstance(URI.create("/api/v1/tasks/42"));

І те саме для 400, коли запит загалом не тієї форми, наприклад тіло запиту не проходить базову перевірку. Знову ж таки, без заглиблення у fieldErrors — лише каркас.

// Приклад: запит невалідний за формою (наприклад, не проходить базову валідацію)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Тіло запиту є невалідним"
);

// type і title тримаємо стабільними, щоб клієнту було простіше розуміти категорію помилки
pd.setType(URI.create("/problems/invalid-input"));
pd.setTitle("Невалідне введення");

// instance допомагає зрозуміти, до якої кінцевої точки належить помилка (особливо в логах і тестах)
pd.setInstance(URI.create("/api/v1/tasks"));

Головне, що потрібно відчути: ми не робимо окремий «формат 404», окремий «формат 409» і третій «формат 400». Ми робимо одну мову помилок, де змінюється зміст, але не граматика.

Що не має потрапити в ProblemDetail

Навіть найкрасивіший ProblemDetail можна зіпсувати однією звичкою: «а давайте просто віддамо назовні ex.getMessage()». Іноді це безпечно, якщо виняток ваш і повідомлення заздалегідь підготовлене. Але часто це призводить до витоку внутрішніх деталей. І це не про параною, а про контракт: клієнт не має залежати від ваших внутрішніх текстів і точно не має бачити технічні назви класів та стек викликів.

Якщо ви віддаєте stack trace, ви одночасно робите дві речі. Перша — видаєте потенційно чутливу інформацію про систему. Друга — робите клієнта залежним від випадкового тексту: завтра ви трохи змінили повідомлення у винятку, і раптом фронтенд-тести почали падати, бо хтось — звісно ж, не ви, а «хтось» — порівнював рядки. Це як побудувати інтеграцію на кольорі кнопки в чужому застосунку: поки кнопка синя — ви щасливі, щойно вона стала зеленою — усе розвалилося.

Тому в мінімальному каркасі ми навмисно тримаємо зовнішній текст під контролем. title і type — стабільні. detail — конкретний, але зовнішній. instance — привʼязаний до запиту. А внутрішні подробиці залишаються в логах сервера, де їм і місце.

5. Типові помилки під час складання ProblemDetail

У цьому місці зазвичай хочеться сказати: «Ну тут усе просто». І так, механічно — просто. Але саме прості речі найчастіше й ламають контракт: не тому, що це складно, а тому, що «та потім виправимо». У помилках API «потім» швидко перетворюється на «ніколи», бо клієнти вже звикли до кривого формату, а змінювати його стає боляче.

Помилка №1: плутати title і detail.
Дуже поширена картина: у title кладуть «Task with id ... not found», а detail роблять загальним «Not found». У підсумку заголовок щоразу різний, його не можна використовувати як стабільний підпис, а detail втрачає сенс конкретики. Лікується просто: title короткий і загальний, detail конкретний і про цей випадок.

Помилка №2: не заповнювати instance, бо «клієнт і так знає URL».
Так, клієнт знає. Але коли помилка потрапляє в логи, у баг-репорт, у звіт тестів або в алерт моніторингу, URI може бути втрачений або неочевидний. instance робить помилку самодостатньою. Це недорога звичка, яка економить багато часу.

Помилка №3: залишати type за замовчуванням і не думати про класифікацію помилок.
Якщо type завжди однаковий або не задається, ви втрачаєте можливість по-людськи групувати помилки. Навіть якщо ви не робите сторінку документації по type, сам факт наявності стабільних type уже допомагає: і в логах, і в тестах, і в майбутній документації.

Помилка №4: намагатися запхати в detail сире повідомлення винятку, особливо фреймворкового.
Повідомлення фреймворка можуть бути довгими, технічними і нестабільними між версіями. Сьогодні одне, завтра інше — і ви самі потім дивуватиметеся, чому у вас «зламався контракт помилок» після оновлення залежностей. detail має бути вашим, зовнішнім і контрольованим.

Помилка №5: робити різні форми помилок для різних контролерів.
Один контролер віддає ProblemDetail, інший — { "error": "..." }, третій — взагалі рядок. Клієнту лишається тільки плакати й писати три парсери. Єдиний нормальний вихід — централізувати обробку і тримати каркас однаковим усюди. Тоді API справді стає передбачуваним.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ