1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-31 22:09:37 +02:00

HTTP API drafts complete

This commit is contained in:
Sergey Konstantinov 2023-02-24 22:36:09 +02:00
parent baf2d5cd88
commit 10b3e53a7b
5 changed files with 91 additions and 33 deletions

View File

@ -1,4 +1,4 @@
### Составляющие протокола HTTP и их семантика
### Составляющие HTTP запросов и их семантика
Третье важное подготовительное упражнение, которое мы должны сделать — это дать описание формата HTTP-запросов и ответов и прояснить базовые понятия. Многое из написанного ниже может показаться читателю самоочевидным, но, увы, специфика протокола такова, что даже базовые сведения о нём, без которых мы не сможем двигаться дальше, разбросаны по обширной и фрагментированной документации. Ниже мы попытаемся дать структурированный обзор протокола в том объёме, который необходим нам для проектирования HTTP API.
@ -41,13 +41,12 @@ URL — единица адресации в HTTP API (некоторые ева
Формат URL регулируется [отдельным стандартом](https://url.spec.whatwg.org/), который развивает независимое сообщество Web Hypertext Application Technology Working Group (WHATWG). Считается, что концепция URL (вместе с понятием универсального имени ресурса, URN) составляет более общую сущность URI (универсальный идентификатор ресурса). (Разница между URL и URN заключается в том, что URL позволяет *найти* некоторый ресурс в рамках некоторого протокола доступа, в то время как URN — «внутреннее» имя объекта, которое само по себе никак не помогает получить к нему доступ.)
URL принято раскладывать на составляющие, каждая из которых имеет свою семантику (и может отсутствовать):
URL принято раскладывать на составляющие, каждая из которых может отсутствовать:
* схема (scheme) — протокол обращения (в нашем случае всегда `https:`);
* хост (host; домен или IP-адрес), к которому обращён запрос — самая крупная единица адресации;
* домен может включать в себя поддомены;
* порт (port);
* путь (path) — часть URL между доменным именем (с портом) и символами `?`, `#` или концом строки
* путь традиционно считается способом выразить строгую иерархию сущностей; если сущность `leaf` полностью вложена в сущность `root`, этот факт отражают в виде path `/root/leaf`;
* путь принято разбивать по символу `/` и работать с каждой частью как отдельным токеном — но стандарт этого, вообще говоря, не предписывает;
* пути с символом `/` и без символа `/` в конце (скажем `/root/leaf` и `/root/leaf/`) с точки зрения стандарта являются разными (и URL, отличающиеся только наличием/отсутствием слэша, считаются разными URL), хотя практически нам неизвестны аргументы в пользу того, чтобы не считать такие пути эквивалентными;
* пути могут содержать секции `.` и/или `..`, которые предлагается трактовать по аналогии с такими же символами в путях на файловой системе (и, соответственно, считать URL `/root/leaf`, `/root/./leaf`, `/root/branch/../leaf` эквивалентными), но стандарт этого вновь не предписывает;
@ -57,7 +56,7 @@ URL принято раскладывать на составляющие, ка
* фрагмент традиционно рассматривается как адресация внутри запрошенного документа, поэтому многими агентами опускается при выполнении запроса;
* два URL с различными значениями фрагмента могут считаться одинаковыми — а могут не считаться, зависит от контекста.
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения).
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения). (Это соглашение, кстати, Филдинг считает самой большой проблемой дизайна протокола.)
**NB**: в стандарте также перечислены разнообразные исторические наслоения (например, передача логинов и паролей в URL или использование не-UTF кодировки), которые нам в рамках вопросов дизайна API неинтересны. Также стандарт содержит правила сериализации, нормализации и сравнения URL, которые в целом полезно знать разработчику HTTP API.
@ -93,7 +92,7 @@ HTTP-глагол определяет два важных свойства HTTP
| POST | Обрабатывает запрос в соответствии со своим внутренним устройством | да | нет | да |
| PATCH | Модифицирует (частично перезаписывает) ресурс согласно данным, переданным в теле запроса | да | нет | да |
Важное свойство модифицирующих идемпотентных глаголов — это то, что **URL запроса является его ключом идемпотентности**. `PUT /url` полностью перезаписывает ресурс так, что `GET /url` возвращает представление, соответствующее переданном в `PUT /url` телу (в случае JSON-over-HTTP API это, как правило, просто означает, что `GET /url` возвращает в точности тот же контент, чтобы передан в `PUT /url`). `DELETE /url` обязан удалить указанный ресурс — так, что `GET /url` должен вернуть `404` или `410`.
Важное свойство модифицирующих идемпотентных глаголов — это то, что **URL запроса является его ключом идемпотентности**. `PUT /url` полностью перезаписывает ресурс так, что `GET /url` возвращает представление, соответствующее переданном в `PUT /url` телу (в случае JSON-over-HTTP API это, как правило, просто означает, что `GET /url` возвращает в точности тот же контент, чтобы передан в `PUT /url`, с точностью до значений полей по умолчанию). `DELETE /url` обязан удалить указанный ресурс — так, что `GET /url` должен вернуть `404` или `410`.
Идемпотентность и симметричность методов `GET` / `PUT` / `DELETE` влечёт за собой запрет `GET` и `DELETE` иметь тело запроса (поскольку этому телу невозможно приписать никакой осмысленной роли). Однако (по-видимому в связи с тем, что многие разработчики попросту не знают семантику этих методов) распространённое ПО веб-серверов обычно разрешает этим методам иметь тело запроса и транслирует его дальше к коду обработки эндпойнта (использование этой практики мы решительно не рекомендуем).
@ -108,13 +107,7 @@ HTTP-глагол определяет два важных свойства HTTP
* обычно эта ошибка сочетается с наличием у запроса тела (чтобы всё-таки отличать, что конкретно нужно перезаписать или удалить), что является само по себе проблемой, так как любой сетевой агент вправе это тело проигнорировать;
* несоблюдение требования симметричности операций `GET` / `PUT` / `DELETE` (т.е., например, после выполнения `DELETE /url` операция `GET /url` продолжает возвращать какие-то данные).
При разработке HTTP API часто возникает и обратная проблема: если метод по умолчанию модифицирующий или неидемпотентный, означает ли это, что операция *обязана* быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации таких API. Рассмотрим, например, ресурс `/v1/search`, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?
* С одной стороны, `GET /v1/search` позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL, т.е. в виде path и query-параметров).
* С другой стороны, согласно семантике операции, `GET /v1/search` должен возвращать *представление ресурса* `search`. Но разве результаты поиска являются представлением ресурса-поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т.е. соответствует методу `POST`. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов. Автор этой книги склоняется к мнению, что семантика важнее (и вообще использование метода `GET` в HTTP API осмысленно, если и только если ответ можно сопроводить параметрами кэширования), но не навязывает его.
#### ### Статус-коды
#### Статус-коды
Статус-код — это численное машиночитаемое описание результата операции. Все статус-коды делятся на пять больших групп:
* `1xx` — информационные (фактически, какое-то хождение имеет разве что `100 Continue`);
@ -129,7 +122,7 @@ HTTP-глагол определяет два важных свойства HTTP
**NB**: обратите внимание на проблему дизайна спецификации. По умолчанию все `4xx` коды не кэшируются, за исключением: `404`, `405`, `410`, `414`. Мы не сомневаемся, что это было сделано из благих намерений, но подозреваем, что множество людей, знающих об этих тонкостях, примерно совпадает с множеством редакторов спецификации HTTP.
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает использовать статус-коды в нарушение стандарта и/или обогащать ответ дополнительной информацией об ошибке. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главах «Организация клиентских ошибок в HTTP API» и «Работа с серверными ошибками в HTTP API».
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает использовать статус-коды в нарушение стандарта и/или обогащать ответ дополнительной информацией об ошибке. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главе «Работа с ошибками в HTTP API».
#### Важное замечание о кэшировании
@ -139,22 +132,23 @@ HTTP-глагол определяет два важных свойства HTTP
Один и тот же параметр в разных ситуациях может находиться в разных частях запроса. Скажем, идентификатор партнёра, совершающего запрос, может быть передан:
* в имени поддомена `<partner-id>.domain.tld`;
* как часть пути `/v1/<partner-id>/orders`;
* как query-параметр `/v1/orders?partner_id=<partner-id>`;
* в имени поддомена `{partner_id}.domain.tld`;
* как часть пути `/v1/{partner_id}/orders`;
* как query-параметр `/v1/orders?partner_id=<partner_id>`;
* как заголовок
```
GET /v1/orders
X-ApiName-Partner-Id: <partner-id>
GET /v1/orders HTTP/1.1
X-ApiName-Partner-Id: <partner_id>
```
* как поле в теле запроса
```
POST /v1/orders/retrieve
POST /v1/orders/retrieve HTTP/1.1
{
"partner_id": <partner-id>
"partner_id": <partner_id>
}
```
@ -164,17 +158,17 @@ HTTP-глагол определяет два важных свойства HTTP
* некоторые виды параметров чувствительны к регистру (путь, query-параметры, имена полей в JSON), некоторые нет (домен, имена заголовков);
* при этом со *значениями* заголовков и вовсе неразбериха: часть из них по стандарту обязательно нечувствительна к регистру (в частности, `Content-Type`), а часть, напротив, обязательно чувствительна (например, `ETag`);
* наборы допустимых символов и правила экранирования также различны для разных частей запроса
* для path, например, стандарта экранирования символов `/`, `?` и `#` нет совсем;
* для path, например, стандарта экранирования символов `/`, `?` и `#` не существует;
* для разных частей запросов используется разный кейсинг:
* `kebab-case` для домена, заголовков и пути;
* `snake_case` для query-параметров;
* `snake_case` или `camelCase` для тела запроса;
При этом использование и `snake_case`, и `camelCase` для доменного имени невозможно, знак подчеркивания в доменных именах недопустим, а заглавные буквы будут приведены к строчным.
При этом использование и `snake_case`, и `camelCase` в доменном имени невозможно, так как знак подчеркивания в доменных именах недопустим, а заглавные буквы будут приведены к строчным.
Чисто теоретически возможно использование `kebab-case` во всех случаях, но в большинстве языков программирования имена переменных и полей объектов в `kebab-case` недопустимы, что приведёт к неудобству работы с таким API.
Короче говоря, ситуация с кейсингом настолько плоха и запутанна, что консистентного и удобного решения попросту нет. В этой книге мы придерживаемся следующего правила: токены даются в том кейсинге, который является общепринятым для той секции запроса, в которой находится токен; если положение токена меняется, то меняется и кейсинг. (Мы далеки от того, чтобы рекомендовать этот подход всюду; наша общая рекомендация, скорее — не умножать энтропию и пытаться минимизировать такого рода коллизии.)
**NB**. Вообще говоря, JSON исходно — это JavaScript Object Notation, а в языке JavaScript кейсинг по умолчанию - `camelCase`. Мы, тем не менее, позволим себе утверждать, что JSON уже перестал быть форматом данных, привязанным к JavaScript, и давно используется для организации взаимодействия агентов, реализованных на любых языках программирования. Использование `snake_case` по крайней мере позволяет легко перебрасывать параметр из query в тело и обратно, что, обычно, является наиболее частотным кейсом при разработке HTTP API. Впрочем, обратный вариант (использование `camelCase` в именах query-параметров) тоже допустим.
**NB**. Вообще говоря, JSON исходно — это JavaScript Object Notation, а в языке JavaScript кейсинг по умолчанию - `camelCase`. Мы, тем не менее, позволим себе утверждать, что JSON давно перестал быть форматом данных, привязанным к JavaScript, и в настоящее время используется для организации взаимодействия агентов, реализованных на любых языках программирования. Использование `snake_case`, по крайней мере, позволяет легко перебрасывать параметр из query в тело и обратно, что, обычно, является наиболее частотным кейсом при разработке HTTP API. Впрочем, обратный вариант (использование `camelCase` в именах query-параметров) тоже допустим.

