Що не так з загальними практиками REST API
Мене звати Юрій Івон, я співпрацюю з компанією EPAM як Senior Solution Architect. Цього разу хотів би поділитися своїми міркуваннями щодо доцільності деяких практик проєктування REST API.
REST-сервіси, їхні моделі зрілості та відповідні «найкращі практики» існують вже досить давно. Безліч статей пояснюють, як створювати RESTful API, і, ймовірно, сотні тисяч годин були витрачені на обговорення правильного використання HTTP-дієслів і принципів іменування ендпоінтів. Давайте знову переглянемо цей досвід і спробуємо відповісти на кілька питань — чи все це було того варте, а якщо ні, то як ми можемо зробити краще?
Концепції REST були представлені Роєм Філдінгом у його докторській дисертації як пропозиція оптимального архітектурного стилю для розподілених систем. Вона є досить загальною і не містить детальних рекомендацій щодо проєктування API, але окреслює кілька ідей, які згодом стали основою практичного підходу.
Варто зазначити, що серед основних архітектурних обмежень, визначених Філдінгом, лише три безпосередньо стосуються проєктування API-контракту: відсутність стану (statelessness), кешованість (cacheability) та уніфікований інтерфейс (uniform interface). Відсутність стану не повинна викликати жодних питань, оскільки вже багато років є загальноприйнятою практикою. Однак певні аспекти кешування та уніфікованого інтерфейсу потребують повторного перегляду.
Ресурси
Можливо, найменш інтуїтивною ідеєю, запропонованою Філдінгом, є те, що все в API має бути представлене як ресурс, який ідентифікується унікальним URI. Коли ви маєте справу з тривіальними CRUD-операціями над окремими об’єктами домену та їх колекціями, все виглядає гарно й просто. Але ситуація починає ускладнюватися, коли потрібно спроєктувати процесно-орієнтований API.
Припустімо, у нас є система керування завданнями, де кожне завдання можна запускати, зупиняти, призупиняти та відновлювати. Найочевидніші HTTP-ендпоїнти для керування виконанням вже створеного завдання виглядали б так:
- /api/v1/jobs/{id}/start
- /api/v1/jobs/{id}/stop
- /api/v1/jobs/{id}/pause
Однак вони не є ресурсно-орієнтованими, як передбачає концепція REST. Щоб виразити ті самі можливості ресурсно-орієнтованим способом, ми могли б мати кілька альтернатив:
- /api/v1/jobs/{id}/status — очікує PUT з тілом запиту, яке вказує новий стан для завдання.
- /api/v1/jobs/{id} — очікує PATCH з тілом запиту, яке може перезаписати будь-яку властивість завдання, в тому числі і його стан. Логіка на бекенді оброблятиме оновлення поля стану спеціальним чином для керування життєвим циклом завдання.
Очевидно, що для цього випадку існує варіант мати ресурсно-орієнтований API, але чи дає він щось, окрім концептуальної чистоти REST? Я вже явно бачу кілька недоліків:
- Призначення ендпоїнту стає менш очевидним.
- Бекенд змушений приховувати багато умовної логіки за однією операцією поверх простого оновлення об’єкта («якщо статус змінився — зробити щось особливе залежно від його значення»).
Але найбільше мене турбує концептуальна невідповідність між ресурсно-орієнтованим підходом і реальними потребами взаємодії між застосунками. Незалежно від походження цієї ідеї чи того, як кінцеві користувачі взаємодіють із вебресурсами через браузер, застосунки взаємодіють між собою переважно процедурним способом.
Коли потрібно прочитати замовлення — викликають метод getOrder або будь-який інший подібний за назвою метод; коли потрібно запустити чи призупинити завдання — викликають методи startJob і pauseJob відповідно (або методи start і pause об’єкта завдання). І незалежно від того, як виглядають REST-ендпоїнти, практично в будь-якій мові програмування взаємодія із сервісом робиться через дієслівну семантику. То який сенс вносити таку невідповідність?

