1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-25 22:08:06 +02:00

styling improved

This commit is contained in:
Sergey Konstantinov 2023-08-30 20:13:15 +03:00
parent 87993f356e
commit bfa0cc9560
33 changed files with 310 additions and 310 deletions

View File

@ -10,7 +10,7 @@ Let's take a look at the following example:
```json
// Method description
POST /v1/bucket/{id}/some-resource
POST /v1/bucket/{id}/some-resource
/{resource_id}
X-Idempotency-Token: <idempotency token>
{
@ -26,8 +26,8 @@ Cache-Control: no-cache
a multiline comment */
"error_reason",
"error_message":
"Long error message
that will span several
"Long error message
that will span several
lines"
}
```
@ -40,7 +40,7 @@ It should be read like this:
* In response (marked with an arrow symbol `→`) the server returns a `404 Not Found` status code; the status might be omitted (treat it like a `200 OK` if no status is provided).
* The response could possibly contain additional notable headers.
* The response body is a JSON comprising two fields: `error_reason` and `error_message`. Absence of a value means that the field contains exactly what you expect it should contain — so there is some generic error reason value which we omitted.
* If some token is too long to fit on a single line, we will split it into several lines adding `` to indicate it continues next line.
* If some token is too long to fit on a single line, we will split it into several lines adding `` to indicate it continues next line.
The term “client” here stands for an application being executed on a user's device, either a native or a web one. The terms “agent” and “user agent” are synonymous with “client.”

View File

@ -193,7 +193,7 @@ In our case, the price mismatch error should look like this:
// Error kind
"reason": "offer_invalid",
"localized_message":
"Something went wrong.
"Something went wrong.
Try restarting the app."
"details": {
// What's wrong exactly?

View File

@ -156,7 +156,7 @@ The word “function” is ambiguous. It might refer to built-in functions, but
**Better**:
```typescript
GET /v1/coffee-machines/{id}
GET /v1/coffee-machines/{id}
/builtin-functions-list
```
@ -231,10 +231,8 @@ const order = api.createOrder(
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how should developers discern the explicit intention to disable the option (`false`) from not knowing if it exists (the field isn't set)? They would have to write something like:
```typescript
const value = orderParams
.contactless_delivery;
if (Type(value) == 'Boolean' &&
value == false) {
const value = orderParams.contactless_delivery;
if (Type(value) == 'Boolean' && value == false) {
}
```
@ -466,12 +464,12 @@ POST /v1/coffee-machines/search
"results": [],
"warnings": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]
"message": "Location [0, 0]
is probably a mistake"
}, {
"type": "unknown_field",
"message": "unknown field:
`force_convact_delivery`. Did you
"message": "unknown field:
`force_convact_delivery`. Did you
mean `force_contact_delivery`?"
}]
}
@ -480,7 +478,7 @@ POST /v1/coffee-machines/search
If it is not possible to add such notices, we can introduce a debug mode or strict mode in which notices are escalated:
```json
POST /v1/coffee-machines/search
POST /v1/coffee-machines/search
?strict_mode=true
{
"location": {
@ -492,7 +490,7 @@ POST /v1/coffee-machines/search⮠
{
"errors": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]
"message": "Location [0, 0]
is probably a mistake"
}],
@ -502,8 +500,8 @@ POST /v1/coffee-machines/search⮠
If the [0, 0] coordinates are not an error, it makes sense to allow for manual bypassing of specific errors:
```json
POST /v1/coffee-machines/search
?strict_mode=true
POST /v1/coffee-machines/search
?strict_mode=true
&disable_errors=suspicious_coordinates
```
@ -566,7 +564,7 @@ POST /v1/coffee-machines/search
{
"reason": "wrong_parameter_value",
"localized_message":
"Something is wrong.
"Something is wrong.
Contact the developer of the app.",
"details": {
"checks_failed": [
@ -574,7 +572,7 @@ POST /v1/coffee-machines/search
"field": "recipe",
"error_type": "wrong_value",
"message":
"Unknown value: 'lngo'.
"Unknown value: 'lngo'.
Did you mean 'lungo'?"
},
{
@ -586,8 +584,8 @@ POST /v1/coffee-machines/search
"max": 90
},
"message":
"'position.latitude' value
must fall within
"'position.latitude' value
must fall within
the [-90, 90] interval"
}
]
@ -727,8 +725,8 @@ Let's emphasize that we understand “cache” in the extended sense: which vari
```json
// Returns lungo prices including
// delivery to the specified location
GET /price?recipe=lungo
&longitude={longitude}
GET /price?recipe=lungo
&longitude={longitude}
&latitude={latitude}
{ "currency_code", "price" }
@ -740,8 +738,8 @@ Two questions arise:
**Better**: you may use standard protocol capabilities to denote cache options, such as the `Cache-Control` header. If you need caching in both temporal and spatial dimensions, you should do something like this:
```json
GET /price?recipe=lungo
&longitude={longitude}
GET /price?recipe=lungo
&longitude={longitude}
&latitude={latitude}
{
@ -804,7 +802,7 @@ POST /v1/orders/drafts
```
```json
// Confirms the draft
PUT /v1/orders/drafts
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
```
@ -883,13 +881,13 @@ It is equally important to provide interfaces to partners that minimize potentia
```json
// Allows partners to set
// descriptions for their beverages
PUT /v1/partner-api/{partner-id}
PUT /v1/partner-api/{partner-id}
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
```
```json
// Returns the desciption
GET /v1/partner-api/{partner-id}
GET /v1/partner-api/{partner-id}
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
@ -903,7 +901,7 @@ In these situations, we recommend, first, sanitizing the data if it appears pote
```json
// Allows for setting a potentially
// unsafe description for a beverage
PUT /v1/partner-api/{partner-id}
PUT /v1/partner-api/{partner-id}
/recipes/lungo/info
X-Dangerously-Disable-Sanitizing: true
"<script>alert(document.cookie)</script>"
@ -911,7 +909,7 @@ X-Dangerously-Disable-Sanitizing: true
```json
// Returns the potentially
// unsafe description
GET /v1/partner-api/{partner-id}
GET /v1/partner-api/{partner-id}
/recipes/lungo/info
X-Dangerously-Allow-Raw-Value: true
@ -925,8 +923,8 @@ One important finding is that if you allow executing scripts via the API, always
POST /v1/run/sql
{
// Passes the full script
"query": "INSERT INTO data (name)
VALUES ('Robert');
"query": "INSERT INTO data (name)
VALUES ('Robert');
DROP TABLE students;--')"
}
```
@ -935,11 +933,11 @@ POST /v1/run/sql
POST /v1/run/sql
{
// Passes the script template
"query": "INSERT INTO data (name)
"query": "INSERT INTO data (name)
VALUES (?)",
// and the parameters to set
"values": [
"Robert');
"Robert');
DROP TABLE students;--"
]
}

View File

