Это — очередная (уже третья) ревизия главы 11 мой книги о разработке API. Если вы найдёте этот текст полезным, я буду очень благодарен за рейтинг на Амазоне.


Важное уточнение под номером ноль:

0. Правила не должны применяться бездумно

Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно.

Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам необходимо, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.

Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API).

Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов set_entity / get_entity в пользу одного метода entity с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.

Обеспечение читабельности и консистентности

Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.

1. Явное лучше неявного

Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.

Плохо:

// Отменяет заказ
GET /orders/cancellation

Неочевидно, что достаточно просто обращения к сущности cancellation (что это?), тем более немодифицирующим методом GET, чтобы отменить заказ.

Хорошо:

// Отменяет заказ
POST /orders/cancel

Плохо:

// Возвращает агрегированную 
// статистику заказов за всё время
GET /orders/statistics

Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.

Хорошо:

// Возвращает агрегированную 
// статистику заказов за указанный период
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }

Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.

Два важных следствия:

1.1. Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за GET.

1.2. Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, либо должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.

2. Указывайте использованные стандарты

К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому всегда указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.

Плохо: "date": "11/12/2020" — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц.

Хорошо: "iso_date": "2020-11-12".

Плохо: "duration": 5000 — пять тысяч чего?

Хорошо:
"duration_ms": 5000
либо
"duration": "5000ms"
либо

"duration": {
  "unit": "ms",
  "value": 5000
}

Отдельное следствие из этого правила — денежные величины всегда должны сопровождаться указанием кода валюты.

Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.

3. Сущности должны именоваться конкретно

Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.

Плохо: user.get() — неочевидно, что конкретно будет возвращено.

Хорошо: user.get_id().

4. Не экономьте буквы

В 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: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.

5. Тип поля должен быть ясен из его названия

Если поле называется 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

6. Подобные сущности должны называться подобно и вести себя подобным образом

Плохо: 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, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.

Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.

7. Избегайте двойных отрицаний

Плохо: "dont_call_me": false
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.

Лучше: "prohibit_calling": true или "avoid_calling": true
— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».

Стоит также отметить, что в использовании законов де Моргана ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:

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), а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».

8. Избегайте неявного приведения типов

Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:

POST /v1/orders
{ … }
→
{ "contactless_delivery": true }

Новая опция contactless_delivery является необязательной, однако её значение по умолчанию — true. Возникает вопрос, каким образом разработчик должен отличить явное нежелание пользоваться опцией (false) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:

if (Type(
    order.contactless_delivery
  ) == 'Boolean' &&
  order.contactless_delivery == false) { 
  … 
}

Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа null или -1.

NB. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция NULL, и значения полей по умолчанию, и поддержка операций вида UPDATE … SET field = DEFAULT (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил UPDATE … DEFAULT), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.

Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.

Хорошо:

POST /v1/orders
{}
→
{ "force_contact_delivery": false }

Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.

Плохо:

// Создаёт пользователя
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. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.

9. Отсутствие результата — тоже результат

Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.

Плохо:

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": []
}

Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)

10. Ошибки должны быть информативными

При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим 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"
      }
    ]
  }
}

Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.

11. Соблюдайте правильный порядок ошибок

Во-первых, всегда показывайте неразрешимые ошибки прежде разрешимых:

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"
}

Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.

Правила разработки машиночитаемых интерфейсов

В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.

12. Состояние системы должно быть понятно клиенту

Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус.

Плохо:

// Создаёт заказ и возвращает его 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"
     }
  ]
}

13. Указывайте время жизни ресурсов и политики кэширования

В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.

Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?

Плохо:

// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
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"
    }
  }
}

14. Пагинация, фильтрация и курсоры

Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.

Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.

Плохо:

// Возвращает указанный 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: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:

POST /v1/records/modified/list
{
  // Опционально
  "cursor"
}
→
{
  "modified": [
    { "date", "record_id" }
  ],
  "cursor"
}

Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.

Техническое качество API

