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

Patterns finished

This commit is contained in:
Sergey Konstantinov 2023-01-10 01:37:48 +02:00
parent 21c878cd52
commit 873b9fe5bb
19 changed files with 429 additions and 571 deletions

View File

@ -219,6 +219,86 @@ GET /coffee-machines/{id}/stocks
```
— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence`, что эквивалентно `!(beans_absence || cup_absence)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».
#### Избегайте неявного приведения типов
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
```
const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
```
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
```
if (Type(orderParams
.contactless_delivery
) == 'Boolean' && orderParams
.contactless_delivery == false) {
}
```
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`.
Если протоколом не предусмотрена нативная поддержка таких кейсов (т.е. разработчик не может допустить ошибку, спутав отсутствие поля с пустым значением), универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
**Хорошо**
```
const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
```
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
**Плохо**:
```
// Создаёт пользователя
POST /v1/users
{ … }
// Пользователи создаются по умолчанию
// с указанием лимита трат в месяц
{
"spending_monthly_limit_usd": "100",
}
// Для отмены лимита требуется
// указать значение null
PUT /v1/users/{id}
{
"spending_monthly_limit_usd": null,
}
```
**Хорошо**
```
POST /v1/users
{
// true — у пользователя снят
// лимит трат в месяц
// false — лимит не снят
// (значение по умолчанию)
"abolish_spending_limit": false,
// Необязательное поле, имеет смысл
// только если предыдущий флаг
// имеет значение false
"spending_monthly_limit_usd": "100",
}
```
**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.
##### Декларируйте технические ограничения явно
У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
@ -227,6 +307,7 @@ GET /coffee-machines/{id}/stocks
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
##### Любые запросы должны быть лимитированы
Ограничения должны быть не только на размеры полей, но и на размеры списков или агрегируемых интервалов.
@ -235,6 +316,19 @@ GET /coffee-machines/{id}/stocks
**Хорошо**: `getOrders({ limit, parameters })` — должно существовать ограничение сверху на размер обрабатываемых и возвращаемых данных и, соответственно, возможность уточнить запрос, если партнёру всё-таки требуется большее количество данных, чем разрешено обрабатывать в одном запросе.
##### Считайте трафик
В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.
Три основные причины раздувания объёма трафика достаточно очевидны:
* клиент слишком часто запрашивает данные и/или слишком мало их кэширует;
* не предусмотрен постраничный перебор данных;
* не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.).
Все проблемы должны решаться через введения ограничений на размеры полей и правильную декомпозицию эндпойнтов. Если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных. Кроме того, причиной слишком большого числа запросов / объёма трафика могут быть ошибки проектирования уведомлений об изменений состояний, которые мы обсудим в следующем разделе.
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
##### Отсутствие результата — тоже результат
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
@ -338,7 +432,7 @@ POST /v1/offers/search
}
```
Мы видим, что разработчик по какой-то причине передал некорректное значение долготы (100 градусов). Да, мы можем его «исправить», т.е. редуцировать до ближайшего допустимого значения (90 градусов), но кому от этого стало лучше? Разработчик никогда не узнает о допущенной ошибке, а конечному пользователю предложения кофе на Северном полюсе скорее всего нерелевантны.
Мы видим, что разработчик по какой-то причине передал некорректное значение широты (100 градусов). Да, мы можем его «исправить», т.е. редуцировать до ближайшего допустимого значения (90 градусов), но кому от этого стало лучше? Разработчик никогда не узнает о допущенной ошибке, а конечному пользователю предложения кофе на Северном полюсе скорее всего нерелевантны.
**Хорошо**:
```
@ -502,6 +596,130 @@ POST /v1/coffee-machines/search
```
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
##### Всегда показывайте неразрешимые ошибки прежде разрешимых
Рассмотрим пример с заказом кофе
```
POST /v1/orders
{
// запрошенный рецепт
"recipe": "lngo",
// идентификатор предложения
"offer"
}
→ 409 Conflict
{
// ошибка: время действия
// предложения истекло
"reason": "offer_expired"
}
// Повторный запрос
// с новым `offer`
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 400 Bad Request
{
// Ошибка: неизвестный рецепт
"reason": "recipe_unknown"
}
```
Какой был смысл получать новый `offer`, если заказ всё равно не может быть создан? Для пользователя это будет выглядеть как бессмысленные действия (или бессмысленное ожидание), которые всё равно завершатся ошибкой, что бы он ни делал. Да, результат не изменится — заказ всё ещё нельзя сделать — но, во-первых, пользователь потратит меньше времени (а также сделает меньше запросов к бэкенду и внесёт меньший вклад в фон ошибок) и, во-вторых, диагностика проблемы будет гораздо проще читаться
##### Начинайте исправление ошибок с более глобальных
Если ошибки исправимы (т.е. пользователь может совершить какие-то действия и всё же добиться желаемого), следует в первую очередь сообщать о тех, которые потребуют более глобального изменения состояния.
**Плохо**:
```
POST /v1/orders
{
"items": [{
"item_id": "123",
"price": "0.10"
}]
}
409 Conflict
{
// Ошибка: пока пользователь
// совершал заказ, цена
// товара изменилась
"reason": "price_changed",
"details": [{
"item_id": "123",
"actual_price": "0.20"
}]
}
// Повторный запрос
// с актуальной ценой
POST /v1/orders
{
"items": [{
"item_id": "123",
"price": "0.20"
}]
}
409 Conflict
{
// Ошибка: у пользователя слишком
// много одновременных заказов,
// создание новых заказов запрещено
"reason": "order_limit_exceeded",
"localized_message":
"Лимит заказов превышен"
}
```
Какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
##### Проанализируйте потенциальные взаимные блокировки
В сложных системах не редки ситуации, когда исправление одной ошибки приводит к возникновению другой и наоборот.
```
// Создаём заказ с платной доставкой
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "1000.00",
"total": "10000.00"
}
→ 409 Conflict
// Ошибка: доставка становится бесплатной
// при стоимости заказа от 9000 тугриков
{
"reason": "delivery_is_free"
}
// Создаём заказ с бесплатной доставкой
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "0.00",
"total": "9000.00"
}
→ 409 Conflict
// Ошибка: минимальная сумма заказа
// 10000 тугриков
{
"reason": "below_minimal_sum",
"currency_code": "MNT",
"minimal_sum": "10000.00"
}
```
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
##### Указывайте время жизни ресурсов и политики кэширования
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.

View File

@ -180,8 +180,8 @@ X-Idempotency-Token: <токен>
}
```
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — для чего нам вновь необходимо хранение истории изменений.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует каким-то детерминированным образом сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности (в простейшем случае — считать токен идемпотентности внутренних запросов равным токену идемпотентости внешнего запроса, если это допустимо в рамках предметной области; иначе придётся использовать составные токены — в нашем случае, например, в виде `<order_id>:<external_token>`).

