1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-31 22:09:37 +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 ```json
// Method description // Method description
POST /v1/bucket/{id}/some-resource POST /v1/bucket/{id}/some-resource
/{resource_id} /{resource_id}
X-Idempotency-Token: <idempotency token> X-Idempotency-Token: <idempotency token>
{ {
@ -26,8 +26,8 @@ Cache-Control: no-cache
a multiline comment */ a multiline comment */
"error_reason", "error_reason",
"error_message": "error_message":
"Long error message "Long error message
that will span several that will span several
lines" 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). * 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 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. * 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.” 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 // Error kind
"reason": "offer_invalid", "reason": "offer_invalid",
"localized_message": "localized_message":
"Something went wrong. "Something went wrong.
Try restarting the app." Try restarting the app."
"details": { "details": {
// What's wrong exactly? // What's wrong exactly?

View File

@ -156,7 +156,7 @@ The word “function” is ambiguous. It might refer to built-in functions, but
**Better**: **Better**:
```typescript ```typescript
GET /v1/coffee-machines/{id} GET /v1/coffee-machines/{id}
/builtin-functions-list /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: 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 ```typescript
const value = orderParams const value = orderParams.contactless_delivery;
.contactless_delivery; if (Type(value) == 'Boolean' && value == false) {
if (Type(value) == 'Boolean' &&
value == false) {
} }
``` ```
@ -466,12 +464,12 @@ POST /v1/coffee-machines/search
"results": [], "results": [],
"warnings": [{ "warnings": [{
"type": "suspicious_coordinates", "type": "suspicious_coordinates",
"message": "Location [0, 0] "message": "Location [0, 0]
is probably a mistake" is probably a mistake"
}, { }, {
"type": "unknown_field", "type": "unknown_field",
"message": "unknown field: "message": "unknown field:
`force_convact_delivery`. Did you `force_convact_delivery`. Did you
mean `force_contact_delivery`?" 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: If it is not possible to add such notices, we can introduce a debug mode or strict mode in which notices are escalated:
```json ```json
POST /v1/coffee-machines/search POST /v1/coffee-machines/search
?strict_mode=true ?strict_mode=true
{ {
"location": { "location": {
@ -492,7 +490,7 @@ POST /v1/coffee-machines/search⮠
{ {
"errors": [{ "errors": [{
"type": "suspicious_coordinates", "type": "suspicious_coordinates",
"message": "Location [0, 0] "message": "Location [0, 0]
is probably a mistake" 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: If the [0, 0] coordinates are not an error, it makes sense to allow for manual bypassing of specific errors:
```json ```json
POST /v1/coffee-machines/search POST /v1/coffee-machines/search
?strict_mode=true ?strict_mode=true
&disable_errors=suspicious_coordinates &disable_errors=suspicious_coordinates
``` ```
@ -566,7 +564,7 @@ POST /v1/coffee-machines/search
{ {
"reason": "wrong_parameter_value", "reason": "wrong_parameter_value",
"localized_message": "localized_message":
"Something is wrong. "Something is wrong.
Contact the developer of the app.", Contact the developer of the app.",
"details": { "details": {
"checks_failed": [ "checks_failed": [
@ -574,7 +572,7 @@ POST /v1/coffee-machines/search
"field": "recipe", "field": "recipe",
"error_type": "wrong_value", "error_type": "wrong_value",
"message": "message":
"Unknown value: 'lngo'. "Unknown value: 'lngo'.
Did you mean 'lungo'?" Did you mean 'lungo'?"
}, },
{ {
@ -586,8 +584,8 @@ POST /v1/coffee-machines/search
"max": 90 "max": 90
}, },
"message": "message":
"'position.latitude' value "'position.latitude' value
must fall within must fall within
the [-90, 90] interval" the [-90, 90] interval"
} }
] ]
@ -727,8 +725,8 @@ Let's emphasize that we understand “cache” in the extended sense: which vari
```json ```json
// Returns lungo prices including // Returns lungo prices including
// delivery to the specified location // delivery to the specified location
GET /price?recipe=lungo GET /price?recipe=lungo
&longitude={longitude} &longitude={longitude}
&latitude={latitude} &latitude={latitude}
{ "currency_code", "price" } { "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: **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 ```json
GET /price?recipe=lungo GET /price?recipe=lungo
&longitude={longitude} &longitude={longitude}
&latitude={latitude} &latitude={latitude}
{ {
@ -804,7 +802,7 @@ POST /v1/orders/drafts
``` ```
```json ```json
// Confirms the draft // Confirms the draft
PUT /v1/orders/drafts PUT /v1/orders/drafts
/{draft_id}/confirmation /{draft_id}/confirmation
{ "confirmed": true } { "confirmed": true }
``` ```
@ -883,13 +881,13 @@ It is equally important to provide interfaces to partners that minimize potentia
```json ```json
// Allows partners to set // Allows partners to set
// descriptions for their beverages // descriptions for their beverages
PUT /v1/partner-api/{partner-id} PUT /v1/partner-api/{partner-id}
/recipes/lungo/info /recipes/lungo/info
"<script>alert(document.cookie)</script>" "<script>alert(document.cookie)</script>"
``` ```
```json ```json
// Returns the desciption // Returns the desciption
GET /v1/partner-api/{partner-id} GET /v1/partner-api/{partner-id}
/recipes/lungo/info /recipes/lungo/info
"<script>alert(document.cookie)</script>" "<script>alert(document.cookie)</script>"
@ -903,7 +901,7 @@ In these situations, we recommend, first, sanitizing the data if it appears pote
```json ```json
// Allows for setting a potentially // Allows for setting a potentially
// unsafe description for a beverage // unsafe description for a beverage
PUT /v1/partner-api/{partner-id} PUT /v1/partner-api/{partner-id}
/recipes/lungo/info /recipes/lungo/info
X-Dangerously-Disable-Sanitizing: true X-Dangerously-Disable-Sanitizing: true
"<script>alert(document.cookie)</script>" "<script>alert(document.cookie)</script>"
@ -911,7 +909,7 @@ X-Dangerously-Disable-Sanitizing: true
```json ```json
// Returns the potentially // Returns the potentially
// unsafe description // unsafe description
GET /v1/partner-api/{partner-id} GET /v1/partner-api/{partner-id}
/recipes/lungo/info /recipes/lungo/info
X-Dangerously-Allow-Raw-Value: true 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 POST /v1/run/sql
{ {
// Passes the full script // Passes the full script
"query": "INSERT INTO data (name) "query": "INSERT INTO data (name)
VALUES ('Robert'); VALUES ('Robert');
DROP TABLE students;--')" DROP TABLE students;--')"
} }
``` ```
@ -935,11 +933,11 @@ POST /v1/run/sql
POST /v1/run/sql POST /v1/run/sql
{ {
// Passes the script template // Passes the script template
"query": "INSERT INTO data (name) "query": "INSERT INTO data (name)
VALUES (?)", VALUES (?)",
// and the parameters to set // and the parameters to set
"values": [ "values": [
"Robert'); "Robert');
DROP TABLE students;--" 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: 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 ```json
GET /v1/partners/{id}/offers/history GET /v1/partners/{id}/offers/history
?limit=<limit> ?limit=<limit>
{ {
@ -213,11 +213,11 @@ If the data storage we use for keeping list items offers the possibility of usin
```json ```json
// Retrieve the records that precede // Retrieve the records that precede
// the one with the given id // the one with the given id
GET /v1/partners/{id}/offers/history GET /v1/partners/{id}/offers/history
?newer_than=<item_id>&limit=<limit> ?newer_than=<item_id>&limit=<limit>
// Retrieve the records that follow // Retrieve the records that follow
// the one with the given id // the one with the given id
GET /v1/partners/{id}/offers/history GET /v1/partners/{id}/offers/history
?older_than=<item_id>&limit=<limit> ?older_than=<item_id>&limit=<limit>
``` ```
@ -235,7 +235,7 @@ Often, the interfaces of traversing data through stating boundaries are generali
```json ```json
// Initiate list traversal // Initiate list traversal
POST /v1/partners/{id}/offers/history POST /v1/partners/{id}/offers/history
/search /search
{ {
"order_by": [{ "order_by": [{
@ -251,8 +251,8 @@ POST /v1/partners/{id}/offers/history⮠
```json ```json
// Get the next data chunk // Get the next data chunk
GET /v1/partners/{id}/offers/history GET /v1/partners/{id}/offers/history
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy ?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy
&limit=100 &limit=100
{ {
@ -268,7 +268,7 @@ The cursor-based approach also allows adding new filters and sorting directions
```json ```json
// Initialize list traversal // Initialize list traversal
POST /v1/partners/{id}/offers/history POST /v1/partners/{id}/offers/history
/search /search
{ {
// Add a filter by the recipe // 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 ```json
// Retrieve all the events older // Retrieve all the events older
// than the one with the given id // than the one with the given id
GET /v1/orders/created-history GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit> ?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. 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 ```json
GET /v1/orders/created-history GET /v1/orders/created-history
?older_than=<item_id>&limit=<limit> ?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 // * Leaves the first beverage
// intact // intact
// * Removes the second beverage. // * Removes the second beverage.
PATCH /v1/orders/{id} PATCH /v1/orders/{id}
// A meta filter: which fields // A meta filter: which fields
// are allowed to be modified // are allowed to be modified
?field_mask=delivery_address,items ?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. 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: 2. Add a new “with options” endpoint:
```json ```json
PUT /v1/partners/{partner_id} PUT /v1/partners/{partner_id}
/coffee-machines-with-options /coffee-machines-with-options
{ {
"coffee_machines": [{ "coffee_machines": [{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и увеличить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик: Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и увеличить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик:
``` ```typescript
// Получаем состояние, // Получаем состояние,
// возможно, из реплики // возможно, из реплики
const orderState = const orderState =
@ -27,7 +27,7 @@ try {
Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли. Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
``` ```typescript
// Создаёт заказ // Создаёт заказ
const api = await api const api = await api
.createOrder(…) .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 можно, если предложить клиенту самому передать токен, описывающий его последние изменения. Важный паттерн, который поможет в этой ситуации — это имплементация модели «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 const order = await api
.createOrder(…); .createOrder(…);
const pendingOrders = await api. const pendingOrders = await api.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример. Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример.
``` ```json
// Создаёт заказ из двух напитков // Создаёт заказ из двух напитков
POST /v1/orders/ POST /v1/orders/
X-Idempotency-Token: <токен> X-Idempotency-Token: <токен>
@ -19,7 +19,7 @@ X-Idempotency-Token: <токен>
{ "order_id" } { "order_id" }
``` ```
``` ```json
// Частично перезаписывает заказ, // Частично перезаписывает заказ,
// обновляет объём второго напитка // обновляет объём второго напитка
PATCH /v1/orders/{id} 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)), например, так: Это решение можно улучшить путём ввода явных управляющих конструкций вместо «магических значений» и введением мета-опций операции (скажем, фильтра по именам полей, как это принято в 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: // Специальное значение №1:
// обнулить поле // обнулить поле
@ -132,7 +132,7 @@ PATCH /v1/orders/{id}?⮠
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности независимых операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility). **Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности независимых операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility).
``` ```json
// Создаёт заказ из двух напитков // Создаёт заказ из двух напитков
POST /v1/orders/ POST /v1/orders/
{ {
@ -160,7 +160,7 @@ POST /v1/orders/
} }
``` ```
``` ```json
// Изменяет параметры, // Изменяет параметры,
// относящиеся ко всему заказу // относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters PUT /v1/orders/{id}/parameters
@ -169,7 +169,7 @@ PUT /v1/orders/{id}/parameters
{ "delivery_address" } { "delivery_address" }
``` ```
``` ```json
// Частично перезаписывает заказ // Частично перезаписывает заказ
// обновляет объём одного напитка // обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id} PUT /v1/orders/{id}/items/{item_id}
@ -182,7 +182,7 @@ PUT /v1/orders/{id}/items/{item_id}
{ "recipe", "volume", "milk_type" } { "recipe", "volume", "milk_type" }
``` ```
``` ```json
// Удаляет один из напитков в заказе // Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id} DELETE /v1/orders/{id}/items/{item_id}
``` ```
@ -205,7 +205,7 @@ DELETE /v1/orders/{id}/items/{item_id}
В нашем случае мы можем пойти, например, вот таким путём: В нашем случае мы можем пойти, например, вот таким путём:
``` ```json
POST /v1/order/changes POST /v1/order/changes
X-Idempotency-Token: <токен> X-Idempotency-Token: <токен>
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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