У наведеному вище прикладі перший варіант є простим і логічним, і клієнтський код може бути прозоро згенерований із визначення API.
Другий варіант вимагає додаткового коду як на клієнті, так і на сервері. Різні переходи станів завдання (або будь-якого іншого об’єкта, що має переходи станів) зазвичай передбачають різні набори дій, які природніше всього реалізовувати у вигляді окремих методів. Отже, другий варіант вимагає від нас:
- В коді клієнту «транслювати» виклик методу («pause») у виклик REST API для оновлення статусу. Не існує автоматизованого способу згенерувати клієнтську бібліотеку з API-специфікації, яка б підтримувала такі семантичні переходи, тому обгортку навколо REST-виклику доведеться писати вручну.
- «Транслювати» оновлення статусу на бекенді у відповідний виклик методу бізнес-логіки.
Виявляється, що RESTful-підхід виглядає громіздким і вимагає більше зусиль. Чому ж Рой Філдінг наполягав на дотриманні саме цього підходу?
Схоже, ідея розглядати все як ресурс тісно пов’язана з концепцією HATEOAS, у якій від клієнтів очікується, що вони будуть «пересуватись» застосунком за допомогою посилань, наданих у відповідях сервера. Такі гіпотетичні клієнти відображали б інтерфейс користувача суто на основі отриманої інформації, яка включає поля даних і навігаційні посилання. Ці посилання давали б користувачам змогу переглядати пов’язані об’єкти та виконувати дії. Представлення дій у вигляді ресурсів спрощує модель, з якою мали б працювати такі клієнти. Це навіть робить її «академічно цілісною», адже не потрібні нові «дієслова» крім тих, що вже існують в HTTP, а всі інші об’єкти представлені у вигляді іменників.
Полегшення розробки та підтримки клієнтів для кінцевих користувачів є цілком слушною метою, але сама ідея викликає чимало занепокоєнь:
- Створити зручний для користувача інтерфейс, в якому набір і розташування елементів формується лише на основі даних, майже неможливо. Поля даних та команди мають бути розташовані найзручнішим для кінцевого користувача чином, що потребує роботи з UX-дизайну і не може бути точно визначено лише з даних. Є випадки, коли UX не має вирішального значення і можна зробити припущення щодо нього — наприклад, у випадку типових адміністративних інтерфейсів, — але це радше виняток, ніж правило.
- Значна частина сучасних API розробляється для взаємодії між застосунками, а не лише для забезпечення інтерфейсів користувача: платформа інтернет-магазину викликає пошуковий рушій, скрипт автоматизації — сервіс сповіщень, сервіс обробки документів — GenAI-сервіс для пошуку важливих атрибутів тощо. У цьому контексті HATEOAS не має жодного сенсу. Система, що викликає, чітко «знає», що саме їй потрібно, і потребує простого, детермінованого способу викликати конкретний метод без необхідності проходити крізь дерево об’єктів у пошуках відповідного ресурсу.
Пройдімо ще раз цей ланцюжок міркувань. Вимога, щоб клієнт взаємодіяв з сервером виключно через ресурси, потрібна для ефективної підтримки HATEOAS, яка необхідна, аби будувати «тонкі» клієнти для користувача, що не містять доменних знань. Однак у більшості випадків практично неможливо створити такий клієнт без «хардкоду» доменних знань, а значна частина API взагалі не призначена для клієнтських застосунків. Виглядає так, що вимога взаємодіяти лише через ресурси не має практичної користі.
Дієслова
Існує кілька загальновідомих рекомендацій щодо того, які HTTP-дієслова слід використовувати в кожній ситуації, і їх доволі часто дотримуються як догми, без повного розуміння практичних компромісів:
- Використовуйте GET для всіх операцій читання
- Використовуйте POST для створення нового ресурсу
- Використовуйте PUT для оновлення існуючого ресурсу
- ...
Якщо відкинути семантику, то на практиці важливі лише дві властивості, які можуть бути різними у різних дієслів — кешування та ідемпотентність:
- Зазвичай лише відповіді на запити GET і HEAD можуть бути закешовані за допомогою стандартних механізмів протоколу HTTP.
- Усі стандартні дієслова HTTP, окрім POST і PATCH, вважаються ідемпотентними згідно зі специфікацією HTTP (RFC 7231). Хоча PATCH може бути реалізований як ідемпотентний, специфікація цього не гарантує. Ідемпотентні операції можна безпечно повторювати, тому різні HTTP-сервери та проксі (наприклад, Nginx) можуть мати вбудовані правила для повтору проблемних запитів лише для ідемпотентних дієслів, уникаючи таким чином небажаних побічних ефектів.
Уявімо, що ми реалізували API на основі HTTP, де всі методи викликаються через дієслово POST незалежно від семантики методу. Нам потрібно зрозуміти, що ми втрачаємо з таким підходом.
Перша проблема полягає в тому, що методи «тільки для читання», такі як отримання списку об’єктів, не будуть кешуватися за замовчуванням в браузерах і на проксі-серверах. Але чи є це великою проблемою? Я можу навести декілька причин, чому це неважливо в більшості випадків.
API працюють із динамічними даними, тому кешування не завжди доцільне — у більшості випадків воно не дає переваг або навіть може погіршити ситуацію, повертаючи застарілі дані. Лише методи, які повертають нечасто змінювані дані, можуть отримати певну перевагу від його використання.
Динамічна персоналізація ускладнює кешування відповідей за допомогою стандартних засобів HTTP-протоколу. Багато сучасних GET-методів повертають дані, специфічні для користувача. Це вимагає використання заголовка «Vary», щоб розділяти записи кешу для різних користувачів. Хоча теоретично можуть існувати ситуації, коли цей заголовок не забезпечує повного розділення, мене ще більше непокоїть потенційне розкриття даних, коли користувач випадково отримує інформацію, призначену для іншого.
Заголовки відповіді є дуже непрямим механізмом керування поведінкою системи, тому ймовірність випадкового видалення необхідних заголовків або надання неправильного значення вища, ніж у разі, якщо логіка кешування була б реалізована безпосередньо в коді клієнта. Випадкове видалення заголовка ‘Vary’ може призвести до серйозного розкриття даних.
Стандартне кешування не контролюється централізовано — якщо з якоїсь причини в кеш браузера потрапили неправильні дані, може бути неможливо видалити їх для всіх користувачів одночасно.
Сучасні рішення мають кілька інших, більш гнучких рівнів кешування, що робить HTTP-кеш непотрібним: на бекенді (зазвичай розподілене), у CDN із розширеними можливостями поза стандартом HTTP, а також у клієнтських застосунках.
Ще однією потенційною проблемою може бути неможливість налаштувати автоматичні повторні спроби (retries) для методів API на рівні вебсервера або проксі. Проте я взагалі не вважаю це проблемою. Я б надав перевагу реалізації повторних спроб у клієнтських застосунках, щоб мати кращий контроль та прозорість.
Отже, виявляється, що немає суттєвих практичних переваг у суворому дотриманні початкових конвенцій. Пригадую пару ситуацій, коли команда розробників мала створити API-метод, який повертає дані на основі критеріїв потенційно необмеженої складності. В обох випадках перша версія методу використовувала дієслово GET, оскільки «рекомендовано реалізовувати операції читання за допомогою GET».
Однак таке рішення мало щонайменше два суттєві недоліки:
- Існував ризик перевищення довжини рядка запиту — у браузері, на вебсервері або в будь-якому проміжному компоненті, такому як API-шлюз чи фаєрвол.
- Запити зазвичай логуються вебсерверами, системами моніторингу та інструментами аналітики. Оскільки критерії запиту можуть включати імена користувачів або інші ідентифікаційні дані, це створює ризик розкриття конфіденційних даних.
Використання POST вирішило б обидві проблеми завдяки передачі параметрів у тілі запиту, але автор методу вперто стверджував, що це «не буде RESTful». На жаль, це типовий приклад карго-культу в інженерії програмного забезпечення — коли реальну проблему ігнорують заради дотримання теоретичної догми. Такі ситуації слід вирішувати, а не замовчувати. У моїх прикладах проблему зрештою вдалося виправити після чіткого, детального та спокійного пояснення недоліків та переваг.
Призначати дієслова методам API відповідно до їхньої семантики — цілком логічно. Було б дивно бачити метод «отримати об’єкт», реалізований через дієслово DELETE, а «видалити об’єкт» — через GET. Однак є ситуації, коли краще використати POST замість GET, і це цілком нормально.
Чого не вистачає?
Є щонайменше дві речі, які багато розробників, що працюють з REST API, часто не враховують — використання специфікацій API та повернення належних відповідей у разі помилок.
Специфікації API
Хоча специфікація OpenAPI (раніше Swagger) існує вже давно, я й досі бачу проєкти, які не використовують її належним чином. Багато розробників знають, що документацію для API та інтерактивний playground можна згенерувати автоматично з коду сервера, але з якихось причин і досі трапляються проєкти, де подібну документацію підтримують вручну. Ба більше, не всі знають про можливість генерації коду, тому клієнти для API часто пишуться вручну, використовуючи звичайну текстову документацію як вхідні дані.
Філософія, що стоїть за HATEOAS, не потребує жодних статичних визначень API, адже клієнти мають динамічно знаходити деталі, не маючи попередніх знань про структуру API. Це може бути однією з причин, чому OpenAPI «загубився» серед численних рецептів того, як бути «справді RESTful».
Я наполегливо рекомендую використовувати специфікацію OpenAPI, оскільки вона надає можливість:
- Генерувати клієнтські бібліотеки для різних мов програмування, усуваючи необхідність вручну створювати HTTP-запити та серіалізатори (дивіться OpenAPI Generator, NSwag та інші альтернативи).
- Генерувати серверні заглушки, забезпечуючи базову логіку маршрутизації та перевірки запитів перед тим, як реалізовувати бізнес-функціональність (якщо ви дотримуєтеся підходу «спочатку специфікація» та не генеруєте специфікації з коду сервера).
- Генерувати документацію, яка залишається синхронізованою з реалізацією.
- Автоматично перевіряти запити та відповіді на відповідність схемі, виявляючи проблеми інтеграції на ранніх етапах.
- Імітувати (mock) ендпоїнти під час розробки або тестування без розгортання реального бекенду.
Хтось може поспорити, що HATEOAS усе ще актуальний, адже його динамічна структура, яка дозволяє клієнту самостійно орієнтуватися в API без знання специфікації, добре підходить для сучасних AI-агентів. Насправді ж усе навпаки. Надати штучному інтелекту специфікацію OpenAPI — це все одно, що дати йому детальну інструкцію. AI може з високою точністю розібрати визначені ендпоїнти, необхідні параметри та очікувані структури даних. Такий формальний контракт мінімізує неоднозначність і знижує ризик неправильних припущень. Натомість API, побудований за принципом HATEOAS, із динамічними посиланнями та потребою орієнтуватися у структурі відповідей під час виконання, створює набагато складніше й менш передбачуване середовище для AI, підвищуючи ризик неочікуваної поведінки.
Відповіді у випадку помилок
Є кілька типових проблем із відповідями у випадку помилок, які я часто помічаю:
- Неінформативні відповіді, які містять лише звичайне текстове повідомлення про помилку для кінцевого користувача, або іноді взагалі не містять тіла відповіді.
- Неправильні коди стану — або не відповідають ситуації, або навіть нестандартні.
Я не можу звинуватити Роя Філдінга в тому, що він не надав належних рекомендацій щодо обробки помилок, адже цей аспект є занадто низькорівневим для його пропозиції. Однак мене дивує, наскільки часто цей аспект ігнорується, навіть людьми з певним досвідом у розробці API.
Окрім коду стану HTTP та зрозумілого для людини повідомлення про помилку, відповідь повинна містити щонайменше:
- Ідентифікатор помилки у вигляді рядка — код HTTP часто є неоднозначним, тому потрібен більш конкретний ідентифікатор (наприклад, InsufficientFunds).
- Ідентифікатор кореляції (correlation identifier) — без нього неможливо знайти логи та інші записи моніторингу, що відповідають конкретній помилці.
Для належної обробки деяких помилок може знадобитися додаткова контекстна інформація.
Існує RFC 9457, який намагається стандартизувати цей підхід, але я ще не бачив його широкого впровадження, як і його попередника, RFC 7807.
Висновки
Відповідаючи на запитання «Що не так із практиками RESTful API?», я можу сказати, що головна проблема полягає в тому, що їх часто дотримуються догматично, хоча деякі з ідей з часом виявилися непрактичними.
Ідея, що всі ендпоїнти повинні бути ресурсно-орієнтованими, не має жодних переваг у більшості сценаріїв, так само як і підхід HATEOAS. Однак концепція ресурсу цілком доречна там, де вона природно вписується — наприклад, у типовому CRUD API. Коли ж вам потрібен ендпоїнт, орієнтований на дії (як у прикладі з API для завдань), я не бачу проблем у тому, щоб реалізувати її без намагання знайти ресурсно-орієнтований еквівалент.
Особисто я зазвичай дотримувався саме такого гібридного підходу: ресурсно-орієнтований стиль — для всіх ендпоїнтів, де це доцільно, і ендпоїнти у вигляді дій — там, де ресурсний еквівалент виглядав би штучно або робив API менш зрозумілим.
Альтернативою цьому підходу є RPC поверх HTTP — і це цілком прийнятний варіант. Наприклад, перша частина URL може ідентифікувати об’єкт, остання — назву методу, а виклик здійснюватися через POST:
- POST /api/v1/jobs/{id}/get
- POST /api/v1/jobs/{id}/start
- POST /api/v1/jobs/{id}/update
Як пояснювалося у розділі «Дієслова», при використанні RPC-семантики немає жодних суттєвих практичних недоліків у використанні POST для будь-якого ендпоїнта.
Специфікація OpenAPI є де-факто обов’язковим засобом для проєктування та розробки HTTP API. Вона набагато практичніша за динамічне виявлення доступних дій та ресурсів, оскільки дає змогу автоматично генерувати клієнтський код, серверні заглушки та документацію — що заощаджує час і запобігає помилкам інтеграції.
Визначення форматів помилок є важливою частиною формування зрілого API-контракту. Справді корисна відповідь про помилку має містити не лише код статусу, а щонайменше — зрозуміле людині повідомлення про помилку, ідентифікатор кореляції для відстеження запиту в логах, а також унікальний ідентифікатор помилки, який дає змогу клієнтському застосунку обробляти збій програмно.
Ця стаття доступна також англійською на Medium.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

65 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів