1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00

Major refactoring started

This commit is contained in:
Sergey Konstantinov
2022-09-28 23:00:00 +03:00
parent a8758a91bc
commit 845751bdc9
12 changed files with 1555 additions and 144 deletions

View File

@@ -1,22 +1,30 @@
# [Read ‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book)
# [Читать книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html)
# Read [‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book) in English
# Читать [книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html) по-русски
This is the working repository for ‘The API’ book written by Sergey Konstantinov ([email](mailto:twirl-team@yandex.ru), [Linkedin profile](https://linkedin.com/in/twirl)).
## Current State and the Roadmap
Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready, I'm working on some cosmetics:
Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready. However, after some beta-testing I understood there were several important problems.
1. 'Describing final interfaces' chapter is way too overloaded; many concepts explained there deserve a separate chapter, and being minimized to fit the format, they arise a lot of controversy.
2. Though I've tried to avoid talking about any specific paradigm (REST in particular), it's often being read as such, thus igniting discussions on whether the samples are proper REST.
* adding readable schemes where it's appropriate;
* refactoring the ‘Describing Final Interfaces’ chapters;
* rephrasing and expanding the chapters on versioning and identifying users.
So the current plan is:
1. To split Chapter 11 into a full Section III (work title: 'API Patterns') comprising:
* defining API-first approach in a technical sense;
* the review of API-describing paradigms (OpenAPI/REST, GraphQL, GRPC, JSON-RPC, SOAP);
* working with default values, backwards compatibility-wise;
* (a)synchronous interaction;
* strong and weak consistency;
* push and poll models;
* machine-readable APIs: iterable lists, cursors, observability;
* an amount of traffic and data compression;
* API errors: resolvability, reduction to defaults;
* degrading properly.
2. To compile Section IV ‘HTTP API & JSON’ from current drafts + HTTP general knowledge + codestyle.
3. Maybe, try to compile Section V ‘SDK’ (much harder as there are very few drafts).
I also have more distant plans on adding two more subsections to Section I.
* Section Ia ‘JSON HTTP APIs’:
* the REST myth;
* following HTTP spec, including those parts where you should not follow the spec;
* best practices;
* Section Ib ‘SDK Design’ covering more tricky issues of having proving UI alongside the API (no specific plan right now)
Also, the book still lacks the readable schemes which I'm still planning to plot with mermaid.
## Translation

View File

@@ -25,30 +25,33 @@ Entity name must explicitly tell what it does and what side effects to expect wh
**Bad**:
```
// Cancels an order
GET /orders/cancellation
order.canceled = true;
```
It's quite a surprise that accessing the `cancellation` resource (what is it?) with the non-modifying `GET` method actually cancels an order.
It's unobvious that a state field might be set, and that this operation will cancel the order.
**Better**:
```
// Cancels an order
POST /orders/cancel
order.cancel();
```
**Bad**:
```
// Returns aggregated statistics
// since the beginning of time
GET /orders/statistics
orders.getStats()
```
Even if the operation is non-modifying but computationally expensive, you should explicitly indicate that, especially if clients got charged for computational resource usage. Even more so, default values must not be set in a manner leading to maximum resource consumption.
**Better**:
```
// Returns aggregated statistics
// Calculates and returns
// aggregated statistics
// for a specified period of time
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
orders.calculateAggregatedStats({
begin_date,
end_date
});
```
**Try to design function signatures to be absolutely transparent about what the function does, what arguments it takes, and what's the result**. While reading a code working with your API, it must be easy to understand what it does without reading docs.
@@ -76,6 +79,8 @@ So *always* specify exactly which standard is applied. Exceptions are possible i
or
`"duration": "5000ms"`
or
`"iso_duration": "PT5S"`
or
`"duration": {"unit": "ms", "value": 5000}`.
One particular implication of this rule is that money sums must *always* be accompanied by a currency code.
@@ -210,20 +215,24 @@ GET /coffee-machines/{id}/stocks
This advice is opposite to the previous one, ironically. When developing APIs you frequently need to add a new optional field with a non-empty default value. For example:
```
POST /v1/orders
{}
{ "contactless_delivery": true }
const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
```
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (field isn't set). They have to write something like:
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (the field isn't set). They have to write something like:
```
if (Type(
order.contactless_delivery
) == 'Boolean' &&
order.contactless_delivery == false) {
if (
Type(
orderParams.contactless_delivery
) == 'Boolean' &&
orderParams
.contactless_delivery == false) {
}
```
@@ -235,10 +244,12 @@ If the protocol does not support resetting to default values as a first-class ci
**Better**
```
POST /v1/orders
{}
{ "force_contact_delivery": false }
const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
```
If a non-Boolean field with specially treated value absence is to be introduced, then introduce two fields.
@@ -733,11 +744,9 @@ Since the produced view is immutable, access to it might be organized in any for
**Option two**: guarantee a strict records order, for example, by introducing a concept of record change events:
```
POST /v1/records/modified/list
{
// Optional
"cursor"
}
// `cursor` is optional
GET /v1/records/modified/list⮠
?[cursor={cursor}]
{
"modified": [
@@ -791,7 +800,8 @@ POST /v1/orders/drafts
```
```
// Confirms the draft
PUT /v1/orders/drafts/{draft_id}
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
```
@@ -852,7 +862,7 @@ There is a common problem with implementing the changes list approach: what to d
**Bad**:
```
// Returns a list of recipes
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@@ -864,8 +874,7 @@ GET /v1/recipes
}]
}
// Changes recipes' parameters
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
@@ -873,10 +882,10 @@ PATCH /v1/recipes
"id": "latte",
"volume": "-1ml"
}]
}
400 Bad Request
});
→ Bad Request
// Re-reading the list
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@@ -896,8 +905,7 @@ If you can't guarantee the atomicity of an operation, you should elaborate in de
**Better**:
```
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
@@ -905,11 +913,11 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "-1ml"
}]
}
});
// You may actually return
// a ‘partial success’ status
// if the protocol allows it
200 OK
{
"changes": [{
"change_id",
@@ -938,8 +946,7 @@ Might be of use:
Non-atomic changes are undesirable because they erode the idempotency concept. Let's take a look at the example:
```
PATCH /v1/recipes
{
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@@ -948,8 +955,8 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@@ -968,8 +975,7 @@ PATCH /v1/recipes
Imagine the client failed to get a response because of a network error, and it repeats the request:
```
PATCH /v1/recipes
{
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@@ -978,8 +984,8 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{

View File

@@ -25,30 +25,32 @@
**Плохо**:
```
// Отменяет заказ
GET /orders/cancellation
order.canceled = true;
```
Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ.
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
**Хорошо**:
```
// Отменяет заказ
POST /orders/cancel
order.cancel();
```
**Плохо**:
```
// Возвращает агрегированную
// статистику заказов за всё время
GET /orders/statistics
orders.getStats()
```
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
**Хорошо**:
```
// Возвращает агрегированную
// Вычисляет и возвращает агрегированную
// статистику заказов за указанный период
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
orders.calculateAggregatedStats({
begin_date: <начало периода>
end_date: <конец_периода>
});
```
**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
@@ -72,7 +74,9 @@ POST /v1/orders/statistics/aggregate
**Хорошо**:
`"duration_ms": 5000`
либо
`"duration": "5000ms"`
`"duration": "5000ms"`
либо
`"iso_duration": "PT5S"`
либо
```
"duration": {
@@ -220,19 +224,23 @@ GET /coffee-machines/{id}/stocks
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
```
POST /v1/orders
{ … }
{ "contactless_delivery": true }
const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
```
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
```
if (Type(
order.contactless_delivery
) == 'Boolean' &&
order.contactless_delivery == false) {
if (
Type(
orderParams.contactless_delivery
) == 'Boolean' &&
orderParams
.contactless_delivery == false) {
}
```
@@ -245,10 +253,12 @@ if (Type(
**Хорошо**
```
POST /v1/orders
{}
{ "force_contact_delivery": false }
const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
```
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
@@ -741,11 +751,9 @@ GET /v1/record-views/{id}⮠
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
```
POST /v1/records/modified/list
{
// Опционально
"cursor"
}
// Курсор опционален
GET /v1/records/modified/list⮠
?[cursor={cursor}]
{
"modified": [
@@ -799,7 +807,8 @@ POST /v1/orders/drafts
```
```
// Подтверждает черновик заказа
PUT /v1/orders/drafts/{draft_id}
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
```
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
@@ -859,7 +868,7 @@ X-Idempotency-Token: <токен>
**Плохо**:
```
// Возвращает список рецептов
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@@ -872,8 +881,7 @@ GET /v1/recipes
}
// Изменяет параметры
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
@@ -881,11 +889,12 @@ PATCH /v1/recipes
"id": "latte",
"volume": "-1ml"
}]
}
400 Bad Request
});
Bad Request
// Перечитываем список
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@@ -905,8 +914,7 @@ GET /v1/recipes
**Лучше**:
```
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
@@ -914,11 +922,11 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "-1ml"
}]
}
});
// Можно воспользоваться статусом
// «частичного успеха»,
// если он предусмотрен протоколом
200 OK
{
"changes": [{
"change_id",
@@ -947,8 +955,7 @@ PATCH /v1/recipes
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
```
PATCH /v1/recipes
{
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@@ -957,8 +964,8 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@@ -977,8 +984,7 @@ PATCH /v1/recipes
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
```
PATCH /v1/recipes
{
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@@ -987,8 +993,8 @@ PATCH /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{

View File

@@ -0,0 +1,675 @@
### Описание конечных интерфейсов
Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке 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
}
```
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
##### Сущности должны именоваться конкретно
Избегайте одиночных слов-«амёб» без определённой семантики, таких как 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)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».
##### Отсутствие результата — тоже результат
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
**Плохо**
```
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": []
}
```
Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)
##### Валидируйте корректность операции
В ситуации выбора: указать на ошибку или молча её проглотить — разработчик API должен всегда выбирать первый вариант.
**Плохо**:
```
POST /v1/coffee-machines/search
{
"recipes": ["lngo"]
// Положение пользователя не задано
}
{
"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": ["lngo"]
// Положение пользователя не задано
}
→ 400 Bad Request
{
// описание ошибки
// см. следующее правило
}
```
Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку:
```
POST /v1/coffee-machines/search
{
"recipes": ["lngo"]
"position": {
"latitude": 0,
"longitude": 0
},
"force_convact_delivery": true
}
{
"results": [],
"warnings": [{
"type": "suspicious_coordinates",
"message": "Position [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"]
"position": {
"latitude": 0,
"longitude": 0
}
}
→ 404 Bad Request
{
"errors": [{
"type": "suspicious_coordinates",
"message": "Position [0, 0]⮠
is probably a mistake"
}],
}
```
Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно задавать игнорируемые ошибки для конкретной операции:
```
POST /v1/coffee-machines/search⮠
strict_mode=true⮠
disable_errors=suspicious_coordinates
```
##### Ошибки должны быть информативными
Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим 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"
}
]
}
}
```
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
##### Декларируйте технические ограничения явно
У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
##### Указывайте время жизни ресурсов и политики кэширования
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
**Плохо**:
```
// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
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) должен быть предусмотрен обязательно.
##### Не предоставляйте endpoint-ов массового получения чувствительных данных
Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.
Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.
##### Локализация и интернационализация
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.
Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.
И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.

View File

@@ -0,0 +1,83 @@
#### Правила разработки машиночитаемых интерфейсов
В погоне за понятностью 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

@@ -0,0 +1,150 @@
##### Избегайте неатомарных операций
С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
**Плохо**:
```
// Возвращает список рецептов
api.getRecipes();
{
"recipes": [{
"id": "lungo",
"volume": "200ml"
}, {
"id": "latte",
"volume": "300ml"
}]
}
// Изменяет параметры
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
}, {
"id": "latte",
"volume": "-1ml"
}]
});
Bad Request
// Перечитываем список
api.getRecipes();
{
"recipes": [{
"id": "lungo",
// Это значение изменилось
"volume": "300ml"
}, {
"id": "latte",
// А это нет
"volume": "300ml"
}]
}
```
— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
**Лучше**:
```
api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "-1ml"
}]
});
// Можно воспользоваться статусом
// «частичного успеха»,
// если он предусмотрен протоколом
{
"changes": [{
"change_id",
"occurred_at",
"recipe_id": "lungo",
"status": "success"
}, {
"change_id",
"occurred_at",
"recipe_id": "latte",
"status": "fail",
"error"
}]
}
```
Здесь:
* `change_id` — уникальный идентификатор каждого атомарного изменения;
* `occurred_at` — время проведения каждого изменения;
* `error` — информация по ошибке для каждого изменения, если она возникла.
Не лишним будет также:
* ввести в запросе `sequence_id`, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом;
* предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
```
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
});
{
"changes": [{
"status": "success"
}, {
"status": "fail",
"error": {
"reason":
"too_many_requests"
}
}]
}
```
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
```
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
});
{
"changes": [{
"status": "success"
}, {
"status": "success",
}]
}
```
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.

