JavaRush /Курси /Java Server /HTTP‑сценарій без суперечностей

HTTP‑сценарій без суперечностей

Java Server
Рівень 8 , Лекція 4
Відкрита

1. HTTP‑сценарій як діалог

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

Уявіть, що HTTP‑сценарій — це мінісцена:

клієнт каже: «Я виконую дію (method) з ось цим ресурсом (path) і, якщо потрібно, передаю дані (body), ось як їх розуміти (headers)»;

сервер відповідає: «Я зрозумів і обробив (status), ось що вийшло (body), і ось як це читати (headers)».

Дуже корисно тримати в голові просту схему, щоб не скочуватися до логіки «тіло запиту вирішує все».

sequenceDiagram
    participant C as Клієнт
    participant S as Сервер
    C->>S: "Запит: method + path + headers + body"
    S-->>C: "Відповідь: status + headers + body"

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


import java.util.Map;

/**
 * Один "сценарій" = запит + відповідь.
 * Ідея в тому, що їх потрібно проєктувати парою, щоб частини не суперечили одна одній.
 */
public record HttpScenario(
        String method,                     // HTTP-метод запиту: GET/POST/DELETE...
        String path,                       // Шлях ресурсу: /api/v1/...
        Map<String, String> requestHeaders,// Заголовки запиту: Accept, Content-Type...
        String requestBody,                // Тіло запиту (часто null для GET)
        int responseStatus,                // Статус відповіді: 200/201/204/400/404/...
        Map<String, String> responseHeaders,// Заголовки відповіді: Content-Type, Location...
        String responseBody                // Тіло відповіді (null для 204)
) {}

Сама ідея тут проста: не можна «спроєктувати тільки request» або «спроєктувати тільки response». Їх проєктують парою, інакше у вас вийде контракт типу «ніби працює, але ніхто не впевнений, що саме».

2. З чого починати: дія → метод → шлях

Коли ви дивитеся на HTTP-виклик, дуже легко одразу піти в JSON, тому що JSON найбільш помітний. Але сценарій збирається раніше: спочатку зрозуміло, що клієнт хоче зробити, потім обирається метод, а вже потім — шлях. Інакше дуже швидко опиняєтеся у світі, де все робиться через POST або все ховається в query.

Для ReadLater цього базового кроку вже достатньо:

- отримати один елемент → GET /api/v1/reading-list/42

- отримати колекцію → GET /api/v1/reading-list

- створити новий елемент → POST /api/v1/reading-list

Уже на цьому рівні видно, куди приблизно піде відповідь: читання зазвичай веде до 200, створення — до 201, а видалення без корисного тіла — до 204. Статус не виникає з повітря — він випливає з дії, методу й шляху.

3. Де зберігати дані запиту

Після методу й шляху лишається розкласти самі дані по правильних місцях. Тут рятує просте правило:

- path — хто саме є ресурсом;

- query — як читати колекцію: фільтри, пошук, уточнення;

- headers — службова домовленість про формат і обробку;

- body — основна payload-частина операції.

Щойно один і той самий зміст розкиданий по кількох місцях — наприклад, id одночасно лежить у path, query і body, — запит стає двозначним. Клієнту і серверу доводиться гадати, що вважати головним.

4. Headers без магії: Accept і Content-Type

На цьому каркасі headers лише уточнюють домовленість. Accept — який формат відповіді клієнт хоче отримати. Content-Type — у якому форматі лежить поточне body. Тому для GET без тіла часто достатньо Accept: application/json, а для POST з JSON уже потрібен Content-Type: application/json і зазвичай той самий Accept.

А далі headers підключаються за потреби: Location допомагає після створення ресурсу, Allow — коли шлях існує, але обрано не той метод. Якщо поміняти Accept і Content-Type місцями або пообіцяти JSON, а надіслати не JSON, сценарій починає сперечатися сам із собою ще до вибору статусу.

5. Відповідь сервера: status, headers, body

Коли запит уже зрозумілий, залишається чесно назвати результат. Тут не потрібно вдруге розбирати весь набір кодів окремо: важливо, щоб status, headers і body не суперечили одне одному.

Мінімум, який варто тримати в голові, такий:

- 200 — є корисні дані;

- 201 — створено новий ресурс, часто разом із Location;

- 204 — операція успішна, але тіла немає;

- 400, 404, 409, 500 — різні причини невдачі, і клієнт має розрізняти їх за змістом, а не навмання.

Тепер зберемо все це в цілі request/response-пари.

6. Цілі сценарії: ReadLater

