Справочник «Стандартизация HTTP API» · модуль 1 · API как контракт
Глава 1.3
Ресурсы
и адреса
Адрес ресурса — та часть контракта, которую разработчик клиента видит раньше всего. В этой главе разобрано, что считать ресурсом, чем адрес коллекции отличается от адреса элемента, почему действие не пишут в путь и где должен находиться идентификатор.
- Категория
- Проектирование контракта
- Уровень
- Начальный
- Связанные главы
- 1.2 Один обмен · 2.2 Методы · 2.4 Параметры · 6.2 Линтер Spectral
- Зачем это знать
- Чтобы клиент угадывал третий адрес вашего API, зная первые два
§1Ресурс — то, у чего есть адрес
В повседневной речи «ресурс» означает запас чего-либо. В HTTP это слово значит другое: именованная вещь, о которой можно говорить с сервером. Если вещи можно дать адрес и сказать про неё «получи», «создай», «измени», «удали» — это ресурс.
Сущность предметной области, которой API выделил адрес (URI). Клиент работает не с самим объектом на сервере, а с его представлением — например, с документом JSON, полученным по этому адресу.
Ресурсы находят в предметной области, а не в коде. В службе поддержки это обращение. В интернет-магазине — товар, заказ, корзина. В библиотеке — книга и выдача. В расписании занятий — занятие, аудитория, группа. Хорошая проверка: если вы можете описать вещь фразой «список таких-то» и «конкретный такой-то» — перед вами ресурс.
| Область | Ресурсы | Адрес коллекции |
|---|---|---|
| Служба поддержки | обращение, комментарий, исполнитель | /api/v1/tickets |
| Библиотека | книга, читатель, выдача | /api/v1/books |
| Кинотеатр | фильм, сеанс, бронь | /api/v1/screenings |
| Склад | позиция, поставка, списание | /api/v1/items |
Обратите внимание: в таблице нет ресурсов «авторизация», «поиск», «отчёт». Это действия, а не вещи. Для них тоже находят форму — обычно это либо параметр коллекции (?status=open), либо отдельный ресурс результата. Если же начинать проектирование с действий, в адресах появляются глаголы: /doSearch и подобные.
§2Коллекция и элемент
У ресурса два вида адресов, и они означают разное. Адрес коллекции обозначает все объекты сразу, адрес элемента — один конкретный.
/api/v1/tickets # коллекция: все обращения /api/v1/tickets/{ticket_id} # элемент: одно обращение
От вида адреса зависит, какие методы уместны. На коллекцию отправляют GET (получить список) и POST (создать новый объект). К элементу обращаются GET, PATCH и DELETE. Метод POST на элемент означал бы «создай внутри конкретного обращения», и такой смысл нужно специально объяснять.
items, total, limit, offset, а не голый массив: так в ответ можно добавить сведения о выборке.коллекцияLocation.коллекция404 с телом в формате Problem Details.элемент204 без тела: возвращать больше нечего.элементОтвет {"items": [...], "total": 3, "limit": 20, "offset": 0} кажется избыточным по сравнению с массивом. Но массив некуда расширять: добавить к нему число найденных или ссылку на следующую страницу нельзя, не сломав клиентов. Объект оставляет место для таких дополнений.
§3В адресе только существительные
Чаще всего в адресах нарушают именно это правило — записывают в путь действие:
GET /getTicketsPOST /createTicketPOST /ticket/deleteGET /deleteUser
GET /ticketsPOST /ticketsDELETE /tickets/{id}DELETE /users/{id}
Глагол в адресе имеет три следствия. Первое: контракт начинает противоречить сам себе — GET /deleteUser обещает чтение методом и удаление словом. Второе: клиент не может предсказать адреса. Узнав /getTickets и /createTicket, он не выведет из них имя третьей операции — вариантов слишком много. Третье: посредники между клиентом и сервером принимают решения по методу. Кэш вправе повторно отдать ответ на GET, а прокси — повторить неудавшийся GET. Если за GET скрыто удаление, повтор удалит второй объект.
Проверка адреса на слух: прочитайте его вслух без метода. Если получилось предложение с глаголом — адрес придётся исправить.
§4Путь или параметр: где чему место
Идентификатор ресурса — часть адреса. Условия выборки — параметры после знака вопроса. Смешение этих ролей ломает смысл адреса.
GET /api/v1/tickets/8d45c3e2-7f1a-4c55-9a39-2b1f0e6d4a10 GET /api/v1/tickets?status=open&limit=20&offset=0
GET /api/v1/tickets?id=8d45c3e2-… # адрес указывает на коллекцию, # а имелся в виду один объект
Первый запрос читается как «дай обращение с таким идентификатором»: адрес указывает на конкретную вещь. Второй читается как «дай коллекцию, но отфильтруй по идентификатору» — и обращение к несуществующему объекту вернёт пустой список вместо 404. Клиенту придётся проверять длину массива вместо того, чтобы посмотреть на код ответа.
/tickets/{ticket_id}. Изменяется от объекта к объекту, но структура пути остаётся прежней.что этоstatus, размер страницы limit, смещение offset, порядок сортировки. Убрали параметр — запрос остался осмысленным.какая частьРазличают их так: если без части адреса запрос теряет смысл, это path parameter. Если без неё запрос по-прежнему осмыслен, но возвращает больше данных, это query parameter.
§5Идентификатор: почему не 1, 2, 3
В учебном проекте идентификатор обращения — UUID вида 8d45c3e2-7f1a-4c55-9a39-2b1f0e6d4a10, а не порядковый номер. Причин две, и обе относятся к контракту, а не к базе данных.
Первая — безопасность. Последовательные номера подсказывают чужие адреса: увидев /tickets/42, легко перебрать соседние. Если сервер не проверяет права на каждый объект, перебор даст чужие обращения. Этот класс уязвимостей стоит первым в списке OWASP API Security Top 10 и называется Broken Object Level Authorization. Переход на UUID не заменяет проверку прав, но убирает возможность угадать адрес.
Вторая — независимость от хранилища. Порядковый номер обычно приходит из счётчика в базе. Как только сервис разделится на два экземпляра или база сменится, счётчик станет проблемой. UUID выдаёт сам сервис, не спрашивая никого.
Угадать адрес трудно, но его можно узнать: из логов, из ссылки в письме, из отладочной панели браузера. Поэтому сервер обязан проверять права на объект при каждом обращении, независимо от вида идентификатора.
§6Вложенность и её границы
Ресурсы бывают связаны: у пользователя есть обращения, у обращения — комментарии. Такие связи выражают вложенным адресом:
GET /api/v1/users/{user_id}/tickets # обращения этого пользователя
GET /api/v1/tickets/{ticket_id}/comments # комментарии этого обращенияВложенность удобна, пока она короткая. Адрес из пяти уровней /companies/{id}/departments/{id}/users/{id}/tickets/{id}/comments заставляет клиента знать всю цепочку идентификаторов ради одного комментария. Если комментарий существует сам по себе, у него должен быть и собственный адрес.
| Правило | Как выглядит | Почему так |
|---|---|---|
| Не больше двух уровней | /users/{user_id}/tickets | клиенту нужен один идентификатор, а не цепочка |
| У вложенного ресурса есть свой адрес | /comments/{comment_id} | комментарий можно открыть по прямой ссылке |
| Фильтр вместо вложенности | /tickets?assignee_id=… | связь «многие ко многим» не помещается в путь |
§7Проверка адресов правилом
Правило «в адресе только существительные» живёт в файле правил проекта — и там же превращается в машинную проверку. В учебном проекте это правило paths-no-verbs в .spectral.yaml.
paths-no-verbs:
description: URI MUST описывать ресурс существительным, а не действием.
severity: error
given: "$.paths"
then:
field: "@key"
function: pattern
functionOptions:
notMatch: "/(get|create|update|delete|remove|add)[A-Za-z]*(/|$)"Проверим этим правилом описание, в котором адреса нарушают договорённость. Вывод ниже — настоящий результат запуска линтера:
10:22 error paths-no-verbs URI MUST описывать ресурс существительным,
а не действием. paths./api/v1/getTickets
11:9 error operation-operationId Operation must have "operationId".
47:19 error json-properties-snake-case Свойства JSON MUST быть записаны в snake_case.
✖ 9 problems (7 errors, 2 warnings, 0 infos, 0 hints)Линтер называет строку, адрес и нарушенное правило и не даёт объединить изменение, пока адрес не исправлен. Записанная, но не проверяемая договорённость таких сообщений не даёт.
§8Частые ошибки
/getTickets, /createTicket, /ticket/delete. Действие уже сказано методом; в адресе остаётся существительное./ticket вместо /tickets. Адрес коллекции читается как «все обращения», поэтому имя во множественном числе./tickets?id=… возвращает пустой список вместо 404 и лишает клиента кода ответа.§9Проверьте себя
- Выберите предметную область и назовите в ней три ресурса. Для каждого запишите адрес коллекции и адрес элемента.
- Почему
GET /deleteUserопасен не только стилистически? Назовите последствие, связанное с посредниками. - Что вернёт
GET /tickets?id=…для несуществующего обращения и чем это хуже, чем404? - Когда связь между ресурсами выражают вложенным адресом, а когда — параметром фильтра?
- Найдите в своём черновике контракта адрес, который нарушает правило
paths-no-verbs, и исправьте его.
существительноеколлекция: /ticketsэлемент: /tickets/{id}коллекция: GET, POSTэлемент: GET, PATCH, DELETEpath — идентификаторquery — фильтр, limit, offsetUUID, не 1, 2, 3права проверяем всегдаСправочник «Стандартизация HTTP API» · модуль 1 · глава 1.3Макаров Максим Николаевич