25 правил разработки программных интерфейсов [издание третье, дополненное и расширенное]

4945fca7ea684051c10c0b6000ee08cf

Это — очередная (уже третья) ревизия главы 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, предполагая, что одинаковые значения означают

© Habrahabr.ru