View File

@ -0,0 +1,188 @@
### Частичные обновления
Описанный выше пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является самоочевидной операцией (как изменение статуса заказа), а является частичной перезаписью нескольких полей объекта? Рассмотрим следующий пример.
```
// Создаёт заказ из двух напитков
POST /v1/orders/
X-Idempotency-Token: <токен>
{
"delivery_address",
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
{ "order_id" }
```
```
// Частично перезаписывает заказ
// обновляет объём второго напитка
PATCH /v1/orders/{id}
{
"items": [null, {
"volume": "800ml"
}]
}
{ /* изменения приняты */ }
```
Эта сигнатура плоха сама по себе, поскольку её читабельность сомнительна. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными?
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают специальные значения обозначения незаданных полей и для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка такой функциональности практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
Частичные изменения состояния ресурсов — одна из самых частых задач, которые решает разработчик API, и, увы, одна из самых сложных. Попытки обойтись малой кровью и упростить имплементацию зачастую приводят к очень большим проблемам в будущем.
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
* повышенные размеры запросов и, как следствие, расход трафика;
* необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
* невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
Во избежание перечисленных проблем разработчики, как правило, реализуют некоторое **наивное решение**:
* клиент передаёт только те поля, которые изменились;
* для сброса значения поля в значение по умолчанию или пропуска/удаления элементов массивов используются специально оговоренные значения.
Если обратиться к примеру выше, наивный подход выглядит примерно так:
```
// Частично перезаписывает заказ:
// * сбрасывает адрес доставки
// в значение по умолчанию
// * не изменяет первый напиток
// * удаляет второй напиток
PATCH /v1/orders/{id}
{
// Специальное значение №1:
// обнулить поле
"delivery_address": null
"items": [
// Специальное значение №2:
// не выполнять никаких
// операций с объектом
{},
// Специальное значение №3:
// удалить объект
false
]
}
```
Предполагается, что:
* повышенного расхода трафика можно избежать, передавая только изменившиеся поля и заменяя пропускаемые элементы специальными значениями (`{}` в нашем случае);
* события изменения значения поля также будут генерироваться только по тем полям и объектам, которые переданы в запросе;
* если два клиента делают одновременный запрос, но изменяют различные поля, конфликта доступа не происходит, и оба изменения применяются.
Все эти соображения, однако, на поверку оказываются мнимыми:
* причины увеличенного расхода трафика мы разберём в следующей главе, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
* это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
* существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
* кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
```
// Создаёт заказ из двух напитков
POST /v1/orders/
{
"parameters": {
"delivery_address"
}
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
{
"order_id",
"created_at",
"parameters": {
"delivery_address"
}
"items": [
{ "item_id", "status"},
{ "item_id", "status"}
]
}
```
```
// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
{ "delivery_address" }
{ "delivery_address" }
```
```
// Частично перезаписывает заказ
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
{
// Все поля передаются, даже если
// изменилось только какое-то одно
"recipe", "volume", "milk_type"
}
{ "recipe", "volume", "milk_type" }
```
```
// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
```
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными. При этом предполагается, что параллельные модификации разных ресурсов не создают конфликтов.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?).
**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
Применения этого паттерна, как правило, достаточно для большинства API, манипулирующих сложносоставленными сущностями, однако и недостатки у него тоже есть: высокие требования к качеству проектирования декомпозиции (иначе велик шанс, что стройное API развалится при дальнейшем расширении функциональности) и необходимость совершать множество запросов для изменения всей сущности целиком (что в целом решается довольно просто через разработку отдельного эндпойнта для массового изменения нескольких сущностей — например, через списки операций, как описано в предыдущей главе).
#### Разрешение конфликтов доступа к разделяемым ресурсам
При всей привлекательности идеи организации изменения состояния ресурса через независимые атомарные идемпотентные операции, у неё есть одна большая проблема: когда мы говорим о разрешении конфликтов, мы подразумеваем следующее: даже если клиент при совершении операции ориентировался на неактуальную ревизию данных, он всё равно не сможет перевести систему в непредвиденное состояние. Так как ресурс перезаписывается целиком, результатом записи будет именно то, что пользователь видел своими глазами на экране своего устройства. Однако этот подход очень мало помогает нам, если мы действительно хотим гарантировать максимально функциональное совместное редактирование данных, т.е. обеспечить максимально гранулярное изменение данных, как, например, это сделано в онлайн-сервисах совместной работы с документами или системах контроля версий.
Для «настоящего» совместного редактирования необходимо будет разработать отдельный формат описания изменений, который позволит:
* иметь максимально возможную гранулярность (т.е. одна операция соответствует одному действию клиента);
* реализовать политику разрешения конфликтов.
В нашем случае мы можем пойти, например, вот таким путём:
```
POST /v1/order/changes
X-Idempotency-Token: <токен>
{
// Какую ревизию ресурса
// видел пользователь, когда
// выполнял изменения
"known_revision",
"changes": [{
"type": "set",
"field": "delivery_address",
"value": <новое значение>
}, {
"type": "unset_item_field",
"item_id",
"field": "volume"
}],
}
```
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий.
**NB**: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)), в которой любые действия коммутативны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни некоммутативные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих агентов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.

