Это глава 38 раздела «HTTP API & REST» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

Как мы уже отмечали в предыдущих главах, стандарты HTTP и URL, а также принципы REST, не предписывают определённой семантики значимым компонентам URL (в частности, частям path и парам ключ‑значение в query). Правила организации URL в HTTP API существуют только для читабельности кода и удобства разработчика. Что, впрочем, совершенно не означает, что они неважны: напротив, URL в HTTP API являются средством выразить уровни абстракции и области ответственности объектов. Правильный дизайн иерархии сущностей в API должен быть отражён в правильном дизайне номенклатуры URL.

NB: отсутствие строгих правил естественным образом привело к тому, что многие разработчики их просто придумали сами для себя. Некоторые наиболее распространённые стихийные практики, например, требование использовать в URL только существительные, в советах по разработке HTTP API в Интернете часто выдаются за стандарты или требования REST, которыми они не являются. Тем не менее, демонстративное игнорирование таких самопровозглашённых правил тоже не лучший подход для провайдера API, поскольку он увеличивает шансы быть неверно понятым.

Традиционно частям URL приписывается следующая семантика:

  • части path (фрагменты пути между символами /) используются для организации вложенных сущностей вида /partner/{id}/coffee-machines/{id}; при этом путь часто может наращиваться, т. е. к конкретному пути продолжают приписываться новые суффиксы, указывающие на подчинённые ресурсы;

  • query используется для организации нестрогой иерархии (отношений «многие ко многим», например /recipes/?partner=<partner_id>) либо как способ передать параметры операции (/search/?recipe=lungo).

Подобная конвенция достаточно хорошо подходит для того, чтобы отразить номенклатуру сущностей почти любого API, поэтому следовать ей вполне разумно (и, наоборот, демонстративное нарушение этого устоявшегося соглашения чревато тем, что разработчики вас просто неправильно поймут). Однако подобная некодифицированная и размытая концепция неизбежно вызывает множество разночтений в конкретных моментах:

  1. Каким образом организовывать эндпойнты, связывающие две сущности, между которыми нет явных отношений подчинения? Скажем, каким должен быть URL запуска приготовления лунго на конкретной кофе‑машине?

    • /coffee-machines/{id}/recipes/lungo/prepare

    • /recipes/lungo/coffee-machines/{id}/prepare

    • /coffee-machines/{id}/prepare?recipe=lungo

    • /recipes/lungo/prepare?coffee_machine_id=<id>

    • /prepare?coffee_machine_id=<id>&recipe=lungo

    • /?action=prepare&coffee_machine_id=<id>&recipe=lungo

    Все эти варианты семантически вполне допустимы и в общем‑то равноправны.

  2. Насколько строго должна выдерживаться буквальная интерпретация конструкции ГЛАГОЛ /ресурс? Если мы принимаем правило «части URL обязаны быть существительными» (и ведь странно применять глагол к глаголу!), то в примерах выше должно быть не prepare, а preparator или preparer (а вариант /action=prepare&coffee_machine_id=<id>&recipe=lungo вовсе недопустим, так как нет объекта действия), что, честно говоря, лишь добавляет визуального шума в виде суффиксов «ator», но никак не способствует большей лаконичности и однозначности понимания.

  3. Если сигнатура вызова по умолчанию модифицирующая или неидемпотентная, означает ли это, что операция обязана  быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации API. Рассмотрим, например, ресурс /v1/search, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?

    • С одной стороны, GET /v1/search?query=<поисковый запрос> позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL).

    • С другой стороны, согласно семантике операции, GET /v1/search должен возвращать представление ресурса search. Но разве результаты поиска являются представлением ресурса‑поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т. е. соответствует методу POST. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.

    Иными словами, для любых операций, результат которых представляет собой результат работы какого‑то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов? Кэширование ответа или индикация того, что операция вычисляет результаты на лету?

    NB: эта дихотомия волнует не только нас, но и авторов стандарта, которые в конечном итоге предложили новый глагол QUERY, который по сути является немодифицирующим POST. Мы, однако, сомневаемся, что он получит широкое распространение — поскольку уже существующий SEARCH оказался в этом качестве никому не нужен.

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

  • сигнатура вызова в первую очередь должна быть лаконична и читабельна; усложнение сигнатур в угоду абстрактным концепциям нежелательно;

  • иерархия ресурсов выдерживается там, где она однозначна (т. е., если сущность низшего уровня абстракции однозначно подчинена сущности высшего уровня абстракции, то отношения между ними будут выражены в виде вложенных путей);

    • если есть сомнения в том, что иерархия в ходе дальнейшего развития API останется неизменной, лучше завести новый верхнеуровневый префикс, а не вкладывать новые сущности в уже существующие;

  • для выполнения «кросс‑доменных» операций (т. е. при необходимости сослаться на объекты разных уровней абстракции в одном вызове) предпочтительнее завести специальный ресурс, выполняющий операцию (т. е. в примере с кофе‑машинами и рецептами автор этой книги выбрал бы вариант /prepare?coffee_machine_id=<id>&recipe=lungo);

  • семантика HTTP‑глагола приоритетнее ложного предупреждения о небезопасности/неидемпотентности (в частности, если операция является безопасной, но ресурсозатратной, с нашей точки зрения вполне разумно использовать метод POST для индикации этого факта).