Зараз найкорисніша вправа для мозку — побачити цілі «сценарії» повністю: як конкретна операція виглядає одночасно в методі, шляху, заголовках, статусі та наявності або відсутності тіла. Тут нарешті важлива не кожна частина окремо, а їхня узгодженість. Ми не пишемо сервер і не обговорюємо, як це зберігається всередині — ми тренуємо вміння збирати й читати контракт як єдине ціле. На такій навичці потім тримаються і Postman-практика, і Java-клієнт, і серверна частина.

Нижче будуть приклади для майбутнього ReadLater API (список читання). У прикладах body буде максимально простим (іноді взагалі «placeholder»), тому що сьогодні нас цікавить не вміст JSON, а узгодженість частин сценарію.

Читання одного елемента

Цей сценарій здається найпростішим, але саме він навчає важливої різниці: неправильний формат ідентифікатора — це не «ресурс не знайдено». Коли клієнт просить /.../abc, проблема не в тому, що ресурс відсутній, а в тому, що запит не має сенсу (ідентифікатор має бути числом). Коли клієнт просить /.../999, формат нормальний, але ресурсу немає — ось там 404. І лише коли id коректний і ресурс існує, ми чесно повертаємо 200 з тілом.

Успішний варіант (ресурс знайдено):


ЗАПИТ:
GET /api/v1/reading-list/42
Accept: application/json

ВІДПОВІДЬ:
200 OK
Content-Type: application/json

{ ...дані елемента... }

Якщо вам хочеться уявити це «як об’єкт» (щоб бачити одразу і request, і response), можна мислити так:


import java.util.Map;

HttpScenario scenario = new HttpScenario(
        "GET",
        "/api/v1/reading-list/42",
        Map.of("Accept", "application/json"),   // Клієнт очікує JSON у відповіді
        null,                                   // Для GET зазвичай немає тіла запиту
        200,
        Map.of("Content-Type", "application/json"), // Сервер підтверджує формат відповіді
        """
        { "id": 42, "title": "Clean Code" }
        """                                     // Тіло відповіді: JSON
);

Некоректний id (це 400, а не 404):


ЗАПИТ:
GET /api/v1/reading-list/abc

ВІДПОВІДЬ:
400 Bad Request
Content-Type: text/plain

id має бути числом

І це дуже логічно: сервер не зобов’язаний шукати «abc» у базі, тому що це навіть не коректний ключ.

Коректний id, але ресурсу немає (це 404):


ЗАПИТ:
GET /api/v1/reading-list/999

ВІДПОВІДЬ:
404 Not Found
Content-Type: text/plain

елемент не знайдено

Зверніть увагу, як у цих трьох варіантах змінюється тільки те, що має змінюватися: шлях загалом той самий, метод той самий, а різниця у статусі й повідомленні відображає реальну причину.

Читання списку з фільтрами

Із читанням колекції зазвичай пов’язана інша типова помилка новачка: «якщо список порожній, значить 404». Але 404 — це «я не знайшов ресурс». А колекція /api/v1/reading-list як ресурс існує навіть тоді, коли елементів у ній поки що нуль. Тому порожній список — це нормальний успішний 200, просто в body буде порожній результат, наприклад порожній масив.

Читання без фільтрів:


ЗАПИТ:
GET /api/v1/reading-list
Accept: application/json

ВІДПОВІДЬ:
200 OK
Content-Type: application/json

{ "items": [ ... ], "count": 2 }

Читання з фільтрами (query params):


ЗАПИТ:
GET /api/v1/reading-list?status=PLANNED&title=clean
Accept: application/json

ВІДПОВІДЬ:
200 OK
Content-Type: application/json

{ "items": [ ... ], "count": 1 }

І ось тут видно хороше застосування query: ми не «ідентифікуємо» один ресурс, ми читаємо колекцію і просимо уточнити видачу. Якби ми намагалися розмістити status у path, вийшло б щось на кшталт /reading-list/PLANNED, і стало б незрозуміло: це новий тип ресурсу? це id? це магічна назва колекції? Query значно чесніший за змістом.

Створення елемента

Створення — це той випадок, де ви дуже швидко бачите, хто проєктував контракт «для галочки», а хто думав про клієнта. Якщо сервер створив ресурс, клієнту корисно знати дві речі: що створення справді відбулося (а не «просто якийсь успіх») і де цей ресурс тепер можна отримати. Тому тут особливо природними є 201 Created і заголовок Location.

Створення (клієнт надсилає дані):