View File

@ -0,0 +1,20 @@
### Деградация и предсказуемость
В предыдущих главах мы много говорили о том, что фон ошибок — не только неизбежное зло в любой достаточно большой системе, но и, зачастую, осознанное решение, которое позволяет сделать систему более масштабируемой и предсказуемой.
Зададим себе, однако, вопрос: а что значит «более предсказуемая» система? Для нас как для вендора API это достаточно просто: процент ошибок (в разбивке по типам) достаточно стабилен, и им можно пользоваться как индикатором возникающих технических проблем (если он растёт) и как KPI для технических улучшений и рефакторингов (если он падает).
Но вот для разработчиков-партнёров понятие «предсказуемость поведения API» имеет совершенно другой смысл: насколько хорошо и полно они в своём коде могут покрыть различные сценарии использования API и потенциальные проблемы — или, иными словами, насколько явно из документации и номенклатуры методов и ошибок API становится ясно, какие типовые ошибки могут возникнуть и как с ними работать.
Чем, например, оптимистичное управление параллелизмом (см. главу «Стратегии синхронизации») лучше блокировок с точки зрения партнёра? Тем, что, получив ошибку несовпадения ревизий, разработчик понимает, какой код он должен написать: обновить состояние и попробовать ещё раз (в простейшем случае — показав новое состояние пользователю и предложив ему решить, что делать дальше). Если же разработчик пытается захватить lock и не может сделать этого в течение разумного времени, то… а что он может полезного сделать? Попробовать ещё раз — но результат ведь, скорее всего, не изменится. Показать пользователю… что? Бесконечный спиннер? Попросить пользователя принять какое решение — сдаться?
При проектировании поведения вашего API исключительно важно представить себя на месте разработчика и попытаться понять, какой код он должен написать для разрешения возникающих ситуаций (включая сетевые таймауты и/или частичную недоступность вашего бэкенда). В этой книге приведено множество частных советов, как поступать в той или иной ситуации, но они, конечно, покрывают только типовые сценарии. О нетиповых вам придётся подумать самостоятельно.
Несколько общих советов, которые могут вам пригодиться:
* если вы можете включить в саму ошибку рекомендации, как с ней бороться — сделайте это не раздумывая (имейте в виду, что таких рекомендаций должно быть две — для пользователя, который увидит ошибку в приложении, и для разработчика, который будет разбирать логи);
* в том числе это касается и таких вещей, как рекомендуемый интервал совершения повторного запроса в случае недоступности бэкенда API и политика кэширования ошибки;
* если ошибки в каком-то эндпойнте некритичны для основной функциональности интеграции, очень желательно описать этот факт в документации (потому что разработчик может просто не догадаться обернуть соответствующий вызов в try-catch), а лучше — привести примеры, каким значением/поведением по умолчанию следует воспользоваться в случае получения ошибки;
* не забывайте, что, какую бы стройную и всеобъемлющую систему ошибок вы ни выстроили, почти в любой среде разработчик может получить ошибку транспортного уровня или таймаут выполнения, а, значит, оказаться в ситуации, когда восстанавливать состояние надо, а «подсказки» от бэкенда недоступны; должна существовать какая-то достаточно очевидная последовательность действий «по умолчанию» для восстановления работы интеграции из любой точки;
* наконец, при введении новых ошибок не забывайте о старых клиентах, которые про эти новые типы ошибок не знают; «реакция по умолчанию» на неизвестные ошибки должна в том числе покрывать и эти новые сценарии.
В идеальном мире для «правильной деградации» клиентов желательно иметь мета-API, позволяющее определить статус доступности для эндпойнтов основного API — тогда партнёры смогут, например, автоматически включать fallback-и, если какая-то функциональность не работает. (В реальном мире, увы, если на уровне сервиса наблюдаются масштабные проблемы, то обычно и API статуса доступности оказывается ими затронуто.)