Хороший API должен не просто решать проблемы разработчиков и пользователей, но и делать это максимально качественно, т.е. не содержать в себе логических и технических ошибок (и не провоцировать на них разработчика), экономить вычислительные ресурсы и вообще имплементировать лучшие практики в своей предметной области.

15. Сохраняйте точность дробных чисел

Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.

Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.

Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат 00:20 формату 0.333333…), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.

16. Все операции должны быть идемпотентны

Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.

Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.

Плохо:

// Создаёт заказ
POST /orders

Повтор запроса создаст два заказа!

Хорошо:

// Создаёт заказ
POST /v1/orders
X-Idempotency-Token: <случайная строка>

Клиент на своей стороне запоминает X-Idempotency-Token, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.

Альтернатива:

// Создаёт черновик заказа
POST /v1/orders/drafts
→
{ "draft_id" }
// Подтверждает черновик заказа
PUT /v1/orders/drafts/{draft_id}
{ "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), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.

17. Избегайте неатомарных операций

С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.

Плохо:

// Возвращает список рецептов
GET /v1/recipes
→
{
  "recipes": [{
    "id": "lungo",
    "volume": "200ml"
  }, {
    "id": "latte",
    "volume": "300ml"
  }]
}
// Изменяет параметры
PATCH /v1/recipes
{
  "changes": [{
    "id": "lungo",
    "volume": "300ml"
  }, {
    "id": "latte",
    "volume": "-1ml"
  }]
}
→ 400 Bad Request
// Перечитываем список
GET /v1/recipes
→
{
  "recipes": [{
    "id": "lungo",
    // Это значение изменилось
    "volume": "300ml"
  }, {
    "id": "latte",
    // А это нет
    "volume": "300ml"
  }]
}

— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.

Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.

Лучше:

PATCH /v1/recipes
{
  "changes": [{
    "recipe_id": "lungo",
    "volume": "300ml"
  }, {
    "recipe_id": "latte",
    "volume": "-1ml"
  }]
}
// Можно воспользоваться статусом
// «частичного успеха», 
// если он предусмотрен протоколом
→ 200 OK
{
  "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, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.

Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:

PATCH /v1/recipes
{
  "idempotency_token",
  "changes": [{
    "recipe_id": "lungo",
    "volume": "300ml"
  }, {
    "recipe_id": "latte",
    "volume": "400ml"
  }]
}
→ 200 OK
{
  "changes": [{
    …
    "status": "success"
  }, {
    …
    "status": "fail",
    "error": {
      "reason":
        "too_many_requests"
    }
  }]
}

Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.

PATCH /v1/recipes
{
  "idempotency_token",
  "changes": [{
    "recipe_id": "lungo",
    "volume": "300ml"
  }, {
    "recipe_id": "latte",
    "volume": "400ml"
  }]
}
→ 200 OK
{
  "changes": [{
    …
    "status": "success"
  }, {
    …
    "status": "success",
  }]
}

По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.

Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.

На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.

18. Не изобретайте безопасность

Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.

Во-первых, почти всегда процедуры, обеспечивающие безопасность той или иной операции, уже разработаны. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки Man-in-the-Middle, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.

Во-вторых, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен атаке по времени, а веб-сервер — атаке с разделением запросов.

19. Декларируйте технические ограничения явно

У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.

Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.

То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.

20. Считайте трафик

В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.

Три основные причины раздувания объёма трафика достаточно очевидны:

  • не предусмотрен постраничный перебор данных;

  • не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.);

  • клиент слишком часто запрашивает данные и/или слишком мало их кэширует.

Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:

  • не злоупотребляйте асинхронными интерфейсами;

    • с одной стороны, они позволяют нивелировать многие технические проблемы с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;

    • с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;

  • объявляйте явную политику перезапросов (например, посредством заголовка Retry-After);

    • да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);

  • если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);

  • если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.

Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.

21. Избегайте неявных частичных обновлений

Один из самых частых антипаттернов в разработке 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-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;

  • наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);

    • кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.

Лучше: разделить эндпойнт. Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем разделе.

// Создаёт заказ из двух напитков
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"
  }],
  …
}

Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения.

Продуктовое качество API