ЗАПИТ:
POST /api/v1/reading-list
Content-Type: application/json
Accept: application/json

{ ...дані нового елемента... }

Успішна відповідь:


ВІДПОВІДЬ:
201 Created
Location: /api/v1/reading-list/43
Content-Type: application/json

{ ...створений елемент, уже з id=43... }

Можна уявити це у вигляді маленької «заготовки» (без деталей JSON), щоб побачити узгодженість:


import java.util.Map;

HttpScenario scenario = new HttpScenario(
        "POST",
        "/api/v1/reading-list",
        Map.of(
                "Content-Type", "application/json", // Формат надісланого body
                "Accept", "application/json"        // Формат, який хочемо отримати у відповідь
        ),
        "{ ... }", // Плейсхолдер: тут має бути JSON, адже Content-Type = application/json
        201,
        Map.of(
                "Location", "/api/v1/reading-list/43",   // Адреса створеного ресурсу
                "Content-Type", "application/json"       // Формат тіла відповіді (якщо воно є)
        ),
        "{ ... }" // Плейсхолдер: створений ресурс (часто з новим id)
);

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

А ще саме створення часто веде до 409 Conflict. Не тому, що «запит поганий», а тому, що дані конфліктують із поточним станом. Наприклад, у нашій предметній області externalId може мати бути унікальним: одну й ту саму зовнішню книгу не можна додати до списку двічі з тим самим externalId.


ЗАПИТ:
POST /api/v1/reading-list
Content-Type: application/json

{ "externalId": "OL12345M", ... }

ВІДПОВІДЬ:
409 Conflict
Content-Type: text/plain

externalId вже існує

Непідтримуваний метод на існуючому шляху

Є ще одна корисна негативна гілка, яка не про дані й не про відсутність ресурсу. Шлях /api/v1/reading-list існує, але в цій колекції тут є читання і створення. Якщо клієнт надішле туди DELETE, це не 404: маршрут знайдено, проблема саме в непідтримуваному методі.


ЗАПИТ:
DELETE /api/v1/reading-list

ВІДПОВІДЬ:
405 Method Not Allowed
Allow: GET, POST
Content-Type: text/plain

метод не підтримується

Заголовок Allow одразу показує клієнту, які методи тут узагалі мають сенс, щоб не гадати й не перебирати варіанти навмання.

Видалення елемента

Видалення — прекрасний тест на те, наскільки ми розуміємо зміст 204. Якщо операція завершилася успішно, але нам не потрібно повертати жодних даних, 204 No Content звучить ідеально. Це як сказати: «Операцію виконано. Не чекайте жодних даних». І тут важливо не зіпсувати цей зміст: якщо ви вибрали 204, не треба надсилати body «про всяк випадок». Це як наклеїти на коробку позначку «ПУСТО», а всередину покласти записку «але насправді ні».

Успішне видалення:


ЗАПИТ:
DELETE /api/v1/reading-list/42

ВІДПОВІДЬ:
204 No Content

У Java-образах це можна записати майже мінімально:


import java.util.Map;

HttpScenario scenario = new HttpScenario(
        "DELETE",
        "/api/v1/reading-list/42",
        Map.of(), // Заголовки не обов’язкові: ні body, ні формат відповіді нам не важливі
        null,     // У DELETE зазвичай немає тіла запиту (у типовому сценарії)
        204,
        Map.of(), // 204 = немає тіла, отже Content-Type теж зазвичай не потрібен
        null      // Важливо: раз 204, то body справді має бути null
);

А от негативні варіанти знову поводяться так само, як у GET:

- якщо id не число, це 400;

- якщо id число, але ресурсу немає, це 404.

І це добре: коли правила повторюються, API стає передбачуваним.

Помилки сервера: 500

500 — статус, який часто використовують неправильно: або ставлять на все підряд («ой, щось не вийшло»), або, навпаки, бояться ставити взагалі («нехай буде 400, а то страшно»). Правильний зміст 500 дуже практичний: сервер сам не зміг коректно обробити запит через внутрішній збій. Клієнт зазвичай не виправить це, просто трохи змінивши запит.

Наприклад, клієнт зробив коректний GET /api/v1/reading-list/42, але на сервері стався NullPointerException (таке теж буває навіть у розумних людей до кави). Тоді 500 чесніше, ніж намагатися видати це за 404 або 400.


ЗАПИТ:
GET /api/v1/reading-list/42

ВІДПОВІДЬ:
500 Internal Server Error
Content-Type: text/plain

внутрішня помилка сервера

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

7. Самоперевірка сценарію