@ -173,7 +173,7 @@ The easiest case is with immutable lists, i.e., when the set of items never chan
The case of a list with immutable items and the operation of adding new ones is more typical. Most notably, we talk about event queues containing, for example, new messages or notifications. Let's imagine there is an endpoint in our coffee API that allows partners to retrieve the history of offers:
```json
GET /v1/partners/{id}/offers/history
GET /v1/partners/{id}/offers/history
?limit=<limit>
{
@ -213,11 +213,11 @@ If the data storage we use for keeping list items offers the possibility of usin
```json
// Retrieve the records that precede
// the one with the given id
GET /v1/partners/{id}/offers/history
GET /v1/partners/{id}/offers/history
?newer_than=<item_id>&limit=<limit>
// Retrieve the records that follow
// the one with the given id
GET /v1/partners/{id}/offers/history
GET /v1/partners/{id}/offers/history
?older_than=<item_id>&limit=<limit>
```
@ -235,7 +235,7 @@ Often, the interfaces of traversing data through stating boundaries are generali
```json
// Initiate list traversal
POST /v1/partners/{id}/offers/history
POST /v1/partners/{id}/offers/history
/search
{
"order_by": [{
@ -251,8 +251,8 @@ POST /v1/partners/{id}/offers/history⮠
```json
// Get the next data chunk
GET /v1/partners/{id}/offers/history
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy
GET /v1/partners/{id}/offers/history
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy
&limit=100
{
@ -268,7 +268,7 @@ The cursor-based approach also allows adding new filters and sorting directions
```json
// Initialize list traversal
POST /v1/partners/{id}/offers/history
POST /v1/partners/{id}/offers/history
/search
{
// Add a filter by the recipe
@ -333,7 +333,7 @@ If none of the approaches above works, our only solution is changing the subject
```json
// Retrieve all the events older
// than the one with the given id
GET /v1/orders/created-history
GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit>
{

View File

@ -3,7 +3,7 @@
In the previous chapter, we discussed the following scenario: a partner receives information about new events occuring in the system by periodically requesting an endpoint that supports retrieving ordered lists.
```json
GET /v1/orders/created-history
GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit>
{

View File

@ -103,7 +103,7 @@ The solution could be enhanced by introducing explicit control sequences instead
// * Leaves the first beverage
// intact
// * Removes the second beverage.
PATCH /v1/orders/{id}
PATCH /v1/orders/{id}
// A meta filter: which fields
// are allowed to be modified
?field_mask=delivery_address,items

View File

@ -62,7 +62,7 @@ More specifically, if we talk about changing available order options, we should
1. Describe the current state. All coffee machines, plugged via the API, must support three options: sprinkling with cinnamon, changing the volume, and contactless delivery.
2. Add a new “with options” endpoint:
```json
PUT /v1/partners/{partner_id}
PUT /v1/partners/{partner_id}
/coffee-machines-with-options
{
"coffee_machines": [{

View File

@ -82,13 +82,13 @@ POST /v1/coffee-machines/search HTTP/1.1
}
HTTP/1.1 400 Bad Request
X-OurCoffeeAPI-Error-Kind:
X-OurCoffeeAPI-Error-Kind:
wrong_parameter_value
{
"reason": "wrong_parameter_value",
"localized_message":
"Something is wrong.
"Something is wrong.
Contact the developer of the app.",
"details": {
"checks_failed": [
@ -96,7 +96,7 @@ X-OurCoffeeAPI-Error-Kind:⮠
"field": "recipe",
"error_type": "wrong_value",
"message":
"Unknown value: 'lngo'.
"Unknown value: 'lngo'.
Did you mean 'lungo'?"
},
{
@ -108,8 +108,8 @@ X-OurCoffeeAPI-Error-Kind:⮠
"max": 90
},
"message":
"'position.latitude' value
must fall within
"'position.latitude' value
must fall within
the [-90, 90] interval"
}
]
@ -129,7 +129,7 @@ However, for internal systems, this argumentation is wrong. To build proper moni
POST /v1/orders/?user_id=<user id> HTTP/1.1
If-Match: <revision>
{ parameters }
{ "parameters" }
// The response the gateway received
// from the server, the metadata
@ -158,8 +158,8 @@ Retry-After: 5
{
"reason": "internal_server_error",
"localized_message": "Cannot get
a response from the server.
"localized_message": "Cannot get
a response from the server.
Please try repeating the operation
or reload the page.",
"details": {

View File

@ -7,9 +7,9 @@
Большинство примеров API в общих разделах будут даны в виде абстрактных обращений по HTTP-протоколу к некоторой специфической именованной функции API («эндпойнту») с передачей данных в формате JSON. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо `GET /v1/orders` вполне может быть вызов метода `orders.get()`, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.
Рассмотрим следующую запись:
```
```json
// Описание метода
POST /v1/bucket/{id}/some-resource
POST /v1/bucket/{id}/some-resource
/{resource_id}
X-Idempotency-Token: <токен идемпотентности>
{
@ -24,8 +24,8 @@ Cache-Control: no-cache
/* А это многострочный
комментарий */
"error_message":
"Длинное сообщение,
которое приходится
"Длинное сообщение,
которое приходится
разбивать на строки"
}
```
@ -38,7 +38,7 @@ Cache-Control: no-cache
* в ответ (индицируется стрелкой `→`) сервер возвращает статус `404 Not Found`; статус может быть опущен (отсутствие статуса следует трактовать как `200 OK`);
* в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;
* телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке
* если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ `` для индикации переноса.
* если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ `` для индикации переноса.
Здесь термин «клиент» означает «приложение, установленное на устройстве пользователя, использующее рассматриваемый API». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент».

View File

@ -20,11 +20,11 @@
Допустим, мы имеем следующий интерфейс:
```
```json
// возвращает рецепт лунго
GET /v1/recipes/lungo
```
```
```json
// размещает на указанной кофемашине
// заказ на приготовление лунго
// и возвращает идентификатор заказа
@ -34,14 +34,14 @@ POST /v1/orders
"recipe": "lungo"
}
```
```
```json
// возвращает состояние заказа
GET /v1/orders/{id}
```
И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов.
```
```json
GET /v1/recipes/lungo
{
@ -49,7 +49,7 @@ GET /v1/recipes/lungo
"volume": "100ml"
}
```
```
```json
GET /v1/orders/{id}
{
@ -67,7 +67,8 @@ GET /v1/orders/{id}
Вариант 1: мы фиксируем список допустимых объёмов и заводим фиктивные рецепты типа `/recipes/small-lungo`, `recipes/large-lungo`. Почему фиктивные? Потому что рецепт один и тот же, меняется только объём. Нам придётся либо тиражировать одинаковые рецепты, отличающиеся только объёмом, либо вводить какое-то «наследование» рецептов, чтобы можно было указать базовый рецепт и только переопределить объём.
Вариант 2: мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного:
```
```json
POST /v1/orders
{
"coffee_machine_id",
@ -75,12 +76,13 @@ POST /v1/orders
"volume":"800ml"
}
```
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из `GET /v1/recipes`, а из `GET /v1/orders`. Сделав так, мы сразу получаем клубок из связанных проблем:
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /v1/orders` нужно не забыть переписать код проверки готовности заказа;
* мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В `GET /v1/recipes` поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в `POST /v1/orders`»; переименовать его в «объём по умолчанию» уже не получится, с этой проблемой теперь придётся жить.
Мы получим:
```
```json
GET /v1/orders/{id}
{
@ -113,7 +115,7 @@ GET /v1/orders/{id}
* с другой стороны, кофемашина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).
Наивный подход в такой ситуации — искусственно ввести некий промежуточный уровень абстракции, «передаточное звено», который переформулирует задачи одного уровня абстракции в другой. Например, введём сущность `task` вида:
```
```json
{
"volume_requested": "800ml",
@ -134,7 +136,7 @@ GET /v1/orders/{id}
Таким образом, сущность «заказ» будет только хранить ссылки на рецепт и исполняемую задачу и не вторгаться в «чужие» уровни абстракции:
```
```json
GET /v1/orders/{id}
{
@ -156,7 +158,7 @@ GET /v1/orders/{id}
Предположим, для большей конкретности, что эти два класса устройств поставляются вот с таким физическим API.
* Машины с предустановленными программами:
```
```json
// Возвращает список
// предустановленных программ
GET /programs
@ -168,7 +170,7 @@ GET /v1/orders/{id}
"type": "lungo"
}
```
```
```json
// Запускает указанную
// программу на исполнение
// и возвращает статус исполнения
@ -188,11 +190,11 @@ GET /v1/orders/{id}
"volume": "200ml"
}
```
```
```json
// Отменяет текущую программу
POST /cancel
```
```
```json
// Возвращает статус исполнения
// Формат аналогичен
// формату ответа `POST /execute`
@ -202,7 +204,7 @@ GET /v1/orders/{id}
**NB**. На всякий случай отметим, что данный API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; он приведен в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такой API от производителей кофемашин, и это ещё довольно вменяемый вариант.
* Машины с предустановленными функциями:
```
```json
// Возвращает список
// доступных функций
GET /functions
@ -229,7 +231,7 @@ GET /v1/orders/{id}
]
}
```
```
```json
// Запускает на исполнение функцию
// с передачей указанных
// значений аргументов
@ -242,7 +244,7 @@ GET /v1/orders/{id}
}]
}
```
```
```json
// Возвращает статусы датчиков
GET /sensors

View File

@ -47,7 +47,7 @@
* самостоятельно выбрать нужные данные.
В псевдокоде это будет выглядеть примерно вот так:
```
```typescript
// Получить все доступные рецепты
let recipes =
api.getRecipes();
@ -76,7 +76,7 @@ app.display(matchingCoffeeMachines);
* показать ближайшие кофейни, где можно заказать конкретный вид кофе — для пользователей, которым нужен конкретный напиток.
Тогда наш новый интерфейс будет выглядеть примерно вот так:
```
```json
POST /v1/offers/search
{
// опционально
@ -108,7 +108,7 @@ POST /v1/offers/search
**NB**. На самом деле, наличие идентификатора кофе-машины в интерфейсах само по себе нарушает принцип изоляции уровней абстракции. Эа функциональность должна быть организована более сложно: кофейни должны распределять поступающие заказы по свободным кофемашинам, и только тип кофемашины (если кофейня оперирует несколькими одновременно) является значимой частью предложения. Мы сознательно допускаем это упрощение (пользователь сам выбирает кофемашину), чтобы не перегружать наш учебный пример.
Вернёмся к коду, который напишет разработчик. Теперь он будет выглядеть примерно так:
```
```typescript
// Ищем предложения,
// соответствующие запросу пользователя
let offers = api.offerSearch(parameters);
@ -128,7 +128,7 @@ app.display(offers);
Для решения третьей проблемы мы могли бы потребовать передать в функцию создания заказа его стоимость, и возвращать ошибку в случае несовпадения суммы с актуальной на текущий момент. (Более того, конечно же в любом API, работающем с деньгами, это нужно делать *обязательно*.) Но это не поможет с первым вопросом: гораздо более удобно с точки зрения UX не отображать ошибку в момент нажатия кнопки «разместить заказ», а всегда показывать пользователю актуальную цену.
Для решения этой проблемы мы можем поступить следующим образом: снабдить каждое предложение идентификатором, который необходимо указывать при создании заказа.
```
```json
{
"results": [
{
@ -157,7 +157,7 @@ app.display(offers);
Сделаем ещё один небольшой шаг в сторону улучшения жизни разработчика. А каким образом будет выглядеть ошибка «неверная цена»?
```
```json
POST /v1/orders
{ … "offer_id" …}
→ 409 Conflict
@ -181,13 +181,13 @@ POST /v1/orders
6. Наконец, если какие-то параметры операции имеют недопустимые значения, то какие значения допустимы?
В нашем случае несовпадения цены ответ должен выглядеть так:
```
```json
409 Conflict
{
// Род ошибки
"reason": "offer_invalid",
"localized_message":
"Что-то пошло не так.
"Что-то пошло не так.
Попробуйте перезагрузить приложение."
"details": {
// Что конкретно неправильно?
@ -215,7 +215,7 @@ POST /v1/orders
Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофемашины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.
```
```json
{
"results": [{
// Данные кофемашины
@ -266,7 +266,7 @@ POST /v1/orders
* данные о цене.
Попробуем сгруппировать:
```
```json
{
"results": [{
// Данные о заведении

View File

@ -21,20 +21,20 @@
Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.
**Плохо**:
```
```typescript
// Отменяет заказ
order.canceled = true;
```
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
**Хорошо**:
```
```typescript
// Отменяет заказ
order.cancel();
```
**Плохо**:
```
```typescript
// Возвращает агрегированную
// статистику заказов за всё время
orders.getStats()
@ -43,7 +43,7 @@ orders.getStats()
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
**Хорошо**:
```
```typescript
// Вычисляет и возвращает агрегированную
// статистику заказов за указанный период
orders.calculateAggregatedStats({
@ -77,7 +77,7 @@ orders.calculateAggregatedStats({
либо
`"iso_duration": "PT5S"`
либо
```
```json
"duration": {
"unit": "ms",
"value": 5000
@ -105,7 +105,7 @@ orders.calculateAggregatedStats({
**Хорошо**: `order.getEstimatedDeliveryTime()`.
**Плохо**:
```
```typescript
// возвращает положение
// первого вхождения в строку str1
// любого символа из строки str2
@ -114,7 +114,7 @@ strpbrk (str1, str2)
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
**Хорошо**:
```
```typescript
str_search_for_characters(
str,
lookup_character_set
@ -146,7 +146,7 @@ str_search_for_characters(
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
**Плохо**:
```
```json
// Возвращает список
// встроенных функций кофемашины
GET /coffee-machines/{id}/functions
@ -154,8 +154,8 @@ GET /coffee-machines/{id}/functions
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует или не функционирует).
**Хорошо**:
```
GET /v1/coffee-machines/{id}
```json
GET /v1/coffee-machines/{id}
/builtin-functions-list
```
@ -167,7 +167,7 @@ GET /v1/coffee-machines/{id}⮠
**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`.
**Плохо**:
```
```typescript
// Находит первую позицию строки `needle`
// внутри строки `haystack`
strpos(haystack, needle)
@ -195,7 +195,7 @@ str_replace(needle, replace, haystack)
Стоит также отметить, что в использовании законов де Моргана[ref Законы де Моргана](https://ru.wikipedia.org/wiki/Законы_де_Моргана) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
```
```json
GET /coffee-machines/{id}/stocks
{
@ -206,7 +206,7 @@ GET /coffee-machines/{id}/stocks
Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
```
```json
{
"beans_absence": false,
"cup_absence": false
@ -219,7 +219,7 @@ GET /coffee-machines/{id}/stocks
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
```
```typescript
const orderParams = {
contactless_delivery: false
};
@ -230,11 +230,9 @@ const order = api.createOrder(
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
```
if (Type(orderParams
.contactless_delivery
) == 'Boolean' && orderParams
.contactless_delivery == false) {
```typescript
const value = orderParams.contactless_delivery;
if (Type(value) == 'Boolean' && value == false) {
}
```
@ -244,7 +242,7 @@ if (Type(orderParams
Если протоколом не предусмотрена нативная поддержка таких кейсов (т.е. разработчик не может допустить ошибку, спутав отсутствие поля с пустым значением), универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
**Хорошо**
```
```typescript
const orderParams = {
force_contact_delivery: true
};
@ -256,7 +254,7 @@ const order = api.createOrder(
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
**Плохо**:
```
```json
// Создаёт пользователя
POST /v1/users
{ … }
@ -277,7 +275,7 @@ PUT /v1/users/{id}
```
**Хорошо**
```
```json
POST /v1/users
{
// true — у пользователя снят
@ -337,7 +335,7 @@ POST /v1/users
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
**Плохо**
```
```json
POST /v1/coffee-machines/search
{
"query": "lungo",
@ -353,7 +351,7 @@ POST /v1/coffee-machines/search
Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
**Хорошо**:
```
```json
POST /v1/coffee-machines/search
{
"query": "lungo",
@ -369,7 +367,7 @@ POST /v1/coffee-machines/search
**NB**: этот паттерн следует применять и в обратную сторону. Если в запросе можно указать массив сущностей, то следует отличать пустой массив от отсутствия параметра. Рассмотрим следующий пример:
```
```json
// Находит все рецепты кофе
// без молока
POST /v1/recipes/search
@ -402,7 +400,7 @@ POST /v1/offers/search
Представим теперь, что вызов первого метода вернул пустой массив результатов, т.е. ни одного рецепта кофе, удовлетворяющего условиям, не было найдено. Хорошо, если разработчик партнёра предусмотрит эту ситуацию и не будет делать запрос поиска предложений — но мы не можем быть стопроцентно в этом уверены. Если обработка пустого массива рецептов не предусмотрена, то приложение партнёра выполнит вот такой запрос:
```
```json
POST /v1/offers/search
{
"location",
@ -419,7 +417,7 @@ POST /v1/offers/search
Это верно не только в случае непустых массивов, но и любых других зафиксированных в контракте ограничений. «Тихое» исправление недопустимых значений почти никогда не имеет никакого практического смысла:
**Плохо**:
```
```json
POST /v1/offers/search
{
"location": {
@ -438,7 +436,7 @@ POST /v1/offers/search
Мы видим, что разработчик по какой-то причине передал некорректное значение широты (100 градусов). Да, мы можем его «исправить», т.е. редуцировать до ближайшего допустимого значения (90 градусов), но кому от этого стало лучше? Разработчик никогда не узнает о допущенной ошибке, а конечному пользователю предложения кофе на Северном полюсе, скорее всего, нерелевантны.
**Хорошо**:
```
```json
POST /v1/coffee-machines/search
{
"location": {
@ -454,7 +452,7 @@ POST /v1/coffee-machines/search
Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку:
```
```json
POST /v1/coffee-machines/search
{
"location": {
@ -467,12 +465,12 @@ POST /v1/coffee-machines/search
"results": [],
"warnings": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]
"message": "Location [0, 0]
is probably a mistake"
}, {
"type": "unknown_field",
"message": "unknown field:
`force_convact_delivery`. Did you
"message": "unknown field:
`force_convact_delivery`. Did you
mean `force_contact_delivery`?"
}]
}
@ -480,9 +478,9 @@ POST /v1/coffee-machines/search
Однако следует отметить, что далеко не во все интерфейсы можно удобно уложить дополнительно возврат предупреждений. В такой ситуации можно ввести дополнительный режим отладки или строгий режим, в котором уровень предупреждений эскалируется:
```
POST /v1/coffee-machines/search
strict_mode=true
```json
POST /v1/coffee-machines/search
?strict_mode=true
{
"location": {
"latitude": 0,
@ -493,7 +491,7 @@ POST /v1/coffee-machines/search⮠
{
"errors": [{
"type": "suspicious_coordinates",
"message": "Location [0, 0]
"message": "Location [0, 0]
is probably a mistake"
}],
@ -502,10 +500,10 @@ POST /v1/coffee-machines/search⮠
Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно разрешить задавать игнорируемые ошибки для конкретной операции:
```
POST /v1/coffee-machines/search
strict_mode=true⮠
disable_errors=suspicious_coordinates
```json
POST /v1/coffee-machines/search
?strict_mode=true↵
&disable_errors=suspicious_coordinates
```
##### Значения по умолчанию должны быть осмысленны
@ -513,7 +511,7 @@ POST /v1/coffee-machines/search⮠
Значения по умолчанию — один из самых ваших сильных инструментов, позволяющих избежать многословности при работе с API. Однако эти умолчания должны помогать разработчикам, а не маскировать их ошибки.
**Плохо**:
```
```json
POST /v1/coffee-machines/search
{
"recipes": ["lungo"]
@ -531,7 +529,7 @@ POST /v1/coffee-machines/search
Формально, подобное умолчание допустимо — почему бы не иметь концепции «географических координат по умолчанию». Однако в реальности результатом подобных политик «тихого» исправления ошибок становятся абсурдные ситуации типа «null island» — самой посещаемой точки в мире[ref Hrala, J. Welcome to Null Island, The Most 'Visited' Place on Earth That Doesn't Actually Exist](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). Чем популярнее API, тем больше шансов, что партнеры просто не обратят внимания на такие пограничные ситуации.
**Хорошо**:
```
```json
POST /v1/coffee-machines/search
{
"recipes": ["lungo"]
@ -548,7 +546,7 @@ POST /v1/coffee-machines/search
Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API.
**Плохо**:
```
```json
POST /v1/coffee-machines/search
{
"recipes": ["lngo"],
@ -563,11 +561,11 @@ POST /v1/coffee-machines/search
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
**Хорошо**:
```
```json
{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так.
"Что-то пошло не так.
Обратитесь к разработчику приложения.",
"details": {
"checks_failed": [
@ -575,7 +573,7 @@ POST /v1/coffee-machines/search
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown.
"Value 'lngo' unknown.
Did you mean 'lungo'?"
},
{
@ -586,8 +584,8 @@ POST /v1/coffee-machines/search
"max": 90
},
"message":
"'position.latitude' value
must fall within
"'position.latitude' value
must fall within
the [-90, 90] interval"
}
]
@ -600,7 +598,7 @@ POST /v1/coffee-machines/search
Рассмотрим пример с заказом кофе
```
```json
POST /v1/orders
{
// запрошенный рецепт
@ -635,7 +633,7 @@ POST /v1/orders
Если ошибки исправимы (т.е. пользователь может совершить какие-то действия и всё же добиться желаемого), следует в первую очередь сообщать о тех, которые потребуют более глобального изменения состояния.
**Плохо**:
```
```json
POST /v1/orders
{
"items": [{
@ -682,7 +680,7 @@ POST /v1/orders
В сложных системах не редки ситуации, когда исправление одной ошибки приводит к возникновению другой и наоборот.
```
```json
// Создаём заказ с платной доставкой
POST /v1/orders
{
@ -727,11 +725,11 @@ POST /v1/orders
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
**Плохо**:
```
```json
// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
GET /v1/price?recipe=lungo­
&longitude={longitude}
GET /v1/price?recipe=lungo­
&longitude={longitude}
­&latitude={latitude}
{ "currency_code", "price" }
@ -743,9 +741,9 @@ GET /v1/price?recipe=lungo­⮠
**Хорошо**:
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
```
GET /v1/price?recipe=lungo
&longitude={longitude}
```json
GET /v1/price?recipe=lungo
&longitude={longitude}
&latitude={latitude}
{
@ -784,14 +782,14 @@ GET /v1/price?recipe=lungo⮠
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
**Плохо**:
```
```json
// Создаёт заказ
POST /orders
```
Повтор запроса создаст два заказа!
**Хорошо**:
```
```json
// Создаёт заказ
POST /v1/orders
X-Idempotency-Token: <случайная строка>
@ -800,15 +798,15 @@ X-Idempotency-Token: <случайная строка>
Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
**Альтернатива**:
```
```json
// Создаёт черновик заказа
POST /v1/orders/drafts
{ "draft_id" }
```
```
```json
// Подтверждает черновик заказа
PUT /v1/orders/drafts
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
```
@ -821,7 +819,7 @@ PUT /v1/orders/drafts⮠
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
```
```json
POST /resource/updates
{
"resource_revision": 123
@ -835,7 +833,7 @@ POST /resource/updates
Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему:
```
```json
POST /resource/updates
X-Idempotency-Token: <токен>
{
@ -847,7 +845,7 @@ X-Idempotency-Token: <токен>
— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.
Или:
```
```json
POST /resource/updates
X-Idempotency-Token: <токен>
{
@ -881,16 +879,16 @@ X-Idempotency-Token: <токен>
Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне.
**Плохо**:
```
```json
// Позволяет партнёру задать
// описание для своего напитка
PUT /v1/partner-api/{partner-id}
PUT /v1/partner-api/{partner-id}
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
```
```
```json
// возвращает описание
GET /v1/partner-api/{partner-id}
GET /v1/partner-api/{partner-id}
/recipes/lungo/info
"<script>alert(document.cookie)</script>"
@ -901,19 +899,19 @@ GET /v1/partner-api/{partner-id}⮠
В таких ситуациях мы рекомендуем, во-первых, экранировать вводимые через API данные, если они выглядят потенциально эксплуатируемыми (предназначены для показа в UI и/или возвращаются по прямой ссылке), и, во-вторых, ограничивать радиус взрыва так, чтобы через уязвимости в коде одного партнёра нельзя было затронуть других партнёров. В случае, если функциональность небезопасного ввода всё же нужна, необходимо предупреждать о рисках максимально явно.
**Лучше** (но не идеально):
```
```json
// Позволяет партнёру задать
// потенциально небезопасное
// описание для своего напитка
PUT /v1/partner-api/{partner-id}
PUT /v1/partner-api/{partner-id}
/recipes/lungo/info
X-Dangerously-Disable-Sanitizing: true
"<script>alert(document.cookie)</script>"
```
```
```json
// возвращает потенциально
// небезопасное описание
GET /v1/partner-api/{partner-id}
GET /v1/partner-api/{partner-id}
/recipes/lungo/info
X-Dangerously-Allow-Raw-Value: true
@ -923,25 +921,25 @@ X-Dangerously-Allow-Raw-Value: true
В частности, если вы позволяете посредством API выполнять какие-то текстовые скрипты, всегда предпочитайте безопасный ввод небезопасному.
**Плохо**
```
```json
POST /v1/run/sql
{
// Передаёт готовый запрос целиком
"query": "INSERT INTO data (name)
VALUES ('Robert');
"query": "INSERT INTO data (name)
VALUES ('Robert');
DROP TABLE students;--')"
}
```
**Лучше**:
```
```json
POST /v1/run/sql
{
// Передаёт шаблон запроса
"query": "INSERT INTO data (name)
"query": "INSERT INTO data (name)
VALUES (?)",
// и параметры для подстановки
values: [
"Robert');
"values": [
"Robert');
DROP TABLE students;--"
]
}

View File

@ -3,7 +3,7 @@
Суммируем текущее состояние нашего учебного API.
##### Поиск предложений
```
```json
POST /v1/offers/search
{
// опционально
@ -56,14 +56,14 @@ POST /v1/offers/search
```
##### Работа с рецептами
```
```json
// Возвращает список рецептов
// Параметр cursor необязателен
GET /v1/recipes?cursor=<курсор>
{ "recipes", "cursor" }
```
```
```json
// Возвращает конкретный рецепт
// по его идентификатору
GET /v1/recipes/{id}
@ -75,7 +75,7 @@ GET /v1/recipes/{id}
}
```
##### Работа с заказами
```
```json
// Размещает заказ
POST /v1/orders
{
@ -91,18 +91,18 @@ POST /v1/orders
{ "order_id" }
```
```
```json
// Возвращает состояние заказа
GET /v1/orders/{id}
{ "order_id", "status" }
```
```
```json
// Отменяет заказ
POST /v1/orders/{id}/cancel
```
##### Работа с программами
```
```json
// Возвращает идентификатор программы,
// соответствующей указанному рецепту
// на указанной кофемашине
@ -111,7 +111,7 @@ POST /v1/program-matcher
{ "program_id" }
```
```
```json
// Возвращает описание
// программы по её идентификатору
GET /v1/programs/{id}
@ -130,7 +130,7 @@ GET /v1/programs/{id}
}
```
##### Исполнение программ
```
```json
// Запускает исполнение программы
// с указанным идентификатором
// на указанной машине
@ -149,12 +149,12 @@ POST /v1/programs/{id}/run
{ "program_run_id" }
```
```
```json
// Останавливает исполнение программы
POST /v1/runs/{id}/cancel
```
##### Управление рантаймами
```
```json
// Создаёт новый рантайм
POST /v1/runtimes
{
@ -165,7 +165,7 @@ POST /v1/runtimes
{ "runtime_id", "state" }
```
```
```json
// Возвращает текущее состояние рантайма
// по его id
GET /v1/runtimes/{runtime_id}/state
@ -178,7 +178,7 @@ GET /v1/runtimes/{runtime_id}/state
"variables"
}
```
```
```json
// Прекращает исполнение рантайма
POST /v1/runtimes/{id}/terminate
```

View File

@ -6,7 +6,7 @@
2. Из-за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут:
* клиент, таким образом, не знает, был ли выполнен запрос или нет.
3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:
```
```typescript
const pendingOrders = await
api.getOngoingOrders(); // → []
```
@ -23,7 +23,7 @@
Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например, вот так:
```
```typescript
let lock;
try {
// Захватываем право
@ -60,7 +60,7 @@ try {
Более щадящий с точки зрения сложности имплементации вариант — это реализовать оптимистичное управление параллелизмом[ref Optimistic Concurrency Control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.
```
```typescript
// Получаем состояние
const orderState =
await api.getOrderState();

View File

@ -2,7 +2,7 @@
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и увеличить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик:
```
```typescript
// Получаем состояние,
// возможно, из реплики
const orderState =
@ -27,7 +27,7 @@ try {
Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
```
```typescript
// Создаёт заказ
const api = await api
.createOrder(…)
@ -41,7 +41,7 @@ const pendingOrders = await api.
Важный паттерн, который поможет в этой ситуации — это имплементация модели «read-your-writes»[ref Consistency Model. Read-Your-Writes Consistency](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read-your-writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
```
```typescript
const order = await api
.createOrder(…);
const pendingOrders = await api.

View File

@ -6,7 +6,7 @@
Наш сценарий использования, напомним, выглядит так:
```
```typescript
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
@ -32,7 +32,7 @@ if (pendingOrder.length == 0) {
Здесь нам на помощь приходят асинхронные вызовы. Если наша цель — уменьшить число коллизий, то нам нет никакой нужды дожидаться, когда заказ будет *действительно* создан; наша цель — максимально быстро распространить по репликам знание о том, что заказ *принят к созданию*. Мы можем поступить следующим образом: создавать не заказ, а задание на создание заказа, и возвращать его идентификатор.
```
```typescript
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
@ -79,7 +79,7 @@ const pendingOrders = await api.
Поэтому, при всей привлекательности идеи, мы всё же склонны рекомендовать ограничиться асинхронными интерфейсами только там, где они действительно критически важны (как в примере выше, где они снижают вероятность коллизий), и при этом иметь отдельные очереди для каждого кейса. Идеальное решение с очередями — то, которое вписано в бизнес-логику и вообще не выглядит очередью. Например, ничто не мешает нам объявить состояние «задание на создание заказа принято и ожидает исполнения» просто отдельным статусом заказа, а его идентификатор сделать идентификатором будущего заказа:
```
```typescript
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {

View File

@ -2,7 +2,7 @@
В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:
```
```typescript
const pendingOrders = await api
.getOngoingOrders();
@ -18,7 +18,7 @@ const pendingOrders = await api
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
```
```typescript
api.getOngoingOrders({
// необязательное, но имеющее
// значение по умолчанию
@ -36,7 +36,7 @@ api.getOngoingOrders({
Стандартный подход к этой проблеме — введение параметра `offset` или номера страницы данных:
```
```typescript
api.getOngoingOrders({
// необязательное, но имеющее
// значение по умолчанию
@ -49,7 +49,7 @@ api.getOngoingOrders({
Однако, как нетрудно заметить, в нашем случае этот подход приведёт к новым проблемам. Пусть для простоты в системе от имени пользователя выполняется три заказа:
```
```json
[{
"id": 3,
"created_iso_time": "2022-12-22T15:35",
@ -67,7 +67,7 @@ api.getOngoingOrders({
Приложение партнёра запросило первую страницу списка заказов:
```
```typescript
api.getOrders({
"limit": 2,
"parameters": {
@ -89,7 +89,7 @@ api.getOrders({
Теперь приложение запрашивает вторую страницу `"limit": 2, "offset": 2` и ожидает получить заказ `"id": 1`. Предположим, однако, что за время, прошедшее с момента первого запроса, в системе появился новый заказ с `"id": 4`.
```
```json
[{
"id": 4,
"created_iso_time": "2022-12-22T15:36",
@ -111,7 +111,7 @@ api.getOrders({
Тогда, запросив вторую страницу заказов, вместо одного заказа `"id": 1`, приложение партнёра получит повторно заказ `"id": 2`:
```
```typescript
api.getOrders({
"limit": 2,
"offset": 2
@ -131,7 +131,7 @@ api.getOrders({
Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если мы добавим сортировку не только по дате создания, но и по статусу заказа:
```
```typescript
api.getOrders({
"limit": 2,
"parameters": {
@ -170,9 +170,9 @@ api.getOrders({
Более распространённый случай — когда не меняются данные в списке, но появляются новые элементы. Чаще всего речь идёт об очередях событий — например, новых сообщений или уведомлений. Представим, что в нашем кофейном API есть эндпойнт для партнёра для получения истории предложений:
```
GET /v1/partners/{id}/offers/history
limit=<лимит>
```json
GET /v1/partners/{id}/offers/history
?limit=<лимит>
{
"offer_history": [{
@ -210,15 +210,15 @@ GET /v1/partners/{id}/offers/history⮠
Если хранилище данных, в котором находятся элементы списка, позволяет использовать монотонно растущие идентификаторы (что на практике означает два условия: (1) база данных поддерживает автоинкрементные колонки, (2) вставка данных осуществляется блокирующим образом), то идентификатор элемента в списке является максимально удобным способом организовать перебор:
```
```json
// Получить записи новее,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history
newer_than=<item_id>&limit=<limit>
GET /v1/partners/{id}/offers/history
?newer_than=<item_id>&limit=<limit>
// Получить записи более старые,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history
older_than=<item_id>&limit=<limit>
GET /v1/partners/{id}/offers/history
?older_than=<item_id>&limit=<limit>
```
Первый формат запроса позволяет решить задачу (1), т.е. получить все элементы списка, появившиеся позднее последнего известного; второй формат — задачу (2), т.е. перебрать нужно количество записей в истории запросов. Важно, что первый запрос при этом ещё и кэшируемый.
@ -233,10 +233,10 @@ GET /v1/partners/{id}/offers/history⮠
Часто подобные интерфейсы перебора данных (путём указания граничного значения) обобщают через введение понятия *курсор*:
```
```json
// Инициализируем поиск
POST /v1/partners/{id}/offers/history
search
POST /v1/partners/{id}/offers/history
/search
{
"order_by": [{
"field": "created",
@ -249,10 +249,10 @@ POST /v1/partners/{id}/offers/history⮠
}
```
```
```json
// Получение порции данных
GET /v1/partners/{id}/offers/history
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy
GET /v1/partners/{id}/offers/history
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy
&limit=100
{
@ -267,9 +267,9 @@ GET /v1/partners/{id}/offers/history⮠
В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.
```
```json
// Инициализируем поиск
POST /v1/partners/{id}/offers/history
POST /v1/partners/{id}/offers/history
search
{
// Добавим фильтр по виду кофе
@ -306,7 +306,7 @@ POST /v1/partners/{id}/offers/history⮠
Бывает так, что задачу можно *свести* к иммутабельному списку, если по запросу создавать какой-то слепок запрошенных данных. Во многих случаях работа с таким срезом данных по состоянию на определённую дату более удобна и для партнёров, поскольку снимает необходимость учитывать текущие изменения. Часто такой подход работает с «холодными» хранилищами, которые по запросу выгружают какой-то подмассив данных в «горячее» хранилище.
```
```json
POST /v1/orders/archive/retrieve
{
"created_iso_date": {
@ -331,11 +331,11 @@ POST /v1/orders/archive/retrieve
Если ни один из описанных вариантов не подходит по тем или иным причинам, единственный способ организации доступа — это изменение предметной области. Если мы не можем консистентно упорядочить элементы списка, нам нужно найти какой-то другой срез тех же данных, который мы *можем* упорядочить. Например, в нашем случае доступа к новым заказам мы можем упорядочить *список событий* создания нового заказа:
```
```json
// Получить все события создания
// заказа, более старые,
// чем запись с указанным id
GET /v1/orders/created-history
GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit>
{

View File

@ -2,9 +2,9 @@
В предыдущей главе мы рассмотрели следующий кейс: партнёр получает информацию о новых событиях, произошедших в системе, периодически опрашивая эндпойнт, поддерживающий отдачу упорядоченных списков.
```
GET /v1/orders/created-history
older_than=<item_id>&limit=<limit>
```json
GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit>
{
"orders_created_events": [{

View File

@ -6,7 +6,7 @@
Рассмотрим на примере нашего кофейного API:
```
```json
// Вариант 1: тело сообщения
// содержит все данные о заказе
POST /partner/webhook
@ -24,7 +24,7 @@ Host: partners.host
}
}
```
```
```json
// Вариант 2: тело сообщения
// содержит только информацию
// о самом событии
@ -50,7 +50,7 @@ GET /v1/orders/{id}
{ /* все детали заказа */ }
```
```
```json
// Вариант 3: мы уведомляем
// партнёра, что его реакции
// ожидают три новых заказа
@ -82,7 +82,7 @@ GET /v1/orders/pending
Применение техник с отправкой только ограниченного набора данных помимо усложнения схемы взаимодействия и увеличения количества запросов имеет ещё один важный недостаток. Если в варианте 1 (сообщение содержит в себе все релевантные данные) мы можем рассчитывать на то, что возврат кода успеха подписчиком эквивалентен успешной обработке сообщения партнёром (что, вообще говоря, тоже не гарантировано, т.к. партнёр может использовать асинхронные схемы), то для вариантов 2 и 3 это заведомо не так: для обработки сообщений партнёр должен выполнить дополнительные действия, начиная с получения нужных данных о заказе. В этом случае нам необходимо иметь раздельные статусы — сообщение доставлено и сообщение обработано; в идеале, второе должно вытекать из логики работы API, т.е. сигналом о том, что сообщение обработано, является какое-то действие, совершаемое партнёром. В нашем кофейном примере это может быть перевод заказа партнёром из статуса `"new"` (заказ создан пользователем) в статус `"accepted"` или `"rejected"` (кофейня партнёра приняла или отклонила заказ). Тогда полный цикл обработки уведомления будет выглядеть так:
```
```json
// Уведомляем партнёра о том,
// что его реакции
// ожидают три новых заказа
@ -94,7 +94,7 @@ Host: partners.host
<число новых заказов>
}
```
```
```json
// В ответ партнёр вызывает
// эндпойнт получения списка заказов
GET /v1/orders/pending
@ -104,7 +104,7 @@ GET /v1/orders/pending
"cursor"
}
```
```
```json
// После того, как заказы обработаны,
// партнёр уведомляет нас об
// изменениях статуса

View File

@ -4,7 +4,7 @@
Пусть партнёр уведомляет нас об изменении статусов двух заказов:
```
```json
POST /v1/orders/bulk-status-change
{
"status_changes": [{
@ -39,7 +39,7 @@ POST /v1/orders/bulk-status-change
Предположим, что на шаге (3) партнёр получил от сервера API ошибку. Что разработчик должен в этой ситуации сделать? Вероятнее всего, в коде партнёра будет реализован один из трёх вариантов:
1. Безусловный повтор запроса:
```
```typescript
// Получаем все текущие заказы
const pendingOrders = await api
.getPendingOrders();
@ -87,7 +87,7 @@ POST /v1/orders/bulk-status-change
**NB**: в примере выше мы приводим «правильную» политику перезапросов (с экспоненциально растущим периодом ожидания и лимитом на количество попыток), как мы ранее рекомендовали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)». Следует, однако, иметь в виду, что в реальном коде партнёров с большой долей вероятности ничего подобного реализовано не будет. В дальнейших примерах эту громоздкую конструкцию мы также будем опускать, чтобы упростить чтение кода.
2. Повтор только неудавшихся подзапросов:
```
```typescript
const pendingOrders = await api
.getPendingOrders();
let changes =
@ -130,7 +130,7 @@ POST /v1/orders/bulk-status-change
```
3. Рестарт всей операции, т.е. в нашем случае — перезапрос всех новых заказов и формирование нового запроса на изменение:
```
```typescript
do {
const pendingOrders = await api
.getPendingOrders();
@ -155,7 +155,7 @@ POST /v1/orders/bulk-status-change
Это приводит нас к парадоксальному умозаключению: гарантировать, что партнёрский код будет *как-то* работать и давать партнёру время разобраться с ошибочными запросами, можно только реализовав максимально нестрогий неидемпотентный неатомарный подход к операции массовых изменений. Однако и этот вывод мы считаем ошибочным, и вот почему: описанный нами «зоопарк» возможных имплементаций клиента и сервера очень хорошо демонстрирует *нежелательность* эндпойнтов массовых изменений как таковых. Такие эндпойнты требуют реализации дополнительного уровня логики и на клиенте, и на сервере, причём логики весьма неочевидной. Функциональность неатомарных массовых изменений очень быстро приведёт нас к крайне неприятным ситуациям:
```
```json
// Партнёр делает рефанд
// и отменяет заказ
POST /v1/bulk-status-change
@ -194,7 +194,7 @@ POST /v1/bulk-status-change
Один из подходов, позволяющих минимизировать возможные проблемы — разработать смешанный эндпойнт, в котором потенциально зависящие друг от друга операции группированы, например, вот так:
```
```json
POST /v1/bulk-status-change
{
"changes": [{

View File

@ -2,7 +2,7 @@
Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример.
```
```json
// Создаёт заказ из двух напитков
POST /v1/orders/
X-Idempotency-Token: <токен>
@ -19,7 +19,7 @@ X-Idempotency-Token: <токен>
{ "order_id" }
```
```
```json
// Частично перезаписывает заказ,
// обновляет объём второго напитка
PATCH /v1/orders/{id}
@ -55,7 +55,7 @@ PATCH /v1/orders/{id}
Если обратиться к примеру выше, наивный подход выглядит примерно так:
```
```json
// Частично перезаписывает заказ:
// * сбрасывает адрес доставки
// в значение по умолчанию
@ -93,16 +93,16 @@ PATCH /v1/orders/{id}
Это решение можно улучшить путём ввода явных управляющих конструкций вместо «магических значений» и введением мета-опций операции (скажем, фильтра по именам полей, как это принято в gRPC поверх Protobuf[ref Protocol Buffers. Field Masks in Update Operations](https://protobuf.dev/reference/protobuf/google.protobuf/#field-masks-updates)), например, так:
```
```json
// Частично перезаписывает заказ:
// * сбрасывает адрес доставки
// в значение по умолчанию
// * не изменяет первый напиток
// * удаляет второй напиток
PATCH /v1/orders/{id}?⮠
PATCH /v1/orders/{id}
// мета-фильтр: какие поля
// переопределяются
field_mask=delivery_address,items
?field_mask=delivery_address,items
{
// Специальное значение №1:
// обнулить поле
@ -132,7 +132,7 @@ PATCH /v1/orders/{id}?⮠
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности независимых операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility).
```
```json
// Создаёт заказ из двух напитков
POST /v1/orders/
{
@ -160,7 +160,7 @@ POST /v1/orders/
}
```
```
```json
// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
@ -169,7 +169,7 @@ PUT /v1/orders/{id}/parameters
{ "delivery_address" }
```
```
```json
// Частично перезаписывает заказ
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
@ -182,7 +182,7 @@ PUT /v1/orders/{id}/items/{item_id}
{ "recipe", "volume", "milk_type" }
```
```
```json
// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
```
@ -205,7 +205,7 @@ DELETE /v1/orders/{id}/items/{item_id}
В нашем случае мы можем пойти, например, вот таким путём:
```
```json
POST /v1/order/changes
X-Idempotency-Token: <токен>
{

View File

@ -28,7 +28,7 @@
Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но при этом нигде не зафиксированы?
**Пример 1**. Рассмотрим SDK работы с заказами.
```
```typescript
// Создаёт заказ
let order = api.createOrder();
// Получает статус заказа
@ -39,7 +39,7 @@ let status = api.getStatus(order.id);
Вы можете сказать: «Позвольте, но мы нигде и не обещали строгую консистентность!» — и это будет, конечно, неправдой. Вы можете так сказать если, и только если, вы действительно в документации метода `createOrder` явно описали нестрогую консистентность, а все ваши примеры использования SDK написаны как-то так:
```
```typescript
let order = api.createOrder();
let status;
while (true) {
@ -63,7 +63,7 @@ if (status) {
**Пример 2**. Представьте себе следующий код:
```
```typescript
let resolve;
let promise = new Promise(
function (innerResolve) {
@ -79,7 +79,7 @@ resolve();
**Пример 3**. Представьте, что вы предоставляете API для анимаций, в котором есть две независимые функции:
```
```typescript
// Анимирует ширину некоторого объекта
// от первого значения до второго
// за указанное время
@ -95,7 +95,7 @@ object.observe('widthchange', observerFunction);
В данном случае следует задокументировать конкретный контракт — как и когда вызывается callback — и придерживаться его даже при смене нижележащей технологии.
**Пример 4**. Представьте, что потребитель совершает заказ, которые проходит через вполне определённую цепочку преобразований:
```
```json
GET /v1/orders/{id}/events/history
{ "event_history": [

View File

@ -14,7 +14,7 @@
Например, можно предоставить второе семейство API (специально для партнёров), содержащее вот такие методы.
```
```json
// 1. Зарегистрировать новый тип API
PUT /v1/api-types/{api_type}
{
@ -24,7 +24,7 @@ PUT /v1/api-types/{api_type}
}
```
```
```json
// 2. Предоставить список кофемашин с разбивкой
// по типу API
PUT /v1/partners/{partnerId}/coffee-machines
@ -63,8 +63,8 @@ PUT /v1/partners/{partnerId}/coffee-machines
1. Документируем текущее состояние. Все кофемашины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.
2. Добавляем новый метод `with-options`:
```
PUT /v1/partners/{partner_id}
```json
PUT /v1/partners/{partner_id}
/coffee-machines-with-options
{
"coffee_machines": [{

View File

@ -4,7 +4,7 @@
Итак, добавим ещё один эндпойнт — для регистрации собственного рецепта партнёра.
```
```json
// Добавляет новый рецепт
POST /v1/recipes
{
@ -24,7 +24,7 @@ POST /v1/recipes
Первая проблема очевидна тем, кто внимательно читал главу [«Описание конечных интерфейсов»](#api-design-describing-interfaces): продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:
```
```json
"product_properties": {
// "l10n" — стандартное сокращение
// для "localization"
@ -58,7 +58,7 @@ POST /v1/recipes
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработал на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API (во внутренней реализации или в составе SDK) есть функция форматирования строк для отображения объёма напитка:
```
```typescript
l10n.volume.format = function(
value, language_code, country_code
) { … }
@ -74,7 +74,7 @@ l10n.volume.format = function(
Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию через партнёрский API, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:
```
```json
// Добавляем общее правило форматирования
// для русского языка
PUT /formatters/volume/ru
@ -101,7 +101,7 @@ PUT /formatters/volume/ru/US
Вернёмся теперь к проблеме `name` и `description`. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем `name` и `description` не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.
```
```json
GET /v1/layouts/{layout_id}
{
"id",
@ -138,9 +138,9 @@ GET /v1/layouts/{layout_id}
Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:
```
PUT /v1/recipes/{id}/⮠
properties/l10n/{lang}
```json
PUT /v1/recipes/{id}
/properties/l10n/{lang}
{
"search_title", "search_description"
}
@ -150,7 +150,7 @@ PUT /v1/recipes/{id}/⮠
Наш интерфейс добавления рецепта получит в итоге вот такой вид:
```
```json
POST /v1/recipes
{ "id" }
@ -159,7 +159,7 @@ POST /v1/recipes
Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:
```
```json
POST /v1/recipe-builder
{
"id",
@ -194,7 +194,7 @@ POST /v1/recipe-builder
Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнёрских рецептов всегда пользоваться парой идентификаторов (партнёра и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в главе [«Описание конечных интерфейсов»](#api-design-describing-interfaces).
```
```json
POST /v1/recipes/custom
{
// Первая часть идентификатора:
@ -214,7 +214,7 @@ POST /v1/recipes/custom
**NB**: внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе [«Разделение уровней абстракции»](#api-design-separating-abstractions) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
```
```json
GET /v1/recipes/{id}/run-data/{api_type}
{ /* описание способа запуска

View File

@ -2,7 +2,7 @@
В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Вернёмся теперь к вопросу, который мы вскользь затронули в главе [«Расширение через абстрагирование»](#back-compat-abstracting-extending): каким образом нам нужно параметризовать приготовление заказа, если оно исполняется через сторонний API? Иными словами, что такое этот самый `order_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
```
```json
PUT /v1/api-types/{api_type}
{
"order_execution_endpoint": {
@ -13,7 +13,7 @@ PUT /v1/api-types/{api_type}
Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
```
```json
PUT /v1/api-types/{api_type}
{
@ -67,7 +67,7 @@ PUT /v1/api-types/{api_type}
Организовать и то, и другое можно разными способами (см. [соответствующую главу](#api-patterns-push-vs-poll) раздела «Паттерны дизайна API»); по сути мы всегда имеем два контекста и поток событий между ними. В случае SDK эту идею можно было бы выразить через генерацию событий:
```
```typescript
/* Имплементация партнёром интерфейса
запуска программы на его кофемашинах */
registerProgramRunHandler(
@ -125,7 +125,7 @@ registerProgramRunHandler(
Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:
```
```typescript
/* Имплементация партнёром интерфейса
запуска программы на его кофемашинах */
registerProgramRunHandler(
@ -172,7 +172,7 @@ registerProgramRunHandler(
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
```
```typescript
program.context.on(
'takeout_requested',
() => {
@ -191,7 +191,7 @@ program.context.on(
Надо отметить, что такой подход *в принципе* не противоречит описанным идеям снижения связности компонентов, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
```
```typescript
program.context.on(
'takeout_requested',
() => {
@ -205,7 +205,7 @@ program.context.on(
);
```
```
```typescript
// Имплементация program.context.dispatch
ProgramContext.dispatch = (action) => {
// program.context обращается к своему

View File

@ -2,7 +2,7 @@
По прочтению предыдущей главы у читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: одни API полагаются на стандартную семантику HTTP, другие полностью от неё отказываются в пользу новоизобретённых стандартов, а третьи существуют где-то посередине. Например, если мы посмотрим на формат ответа в JSON-RPC[ref JSON-RPC 2.0 Specification. Response object](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
```
```json
HTTP/1.1 200 OK
{

View File

@ -6,7 +6,7 @@
HTTP-запрос представляет собой (1) применение определённого глагола к URL с (2) указанием версии протокола, (3) передачей дополнительной мета-информации в заголовках и, возможно, (4) каких-то данных в теле запроса:
```
```json
POST /v1/orders HTTP/1.1
Host: our-api-host.tld
Content-Type: application/json
@ -23,7 +23,7 @@ Content-Type: application/json
Ответом на HTTP-запрос будет являться конструкция, состоящая из (1) версии протокола, (2) статус-кода ответа, (3) сообщения, (4) заголовков и, возможно, (5) тела ответа:
```
```json
HTTP/1.1 201 Created
Location: /v1/orders/123
Content-Type: application/json
@ -146,14 +146,14 @@ HTTP-глагол определяет два важных свойства HTTP
* как query-параметр `/v1/orders?partner_id=<partner_id>`;
* как заголовок
```
```json
GET /v1/orders HTTP/1.1
X-ApiName-Partner-Id: <partner_id>
```
* как поле в теле запроса
```
```json
POST /v1/orders/retrieve HTTP/1.1
{

View File

@ -20,7 +20,7 @@
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
```
```json
GET /v1/state HTTP/1.1
Authorization: Bearer <token>
@ -52,11 +52,11 @@ HTTP/1.1 200 OK
Нетрудно заметить, что мы тем самым создаём излишнюю нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный `user_id` передавался остальным сервисам по цепочке:
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
* гейтвей D обращается к сервису B
```
```json
GET /v1/profiles/{user_id}
```
и к сервису C
```
```json
GET /v1/orders?user_id=<user id>
```
@ -85,7 +85,7 @@ HTTP/1.1 200 OK
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще профиля пользователя, и возврат неверного состояния может приводить к крайне неприятным последствиям. Вспомним, однако, описанный нами в главе «[Стратегии синхронизации](#api-patterns-sync-strategies)» паттерн оптимистичного управления параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам тэг, соответствующий текущему состоянию заказов пользователя:
```
```json
GET /v1/orders?user_id=<user_id> HTTP/1.1
HTTP/1.1 200 OK
@ -98,7 +98,7 @@ ETag: <ревизия>
2. При получении повторного запроса:
* найти закэшированное состояние, если оно есть;
* отправить запрос к сервису C вида
```
```json
GET /v1/orders?user_id=<user_id> HTTP/1.1
If-None-Match: <ревизия>
```
@ -109,21 +109,21 @@ ETag: <ревизия>
Использовав такое решение [функциональность управления кэшом через `ETag` ресурсов], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Если мы используем оптимистичное управление параллелизмом, то клиент должен передать в запросе актуальную ревизию ресурса `orders`:
```
```json
POST /v1/orders HTTP/1.1
If-Match: <ревизия>
```
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису C:
```
```json
POST /v1/orders?user_id=<user_id> HTTP/1.1
If-Match: <ревизия>
```
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса C обновлённый список заказов и его ревизию:
```
```json
HTTP/1.1 201 Created
Content-Location: /v1/orders?user_id=<user_id>
ETag: <новая ревизия>
@ -153,12 +153,12 @@ ETag: <новая ревизия>
Рассмотрим подробнее подход, в котором авторизационного сервиса A фактически нет (точнее, он имплементируется как библиотека или локальный демон в составе сервисов B, C и D), и все необходимые данные зашифрованы в самом токене авторизации. Тогда каждый сервис должен выполнять следующие действия:
1. Получить запрос вида
```
```json
GET /v1/profiles/{user_id}
Authorization: Bearer <token>
```
2. Расшифровать токен и получить вложенные данные, например, в следующем виде:
```
```json
{
// Идентификатор пользователя-
// владельца токена
@ -171,7 +171,7 @@ ETag: <новая ревизия>
Требование передавать `user_id` дважды и потом сравнивать две копии друг с другом может показаться нелогичным и избыточным. Однако это мнение ошибочно, и проистекает из широко распространённого (анти)паттерна, с описания которого мы начали главу, а именно — stateful-определение параметров операции:
```
```json
GET /v1/profile
Authorization: Bearer <token>
```
@ -185,7 +185,7 @@ Authorization: Bearer <token>
В случае «тройственного» эндпойнта проверки доступа мы можем только разработать новый эндпойнт с новым интерфейсом. В случае stateless-токенов мы можем поступить так:
1. Зашифровать в токене *список* пользователей, доступ к которым возможен через предъявление настоящего токена:
```
```json
{
// Идентификаторы пользователей,
// доступ к профилям которых

View File

@ -16,7 +16,7 @@
* как path-параметр: `/v1/orders/{id}`;
* как query-параметр: `/orders/{id}?version=1`;
* как заголовок:
```
```json
GET /orders/{id} HTTP/1.1
X-OurCoffeeAPI-Version: 1
```
@ -83,7 +83,7 @@
Начнём с операции создания ресурса. Как мы помним из главы «[Стратегии синхронизации](#api-patterns-sync-strategies)», операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
1. Через метод `POST` с передачей токена идемпотентности (им может выступать, в частности, `ETag` ресурса):
```
```json
POST /v1/orders/?user_id=<user_id> HTTP/1.1
If-Match: <ревизия>
@ -91,7 +91,7 @@
```
2. Через метод `PUT`, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):
```
```json
PUT /v1/orders/{order_id} HTTP/1.1
If-Match: <ревизия>
@ -99,7 +99,7 @@
```
3. Через схему создания черновика методом `POST` и его подтверждения методом `PUT`:
```
```json
POST /v1/drafts HTTP/1.1
{ … }
@ -107,7 +107,7 @@
HTTP/1.1 201 Created
Location: /v1/drafts/{id}
```
```
```json
PUT /v1/drafts/{id}/commit
If-Match: <ревизия>

View File

@ -2,7 +2,7 @@
Рассмотренные в предыдущих главах примеры организации API согласно стандарту HTTP и принципам REST покрывают т.н. «happy path», т.е. стандартный процесс работы с API в отсутствие ошибок. Конечно, нам не менее интересен и обратный кейс — каким образом HTTP API следует работать с ошибками, и чем стандарт и архитектурные принципы могут нам в этом помочь. Пусть какой-то агент в системе (неважно, клиент или гейтвей) пытается создать новый заказ:
```
```json
POST /v1/orders?user_id=<user_id> HTTP/1.1
Authorization: Bearer <token>
If-Match: <ревизия>
@ -68,7 +68,7 @@ If-Match: <ревизия>
Всё это естественным образом подводит нас к следующему выводу: если мы хотим использовать ошибки для диагностики и (возможно) восстановления состояния клиента, нам необходимо добавить машиночитаемую метаинформацию о подвиде ошибки и, возможно, тело ошибки с указанием подробной информации о проблемах — например, как мы предлагали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)»:
```
```json
POST /v1/coffee-machines/search HTTP/1.1
{
@ -80,13 +80,13 @@ POST /v1/coffee-machines/search HTTP/1.1
}
HTTP/1.1 400 Bad Request
X-OurCoffeeAPI-Error-Kind:
X-OurCoffeeAPI-Error-Kind:
wrong_parameter_value
{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так.
"Что-то пошло не так.
Обратитесь к разработчику приложения.",
"details": {
"checks_failed": [
@ -94,7 +94,7 @@ X-OurCoffeeAPI-Error-Kind:⮠
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown.
"Value 'lngo' unknown.
Did you mean 'lungo'?"
},
{
@ -105,8 +105,8 @@ X-OurCoffeeAPI-Error-Kind:⮠
"max": 90
},
"message":
"'position.latitude' value
must fall within
"'position.latitude' value
must fall within
the [-90, 90] interval"
}
]
@ -122,11 +122,11 @@ X-OurCoffeeAPI-Error-Kind:⮠
Для внутренних систем, вообще говоря, такое рассуждение неверно. Для построения правильных мониторингов и системы оповещений необходимо, чтобы серверные ошибки, точно так же, как и клиентские, содержали подтип ошибки в машиночитаемом виде. Здесь по-прежнему применимы те же подходы — использование широкой номенклатуры кодов и/или передача типа ошибки заголовком — однако эта информация должна быть вырезана гейтвеем на границе внешней и внутренней систем, и заменена на общую информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки.
```
```json
POST /v1/orders/?user_id=<user id> HTTP/1.1
If-Match: <ревизия>
{ parameters }
{ "parameters" }
// Ответ, полученный гейтвеем
// от сервиса обработки заказов,
@ -140,7 +140,7 @@ X-OurCoffeAPI-Error-Kind: db_timeout
* какой хост ответил таймаутом
*/ }
```
```
```json
// Ответ, передаваемый клиенту.
// Детали серверной ошибки удалены
// и заменены на инструкцию клиенту.
@ -154,8 +154,8 @@ Retry-After: 5
{
"reason": "internal_server_error",
"localized_message": "Не удалось
получить ответ от сервера.
"localized_message": "Не удалось
получить ответ от сервера.
Попробуйте повторить операцию
или обновить страницу.",
"details": {

View File

@ -21,7 +21,7 @@
1. В клиент-серверных API данные передаются только по значению; чтобы сослаться на какую-то сущность, необходимо использовать какие-то внешние идентификаторы. Например, если у нас есть два набора сущностей — рецепты и предложения кофе — то нам необходимо будет построить карту рецептов по id, чтобы понять, на какой рецепт ссылается какое предложение:
```
```typescript
// Запрашиваем информацию о рецептах
// лунго и латте
const recipes = await api
@ -48,12 +48,13 @@
const recipe = recipeMap
.get(offer.recipe_id);
return {offer, recipe};
}));
```
})
);
```
Указанный код мог бы быть вдвое короче, если бы мы сразу получали из метода `api.search` предложения с заполненной ссылкой на рецепт:
```
```typescript
// Запрашиваем информацию о рецептах
// лунго и латте
const recipes = await api
@ -78,7 +79,7 @@
2. Клиент-серверные API, как правило, стараются декомпозировать так, чтобы одному запросу соответствовал один тип возвращаемых данных. Даже если эндпойнт композитный (т.е. позволяет при запросе с помощью параметров указать, какие из дополнительных данных необходимо вернуть), это всё ещё ответственность разработчика этими параметрами воспользоваться. Код из примера выше мог бы быть ещё короче, если бы SDK взял на себя инициализацию всех нужных связанных объектов:
```
```typescript
// Запрашиваем предложения
// лунго и латте
const offers = await api.search({
@ -101,7 +102,7 @@
3. Получение обратных вызовов в клиент-серверном API, даже если это дуплексный канал, с точки зрения клиента выглядит крайне неудобным в разработке, поскольку вновь требует наличия карт объектов. Даже если в API реализована push-модель, код выходит чрезвычайно громоздким:
```
```typescript
// Получаем текущие заказы
const orders = await api
.getOngoingOrders();
@ -134,7 +135,7 @@
И вновь мы приходим к тому, что недостаточно продуманный SDK приводит к ошибкам в работе использующих его приложений. Разработчику было бы намного удобнее, если бы объект заказа позволял подписаться на свои события, не задумываясь о том, как эта подписка технически работает и как не пропустить события:
```
```typescript
const order = await api
.createOrder(…)
// Нет нужды подписываться
@ -150,7 +151,7 @@
4. Восстановление после ошибок в бизнес-логике, как правило, достаточно сложная операция, которую сложно описать в машиночитаемом виде. Разработчику клиента необходимо самому продумать эти сценарии.
```
```typescript
// Получаем предложения
const offers = await api
.search(…);

View File

@ -57,7 +57,7 @@
Но на этом история не заканчивается. Если разработчик всё-таки хочет именно этого, т.е. показывать иконку сети кофеен (если она есть) на кнопке создания заказа — как ему это сделать? Из той же логики, нам необходимо предоставить ещё более частную возможность такого переопределения. Например, представим себе следующую функциональность: если в данных предложения есть поле `createOrderButtonIconUrl`, то иконка будет взята из этого поля. Тогда разработчик сможет кастомизировать кнопку заказа, подменив в данных поле `createOrderButtonIconUrl` для каждого результата поиска:
```
```typescript
const searchBox = new SearchBox({
// Предположим, что мы разрешили
// переопределять поисковую функцию

View File

@ -362,7 +362,7 @@ class SearchBox {
Всё это приводит нас к простому выводу: мы не можем декомпозировать `SearchBox` просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между `SearchBox`, который не зависит от конкретной имплементации UI работы с предложениями и `OfferList`/`OfferPanel`, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных.
```
```typescript
class SearchBoxComposer implements ISearchBoxComposer {
// Ответственность `composer`-а состоит в:
// 1. Создании собственного контекста
@ -408,6 +408,7 @@ class SearchBoxComposer implements ISearchBoxComposer {
this.generateOfferPanelOptions()
);
}
}
```
Мы можем придать `SearchBoxComposer`-у функциональность трансляции любых контекстов. В частности: