Модуль 1 · API как контракт

Справочник «Стандартизация HTTP API» · модуль 1 · API как контракт

Глава 1.3

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 на элемент означал бы «создай внутри конкретного обращения», и такой смысл нужно специально объяснять.

GET /ticketsСтраница обращений. Ответ — объект с полями items, total, limit, offset, а не голый массив: так в ответ можно добавить сведения о выборке.коллекция
POST /ticketsСоздание. Идентификатор выдаёт сервер, адрес нового обращения возвращается в заголовке Location.коллекция
GET /tickets/{id}Одно обращение. Если его нет — 404 с телом в формате Problem Details.элемент
PATCH /tickets/{id}Изменение переданных полей. Остальные свойства не трогаются.элемент
DELETE /tickets/{id}Удаление. Успех — 204 без тела: возвращать больше нечего.элемент
Почему список завёрнут в объект

Ответ {"items": [...], "total": 3, "limit": 20, "offset": 0} кажется избыточным по сравнению с массивом. Но массив некуда расширять: добавить к нему число найденных или ссылку на следующую страницу нельзя, не сломав клиентов. Объект оставляет место для таких дополнений.

§3В адресе только существительные

Чаще всего в адресах нарушают именно это правило — записывают в путь действие:

плохоДействие в пути
  • GET /getTickets
  • POST /createTicket
  • POST /ticket/delete
  • GET /deleteUser
хорошоСуществительное + метод
  • GET /tickets
  • POST /tickets
  • DELETE /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. Клиенту придётся проверять длину массива вместо того, чтобы посмотреть на код ответа.

path parameterЧасть адреса, без которой ресурс не определён: /tickets/{ticket_id}. Изменяется от объекта к объекту, но структура пути остаётся прежней.что это
query parameterУточнение выборки: фильтр 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 выдаёт сам сервис, не спрашивая никого.

Почему 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.

.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]*(/|$)"

Проверим этим правилом описание, в котором адреса нарушают договорённость. Вывод ниже — настоящий результат запуска линтера:

npx @stoplight/spectral-cli lint openapi.yaml
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. Адрес коллекции читается как «все обращения», поэтому имя во множественном числе.
Идентификатор в query/tickets?id=… возвращает пустой список вместо 404 и лишает клиента кода ответа.
Глубокая вложенностьПять уровней в пути заставляют клиента хранить цепочку идентификаторов ради одного объекта.
Порядковые идентификаторыНомера 41, 42, 43 позволяют перебрать чужие объекты, если права проверяются не на каждом запросе.

§9Проверьте себя

  1. Выберите предметную область и назовите в ней три ресурса. Для каждого запишите адрес коллекции и адрес элемента.
  2. Почему GET /deleteUser опасен не только стилистически? Назовите последствие, связанное с посредниками.
  3. Что вернёт GET /tickets?id=… для несуществующего обращения и чем это хуже, чем 404?
  4. Когда связь между ресурсами выражают вложенным адресом, а когда — параметром фильтра?
  5. Найдите в своём черновике контракта адрес, который нарушает правило paths-no-verbs, и исправьте его.
Ресурссуществительноеколлекция: /ticketsэлемент: /tickets/{id}
Методы по адресамколлекция: GET, POSTэлемент: GET, PATCH, DELETE
Где чтоpath — идентификаторquery — фильтр, limit, offset
ИдентификаторUUID, не 1, 2, 3права проверяем всегда

Справочник «Стандартизация HTTP API» · модуль 1 · глава 1.3Макаров Максим Николаевич

Кадр