Помимо технологических ограничений, любой реальный API скоро столкнётся и с несовершенством окружающей действительности. Конечно, мы все хотели бы жить в мире розовых единорогов, свободном от накопления legacy, злоумышленников, национальных конфликтов и происков конкурентов. Но, к сожалению или к счастью, живём мы в реальном мире, в котором хороший API должен учитывать всё вышеперечисленное.

22. Используйте глобально уникальные идентификаторы

Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например UUID-4). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.

Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. urn:order:<uuid> (или просто order:<uuid>), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.

Отдельное важное следствие: не используйте инкрементальные номера как идентификаторы. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.

NB: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.

23. Предусмотрите ограничения доступа

С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.

Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку 429 Too Many Requests или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.

Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.

24. Не предоставляйте endpoint-ов массового получения чувствительных данных

Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.

Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.

25. Локализация и интернационализация

Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка Accept-Language), даже если на текущем этапе нужды в локализации нет.

Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.

Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.

Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».

Важно: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение localized_message адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение details.checks_failed[].message написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.

Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс localized_.

И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.

Комментарии (31)


  1. panzerfaust
    19.09.2022 08:17
    +7

    Ошибки должны быть информативными

    Я выработал для себя правило (но не изобрел его, само собой), что сообщение об ошибке должно строиться по шаблону "что случилось - почему так случилось - что с этим делать". Что-то вроде "Невозможно выполнить загрузку. Удаленный сервер недоступен. Попробуйте выполнить загрузку позднее или обратитесь в администратору. Детали ошибки: ххх". Просто ужасно, когда люди не парятся и просто швыряют в клиента exception.getMessage() не глядя.


    1. forgotten Автор
      19.09.2022 10:03

      Это весьма дельная мысль, спасибо.


  1. nin-jin
    19.09.2022 14:52
    +3

    если вам необходимо, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно

    Не разумно. Кто не хочет читать доку не будет её читать, а для того, кто с ней знаком, это будет постоянным раздражителем.

    Хорошо: POST /orders/cancel

    Отменяет который заказ? Последний? Все? Почему хорошо удалять заказ неидемпотентным методом создания ресурса?

    Хорошо: POST /v1/orders/statistics/aggregate

    И что хорошего в запросе данных некешируемым методом создания ресурса?

    Если операция модифицирующая, это должно быть очевидно из сигнатуры.

    По POST /v1/orders/statistics/aggregate как-то не очень очевидно, что оно не модифицирующее.

    Плохо: "date": "11/12/2020" — существует огромное количество стандартов записи дат,

    Международный стандарт только один - ISO8601.

    Хорошо: "duration_ms": 5000, "duration": "5000ms", ...

    'PT5S'. Читайте IS8601 и не изобретайте своих "стандартов".

    Хорошо: begin_transition / end_transition

    Так этот "конец транзакции" применяет её или отменяет?

    Ошибка: доставка становится бесплатной при стоимости заказа от 9000 тугриков

    А если стоимость заказа меньше 9000, то в качестве delivery_fee и total клиент сможет указывать любое положительное число? Скажите же скорее адрес этого магазина - скуплю его за бесценок.

    Возвращает указанный limit записей, отсортированных по дате создания, начиная с первой записи, созданной позднее, чем запись с указанным id

    Этот метод нормально работает только с сортировкой по индексу без фильтрации.

    Другой вариант организации таких списков — возврат курсора cursor, который используется вместо record_id

    Не забудьте про сборщик мусора для старых курсоров, пока они не расплодились как кролики.

    подход с курсором не означает, что limit/offset использовать нельзя — ничто не мешает сделать двойной интерфейс

    И время запроса будет пропорционально величине offset.

    // Подтверждает черновик заказа PUT /v1/orders/drafts/{draft_id}

    И стирает все его данные, кроме поля confirmed, если вы реализуете метод PUT в соответствии со спецификацией.

    Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения.

    Два пользователя изменили в оффлайне одно и то же поле одного и того же объекта (поправили описание товара, например). Как такой формат патчей поможет "перебазировать" изменения не потеряв изменения?


    1. forgotten Автор
      19.09.2022 14:56

      Не разумно. Кто не хочет читать доку не будет её читать, а для того, кто с ней знаком, это будет постоянным раздражителем.

      Очевидно, процент тех, кто всё-таки прочитает доку будет выше по сравнению с ситуацией без раздражителя. Ну а стоит ли раздражение многих этой дополнительно подушки безопасности — зависит от цены ошибки.


    1. forgotten Автор
      19.09.2022 15:00

      Отменяет который заказ? Последний? Все?

      Тот, который указан в теле запроса

      Почему хорошо удалять заказ неидемпотентным методом создания ресурса?

      POST не является методом создания ресурса. «The POST method requests that the target resource process the representation enclosed in the request according to the resource's own specific semantics» — https://www.rfc-editor.org/rfc/rfc7231#page-25

      То, что метод может быть неидемпотентным не означает, что он обязан быть идемпотентным (как раз наоборот, далее в тексте предлагается все методы обязательно делать идемпотентными). POST здесь используется именно для того, чтобы соответствовать процитированной семантике HTTP-методов согласно RFC. `PUT /orders/{id}/cancellation` тоже допустим.


    1. forgotten Автор
      19.09.2022 15:04
      -1

      И что хорошего в запросе данных некешируемым методом создания ресурса?

      POST не является методом создания ресурса. «The POST method requests
      that the target resource process the representation enclosed in the
      request according to the resource's own specific semantics» — https://www.rfc-editor.org/rfc/rfc7231#page-25

      В вопросе кэширования результатов «тяжёлых» вычислений нас интересует прежде всего серверный кэш, а не клиентский, правда же? Доступ к нему вполне можно и через POST организовать — вновь вопрос того, что мы считаем важным: индицировать семантику операции (вы запускаете сложный алгоритм) или сэкономить какие-то байты на клиентском кэшировании.

      Наконец, результаты POST могут кэшироваться (хотя я лично не рекомендую это делать), см. тот же RFC.


      1. nin-jin
        19.09.2022 15:48

        POST имеет семантику "оправки данных", а не "индикации сложности алгоритма".


        1. forgotten Автор
          19.09.2022 15:49
          -1

          Нет.


    1. forgotten Автор
      19.09.2022 15:08
      -1

      Международный стандарт только один - ISO8601

      Во-первых, это неправда. Есть ещё как минимум RFC 3339 https://www.rfc-editor.org/rfc/rfc3339 и RFC 7231 https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1

      Во-вторых, независимо от отсутствия / наличия единого стандарта, даты в интернете вы можете получить в каком угодно виде. См. вторую часть фразы — «Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе


      1. nin-jin
        19.09.2022 15:43

        Первый - подмножество ISO8601, второй - специфичное легаси HTTP протокола, которое нигде больше не используется.

        В кривых API я много чего могу получить. В любом нормальном API время представлено в стандартном виде.


        1. forgotten Автор
          19.09.2022 15:48
          -1

          Первый - подмножество ISO8601

          Нет.


        1. forgotten Автор
          19.09.2022 16:57
          +1

          В частности, 2022-09-19 16:46:00 — валидная дата с т.з. RFC 3339, но невалидная с т.з. ISO8601. Запись 2022-09-19T16:46:00-00:00 валидна с т.з RFC, но невалидна с т.з. ISO


    1. forgotten Автор
      19.09.2022 15:16
      -1

      Так этот "конец транзакции" применяет её или отменяет?

      В тексте не transaction, а transition. Если существует двусмысленность (любого из терминов), следует выбрать не-двусмысленный вариант (для обоих терминов).


      1. nin-jin
        19.09.2022 15:35

        Да без разницы, transition тоже можно как завершить, так и отменить.


        1. forgotten Автор
          19.09.2022 15:38

          С трудом себе это представляю. Если я анимировал положение элемента, то отменить это действие как если б его не было уже не получится.


          1. nin-jin
            19.09.2022 15:52

            Получится - он вернётся в исходное положение, а не финальное.


    1. forgotten Автор
      19.09.2022 15:17

      А если стоимость заказа меньше 9000, то в качестве delivery_fee и total
      клиент сможет указывать любое положительное число? Скажите же скорее
      адрес этого магазина - скуплю его за бесценок.

      Я не понял этого комментария.


      1. nin-jin
        19.09.2022 15:32

        Вы тут на сервере валидируете значения, которые должны вычисляться сервером, а не задаваться клиентом.


        1. forgotten Автор
          19.09.2022 15:37

          Там, где речь идёт о деньгах, обычно лучше передать с клиента ту цифру, которую клиент видел глазами (напрямую в виде значения, или закодированную в offer_id) и валидировать её на сервере. Потому что между этими двумя моментами (клиент видел цифру стоимости доставки — клиент подтвердил заказ) что-то могло измениться (закончилась скидка, увеличился сурж), и клиент будет неприятно удивлён.


          1. nin-jin
            19.09.2022 15:53

            Всё это и так фиксируется в оффере, по которому вы собираетесь валидировать.


            1. forgotten Автор
              19.09.2022 15:57

              Может фиксируется, а может и нет. Оффер может содержать все параметры заказа, а может просто валидировать, что клиент их не поменял.


    1. forgotten Автор
      19.09.2022 15:18
      -1

      Этот метод нормально работает только с сортировкой по индексу без фильтрации.

      Не забудьте про сборщик мусора для старых курсоров, пока они не расплодились как кролики.

      И время запроса будет пропорционально величине offset.

      Я не обсуждаю здесь детали технической имплементации.


    1. forgotten Автор
      19.09.2022 15:20
      +1

      И стирает все его данные, кроме поля confirmed, если вы реализуете метод PUT в соответствии со спецификацией.

      Действительно, URL должен быть `PUT /v1/orders/drafts/{draft_id}/confirm`. Поправлю, спасибо.


    1. forgotten Автор
      19.09.2022 15:20

      Два пользователя изменили в оффлайне одно и то же поле одного и того же
      объекта (поправили описание товара, например). Как такой формат патчей
      поможет "перебазировать" изменения не потеряв изменения?

      Никак. Чудес не присходит. Вопрос в том, как применить изменения, если пользователи патчили разные поля.


      1. nin-jin
        19.09.2022 15:55

        Ну значит CRDT творят чудеса.


        1. forgotten Автор
          19.09.2022 16:03
          -1

          CRDT и представляют формат описания атомарных изменений, в точности как в тексте описано.


          1. nin-jin
            19.09.2022 16:26

            Даже близко не похоже.


    1. tolik_anabolik
      20.09.2022 00:13

      Два пользователя изменили в оффлайне одно и то же поле одного и того же объекта

      Для разрешения ситуации с потерянным обновлением надо применять родные средства http – условные запросы. Ответ на запрос состояния объекта перед редактированием возвращает вам Etag. А в запрос редактирования вы передается условие If-Match.

      Автор в статье указал пример с публикацией нового ресурса через POST. По спецификации метод POST не обязан быть идемпотентным. Но вполне логично предположить, что вряд ли при случайном повторном запросе (от браузера / api-gateway или какого промежуточного прокси) вы захотите, чтобы создавался дубль объекта. Для обхода этой ситуации автор предлагает на клиенте генерировать некий ИД, по которому на сервере мы будем определять - приходил ли уже такой запрос или нет. Если приходил, то повторно ничего создавать не надо.


      1. nin-jin
        20.09.2022 01:40

        А в запрос редактирования вы передается условие If-Match.

        Я не хочу терять свой параграф текста только потому, что кто-то другой изменил в другом параграфе опечатку.


        1. tolik_anabolik
          21.09.2022 23:23

          Предлагаете другому потерять исправление опечатки?

          Это уже вопрос совместного доступа и резолва конфликтов. Это проблема уровня приложения, а не транспорта. Транспорт вам тут никак не поможет.


          1. nin-jin
            22.09.2022 00:18

            Предлагаю ничего не терять. CRDT разрешает конфликты на уровне транспорта.