NB: отметим, что передача параметров в виде пути или query‑параметра в URL влияет не только на читабельность. Вернёмся к примеру из предыдущей главы и представим, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией. Тогда получать от клиента запрос в виде:

  • GET /v1/state?user_id=<user_id>

    и преобразовывать в пару вложенных запросов

  • GET /v1/profiles?user_id=<user_id>

  • GET /v1/orders?user_id=<user_id>

    гораздо удобнее, чем извлекать идентификатор из path и преобразовывать его в query‑параметр. Первую операцию [замена одного path целиком на другой] достаточно просто описать декларативно, и в большинстве ПО для веб‑серверов она поддерживается из коробки. Напротив, извлечение данных из разных компонентов и полная пересборка запроса — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana (да и в целом любой инструмент разбора логов) гораздо проще организовать по path, чем вычленять из данных запроса какой‑то синтетический ключ группировки запросов.

    Всё это приводит нас к соображению, что поддержание одинаковой структуры URL, в которой меняется только путь или домен, а параметры всегда находятся в query и именуются одинаково, приводит к ещё более унифицированному интерфейсу, хотя бы и в ущерб читабельности и семантичности URL. Во многих внутренних системах выбор в пользу удобства выглядит самоочевидным, хотя во внешних API мы бы такой подход не рекомендовали.

CRUD-операции

Одно из самых популярных приложений HTTP API — это реализация CRUD‑интерфейсов. Акроним CRUD (Create, Read, Update, Delete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD‑операций соответствует один из глаголов HTTP:

  • операции создания — создание ресурса через метод POST;

  • операции чтения — возврат представления ресурса через метод GET;

  • операции редактирования — перезапись ресурса через метод PUT или редактирование через PATCH;

  • операции удаления — удаление ресурса через метод DELETE.

NB: фактически, подобное соответствие — это просто мнемоническое правило, позволяющее определить, какой глагол следует использовать к какой операции. Мы, однако, должны предостеречь читателя: глагол следует выбирать по его семантике согласно стандарту, а не по мнемоническим правилам. Может показаться, что, например, операцию удаления 3-го элемента списка нужно реализовать через DELETE:

  • DELETE /v1/list/{list_id}/?position=3

    но, как мы помним, делать так категорически нельзя: во-первых, такой вызов неидемпотентен; во-вторых, нарушает требование консистентности GET и DELETE.

С точки зрения удобства разработки концепция соответствия CRUD и HTTP выглядит очень удобной — каждому виду ресурсов соответствует свой URL, каждой операции — свой глагол. При пристальном рассмотрении, однако, оказывается, что это отношение — очень упрощённое представление о манипуляции ресурсами, и, что самое неприятное, плохо расширяемое.

1. Создание

Начнём с операции создания ресурса. Как мы помним из главы «Стратегии синхронизации«, операция создания в любой сколько‑нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:

  1. Через метод POST с передачей токена идемпотентности (им может выступать, в частности, ETag ресурса):

    POST /v1/orders/?user_id=<user_id> HTTP/1.1
    If-Match: <ревизия>
    
    { … }
  2. Через метод PUT, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):

    PUT /v1/orders/{order_id} HTTP/1.1
    If-Match: <ревизия>
    
    { … }
  3. Через схему создания черновика методом POST и его подтверждения методом PUT:

    POST /v1/drafts HTTP/1.1
    
    { … }
    →
    HTTP/1.1 201 Created
    Location: /v1/drafts/{id}
    PUT /v1/drafts/{id}/commit
    If-Match: <ревизия>
    
    {"status": "confirmed"}
    →
    HTTP/1.1 200 OK
    Location: /v1/orders/{id}