View File

@@ -0,0 +1,105 @@
##### Соблюдайте правильный порядок ошибок
**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых:
```
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

@@ -0,0 +1,134 @@
##### Пагинация, фильтрация и курсоры
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.
**Плохо**:
```
// Возвращает указанный 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

@@ -0,0 +1,137 @@
##### Избегайте неявных частичных обновлений
Один из самых частых антипаттернов в разработке 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

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

View File

@@ -0,0 +1,84 @@
##### Избегайте неявного приведения типов
Этот совет парадоксально противоположен предыдущему. Часто при разработке 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`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.

View File

@@ -1,43 +1,43 @@
1. Зачем предоставлять API
* возможности интеграции с основным сервисом
* непрофильные сервисы
* концепция API-first
2. Выгоды наличия API и виды монетизации
* API к монетизируемым сервисам (+ Affiliate API)
* on-premise
* freemium-модели
* лимиты
* ограничения на типы использования
* техническая поддержка
* премиум-функциональность
* маркетплейсы
* рекламные модели
* сбор данных / UGC
* контакт с брендом
* терраформирование
3. Прямая и обратная API-пирамида Маслоу
4. Виды API
* API
* SDK
* виджеты
* embedded (включая картинки)
5. Бизнес-аудитория, её интересы и места обитания
* SLA
* безопасность
* идентификация пользователей, ведение статистики, защита публичных ключей
* контакты и оповещения
6. Разработчики, их интересы и места обитания
* новички vs продвинутые
* OpenSource
7. Документация
* референс
* tutorial
* how-to
* примеры
* песочница
8. Поддержка пользователей
* форумы
* поддержка разработчиков
* работа с комьюнити
* обратная связь
9. Продуктовое управление разработкой API
Раздел I
* описание конечных советов — оставить только кодстайл
Раздел II
* API-first подход
* выбор поддерживаемых стандартов
* существующие стандарты (описания) взаимодействия через API
* JSON / OpenAPI
* XML-RPC / WSDL
* JSON-RPC
* GraphQL
* GRPC
* клиентские библиотеки
* хелперы, обратная совместимость, работа с умолчаниями
* синхронное и асинхронное взаимодействие
* push- и poll-модели
* сильная и слабая консистентность
* машиночитаемое API
* observability
* перебор списков, курсоры
* объёмы передаваемых данных
* сжатие
* кэширование
* ошибки
* разрешимость
* сведение к умолчанию
* ошибка для пользователя vs ошибка для разработчика
* деградация
* мониторинг состояния
Раздел III
* о терминологии
* введение в HTTP
* REST, определение и реальность
* плюсы и минусы разработки HTTP API
* проблема статус-кодов
* трактовка методов
* CRUD
* кодстайл
* domain/path/query/body
Раздел IV
* постановка проблемы
* реюз кода vs реюз поведения
* трехстороннее взаимодействие
* асинхронность, разделяемые ресурсы