View File

@ -1,83 +0,0 @@
#### Правила разработки машиночитаемых интерфейсов
В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.
##### Состояние системы должно быть понятно клиенту
Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус.
**Плохо**:
```
// Создаёт заказ и возвращает его id
POST /v1/orders
{ … }
{ "order_id" }
```
```
// Возвращает заказ по его id
GET /v1/orders/{id}
// Заказ ещё не подтверждён
// и ожидает проверки
→ 404 Not Found
```
— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние `GET /v1/orders/{id}`. Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами:
* клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения;
* потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту.
В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами.
**Хорошо**:
```
// Создаёт заказ и возвращает его
POST /v1/orders
{ <параметры заказа> }
{
"order_id",
// Заказ создаётся в явном статусе
// «идёт проверка»
"status": "checking",
}
```
```
// Возвращает заказ по его id
GET /v1/orders/{id}
{ "order_id", "status" … }
```
```
// Возвращает все заказы пользователя
// во всех статусах
GET /v1/users/{id}/orders
```
Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема.
**Плохо**: `{ "error": "email malformed" }`
— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю
**Хорошо**:
```
{
// Машиночитаемый статус
"status": "validation_failed",
// Массив описания проблем;
// если пользовательский ввод
// некорректен в нескольких
// аспектах, пользователь сможет
// исправить их все
"failed_checks": [
{
"field: "email",
"error_type": "malformed",
// Локализованное
// человекочитаемое
// сообщение
"message": "email malformed"
}
]
}
```