View File

@ -17,7 +17,7 @@ HTTP/1.1 200 OK
}
```
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-Request-ID`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-Request-Id`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
Такая ситуация (не только конкретно с JSON-RPC, а почти со всеми высокоуровневыми протоколами поверх HTTP) сложилась по множеству причин, включая разнообразные исторические (например, невозможность использовать многие возможности HTTP из ранних реализаций `XMLHttpRequest` в браузерах). Однако, нужно отметить одно принципиальное различие между использованием протоколов уровня приложения (как в нашем примере с JSON-RPC) и чистого HTTP: если ошибка `400 Bad Request` является прозрачной для практически любого сетевого агента, то ошибка в собственном формате JSON-RPC таковой не является — во-первых, потому что понять её может только агент с поддержкой JSON-RPC, а, во-вторых, что более важно, в JSON-RPC статус запроса *не является метаинформацией*. Протокол HTTP позволяет прочитать такие детали, как метод и URL запроса, статус операции, заголовки запроса и ответа, *не читая тело запроса целиком*. Для большинства протоколов более высокого уровня, включая JSON-RPC, это не так: даже если агент и обладает поддержкой протокола, ему необходимо прочитать и разобрать тело ответа.
@ -62,7 +62,7 @@ HTTP/1.1 200 OK
3. Если использовать стандартные десериализаторы JSON, разница по сравнению с бинарными форматами может оказаться действительно очень большой. Если, однако, эти накладные расходы являются проблемой, стоит обратиться к альтернативным десериализаторам — в частности, [simdjson](https://github.com/simdjson/simdjson). Благодаря оптимизированному низкоуровневому коду simdjson показывает отличную производительность, которой может не хватить только совсем уж экзотическим API.
4. Вообще говоря, парадигма HTTP API подразумевает, что для бинарных данных (такие как изображения или видеофайлы) предоставляются отдельные эндпойнты. Передача бинарных данных в теле JSON-ответа необходима только в случаях, когда отдельный запрос за ними представляет собой проблему с точки зрения производительности. Такой проблемы фактически не существует в server-2-server взаимодействии, и даже в мобильных сетях она уже далеко не так остра.
4. Вообще говоря, парадигма HTTP API подразумевает, что для бинарных данных (такие как изображения или видеофайлы) предоставляются отдельные эндпойнты. Передача бинарных данных в теле JSON-ответа необходима только в случаях, когда отдельный запрос за ними представляет собой проблему с точки зрения производительности. Такой проблемы фактически не существует в server-2-server взаимодействии и в протоколе HTTP 2.0 и выше.
5. Протокол HTTP 1.1 действительно неидеален с точки зрения мультиплексирования запросов. Однако альтернативные парадигмы организации API для решения этой проблемы опираются… на HTTP версии 2.0. Разумеется, и HTTP API можно построить поверх версии 2.0.

View File

@ -15,9 +15,9 @@
**NB**: мы намеренно опускаем многие тонкости стандарта:
* ключ кэширования фактически является составным [включает в себя заголовки запроса], если в ответе содержится заголовок `Vary`;
* ключ идемпотентности также может быть составным, если в запросе содержится заголовок `Range`;
* политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус-кодом и другими заголовками запроса и ответа, а также политиками платформы
В рамках HTTP API использование подобных техник является скорее экзотикой, поэтому в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем.
* политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус-кодом и другими заголовками запроса и ответа, а также политиками платформы;
— в рамках HTTP API использование подобных техник является скорее экзотикой, поэтому в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем.
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
@ -38,7 +38,7 @@ Content-Type: application/json
Подобный простой API нарушает сразу несколько архитектурных принципов REST:
* нет кэширования (и оно вряд ли возможно, так как в одном ответе совмещены разнородные данные);
* операция является stateful, т.к. сервер не знает идентификатор клиента (к которому привязаны запрошенные данные), пока не проверит токен;
* операция является stateful, т.к. сервер должен хранить токены в памяти, чтобы извлечь из них идентификатор клиента (к которому привязаны запрошенные данные);
* система однослойна (и таким образом вопрос об унифицированном интерфейсе бессмыслен).
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится. Допустим, мы приняли решение декомпозировать единый бэкенд на четыре микросервиса:
@ -70,7 +70,7 @@ Content-Type: application/json
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он *не требует от (микро)сервиса обращаться за данными за пределами его области ответственности*, добившись соответствия stateless-принципу.
Вопрос о том, в чём разница между **stateless** и **stateful** подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о *логическом* принципе изоляции уровней абстракции, который мы подробно обсуждали в соответствующей главе:
Вопрос о том, в чём разница между **stateless** и **stateful** подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы скорее интуитивно соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о *логическом* принципе изоляции уровней абстракции, который мы подробно обсуждали в соответствующей главе:
* микросервисы разрабатываются так, чтобы не хранить данные, не относящиеся к другим уровням абстракции;
* такие «внешние» данные являются лишь идентификаторами контекстов, и сам микросервис никак их не трактует;
* если всё же какие-то дополнительные операции с внешними данными требуется производить (например, проверять, авторизована ли запрашивающая сторона на выполнение операции), то следует *организовать передачу данных так, чтобы свести операцию к проверке целостности переданных данных* (в нашем примере — использовать подписывание запросов вместо хранения копии базы данных токенов).

View File

@ -1,4 +1,47 @@
### CRUD
### Организация URL ресурсов и операций над ними в HTTP API
Как мы поняли из предыдущих глав, разработка номенклатуры URL и применимых к этим URL методов (вкупе с предоставлением метаинформации и кодификацией ошибок) — важнейший аспект проектирования HTTP API. Однако, если мы взглянем на *технические* требования к URL, выдвигаемые разработчиками стандарта, то обнаружим, что они сводятся к следующему:
* URL должны быть ключами кэширования и идемпотентности там, где это применимо;
* должна быть поддержана ссылочная целостность там, где этого требует стандарт (например, при получении статус-кода `201 Created` URL вновь созданного ресурса считается равным заголовку `Location`, если он есть, или самому исходному URL, если заголовка нет).
Требований к URL иметь определённую *структуру* ни стандарт HTTP, ни ограничения REST не содержат — более того, разработчики стандарта намеренно уклоняются от определений path и query, не предоставляя никаких рекомендаций по их семантике и организации.
Важный вывод из вышесказанного, который мы хотели бы подчеркнуть, следующий: **правила организации URL в API существуют *только* для читабельности кода и удобства разработчика**. С точки зрения буквы стандарта (а также диссертации и последующих разъяснений Филдинга) URL вообще могут быть просто случайными строками, лишь бы пресловутая ссылочная целостность была сохранена. Многочисленные советы по разработке HTTP API, которые вы можете найти в Интернете — типа «URL не должен содержать глаголов» или «используйте вложенные path для организации иерархических данных» — не более чем явочным порядком сложившийся стиль кодирования, который теперь навязывается разработчикам под видом best practice разработки «REST API».
Мы, тем не менее, совершенно не склонны утверждать, что организация URL — это что-то неважное. Напротив, URL в HTTP API являются средством выразить уровни абстракции и области ответственности объектов; поэтому разработка иерархия сущностей API должна напрямую отражаться в иерархии URL. Традиционно частям 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. Насколько строго должна выдерживаться читабельность операции `ГЛАГОЛ ресурс`? Многие учебники по REST API безапелляционно требуют, чтобы `ресурс` был существительным (ведь странно применять глагол к глаголу), и, таким образом, в примерах выше должно быть не `prepare`, а `preparation` (а вариант `/action=prepare&coffee_machine_id=<id>&recipe=lungo` вовсе недопустим, так как нет объекта действия). С нашей точки зрения единственная выгода от соблюдения этого требования только одна — становится интуитивно понятным требование «URL = ключ кэширования»; но мы, естественно, выступаем за то, чтобы разработчик *понимал* основы протокола, а не интуитивно догадывался о них.
3. Если сигнатура вызова по умолчанию модифицирующая или неидемпотентная, означает ли это, что операция *обязана* быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации таких API. Рассмотрим, например, ресурс `/v1/search`, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?
* С одной стороны, `GET /v1/search?query=<поисковый запрос>` позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL, т.е. в виде path и query-параметров).
* С другой стороны, согласно семантике операции, `GET /v1/search` должен возвращать *представление ресурса* `search`. Но разве результаты поиска являются представлением ресурса-поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т.е. соответствует методу `POST`. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов; кэширование запроса или индикация его сложности с вычислительной точки зрения.
Простых ответов на вопросы выше у нас, к сожалению, нет (особенно если мы добавим к ним механики логирования и построения мониторингов по URL запроса). В рамках настоящей книги мы придерживаемся следующего подхода:
* сигнатура вызова в первую очередь должна быть лаконична и читабельна; усложение сигнатур в угоду абстрактным концепциям нежелательно;
* иерархия ресурсов выдерживается там, где она однозначна (т.е., если сущность низшего уровня абстракции однозначно подчинена сущности высшего уровня абстракции, то отношения между ними будут выражены в виде вложенных путей);
* если есть сомнения в том, что иерархия в ходе дальнейшего развития API останется неизменной, лучше завести новый верхнеуровневый префикс, а не вкладывать новые сущности в уже существующие;
* семантика HTTP-глагола приоритетнее ложного предупреждения о небезопасности/неидемпотентности (в частности, если операция является безопасной, но ресурсозатратной, с нашей точки зрения вполне разумно использовать метод `POST` для индикации этого факта);
* для выполнения кросс-доменных операций предпочтительнее завести специальный ресурс, выполняющий операцию (т.е. в примере с кофе-машинами и рецептами автор этой книги выбрал бы вариант `/prepare?coffee_machine_id=<id>&recipe=lungo`).
#### CRUD-операции
Одно из самых популярных приложений HTTP API — это реализация CRUD-интерфейсов. Акроним CRUD (**C**reate, **R**ead, **U**pdate, **D**elete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD-операций соответствует один из глаголов HTTP:
* операции создания — создание ресурса через метод `POST`;
@ -87,4 +130,4 @@
С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо `DELETE /v1/orders/{id}` необходимо разработать эндпойнт типа `PUT /v1/orders/{id}/archive` или `PUT /v1/archive?order=<order_id>`.
Таким образом, идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла. Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция.
Таким образом, идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла. Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция. Если вы всё же хотите разработать типовой API для манипуляции типовыми сущностями, стоит изначально разработать его гораздо более гибким, чем предлагает CRUD-HTTP методология.

View File

@ -1 +1,22 @@
### Общие рекомендации
### Заключительные положения и общие рекомендации
Подведём итог описанному в предыдущих главах. Чтобы разработать качественное HTTP API, необходимо:
1. Описать happy path, т.е. составить диаграмму вызовов для стандартного цикла работы клиентского приложения;
2. Понять, какие ошибки возможны на этом пути и каким образом клиент должен восстанавливаться из какого состояния;
3. Определиться с номенклатурой ресурсов и операций над ними;
4. Решить, какая функциональность будет передана на уровень протокола HTTP [какие стандартные возможности протокола будут использованы] и в каком объёме, и разработать конкретную спецификацию.
5. Детализировать пп. 1-3, т.е. записать все запросы и ответы согласно разработанной спецификации, и оценить, насколько удобным, понятным и читабельным оказалось результирующее API.
Позволим себе так же дать несколько советов по code style [которые в самоучителях принято выдавать за best practice разработки HTTP API]:
1. Не различайте пути с `/` на конце и без него и примите какую-то рекомендацию по умолчанию (мы рекомендуем все пути заканчивать на `/` — по простой причине, это позволяет разумно описать обращение к корню домена как `GET /`).
2. Не пренебрегайте стандартными заголовками — `Date`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Cache-Control`, `Retry-After` — и вообще старайтесь не полагать на то, что клиент правильно догадывается о параметрах по умолчанию.
3. Поддержите метод `OPTIONS` на случай, если ваш API захотят использовать из браузеров.
4. Определитесь с правилами выбора кейсинга параметров (и преобразований кейсинга при перемещении параметра между различными частями запроса) и придерживайтесь их.
5. Для всех `GET`-запросов указывайте политику кэширования (иначе всегда есть шанс, что клиент придумает её за вас).
В заключение хотелось бы сказать следующее: HTTP API — это способ организовать ваше API так, чтобы полагаться на понимание семантики операций как разнообразным программным обеспечением, от клиентских фреймворков до серверных гейтвеев, так и разработчиком, который читает спецификацию. В этом смысле HTTP предоставляет пожалуй что наиболее широкий (и в плане глубины, и в плане распространённости) по сравнению с другими технологиями словарь для описания самых разнообразных ситуаций, возникающих во время работы клиент-серверных приложений. Разумеется, эта технология не лишена своих недостатков, но для разработчика публичного API она является выбором по умолчанию — на сегодняшний день скорее надо обосновывать отказ от HTTP API чем выбор в его пользу. Для непубличных API (особенно при наличии самописных инструментов кодогенерации) этот выбор не столь очевиден, но и здесь HTTP API обладает рядом преимуществ — большое количество готовых инструментов и широкое распространение знаний о технологии (пусти и фрагментарных) среди разработчиков.