Коли сценаріїв стає багато, дуже легко зробити «майже правильну» відповідь, яка насправді суперечить самій собі. Це схоже на ситуацію, коли ви пишете людині «я не можу говорити» й одразу телефонуєте їй. Формально обидві дії можливі, але разом вони виглядають дивно. В HTTP такі суперечності зазвичай проявляються в дрібницях: статус говорить одне, headers — інше, а body взагалі третє.

Гарна новина: більшість суперечностей ловляться простою розумовою схемою «збери сценарій зверху вниз». Можна навіть тримати це як маленький внутрішній flowchart.

flowchart TD
    A["Що робить клієнт? (дія)"] --> B["Метод (GET/POST/PUT/PATCH/DELETE)"]
    B --> C["Шлях: колекція чи ресурс?"]
    C --> D["Дані: path/query/headers/body"]
    D --> E["Чи потрібні Accept / Content-Type?"]
    E --> F["Що сталося на сервері? (результат)"]
    F --> G["Статус (200/201/204/400/404/409/500)"]
    G --> H["Заголовки відповіді (Content-Type/Location/Allow)"]
    H --> I["Чи є body і чи відповідає воно статусу?"]

І ще один простий «детектор суперечностей» — дивитися на найчастіші пари, які зобов’язані дружити:

Якщо у вас… Перевірте, що… Інакше клієнт подумає…
204 No Content body справді немає «Чому ви кажете “немає контенту”, але контент є?»
Content-Type: application/json body справді JSON «Я намагався читати JSON, а там не JSON»
Accept: application/json сервер справді повертає JSON «Я просив JSON, а мені дали щось інше»
201 Created справді створено новий ресурс, є корисний Location «Гаразд, де він тепер живе?»
405 Method Not Allowed шлях існує, але метод не підтримується; є корисний Allow «Мені не треба шукати інший шлях, мені треба вибрати підтримуваний метод»
404 Not Found запит коректний, але ресурсу немає «Я все зробив правильно, просто об’єкта не існує»
400 Bad Request проблема у форматі або значеннях запиту «Мені треба виправити запит, а не чекати»
409 Conflict проблема у конфлікті стану даних «Запит нормальний, але дані не дозволяють»
500 Internal Server Error це справді внутрішній збій «Я нічого не можу з цим зробити зі свого боку»

Ця таблиця не робить вас «REST-гуру», але робить вас людиною, яка не ліпить контракт навмання. А це вже величезний крок.

8. Типові помилки HTTP‑сценарію

Помилка №1: проєктувати тільки body, а про метод/статус/headers згадувати наприкінці.
Це виглядає так: «у нас буде JSON ось такий… а, точно, який там метод? давайте POST… і статус… нехай 200». У підсумку зміст операції розмивається. Краще спочатку визначити дію і метод, потім шлях і розміщення даних, і лише потім думати про тіло.

Помилка №2: повертати 200 OK завжди, бо «успіх же».
Так, 200 — універсальний, але саме універсальність робить його слабким сигналом. Створення ресурсу (POST) набагато зрозуміліше клієнту, якщо завершується 201 Created, а видалення без даних — якщо це 204 No Content. Точність статусу — це не занудство, а спосіб не змушувати клієнта вгадувати.

Помилка №3: надсилати body разом із 204 No Content.
Це суперечність у чистому вигляді: ви повідомляєте «контенту немає», а потім усе одно надсилаєте контент. Клієнтська бібліотека може ігнорувати тіло, може спробувати прочитати його, може поводитися непередбачувано — і всі варіанти будуть уже вашою проблемою, бо ви самі створили конфлікт.

Помилка №4: плутати Accept і Content-Type.
Дуже часта плутанина: люди пишуть Accept: application/json і думають, що цим описали відправлюваний JSON у запиті. Але Accept — це «що я хочу отримати», а формат надісланого body описує Content-Type. Якщо ви переплутаєте їх, сервер може не зрозуміти ваше тіло або клієнт — не зрозуміти відповідь.

Помилка №5: класти ідентифікатор ресурсу в query «за звичкою» і дублювати його в кількох місцях.
Запит виду /reading-list?id=42 ще можна зустріти, але щойно поруч з’являється /reading-list/42?id=42 і, до того ж, id=42 у body — контракт стає нечитабельним. Ідентичність ресурсу зазвичай живе в path, а query краще залишити для фільтрів і уточнень читання.

1
Опитування
HTTP Статуси, рівень 8, лекція 4
Недоступний
HTTP Статуси
Коди й заголовки HTTP
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