mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-05-25 22:08:06 +02:00
styling improved
This commit is contained in:
parent
87993f356e
commit
bfa0cc9560
@ -10,7 +10,7 @@ Let's take a look at the following example:
|
||||
|
||||
```json
|
||||
// Method description
|
||||
POST /v1/bucket/{id}/some-resource⮠
|
||||
POST /v1/bucket/{id}/some-resource↵
|
||||
/{resource_id}
|
||||
X-Idempotency-Token: <idempotency token>
|
||||
{
|
||||
@ -26,8 +26,8 @@ Cache-Control: no-cache
|
||||
a multiline comment */
|
||||
"error_reason",
|
||||
"error_message":
|
||||
"Long error message⮠
|
||||
that will span several⮠
|
||||
"Long error message↵
|
||||
that will span several↵
|
||||
lines"
|
||||
}
|
||||
```
|
||||
@ -40,7 +40,7 @@ It should be read like this:
|
||||
* In response (marked with an arrow symbol `→`) the server returns a `404 Not Found` status code; the status might be omitted (treat it like a `200 OK` if no status is provided).
|
||||
* The response could possibly contain additional notable headers.
|
||||
* The response body is a JSON comprising two fields: `error_reason` and `error_message`. Absence of a value means that the field contains exactly what you expect it should contain — so there is some generic error reason value which we omitted.
|
||||
* If some token is too long to fit on a single line, we will split it into several lines adding `⮠` to indicate it continues next line.
|
||||
* If some token is too long to fit on a single line, we will split it into several lines adding `↵` to indicate it continues next line.
|
||||
|
||||
The term “client” here stands for an application being executed on a user's device, either a native or a web one. The terms “agent” and “user agent” are synonymous with “client.”
|
||||
|
||||
|
@ -193,7 +193,7 @@ In our case, the price mismatch error should look like this:
|
||||
// Error kind
|
||||
"reason": "offer_invalid",
|
||||
"localized_message":
|
||||
"Something went wrong.⮠
|
||||
"Something went wrong.↵
|
||||
Try restarting the app."
|
||||
"details": {
|
||||
// What's wrong exactly?
|
||||
|
@ -156,7 +156,7 @@ The word “function” is ambiguous. It might refer to built-in functions, but
|
||||
|
||||
**Better**:
|
||||
```typescript
|
||||
GET /v1/coffee-machines/{id}⮠
|
||||
GET /v1/coffee-machines/{id}↵
|
||||
/builtin-functions-list
|
||||
```
|
||||
|
||||
@ -231,10 +231,8 @@ const order = api.createOrder(
|
||||
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how should developers discern the explicit intention to disable the option (`false`) from not knowing if it exists (the field isn't set)? They would have to write something like:
|
||||
|
||||
```typescript
|
||||
const value = orderParams
|
||||
.contactless_delivery;
|
||||
if (Type(value) == 'Boolean' &&
|
||||
value == false) {
|
||||
const value = orderParams.contactless_delivery;
|
||||
if (Type(value) == 'Boolean' && value == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
@ -466,12 +464,12 @@ POST /v1/coffee-machines/search
|
||||
"results": [],
|
||||
"warnings": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Location [0, 0]⮠
|
||||
"message": "Location [0, 0]↵
|
||||
is probably a mistake"
|
||||
}, {
|
||||
"type": "unknown_field",
|
||||
"message": "unknown field:⮠
|
||||
`force_convact_delivery`. Did you⮠
|
||||
"message": "unknown field:↵
|
||||
`force_convact_delivery`. Did you↵
|
||||
mean `force_contact_delivery`?"
|
||||
}]
|
||||
}
|
||||
@ -480,7 +478,7 @@ POST /v1/coffee-machines/search
|
||||
If it is not possible to add such notices, we can introduce a debug mode or strict mode in which notices are escalated:
|
||||
|
||||
```json
|
||||
POST /v1/coffee-machines/search⮠
|
||||
POST /v1/coffee-machines/search↵
|
||||
?strict_mode=true
|
||||
{
|
||||
"location": {
|
||||
@ -492,7 +490,7 @@ POST /v1/coffee-machines/search⮠
|
||||
{
|
||||
"errors": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Location [0, 0]⮠
|
||||
"message": "Location [0, 0]↵
|
||||
is probably a mistake"
|
||||
}],
|
||||
…
|
||||
@ -502,8 +500,8 @@ POST /v1/coffee-machines/search⮠
|
||||
If the [0, 0] coordinates are not an error, it makes sense to allow for manual bypassing of specific errors:
|
||||
|
||||
```json
|
||||
POST /v1/coffee-machines/search⮠
|
||||
?strict_mode=true⮠
|
||||
POST /v1/coffee-machines/search↵
|
||||
?strict_mode=true↵
|
||||
&disable_errors=suspicious_coordinates
|
||||
```
|
||||
|
||||
@ -566,7 +564,7 @@ POST /v1/coffee-machines/search
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Something is wrong.⮠
|
||||
"Something is wrong.↵
|
||||
Contact the developer of the app.",
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
@ -574,7 +572,7 @@ POST /v1/coffee-machines/search
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Unknown value: 'lngo'.⮠
|
||||
"Unknown value: 'lngo'.↵
|
||||
Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
@ -586,8 +584,8 @@ POST /v1/coffee-machines/search
|
||||
"max": 90
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value⮠
|
||||
must fall within⮠
|
||||
"'position.latitude' value↵
|
||||
must fall within↵
|
||||
the [-90, 90] interval"
|
||||
}
|
||||
]
|
||||
@ -727,8 +725,8 @@ Let's emphasize that we understand “cache” in the extended sense: which vari
|
||||
```json
|
||||
// Returns lungo prices including
|
||||
// delivery to the specified location
|
||||
GET /price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
GET /price?recipe=lungo↵
|
||||
&longitude={longitude}↵
|
||||
&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
@ -740,8 +738,8 @@ Two questions arise:
|
||||
**Better**: you may use standard protocol capabilities to denote cache options, such as the `Cache-Control` header. If you need caching in both temporal and spatial dimensions, you should do something like this:
|
||||
|
||||
```json
|
||||
GET /price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
GET /price?recipe=lungo↵
|
||||
&longitude={longitude}↵
|
||||
&latitude={latitude}
|
||||
→
|
||||
{
|
||||
@ -804,7 +802,7 @@ POST /v1/orders/drafts
|
||||
```
|
||||
```json
|
||||
// Confirms the draft
|
||||
PUT /v1/orders/drafts⮠
|
||||
PUT /v1/orders/drafts↵
|
||||
/{draft_id}/confirmation
|
||||
{ "confirmed": true }
|
||||
```
|
||||
@ -883,13 +881,13 @@ It is equally important to provide interfaces to partners that minimize potentia
|
||||
```json
|
||||
// Allows partners to set
|
||||
// descriptions for their beverages
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
PUT /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```json
|
||||
// Returns the desciption
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
GET /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
→
|
||||
"<script>alert(document.cookie)</script>"
|
||||
@ -903,7 +901,7 @@ In these situations, we recommend, first, sanitizing the data if it appears pote
|
||||
```json
|
||||
// Allows for setting a potentially
|
||||
// unsafe description for a beverage
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
PUT /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Disable-Sanitizing: true
|
||||
"<script>alert(document.cookie)</script>"
|
||||
@ -911,7 +909,7 @@ X-Dangerously-Disable-Sanitizing: true
|
||||
```json
|
||||
// Returns the potentially
|
||||
// unsafe description
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
GET /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Allow-Raw-Value: true
|
||||
→
|
||||
@ -925,8 +923,8 @@ One important finding is that if you allow executing scripts via the API, always
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Passes the full script
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
VALUES ('Robert');⮠
|
||||
"query": "INSERT INTO data (name)↵
|
||||
VALUES ('Robert');↵
|
||||
DROP TABLE students;--')"
|
||||
}
|
||||
```
|
||||
@ -935,11 +933,11 @@ POST /v1/run/sql
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Passes the script template
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
"query": "INSERT INTO data (name)↵
|
||||
VALUES (?)",
|
||||
// and the parameters to set
|
||||
"values": [
|
||||
"Robert');⮠
|
||||
"Robert');↵
|
||||
DROP TABLE students;--"
|
||||
]
|
||||
}
|
||||
|
@ -173,7 +173,7 @@ The easiest case is with immutable lists, i.e., when the set of items never chan
|
||||
The case of a list with immutable items and the operation of adding new ones is more typical. Most notably, we talk about event queues containing, for example, new messages or notifications. Let's imagine there is an endpoint in our coffee API that allows partners to retrieve the history of offers:
|
||||
|
||||
```json
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?limit=<limit>
|
||||
→
|
||||
{
|
||||
@ -213,11 +213,11 @@ If the data storage we use for keeping list items offers the possibility of usin
|
||||
```json
|
||||
// Retrieve the records that precede
|
||||
// the one with the given id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?newer_than=<item_id>&limit=<limit>
|
||||
// Retrieve the records that follow
|
||||
// the one with the given id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
```
|
||||
|
||||
@ -235,7 +235,7 @@ Often, the interfaces of traversing data through stating boundaries are generali
|
||||
|
||||
```json
|
||||
// Initiate list traversal
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
POST /v1/partners/{id}/offers/history↵
|
||||
/search
|
||||
{
|
||||
"order_by": [{
|
||||
@ -251,8 +251,8 @@ POST /v1/partners/{id}/offers/history⮠
|
||||
|
||||
```json
|
||||
// Get the next data chunk
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy↵
|
||||
&limit=100
|
||||
→
|
||||
{
|
||||
@ -268,7 +268,7 @@ The cursor-based approach also allows adding new filters and sorting directions
|
||||
|
||||
```json
|
||||
// Initialize list traversal
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
POST /v1/partners/{id}/offers/history↵
|
||||
/search
|
||||
{
|
||||
// Add a filter by the recipe
|
||||
@ -333,7 +333,7 @@ If none of the approaches above works, our only solution is changing the subject
|
||||
```json
|
||||
// Retrieve all the events older
|
||||
// than the one with the given id
|
||||
GET /v1/orders/created-history⮠
|
||||
GET /v1/orders/created-history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
|
@ -3,7 +3,7 @@
|
||||
In the previous chapter, we discussed the following scenario: a partner receives information about new events occuring in the system by periodically requesting an endpoint that supports retrieving ordered lists.
|
||||
|
||||
```json
|
||||
GET /v1/orders/created-history⮠
|
||||
GET /v1/orders/created-history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
|
@ -103,7 +103,7 @@ The solution could be enhanced by introducing explicit control sequences instead
|
||||
// * Leaves the first beverage
|
||||
// intact
|
||||
// * Removes the second beverage.
|
||||
PATCH /v1/orders/{id}⮠
|
||||
PATCH /v1/orders/{id}↵
|
||||
// A meta filter: which fields
|
||||
// are allowed to be modified
|
||||
?field_mask=delivery_address,items
|
||||
|
@ -62,7 +62,7 @@ More specifically, if we talk about changing available order options, we should
|
||||
1. Describe the current state. All coffee machines, plugged via the API, must support three options: sprinkling with cinnamon, changing the volume, and contactless delivery.
|
||||
2. Add a new “with options” endpoint:
|
||||
```json
|
||||
PUT /v1/partners/{partner_id}⮠
|
||||
PUT /v1/partners/{partner_id}↵
|
||||
/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
|
@ -82,13 +82,13 @@ POST /v1/coffee-machines/search HTTP/1.1
|
||||
}
|
||||
→
|
||||
HTTP/1.1 400 Bad Request
|
||||
X-OurCoffeeAPI-Error-Kind:⮠
|
||||
X-OurCoffeeAPI-Error-Kind:↵
|
||||
wrong_parameter_value
|
||||
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Something is wrong.⮠
|
||||
"Something is wrong.↵
|
||||
Contact the developer of the app.",
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
@ -96,7 +96,7 @@ X-OurCoffeeAPI-Error-Kind:⮠
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Unknown value: 'lngo'.⮠
|
||||
"Unknown value: 'lngo'.↵
|
||||
Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
@ -108,8 +108,8 @@ X-OurCoffeeAPI-Error-Kind:⮠
|
||||
"max": 90
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value⮠
|
||||
must fall within⮠
|
||||
"'position.latitude' value↵
|
||||
must fall within↵
|
||||
the [-90, 90] interval"
|
||||
}
|
||||
]
|
||||
@ -129,7 +129,7 @@ However, for internal systems, this argumentation is wrong. To build proper moni
|
||||
POST /v1/orders/?user_id=<user id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
|
||||
{ parameters }
|
||||
{ "parameters" }
|
||||
→
|
||||
// The response the gateway received
|
||||
// from the server, the metadata
|
||||
@ -158,8 +158,8 @@ Retry-After: 5
|
||||
|
||||
{
|
||||
"reason": "internal_server_error",
|
||||
"localized_message": "Cannot get⮠
|
||||
a response from the server.⮠
|
||||
"localized_message": "Cannot get↵
|
||||
a response from the server.↵
|
||||
Please try repeating the operation
|
||||
or reload the page.",
|
||||
"details": {
|
||||
|
@ -7,9 +7,9 @@
|
||||
Большинство примеров API в общих разделах будут даны в виде абстрактных обращений по HTTP-протоколу к некоторой специфической именованной функции API («эндпойнту») с передачей данных в формате JSON. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо `GET /v1/orders` вполне может быть вызов метода `orders.get()`, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.
|
||||
|
||||
Рассмотрим следующую запись:
|
||||
```
|
||||
```json
|
||||
// Описание метода
|
||||
POST /v1/bucket/{id}/some-resource⮠
|
||||
POST /v1/bucket/{id}/some-resource↵
|
||||
/{resource_id}
|
||||
X-Idempotency-Token: <токен идемпотентности>
|
||||
{
|
||||
@ -24,8 +24,8 @@ Cache-Control: no-cache
|
||||
/* А это многострочный
|
||||
комментарий */
|
||||
"error_message":
|
||||
"Длинное сообщение,⮠
|
||||
которое приходится⮠
|
||||
"Длинное сообщение,↵
|
||||
которое приходится↵
|
||||
разбивать на строки"
|
||||
}
|
||||
```
|
||||
@ -38,7 +38,7 @@ Cache-Control: no-cache
|
||||
* в ответ (индицируется стрелкой `→`) сервер возвращает статус `404 Not Found`; статус может быть опущен (отсутствие статуса следует трактовать как `200 OK`);
|
||||
* в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;
|
||||
* телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке
|
||||
* если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ `⮠` для индикации переноса.
|
||||
* если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ `↵` для индикации переноса.
|
||||
|
||||
Здесь термин «клиент» означает «приложение, установленное на устройстве пользователя, использующее рассматриваемый API». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент».
|
||||
|
||||
|
@ -20,11 +20,11 @@
|
||||
|
||||
Допустим, мы имеем следующий интерфейс:
|
||||
|
||||
```
|
||||
```json
|
||||
// возвращает рецепт лунго
|
||||
GET /v1/recipes/lungo
|
||||
```
|
||||
```
|
||||
```json
|
||||
// размещает на указанной кофемашине
|
||||
// заказ на приготовление лунго
|
||||
// и возвращает идентификатор заказа
|
||||
@ -34,14 +34,14 @@ POST /v1/orders
|
||||
"recipe": "lungo"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// возвращает состояние заказа
|
||||
GET /v1/orders/{id}
|
||||
```
|
||||
|
||||
И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов.
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/recipes/lungo
|
||||
→
|
||||
{
|
||||
@ -49,7 +49,7 @@ GET /v1/recipes/lungo
|
||||
"volume": "100ml"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -67,7 +67,8 @@ GET /v1/orders/{id}
|
||||
Вариант 1: мы фиксируем список допустимых объёмов и заводим фиктивные рецепты типа `/recipes/small-lungo`, `recipes/large-lungo`. Почему фиктивные? Потому что рецепт один и тот же, меняется только объём. Нам придётся либо тиражировать одинаковые рецепты, отличающиеся только объёмом, либо вводить какое-то «наследование» рецептов, чтобы можно было указать базовый рецепт и только переопределить объём.
|
||||
|
||||
Вариант 2: мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного:
|
||||
```
|
||||
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"coffee_machine_id",
|
||||
@ -75,12 +76,13 @@ POST /v1/orders
|
||||
"volume":"800ml"
|
||||
}
|
||||
```
|
||||
|
||||
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из `GET /v1/recipes`, а из `GET /v1/orders`. Сделав так, мы сразу получаем клубок из связанных проблем:
|
||||
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /v1/orders` нужно не забыть переписать код проверки готовности заказа;
|
||||
* мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В `GET /v1/recipes` поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в `POST /v1/orders`»; переименовать его в «объём по умолчанию» уже не получится, с этой проблемой теперь придётся жить.
|
||||
|
||||
Мы получим:
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -113,7 +115,7 @@ GET /v1/orders/{id}
|
||||
* с другой стороны, кофемашина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).
|
||||
|
||||
Наивный подход в такой ситуации — искусственно ввести некий промежуточный уровень абстракции, «передаточное звено», который переформулирует задачи одного уровня абстракции в другой. Например, введём сущность `task` вида:
|
||||
```
|
||||
```json
|
||||
{
|
||||
…
|
||||
"volume_requested": "800ml",
|
||||
@ -134,7 +136,7 @@ GET /v1/orders/{id}
|
||||
|
||||
Таким образом, сущность «заказ» будет только хранить ссылки на рецепт и исполняемую задачу и не вторгаться в «чужие» уровни абстракции:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -156,7 +158,7 @@ GET /v1/orders/{id}
|
||||
Предположим, для большей конкретности, что эти два класса устройств поставляются вот с таким физическим API.
|
||||
|
||||
* Машины с предустановленными программами:
|
||||
```
|
||||
```json
|
||||
// Возвращает список
|
||||
// предустановленных программ
|
||||
GET /programs
|
||||
@ -168,7 +170,7 @@ GET /v1/orders/{id}
|
||||
"type": "lungo"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Запускает указанную
|
||||
// программу на исполнение
|
||||
// и возвращает статус исполнения
|
||||
@ -188,11 +190,11 @@ GET /v1/orders/{id}
|
||||
"volume": "200ml"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Отменяет текущую программу
|
||||
POST /cancel
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает статус исполнения
|
||||
// Формат аналогичен
|
||||
// формату ответа `POST /execute`
|
||||
@ -202,7 +204,7 @@ GET /v1/orders/{id}
|
||||
**NB**. На всякий случай отметим, что данный API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; он приведен в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такой API от производителей кофемашин, и это ещё довольно вменяемый вариант.
|
||||
|
||||
* Машины с предустановленными функциями:
|
||||
```
|
||||
```json
|
||||
// Возвращает список
|
||||
// доступных функций
|
||||
GET /functions
|
||||
@ -229,7 +231,7 @@ GET /v1/orders/{id}
|
||||
]
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Запускает на исполнение функцию
|
||||
// с передачей указанных
|
||||
// значений аргументов
|
||||
@ -242,7 +244,7 @@ GET /v1/orders/{id}
|
||||
}]
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает статусы датчиков
|
||||
GET /sensors
|
||||
→
|
||||
|
@ -47,7 +47,7 @@
|
||||
* самостоятельно выбрать нужные данные.
|
||||
|
||||
В псевдокоде это будет выглядеть примерно вот так:
|
||||
```
|
||||
```typescript
|
||||
// Получить все доступные рецепты
|
||||
let recipes =
|
||||
api.getRecipes();
|
||||
@ -76,7 +76,7 @@ app.display(matchingCoffeeMachines);
|
||||
* показать ближайшие кофейни, где можно заказать конкретный вид кофе — для пользователей, которым нужен конкретный напиток.
|
||||
|
||||
Тогда наш новый интерфейс будет выглядеть примерно вот так:
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
// опционально
|
||||
@ -108,7 +108,7 @@ POST /v1/offers/search
|
||||
**NB**. На самом деле, наличие идентификатора кофе-машины в интерфейсах само по себе нарушает принцип изоляции уровней абстракции. Эа функциональность должна быть организована более сложно: кофейни должны распределять поступающие заказы по свободным кофемашинам, и только тип кофемашины (если кофейня оперирует несколькими одновременно) является значимой частью предложения. Мы сознательно допускаем это упрощение (пользователь сам выбирает кофемашину), чтобы не перегружать наш учебный пример.
|
||||
|
||||
Вернёмся к коду, который напишет разработчик. Теперь он будет выглядеть примерно так:
|
||||
```
|
||||
```typescript
|
||||
// Ищем предложения,
|
||||
// соответствующие запросу пользователя
|
||||
let offers = api.offerSearch(parameters);
|
||||
@ -128,7 +128,7 @@ app.display(offers);
|
||||
Для решения третьей проблемы мы могли бы потребовать передать в функцию создания заказа его стоимость, и возвращать ошибку в случае несовпадения суммы с актуальной на текущий момент. (Более того, конечно же в любом API, работающем с деньгами, это нужно делать *обязательно*.) Но это не поможет с первым вопросом: гораздо более удобно с точки зрения UX не отображать ошибку в момент нажатия кнопки «разместить заказ», а всегда показывать пользователю актуальную цену.
|
||||
|
||||
Для решения этой проблемы мы можем поступить следующим образом: снабдить каждое предложение идентификатором, который необходимо указывать при создании заказа.
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
@ -157,7 +157,7 @@ app.display(offers);
|
||||
|
||||
Сделаем ещё один небольшой шаг в сторону улучшения жизни разработчика. А каким образом будет выглядеть ошибка «неверная цена»?
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{ … "offer_id" …}
|
||||
→ 409 Conflict
|
||||
@ -181,13 +181,13 @@ POST /v1/orders
|
||||
6. Наконец, если какие-то параметры операции имеют недопустимые значения, то какие значения допустимы?
|
||||
|
||||
В нашем случае несовпадения цены ответ должен выглядеть так:
|
||||
```
|
||||
```json
|
||||
409 Conflict
|
||||
{
|
||||
// Род ошибки
|
||||
"reason": "offer_invalid",
|
||||
"localized_message":
|
||||
"Что-то пошло не так.⮠
|
||||
"Что-то пошло не так.↵
|
||||
Попробуйте перезагрузить приложение."
|
||||
"details": {
|
||||
// Что конкретно неправильно?
|
||||
@ -215,7 +215,7 @@ POST /v1/orders
|
||||
|
||||
Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофемашины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [{
|
||||
// Данные кофемашины
|
||||
@ -266,7 +266,7 @@ POST /v1/orders
|
||||
* данные о цене.
|
||||
|
||||
Попробуем сгруппировать:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [{
|
||||
// Данные о заведении
|
||||
|
@ -21,20 +21,20 @@
|
||||
Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```typescript
|
||||
// Отменяет заказ
|
||||
order.canceled = true;
|
||||
```
|
||||
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```typescript
|
||||
// Отменяет заказ
|
||||
order.cancel();
|
||||
```
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```typescript
|
||||
// Возвращает агрегированную
|
||||
// статистику заказов за всё время
|
||||
orders.getStats()
|
||||
@ -43,7 +43,7 @@ orders.getStats()
|
||||
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```typescript
|
||||
// Вычисляет и возвращает агрегированную
|
||||
// статистику заказов за указанный период
|
||||
orders.calculateAggregatedStats({
|
||||
@ -77,7 +77,7 @@ orders.calculateAggregatedStats({
|
||||
либо
|
||||
`"iso_duration": "PT5S"`
|
||||
либо
|
||||
```
|
||||
```json
|
||||
"duration": {
|
||||
"unit": "ms",
|
||||
"value": 5000
|
||||
@ -105,7 +105,7 @@ orders.calculateAggregatedStats({
|
||||
**Хорошо**: `order.getEstimatedDeliveryTime()`.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```typescript
|
||||
// возвращает положение
|
||||
// первого вхождения в строку str1
|
||||
// любого символа из строки str2
|
||||
@ -114,7 +114,7 @@ strpbrk (str1, str2)
|
||||
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```typescript
|
||||
str_search_for_characters(
|
||||
str,
|
||||
lookup_character_set
|
||||
@ -146,7 +146,7 @@ str_search_for_characters(
|
||||
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
// Возвращает список
|
||||
// встроенных функций кофемашины
|
||||
GET /coffee-machines/{id}/functions
|
||||
@ -154,8 +154,8 @@ GET /coffee-machines/{id}/functions
|
||||
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует или не функционирует).
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
GET /v1/coffee-machines/{id}⮠
|
||||
```json
|
||||
GET /v1/coffee-machines/{id}↵
|
||||
/builtin-functions-list
|
||||
```
|
||||
|
||||
@ -167,7 +167,7 @@ GET /v1/coffee-machines/{id}⮠
|
||||
**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```typescript
|
||||
// Находит первую позицию строки `needle`
|
||||
// внутри строки `haystack`
|
||||
strpos(haystack, needle)
|
||||
@ -195,7 +195,7 @@ str_replace(needle, replace, haystack)
|
||||
|
||||
Стоит также отметить, что в использовании законов де Моргана[ref Законы де Моргана](https://ru.wikipedia.org/wiki/Законы_де_Моргана) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /coffee-machines/{id}/stocks
|
||||
→
|
||||
{
|
||||
@ -206,7 +206,7 @@ GET /coffee-machines/{id}/stocks
|
||||
|
||||
Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"beans_absence": false,
|
||||
"cup_absence": false
|
||||
@ -219,7 +219,7 @@ GET /coffee-machines/{id}/stocks
|
||||
|
||||
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const orderParams = {
|
||||
contactless_delivery: false
|
||||
};
|
||||
@ -230,11 +230,9 @@ const order = api.createOrder(
|
||||
|
||||
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
|
||||
|
||||
```
|
||||
if (Type(orderParams
|
||||
.contactless_delivery
|
||||
) == 'Boolean' && orderParams
|
||||
.contactless_delivery == false) {
|
||||
```typescript
|
||||
const value = orderParams.contactless_delivery;
|
||||
if (Type(value) == 'Boolean' && value == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
@ -244,7 +242,7 @@ if (Type(orderParams
|
||||
Если протоколом не предусмотрена нативная поддержка таких кейсов (т.е. разработчик не может допустить ошибку, спутав отсутствие поля с пустым значением), универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
```typescript
|
||||
const orderParams = {
|
||||
force_contact_delivery: true
|
||||
};
|
||||
@ -256,7 +254,7 @@ const order = api.createOrder(
|
||||
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
// Создаёт пользователя
|
||||
POST /v1/users
|
||||
{ … }
|
||||
@ -277,7 +275,7 @@ PUT /v1/users/{id}
|
||||
```
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
```json
|
||||
POST /v1/users
|
||||
{
|
||||
// true — у пользователя снят
|
||||
@ -337,7 +335,7 @@ POST /v1/users
|
||||
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
|
||||
|
||||
**Плохо**
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
@ -353,7 +351,7 @@ POST /v1/coffee-machines/search
|
||||
Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
@ -369,7 +367,7 @@ POST /v1/coffee-machines/search
|
||||
|
||||
**NB**: этот паттерн следует применять и в обратную сторону. Если в запросе можно указать массив сущностей, то следует отличать пустой массив от отсутствия параметра. Рассмотрим следующий пример:
|
||||
|
||||
```
|
||||
```json
|
||||
// Находит все рецепты кофе
|
||||
// без молока
|
||||
POST /v1/recipes/search
|
||||
@ -402,7 +400,7 @@ POST /v1/offers/search
|
||||
|
||||
Представим теперь, что вызов первого метода вернул пустой массив результатов, т.е. ни одного рецепта кофе, удовлетворяющего условиям, не было найдено. Хорошо, если разработчик партнёра предусмотрит эту ситуацию и не будет делать запрос поиска предложений — но мы не можем быть стопроцентно в этом уверены. Если обработка пустого массива рецептов не предусмотрена, то приложение партнёра выполнит вот такой запрос:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
"location",
|
||||
@ -419,7 +417,7 @@ POST /v1/offers/search
|
||||
Это верно не только в случае непустых массивов, но и любых других зафиксированных в контракте ограничений. «Тихое» исправление недопустимых значений почти никогда не имеет никакого практического смысла:
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
"location": {
|
||||
@ -438,7 +436,7 @@ POST /v1/offers/search
|
||||
Мы видим, что разработчик по какой-то причине передал некорректное значение широты (100 градусов). Да, мы можем его «исправить», т.е. редуцировать до ближайшего допустимого значения (90 градусов), но кому от этого стало лучше? Разработчик никогда не узнает о допущенной ошибке, а конечному пользователю предложения кофе на Северном полюсе, скорее всего, нерелевантны.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"location": {
|
||||
@ -454,7 +452,7 @@ POST /v1/coffee-machines/search
|
||||
|
||||
Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"location": {
|
||||
@ -467,12 +465,12 @@ POST /v1/coffee-machines/search
|
||||
"results": [],
|
||||
"warnings": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Location [0, 0]⮠
|
||||
"message": "Location [0, 0]↵
|
||||
is probably a mistake"
|
||||
}, {
|
||||
"type": "unknown_field",
|
||||
"message": "unknown field:⮠
|
||||
`force_convact_delivery`. Did you⮠
|
||||
"message": "unknown field:↵
|
||||
`force_convact_delivery`. Did you↵
|
||||
mean `force_contact_delivery`?"
|
||||
}]
|
||||
}
|
||||
@ -480,9 +478,9 @@ POST /v1/coffee-machines/search
|
||||
|
||||
Однако следует отметить, что далеко не во все интерфейсы можно удобно уложить дополнительно возврат предупреждений. В такой ситуации можно ввести дополнительный режим отладки или строгий режим, в котором уровень предупреждений эскалируется:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true
|
||||
```json
|
||||
POST /v1/coffee-machines/search↵
|
||||
?strict_mode=true
|
||||
{
|
||||
"location": {
|
||||
"latitude": 0,
|
||||
@ -493,7 +491,7 @@ POST /v1/coffee-machines/search⮠
|
||||
{
|
||||
"errors": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Location [0, 0]⮠
|
||||
"message": "Location [0, 0]↵
|
||||
is probably a mistake"
|
||||
}],
|
||||
…
|
||||
@ -502,10 +500,10 @@ POST /v1/coffee-machines/search⮠
|
||||
|
||||
Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно разрешить задавать игнорируемые ошибки для конкретной операции:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true⮠
|
||||
disable_errors=suspicious_coordinates
|
||||
```json
|
||||
POST /v1/coffee-machines/search↵
|
||||
?strict_mode=true↵
|
||||
&disable_errors=suspicious_coordinates
|
||||
```
|
||||
|
||||
##### Значения по умолчанию должны быть осмысленны
|
||||
@ -513,7 +511,7 @@ POST /v1/coffee-machines/search⮠
|
||||
Значения по умолчанию — один из самых ваших сильных инструментов, позволяющих избежать многословности при работе с API. Однако эти умолчания должны помогать разработчикам, а не маскировать их ошибки.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lungo"]
|
||||
@ -531,7 +529,7 @@ POST /v1/coffee-machines/search
|
||||
Формально, подобное умолчание допустимо — почему бы не иметь концепции «географических координат по умолчанию». Однако в реальности результатом подобных политик «тихого» исправления ошибок становятся абсурдные ситуации типа «null island» — самой посещаемой точки в мире[ref Hrala, J. Welcome to Null Island, The Most 'Visited' Place on Earth That Doesn't Actually Exist](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). Чем популярнее API, тем больше шансов, что партнеры просто не обратят внимания на такие пограничные ситуации.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lungo"]
|
||||
@ -548,7 +546,7 @@ POST /v1/coffee-machines/search
|
||||
Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
@ -563,11 +561,11 @@ POST /v1/coffee-machines/search
|
||||
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Что-то пошло не так.⮠
|
||||
"Что-то пошло не так.↵
|
||||
Обратитесь к разработчику приложения.",
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
@ -575,7 +573,7 @@ POST /v1/coffee-machines/search
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Value 'lngo' unknown.⮠
|
||||
"Value 'lngo' unknown.↵
|
||||
Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
@ -586,8 +584,8 @@ POST /v1/coffee-machines/search
|
||||
"max": 90
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value⮠
|
||||
must fall within⮠
|
||||
"'position.latitude' value↵
|
||||
must fall within↵
|
||||
the [-90, 90] interval"
|
||||
}
|
||||
]
|
||||
@ -600,7 +598,7 @@ POST /v1/coffee-machines/search
|
||||
|
||||
Рассмотрим пример с заказом кофе
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
// запрошенный рецепт
|
||||
@ -635,7 +633,7 @@ POST /v1/orders
|
||||
Если ошибки исправимы (т.е. пользователь может совершить какие-то действия и всё же добиться желаемого), следует в первую очередь сообщать о тех, которые потребуют более глобального изменения состояния.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{
|
||||
@ -682,7 +680,7 @@ POST /v1/orders
|
||||
|
||||
В сложных системах не редки ситуации, когда исправление одной ошибки приводит к возникновению другой и наоборот.
|
||||
|
||||
```
|
||||
```json
|
||||
// Создаём заказ с платной доставкой
|
||||
POST /v1/orders
|
||||
{
|
||||
@ -727,11 +725,11 @@ POST /v1/orders
|
||||
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
// Возвращает цену лунго в кафе,
|
||||
// ближайшем к указанной точке
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
GET /v1/price?recipe=lungo↵
|
||||
&longitude={longitude}↵
|
||||
&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
@ -743,9 +741,9 @@ GET /v1/price?recipe=lungo⮠
|
||||
**Хорошо**:
|
||||
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
|
||||
|
||||
```
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
```json
|
||||
GET /v1/price?recipe=lungo↵
|
||||
&longitude={longitude}↵
|
||||
&latitude={latitude}
|
||||
→
|
||||
{
|
||||
@ -784,14 +782,14 @@ GET /v1/price?recipe=lungo⮠
|
||||
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
// Создаёт заказ
|
||||
POST /orders
|
||||
```
|
||||
Повтор запроса создаст два заказа!
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
```json
|
||||
// Создаёт заказ
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <случайная строка>
|
||||
@ -800,15 +798,15 @@ X-Idempotency-Token: <случайная строка>
|
||||
Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
|
||||
|
||||
**Альтернатива**:
|
||||
```
|
||||
```json
|
||||
// Создаёт черновик заказа
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Подтверждает черновик заказа
|
||||
PUT /v1/orders/drafts⮠
|
||||
PUT /v1/orders/drafts↵
|
||||
/{draft_id}/confirmation
|
||||
{ "confirmed": true }
|
||||
```
|
||||
@ -821,7 +819,7 @@ PUT /v1/orders/drafts⮠
|
||||
|
||||
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
@ -835,7 +833,7 @@ POST /resource/updates
|
||||
|
||||
Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
@ -847,7 +845,7 @@ X-Idempotency-Token: <токен>
|
||||
— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.
|
||||
|
||||
Или:
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
@ -881,16 +879,16 @@ X-Idempotency-Token: <токен>
|
||||
Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
```json
|
||||
// Позволяет партнёру задать
|
||||
// описание для своего напитка
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
PUT /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
```json
|
||||
// возвращает описание
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
GET /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
→
|
||||
"<script>alert(document.cookie)</script>"
|
||||
@ -901,19 +899,19 @@ GET /v1/partner-api/{partner-id}⮠
|
||||
В таких ситуациях мы рекомендуем, во-первых, экранировать вводимые через API данные, если они выглядят потенциально эксплуатируемыми (предназначены для показа в UI и/или возвращаются по прямой ссылке), и, во-вторых, ограничивать радиус взрыва так, чтобы через уязвимости в коде одного партнёра нельзя было затронуть других партнёров. В случае, если функциональность небезопасного ввода всё же нужна, необходимо предупреждать о рисках максимально явно.
|
||||
|
||||
**Лучше** (но не идеально):
|
||||
```
|
||||
```json
|
||||
// Позволяет партнёру задать
|
||||
// потенциально небезопасное
|
||||
// описание для своего напитка
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
PUT /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Disable-Sanitizing: true
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
```json
|
||||
// возвращает потенциально
|
||||
// небезопасное описание
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
GET /v1/partner-api/{partner-id}↵
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Allow-Raw-Value: true
|
||||
→
|
||||
@ -923,25 +921,25 @@ X-Dangerously-Allow-Raw-Value: true
|
||||
В частности, если вы позволяете посредством API выполнять какие-то текстовые скрипты, всегда предпочитайте безопасный ввод небезопасному.
|
||||
|
||||
**Плохо**
|
||||
```
|
||||
```json
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Передаёт готовый запрос целиком
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
VALUES ('Robert');⮠
|
||||
"query": "INSERT INTO data (name)↵
|
||||
VALUES ('Robert');↵
|
||||
DROP TABLE students;--')"
|
||||
}
|
||||
```
|
||||
**Лучше**:
|
||||
```
|
||||
```json
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Передаёт шаблон запроса
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
"query": "INSERT INTO data (name)↵
|
||||
VALUES (?)",
|
||||
// и параметры для подстановки
|
||||
values: [
|
||||
"Robert');⮠
|
||||
"values": [
|
||||
"Robert');↵
|
||||
DROP TABLE students;--"
|
||||
]
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
Суммируем текущее состояние нашего учебного API.
|
||||
|
||||
##### Поиск предложений
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
// опционально
|
||||
@ -56,14 +56,14 @@ POST /v1/offers/search
|
||||
```
|
||||
|
||||
##### Работа с рецептами
|
||||
```
|
||||
```json
|
||||
// Возвращает список рецептов
|
||||
// Параметр cursor необязателен
|
||||
GET /v1/recipes?cursor=<курсор>
|
||||
→
|
||||
{ "recipes", "cursor" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает конкретный рецепт
|
||||
// по его идентификатору
|
||||
GET /v1/recipes/{id}
|
||||
@ -75,7 +75,7 @@ GET /v1/recipes/{id}
|
||||
}
|
||||
```
|
||||
##### Работа с заказами
|
||||
```
|
||||
```json
|
||||
// Размещает заказ
|
||||
POST /v1/orders
|
||||
{
|
||||
@ -91,18 +91,18 @@ POST /v1/orders
|
||||
→
|
||||
{ "order_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает состояние заказа
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{ "order_id", "status" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Отменяет заказ
|
||||
POST /v1/orders/{id}/cancel
|
||||
```
|
||||
##### Работа с программами
|
||||
```
|
||||
```json
|
||||
// Возвращает идентификатор программы,
|
||||
// соответствующей указанному рецепту
|
||||
// на указанной кофемашине
|
||||
@ -111,7 +111,7 @@ POST /v1/program-matcher
|
||||
→
|
||||
{ "program_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает описание
|
||||
// программы по её идентификатору
|
||||
GET /v1/programs/{id}
|
||||
@ -130,7 +130,7 @@ GET /v1/programs/{id}
|
||||
}
|
||||
```
|
||||
##### Исполнение программ
|
||||
```
|
||||
```json
|
||||
// Запускает исполнение программы
|
||||
// с указанным идентификатором
|
||||
// на указанной машине
|
||||
@ -149,12 +149,12 @@ POST /v1/programs/{id}/run
|
||||
→
|
||||
{ "program_run_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Останавливает исполнение программы
|
||||
POST /v1/runs/{id}/cancel
|
||||
```
|
||||
##### Управление рантаймами
|
||||
```
|
||||
```json
|
||||
// Создаёт новый рантайм
|
||||
POST /v1/runtimes
|
||||
{
|
||||
@ -165,7 +165,7 @@ POST /v1/runtimes
|
||||
→
|
||||
{ "runtime_id", "state" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Возвращает текущее состояние рантайма
|
||||
// по его id
|
||||
GET /v1/runtimes/{runtime_id}/state
|
||||
@ -178,7 +178,7 @@ GET /v1/runtimes/{runtime_id}/state
|
||||
"variables"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Прекращает исполнение рантайма
|
||||
POST /v1/runtimes/{id}/terminate
|
||||
```
|
||||
|
@ -6,7 +6,7 @@
|
||||
2. Из-за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут:
|
||||
* клиент, таким образом, не знает, был ли выполнен запрос или нет.
|
||||
3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await
|
||||
api.getOngoingOrders(); // → []
|
||||
```
|
||||
@ -23,7 +23,7 @@
|
||||
|
||||
Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например, вот так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let lock;
|
||||
try {
|
||||
// Захватываем право
|
||||
@ -60,7 +60,7 @@ try {
|
||||
|
||||
Более щадящий с точки зрения сложности имплементации вариант — это реализовать оптимистичное управление параллелизмом[ref Optimistic Concurrency Control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Получаем состояние
|
||||
const orderState =
|
||||
await api.getOrderState();
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и увеличить её мы можем единственным образом — убрав строгую консистентность из внешнего API, что даст возможность осуществлять чтение состояния системы из реплик:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Получаем состояние,
|
||||
// возможно, из реплики
|
||||
const orderState =
|
||||
@ -27,7 +27,7 @@ try {
|
||||
|
||||
Однако, выбор слабой консистентности вместо сильной влечёт за собой и другие проблемы. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Создаёт заказ
|
||||
const api = await api
|
||||
.createOrder(…)
|
||||
@ -41,7 +41,7 @@ const pendingOrders = await api.
|
||||
|
||||
Важный паттерн, который поможет в этой ситуации — это имплементация модели «read-your-writes»[ref Consistency Model. Read-Your-Writes Consistency](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read-your-writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
|
||||
|
||||
```
|
||||
```typescript
|
||||
const order = await api
|
||||
.createOrder(…);
|
||||
const pendingOrders = await api.
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
Наш сценарий использования, напомним, выглядит так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrder.length == 0) {
|
||||
@ -32,7 +32,7 @@ if (pendingOrder.length == 0) {
|
||||
|
||||
Здесь нам на помощь приходят асинхронные вызовы. Если наша цель — уменьшить число коллизий, то нам нет никакой нужды дожидаться, когда заказ будет *действительно* создан; наша цель — максимально быстро распространить по репликам знание о том, что заказ *принят к созданию*. Мы можем поступить следующим образом: создавать не заказ, а задание на создание заказа, и возвращать его идентификатор.
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrder.length == 0) {
|
||||
@ -79,7 +79,7 @@ const pendingOrders = await api.
|
||||
|
||||
Поэтому, при всей привлекательности идеи, мы всё же склонны рекомендовать ограничиться асинхронными интерфейсами только там, где они действительно критически важны (как в примере выше, где они снижают вероятность коллизий), и при этом иметь отдельные очереди для каждого кейса. Идеальное решение с очередями — то, которое вписано в бизнес-логику и вообще не выглядит очередью. Например, ничто не мешает нам объявить состояние «задание на создание заказа принято и ожидает исполнения» просто отдельным статусом заказа, а его идентификатор сделать идентификатором будущего заказа:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrder.length == 0) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api
|
||||
.getOngoingOrders();
|
||||
→
|
||||
@ -18,7 +18,7 @@ const pendingOrders = await api
|
||||
|
||||
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOngoingOrders({
|
||||
// необязательное, но имеющее
|
||||
// значение по умолчанию
|
||||
@ -36,7 +36,7 @@ api.getOngoingOrders({
|
||||
|
||||
Стандартный подход к этой проблеме — введение параметра `offset` или номера страницы данных:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOngoingOrders({
|
||||
// необязательное, но имеющее
|
||||
// значение по умолчанию
|
||||
@ -49,7 +49,7 @@ api.getOngoingOrders({
|
||||
|
||||
Однако, как нетрудно заметить, в нашем случае этот подход приведёт к новым проблемам. Пусть для простоты в системе от имени пользователя выполняется три заказа:
|
||||
|
||||
```
|
||||
```json
|
||||
[{
|
||||
"id": 3,
|
||||
"created_iso_time": "2022-12-22T15:35",
|
||||
@ -67,7 +67,7 @@ api.getOngoingOrders({
|
||||
|
||||
Приложение партнёра запросило первую страницу списка заказов:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
@ -89,7 +89,7 @@ api.getOrders({
|
||||
|
||||
Теперь приложение запрашивает вторую страницу `"limit": 2, "offset": 2` и ожидает получить заказ `"id": 1`. Предположим, однако, что за время, прошедшее с момента первого запроса, в системе появился новый заказ с `"id": 4`.
|
||||
|
||||
```
|
||||
```json
|
||||
[{
|
||||
"id": 4,
|
||||
"created_iso_time": "2022-12-22T15:36",
|
||||
@ -111,7 +111,7 @@ api.getOrders({
|
||||
|
||||
Тогда, запросив вторую страницу заказов, вместо одного заказа `"id": 1`, приложение партнёра получит повторно заказ `"id": 2`:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"offset": 2
|
||||
@ -131,7 +131,7 @@ api.getOrders({
|
||||
|
||||
Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если мы добавим сортировку не только по дате создания, но и по статусу заказа:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
@ -170,9 +170,9 @@ api.getOrders({
|
||||
|
||||
Более распространённый случай — когда не меняются данные в списке, но появляются новые элементы. Чаще всего речь идёт об очередях событий — например, новых сообщений или уведомлений. Представим, что в нашем кофейном API есть эндпойнт для партнёра для получения истории предложений:
|
||||
|
||||
```
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
limit=<лимит>
|
||||
```json
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?limit=<лимит>
|
||||
→
|
||||
{
|
||||
"offer_history": [{
|
||||
@ -210,15 +210,15 @@ GET /v1/partners/{id}/offers/history⮠
|
||||
|
||||
Если хранилище данных, в котором находятся элементы списка, позволяет использовать монотонно растущие идентификаторы (что на практике означает два условия: (1) база данных поддерживает автоинкрементные колонки, (2) вставка данных осуществляется блокирующим образом), то идентификатор элемента в списке является максимально удобным способом организовать перебор:
|
||||
|
||||
```
|
||||
```json
|
||||
// Получить записи новее,
|
||||
// чем запись с указанным id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
newer_than=<item_id>&limit=<limit>
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?newer_than=<item_id>&limit=<limit>
|
||||
// Получить записи более старые,
|
||||
// чем запись с указанным id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
```
|
||||
|
||||
Первый формат запроса позволяет решить задачу (1), т.е. получить все элементы списка, появившиеся позднее последнего известного; второй формат — задачу (2), т.е. перебрать нужно количество записей в истории запросов. Важно, что первый запрос при этом ещё и кэшируемый.
|
||||
@ -233,10 +233,10 @@ GET /v1/partners/{id}/offers/history⮠
|
||||
|
||||
Часто подобные интерфейсы перебора данных (путём указания граничного значения) обобщают через введение понятия *курсор*:
|
||||
|
||||
```
|
||||
```json
|
||||
// Инициализируем поиск
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
search
|
||||
POST /v1/partners/{id}/offers/history↵
|
||||
/search
|
||||
{
|
||||
"order_by": [{
|
||||
"field": "created",
|
||||
@ -249,10 +249,10 @@ POST /v1/partners/{id}/offers/history⮠
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Получение порции данных
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
|
||||
GET /v1/partners/{id}/offers/history↵
|
||||
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy↵
|
||||
&limit=100
|
||||
→
|
||||
{
|
||||
@ -267,9 +267,9 @@ GET /v1/partners/{id}/offers/history⮠
|
||||
|
||||
В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.
|
||||
|
||||
```
|
||||
```json
|
||||
// Инициализируем поиск
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
POST /v1/partners/{id}/offers/history↵
|
||||
search
|
||||
{
|
||||
// Добавим фильтр по виду кофе
|
||||
@ -306,7 +306,7 @@ POST /v1/partners/{id}/offers/history⮠
|
||||
|
||||
Бывает так, что задачу можно *свести* к иммутабельному списку, если по запросу создавать какой-то слепок запрошенных данных. Во многих случаях работа с таким срезом данных по состоянию на определённую дату более удобна и для партнёров, поскольку снимает необходимость учитывать текущие изменения. Часто такой подход работает с «холодными» хранилищами, которые по запросу выгружают какой-то подмассив данных в «горячее» хранилище.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/archive/retrieve
|
||||
{
|
||||
"created_iso_date": {
|
||||
@ -331,11 +331,11 @@ POST /v1/orders/archive/retrieve
|
||||
|
||||
Если ни один из описанных вариантов не подходит по тем или иным причинам, единственный способ организации доступа — это изменение предметной области. Если мы не можем консистентно упорядочить элементы списка, нам нужно найти какой-то другой срез тех же данных, который мы *можем* упорядочить. Например, в нашем случае доступа к новым заказам мы можем упорядочить *список событий* создания нового заказа:
|
||||
|
||||
```
|
||||
```json
|
||||
// Получить все события создания
|
||||
// заказа, более старые,
|
||||
// чем запись с указанным id
|
||||
GET /v1/orders/created-history⮠
|
||||
GET /v1/orders/created-history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
|
@ -2,9 +2,9 @@
|
||||
|
||||
В предыдущей главе мы рассмотрели следующий кейс: партнёр получает информацию о новых событиях, произошедших в системе, периодически опрашивая эндпойнт, поддерживающий отдачу упорядоченных списков.
|
||||
|
||||
```
|
||||
GET /v1/orders/created-history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
```json
|
||||
GET /v1/orders/created-history↵
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
"orders_created_events": [{
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
Рассмотрим на примере нашего кофейного API:
|
||||
|
||||
```
|
||||
```json
|
||||
// Вариант 1: тело сообщения
|
||||
// содержит все данные о заказе
|
||||
POST /partner/webhook
|
||||
@ -24,7 +24,7 @@ Host: partners.host
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Вариант 2: тело сообщения
|
||||
// содержит только информацию
|
||||
// о самом событии
|
||||
@ -50,7 +50,7 @@ GET /v1/orders/{id}
|
||||
→
|
||||
{ /* все детали заказа */ }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Вариант 3: мы уведомляем
|
||||
// партнёра, что его реакции
|
||||
// ожидают три новых заказа
|
||||
@ -82,7 +82,7 @@ GET /v1/orders/pending
|
||||
|
||||
Применение техник с отправкой только ограниченного набора данных помимо усложнения схемы взаимодействия и увеличения количества запросов имеет ещё один важный недостаток. Если в варианте 1 (сообщение содержит в себе все релевантные данные) мы можем рассчитывать на то, что возврат кода успеха подписчиком эквивалентен успешной обработке сообщения партнёром (что, вообще говоря, тоже не гарантировано, т.к. партнёр может использовать асинхронные схемы), то для вариантов 2 и 3 это заведомо не так: для обработки сообщений партнёр должен выполнить дополнительные действия, начиная с получения нужных данных о заказе. В этом случае нам необходимо иметь раздельные статусы — сообщение доставлено и сообщение обработано; в идеале, второе должно вытекать из логики работы API, т.е. сигналом о том, что сообщение обработано, является какое-то действие, совершаемое партнёром. В нашем кофейном примере это может быть перевод заказа партнёром из статуса `"new"` (заказ создан пользователем) в статус `"accepted"` или `"rejected"` (кофейня партнёра приняла или отклонила заказ). Тогда полный цикл обработки уведомления будет выглядеть так:
|
||||
|
||||
```
|
||||
```json
|
||||
// Уведомляем партнёра о том,
|
||||
// что его реакции
|
||||
// ожидают три новых заказа
|
||||
@ -94,7 +94,7 @@ Host: partners.host
|
||||
<число новых заказов>
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// В ответ партнёр вызывает
|
||||
// эндпойнт получения списка заказов
|
||||
GET /v1/orders/pending
|
||||
@ -104,7 +104,7 @@ GET /v1/orders/pending
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// После того, как заказы обработаны,
|
||||
// партнёр уведомляет нас об
|
||||
// изменениях статуса
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
Пусть партнёр уведомляет нас об изменении статусов двух заказов:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/bulk-status-change
|
||||
{
|
||||
"status_changes": [{
|
||||
@ -39,7 +39,7 @@ POST /v1/orders/bulk-status-change
|
||||
Предположим, что на шаге (3) партнёр получил от сервера API ошибку. Что разработчик должен в этой ситуации сделать? Вероятнее всего, в коде партнёра будет реализован один из трёх вариантов:
|
||||
|
||||
1. Безусловный повтор запроса:
|
||||
```
|
||||
```typescript
|
||||
// Получаем все текущие заказы
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
@ -87,7 +87,7 @@ POST /v1/orders/bulk-status-change
|
||||
**NB**: в примере выше мы приводим «правильную» политику перезапросов (с экспоненциально растущим периодом ожидания и лимитом на количество попыток), как мы ранее рекомендовали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)». Следует, однако, иметь в виду, что в реальном коде партнёров с большой долей вероятности ничего подобного реализовано не будет. В дальнейших примерах эту громоздкую конструкцию мы также будем опускать, чтобы упростить чтение кода.
|
||||
|
||||
2. Повтор только неудавшихся подзапросов:
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
let changes =
|
||||
@ -130,7 +130,7 @@ POST /v1/orders/bulk-status-change
|
||||
```
|
||||
|
||||
3. Рестарт всей операции, т.е. в нашем случае — перезапрос всех новых заказов и формирование нового запроса на изменение:
|
||||
```
|
||||
```typescript
|
||||
do {
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
@ -155,7 +155,7 @@ POST /v1/orders/bulk-status-change
|
||||
|
||||
Это приводит нас к парадоксальному умозаключению: гарантировать, что партнёрский код будет *как-то* работать и давать партнёру время разобраться с ошибочными запросами, можно только реализовав максимально нестрогий неидемпотентный неатомарный подход к операции массовых изменений. Однако и этот вывод мы считаем ошибочным, и вот почему: описанный нами «зоопарк» возможных имплементаций клиента и сервера очень хорошо демонстрирует *нежелательность* эндпойнтов массовых изменений как таковых. Такие эндпойнты требуют реализации дополнительного уровня логики и на клиенте, и на сервере, причём логики весьма неочевидной. Функциональность неатомарных массовых изменений очень быстро приведёт нас к крайне неприятным ситуациям:
|
||||
|
||||
```
|
||||
```json
|
||||
// Партнёр делает рефанд
|
||||
// и отменяет заказ
|
||||
POST /v1/bulk-status-change
|
||||
@ -194,7 +194,7 @@ POST /v1/bulk-status-change
|
||||
|
||||
Один из подходов, позволяющих минимизировать возможные проблемы — разработать смешанный эндпойнт, в котором потенциально зависящие друг от друга операции группированы, например, вот так:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/bulk-status-change
|
||||
{
|
||||
"changes": [{
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример.
|
||||
|
||||
```
|
||||
```json
|
||||
// Создаёт заказ из двух напитков
|
||||
POST /v1/orders/
|
||||
X-Idempotency-Token: <токен>
|
||||
@ -19,7 +19,7 @@ X-Idempotency-Token: <токен>
|
||||
{ "order_id" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Частично перезаписывает заказ,
|
||||
// обновляет объём второго напитка
|
||||
PATCH /v1/orders/{id}
|
||||
@ -55,7 +55,7 @@ PATCH /v1/orders/{id}
|
||||
|
||||
Если обратиться к примеру выше, наивный подход выглядит примерно так:
|
||||
|
||||
```
|
||||
```json
|
||||
// Частично перезаписывает заказ:
|
||||
// * сбрасывает адрес доставки
|
||||
// в значение по умолчанию
|
||||
@ -93,16 +93,16 @@ PATCH /v1/orders/{id}
|
||||
|
||||
Это решение можно улучшить путём ввода явных управляющих конструкций вместо «магических значений» и введением мета-опций операции (скажем, фильтра по именам полей, как это принято в gRPC поверх Protobuf[ref Protocol Buffers. Field Masks in Update Operations](https://protobuf.dev/reference/protobuf/google.protobuf/#field-masks-updates)), например, так:
|
||||
|
||||
```
|
||||
```json
|
||||
// Частично перезаписывает заказ:
|
||||
// * сбрасывает адрес доставки
|
||||
// в значение по умолчанию
|
||||
// * не изменяет первый напиток
|
||||
// * удаляет второй напиток
|
||||
PATCH /v1/orders/{id}?⮠
|
||||
PATCH /v1/orders/{id}↵
|
||||
// мета-фильтр: какие поля
|
||||
// переопределяются
|
||||
field_mask=delivery_address,items
|
||||
?field_mask=delivery_address,items
|
||||
{
|
||||
// Специальное значение №1:
|
||||
// обнулить поле
|
||||
@ -132,7 +132,7 @@ PATCH /v1/orders/{id}?⮠
|
||||
|
||||
**Более консистентное решение**: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности независимых операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе [«Разграничение областей ответственности»](#api-design-isolating-responsibility).
|
||||
|
||||
```
|
||||
```json
|
||||
// Создаёт заказ из двух напитков
|
||||
POST /v1/orders/
|
||||
{
|
||||
@ -160,7 +160,7 @@ POST /v1/orders/
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Изменяет параметры,
|
||||
// относящиеся ко всему заказу
|
||||
PUT /v1/orders/{id}/parameters
|
||||
@ -169,7 +169,7 @@ PUT /v1/orders/{id}/parameters
|
||||
{ "delivery_address" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Частично перезаписывает заказ
|
||||
// обновляет объём одного напитка
|
||||
PUT /v1/orders/{id}/items/{item_id}
|
||||
@ -182,7 +182,7 @@ PUT /v1/orders/{id}/items/{item_id}
|
||||
{ "recipe", "volume", "milk_type" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Удаляет один из напитков в заказе
|
||||
DELETE /v1/orders/{id}/items/{item_id}
|
||||
```
|
||||
@ -205,7 +205,7 @@ DELETE /v1/orders/{id}/items/{item_id}
|
||||
|
||||
В нашем случае мы можем пойти, например, вот таким путём:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/order/changes
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
|
@ -28,7 +28,7 @@
|
||||
Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но при этом нигде не зафиксированы?
|
||||
|
||||
**Пример 1**. Рассмотрим SDK работы с заказами.
|
||||
```
|
||||
```typescript
|
||||
// Создаёт заказ
|
||||
let order = api.createOrder();
|
||||
// Получает статус заказа
|
||||
@ -39,7 +39,7 @@ let status = api.getStatus(order.id);
|
||||
|
||||
Вы можете сказать: «Позвольте, но мы нигде и не обещали строгую консистентность!» — и это будет, конечно, неправдой. Вы можете так сказать если, и только если, вы действительно в документации метода `createOrder` явно описали нестрогую консистентность, а все ваши примеры использования SDK написаны как-то так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let order = api.createOrder();
|
||||
let status;
|
||||
while (true) {
|
||||
@ -63,7 +63,7 @@ if (status) {
|
||||
|
||||
**Пример 2**. Представьте себе следующий код:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let resolve;
|
||||
let promise = new Promise(
|
||||
function (innerResolve) {
|
||||
@ -79,7 +79,7 @@ resolve();
|
||||
|
||||
**Пример 3**. Представьте, что вы предоставляете API для анимаций, в котором есть две независимые функции:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Анимирует ширину некоторого объекта
|
||||
// от первого значения до второго
|
||||
// за указанное время
|
||||
@ -95,7 +95,7 @@ object.observe('widthchange', observerFunction);
|
||||
В данном случае следует задокументировать конкретный контракт — как и когда вызывается callback — и придерживаться его даже при смене нижележащей технологии.
|
||||
|
||||
**Пример 4**. Представьте, что потребитель совершает заказ, которые проходит через вполне определённую цепочку преобразований:
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}/events/history
|
||||
→
|
||||
{ "event_history": [
|
||||
|
@ -14,7 +14,7 @@
|
||||
|
||||
Например, можно предоставить второе семейство API (специально для партнёров), содержащее вот такие методы.
|
||||
|
||||
```
|
||||
```json
|
||||
// 1. Зарегистрировать новый тип API
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
@ -24,7 +24,7 @@ PUT /v1/api-types/{api_type}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// 2. Предоставить список кофемашин с разбивкой
|
||||
// по типу API
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
@ -63,8 +63,8 @@ PUT /v1/partners/{partnerId}/coffee-machines
|
||||
1. Документируем текущее состояние. Все кофемашины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.
|
||||
|
||||
2. Добавляем новый метод `with-options`:
|
||||
```
|
||||
PUT /v1/partners/{partner_id}⮠
|
||||
```json
|
||||
PUT /v1/partners/{partner_id}↵
|
||||
/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
Итак, добавим ещё один эндпойнт — для регистрации собственного рецепта партнёра.
|
||||
|
||||
```
|
||||
```json
|
||||
// Добавляет новый рецепт
|
||||
POST /v1/recipes
|
||||
{
|
||||
@ -24,7 +24,7 @@ POST /v1/recipes
|
||||
|
||||
Первая проблема очевидна тем, кто внимательно читал главу [«Описание конечных интерфейсов»](#api-design-describing-interfaces): продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:
|
||||
|
||||
```
|
||||
```json
|
||||
"product_properties": {
|
||||
// "l10n" — стандартное сокращение
|
||||
// для "localization"
|
||||
@ -58,7 +58,7 @@ POST /v1/recipes
|
||||
|
||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработал на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API (во внутренней реализации или в составе SDK) есть функция форматирования строк для отображения объёма напитка:
|
||||
|
||||
```
|
||||
```typescript
|
||||
l10n.volume.format = function(
|
||||
value, language_code, country_code
|
||||
) { … }
|
||||
@ -74,7 +74,7 @@ l10n.volume.format = function(
|
||||
|
||||
Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию через партнёрский API, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:
|
||||
|
||||
```
|
||||
```json
|
||||
// Добавляем общее правило форматирования
|
||||
// для русского языка
|
||||
PUT /formatters/volume/ru
|
||||
@ -101,7 +101,7 @@ PUT /formatters/volume/ru/US
|
||||
|
||||
Вернёмся теперь к проблеме `name` и `description`. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем `name` и `description` не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
@ -138,9 +138,9 @@ GET /v1/layouts/{layout_id}
|
||||
|
||||
Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:
|
||||
|
||||
```
|
||||
PUT /v1/recipes/{id}/⮠
|
||||
properties/l10n/{lang}
|
||||
```json
|
||||
PUT /v1/recipes/{id}↵
|
||||
/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
@ -150,7 +150,7 @@ PUT /v1/recipes/{id}/⮠
|
||||
|
||||
Наш интерфейс добавления рецепта получит в итоге вот такой вид:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
@ -159,7 +159,7 @@ POST /v1/recipes
|
||||
|
||||
Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
@ -194,7 +194,7 @@ POST /v1/recipe-builder
|
||||
|
||||
Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнёрских рецептов всегда пользоваться парой идентификаторов (партнёра и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в главе [«Описание конечных интерфейсов»](#api-design-describing-interfaces).
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// Первая часть идентификатора:
|
||||
@ -214,7 +214,7 @@ POST /v1/recipes/custom
|
||||
|
||||
**NB**: внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе [«Разделение уровней абстракции»](#api-design-separating-abstractions) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* описание способа запуска
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Вернёмся теперь к вопросу, который мы вскользь затронули в главе [«Расширение через абстрагирование»](#back-compat-abstracting-extending): каким образом нам нужно параметризовать приготовление заказа, если оно исполняется через сторонний API? Иными словами, что такое этот самый `order_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
||||
|
||||
```
|
||||
```json
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
@ -13,7 +13,7 @@ PUT /v1/api-types/{api_type}
|
||||
|
||||
Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
|
||||
|
||||
```
|
||||
```json
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
…
|
||||
@ -67,7 +67,7 @@ PUT /v1/api-types/{api_type}
|
||||
|
||||
Организовать и то, и другое можно разными способами (см. [соответствующую главу](#api-patterns-push-vs-poll) раздела «Паттерны дизайна API»); по сути мы всегда имеем два контекста и поток событий между ними. В случае SDK эту идею можно было бы выразить через генерацию событий:
|
||||
|
||||
```
|
||||
```typescript
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофемашинах */
|
||||
registerProgramRunHandler(
|
||||
@ -125,7 +125,7 @@ registerProgramRunHandler(
|
||||
|
||||
Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофемашинах */
|
||||
registerProgramRunHandler(
|
||||
@ -172,7 +172,7 @@ registerProgramRunHandler(
|
||||
|
||||
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
|
||||
|
||||
```
|
||||
```typescript
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
@ -191,7 +191,7 @@ program.context.on(
|
||||
|
||||
Надо отметить, что такой подход *в принципе* не противоречит описанным идеям снижения связности компонентов, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
|
||||
|
||||
```
|
||||
```typescript
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
@ -205,7 +205,7 @@ program.context.on(
|
||||
);
|
||||
```
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Имплементация program.context.dispatch
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context обращается к своему
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
По прочтению предыдущей главы у читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: одни API полагаются на стандартную семантику HTTP, другие полностью от неё отказываются в пользу новоизобретённых стандартов, а третьи существуют где-то посередине. Например, если мы посмотрим на формат ответа в JSON-RPC[ref JSON-RPC 2.0 Specification. Response object](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
HTTP-запрос представляет собой (1) применение определённого глагола к URL с (2) указанием версии протокола, (3) передачей дополнительной мета-информации в заголовках и, возможно, (4) каких-то данных в теле запроса:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders HTTP/1.1
|
||||
Host: our-api-host.tld
|
||||
Content-Type: application/json
|
||||
@ -23,7 +23,7 @@ Content-Type: application/json
|
||||
|
||||
Ответом на HTTP-запрос будет являться конструкция, состоящая из (1) версии протокола, (2) статус-кода ответа, (3) сообщения, (4) заголовков и, возможно, (5) тела ответа:
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/orders/123
|
||||
Content-Type: application/json
|
||||
@ -146,14 +146,14 @@ HTTP-глагол определяет два важных свойства HTTP
|
||||
* как query-параметр `/v1/orders?partner_id=<partner_id>`;
|
||||
* как заголовок
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders HTTP/1.1
|
||||
X-ApiName-Partner-Id: <partner_id>
|
||||
```
|
||||
|
||||
* как поле в теле запроса
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/retrieve HTTP/1.1
|
||||
|
||||
{
|
||||
|
@ -20,7 +20,7 @@
|
||||
|
||||
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/state HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
→
|
||||
@ -52,11 +52,11 @@ HTTP/1.1 200 OK
|
||||
Нетрудно заметить, что мы тем самым создаём излишнюю нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный `user_id` передавался остальным сервисам по цепочке:
|
||||
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
|
||||
* гейтвей D обращается к сервису B
|
||||
```
|
||||
```json
|
||||
GET /v1/profiles/{user_id}
|
||||
```
|
||||
и к сервису C
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user id>
|
||||
```
|
||||
|
||||
@ -85,7 +85,7 @@ HTTP/1.1 200 OK
|
||||
|
||||
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще профиля пользователя, и возврат неверного состояния может приводить к крайне неприятным последствиям. Вспомним, однако, описанный нами в главе «[Стратегии синхронизации](#api-patterns-sync-strategies)» паттерн оптимистичного управления параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам тэг, соответствующий текущему состоянию заказов пользователя:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
@ -98,7 +98,7 @@ ETag: <ревизия>
|
||||
2. При получении повторного запроса:
|
||||
* найти закэшированное состояние, если оно есть;
|
||||
* отправить запрос к сервису C вида
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <ревизия>
|
||||
```
|
||||
@ -109,21 +109,21 @@ ETag: <ревизия>
|
||||
|
||||
Использовав такое решение [функциональность управления кэшом через `ETag` ресурсов], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Если мы используем оптимистичное управление параллелизмом, то клиент должен передать в запросе актуальную ревизию ресурса `orders`:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису C:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса C обновлённый список заказов и его ревизию:
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 201 Created
|
||||
Content-Location: /v1/orders?user_id=<user_id>
|
||||
ETag: <новая ревизия>
|
||||
@ -153,12 +153,12 @@ ETag: <новая ревизия>
|
||||
|
||||
Рассмотрим подробнее подход, в котором авторизационного сервиса A фактически нет (точнее, он имплементируется как библиотека или локальный демон в составе сервисов B, C и D), и все необходимые данные зашифрованы в самом токене авторизации. Тогда каждый сервис должен выполнять следующие действия:
|
||||
1. Получить запрос вида
|
||||
```
|
||||
```json
|
||||
GET /v1/profiles/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
2. Расшифровать токен и получить вложенные данные, например, в следующем виде:
|
||||
```
|
||||
```json
|
||||
{
|
||||
// Идентификатор пользователя-
|
||||
// владельца токена
|
||||
@ -171,7 +171,7 @@ ETag: <новая ревизия>
|
||||
|
||||
Требование передавать `user_id` дважды и потом сравнивать две копии друг с другом может показаться нелогичным и избыточным. Однако это мнение ошибочно, и проистекает из широко распространённого (анти)паттерна, с описания которого мы начали главу, а именно — stateful-определение параметров операции:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/profile
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
@ -185,7 +185,7 @@ Authorization: Bearer <token>
|
||||
|
||||
В случае «тройственного» эндпойнта проверки доступа мы можем только разработать новый эндпойнт с новым интерфейсом. В случае stateless-токенов мы можем поступить так:
|
||||
1. Зашифровать в токене *список* пользователей, доступ к которым возможен через предъявление настоящего токена:
|
||||
```
|
||||
```json
|
||||
{
|
||||
// Идентификаторы пользователей,
|
||||
// доступ к профилям которых
|
||||
|
@ -16,7 +16,7 @@
|
||||
* как path-параметр: `/v1/orders/{id}`;
|
||||
* как query-параметр: `/orders/{id}?version=1`;
|
||||
* как заголовок:
|
||||
```
|
||||
```json
|
||||
GET /orders/{id} HTTP/1.1
|
||||
X-OurCoffeeAPI-Version: 1
|
||||
```
|
||||
@ -83,7 +83,7 @@
|
||||
Начнём с операции создания ресурса. Как мы помним из главы «[Стратегии синхронизации](#api-patterns-sync-strategies)», операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
|
||||
|
||||
1. Через метод `POST` с передачей токена идемпотентности (им может выступать, в частности, `ETag` ресурса):
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
@ -91,7 +91,7 @@
|
||||
```
|
||||
|
||||
2. Через метод `PUT`, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):
|
||||
```
|
||||
```json
|
||||
PUT /v1/orders/{order_id} HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
@ -99,7 +99,7 @@
|
||||
```
|
||||
|
||||
3. Через схему создания черновика методом `POST` и его подтверждения методом `PUT`:
|
||||
```
|
||||
```json
|
||||
POST /v1/drafts HTTP/1.1
|
||||
|
||||
{ … }
|
||||
@ -107,7 +107,7 @@
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/drafts/{id}
|
||||
```
|
||||
```
|
||||
```json
|
||||
PUT /v1/drafts/{id}/commit
|
||||
If-Match: <ревизия>
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
Рассмотренные в предыдущих главах примеры организации API согласно стандарту HTTP и принципам REST покрывают т.н. «happy path», т.е. стандартный процесс работы с API в отсутствие ошибок. Конечно, нам не менее интересен и обратный кейс — каким образом HTTP API следует работать с ошибками, и чем стандарт и архитектурные принципы могут нам в этом помочь. Пусть какой-то агент в системе (неважно, клиент или гейтвей) пытается создать новый заказ:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
If-Match: <ревизия>
|
||||
@ -68,7 +68,7 @@ If-Match: <ревизия>
|
||||
|
||||
Всё это естественным образом подводит нас к следующему выводу: если мы хотим использовать ошибки для диагностики и (возможно) восстановления состояния клиента, нам необходимо добавить машиночитаемую метаинформацию о подвиде ошибки и, возможно, тело ошибки с указанием подробной информации о проблемах — например, как мы предлагали в главе «[Описание конечных интерфейсов](#api-design-describing-interfaces)»:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search HTTP/1.1
|
||||
|
||||
{
|
||||
@ -80,13 +80,13 @@ POST /v1/coffee-machines/search HTTP/1.1
|
||||
}
|
||||
→
|
||||
HTTP/1.1 400 Bad Request
|
||||
X-OurCoffeeAPI-Error-Kind:⮠
|
||||
X-OurCoffeeAPI-Error-Kind:↵
|
||||
wrong_parameter_value
|
||||
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Что-то пошло не так.⮠
|
||||
"Что-то пошло не так.↵
|
||||
Обратитесь к разработчику приложения.",
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
@ -94,7 +94,7 @@ X-OurCoffeeAPI-Error-Kind:⮠
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Value 'lngo' unknown.⮠
|
||||
"Value 'lngo' unknown.↵
|
||||
Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
@ -105,8 +105,8 @@ X-OurCoffeeAPI-Error-Kind:⮠
|
||||
"max": 90
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value⮠
|
||||
must fall within⮠
|
||||
"'position.latitude' value↵
|
||||
must fall within↵
|
||||
the [-90, 90] interval"
|
||||
}
|
||||
]
|
||||
@ -122,11 +122,11 @@ X-OurCoffeeAPI-Error-Kind:⮠
|
||||
|
||||
Для внутренних систем, вообще говоря, такое рассуждение неверно. Для построения правильных мониторингов и системы оповещений необходимо, чтобы серверные ошибки, точно так же, как и клиентские, содержали подтип ошибки в машиночитаемом виде. Здесь по-прежнему применимы те же подходы — использование широкой номенклатуры кодов и/или передача типа ошибки заголовком — однако эта информация должна быть вырезана гейтвеем на границе внешней и внутренней систем, и заменена на общую информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/?user_id=<user id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ parameters }
|
||||
{ "parameters" }
|
||||
→
|
||||
// Ответ, полученный гейтвеем
|
||||
// от сервиса обработки заказов,
|
||||
@ -140,7 +140,7 @@ X-OurCoffeAPI-Error-Kind: db_timeout
|
||||
* какой хост ответил таймаутом
|
||||
*/ }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Ответ, передаваемый клиенту.
|
||||
// Детали серверной ошибки удалены
|
||||
// и заменены на инструкцию клиенту.
|
||||
@ -154,8 +154,8 @@ Retry-After: 5
|
||||
|
||||
{
|
||||
"reason": "internal_server_error",
|
||||
"localized_message": "Не удалось⮠
|
||||
получить ответ от сервера.⮠
|
||||
"localized_message": "Не удалось↵
|
||||
получить ответ от сервера.↵
|
||||
Попробуйте повторить операцию
|
||||
или обновить страницу.",
|
||||
"details": {
|
||||
|
@ -21,7 +21,7 @@
|
||||
|
||||
1. В клиент-серверных API данные передаются только по значению; чтобы сослаться на какую-то сущность, необходимо использовать какие-то внешние идентификаторы. Например, если у нас есть два набора сущностей — рецепты и предложения кофе — то нам необходимо будет построить карту рецептов по id, чтобы понять, на какой рецепт ссылается какое предложение:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Запрашиваем информацию о рецептах
|
||||
// лунго и латте
|
||||
const recipes = await api
|
||||
@ -48,12 +48,13 @@
|
||||
const recipe = recipeMap
|
||||
.get(offer.recipe_id);
|
||||
return {offer, recipe};
|
||||
}));
|
||||
```
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Указанный код мог бы быть вдвое короче, если бы мы сразу получали из метода `api.search` предложения с заполненной ссылкой на рецепт:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Запрашиваем информацию о рецептах
|
||||
// лунго и латте
|
||||
const recipes = await api
|
||||
@ -78,7 +79,7 @@
|
||||
|
||||
2. Клиент-серверные API, как правило, стараются декомпозировать так, чтобы одному запросу соответствовал один тип возвращаемых данных. Даже если эндпойнт композитный (т.е. позволяет при запросе с помощью параметров указать, какие из дополнительных данных необходимо вернуть), это всё ещё ответственность разработчика этими параметрами воспользоваться. Код из примера выше мог бы быть ещё короче, если бы SDK взял на себя инициализацию всех нужных связанных объектов:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Запрашиваем предложения
|
||||
// лунго и латте
|
||||
const offers = await api.search({
|
||||
@ -101,7 +102,7 @@
|
||||
|
||||
3. Получение обратных вызовов в клиент-серверном API, даже если это дуплексный канал, с точки зрения клиента выглядит крайне неудобным в разработке, поскольку вновь требует наличия карт объектов. Даже если в API реализована push-модель, код выходит чрезвычайно громоздким:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Получаем текущие заказы
|
||||
const orders = await api
|
||||
.getOngoingOrders();
|
||||
@ -134,7 +135,7 @@
|
||||
|
||||
И вновь мы приходим к тому, что недостаточно продуманный SDK приводит к ошибкам в работе использующих его приложений. Разработчику было бы намного удобнее, если бы объект заказа позволял подписаться на свои события, не задумываясь о том, как эта подписка технически работает и как не пропустить события:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const order = await api
|
||||
.createOrder(…)
|
||||
// Нет нужды подписываться
|
||||
@ -150,7 +151,7 @@
|
||||
|
||||
4. Восстановление после ошибок в бизнес-логике, как правило, достаточно сложная операция, которую сложно описать в машиночитаемом виде. Разработчику клиента необходимо самому продумать эти сценарии.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Получаем предложения
|
||||
const offers = await api
|
||||
.search(…);
|
||||
|
@ -57,7 +57,7 @@
|
||||
|
||||
Но на этом история не заканчивается. Если разработчик всё-таки хочет именно этого, т.е. показывать иконку сети кофеен (если она есть) на кнопке создания заказа — как ему это сделать? Из той же логики, нам необходимо предоставить ещё более частную возможность такого переопределения. Например, представим себе следующую функциональность: если в данных предложения есть поле `createOrderButtonIconUrl`, то иконка будет взята из этого поля. Тогда разработчик сможет кастомизировать кнопку заказа, подменив в данных поле `createOrderButtonIconUrl` для каждого результата поиска:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const searchBox = new SearchBox({
|
||||
// Предположим, что мы разрешили
|
||||
// переопределять поисковую функцию
|
||||
|
@ -362,7 +362,7 @@ class SearchBox {
|
||||
|
||||
Всё это приводит нас к простому выводу: мы не можем декомпозировать `SearchBox` просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между `SearchBox`, который не зависит от конкретной имплементации UI работы с предложениями и `OfferList`/`OfferPanel`, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных.
|
||||
|
||||
```
|
||||
```typescript
|
||||
class SearchBoxComposer implements ISearchBoxComposer {
|
||||
// Ответственность `composer`-а состоит в:
|
||||
// 1. Создании собственного контекста
|
||||
@ -408,6 +408,7 @@ class SearchBoxComposer implements ISearchBoxComposer {
|
||||
this.generateOfferPanelOptions()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Мы можем придать `SearchBoxComposer`-у функциональность трансляции любых контекстов. В частности:
|
||||
|
Loading…
x
Reference in New Issue
Block a user