1. Роль API‑контракту, навіть коли «і так видно»
Якщо ви колись намагалися домовитися: «Давайте просто зателефонуємо й на ходу розберемося», то вам уже знайома концепція «контракт є, але він у голові, і в кожного свій». У світі HTTP це майже гарантовано закінчується тим, що клієнт очікує одне, сервер вважає інше, а між ними стоять логи, які сумно шепочуть: «400». API‑контракт — це спосіб перестати вгадувати й почати домовлятися.
Коли ми говоримо «API‑контракт», мова не про юридичний документ і не про печатки з грифом «узгоджено» (хоча іноді здається, що без печатки RFC сервери теж не слухаються). У прикладному backend‑сенсі контракт — це набір правил, які однаково розуміють і клієнт, і сервер: який метод використовувати, який шлях викликати, які параметри та заголовки обов’язкові, які статуси означають успіх або помилку і в якій формі приходить відповідь.
Чому без контракту погано навіть у маленьких навчальних проєктах? Тому що ви дуже швидко потрапляєте в ситуацію «вчора працювало». Ви змінили шлях, а клієнт і далі викликає старий. Або сервер почав віддавати 200 OK із текстом «error», а клієнт радісно вважає це успіхом. Або ви чекаєте JSON, а отримуєте HTML‑сторінку з повідомленням «Service unavailable» (і так, це реальне життя, а не сюжет фантастики). Контракт — це те, що не дає системі перетворитися на спонтанний театр імпровізації.
Важливо: контракт — це не «те, що сервер сьогодні надіслав». Одна вдала відповідь — це просто удача. Контракт — це те, що має залишатися передбачуваним від запиту до запиту і від дня до дня.
2. Склад API‑контракту
У попередніх темах ми розібрали request/response по деталях, і тепер ці деталі складаються в дуже практичну думку: контракт — це не один шматок (не лише body і не лише статус), а зв’язка. Якщо ви викинете один елемент, домовленість стає дірявою. Це як сказати «приходь до мене» і не уточнити адресу: фраза ніби є, а працювати не буде.
Нижче — зручна карта контракту. Це не чек‑ліст для іспиту, а просто спосіб тримати в голові, що саме клієнт і сервер мають розуміти однаково.
| Частина контракту | Де міститься | Приклад (по-людськи) | Чому це важливо |
|---|---|---|---|
| HTTP‑метод | рядок запиту | GET або |
Метод задає очікувану дію: читаємо ми дані чи змінюємо стан |
| Шлях | URL | /api/v1/reading-list | Шлях визначає ресурс; змінили шлях — фактично змінили адресу |
| Query params | URL | ?status=PLANNED | Фільтри та режими читання; їхні імена й значення теж частина договору |
| Заголовки запиту | заголовки | Accept: application/json | Клієнт повідомляє, що очікує; сервер може змінювати поведінку |
| Статуси відповіді | статус відповіді | 200, , |
Це смислова розвилка для клієнта: успіх, помилка чи конфлікт |
| Заголовки відповіді | заголовки відповіді | Content-Type: application/json | Без них клієнт може не зрозуміти формат тіла |
| Тіло відповіді (форма) | тіло відповіді | «об’єкт зі списком + count» | Форма даних має бути стабільною, інакше клієнт ламається |
| Помилки (форма) | status + body | «завжди JSON з кодом і повідомленням» | Помилки — теж контракт, а не випадковий текст |
Зверніть увагу на тонкий момент. Коли ми говоримо про форму тіла відповіді, нам не потрібно зараз занурюватися в детальний дизайн JSON (це буде окремий рівень). Але вже сьогодні можна розуміти різницю між «відповідь має бути JSON» і «відповідь — щось незрозуміле: іноді JSON, іноді рядок, іноді взагалі мовчання». Навіть без знання всіх тонкощів JSON ви вже можете мислити контрактно: у відповіді є очікуваний формат, і його треба дотримуватися.
3. Форма і значення: порушення контракту
Коли новачок уперше чує слово «контракт», у голові часто з’являється страх: «То що, взагалі нічого не можна змінювати?». Можна. Ба більше, дані у відповідях постійно змінюються. У цьому й сенс API: воно віддає актуальну інформацію. Контракт — це не заборона на життя, а домовленість про структуру і зміст.
Давайте розділимо два типи змін.
Перший тип — зміни значень. Наприклад, учора книга «Clean Code» мала рейтинг 4.7, а сьогодні — 4.8. Або список читання був порожній, а потім став із трьох елементів. Це нормальне життя даних. Контракт від цього не ламається, бо форма та сама: «є поле rating як число», «є масив items».
Другий тип — зміни форми. Наприклад, замість items сервер почав віддавати data. Або замість масиву почав віддавати рядок. Або змінив GET /books/search на POST /searchBooks (так, іноді так «покращують» API). Оце вже зміна контракту, і клієнт має повне право зламатися — він не зобов’язаний вгадувати нову реальність.
Ще один популярний вид порушення контракту — коли статус говорить одне, а тіло говорить інше. Наприклад, сервер повертає 200 OK, але в тілі пише «error: invalid token». Для людини це ще можна зрозуміти очима, але для клієнта це виглядає як успіх, бо статус — головний сигнал. Ми ще повернемося до цього, але зафіксуймо: статус і форма тіла мають бути узгоджені.
Щоб відчути це на практиці (хоча ми ще не пишемо HTTP‑клієнт), можна подивитися на контракт як на очікуваний шаблон. Припустімо, ми очікуємо, що успішна відповідь в принципі виглядає так:
— статус 200 OK;
— Content-Type повідомляє JSON;
— тіло містить очікувані ключі.
І навіть на plain Java можна записати таку ідею у вигляді маленької перевірки форми, не прив’язуючись до конкретних значень:
boolean looksLikeListResponse(String body) {
// Дуже груба перевірка за формою, не за значеннями.
// Важливо: це НЕ парсинг JSON — це лише швидка «саніті-перевірка», що відповідь схожа на домовленість.
return body.contains("\"items\"") && body.contains("\"count\"");
}
Так, це не справжній JSON parsing (його ми робитимемо пізніше через Jackson), але думка корисна: ми перевіряємо не те, скільки там елементів, а те, що відповідь хоча б схожа на те, що ми вважали домовленістю.
4. Помилки — частина контракту: 404 за правилами
Тут достатньо втримати одну опору з розмови про мережу: контракт має сенс лише там, де response взагалі отримано. Якщо відповіді немає, ми залишаємося в гілці мережевого збою і до status не доходимо. А ось щойно response є — навіть помилковий — він уже має підкорятися домовленості.
У здоровому API помилки — це не «щось пішло не так, тримай шматок тексту», а частина домовленості. Коли клієнт отримує 404, це не «сервер помер». Це нормальна контрактна гілка: сервер повідомив, що ресурс не знайдено. Клієнт може обробити це як очікувану ситуацію, а не як катастрофу.
А ось якщо сервер замість 404 віддає 200 з тілом «Not Found», контракт стає нечитаємим: за статусом це успіх, за змістом — ні. Аналогічно, якщо клієнт не надіслав обов’язковий параметр, а сервер намагається вгадати й повернути щось дивне, це теж ламає договір. Контракт корисний саме тим, що він визначає, де закінчуються здогадки і починаються чіткі правила.
Важливо також домовитися, хоча б на рівні принципу, що в помилок має бути передбачувана форма. Зараз ми не проєктуємо фінальний error JSON нашого майбутнього локального API (це буде окремий блок у курсі), але можна вже розуміти ідею: якщо ви повертаєте помилки, то краще повертати їх в одному форматі. Інакше клієнту доведеться писати десять різних обробників: один для рядка, другий для JSON, третій для HTML, четвертий для «ой, порожнє тіло». Такий клієнт виходить нервовим і з підозрою дивиться на інтернет.
Гарна ментальна картинка виглядає так:
flowchart TD
A[Клієнт надіслав запит] --> B{"Відповідь надійшла?"}
B -- ні --> C["Мережевий збій: немає status і body"]
B -- так --> D{"Код статусу"}
D -- 2xx --> E["Успіх: перевіряємо, чи відповідь відповідає формі контракту"]
D -- 4xx/5xx --> F["Помилка: це теж контрактна гілка, і вона теж має бути передбачуваною"]
Ця схема не для того, щоб ви її зубрили. Вона для того, щоб ви не починали розбір проблемного виклику зі слів «ну там у body щось написано». Якщо відповіді немає — body ви не прочитаєте. Якщо статус помилковий — це може бути нормальна гілка. Якщо статус успішний, але форма зламана — це вже порушення контракту.
5. Як фіксують контракт
Навіть якщо ви ідеально зрозуміли, що контракт — це домовленість, лишається смішне й сумне питання: «А де зберігається ця домовленість?». Якщо відповідь «у мене в голові» — вітаю, це найнестабільніше сховище у світі. Воно падає під час кожної відпустки і часом після обіду.
У реальних командах контракт фіксують артефактами. Іноді це документація, іноді табличка, іноді специфікація, іноді приклади запитів і відповідей, іноді колекція запитів в інструменті. У межах нашого курсу ми поступово прийдемо до дуже практичної схеми: контракт ви будете не лише тримати в теорії, а й досліджувати його інструментально (трохи пізніше з’явиться Postman і приклади відповідей для стабільності курсу). Але сьогодні важливо зрозуміти принцип: контракт має бути явним і перевірюваним.
Явним — означає, що ви можете показати іншій людині: ось метод, ось шлях, ось обов’язковий query‑параметр, ось очікувані статуси. Перевірюваним — означає, що ви можете відтворити запит і побачити, що сервер відповідає так, як обіцяв. І так, «я пам’ятаю, що він так відповідав» — це не перевірка. Це просто пам’ять. Контракт цінний тим, що його можна переперевірити через місяць, коли ви вже забудете деталі.
Є ще одна хитра причина любити явний контракт: він захищає вас від випадкових змін на боці зовнішнього сервісу. У проєкті ReadLater Starter у нас буде зовнішній каталог книг. Ми не володіємо цим сервісом. Він може бути повільним, може змінюватися, може бути недоступним. Якщо в нас немає явного контракту (і прикладів), то кожен збій перетворюється на розслідування: «А так і має бути? Чи все зламалося?». З контрактом ви хоча б розумієте: «За домовленістю має бути ось так; якщо не так — це проблема».
6. Міні‑контракт у коді: Java‑структури
Поки ми не пишемо HTTP‑клієнт і не підіймаємо сервер, але вже можемо прищепити собі корисну звичку: не розмазувати знання про контракт по рядках усюди підряд. Навіть у навчальному проєкті зручно зберігати ключові елементи контракту в одному місці: шляхи, імена query‑параметрів, очікувані media types. Це не «архітектура на виріст», це просто спосіб не отримати десять різних рядків /health, /Health і /heath (останній особливо гарний).
Уявімо, що ми хочемо зафіксувати шматок майбутнього контракту локального API ReadLater: endpoint /health. Ми поки не реалізуємо сервер, ми лише фіксуємо, як має називатися.
package com.example.readlater.app;
public final class ReadLaterApiContract {
private ReadLaterApiContract() { }
// Базовий шлях для перевірки «сервіс живий» (health-check)
public static final String HEALTH_PATH = "/health";
// Префікс версії API: зручно централізовано змінювати версію
public static final String API_PREFIX = "/api/v1";
// Повний шлях до ресурсу reading list (будуємо з префікса, щоб не дублювати рядки)
public static final String READING_LIST_PATH = API_PREFIX + "/reading-list";
}
Це виглядає як дрібниця, але через кілька тижнів ви скажете собі дякую: коли почнете писати обробники й клієнтські виклики, у вас не буде «магічних рядків» по всьому коду.
Тепер зробімо ще один крок: спробуймо описати endpoint як маленьку «специфікацію». Без фанатизму, буквально 6–8 рядків.
package com.example.readlater.app;
public record EndpointSpec(
// HTTP-метод: GET/POST/... (частина контракту)
String method,
// Шлях (URL без host): наприклад, /health або /api/v1/reading-list (частина контракту)
String path,
// Що клієнт очікує отримати: наприклад, application/json (частина контракту)
String accept,
// Який status code вважаємо успішним у цій точці контракту
int successStatus
) { }
І приклад використання (навіть без реального HTTP):
package com.example.readlater.app;
public class ContractDemo {
public static void main(String[] args) {
// Фіксуємо мінімальні «контрактні очікування» для /health
EndpointSpec health = new EndpointSpec(
"GET",
ReadLaterApiContract.HEALTH_PATH,
"application/json",
200
);
// Просто показуємо, що це вже можна зберігати, логувати й порівнювати
System.out.println(health); // EndpointSpec[method=GET, path=/health, accept=application/json, successStatus=200]
}
}
Сенс не в тому, щоб усе описувати кодом. Сенс у тому, щоб ви відчули: контракт — це не абстракція. Його можна представити дуже конкретно. І коли пізніше ви будете писати HTTP‑клієнт на HttpClient або сервер на HttpServer, ви будете точно знати, що ви реалізуєте: метод, шлях, заголовки, очікуваний статус.
А тепер — найкорисніша частина: контракт допомагає писати просту перевірку «ми взагалі потрапили туди, куди збиралися». Наприклад, у вас є method+path, і ви хочете переконатися, що запит хоча б на рівні форми схожий на контракт.
package com.example.readlater.app;
public class ContractMatcher {
public static boolean matches(EndpointSpec spec, String method, String path, String accept) {
// Порівняння в лоб: корисно як перше наближення.
// У реальному коді часто додають нормалізацію (регістр методу, пробіли, значення за замовчуванням).
return spec.method().equals(method)
&& spec.path().equals(path)
&& spec.accept().equals(accept);
}
}
Це примітивно — і це нормально. Ми зараз не будуємо фреймворк. Ми тренуємо м’яз: «контракт можна формалізувати, а отже можна перестати гадати».
7. Контракт у ReadLater Starter: каталог і локальний API
Щоб контракт не залишався «про когось там в інтернеті», давайте приміряємо його на наш наскрізний проєкт. У ReadLater Starter буде дві великі фази: спочатку ми ходимо у зовнішній каталог книг, потім підіймаємо локальний API. Для обох фаз контрактний погляд працює однаково: ми фіксуємо, що саме вважаємо правильним запитом і правильною відповіддю.
Візьмімо максимально абстрактний, але реалістичний контракт пошуку в каталозі книг. Тут нам важливі method, path, query і очікувані статуси, без прив’язки до конкретного провайдера.
Приклад контракту (зовнішній каталог, пошук):
# Це не «сирий HTTP-пакет», а короткий опис очікуваного контракту.
METHOD: GET
PATH: /catalog/search
QUERY: q=<рядок пошуку>, limit=<число> (необов’язково)
HEADERS: Accept: application/json
SUCCESS: 200 OK + JSON (зокрема порожній список, якщо збігів немає)
ERRORS: 400 (поганий запит), 404 (неправильний шлях або запит до неіснуючого конкретного ресурсу), 500 (помилка сервісу)
Для search-сценарію відсутність результатів часто залишається успішною відповіддю: endpoint існує, просто колекція порожня.
Тепер приклад локального API. Найпростіша й найкорисніша кінцева точка — /health.
Приклад контракту (локальний API, health):
# Класичний приклад HTTP-запиту: метод, шлях, версія протоколу та заголовки.
GET /health HTTP/1.1
Host: localhost:8080
Accept: application/json
Очікувана відповідь:
HTTP/1.1 200 OK
Content-Type: application/json
І тіло — «в певній формі JSON». Поки достатньо розуміти: це не HTML, не випадковий рядок і не порожнеча. Пізніше ми домовимося про точну структуру.
Щоб бачити контракт цілком, корисно тримати його в табличці «метод + шлях + зміст». Наприклад, для майбутнього reading list API:
| Операція | Метод | Шлях | Зміст (дуже коротко) |
|---|---|---|---|
| Перевірити живість | GET | /health | Сервер живий і віддає статус |
| Отримати список | GET | /api/v1/reading-list | Читати колекцію |
| Отримати за id | GET | /api/v1/reading-list/{id} | Читати один ресурс |
Навіть без реалізації вже видно важливу річ: шлях і метод — це частина договору, і далі реалізація має підкорятися цій домовленості. Інакше ви почнете «випадково» робити POST /reading-list/getById і дивуватися, чому все виглядає дивно.
8. Типові помилки під час мислення про API‑контракт
Помилка №1: вважати, що контракт — це лише JSON-body.
Це дуже часта пастка: людина дивиться на красиву відповідь і забуває, що контракт починається з методу й шляху, продовжується заголовками та статусом, і лише потім доходить до body. У підсумку змінюється Content-Type або статус, клієнт ламається, а винним призначають «поганий JSON», хоча проблема була взагалі на іншому рівні.
Помилка №2: «якщо статус 200, значить усе гаразд».
200 OK означає лише те, що сервер заявив про успіх. Якщо при цьому тіло відповіді не відповідає домовленій формі або сервер повертає повідомлення про помилку в «успішному» тілі, то це не успіх, а порушення контракту. Клієнту доводиться вгадувати, що вважати правдою: статус чи тіло, і обидва варіанти погані.
Помилка №3: змішувати «немає відповіді» і «відповідь з помилкою».
Коли сервіс недоступний або мережа не дала відповіді, у вас немає HTTP‑статусу. Це не 404 і не 500. Це інший клас проблем, який обробляється інакше. Якщо ви називаєте будь-який збій «500», ви втрачаєте важливу інформацію і ускладнюєте діагностику: ви не розумієте, де помилка — у мережі чи в застосунку.
Помилка №4: змінювати метод/шлях/статус і не вважати це зміною контракту.
Іноді здається: «подумаєш, перейменували /books на /book». Для клієнта це не «подумаєш», це інша адреса, і старий виклик перестане працювати. Контракт — це не внутрішня деталь сервера, це зовнішній інтерфейс. Його зміни треба сприймати серйозно.
Помилка №5: тримати контракт лише в головах розробників.
Якщо в контракту немає явного представлення (хоча б у вигляді таблиці, прикладів, нотаток, набору запитів), він почне дрейфувати. Один розробник пам’ятає, що потрібен Accept, інший — що не потрібен. Один очікує 201, інший завжди повертає 200. У підсумку ви витрачаєте час не на код, а на відновлення домовленостей заднім числом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