diff --git a/docs/API.ru.epub b/docs/API.ru.epub index 61bf8dd..9e2b98b 100644 Binary files a/docs/API.ru.epub and b/docs/API.ru.epub differ diff --git a/docs/API.ru.html b/docs/API.ru.html index cd87ed6..cd528b7 100644 --- a/docs/API.ru.html +++ b/docs/API.ru.html @@ -634,7 +634,7 @@ ul.references li p a.back-anchor {

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

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

Более подробно о жизненном цикле API и политиках выпуска новых версий будет рассказано в разделе II.

Глава 5. О версионировании

-

Здесь и далее мы будем придерживаться принципов версионирования semver:

+

Здесь и далее мы будем придерживаться принципов версионирования semver.

  1. Версия API задаётся тремя цифрами вида 1.2.3.
  2. Первая цифра (мажорная версия) увеличивается при обратно несовместимых изменениях в API.
  3. @@ -642,7 +642,8 @@ ul.references li p a.back-anchor {
  4. Третья цифра (патч) увеличивается при выпуске новых версий, содержащих только исправление ошибок.

Выражения «мажорная версия API» и «версия API, содержащая обратно несовместимые изменения функциональности» тем самым следует считать эквивалентными.

-

Более подробно о политиках версионирования будет рассказано в разделе II. В разделе I мы ограничимся лишь указанием версии API в формате v1, v2, etc.

Глава 6. Условные обозначения и терминология

+

Обычно (но не обязательно) устанавливается, что на последнюю стабильную версию API можно сослаться как по полной версии (1.2.3), так и по усечённой (1.2 или просто 1). Некоторые системы поддерживают и более сложные схемы указания подключаемой версии (например, ^1.2.3 читается как «подключить последнюю стабильную версию, обратно совместимую с версией 1.2.3) или дополнительные шорткаты (например 1.2-beta для подключения бета-версии API семейства 1.2). В настоящей книге мы будем в основном использовать обозначения вида v1 (v2, v3 и так далее) для обозначения последнего стабильного релиза API семейства 1.x.x.

+

Более подробно о смысле и политиках такого версионирования читайте в главе «Постановка проблемы обратной совместимости».

Глава 6. Условные обозначения и терминология

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

Для составных частей сущности, к сожалению, достаточно нейтрального термина нам придумать не удалось, поэтому мы используем слова «поля» и «методы».

Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо GET /v1/orders вполне может быть вызов метода orders.get(), локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.

@@ -1414,11 +1415,13 @@ app.display(offers);

Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чём мы поговорим в разделе II.

Глава 11. Описание конечных интерфейсов

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

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

-
0. Правила — это всего лишь обобщения
-

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

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

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

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

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

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

+

Читабельность и консистентность

+

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

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

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

Плохо:

@@ -1445,8 +1448,7 @@ POST /v1/orders/statistics/aggregate

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

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

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

К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.

-

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

+

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

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

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

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

@@ -1458,14 +1460,11 @@ POST /v1/orders/statistics/aggregate "duration": {"unit": "ms", "value": 5000}.

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

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

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

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

-

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

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

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

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

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

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

В XXI веке давно уже нет нужды называть переменные покороче.

Плохо: order.time() — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…

Хорошо: order.get_estimated_delivery_time()

@@ -1477,7 +1476,7 @@ strpbrk (str1, str2)

Возможно, автору этого API казалось, что аббревиатура pbrk что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк str1, str2 является набором символов для поиска.

Хорошо: str_search_for_characters (lookup_character_set, str)
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение string до str выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.

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

Если поле называется recipe — мы ожидаем, что его значением является сущность типа Recipe. Если поле называется recipe_id — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности Recipe.

То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — objects, children; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений.

Плохо: GET /news — неясно, будет ли получена какая-то конкретная новость или массив новостей.

@@ -1493,7 +1492,7 @@ GET /coffee-machines/{id}/functions

Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).

Хорошо: GET /v1/coffee-machines/{id}/builtin-functions-list

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

Плохо: begin_transition / stop_transition
begin и stop — непарные термины; разработчик будет вынужден рыться в документации.

Хорошо: begin_transition / end_transition либо start_transition / stop_transition.

@@ -1513,55 +1512,7 @@ str_replace(needle, replace, haystack)
  • первый из методов находит только первое вхождение строки needle, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.
  • Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.

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

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

    -

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

    -

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

    -

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

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

    Правило можно ещё сформулировать так: не заставляйте разработчика клиента гадать.

    -

    Плохо:

    -
    // Создаёт заказ и возвращает его 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
    -
    -
    10. Избегайте двойных отрицаний
    +
    7. Избегайте двойных отрицаний

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

    Лучше: "prohibit_calling": true или "avoid_calling": true
    @@ -1581,7 +1532,7 @@ GET /v1/users/{id}/orders }

    — то разработчику потребуется вычислить флаг !beans_absence && !cup_absence!(beans_absence || cup_absence), а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».

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

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

    POST /v1/orders
     {}
    @@ -1595,7 +1546,8 @@ GET /v1/users/{id}/orders
         order.contactless_delivery == false) { … }
     

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

    -

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

    +

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

    +

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

    Хорошо

    POST /v1/orders
     {}
    @@ -1640,85 +1592,382 @@ POST /users
     }
     

    NB: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в abolish_spending_limit. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.

    -
    12. Избегайте неявных частичных обновлений
    +
    9. Отсутствие результата — тоже результат
    +

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

    +

    Плохо

    +
    POST /search
    +{
    +  "query": "lungo",
    +  "location": <положение пользователя>
    +}
    +→ 404 Not Found
    +{
    +  "localized_message":
    +    "Рядом с вами не делают лунго"
    +}
    +
    +

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

    +

    Хорошо:

    +
    POST /search
    +{
    +  "query": "lungo",
    +  "location": <положение пользователя>
    +}
    +→ 200 OK
    +{
    +  "results": []
    +}
    +
    +

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

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

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

    Плохо:

    -
    // Возвращает состояние заказа
    -// по его идентификатору
    -GET /v1/orders/123
    +
    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. Do you mean 'lungo'?"
    +      },
    +      {
    +        "field": "position.latitude",
    +        "error_type": "constraint_violation",
    +        "constraints": {
    +          "min": -90,
    +          "max": 90
    +        },
    +        "message":
    +          "'position.latitude' value must fall in [-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 для людей мы часто забываем, что работать с 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",
    -  "delivery_address",
    -  "client_phone_number",
    -  "client_phone_number_ext",
    -  "updated_at"
    +  // Заказ создаётся в явном статусе
    +  // «идёт проверка»
    +  "status": "checking",
    +  …
     }
    -// Частично перезаписывает заказ
    -PATCH /v1/orders/123
    -{ "delivery_address" }
    -→
    -{ "delivery_address" }
     
    -

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

    -

    Во-первых, экономия объёма ответа в современных условиях требуется редко. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени.

    -

    Перерасход трафика возникает, если:

    +
    // Возвращает заказ по его id
    +GET /v1/orders/{id}
    +→
    +{ "order_id", "status" … }
    +
    +
    // Возвращает все заказы пользователя
    +// во всех статусах
    +GET /v1/users/{id}/orders
    +
    +

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

    +

    Плохо: {"error": "email malformed"} +— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю +Хорошо:

    +
    {
    +  // Machine-readable
    +  "status": "validation_failed",
    +  // An array; if there are several
    +  // errors, the user might correct
    +  // them all at once
    +  "failed_checks": [
    +     {
    +       "field: "email",
    +       "error_type": "malformed",
    +       // Localized human-readable message
    +       "message": "email malformed"
    +     }
    +  ]
    +}
    +
    +
    13. Указывайте время жизни ресурсов и политики кэширования
    +

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

    +

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

    +

    Плохо:

    +
    // Возвращает цену лунго в кафе,
    +// ближайшем к указанной точке
    +GET /v1/price?recipe=lungo
    +  &longitude={longitude}&latitude={latitude}
    +→
    +{ "currency_code", "price" }
    +
    +

    Возникает два вопроса:

    -

    Во всех трёх случаях передача части полей в лучшем случае замаскирует проблему, но не решит. Более оправдан следующий подход:

    - -

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

    -

    В-третьих, этот подход может как-то работать при необходимости перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как удалить client_phone_number_ext?

    -

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

    -

    Хорошо: можно применить одну из двух стратегий.

    -

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

    -
    // Возвращает состояние заказа
    -// по его идентификатору
    -GET /v1/orders/123
    +

    Хорошо: +Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком Cache-Control. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.

    +
    // Возвращает предложение: за какую сумму
    +// наш сервис готов приготовить лунго
    +GET /v1/price?recipe=lungo
    +  &longitude={longitude}&latitude={latitude}
     →
     {
    -  "order_id",
    -  "delivery_details": {
    -    "address"
    -  },
    -  "client_details": {
    -    "phone_number",
    -    "phone_number_ext"
    -  },
    -  "updated_at"
    +  "offer": {
    +    "id",
    +    "currency_code",
    +    "price",
    +    "conditions": {
    +      // До какого времени валидно предложение
    +      "valid_until",
    +      // Где валидно предложение:
    +      // * город
    +      // * географический объект
    +      // * …
    +      "valid_within"
    +    }
    +  }
     }
    -// Полностью перезаписывает
    -// информацию о доставке заказа
    -PUT /v1/orders/123/delivery-details
    -{ "address" }
    -// Полностью перезаписывает
    -// информацию о клиенте
    -PUT /v1/orders/123/client-details
    -{ "phone_number" }
     
    -

    Теперь для удаления client_phone_number_ext достаточно не передавать его в PUT client-details. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (order_id и updated_at) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить updated_at?). В этом подходе также можно в ответах операций PUT возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования).

    -

    Вариант 2: разработать формат описания атомарных изменений.

    -
    POST /v1/order/changes
    -X-Idempotency-Token: <см. следующий раздел>
    +
    14. Пагинация, фильтрация и курсоры
    +

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

    +

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

    +

    Плохо:

    +
    // Возвращает указанный limit записей,
    +// отсортированных по дате создания
    +// начиная с записи с номером offset
    +GET /v1/records?limit=10&offset=100
    +
    +

    На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.

    +
      +
    1. Каким образом клиент узнает о появлении новых записей в начале списка? +Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit? Представим себе ситуацию: +
        +
      • клиент обрабатывает записи в порядке поступления;
      • +
      • произошла какая-то проблема, и накопилось большое количество необработанных записей;
      • +
      • клиент запрашивает новые записи (offset=0), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit;
      • +
      • клиент вынужден продолжить перебирать записи (увеличивая offset) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает;
      • +
      • таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
      • +
      +
    2. +
    3. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?
      +Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
    4. +
    5. Какие параметры кэширования мы можем выставить на этот эндпойнт?
      +Никакие: повторяя запрос с теми же limit-offset, мы каждый раз получаем новый набор записей.
    6. +
    +

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

    +
    // Возвращает указанный 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
     {
    -  "changes": [{
    -    "type": "set",
    -    "field": "delivery_address",
    -    "value": <новое значение>
    -  }, {
    -    "type": "unset",
    -    "field": "client_phone_number_ext"
    -  }]
    +  // Какие-то дополнительные параметры фильтрации
    +  "filter": {
    +    "category": "some_category",
    +    "created_date": {
    +      "older_than": "2020-12-07"
    +    }
    +  }
    +}
    +→
    +{
    +  "cursor"
     }
     
    -

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

    -
    13. Все операции должны быть идемпотентны
    +
    // Последующие запросы
    +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 нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.

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

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

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

    Плохо:

    @@ -1784,7 +2033,7 @@ X-Idempotency-Token: <токен>
  • нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
  • клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
  • -
    14. Избегайте неатомарных операций
    +
    17. Избегайте неатомарных операций

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

    Плохо:

    // Возвращает список рецептов
    @@ -1923,319 +2172,147 @@ GET /v1/recipes
     

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

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

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

    -
    15. Указывайте политики кэширования
    -

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

    -

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

    -

    Плохо:

    -
    // Возвращает цену лунго в кафе,
    -// ближайшем к указанной точке
    -GET /v1/price?recipe=lungo
    -  &longitude={longitude}&latitude={latitude}
    -→
    -{ "currency_code", "price" }
    -
    -

    Возникает два вопроса:

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

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

    +

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

    +

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

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

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

    +

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

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

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

    +

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

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

    Хорошо: -Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком Cache-Control. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так:

    -
    // Возвращает предложение: за какую сумму
    -// наш сервис готов приготовить лунго
    -GET /v1/price?recipe=lungo
    -  &longitude={longitude}&latitude={latitude}
    -→
    -{
    -  "offer": {
    -    "id",
    -    "currency_code",
    -    "price",
    -    "conditions": {
    -      // До какого времени валидно предложение
    -      "valid_until",
    -      // Где валидно предложение:
    -      // * город
    -      // * географический объект
    -      // * …
    -      "valid_within"
    -    }
    -  }
    -}
    -
    -
    16. Пагинация, фильтрация и курсоры
    -

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

    -

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

    -

    Плохо:

    -
    // Возвращает указанный limit записей,
    -// отсортированных по дате создания
    -// начиная с записи с номером offset
    -GET /v1/records?limit=10&offset=100
    -
    -

    На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.

    -
      -
    1. Каким образом клиент узнает о появлении новых записей в начале списка? -Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit? Представим себе ситуацию: +

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

        -
      • клиент обрабатывает записи в порядке поступления;
      • -
      • произошла какая-то проблема, и накопилось большое количество необработанных записей;
      • -
      • клиент запрашивает новые записи (offset=0), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit;
      • -
      • клиент вынужден продолжить перебирать записи (увеличивая offset) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает;
      • -
      • таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
      • +
      • не злоупотребляйте асинхронными интерфейсами; с одной стороны, они позволяют избежать многих технических проблем с производительностью 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-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля;
      • +
      • наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); +
          +
        • кроме того, часто в рамках той же экономии ответ на операцию частичного обновления пуст; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
      • -
      • Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?
        -Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
      • -
      • Какие параметры кэширования мы можем выставить на этот эндпойнт?
        -Никакие: повторяя запрос с теми же 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
    +

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

    +
    +

    // Создаёт заказ из двух напитков +POST /v1/orders/ { - sort_by: [ - { "field": "date_modified", "order": "desc" } - ] +"parameters": { +"delivery_address" } -→ -{ "id", "cursor" } -

    -
    // Позволяет получить часть представления
    -GET /v1/record-views/{id}?cursor={cursor}
    -
    -

    Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).

    -

    Вариант 2: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:

    -
    POST /v1/records/modified/list
    -{
    -  // Опционально
    -  "cursor"
    +"items": [{
    +"recipe": "lungo",
    +}, {
    +"recipe": "latte",
    +"milk_type": "oats"
    +}]
     }
     →
     {
    -  "modified": [
    -    { "date", "record_id" }
    -  ],
    -  "cursor"
    -}
    -
    -

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

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

    При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим 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. Do you mean 'lungo'?"
    -      },
    -      {
    -        "field": "position.latitude",
    -        "error_type": "constraint_violation",
    -        "constraints": {
    -          "min": -90,
    -          "max": 90
    -        },
    -        "message":
    -          "'position.latitude' value must fall in [-90, 90] interval"
    -      }
    -    ]
    -  }
    -}
    -
    -

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

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

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

    -
    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" }]
    +"order_id",
    +"created_at",
    +"parameters": {
    +"delivery_address"
     }
    +"items": [
    +{"item_id", "status"},
    +{"item_id", "status"}
    +]
    +}

    +
    +

    // Изменяет параметры заказа +PUT /v1/orders/{id}/parameters +{"delivery_address"} → -409 Conflict -{ - "reason": "price_changed", - "details": [{ "item_id": "123", "actual_price": "0.20" }] -} -// Повторный запрос -// с актуальной ценой -POST /v1/orders -{ - "items": [{ "item_id": "123", "price": "0.20" }] -} +{"delivery_address"}

    +
    +

    // Частично перезаписывает заказ +// обновляет объём второго напитка +PUT /v1/orders/{id}/items/{item_id} +{"recipe", "volume", "milk_type"} → -409 Conflict +{"recipe", "volume", "milk_type"}

    +
    +

    Теперь для удаления volume достаточно не передавать его в PUT items/{item_id}. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (created_at и status) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить created_at?). В этом подходе также можно в ответах операций PUT возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты.

    +

    Ещё лучше: разработать формат описания атомарных изменений.

    +
    POST /v1/order/changes
    +X-Idempotency-Token: <см. следующий раздел>
     {
    -  "reason": "order_limit_exceeded",
    -  "localized_message": "Лимит заказов превышен"
    +  "changes": [{
    +    "type": "set",
    +    "field": "delivery_address",
    +    "value": <новое значение>
    +  }, {
    +    "type": "unset_item_field",
    +    "item_id",
    +    "field": "volume"
    +  }],
    +  …
     }
     
    -

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

    -

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

    -
    // Создаём заказ с платной доставкой
    -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"
    -}
    -
    -

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

    -
    19. Предусмотрите ограничения
    +

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

    +

    Продуктовое качество 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 или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет такая необходимость.

    +

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

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

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

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

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

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

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

    -

    Плохо

    -
    POST /search
    -{
    -  "query": "lungo",
    -  "location": <положение пользователя>
    -}
    -→ 404 Not Found
    -{
    -  "localized_message":
    -    "Рядом с вами не делают лунго"
    -}
    -
    -

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

    -

    Хорошо:

    -
    POST /search
    -{
    -  "query": "lungo",
    -  "location": <положение пользователя>
    -}
    -→ 200 OK
    -{
    -  "results": []
    -}
    -
    -

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

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

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

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

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

    @@ -2475,7 +2552,19 @@ POST /v1/runtimes/{id}/terminate -

    Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.

    Глава 14. О ватерлинии айсберга

    +

    Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.

    +

    Одновременный доступ к нескольким минорным версиям API

    +

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

    +

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

    +

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

    +

    Несомненный и очень важный плюс semver состоит в том, что она предоставляет возможность подключать версии с нужной гранулярностью:

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

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

    +

    NB. Часто в защиту политики только одной доступной версии API можно услышать аргумент о том, что кодом SDK или сервера API проблема обратной совместимости не исчерпывается, т.к. он может опираться на какие-то неверсионируемые сервисы (например, на какие-то данные в БД, которые должны разделяться между всеми версиями API) или другие API, придерживающиеся менее строгих политик. Это соображение, на самом деле, является лишь дополнительным аргументом в пользу изоляции таких зависимостей (см. главу «Блокнот душевного покоя»), поскольку это означает только лишь то, что изменения в этих подсистемах могут привести к неработоспособности API сами по себе.

    Глава 14. О ватерлинии айсберга

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

    1. Предоставляйте минимальный объём функциональности

    В любой момент времени ваш API подобен айсбергу: у него есть видимая (документированная) часть и невидимая — недокументированная. В хорошем API эти две части соотносятся друг с другом примерно как надводная и подводная часть настоящего айсберга, 1 к 10. Почему так? Из двух очевидных соображений.

    @@ -3449,7 +3538,8 @@ ProgramContext.dispatch = (action) => {

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

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

    -

    Глава 28. Технические способы борьбы с несанкционированным доступом к API

    + +

    NB. В некоторых юрисдикциях IP-адреса считаются персональными данными, и их сбор также запрещён. Мы не берёмся давать советы, каким образом поставщик API должен одновременно уметь бороться с незаконным контентом на платформе и при этом не иметь доступа к IP-адресам пользователей. Предполагаем, что для соответствия такого рода законодательству необходимо будет хранить статистику (и банить пользователей) по хэшам IP-адресов. (На всякий случай, мы не будем здесь уточнять, что построение радужной таблицы SHA-256 хэшей для 4 млрд возможных IPv4 адресов — задача на несколько часов работы обычного офисного компьютера.)

    Глава 28. Технические способы борьбы с несанкционированным доступом к API

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

    Задача отсеивания нежелательных запросов, в общем случае, состоит из трёх шагов:

      diff --git a/docs/API.ru.pdf b/docs/API.ru.pdf index 4ee6acb..aef3938 100644 Binary files a/docs/API.ru.pdf and b/docs/API.ru.pdf differ diff --git a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md index d90fbb3..e41c1fa 100644 --- a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md @@ -4,9 +4,9 @@ Важное уточнение под номером ноль: -##### 0. Правила — это всего лишь обобщения +##### 0. Правила не должны применяться бездумно -Правила не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно. +Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно. Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам _необходимо_, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно. @@ -14,9 +14,13 @@ Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов. +#### Обеспечение читабельности и консистентности + +Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API. + ##### Явное лучше неявного -Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование. +Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование. **Плохо**: ``` @@ -55,9 +59,7 @@ POST /v1/orders/statistics/aggregate ##### Указывайте использованные стандарты -К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах. - -Поэтому _всегда_ указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе. +К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому *всегда* указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе. **Плохо**: `"date": "11/12/2020"` — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц. @@ -70,18 +72,12 @@ POST /v1/orders/statistics/aggregate либо `"duration": "5000ms"` либо - `"duration": {"unit": "ms", "value": 5000}`. + `"duration": { "unit": "ms", "value": 5000 }`. Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты. Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. -##### Сохраняйте точность дробных чисел - -Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных. - -Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип. - ##### Сущности должны именоваться конкретно Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make. @@ -125,7 +121,7 @@ strpbrk (str1, str2) **Хорошо**: `"task.is_finished": true`. -Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`. +Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date` и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`. Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания. @@ -163,19 +159,296 @@ str_replace(needle, replace, haystack) Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю. -##### Используйте глобально уникальные идентификаторы +##### Избегайте двойных отрицаний -Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором. +**Плохо**: `"dont_call_me": false` +— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки. -Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:` (или просто `order:`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования. +**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true` +— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не». -Отдельное важное следствие: **не используйте инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений. +Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага: -**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо. +``` +GET /coffee-machines/{id}/stocks +→ +{ + "has_beans": true, + "has_cup": true +} +``` + +Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов: + +``` +{ + "beans_absence": false, + "cup_absence": false +} +``` +— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence`, что эквивалентно `!(beans_absence || cup_absence)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги». + +##### Избегайте неявного приведения типов + +Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: + +``` +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 /users +{ … } +→ +// Пользователи создаются по умолчанию +// с указанием лимита трат в месяц +{ + … + "spending_monthly_limit_usd": "100" +} +// Для отмены лимита требуется +// указать значение null +POST /users +{ + … + "spending_monthly_limit_usd": null +} +``` + +**Хорошо** +``` +POST /users +{ + // true — у пользователя снят + // лимит трат в месяц + // false — лимит не снят + // (значение по умолчанию) + "abolish_spending_limit": false, + // Необязательное поле, имеет смысл + // только если предыдущий флаг + // имеет значение false + "spending_monthly_limit_usd": "100", + … +} +``` + +**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь. + +##### Отсутствие результата — тоже результат + +Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой. + +**Плохо** +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 404 Not Found +{ + "localized_message": + "Рядом с вами не делают лунго" +} +``` + +Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет. + +**Хорошо**: +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 200 OK +{ + "results": [] +} +``` + +Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.) + +##### Ошибки должны быть информативными + +При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим 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. Do you mean 'lungo'?" + }, + { + "field": "position.latitude", + "error_type": "constraint_violation", + "constraints": { + "min": -90, + "max": 90 + }, + "message": + "'position.latitude' value must fall in [-90, 90] interval" + } + ] + } +} +``` +Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной. + +##### Соблюдайте правильный порядок ошибок + +**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых: +``` +POST /v1/orders +{ + "recipe": "lngo", + "offer" +} +→ 409 Conflict +{ + "reason": "offer_expired" +} +// Повторный запрос +// с новым `offer` +POST /v1/orders +{ + "recipe": "lngo", + "offer" +} +→ 400 Bad Request +{ + "reason": "recipe_unknown" +} +``` +— какой был смысл получать новый `offer`, если заказ всё равно не может быть создан? + +**Во-вторых**, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений. + +**Плохо**: +``` +POST /v1/orders +{ + "items": [{ "item_id": "123", "price": "0.10" }] +} +→ +409 Conflict +{ + "reason": "price_changed", + "details": [{ "item_id": "123", "actual_price": "0.20" }] +} +// Повторный запрос +// с актуальной ценой +POST /v1/orders +{ + "items": [{ "item_id": "123", "price": "0.20" }] +} +→ +409 Conflict +{ + "reason": "order_limit_exceeded", + "localized_message": "Лимит заказов превышен" +} +``` +— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз. + +**В-третьих**, постройте схему: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок. + +``` +// Создаём заказ с платной доставкой +POST /v1/orders +{ + "items": 3, + "item_price": "3000.00" + "currency_code": "MNT", + "delivery_fee": "1000.00", + "total": "10000.00" +} +→ 409 Conflict +// Ошибка: доставка становится бесплатной +// при стоимости заказа от 9000 тугриков +{ + "reason": "delivery_is_free" +} + +// Создаём заказ с бесплатной доставкой +POST /v1/orders +{ + "items": 3, + "item_price": "3000.00" + "currency_code": "MNT", + "delivery_fee": "0.00", + "total": "9000.00" +} +→ 409 Conflict +// Ошибка: минимальная сумма заказа +// 10000 тугриков +{ + "reason": "below_minimal_sum", + "currency_code": "MNT", + "minimal_sum": "10000.00" +} +``` + +Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса. + +#### Правила разработки машиночитаемых API + +В погоне за понятностью концепций API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации. ##### Состояние системы должно быть понятно клиенту -Правило можно ещё сформулировать так: не заставляйте разработчика клиента гадать. +Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус. **Плохо**: ``` @@ -225,205 +498,208 @@ GET /v1/orders/{id} GET /v1/users/{id}/orders ``` +Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема. -##### Избегайте двойных отрицаний - -**Плохо**: `"dont_call_me": false` -— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки. - -**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true` -— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не». - -Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага: +**Плохо**: `{ "error": "email malformed" }` +— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю +**Хорошо**: ``` -GET /coffee-machines/{id}/stocks -→ { - "has_beans": true, - "has_cup": true + // Machine-readable + "status": "validation_failed", + // An array; if there are several + // errors, the user might correct + // them all at once + "failed_checks": [ + { + "field: "email", + "error_type": "malformed", + // Localized human-readable message + "message": "email malformed" + } + ] } ``` -Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов: +##### Указывайте время жизни ресурсов и политики кэширования -``` -{ - "beans_absence": false, - "cup_absence": false -} -``` -— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence` ⇔ `!(beans_absence || cup_absence)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги». +В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Желательно в такой ситуации вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации. -##### Избегайте неявного приведения типов - -Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: - -``` -POST /v1/orders -{} -→ -{ - "contactless_delivery": true -} -``` - -Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого: - -``` -if (Type(order.contactless_delivery) == 'Boolean' && - order.contactless_delivery == false) { … } -``` - -Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`. - -В этих ситуациях универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false. - -**Хорошо** -``` -POST /v1/orders -{} -→ -{ - "force_contact_delivery": false -} -``` - -Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей. +Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать повтором запроса? **Плохо**: ``` -// Создаёт пользователя -POST /users -{ … } +// Возвращает цену лунго в кафе, +// ближайшем к указанной точке +GET /v1/price?recipe=lungo + &longitude={longitude}&latitude={latitude} +→ +{ "currency_code", "price" } +``` +Возникает два вопроса: + * в течение какого времени эта цена действительна? + * на каком расстоянии от указанной точки цена всё ещё действительна? + +**Хорошо**: +Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования. + +``` +// Возвращает предложение: за какую сумму +// наш сервис готов приготовить лунго +GET /v1/price?recipe=lungo + &longitude={longitude}&latitude={latitude} → -// Пользователи создаются по умолчанию -// с указанием лимита трат в месяц { - … - "spending_monthly_limit_usd": "100" -} -// Для отмены лимита требуется -// указать значение null -POST /users -{ - … - "spending_monthly_limit_usd": null + "offer": { + "id", + "currency_code", + "price", + "conditions": { + // До какого времени валидно предложение + "valid_until", + // Где валидно предложение: + // * город + // * географический объект + // * … + "valid_within" + } + } } ``` -**Хорошо** -``` -POST /users -{ - // true — у пользователя снят - // лимит трат в месяц - // false — лимит не снят - // (значение по умолчанию) - "abolish_spending_limit": false, - // Необязательное поле, имеет смысл - // только если предыдущий флаг - // имеет значение false - "spending_monthly_limit_usd": "100", - … -} -``` +##### Пагинация, фильтрация и курсоры -**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь. +Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. -##### Избегайте неявных частичных обновлений +Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать. **Плохо**: ``` -// Возвращает состояние заказа -// по его идентификатору -GET /v1/orders/123 -→ +// Возвращает указанный 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 { - "order_id", - "delivery_address", - "client_phone_number", - "client_phone_number_ext", - "updated_at" + // Какие-то дополнительные параметры фильтрации + "filter": { + "category": "some_category", + "created_date": { + "older_than": "2020-12-07" + } + } } -// Частично перезаписывает заказ -PATCH /v1/orders/123 -{ "delivery_address" } → -{ "delivery_address" } +{ "cursor" } ``` -— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми. +``` +// Последующие запросы +GET /v1/records?cursor=<значение курсора> +{ "records", "cursor" } +``` +Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. `filter` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее. -**Во-первых**, экономия объёма ответа в современных условиях требуется редко. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. +Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один. -Перерасход трафика возникает, если: +**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 нерасширяем — невозможно добавить сортировку по двум и более полям. -Во всех трёх случаях передача части полей в лучшем случае замаскирует проблему, но не решит. Более оправдан следующий подход: +**Хорошо**: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов. - * для «тяжёлых» данных сделать отдельные эндпойнты; - * ввести пагинацию и лимитирование значений полей; - * на всём остальном не пытаться экономить. - -**Во-вторых**, экономия размера ответа выйдет боком как раз при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. - -**В-третьих**, этот подход может как-то работать при необходимости перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`? - -Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, `null`. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API. - -**Хорошо**: можно применить одну из двух стратегий. - -**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. +**Вариант 1**: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде: ``` -// Возвращает состояние заказа -// по его идентификатору -GET /v1/orders/123 -→ +// Создаёт представление по указанным параметрам +POST /v1/record-views { - "order_id", - "delivery_details": { - "address" - }, - "client_details": { - "phone_number", - "phone_number_ext" - }, - "updated_at" + sort_by: [ + { "field": "date_modified", "order": "desc" } + ] } -// Полностью перезаписывает -// информацию о доставке заказа -PUT /v1/orders/123/delivery-details -{ "address" } -// Полностью перезаписывает -// информацию о клиенте -PUT /v1/orders/123/client-details -{ "phone_number" } +→ +{ "id", "cursor" } +``` +``` +// Позволяет получить часть представления +GET /v1/record-views/{id}?cursor={cursor} ``` -Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования). +Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком). -**Вариант 2**: разработать формат описания атомарных изменений. +**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: ``` -POST /v1/order/changes -X-Idempotency-Token: <см. следующий раздел> +POST /v1/records/modified/list { - "changes": [{ - "type": "set", - "field": "delivery_address", - "value": <новое значение> - }, { - "type": "unset", - "field": "client_phone_number_ext" - }] + // Опционально + "cursor" +} +→ +{ + "modified": [ + { "date", "record_id" } + ], + "cursor" } ``` -Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения. +Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто. + +#### Техническое качество API + +Хороший API должен не просто решать проблемы разработчиков и пользователей, но и делать это максимально качественно, т.е. не содержать в себе логических и технических ошибок (и не провоцировать на них разработчика), экономить вычислительные ресурсы и вообще имплементировать лучшие практики в своей предметной области. + +##### Сохраняйте точность дробных чисел + +Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных. + +Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип. ##### Все операции должны быть идемпотентны @@ -661,326 +937,178 @@ PATCH /v1/recipes На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности. -##### Указывайте политики кэширования +##### Не изобретайте безопасность -В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование результатов операции на клиенте является стандартным действием. +Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна. -Желательно в такой ситуации внести ясность; каким образом можно кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации. +**Во-первых**, почти всегда процедуры, обеспечивающие безопасность той или иной операции, *уже разработаны*. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки [Man-in-the-Middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов. + +**Во-вторых**, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен [атаке по времени](https://en.wikipedia.org/wiki/Timing_attack), а веб-сервер — [атаке с разделением запросов](https://capec.mitre.org/data/definitions/105.html). + +##### Декларируйте технические ограничения явно + +У любого поля в вашем API есть ограничения на допустимые значения: размеры текстовых полей, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам. + +Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено. + +То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными. + +##### Считайте трафик + +В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей. + +Три основные причины раздувания объёма трафика достаточно очевидны: + * не предусмотрен постраничный перебор данных; + * не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.); + * клиент слишком часто запрашивает данные и/или слишком мало их кэширует. + +Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций: + * не злоупотребляйте асинхронными интерфейсами; с одной стороны, они позволяют избежать многих технических проблем с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость (если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных); но, с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым; + * объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`); да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK); + * если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы); + * если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по разумеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это как минимум позволит задавать различные политики кэширования для разных данных. + +Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл. + +##### Избегайте неявных частичных обновлений + +Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния. **Плохо**: -``` -// Возвращает цену лунго в кафе, -// ближайшем к указанной точке -GET /v1/price?recipe=lungo - &longitude={longitude}&latitude={latitude} -→ -{ "currency_code", "price" } -``` -Возникает два вопроса: - * в течение какого времени эта цена действительна? - * на каком расстоянии от указанной точки цена всё ещё действительна? -**Хорошо**: -Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так: ``` -// Возвращает предложение: за какую сумму -// наш сервис готов приготовить лунго -GET /v1/price?recipe=lungo - &longitude={longitude}&latitude={latitude} -→ +// Создаёт заказ из двух напитков +POST /v1/orders/ { - "offer": { - "id", - "currency_code", - "price", - "conditions": { - // До какого времени валидно предложение - "valid_until", - // Где валидно предложение: - // * город - // * географический объект - // * … - "valid_within" - } - } -} -``` - -##### Пагинация, фильтрация и курсоры - -Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. - -Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать. - -**Плохо**: -``` -// Возвращает указанный 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" - } + "delivery_address", + "items": [{ + "recipe": "lungo", + }, { + "recipe": "latte", + "milk_type": "oats" + }] +} +→ +{ "order_id" } +``` + +``` +// Частично перезаписывает заказ +// обновляет объём второго напитка +PATCH /v1/orders/{id} +{ + "items": [null, { + "volume": "800ml" + }] +} +→ +{ /* изменения приняты */ } +``` + +Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? Ну и самое неприятное — какой бы вариант вы ни выбрали, это только начало проблем. + +Допустим, мы договорились, что конструкция `{ "items":[null, {…}] }` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию? + +**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта и полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам: + * повышенные размеры запросов и, как следствие, расход трафика; + * необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения; + * невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства. + +Все эти соображения, однако, на поверку оказываются мнимыми: + * причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт); + * концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент — что не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; более того, существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля; + * наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); + * кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга. + +**Лучше**: разделить эндпойнты. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. + +``` +``` +// Создаёт заказ из двух напитков +POST /v1/orders/ +{ + "parameters": { + "delivery_address" } + "items": [{ + "recipe": "lungo", + }, { + "recipe": "latte", + "milk_type": "oats" + }] } → { - "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" } + "order_id", + "created_at", + "parameters": { + "delivery_address" + } + "items": [ + { "item_id", "status"}, + { "item_id", "status"} ] } +``` +``` +// Изменяет параметры заказа +PUT /v1/orders/{id}/parameters +{ "delivery_address" } → -{ "id", "cursor" } +{ "delivery_address" } ``` ``` -// Позволяет получить часть представления -GET /v1/record-views/{id}?cursor={cursor} -``` - -Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком). - -**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: - -``` -POST /v1/records/modified/list -{ - // Опционально - "cursor" -} +// Частично перезаписывает заказ +// обновляет объём второго напитка +PUT /v1/orders/{id}/items/{item_id} +{ "recipe", "volume", "milk_type" } → +{ "recipe", "volume", "milk_type" } +``` +``` + +Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты. + +**Ещё лучше**: разработать формат описания атомарных изменений. + +``` +POST /v1/order/changes +X-Idempotency-Token: <см. следующий раздел> { - "modified": [ - { "date", "record_id" } - ], - "cursor" + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": <новое значение> + }, { + "type": "unset_item_field", + "item_id", + "field": "volume" + }], + … } ``` -Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто. +Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения. -##### Ошибки должны быть информативными +#### Продуктовое качество API -При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API. +Помимо технологических ограничений, любой реальный API скоро столкнётся и с несовершенством окружающей действительности. Конечно, мы все хотели бы жить в мире розовых единорогов, свободном от накопления legacy, злоумышленников, национальных конфликтов и происков конкурентов. Но, к сожалению или к счастью, живём мы в реальном мире, в котором хороший 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. Do you mean 'lungo'?" - }, - { - "field": "position.latitude", - "error_type": "constraint_violation", - "constraints": { - "min": -90, - "max": 90 - }, - "message": - "'position.latitude' value must fall in [-90, 90] interval" - } - ] - } -} -``` -Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной. +Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором. -##### Соблюдайте правильный порядок ошибок +Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:` (или просто `order:`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования. -**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых: -``` -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`, если заказ всё равно не может быть создан? +Отдельное важное следствие: **не используйте инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений. -**Во-вторых**, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений. +**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо. -**Плохо**: -``` -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 вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений. -Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет такая необходимость. +Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость. Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно. @@ -990,41 +1118,6 @@ POST /v1/orders Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API. -##### Отсутствие результата — тоже результат - -Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой. - -**Плохо** -``` -POST /search -{ - "query": "lungo", - "location": <положение пользователя> -} -→ 404 Not Found -{ - "localized_message": - "Рядом с вами не делают лунго" -} -``` - -Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет. - -**Хорошо**: -``` -POST /search -{ - "query": "lungo", - "location": <положение пользователя> -} -→ 200 OK -{ - "results": [] -} -``` - -Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.) - ##### Локализация и интернационализация Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет. diff --git a/src/ru/drafts/05.md b/src/ru/drafts/05.md index eda1a86..a9c3622 100644 --- a/src/ru/drafts/05.md +++ b/src/ru/drafts/05.md @@ -525,6 +525,52 @@ GET /v1/users/{id}/orders } ``` +##### Указывайте время жизни ресурсов и политики кэширования + +В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Желательно в такой ситуации вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации. + +Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать повтором запроса? + +**Плохо**: +``` +// Возвращает цену лунго в кафе, +// ближайшем к указанной точке +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" + } + } +} +``` + ##### Пагинация, фильтрация и курсоры Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. @@ -927,115 +973,102 @@ PATCH /v1/recipes Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл. -##### Указывайте политики кэширования - -В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование результатов операции на клиенте является стандартным действием. - -Желательно в такой ситуации внести ясность; каким образом можно кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации. - -**Плохо**: -``` -// Возвращает цену лунго в кафе, -// ближайшем к указанной точке -GET /v1/price?recipe=lungo - &longitude={longitude}&latitude={latitude} -→ -{ "currency_code", "price" } -``` -Возникает два вопроса: - * в течение какого времени эта цена действительна? - * на каком расстоянии от указанной точки цена всё ещё действительна? - -**Хорошо**: -Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так: -``` -// Возвращает предложение: за какую сумму -// наш сервис готов приготовить лунго -GET /v1/price?recipe=lungo - &longitude={longitude}&latitude={latitude} -→ -{ - "offer": { - "id", - "currency_code", - "price", - "conditions": { - // До какого времени валидно предложение - "valid_until", - // Где валидно предложение: - // * город - // * географический объект - // * … - "valid_within" - } - } -} -``` - ##### Избегайте неявных частичных обновлений +Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния. + **Плохо**: ``` -// Возвращает состояние заказа -// по его идентификатору -GET /v1/orders/123 -→ +// Создаёт заказ из двух напитков +POST /v1/orders/ { - "order_id", "delivery_address", - "client_phone_number", - "client_phone_number_ext", - "updated_at" + "items": [{ + "recipe": "lungo", + }, { + "recipe": "latte", + "milk_type": "oats" + }] } -// Частично перезаписывает заказ -PATCH /v1/orders/123 -{ "delivery_address" } → -{ "delivery_address" } +{ "order_id" } +``` +``` +// Частично перезаписывает заказ +// обновляет объём второго напитка +PATCH /v1/orders/{id} +{"items": [null, { + "volume": "800ml" +}]} +→ +{ /* изменения приняты */ } ``` -— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми. -**Во-первых**, экономия объёма ответа в таком формате бессмысленна. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. Реальные причины слишком высокого расхода трафика мы описали выше. +Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? Ну и самое неприятное — какой бы вариант вы ни выбрали, это только начало проблем. -**Во-вторых**, экономия размера ответа выйдет боком как раз при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. +Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию? -**В-третьих**, этот подход может как-то работать при необходимости перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`? +**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта и полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам: + * повышенные размеры запросов и, как следствие, расход трафика; + * необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения; + * невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства. -Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, `null`. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API. +Все эти соображения, однако, на поверку оказываются мнимыми: + * причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт); + * концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент — что не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; более того, существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля; + * наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); + * кроме того, часто в рамках той же экономии ответ на операцию частичного обновления пуст; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга. -**Хорошо**: можно применить одну из двух стратегий. - -**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. +**Лучше**: разделить эндпойнты. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. ``` -// Возвращает состояние заказа -// по его идентификатору -GET /v1/orders/123 +``` +// Создаёт заказ из двух напитков +POST /v1/orders/ +{ + "parameters": { + "delivery_address" + } + "items": [{ + "recipe": "lungo", + }, { + "recipe": "latte", + "milk_type": "oats" + }] +} → { - "order_id", - "delivery_details": { - "address" - }, - "client_details": { - "phone_number", - "phone_number_ext" - }, - "updated_at" + "order_id", + "created_at", + "parameters": { + "delivery_address" + } + "items": [ + {"item_id", "status"}, + {"item_id", "status"} + ] } -// Полностью перезаписывает -// информацию о доставке заказа -PUT /v1/orders/123/delivery-details -{ "address" } -// Полностью перезаписывает -// информацию о клиенте -PUT /v1/orders/123/client-details -{ "phone_number" } +``` +``` +// Изменяет параметры заказа +PUT /v1/orders/{id}/parameters +{"delivery_address"} +→ +{"delivery_address"} +``` +``` +// Частично перезаписывает заказ +// обновляет объём второго напитка +PUT /v1/orders/{id}/items/{item_id} +{"recipe", "volume", "milk_type"} +→ +{"recipe", "volume", "milk_type"} +``` ``` -Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования). +Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты. -**Вариант 2**: разработать формат описания атомарных изменений. +**Ещё лучше**: разработать формат описания атомарных изменений. ``` POST /v1/order/changes @@ -1046,9 +1079,11 @@ X-Idempotency-Token: <см. следующий раздел> "field": "delivery_address", "value": <новое значение> }, { - "type": "unset", - "field": "client_phone_number_ext" - }] + "type": "unset_item_field", + "item_id", + "field": "volume" + }], + … } ``` @@ -1068,11 +1103,11 @@ X-Idempotency-Token: <см. следующий раздел> **NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо. -##### Предусмотрите ограничения +##### Предусмотрите ограничения доступа С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений. -Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет такая необходимость. +Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость. Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.