Метод (2) в современных системах используется редко, так как вынуждает доверять правильности генерации идентификатора заказа клиентом. Если же рассматривать варианты (1) и (3), то необходимо отметить, что семантике протокола вариант (3) соответствует лучше, так как POST‑запросы по умолчанию считаются неидемпотентными, и их автоматический повтор в случае получения сетевого таймаута или ошибки сервера будет выглядеть для постороннего наблюдателя опасной операцией (которой запрос и правда может стать, если сервер изменит политику проверки заголовка If-Match на более мягкую). Повтор PUT‑запроса (а мы предполагаем, что таймауты и серверные ошибки на «тяжёлой» операции создания заказа намного более вероятны, чем на «лёгкой» операции создания черновика) вполне может быть автоматизирован, и не будет создавать дубликаты заказа, даже если проверка ревизии будет отключена вообще. Однако теперь вместо двух URL и двух операций (POST /v1/orders — GET /v1/orders/{id}) мы имеем четыре URL и пять операций:

  1. URL создания черновика (POST /v1/drafts), который дополнительно потребует существования URL последнего черновика и/или списка черновиков пользователя (GET /v1/drafts/?user_id=<user_id> или что-то аналогичное).

  2. URL подтверждения черновика (PUT /v1/drafts/{id}/status) и, может быть, симметричную операцию чтения статуса черновика для получения актуальной ревизии (хотя эндпойнт GET /v1/drafts, описанный выше, для этого подходит лучше).

  3. URL заказа (GET /v1/orders/{id}).

2. Чтение

Идём дальше. Операция чтения на первый взгляд не вызывает сомнений:

  • GET /v1/orders/{id}.

Стоит, однако, присмотреться внимательнее, и всё оказывается не так просто. Клиент как минимум должен обладать способом выяснить, какие заказы сейчас выполняются от его имени, что требует создания отдельного ресурса‑поисковика:

  • GET /v1/orders/?user_id=<user_id>.

Передача списков без ограничений по их длине — потенциально плохая идея, а значит необходимо ввести поддержку пагинации:

  • GET /v1/orders/?user_id=<user_id>&cursor=<cursor>.

Если заказов много, наверняка пользователю понадобятся фильтры, скажем, по названию напитка:

  • GET /v1/orders/?user_id=<user_id>&recipe=lungo.

Однако, если пользователь захочет видеть в одном списке и латте и лунго, этот интерфейс уже окажется ограниченно применимым, поскольку общепринятого стандарта передачи в URL более сложных структур, чем пары ключ‑значение, не существует. Довольно скоро мы придём к тому, что, наряду с доступом по идентификатору заказа потребуется ещё и поисковый эндпойнт со сложной семантикой (которую гораздо удобнее было бы разместить за POST):

  • POST /v1/orders/search { /* parameters */ }

Кроме того, если к заказу можно прикладывать какие-то медиа-данные (скажем, фотографии), то для доступа к ним придётся разработать отдельные URL:

  • GET /v1/orders/{order_id}/attachments/{id}

3. Редактирование

