Які є конвенції в REST API та для чого їх дотримуватись
REST API — один з найпопулярніших підходів для проєктування API ваших сервісів. Ця технологія здається простою, але, на жаль, дуже часто розробники неправильно розуміють або застосовують її принципи. Тим більше, що REST (на відміну від того ж HTTP) не є стандартом. А там, де немає стандарту, не може бути однієї правильної відповіді/рішення на технічне питання/завдання. Але можуть бути конвенції та правила, які відображають сталi best practices.
Тому я вирішив написати статтю про конвенції, які зараз поширені, і про те, які наслідки можуть бути, якщо їх не дотримуватись. Конвенції цікаві тим, що можуть відрізнятися від компанії до компанії, тому ця стаття заснована на моєму практичному досвіді, включаючи деякі наші тренінги. Також я помістив цікаві приклади з API популярних соцмереж. Але мені було б також цікаво, якби читачі поділилися і своїм практичним досвідом.
З чого усе почалося
Отже, 2000 року відбулася подія, яка начебто не повинна була істотно вплинути на майбутнє ІТ і Web 2.0. Американський інженер Рой Філдінг захистив докторську дисертацію в університеті в Ірвіні, штат Каліфорнія. Саме ця дисертація під назвою Architectural Styles and the Design of Network-based Software Architectures привела до домінування REST API, який зараз масово використовують по всьому світу. Щоправда, це сталося не відразу, тільки в
Чому Філдінг вирішив взяти саме цю тему? Річ у тому, що Філдінг був не простим інженером, а ще й комп’ютерним вченим і дослідником, одним з авторів протоколу HTTP і Apache HTTP вебсервера. Що ж таке REST? Це не протокол, не формат даних і не фреймворк, це архітектурний стиль, який вводить 5 обмежень або принципів (constraints) для комунікації в розподіленої архітектури.
- Наявність клієнта і сервера.
- Принцип stateless.
- Єдині інтерфейси (Uniform interface) для ресурсів.
- Layered-архітектура.
- Можливості кешування відповіді сервера.
Відповідно, якщо ви створюєте сервіси в своєму застосунку і вони відповідають цим обмеженням, то ви маєте усі підстави назвати свої сервіси RESTful. Ви можете використовувати будь-який протокол, формат даних або фреймворк для розробки своїх сервісів. Але традиційно саме протокол HTTP і формат JSON найбільш часто використовуються завдяки своїй простоті та популярності в ІТ. Тоді ваші сервіси матимуть назву RESTful вебсервіси. Я навів ці обмеження, оскільки вони не раз знадобляться нам пізніше.
Операції з ресурсами
REST — це дуже велика тема, а в цій статті мені б хотілося торкнутися конвенції, пов’язаної з ресурсами і uniform interface для RESTful вебсервісів, і навести кілька практичних прикладів. Отже, будь-яка операція, ініційована клієнтом, пов’язана з деякою дією над тим ресурсом, який міститься на сервері. Якщо ваш проєкт — це інтернет-магазин, а ваші ресурси — книги, то це може бути отримання, зміна або видалення книг з магазину. Для того щоб уніфікувати операції з ресурсами, обмеження uniform interface вводить 4 умови:
- У кожного ресурсу повинен бути унікальний ідентифікатор (URI), який клієнт вкаже в своєму запиті.
- Сервер зберігає у себе інформацію про ресури, а повертає їх клієнту у вигляді представлень (representations). Причому внутрішній спосіб зберігання (СУБД, файлові системи) повинен бути незалежним від формату представлення (XML, JSON, HTML). По суті, тут REST API приховує (інкапсулює) внутрішнє представлення ресурсу. Представлення може містити не тільки інформацію про ресурс, а й додаткові метадані.
- Запити від клієнта повинні бути самопояснювальними і містити всю необхідну інформацію, щоб сервер міг обробити запит.
- Сервер повинен повертати клієнту не тільки запитану інформацію (про ресурс), а й всі доступні операції, пов’язані з нею, а також посилання на пов’язані ресурси. Цей принцип відомий як HATEOAS (Hypermedia as the engine of application state).
Uniform interface — це важливий момент в REST, оскільки він за визначенням є stateless, і сервер обробляє запит, тільки з огляду на його вміст (включаючи і URI). Як же правильно скласти URI для ваших запитів? Уявімо, що у вас є найпростіший інтернет-магазин, в якому є 4 ресурси:
- Книги (books).
- Замовлення (orders).
- Автори (authors).
- Користувачі (users).
Хочу ще раз нагадати, що REST є не стандартом, а архітектурним стилем, і розробники вільні реалізувати його як вважають за потрібне. Але RESTful вебсервіси використовують HTTP, а цей протокол вже є стандартом зі своєю специфікацією. Тому загальновживані конвенції потрібні для того, щоб правильно використовувати HTTP і щоб розробники щоразу не вигадували велосипед.
У програмуванні ми вже використовуємо кілька конвенцій, пов’язаних з найменуванням:
- Назва класiв — іменники.
- Назва інтерфейсiв — іменники або прикметники.
- Назва методiв (функцiй) — дієслова.
- Назва змінних — іменники або прикметники.
Ці конвенції випливають з тієї ролі, яку відіграють наші абстракції і моделі. Так метод виконує деяку дію, тому його назва буде дієсловом. А клас є узагальненням тих об’єктів, які ми використовуємо в проєкті і тому буде іменником.
В RESTful вебсервісах прийнята така конвенція:
- Назва ресурсу — це іменник у множині
- Дія над ресурсом — це HTTP-метод, який вказує клієнт (споживач нашого API).
- GET — отримати ресурс.
- POST — створити ресурс.
- PUT — змінити ресурс повністю (оновити).
- DELETE — видалити ресурс.
- PATCH — частково змінити ресурс.
Цим REST API відрізняється від SOAP, де і операція, і вхідні аргументи (метадані) зберігаються в самому XML envelope body. Таким чином, для будь-якого REST-сервісу потрібно вибрати і правильний URI, і HTTP-метод. І саме метод визначає, яку операцію над ресурсом ми будемо виконувати. Здавалося б, усе просто, але розберімо кілька прикладів. Ті варіанти, які відповідають сформованим конвенціям, відзначені жирним шрифтом, невідповідні, спірні та альтернативні — курсивом.
Практика
1. Отже, вам потрібно отримати всі книги з магазину. У такому випадку клієнт повинен відправити запит на сервер:
GET /books
Я бачив і URI /allbooks, і /getAllBooks, і навіть суперечливий URI від GitHub виду /search/books. На мою думку, вони є некоректними, тому що або містять надлишкову інформацію, або вже містили дію (get, search), тоді як дію має вказувати клієнт, обираючи той чи інший HTTP-метод.
2. Тепер вам потрібно отримати книги англійською мовою. Чи можемо ми вибрати для нового сервісу URI /englishbooks або /books/english?
В принципі ніхто нам це не може заборонити, але з часом виникнуть нові запити і нові endpoints /availablebooks, /soldoutbooks тощо. У цьому разі English, available, sold out — це атрибути стану ресурсу (книги), і ми в нашому сервісі хочемо фільтрувати книги з тих чи інших атрибутів.
У REST API прийнята така конвенція. Для сортування, фільтрації та обмеження ресурсів використовуйте параметри запиту (query parameters). Тоді наші URI будуть виглядати так:
/books?type=available або /books?language=english
Такий підхід допоможе нам одним REST API виконати будь-яку операцію з пошуку ресурсів, змінюючи тільки параметри запитів.
3. Тепер вам потрібно повернути книгу з ідентифікатором 100. У минулому прикладі ми використовували параметри запиту для фільтрації. Чи можемо ми використовувати їх і в цьому випадку?
GET /books?id=100
Ні в REST, ні в HTTP ця ситуація прямо не описана і не стандартизована. Але тут потрібно згадати, що одна книга — це теж ресурс і частина більш глобального ресурсу книги. У REST API прийнято використовувати символ «/» для відображення ієрархічної залежності між ресурсами. Тому нам варто вибрати альтернативний варіант — параметри шляху (path variables). І в такому варіанті URI буде /books /100. У другого варіанта є й інші переваги:
- Параметр шляху завжди є обов’язковим, тоді як параметр запиту — опціональним.
- Якщо ви вирішите вказувати ідентифікатори через параметри запиту, то у вас буде багато способів так зробити, наприклад, /books?Id=100 або /books?book_id=100, а це може призвести до відсутності єдиного стандарту навіть в межах одного проєкту.
- Згідно з HTTP-конвенцією, якщо ви вказуєте URI з параметрами запиту, за яким нічого не знайдено, то ви повинні просто повернути порожню відповідь з кодом 200. А ось якщо ви використовуєте параметри шляху і нічого не знайдено, тоді код 404.
Ви можете навести карколомне заперечення. А яка взагалі різниця, який варіант використовувати, якщо у нас є HATEOAS? При запиті всіх ресурсів (GET / books) сервіс поверне посилання для отримання інформації про індивідуальні ресурси:
[{
«id»: «123»,
«title»: «REST API»,
«year»: 2021,
«_links»: {
«self»: {
«href»: «shop.com/api/books/123»
}
}]
Це все правильно, HATEOAS і був придуманий, щоб REST-клієнт не залежав від внутрішньої логіки і реалізації сервера. Ба більше, якщо ми захочемо поміняти URI сервісу, не буде потрібно змін на клієнті. Але тут можуть виникнути дві складності:
- У нас може не бути HATEOAS.
- Нам може знадобитися окрема сторінка на операцію з ресурсом для стороннього сервісу або клієнта, де workflow HATEOAS не застосовують.
У будь-якому випадку в межах одного проєкту (компанії) повинен бути вироблений один стандарт (або набір конвенцій), щоб уніфікувати розробку і використання всіх сервісів. Тут краще використовувати наявні конвенції, які пропонує ІТ-індустрія.
4. Ідентифікатори не завжди передаються як параметри шляху. У разі BATCH-запиту, коли ми хочемо отримати відразу кілька ресурсів за їх ідентифікаторами, ми вже використовуємо параметри запиту:
GET /books?ids=1,2,3
5. Можливий варіант, коли у ресурсу є не тільки первинний ідентифікатор у вашому сервісі (цифровий, UUID), але і глобальний бізнес-ідентифікатор, наприклад ISBN у книг. І може бути так, що сторонні сервіси не знають первинний ідентифікатор книги, але знають її ISBN і хочуть отримувати інформацію про книги по ньому. Якою буде URI в цьому випадку?
Ми можемо передавати ISBN як параметр запиту, і багато хто так і робить: /books?isbn= 978-1-56619-909-4. Але ISBN — унікальний ідентифікатор ресурсу, тому логічніше буде передавати його як параметр шляху. Але ми не можемо використовувати URI /books/978-1-56619-909-4, оскільки його вже зарезервовано. Тому є ось такий витончений варіант:
GET /books/isbn/978-1-56619-909-4
6. У нашому інтернет-магазині потрібен REST-сервіс для отримання інформації про поточного (залогіненого) клієнта. REST або HTTP ніяк не роз’яснюють цю ситуацію. За аналогією з попередніми прикладами ви можете використовувати URI:
GET /users/user_login
Такий варіант існує, наприклад, у BitBucket. Але є більш популярний підхід, який використовують GitHub, StackExchange, Facebook і багато інших. Вони ввели спеціальний ресурс — авторизований користувач, і URI виглядає відповідно: /me, /self або навіть /users/me або /users/current. Це не суперечить REST, оскільки ви самі вибираєте назву для ваших ресурсів. А ідентифікатор користувача передається в заголовку запиту Authorization (або якимось іншим способом). До того ж це приватний API, який ніколи буде використаний стороннім сервісом або клієнтом.
Іноді трапляється варіант /user, і він мені здається неправильним, тому що назва ресурсу повинна бути у множині. А /users теж не підходить, тому що GET /users повинен повернути всіх користувачів.
Але тут є непомітний підступ. Одна з вимог до REST API — це можливість кешування результатів роботи сервісу (якщо сервіс ідемпотентний). Чи можемо ми зробити endpoint /me кешованим? У дефолтному варіанті як ключ кешу використовується URI і параметри запиту. Якщо у вас поточний користувач передається через заголовки запиту, то ви можете закешувати перше значення і помилково повертати його для всіх користувачів. Тому тут дуже важливо налаштувати конфігурацію кеша і включити заголовки запиту.
7. Тепер потрібно повернути замовлення користувача за його ідентифікатором. Якщо ви впевнені, що URI повинен бути /orders/REF9423402343, то тут потрібно згадати про те, що ресурси бувають трьох видів:
- Незалежні — можуть існувати без прив’язки до інших ресурсів.
- Залежні — існують тільки в контексті інших ресурсiв (як inner classes в Java).
- Асоціативні — незалежні ресурси, на які можна посилатися з інших ресурсів.
Замовлення не можна створити само собою, для цього потрібні ресурси-контейнери (книга і користувач), тому це залежний ресурс. Для залежних ресурсів в URI прийнято вказувати всіх його «батьків». Якщо ми хочемо отримати всі замовлення по книзі з ідентифікатором 100, то URI буде не /orders?bookId=100, а /books/100/orders. Такий підхід зручний тим, що можна відразу отримати URI «батька» — /books/100. Іноді трапляється варіант виду /books/orders/100, але він неправильний.
Тут виникає цікаве питання. А який може бути максимальний рівень вкладеності такого посилання? Практично я не бачив більше трьох. Наприклад, якщо ми хочемо отримати замовлення за книгами певного автора, то URI буде виду /authors/2/books/100/orders.
8. Яким буде URI для додавання нової книги? Для створення ресурсу згідно з HTTP-специфікацією використовується HTTP-метод POST, і тоді запит буде мати такий вигляд:
POST /books
Іноді трапляються неправильні підходи:
- PUT /books — PUT використовується тільки для зміни наявного ресурсу.
- POST /create-book — дію зазначено в URI.
- GET /create-book — неправильний HTTP-метод.
Як бути з операцією купівлі книги? Часом бувають неправильні варіанти типу POST /books/100/pay. Тут знову ж дію вказують в самому URI. Щоб вибрати правильний варіант, потрібно відповісти на питання. Купівля книжки — це операція з яким ресурсом? Зрозуміло, із замовленням (його створення). Тому правильний варіант буде:
POST /books/100/orders
Дивно, що іноді неправильні варіанти є у відомих проєктах. Наприклад, в Twitter API v 1.1 (поточної) для створення лайку використовують такий запит:
POST favorites/create
І тільки у версії 2.0 вони його привели до тями:
POST /2/users/{id}/likes
Некоректний URI і в API вiд Bitbucket для додавання користувача в групу:
/rest/api/1.0/admin/groups/add-user
9. Для зміни ресурсу згідно з HTTP-специфікацією використовують HTTP-метод PUT, причому ми обов’язково вказуємо ідентифікатор ресурсу (навіть якщо він є в тілі запиту):
PUT /books/100
Але бувають і більш складні приклади. Наприклад, у книги є прапорець activated — чи доступна вона для продажу. Відповідно нам потрібно мати сервіс(и) для установки і скидання цього прапорця. Тут можна використовувати і PUT, але тоді нам доведеться отримати книгу з сервера, змінити activated, пройти шлях серіалізації → відправки по мережі → десеріалізацію і потім зміну всього вмісту в базі. Це дуже неощадливий варіант.
Іноді є варіанти, які я б назвав workarounds:
- POST /books/100/activate.
- POST /books/100?changeStatus=activated.
- POST /books/100?action=activate.
Це, по суті, милиці, тому що в HTTP-специфікації є спеціальний HTTP-метод — PATCH, який використовується для часткової зміни ресурсу. Є навіть специфікація JSON Patch (RFC 6902), яка вказує формат тіла запиту:
[
{ «op»: «replace», «path»: «/activated», «value»: true}
]
У параметрі op вказують операцію над тим полем, яке міститься в параметрі path. Це може бути додавання, видалення, зміна, копіювання, переміщення або тестування. А сам запит буде виглядати так:
PATCH /books/100
І все ж є проєкти, де люблять вибирати свій шлях самурая. Наприклад, в LinkedIn API для часткової зміни потрібно вказати в заголовку запиту X-Restli-Method значення PARTIAL_UPDATE, а сам запит буде виглядати як POST /books/100.
10. Що, як потрібно виконати операцію, яка начебто не пов’язана ні з яким ресурсом? Наприклад, користувач забув свій пароль на сайті, він вводить email у формі та очікує отримати на пошту лист з новим паролем (або з посиланням на скидання пароля). Чи можна використовувати запит виду:
POST /users/sendReminderEmail?email= [email protected]?
Тут потрібно знову поставити питання — до якого ресурсу належить цей запит? І відповідь дуже проста: лист-нагадування паролю і є цей ресурс, який створюється нашим запитом. Тут ми не знаємо ідентифікатор користувача, тому ним слугує його email. І правильний запит має такий вигляд:
POST /users/my_email@shop.com/reminder-email
11. Яким буде URI для копіювання (клонування) книги? Мало хто знає, що в специфікації HTTP є метод COPY. І в найбільш RESTful варіанті ми саме його і використовуємо:
COPY /books/100
Але на практиці більше застосовують іншу конвенцію з більш популярним методом POST-виду POST /books/100 або POST /books?source=100
Такий варіант існує в в SendGrid API.
Висновок
Ми з вами розібрали найбільш популярні операції з REST API. Як ви могли бачити з прикладів, дуже часто для кожної операції є кілька альтернативних варіантів реалізації. Але я закликаю всіх використовувати сформовані конвенції, які випливають з REST constraints, HTTP-специфікації та сформованих best practices.
Найкращі коментарі пропустити