|
|
|
@@ -1 +1,153 @@
|
|
|
|
|
### Работа с ошибками в HTTP API
|
|
|
|
|
### [Разработка номенклатуры URL ресурсов и операций над ними][http-api-urls-crud]
|
|
|
|
|
|
|
|
|
|
Как мы уже отмечали в предыдущих главах, стандарты 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`. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
|
|
|
|
|
|
|
|
|
|
Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов? Кэширование ответа или индикация того, что операция вычисляет результаты на лету?
|
|
|
|
|
|
|
|
|
|
Простых ответов на вопросы выше у нас, к сожалению, нет (особенно если мы добавим к ним механики логирования и построения мониторингов по URL запроса). В рамках настоящей книги мы придерживаемся следующего подхода:
|
|
|
|
|
|
|
|
|
|
* сигнатура вызова в первую очередь должна быть лаконична и читабельна; усложнение сигнатур в угоду абстрактным концепциям нежелательно;
|
|
|
|
|
* иерархия ресурсов выдерживается там, где она однозначна (т.е., если сущность низшего уровня абстракции однозначно подчинена сущности высшего уровня абстракции, то отношения между ними будут выражены в виде вложенных путей);
|
|
|
|
|
* если есть сомнения в том, что иерархия в ходе дальнейшего развития API останется неизменной, лучше завести новый верхнеуровневый префикс, а не вкладывать новые сущности в уже существующие;
|
|
|
|
|
* семантика HTTP-глагола приоритетнее ложного предупреждения о небезопасности/неидемпотентности (в частности, если операция является безопасной, но ресурсозатратной, с нашей точки зрения вполне разумно использовать метод `POST` для индикации этого факта);
|
|
|
|
|
* для выполнения кросс-доменных операций предпочтительнее завести специальный ресурс, выполняющий операцию (т.е. в примере с кофе-машинами и рецептами автор этой книги выбрал бы вариант `/prepare?coffee_machine_id=<id>&recipe=lungo`).
|
|
|
|
|
|
|
|
|
|
**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 мы бы такой подход не рекомендовали.
|
|
|
|
|
|
|
|
|
|
**NB**: passing variables as either query parameters or path fragments affects not only readability. If gateway D is implemented as a stateless proxy with a declarative configuration, then receiving a request like:
|
|
|
|
|
* `GET /v1/state?user_id=<user_id>`
|
|
|
|
|
|
|
|
|
|
and transforming it into a pair of nested sub-requests:
|
|
|
|
|
|
|
|
|
|
* `GET /v1/profiles?user_id=<user_id>`
|
|
|
|
|
* `GET /v1/orders?user_id=<user_id>`
|
|
|
|
|
|
|
|
|
|
would be much more convenient than extracting identifiers from the path or some header and putting them into query parameters. The former operation [replacing one path with another] is easily described declaratively and is supported by most server software out of the box. On the other hand, retrieving data from various components and rebuilding requests is a complex functionality that most likely requires a gateway supporting scripting languages and/or plugins for such manipulations. Conversely, automated creation of monitoring panels in services like Prometheus+Grafana bundle is much easier to organize by path prefix than by a synthetic key computed from request parameters.
|
|
|
|
|
|
|
|
|
|
All this leads us to the conclusion than maintaining an identical URL structure when only the path changes while custom parameters are passed in queries will lead to an even more uniform interface, although less readable and semantic. In internal systems, preferring convenience of usage over readability is sometimes an obvious decision. In public APIs, we would rather discourage implementing this approach.
|
|
|
|
|
|
|
|
|
|
#### CRUD-операции
|
|
|
|
|
|
|
|
|
|
Одно из самых популярных приложений HTTP API — это реализация CRUD-интерфейсов. Акроним CRUD (**C**reate, **R**ead, **U**pdate, **D**elete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD-операций соответствует один из глаголов HTTP:
|
|
|
|
|
* операции создания — создание ресурса через метод `POST`;
|
|
|
|
|
* операции чтения — возврат представления ресурса через метод `GET`;
|
|
|
|
|
* операции редактирования — перезапись ресурса через метод `PUT` или редактирование через `PATCH`;
|
|
|
|
|
* операции удаления — удаление ресурса через метод `DELETE`.
|
|
|
|
|
|
|
|
|
|
Фактически, подобное соответствие — это просто мнемоническое правило, позволяющее определить, какой глагол следует использовать к какой операции. Мы, однако, должны предостеречь читателя: глагол следует выбирать по его семантике согласно стандарту, а не по мнемоническим правилам. Может показаться, что, например, операцию удаления 3-го элемента списка нужно реализовать через `DELETE`:
|
|
|
|
|
* `DELETE /v1/list/{list_id}/?position=3 HTTP 1.1`
|
|
|
|
|
но, как мы помним, делать так категорически нельзя: во-первых, такой вызов неидемпотентен; во-вторых, нарушает требование консистентности `GET` и `DELETE`.
|
|
|
|
|
|
|
|
|
|
С точки зрения удобства разработки концепция выглядит очень удобной — каждому виду ресурсов соответствует свой URL, каждой операции — свой глагол. При пристальном рассмотрении, однако, оказывается, что отношение CRUD-операция / HTTP-глагол — очень упрощённое представление о манипуляции ресурсами, и, что самое неприятное, плохо расширяемое.
|
|
|
|
|
|
|
|
|
|
##### Создание
|
|
|
|
|
|
|
|
|
|
Начнём с операции создания ресурса. Как мы помним из предыдущих глав, операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
|
|
|
|
|
|
|
|
|
|
1. Через метод `POST` с передачей токена идемпотентности (им может выступать, в частности, ревизия ресурса):
|
|
|
|
|
```
|
|
|
|
|
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}/status
|
|
|
|
|
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`) и симметричную операцию чтения статуса черновика (через которую клиент должен будет получать актуальную ревизию для подтверждения черновика).
|
|
|
|
|
3. URL заказа (`GET /v1/orders/{id}`).
|
|
|
|
|
|
|
|
|
|
##### Чтение
|
|
|
|
|
|
|
|
|
|
Идём дальше. Операция чтения на первый взгляд не вызывает сомнений:
|
|
|
|
|
* `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 более сложных структур, чем пары ключ-значение, не существует.
|
|
|
|
|
|
|
|
|
|
Довольно скоро мы придём к тому, что, наряду с доступом по идентификатору заказа потребуется ещё и, во-первых, способ строго перебрать все заказы и способ искать по нестрогому совпадению.
|
|
|
|
|
|
|
|
|
|
Кроме того, если к заказу можно прикладывать какие-то медиа-данные (скажем, фотографии), то для доступа к ним придётся разработать отдельные URL:
|
|
|
|
|
* `GET /v1/orders/{order_id}/attachements/{id}`
|
|
|
|
|
|
|
|
|
|
##### Редактирование
|
|
|
|
|
|
|
|
|
|
Вопросы частичного редактирования мы подробно разбирали в соответствующей главе раздела «Паттерны API». Идея полной перезаписи ресурса методом `PUT` быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным, для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
|
|
|
|
|
* либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции;
|
|
|
|
|
* либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение.
|
|
|
|
|
|
|
|
|
|
Если к сущности прилагаются медиаданные, для их редактирования также придётся разработать отдельные эндпойнты.
|
|
|
|
|
|
|
|
|
|
##### Удаление
|
|
|
|
|
|
|
|
|
|
С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо `DELETE /v1/orders/{id}` необходимо разработать эндпойнт типа `PUT /v1/orders/{id}/archive` или `PUT /v1/archive?order=<order_id>`.
|
|
|
|
|
|
|
|
|
|
Таким образом, идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла. Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция. Если вы всё же хотите разработать типовой API для манипуляции типовыми сущностями, стоит изначально разработать его гораздо более гибким, чем предлагает CRUD-HTTP методология.
|