View File

@ -1,105 +0,0 @@
##### Соблюдайте правильный порядок ошибок
**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых:
```
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 409 Conflict
{
"reason": "offer_expired"
}
// Повторный запрос
// с новым `offer`
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 400 Bad Request
{
"reason": "recipe_unknown"
}
```
— какой был смысл получать новый `offer`, если заказ всё равно не может быть создан?
**Во-вторых**, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений.
**Плохо**:
```
POST /v1/orders
{
"items": [{
"item_id": "123",
"price": "0.10"
}]
}
409 Conflict
{
"reason": "price_changed",
"details": [{
"item_id": "123",
"actual_price": "0.20"
}]
}
// Повторный запрос
// с актуальной ценой
POST /v1/orders
{
"items": [{
"item_id": "123",
"price": "0.20"
}]
}
409 Conflict
{
"reason": "order_limit_exceeded",
"localized_message":
"Лимит заказов превышен"
}
```
— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
**В-третьих**, постройте схему: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок.
```
// Создаём заказ с платной доставкой
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "1000.00",
"total": "10000.00"
}
→ 409 Conflict
// Ошибка: доставка становится бесплатной
// при стоимости заказа от 9000 тугриков
{
"reason": "delivery_is_free"
}
// Создаём заказ с бесплатной доставкой
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "0.00",
"total": "9000.00"
}
→ 409 Conflict
// Ошибка: минимальная сумма заказа
// 10000 тугриков
{
"reason": "below_minimal_sum",
"currency_code": "MNT",
"minimal_sum": "10000.00"
}
```
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.

View File