Проблемы частичного обновления ресурсов мы подробно разбирали в соответствующей главе раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом PUT возможна, но быстро разбивается о двусмысленность работы с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Редактирование через метод PATCH возможно, но, так как этот метод по умолчанию считается неидемпотентным (и часто нетразитивным), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:

  • либо PUT декомпозирован на множество составных PUT /v1/orders/{id}/address, PUT /v1/orders/{id}/volume и т. д. — по ресурсу для каждой частной операции;

  • либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик‑подтверждение в виде пары методов POST + PUT.

Если к сущности прилагаются медиаданные, для их редактирования также придётся разработать отдельные эндпойнты.

4. Удаление

С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо DELETE /v1/orders/{id} необходимо разработать эндпойнт типа PUT /v1/orders/{id}/archive или PUT /v1/archive?order=<order_id>.

В качестве заключения

Идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла.

Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция. Если вы всё же хотите разработать типовой API для манипуляции типовыми сущностями, стоит изначально разработать его гораздо более гибким, чем предлагает CRUD‑HTTP методология.

Комментарии (5)


  1. nronnie
    26.06.2023 10:18

    Для сложных поисков я бы делал общий для всех сущностей endpoint /api/search, и работал бы с ним по схеме POST/GET:

    • на URL /api/search отправляем POST со сложным запросом (возможно включающем в себя тип сущности);

    • сервер сохраняет запрос в каком-то хранилище под уникальным идентификатором id;

    • сервер в качестве результата POST отправляет клиенту id;

    • клиент отправляет GET на /api/search/{id};

    • сервер получает по id параметры ранее сохраненного запроса из хранилища, выполняет его и отправляет клиенту результат этого выполнения;

    Несмотря на дополнительные накладные расходы (два HTTP-запроса вместо одного и работа с хранилищем) такой подход дает много преимуществ, например, кеширование поиска, история запросов, возможность последующего анализа и т.д., плюс хорошо укладывается в идеологию ресурсов - в данном случае:

    • "поисковой запрос" это сам отдельный ресурс

    • /api/search/ - контейнер этого ресурса

    • /api/search/{id} - URI отдельного ресурса

    • POST /api/search/ - глагол "создать ресурс"

    • GET /api/search/{id} - глагол "получить представление ресурса c идентификатором {id}".


    1. forgotten Автор
      26.06.2023 10:18

      Этот паттерн я разбирал в главе «Асинхронность и управление временем»

      https://habr.com/ru/articles/732646/

      Да, он вполне возможен с точки зрения архитектуры (хотя не совсем REST-way с той точки зрения, что id — черный ящик, по нему невозможно понять, что это была за операция).


      1. nronnie
        26.06.2023 10:18

        Ну почему же "черный ящик". Вполне прозрачно: /container/subcontainer/susbsubcontainer/{id} - id это идентификатор ресурса внутри контейнера /container/subcontainer/susbsubcontainer/, соответственно полный path это полный идентификатор ресурса. А операция над ним (глагол) определяется по HTTP verb (кстати в английском "verb" это как раз и означает "глагол"): GET, PUT, DELETE.

        В общем-то, как вы совершенно правильно написали, на REST нет какого-то официального стандарта, но, есть т.н. "REST maturity model" описанный, например в "REST in Practice" (отличная, кстати, книга) - согласно нему "все системы, по сути, REST, но некоторые более REST чем другие" :)

        Я, в общем-то, адепт такого подхода, когда полный path это всегда какой-то (уникальный) ресурс (или контейнер ресурсов, впрочем, контейнер ведь это тоже частный случай ресурса), HTTP-verb это операция над ресурсом, а query string (если он есть) это какие либо "аспекты" этой операции (например, выбор того какое представление ресурса вернуть).


  1. nronnie
    26.06.2023 10:18

    Неплохо было бы еще написать статью по "правильному" использованию в REST "HTTP status code". Потому что с этим повсюду совсем беда-беда. Чего только не насмотришься - видел как вообще на все что угодно возвращали "200", в случае ошибки возвращая её при этом в теле ответа, или наоборот на любую ошибку возвращали исключительно "500".


    1. forgotten Автор
      26.06.2023 10:18

      Это следующая глава ;)