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

The Describing final interfaces chapter refactoring

This commit is contained in:
Sergey Konstantinov 2023-04-14 20:36:44 +03:00
parent cf656ab7e3
commit 78034cec81
4 changed files with 643 additions and 2140 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
### Двунаправленные потоки данных. Push и poll-модели
### [Двунаправленные потоки данных. Push и poll-модели][api-patterns-push-vs-poll]

View File

@ -1,989 +0,0 @@
### Описание конечных интерфейсов
Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.
Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.
**NB**: примеры, приведённые в этой главе, прежде всего иллюстрируют проблемы консистентности и читабельности, возникающие при разработке API. Мы не ставим здесь цели дать рекомендации по разработке REST API (такого рода советы будут даны в разделе III) или стандартных библиотек языков программирования — важен не конкретный синтаксис, а общая идея.
Важное уточнение под номером ноль:
##### 0. Правила не должны применяться бездумно
Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно.
Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам _необходимо_, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.
Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API).
Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.
##### Явное лучше неявного
Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.
**Плохо**:
```
// Отменяет заказ
order.canceled = true;
```
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
**Хорошо**:
```
// Отменяет заказ
order.cancel();
```
**Плохо**:
```
// Возвращает агрегированную
// статистику заказов за всё время
orders.getStats()
```
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
**Хорошо**:
```
// Вычисляет и возвращает агрегированную
// статистику заказов за указанный период
orders.calculateAggregatedStats({
begin_date: <начало периода>
end_date: <конец_периода>
});
```
**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
Два важных следствия:
**1.1.** Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за `GET`.
**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
##### Указывайте использованные стандарты
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому *всегда* указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.
**Плохо**: `"date": "11/12/2020"` — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц.
**Хорошо**: `"iso_date": "2020-11-12"`.
**Плохо**: `"duration": 5000` — пять тысяч чего?
**Хорошо**:
`"duration_ms": 5000`
либо
`"duration": "5000ms"`
либо
`"iso_duration": "PT5S"`
либо
```
"duration": {
"unit": "ms",
"value": 5000
}
```
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе «Обратная совместимость».
##### Сущности должны именоваться конкретно
Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.
**Плохо**: `user.get()` — неочевидно, что конкретно будет возвращено.
**Хорошо**: `user.get_id()`.
##### Не экономьте буквы
В XXI веке давно уже нет нужды называть переменные покороче.
**Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
**Хорошо**:
```
order
.get_estimated_delivery_time()
```
**Плохо**:
```
// возвращает положение
// первого вхождения в строку str1
// любого символа из строки str2
strpbrk (str1, str2)
```
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
**Хорошо**:
```
str_search_for_characters(
str,
lookup_character_set
)
```
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
**NB**: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.
##### Тип поля должен быть ясен из его названия
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — `objects`, `children`; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений.
**Плохо**: `GET /news` — неясно, будет ли получена какая-то конкретная новость или массив новостей.
**Хорошо**: `GET /news-list`.
Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, `is_ready`, `open_now`.
**Плохо**: `"task.status": true` — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым.
**Хорошо**: `"task.is_finished": true`.
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date`, и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`.
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
**Плохо**:
```
// Возвращает список
// встроенных функций кофемашины
GET /coffee-machines/{id}/functions
```
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
**Хорошо**:
```
GET /v1/coffee-machines/{id}⮠
/builtin-functions-list
```
##### Подобные сущности должны называться подобно и вести себя подобным образом
**Плохо**: `begin_transition` / `stop_transition`
`begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации.
**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`.
**Плохо**:
```
// Находит первую позицию строки `needle`
// внутри строки `haystack`
strpos(haystack, needle)
```
```
// Находит и заменяет
// все вхождения строки `needle`
// внутри строки `haystack`
// на строку `replace`
str_replace(needle, replace, haystack)
```
Здесь нарушены сразу несколько правил:
* написание неконсистентно в части знака подчёркивания;
* близкие по смыслу методы имеют разный порядок аргументов `needle`/`haystack`;
* первый из методов находит только первое вхождение строки `needle`, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
##### Избегайте двойных отрицаний
**Плохо**: `"dont_call_me": false`
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.
**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true`
— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».
Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
```
GET /coffee-machines/{id}/stocks
{
"has_beans": true,
"has_cup": true
}
```
Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
```
{
"beans_absence": false,
"cup_absence": false
}
```
— то разработчику потребуется вычислить флаг `!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 пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
##### Любые запросы должны быть лимитированы
Ограничения должны быть не только на размеры полей, но и на размеры списков или агрегируемых интервалов.
**Плохо**: `getOrders()` — что, если пользователь совершил миллион заказов?
**Хорошо**: `getOrders({ limit, parameters })` — должно существовать ограничение сверху на размер обрабатываемых и возвращаемых данных и, соответственно, возможность уточнить запрос, если партнёру всё-таки требуется большее количество данных, чем разрешено обрабатывать в одном запросе.
##### Считайте трафик
В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.
Три основные причины раздувания объёма трафика достаточно очевидны:
* клиент слишком часто запрашивает данные и/или слишком мало их кэширует;
* не предусмотрен постраничный перебор данных;
* не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.).
Все проблемы должны решаться через введения ограничений на размеры полей и правильную декомпозицию эндпойнтов. Если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных. Кроме того, причиной слишком большого числа запросов / объёма трафика могут быть ошибки проектирования уведомлений об изменений состояний, которые мы обсудим в следующем разделе.
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
##### Отсутствие результата — тоже результат
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
**Плохо**
```
POST /v1/coffee-machines/search
{
"query": "lungo",
"location": <положение пользователя>
}
→ 404 Not Found
{
"localized_message":
"Рядом с вами не делают лунго"
}
```
Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
**Хорошо**:
```
POST /v1/coffee-machines/search
{
"query": "lungo",
"location": <положение пользователя>
}
→ 200 OK
{
"results": []
}
```
Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)
**NB**: этот паттерн следует применять и в обратную сторону: если в запросе можно указать массив сущностей, то следует отличать пустой массив от отсутствия параметра. Рассмотрим следующий пример:
```
// Находит все рецепты кофе
// без молока
POST /v1/recipes/search
{
"filter": {
"no_milk": true
}
}
→ 200 OK
{
"results": [{
"recipe": "espresso"
}, {
"recipe": "lungo",
}]
}
// Находит все предложения
// указанных рецептов
POST /v1/offers/search
{
"location",
"recipes": [
"espresso",
"lungo"
]
}
```
Представим теперь, что вызов первого метода вернул пустой массив результатов, т.е. ни одного рецепта кофе, удовлетворяющего условиям, не было найдено. Хорошо, если разработчик партнёра предусмотрит эту ситуацию и не будет делать запрос поиска предложений — но мы не можем быть стопроцентно в этом уверены. Если обработка пустого массива рецептов не предусмотрена, то приложение партнёра выполнит вот такой запрос:
```
POST /v1/offers/search
{
"location",
"recipes": []
}
```
Часто можно столкнуться с ситуацией, когда эндпойнт просто проигнорирует наличие пустого массива `recipes` и вернёт предложения так, как будто никакого фильтра по рецепту передано не было. В нашем примере это будет означать, что приложение просто проигнорирует требование пользователя показать только напитки без молока, что мы никак не можем счесть приемлемым поведением. Поэтому ответом на такой запрос с пустым массивом в качестве параметра должна быть либо ошибка, либо пустой же массив предложений.
##### Валидируйте ввод
Какой из вариантов действий выбрать в предыдущем примере — исключение или пустой ответ — напрямую зависит от того, что записано в вашем контракте. Если спецификация прямо предписывает, что массив `recipes` должен быть непустым, то необходимо сгенерировать исключение (иначе вы фактически нарушаете собственную спецификацию!).
Это верно не только в случае непустых массивов, но и любых других зафиксированных в контракте ограничений. «Тихое» исправление недопустимых значений почти никогда не имеет никакого практического смысла:
**Плохо**:
```
POST /v1/offers/search
{
"location": {
"longitude": 20,
"latitude": 100
}
}
→ 200 OK
{
// Предложения для точки
// [0, 90]
"offers"
}
```
Мы видим, что разработчик по какой-то причине передал некорректное значение широты (100 градусов). Да, мы можем его «исправить», т.е. редуцировать до ближайшего допустимого значения (90 градусов), но кому от этого стало лучше? Разработчик никогда не узнает о допущенной ошибке, а конечному пользователю предложения кофе на Северном полюсе скорее всего нерелевантны.
**Хорошо**:
```
POST /v1/coffee-machines/search
{
"location": {
"longitude": 20,
"latitude": 100
}
}
→ 400 Bad Request
{
// описание ошибки
}
```
Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку:
```
POST /v1/coffee-machines/search
{
"recipes": ["lngo"]
"location": {
"latitude": 0,
"longitude": 0
},
"force_convact_delivery": true
}
{
"results": [],
"warnings": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]⮠
is probably a mistake"
}, {
"type": "unknown_field",
"message": "unknown field:⮠
`force_convact_delivery`. Did you⮠
mean `force_contact_delivery`?"
}]
}
```
Однако следует отметить, что далеко не во все интерфейсы можно удобно уложить дополнительно возврат предупреждений. В такой ситуации можно ввести дополнительный режим отладки или строгий режим, в котором уровень предупреждений эскалируется:
```
POST /v1/coffee-machines/search⮠
strict_mode=true
{
"recipes": ["lngo"]
"location": {
"latitude": 0,
"longitude": 0
}
}
→ 404 Bad Request
{
"errors": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]⮠
is probably a mistake"
}],
}
```
Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно задавать игнорируемые ошибки для конкретной операции:
```
POST /v1/coffee-machines/search⮠
strict_mode=true⮠
disable_errors=suspicious_coordinates
```
##### Значения по умолчанию должны быть осмысленны
Значения по умолчанию — один из самых ваших сильных инструментов, позволяющих избежать многословности при работе с API. Однако эти умолчания должны помогать разработчикам, а не маскировать их ошибки.
**Плохо**:
```
POST /v1/coffee-machines/search
{
"recipes": ["lungo"]
// Положение пользователя не задано
}
{
"results": [
// Результаты для какой-то
// локации по умолчанию
]
}
```
Формально, подобное умолчание допустимо — почему бы не иметь концепции «географических координат по умолчанию». Однако в реальности результатом подобных политик «тихого» исправления ошибок становятся абсурдные ситуации типа «null island» — [самой посещаемой точки в мире](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). Чем популярнее API, тем больше шансов, что партнеры просто не обратят внимания на такие пограничные ситуации.
**Хорошо**:
```
POST /v1/coffee-machines/search
{
"recipes": ["lungo"]
// Положение пользователя не задано
}
→ 400 Bad Request
{
// описание ошибки
}
```
##### Ошибки должны быть информативными
Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API.
**Плохо**:
```
POST /v1/coffee-machines/search
{
"recipes": ["lngo"],
"position": {
"latitude": 110,
"longitude": 55
}
}
→ 400 Bad Request
{}
```
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
**Хорошо**:
```
{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так.⮠
Обратитесь к разработчику приложения."
"details": {
"checks_failed": [
{
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown.⮠
Did you mean 'lungo'?"
},
{
"field": "position.latitude",
"error_type": "constraint_violation",
"constraints": {
"min": -90,
"max": 90
},
"message":
"'position.latitude' value⮠
must fall within⮠
the [-90, 90] interval"
}
]
}
}
```
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
##### Всегда показывайте неразрешимые ошибки прежде разрешимых
Рассмотрим пример с заказом кофе
```
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"
}
```
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
##### Указывайте время жизни ресурсов и политики кэширования
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
**Плохо**:
```
// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
GET /v1/price?recipe=lungo­⮠
&longitude={longitude}⮠
­&latitude={latitude}
{ "currency_code", "price" }
```
Возникает два вопроса:
* в течение какого времени эта цена действительна?
* на каком расстоянии от указанной точки цена всё ещё действительна?
**Хорошо**:
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
```
// Возвращает предложение: за какую сумму
// наш сервис готов приготовить лунго
GET /v1/price?recipe=lungo⮠
&longitude={longitude}⮠
&latitude={latitude}
{
"offer": {
"id",
"currency_code",
"price",
"conditions": {
// До какого времени
// валидно предложение
"valid_until",
// Где валидно предложение:
// * город
// * географический объект
// * …
"valid_within"
}
}
}
```
##### Сохраняйте точность дробных чисел
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат `00:20` формату `0.333333…`), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.
##### Все операции должны быть идемпотентны
Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
**Плохо**:
```
// Создаёт заказ
POST /orders
```
Повтор запроса создаст два заказа!
**Хорошо**:
```
// Создаёт заказ
POST /v1/orders
X-Idempotency-Token: <случайная строка>
```
Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
**Альтернатива**:
```
// Создаёт черновик заказа
POST /v1/orders/drafts
{ "draft_id" }
```
```
// Подтверждает черновик заказа
PUT /v1/orders/drafts⮠
/{draft_id}/confirmation
{ "confirmed": true }
```
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё `draft_id` играет роль ключа идемпотентности.
Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так изначально идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации:
* клиент не получил ответ из-за сетевых проблем и пытается повторить запрос;
* клиент ошибся, пытаясь применить конфликтующие изменения.
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
```
POST /resource/updates
{
"resource_revision": 123
"updates"
}
```
Сервер извлекает актуальный номер ревизии и обнаруживает, что он равен 124. Как ответить правильно? Можно просто вернуть `409 Conflict`, но тогда клиент будет вынужден попытаться выяснить причину конфликта и как-то решить его, потенциально запутав пользователя. К тому же, фрагментировать алгоритмы разрешения конфликтов, разрешая каждому клиенту реализовать какой-то свой — плохая идея.
Сервер мог бы попытаться сравнить значения поля `updates`, предполагая, что одинаковые значения означают перезапрос, но это предположение будет опасно неверным (например, если ресурс представляет собой счётчик, то последовательные запросы с идентичным телом нормальны).
Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему:
```
POST /resource/updates
X-Idempotency-Token: <токен>
{
"resource_revision": 123
"updates"
}
→ 201 Created
```
— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.
Или:
```
POST /resource/updates
X-Idempotency-Token: <токен>
{
"resource_revision": 123
"updates"
}
→ 409 Conflict
```
— сервер обнаружил, что ревизия 123 была создана с другим токеном, значит имеет место быть конфликт общего доступа к ресурсу.
Более того, добавление токена идемпотентности не только решает эту проблему, но и позволяет в будущем сделать продвинутые оптимизации. Если сервер обнаруживает конфликт общего доступа, он может попытаться решить его, «перебазировав» обновление, как это делают современные системы контроля версий, и вернуть `200 OK` вместо `409 Conflict`. Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима и предотвращает фрагментацию кода разрешения конфликтов.
Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно:
* нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
* клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
##### Не изобретайте безопасность
Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметров запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.
**Во-первых**, почти всегда процедуры, обеспечивающие безопасность той или иной операции, *уже разработаны*. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки [Man-in-the-Middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.
**Во-вторых**, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен [атаке по времени](https://en.wikipedia.org/wiki/Timing_attack), а веб-сервер — [атаке с разделением запросов](https://capec.mitre.org/data/definitions/105.html).
Отдельно уточним: любые API должны предоставляться строго по протоколу TLS версии не ниже 1.2 (лучше 1.3).
##### Помогайте партнёрам не изобретать безопасность
Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне.
**Плохо**:
```
// Позволяет партнёру задать
// описание для своего напитка
PUT /v1/partner-api/{partner-id}⮠
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
```
```
// возвращает описание
GET /v1/partner-api/{partner-id}⮠
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
```
Подобный интерфейс является прямым способом соорудить хранимую XSS, которым потенциально может воспользоваться злоумышленник. Да, это ответственность самого партнёра — не допускать сохранения подобного ввода и/или экранировать его при выводе. Но большие цифры по-прежнему работают против вас: всегда найдутся начинающие разработчики, которые не знают об этом виде уязвимости или не подумали о нём. В худшем случае существование таких хранимых уязвимостей может затронуть не только конкретного партнёра, но и вообще всех пользователей API.
В таких ситуациях мы рекомендуем, во-первых, всегда экранировать вводимые через API данные, и, во-вторых, ограничивать радиус взрыва так, чтобы через уязвимости в коде одного партнёра нельзя было затронуть других партнёров. В случае, если функциональность небезопасного ввода всё же нужна, необходимо предупреждать о рисках максимально явно.
**Лучше** (но не идеально):
```
// Позволяет партнёру задать
// потенциально небезопасное
// описание для своего напитка
PUT /v1/partner-api/{partner-id}⮠
/recipes/lungo/info
X-Dangerously-Disable-Sanitizing: true
"<script>alert(document.cookie)</script>"
```
```
// возвращает потенциально
// небезопасное описание
GET /v1/partner-api/{partner-id}⮠
/recipes/lungo/info
X-Dangerously-Allow-Raw-Value: true
"<script>alert(document.cookie)</script>"
```
В частности, если вы позволяете посредством API выполнять какие-то текстовые скрипты, всегда предпочитайте безопасный ввод небезопасному.
**Плохо**
```
POST /v1/run/sql
{
// Передаёт готовый запрос целиком
"query": "INSERT INTO data (name)⮠
VALUES ('Robert');⮠
DROP TABLE students;--')"
}
```
**Лучше**
```
POST /v1/run/sql
{
// Передаёт шаблон запроса
"query": "INSERT INTO data (name)⮠
VALUES (?)",
// и параметры для подстановки
values: [
"Robert');⮠
DROP TABLE students;--"
]
}
```
Во втором случае вы сможете централизованно экранировать небезопасный ввод и избежать тем самым SQL-инъекции. Напомним повторно, что делать это необходимо с помощью state-of-the-art инструментов, а не самописных регулярных выражений.
##### Используйте глобально уникальные идентификаторы
Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.
Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:<uuid>` (или просто `order:<uuid>`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.
Отдельное важное следствие: **не используйте инкрементальные номера как внешние идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
##### Предусмотрите ограничения доступа
С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.
Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
**NB**: из этого пункта вытекает достаточно очевидное правило, которое, несмотря на его очевидность, умудряются нарушить все до одного разработчики популярных API — **всегда разделяйте эндпойнты разных семейств API**. Если вы предоставляете и серверное API, и сервисы для конечных пользователей, и виджеты для встраивания в сторонние приложения — эти API должны обслужиться с разных эндпойнтов для того, чтобы вы могли вводить разные меры безопасности (скажем, API-ключи, требование логина и капчу, соответственно).
##### Не предоставляйте endpoint-ов массового получения чувствительных данных
Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.
Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.
##### Локализация и интернационализация
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.
Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примерах выше сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.
И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.