@ -1,134 +0,0 @@
##### Пагинация, фильтрация и курсоры
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.
**Плохо**:
```
// Возвращает указанный limit записей,
// отсортированных по дате создания
// начиная с записи с номером offset
GET /v1/records?limit=10&offset=100
```
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.
1. Каким образом клиент узнает о появлении новых записей в начале списка?
Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает `limit`? Представим себе ситуацию:
* клиент обрабатывает записи в порядке поступления;
* произошла какая-то проблема, и накопилось большое количество необработанных записей;
* клиент запрашивает новые записи (`offset=0`), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем `limit`;
* клиент вынужден продолжить перебирать записи (увеличивая `offset`) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает;
* таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
2. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?
Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
3. Какие параметры кэширования мы можем выставить на этот эндпойнт?
Никакие: повторяя запрос с теми же `limit`-`offset`, мы каждый раз получаем новый набор записей.
**Хорошо**: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так:
```
// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи,
// созданной позднее,
// чем запись с указанным id
GET /v1/records⮠
?older_than={record_id}&limit=10
// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи,
// созданной раньше,
// чем запись с указанным id
GET /v1/records⮠
?newer_than={record_id}&limit=10
```
При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.
Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы более универсальными.
```
// Первый запрос данных
POST /v1/records/list
{
// Какие-то дополнительные
// параметры фильтрации
"filter": {
"category": "some_category",
"created_date": {
"older_than": "2020-12-07"
}
}
}
{ "cursor" }
```
```
// Последующие запросы
GET /v1/records?cursor=<курсор>
{ "records", "cursor" }
```
Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. `filter` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.
Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один.
**NB**: в некоторых источниках такой подход, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
* подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
* если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
* подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`;
* наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
**Плохо**:
```
// Возвращает указанный limit записей,
// отсортированных по полю sort_by
// в порядке sort_order,
// начиная с записи с номером offset
GET /records?sort_by=date_modified⮠
&sort_order=desc&limit=10&offset=100
```
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.
**Хорошо**: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
**Вариант 1**: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
```
// Создаёт представление по указанным параметрам
POST /v1/record-views
{
sort_by: [{
"field": "date_modified",
"order": "desc"
}]
}
{ "id", "cursor" }
```
```
// Позволяет получить часть представления
GET /v1/record-views/{id}⮠
?cursor={cursor}
```
Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
```
// Курсор опционален
GET /v1/records/modified/list⮠
?[cursor={cursor}]
{
"modified": [
{ "date", "record_id" }
],
"cursor"
}
```
Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.

View File

@ -1,139 +0,0 @@
### Частичные обновления и совместный доступ
Описанный выше пример
Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.
**Плохо**:
```
// Создаёт заказ из двух напитков
POST /v1/orders/
{
"delivery_address",
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
{ "order_id" }
```
```
// Частично перезаписывает заказ
// обновляет объём второго напитка
PATCH /v1/orders/{id}
{
"items": [null, {
"volume": "800ml"
}]
}
{ /* изменения приняты */ }
```
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными?
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
* повышенные размеры запросов и, как следствие, расход трафика;
* необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
* невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
Все эти соображения, однако, на поверку оказываются мнимыми:
* причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
* это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
* существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
* кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
**Лучше**: разделить эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
```
// Создаёт заказ из двух напитков
POST /v1/orders/
{
"parameters": {
"delivery_address"
}
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
{
"order_id",
"created_at",
"parameters": {
"delivery_address"
}
"items": [
{ "item_id", "status"},
{ "item_id", "status"}
]
}
```
```
// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
{ "delivery_address" }
{ "delivery_address" }
```
```
// Частично перезаписывает заказ
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
{
// Все поля передаются, даже если
// изменилось только какое-то одно
"recipe", "volume", "milk_type"
}
{ "recipe", "volume", "milk_type" }
```
```
// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
```
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?).
Также в ответах операций `PUT` можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования).
**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
**Ещё лучше**: разработать формат описания атомарных изменений.
```
POST /v1/order/changes
X-Idempotency-Token: <токен идемпотентности>
{
"changes": [{
"type": "set",
"field": "delivery_address",
"value": <новое значение>
}, {
"type": "unset_item_field",
"item_id",
"field": "volume"
}],
}
```
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения.

View File

@ -1,23 +0,0 @@
##### Считайте трафик
В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.
Три основные причины раздувания объёма трафика достаточно очевидны:
* не предусмотрен постраничный перебор данных;
* не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.);
* клиент слишком часто запрашивает данные и/или слишком мало их кэширует.
Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:
* не злоупотребляйте асинхронными интерфейсами;
* с одной стороны, они позволяют нивелировать многие технические проблемы с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;
* с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;
* объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`);
* да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);
* если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);
* если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.

View File

@ -1,84 +0,0 @@
##### Избегайте неявного приведения типов
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
```
const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
```
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
```
if (
Type(
orderParams.contactless_delivery
) == 'Boolean' &&
orderParams
.contactless_delivery == false) {
}
```
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`.
**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
**Хорошо**
```
const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
```
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
**Плохо**:
```
// Создаёт пользователя
POST /v1/users
{ … }
// Пользователи создаются по умолчанию
// с указанием лимита трат в месяц
{
"spending_monthly_limit_usd": "100",
}
// Для отмены лимита требуется
// указать значение null
PUT /v1/users/{id}
{
"spending_monthly_limit_usd": null,
}
```
**Хорошо**
```
POST /v1/users
{
// true — у пользователя снят
// лимит трат в месяц
// false — лимит не снят
// (значение по умолчанию)
"abolish_spending_limit": false,
// Необязательное поле, имеет смысл
// только если предыдущий флаг
// имеет значение false
"spending_monthly_limit_usd": "100",
}
```
**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.