You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-08-10 21:51:42 +02:00
Major refactoring started
This commit is contained in:
32
README.md
32
README.md
@@ -1,22 +1,30 @@
|
||||
# [Read ‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book)
|
||||
# [Читать книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html)
|
||||
# Read [‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book) in English
|
||||
# Читать [книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html) по-русски
|
||||
|
||||
This is the working repository for ‘The API’ book written by Sergey Konstantinov ([email](mailto:twirl-team@yandex.ru), [Linkedin profile](https://linkedin.com/in/twirl)).
|
||||
|
||||
## Current State and the Roadmap
|
||||
|
||||
Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready, I'm working on some cosmetics:
|
||||
Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready. However, after some beta-testing I understood there were several important problems.
|
||||
1. 'Describing final interfaces' chapter is way too overloaded; many concepts explained there deserve a separate chapter, and being minimized to fit the format, they arise a lot of controversy.
|
||||
2. Though I've tried to avoid talking about any specific paradigm (REST in particular), it's often being read as such, thus igniting discussions on whether the samples are proper REST.
|
||||
|
||||
* adding readable schemes where it's appropriate;
|
||||
* refactoring the ‘Describing Final Interfaces’ chapters;
|
||||
* rephrasing and expanding the chapters on versioning and identifying users.
|
||||
So the current plan is:
|
||||
1. To split Chapter 11 into a full Section III (work title: 'API Patterns') comprising:
|
||||
* defining API-first approach in a technical sense;
|
||||
* the review of API-describing paradigms (OpenAPI/REST, GraphQL, GRPC, JSON-RPC, SOAP);
|
||||
* working with default values, backwards compatibility-wise;
|
||||
* (a)synchronous interaction;
|
||||
* strong and weak consistency;
|
||||
* push and poll models;
|
||||
* machine-readable APIs: iterable lists, cursors, observability;
|
||||
* an amount of traffic and data compression;
|
||||
* API errors: resolvability, reduction to defaults;
|
||||
* degrading properly.
|
||||
2. To compile Section IV ‘HTTP API & JSON’ from current drafts + HTTP general knowledge + codestyle.
|
||||
3. Maybe, try to compile Section V ‘SDK’ (much harder as there are very few drafts).
|
||||
|
||||
I also have more distant plans on adding two more subsections to Section I.
|
||||
* Section Ia ‘JSON HTTP APIs’:
|
||||
* the REST myth;
|
||||
* following HTTP spec, including those parts where you should not follow the spec;
|
||||
* best practices;
|
||||
* Section Ib ‘SDK Design’ covering more tricky issues of having proving UI alongside the API (no specific plan right now)
|
||||
Also, the book still lacks the readable schemes which I'm still planning to plot with mermaid.
|
||||
|
||||
## Translation
|
||||
|
||||
|
@@ -25,30 +25,33 @@ Entity name must explicitly tell what it does and what side effects to expect wh
|
||||
**Bad**:
|
||||
```
|
||||
// Cancels an order
|
||||
GET /orders/cancellation
|
||||
order.canceled = true;
|
||||
```
|
||||
It's quite a surprise that accessing the `cancellation` resource (what is it?) with the non-modifying `GET` method actually cancels an order.
|
||||
It's unobvious that a state field might be set, and that this operation will cancel the order.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
// Cancels an order
|
||||
POST /orders/cancel
|
||||
order.cancel();
|
||||
```
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns aggregated statistics
|
||||
// since the beginning of time
|
||||
GET /orders/statistics
|
||||
orders.getStats()
|
||||
```
|
||||
Even if the operation is non-modifying but computationally expensive, you should explicitly indicate that, especially if clients got charged for computational resource usage. Even more so, default values must not be set in a manner leading to maximum resource consumption.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
// Returns aggregated statistics
|
||||
// Calculates and returns
|
||||
// aggregated statistics
|
||||
// for a specified period of time
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "begin_date", "end_date" }
|
||||
orders.calculateAggregatedStats({
|
||||
begin_date,
|
||||
end_date
|
||||
});
|
||||
```
|
||||
|
||||
**Try to design function signatures to be absolutely transparent about what the function does, what arguments it takes, and what's the result**. While reading a code working with your API, it must be easy to understand what it does without reading docs.
|
||||
@@ -76,6 +79,8 @@ So *always* specify exactly which standard is applied. Exceptions are possible i
|
||||
or
|
||||
`"duration": "5000ms"`
|
||||
or
|
||||
`"iso_duration": "PT5S"`
|
||||
or
|
||||
`"duration": {"unit": "ms", "value": 5000}`.
|
||||
|
||||
One particular implication of this rule is that money sums must *always* be accompanied by a currency code.
|
||||
@@ -210,19 +215,23 @@ GET /coffee-machines/{id}/stocks
|
||||
This advice is opposite to the previous one, ironically. When developing APIs you frequently need to add a new optional field with a non-empty default value. For example:
|
||||
|
||||
```
|
||||
POST /v1/orders
|
||||
{}
|
||||
→
|
||||
{ "contactless_delivery": true }
|
||||
const orderParams = {
|
||||
contactless_delivery: false
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (field isn't set). They have to write something like:
|
||||
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (the field isn't set). They have to write something like:
|
||||
|
||||
```
|
||||
if (Type(
|
||||
order.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
order.contactless_delivery == false) {
|
||||
if (
|
||||
Type(
|
||||
orderParams.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
orderParams
|
||||
.contactless_delivery == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
@@ -235,10 +244,12 @@ If the protocol does not support resetting to default values as a first-class ci
|
||||
|
||||
**Better**
|
||||
```
|
||||
POST /v1/orders
|
||||
{}
|
||||
→
|
||||
{ "force_contact_delivery": false }
|
||||
const orderParams = {
|
||||
force_contact_delivery: true
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
If a non-Boolean field with specially treated value absence is to be introduced, then introduce two fields.
|
||||
@@ -733,11 +744,9 @@ Since the produced view is immutable, access to it might be organized in any for
|
||||
**Option two**: guarantee a strict records order, for example, by introducing a concept of record change events:
|
||||
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Optional
|
||||
"cursor"
|
||||
}
|
||||
// `cursor` is optional
|
||||
GET /v1/records/modified/list⮠
|
||||
?[cursor={cursor}]
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
@@ -791,7 +800,8 @@ POST /v1/orders/drafts
|
||||
```
|
||||
```
|
||||
// Confirms the draft
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
PUT /v1/orders/drafts⮠
|
||||
/{draft_id}/confirmation
|
||||
{ "confirmed": true }
|
||||
```
|
||||
|
||||
@@ -852,7 +862,7 @@ There is a common problem with implementing the changes list approach: what to d
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a list of recipes
|
||||
GET /v1/recipes
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
@@ -864,8 +874,7 @@ GET /v1/recipes
|
||||
}]
|
||||
}
|
||||
// Changes recipes' parameters
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"id": "lungo",
|
||||
"volume": "300ml"
|
||||
@@ -873,10 +882,10 @@ PATCH /v1/recipes
|
||||
"id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
→ 400 Bad Request
|
||||
});
|
||||
→ Bad Request
|
||||
// Re-reading the list
|
||||
GET /v1/recipes
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
@@ -896,8 +905,7 @@ If you can't guarantee the atomicity of an operation, you should elaborate in de
|
||||
|
||||
**Better**:
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
@@ -905,11 +913,11 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
});
|
||||
// You may actually return
|
||||
// a ‘partial success’ status
|
||||
// if the protocol allows it
|
||||
→ 200 OK
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
"change_id",
|
||||
@@ -938,8 +946,7 @@ Might be of use:
|
||||
Non-atomic changes are undesirable because they erode the idempotency concept. Let's take a look at the example:
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
@@ -948,8 +955,8 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
@@ -968,8 +975,7 @@ PATCH /v1/recipes
|
||||
Imagine the client failed to get a response because of a network error, and it repeats the request:
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
@@ -978,8 +984,8 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
|
@@ -25,30 +25,32 @@
|
||||
**Плохо**:
|
||||
```
|
||||
// Отменяет заказ
|
||||
GET /orders/cancellation
|
||||
order.canceled = true;
|
||||
```
|
||||
Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ.
|
||||
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Отменяет заказ
|
||||
POST /orders/cancel
|
||||
order.cancel();
|
||||
```
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает агрегированную
|
||||
// статистику заказов за всё время
|
||||
GET /orders/statistics
|
||||
orders.getStats()
|
||||
```
|
||||
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Возвращает агрегированную
|
||||
// Вычисляет и возвращает агрегированную
|
||||
// статистику заказов за указанный период
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "begin_date", "end_date" }
|
||||
orders.calculateAggregatedStats({
|
||||
begin_date: <начало периода>
|
||||
end_date: <конец_периода>
|
||||
});
|
||||
```
|
||||
|
||||
**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
|
||||
@@ -74,6 +76,8 @@ POST /v1/orders/statistics/aggregate
|
||||
либо
|
||||
`"duration": "5000ms"`
|
||||
либо
|
||||
`"iso_duration": "PT5S"`
|
||||
либо
|
||||
```
|
||||
"duration": {
|
||||
"unit": "ms",
|
||||
@@ -220,19 +224,23 @@ GET /coffee-machines/{id}/stocks
|
||||
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
|
||||
|
||||
```
|
||||
POST /v1/orders
|
||||
{ … }
|
||||
→
|
||||
{ "contactless_delivery": true }
|
||||
const orderParams = {
|
||||
contactless_delivery: false
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
|
||||
|
||||
```
|
||||
if (Type(
|
||||
order.contactless_delivery
|
||||
if (
|
||||
Type(
|
||||
orderParams.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
order.contactless_delivery == false) {
|
||||
orderParams
|
||||
.contactless_delivery == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
@@ -245,10 +253,12 @@ if (Type(
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
POST /v1/orders
|
||||
{}
|
||||
→
|
||||
{ "force_contact_delivery": false }
|
||||
const orderParams = {
|
||||
force_contact_delivery: true
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
|
||||
@@ -741,11 +751,9 @@ GET /v1/record-views/{id}⮠
|
||||
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
"cursor"
|
||||
}
|
||||
// Курсор опционален
|
||||
GET /v1/records/modified/list⮠
|
||||
?[cursor={cursor}]
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
@@ -799,7 +807,8 @@ POST /v1/orders/drafts
|
||||
```
|
||||
```
|
||||
// Подтверждает черновик заказа
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
PUT /v1/orders/drafts⮠
|
||||
/{draft_id}/confirmation
|
||||
{ "confirmed": true }
|
||||
```
|
||||
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
@@ -859,7 +868,7 @@ X-Idempotency-Token: <токен>
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает список рецептов
|
||||
GET /v1/recipes
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
@@ -872,8 +881,7 @@ GET /v1/recipes
|
||||
}
|
||||
|
||||
// Изменяет параметры
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"id": "lungo",
|
||||
"volume": "300ml"
|
||||
@@ -881,11 +889,12 @@ PATCH /v1/recipes
|
||||
"id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
→ 400 Bad Request
|
||||
});
|
||||
→
|
||||
Bad Request
|
||||
|
||||
// Перечитываем список
|
||||
GET /v1/recipes
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
@@ -905,8 +914,7 @@ GET /v1/recipes
|
||||
|
||||
**Лучше**:
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
@@ -914,11 +922,11 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
});
|
||||
// Можно воспользоваться статусом
|
||||
// «частичного успеха»,
|
||||
// если он предусмотрен протоколом
|
||||
→ 200 OK
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
"change_id",
|
||||
@@ -947,8 +955,7 @@ PATCH /v1/recipes
|
||||
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
@@ -957,8 +964,8 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
@@ -977,8 +984,7 @@ PATCH /v1/recipes
|
||||
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
@@ -987,8 +993,8 @@ PATCH /v1/recipes
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
|
675
src/ru/drafts/02-Раздел I. Проектирование API/05.md
Normal file
675
src/ru/drafts/02-Раздел I. Проектирование API/05.md
Normal file
@@ -0,0 +1,675 @@
|
||||
### Описание конечных интерфейсов
|
||||
|
||||
Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.
|
||||
|
||||
Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.
|
||||
|
||||
**NB**: примеры, приведённые в этой главе, прежде всего иллюстрируют проблемы консистентности и читабельности, возникающие при разработке API. Мы не ставим здесь цели дать рекомендации по разработке REST API (такого рода советы будут даны в разделе III) или стандартных библиотек языков программирования — важен не конкретный синтаксис, а общая идея.
|
||||
|
||||
Важное уточнение под номером ноль:
|
||||
|
||||
##### 0. Правила не должны применяться бездумно
|
||||
|
||||
Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно.
|
||||
|
||||
Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам _необходимо_, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.
|
||||
|
||||
Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API).
|
||||
|
||||
Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.
|
||||
|
||||
##### Явное лучше неявного
|
||||
|
||||
Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Отменяет заказ
|
||||
order.canceled = true;
|
||||
```
|
||||
Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Отменяет заказ
|
||||
order.cancel();
|
||||
```
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает агрегированную
|
||||
// статистику заказов за всё время
|
||||
orders.getStats()
|
||||
```
|
||||
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Вычисляет и возвращает агрегированную
|
||||
// статистику заказов за указанный период
|
||||
orders.calculateAggregatedStats({
|
||||
begin_date: <начало периода>
|
||||
end_date: <конец_периода>
|
||||
});
|
||||
```
|
||||
|
||||
**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
|
||||
|
||||
Два важных следствия:
|
||||
|
||||
**1.1.** Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за `GET`.
|
||||
|
||||
**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
|
||||
|
||||
##### Указывайте использованные стандарты
|
||||
|
||||
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому *всегда* указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.
|
||||
|
||||
**Плохо**: `"date": "11/12/2020"` — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц.
|
||||
|
||||
**Хорошо**: `"iso_date": "2020-11-12"`.
|
||||
|
||||
**Плохо**: `"duration": 5000` — пять тысяч чего?
|
||||
|
||||
**Хорошо**:
|
||||
`"duration_ms": 5000`
|
||||
либо
|
||||
`"duration": "5000ms"`
|
||||
либо
|
||||
`"iso_duration": "PT5S"`
|
||||
либо
|
||||
```
|
||||
"duration": {
|
||||
"unit": "ms",
|
||||
"value": 5000
|
||||
}
|
||||
```
|
||||
|
||||
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
|
||||
|
||||
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
|
||||
|
||||
##### Сущности должны именоваться конкретно
|
||||
|
||||
Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.
|
||||
|
||||
**Плохо**: `user.get()` — неочевидно, что конкретно будет возвращено.
|
||||
|
||||
**Хорошо**: `user.get_id()`.
|
||||
|
||||
##### Не экономьте буквы
|
||||
|
||||
В XXI веке давно уже нет нужды называть переменные покороче.
|
||||
|
||||
**Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
order
|
||||
.get_estimated_delivery_time()
|
||||
```
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// возвращает положение
|
||||
// первого вхождения в строку str1
|
||||
// любого символа из строки str2
|
||||
strpbrk (str1, str2)
|
||||
```
|
||||
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
str_search_for_characters(
|
||||
str,
|
||||
lookup_character_set
|
||||
)
|
||||
```
|
||||
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
|
||||
|
||||
**NB**: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.
|
||||
|
||||
##### Тип поля должен быть ясен из его названия
|
||||
|
||||
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
|
||||
|
||||
То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — `objects`, `children`; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений.
|
||||
|
||||
**Плохо**: `GET /news` — неясно, будет ли получена какая-то конкретная новость или массив новостей.
|
||||
|
||||
**Хорошо**: `GET /news-list`.
|
||||
|
||||
Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, `is_ready`, `open_now`.
|
||||
|
||||
**Плохо**: `"task.status": true` — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым.
|
||||
|
||||
**Хорошо**: `"task.is_finished": true`.
|
||||
|
||||
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date`, и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`.
|
||||
|
||||
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает список
|
||||
// встроенных функций кофемашины
|
||||
GET /coffee-machines/{id}/functions
|
||||
```
|
||||
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
GET /v1/coffee-machines/{id}⮠
|
||||
/builtin-functions-list
|
||||
```
|
||||
|
||||
##### Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
|
||||
**Плохо**: `begin_transition` / `stop_transition`
|
||||
— `begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации.
|
||||
|
||||
**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Находит первую позицию строки `needle`
|
||||
// внутри строки `haystack`
|
||||
strpos(haystack, needle)
|
||||
```
|
||||
```
|
||||
// Находит и заменяет
|
||||
// все вхождения строки `needle`
|
||||
// внутри строки `haystack`
|
||||
// на строку `replace`
|
||||
str_replace(needle, replace, haystack)
|
||||
```
|
||||
Здесь нарушены сразу несколько правил:
|
||||
* написание неконсистентно в части знака подчёркивания;
|
||||
* близкие по смыслу методы имеют разный порядок аргументов `needle`/`haystack`;
|
||||
* первый из методов находит только первое вхождение строки `needle`, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.
|
||||
|
||||
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
|
||||
|
||||
##### Избегайте двойных отрицаний
|
||||
|
||||
**Плохо**: `"dont_call_me": false`
|
||||
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.
|
||||
|
||||
**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true`
|
||||
— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».
|
||||
|
||||
Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
|
||||
|
||||
```
|
||||
GET /coffee-machines/{id}/stocks
|
||||
→
|
||||
{
|
||||
"has_beans": true,
|
||||
"has_cup": true
|
||||
}
|
||||
```
|
||||
|
||||
Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
|
||||
|
||||
```
|
||||
{
|
||||
"beans_absence": false,
|
||||
"cup_absence": false
|
||||
}
|
||||
```
|
||||
— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence`, что эквивалентно `!(beans_absence || cup_absence)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».
|
||||
|
||||
##### Отсутствие результата — тоже результат
|
||||
|
||||
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
|
||||
|
||||
**Плохо**
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
"location": <положение пользователя>
|
||||
}
|
||||
→ 404 Not Found
|
||||
{
|
||||
"localized_message":
|
||||
"Рядом с вами не делают лунго"
|
||||
}
|
||||
```
|
||||
|
||||
Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
"location": <положение пользователя>
|
||||
}
|
||||
→ 200 OK
|
||||
{
|
||||
"results": []
|
||||
}
|
||||
```
|
||||
|
||||
Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)
|
||||
|
||||
##### Валидируйте корректность операции
|
||||
|
||||
В ситуации выбора: указать на ошибку или молча её проглотить — разработчик API должен всегда выбирать первый вариант.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"]
|
||||
// Положение пользователя не задано
|
||||
}
|
||||
→
|
||||
{
|
||||
"results": [
|
||||
// Результаты для какой-то
|
||||
// локации по умолчанию
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Результатом подобных политик «тихого» исправления ошибок становятся абсурдные ситуации типа «null island» — [самой посещаемой точки в мире](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). Чем популярнее API, тем больше шансов, что партнеры просто не обратят внимания на такие пограничные ситуации.
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"]
|
||||
// Положение пользователя не задано
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{
|
||||
// описание ошибки
|
||||
// см. следующее правило
|
||||
}
|
||||
```
|
||||
|
||||
Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"]
|
||||
"position": {
|
||||
"latitude": 0,
|
||||
"longitude": 0
|
||||
},
|
||||
"force_convact_delivery": true
|
||||
}
|
||||
→
|
||||
{
|
||||
"results": [],
|
||||
"warnings": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Position [0, 0]⮠
|
||||
is probably a mistake"
|
||||
}, {
|
||||
"type": "unknown_field",
|
||||
"message": "unknown field:⮠
|
||||
`force_convact_delivery`. Did you⮠
|
||||
mean `force_contact_delivery`?"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Однако следует отметить, что далеко не во все интерфейсы можно удобно уложить дополнительно возврат предупреждений. В такой ситуации можно ввести дополнительный режим отладки или строгий режим, в котором уровень предупреждений эскалируется:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true
|
||||
{
|
||||
"recipes": ["lngo"]
|
||||
"position": {
|
||||
"latitude": 0,
|
||||
"longitude": 0
|
||||
}
|
||||
}
|
||||
→ 404 Bad Request
|
||||
{
|
||||
"errors": [{
|
||||
"type": "suspicious_coordinates",
|
||||
"message": "Position [0, 0]⮠
|
||||
is probably a mistake"
|
||||
}],
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно задавать игнорируемые ошибки для конкретной операции:
|
||||
|
||||
```
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true⮠
|
||||
disable_errors=suspicious_coordinates
|
||||
```
|
||||
|
||||
##### Ошибки должны быть информативными
|
||||
|
||||
Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{}
|
||||
```
|
||||
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Что-то пошло не так.⮠
|
||||
Обратитесь к разработчику приложения."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Value 'lngo' unknown.⮠
|
||||
Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"constraints": {
|
||||
"min": -90,
|
||||
"max": 90
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value⮠
|
||||
must fall within⮠
|
||||
the [-90, 90] interval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
|
||||
|
||||
##### Декларируйте технические ограничения явно
|
||||
|
||||
У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
|
||||
|
||||
Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.
|
||||
|
||||
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
|
||||
|
||||
##### Указывайте время жизни ресурсов и политики кэширования
|
||||
|
||||
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
|
||||
|
||||
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает цену лунго в кафе,
|
||||
// ближайшем к указанной точке
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
```
|
||||
Возникает два вопроса:
|
||||
* в течение какого времени эта цена действительна?
|
||||
* на каком расстоянии от указанной точки цена всё ещё действительна?
|
||||
|
||||
**Хорошо**:
|
||||
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
|
||||
|
||||
```
|
||||
// Возвращает предложение: за какую сумму
|
||||
// наш сервис готов приготовить лунго
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"conditions": {
|
||||
// До какого времени
|
||||
// валидно предложение
|
||||
"valid_until",
|
||||
// Где валидно предложение:
|
||||
// * город
|
||||
// * географический объект
|
||||
// * …
|
||||
"valid_within"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Сохраняйте точность дробных чисел
|
||||
|
||||
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
|
||||
|
||||
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
|
||||
|
||||
Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат `00:20` формату `0.333333…`), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.
|
||||
|
||||
##### Все операции должны быть идемпотентны
|
||||
|
||||
Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
|
||||
|
||||
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Создаёт заказ
|
||||
POST /orders
|
||||
```
|
||||
Повтор запроса создаст два заказа!
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Создаёт заказ
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <случайная строка>
|
||||
```
|
||||
Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
|
||||
|
||||
**Альтернатива**:
|
||||
```
|
||||
// Создаёт черновик заказа
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
```
|
||||
// Подтверждает черновик заказа
|
||||
PUT /v1/orders/drafts⮠
|
||||
/{draft_id}/confirmation
|
||||
{ "confirmed": true }
|
||||
```
|
||||
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё `draft_id` играет роль ключа идемпотентности.
|
||||
|
||||
Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так изначально идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации:
|
||||
* клиент не получил ответ из-за сетевых проблем и пытается повторить запрос;
|
||||
* клиент ошибся, пытаясь применить конфликтующие изменения.
|
||||
|
||||
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
|
||||
```
|
||||
POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
```
|
||||
|
||||
Сервер извлекает актуальный номер ревизии и обнаруживает, что он равен 124. Как ответить правильно? Можно просто вернуть `409 Conflict`, но тогда клиент будет вынужден попытаться выяснить причину конфликта и как-то решить его, потенциально запутав пользователя. К тому же, фрагментировать алгоритмы разрешения конфликтов, разрешая каждому клиенту реализовать какой-то свой — плохая идея.
|
||||
|
||||
Сервер мог бы попытаться сравнить значения поля `updates`, предполагая, что одинаковые значения означают перезапрос, но это предположение будет опасно неверным (например, если ресурс представляет собой счётчик, то последовательные запросы с идентичным телом нормальны).
|
||||
|
||||
Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему
|
||||
```
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 201 Created
|
||||
```
|
||||
— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.
|
||||
|
||||
Или:
|
||||
```
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 409 Conflict
|
||||
```
|
||||
— сервер обнаружил, что ревизия 123 была создана с другим токеном, значит имеет место быть конфликт общего доступа к ресурсу.
|
||||
|
||||
Более того, добавление токена идемпотентности не только решает эту проблему, но и позволяет в будущем сделать продвинутые оптимизации. Если сервер обнаруживает конфликт общего доступа, он может попытаться решить его, «перебазировав» обновление, как это делают современные системы контроля версий, и вернуть `200 OK` вместо `409 Conflict`. Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима и предотвращает фрагментацию кода разрешения конфликтов.
|
||||
|
||||
Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно:
|
||||
* нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
|
||||
* клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
|
||||
|
||||
##### Не изобретайте безопасность
|
||||
|
||||
Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.
|
||||
|
||||
**Во-первых**, почти всегда процедуры, обеспечивающие безопасность той или иной операции, *уже разработаны*. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки [Man-in-the-Middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.
|
||||
|
||||
**Во-вторых**, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен [атаке по времени](https://en.wikipedia.org/wiki/Timing_attack), а веб-сервер — [атаке с разделением запросов](https://capec.mitre.org/data/definitions/105.html).
|
||||
|
||||
Отдельно уточним: любые API должны предоставляться строго по протоколу TLS версии не ниже 1.2 (лучше 1.3).
|
||||
|
||||
##### Помогайте партнёрам не изобретать безопасность
|
||||
|
||||
Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Позволяет партнёру задать
|
||||
// описание для своего напитка
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
// возвращает описание
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
→
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
|
||||
Подобный интерфейс является прямым способом соорудить хранимую XSS, которым потенциально может воспользоваться злоумышленник. Да, это ответственность самого партнёра — не допускать сохранения подобного ввода. Но большие цифры по-прежнему работают против вас: всегда найдутся начинающие разработчики, которые не знают об этом виде уязвимости или не подумали о нём. В худшем случае существование таких хранимых уязвимостей может затронуть не только конкретного партнёра, но и вообще всех пользователей API.
|
||||
|
||||
В таких ситуациях мы рекомендуем, во-первых, всегда валидировать вводимые через API данные, и, во-вторых, ограничивать радиус взрыва так, чтобы через уязвимости в коде одного партнёра нельзя было затронуть других партнёров. В случае, если функциональность небезопасного ввода всё же нужна, необходимо предупреждать о рисках максимально явно.
|
||||
|
||||
**Луче** (но не идеально):
|
||||
```
|
||||
// Позволяет партнёру задать
|
||||
// потенциально небезопасное
|
||||
// описание для своего напитка
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Disable-Sanitizing: true
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
// возвращает потенциально
|
||||
// небезопасное описание
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
X-Dangerously-Allow-Raw-Value: true
|
||||
→
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
|
||||
В частности, если вы позволяете посредством API выполнять какие-то текстовые скрипты, всегда предпочитайте безопасный ввод небезопасному.
|
||||
|
||||
**Плохо**
|
||||
```
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Передаёт готовый запрос целиком
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
VALUES ('Robert');⮠
|
||||
DROP TABLE students;--')"
|
||||
}
|
||||
```
|
||||
**Лучше**
|
||||
```
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Передаёт шаблон запроса
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
VALUES (?)",
|
||||
// и параметры для подстановки
|
||||
values: [
|
||||
"Robert');⮠
|
||||
DROP TABLE students;--"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Во втором случае вы сможете централизованно экранировать небезопасный ввод и избежать тем самым SQL-инъекции. Напомним повторно, что делать это необходимо с помощью state-of-the-art инструментов, а не самописных регулярных выражений.
|
||||
|
||||
##### Используйте глобально уникальные идентификаторы
|
||||
|
||||
Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.
|
||||
|
||||
Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:<uuid>` (или просто `order:<uuid>`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.
|
||||
|
||||
Отдельное важное следствие: **не используйте инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
|
||||
|
||||
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
|
||||
|
||||
##### Предусмотрите ограничения доступа
|
||||
|
||||
С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.
|
||||
|
||||
Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.
|
||||
|
||||
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
|
||||
|
||||
##### Не предоставляйте endpoint-ов массового получения чувствительных данных
|
||||
|
||||
Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.
|
||||
|
||||
Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.
|
||||
|
||||
##### Локализация и интернационализация
|
||||
|
||||
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
|
||||
|
||||
Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.
|
||||
|
||||
Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.
|
||||
|
||||
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
|
||||
|
||||
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
|
||||
|
||||
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.
|
||||
|
||||
И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.
|
83
src/ru/drafts/03-Раздел II. Паттерны API/Observability.md
Normal file
83
src/ru/drafts/03-Раздел II. Паттерны API/Observability.md
Normal file
@@ -0,0 +1,83 @@
|
||||
#### Правила разработки машиночитаемых интерфейсов
|
||||
|
||||
В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.
|
||||
|
||||
##### Состояние системы должно быть понятно клиенту
|
||||
|
||||
Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Создаёт заказ и возвращает его id
|
||||
POST /v1/orders
|
||||
{ … }
|
||||
→
|
||||
{ "order_id" }
|
||||
```
|
||||
```
|
||||
// Возвращает заказ по его id
|
||||
GET /v1/orders/{id}
|
||||
// Заказ ещё не подтверждён
|
||||
// и ожидает проверки
|
||||
→ 404 Not Found
|
||||
```
|
||||
— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние `GET /v1/orders/{id}`. Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами:
|
||||
|
||||
* клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения;
|
||||
* потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту.
|
||||
|
||||
В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Создаёт заказ и возвращает его
|
||||
POST /v1/orders
|
||||
{ <параметры заказа> }
|
||||
→
|
||||
{
|
||||
"order_id",
|
||||
// Заказ создаётся в явном статусе
|
||||
// «идёт проверка»
|
||||
"status": "checking",
|
||||
…
|
||||
}
|
||||
```
|
||||
```
|
||||
// Возвращает заказ по его id
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{ "order_id", "status" … }
|
||||
```
|
||||
```
|
||||
// Возвращает все заказы пользователя
|
||||
// во всех статусах
|
||||
GET /v1/users/{id}/orders
|
||||
```
|
||||
|
||||
Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема.
|
||||
|
||||
**Плохо**: `{ "error": "email malformed" }`
|
||||
— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
{
|
||||
// Машиночитаемый статус
|
||||
"status": "validation_failed",
|
||||
// Массив описания проблем;
|
||||
// если пользовательский ввод
|
||||
// некорректен в нескольких
|
||||
// аспектах, пользователь сможет
|
||||
// исправить их все
|
||||
"failed_checks": [
|
||||
{
|
||||
"field: "email",
|
||||
"error_type": "malformed",
|
||||
// Локализованное
|
||||
// человекочитаемое
|
||||
// сообщение
|
||||
"message": "email malformed"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
150
src/ru/drafts/03-Раздел II. Паттерны API/Атомарность.md
Normal file
150
src/ru/drafts/03-Раздел II. Паттерны API/Атомарность.md
Normal file
@@ -0,0 +1,150 @@
|
||||
##### Избегайте неатомарных операций
|
||||
|
||||
С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает список рецептов
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
"id": "lungo",
|
||||
"volume": "200ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
"volume": "300ml"
|
||||
}]
|
||||
}
|
||||
|
||||
// Изменяет параметры
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
});
|
||||
→
|
||||
Bad Request
|
||||
|
||||
// Перечитываем список
|
||||
api.getRecipes();
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
"id": "lungo",
|
||||
// Это значение изменилось
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
// А это нет
|
||||
"volume": "300ml"
|
||||
}]
|
||||
}
|
||||
```
|
||||
— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.
|
||||
|
||||
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
|
||||
|
||||
**Лучше**:
|
||||
```
|
||||
api.updateRecipes({
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
});
|
||||
// Можно воспользоваться статусом
|
||||
// «частичного успеха»,
|
||||
// если он предусмотрен протоколом
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
"change_id",
|
||||
"occurred_at",
|
||||
"recipe_id": "lungo",
|
||||
"status": "success"
|
||||
}, {
|
||||
"change_id",
|
||||
"occurred_at",
|
||||
"recipe_id": "latte",
|
||||
"status": "fail",
|
||||
"error"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Здесь:
|
||||
* `change_id` — уникальный идентификатор каждого атомарного изменения;
|
||||
* `occurred_at` — время проведения каждого изменения;
|
||||
* `error` — информация по ошибке для каждого изменения, если она возникла.
|
||||
|
||||
Не лишним будет также:
|
||||
* ввести в запросе `sequence_id`, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом;
|
||||
* предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.
|
||||
|
||||
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
|
||||
|
||||
```
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
"status": "success"
|
||||
}, {
|
||||
…
|
||||
"status": "fail",
|
||||
"error": {
|
||||
"reason":
|
||||
"too_many_requests"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
|
||||
|
||||
```
|
||||
api.updateRecipes({
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
});
|
||||
→
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
"status": "success"
|
||||
}, {
|
||||
…
|
||||
"status": "success",
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
|
||||
|
||||
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.
|
||||
|
||||
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.
|
105
src/ru/drafts/03-Раздел II. Паттерны API/Ошибки.md
Normal file
105
src/ru/drafts/03-Раздел II. Паттерны API/Ошибки.md
Normal file
@@ -0,0 +1,105 @@
|
||||
##### Соблюдайте правильный порядок ошибок
|
||||
|
||||
**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых:
|
||||
```
|
||||
POST /v1/orders
|
||||
{
|
||||
"recipe": "lngo",
|
||||
"offer"
|
||||
}
|
||||
→ 409 Conflict
|
||||
{
|
||||
"reason": "offer_expired"
|
||||
}
|
||||
// Повторный запрос
|
||||
// с новым `offer`
|
||||
POST /v1/orders
|
||||
{
|
||||
"recipe": "lngo",
|
||||
"offer"
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{
|
||||
"reason": "recipe_unknown"
|
||||
}
|
||||
```
|
||||
— какой был смысл получать новый `offer`, если заказ всё равно не может быть создан?
|
||||
|
||||
**Во-вторых**, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{
|
||||
"item_id": "123",
|
||||
"price": "0.10"
|
||||
}]
|
||||
}
|
||||
→
|
||||
409 Conflict
|
||||
{
|
||||
"reason": "price_changed",
|
||||
"details": [{
|
||||
"item_id": "123",
|
||||
"actual_price": "0.20"
|
||||
}]
|
||||
}
|
||||
// Повторный запрос
|
||||
// с актуальной ценой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{
|
||||
"item_id": "123",
|
||||
"price": "0.20"
|
||||
}]
|
||||
}
|
||||
→
|
||||
409 Conflict
|
||||
{
|
||||
"reason": "order_limit_exceeded",
|
||||
"localized_message":
|
||||
"Лимит заказов превышен"
|
||||
}
|
||||
```
|
||||
— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
|
||||
|
||||
**В-третьих**, постройте схему: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок.
|
||||
|
||||
```
|
||||
// Создаём заказ с платной доставкой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": 3,
|
||||
"item_price": "3000.00"
|
||||
"currency_code": "MNT",
|
||||
"delivery_fee": "1000.00",
|
||||
"total": "10000.00"
|
||||
}
|
||||
→ 409 Conflict
|
||||
// Ошибка: доставка становится бесплатной
|
||||
// при стоимости заказа от 9000 тугриков
|
||||
{
|
||||
"reason": "delivery_is_free"
|
||||
}
|
||||
|
||||
// Создаём заказ с бесплатной доставкой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": 3,
|
||||
"item_price": "3000.00"
|
||||
"currency_code": "MNT",
|
||||
"delivery_fee": "0.00",
|
||||
"total": "9000.00"
|
||||
}
|
||||
→ 409 Conflict
|
||||
// Ошибка: минимальная сумма заказа
|
||||
// 10000 тугриков
|
||||
{
|
||||
"reason": "below_minimal_sum",
|
||||
"currency_code": "MNT",
|
||||
"minimal_sum": "10000.00"
|
||||
}
|
||||
```
|
||||
|
||||
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
|
134
src/ru/drafts/03-Раздел II. Паттерны API/Пагинация.md
Normal file
134
src/ru/drafts/03-Раздел II. Паттерны API/Пагинация.md
Normal file
@@ -0,0 +1,134 @@
|
||||
##### Пагинация, фильтрация и курсоры
|
||||
|
||||
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
||||
|
||||
Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания
|
||||
// начиная с записи с номером offset
|
||||
GET /v1/records?limit=10&offset=100
|
||||
```
|
||||
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.
|
||||
1. Каким образом клиент узнает о появлении новых записей в начале списка?
|
||||
Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает `limit`? Представим себе ситуацию:
|
||||
* клиент обрабатывает записи в порядке поступления;
|
||||
* произошла какая-то проблема, и накопилось большое количество необработанных записей;
|
||||
* клиент запрашивает новые записи (`offset=0`), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем `limit`;
|
||||
* клиент вынужден продолжить перебирать записи (увеличивая `offset`) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает;
|
||||
* таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
|
||||
2. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?
|
||||
Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
|
||||
3. Какие параметры кэширования мы можем выставить на этот эндпойнт?
|
||||
Никакие: повторяя запрос с теми же `limit`-`offset`, мы каждый раз получаем новый набор записей.
|
||||
|
||||
**Хорошо**: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи,
|
||||
// созданной позднее,
|
||||
// чем запись с указанным id
|
||||
GET /v1/records⮠
|
||||
?older_than={record_id}&limit=10
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи,
|
||||
// созданной раньше,
|
||||
// чем запись с указанным id
|
||||
GET /v1/records⮠
|
||||
?newer_than={record_id}&limit=10
|
||||
```
|
||||
|
||||
При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
|
||||
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.
|
||||
Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы более универсальными.
|
||||
|
||||
```
|
||||
// Первый запрос данных
|
||||
POST /v1/records/list
|
||||
{
|
||||
// Какие-то дополнительные
|
||||
// параметры фильтрации
|
||||
"filter": {
|
||||
"category": "some_category",
|
||||
"created_date": {
|
||||
"older_than": "2020-12-07"
|
||||
}
|
||||
}
|
||||
}
|
||||
→
|
||||
{ "cursor" }
|
||||
```
|
||||
|
||||
```
|
||||
// Последующие запросы
|
||||
GET /v1/records?cursor=<курсор>
|
||||
{ "records", "cursor" }
|
||||
```
|
||||
|
||||
Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. `filter` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.
|
||||
|
||||
Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один.
|
||||
|
||||
**NB**: в некоторых источниках такой подход, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
|
||||
* подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
|
||||
* если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
|
||||
* подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`;
|
||||
* наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по полю sort_by
|
||||
// в порядке sort_order,
|
||||
// начиная с записи с номером offset
|
||||
GET /records?sort_by=date_modified⮠
|
||||
&sort_order=desc&limit=10&offset=100
|
||||
```
|
||||
|
||||
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.
|
||||
|
||||
**Хорошо**: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
|
||||
|
||||
**Вариант 1**: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
|
||||
|
||||
```
|
||||
// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [{
|
||||
"field": "date_modified",
|
||||
"order": "desc"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
```
|
||||
|
||||
```
|
||||
// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}⮠
|
||||
?cursor={cursor}
|
||||
```
|
||||
|
||||
Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
|
||||
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
|
||||
```
|
||||
// Курсор опционален
|
||||
GET /v1/records/modified/list⮠
|
||||
?[cursor={cursor}]
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
|
||||
Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.
|
137
src/ru/drafts/03-Раздел II. Паттерны API/Совместный доступ.md
Normal file
137
src/ru/drafts/03-Раздел II. Паттерны API/Совместный доступ.md
Normal file
@@ -0,0 +1,137 @@
|
||||
##### Избегайте неявных частичных обновлений
|
||||
|
||||
Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.
|
||||
|
||||
**Плохо**:
|
||||
|
||||
```
|
||||
// Создаёт заказ из двух напитков
|
||||
POST /v1/orders/
|
||||
{
|
||||
"delivery_address",
|
||||
"items": [{
|
||||
"recipe": "lungo",
|
||||
}, {
|
||||
"recipe": "latte",
|
||||
"milk_type": "oats"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{ "order_id" }
|
||||
```
|
||||
|
||||
```
|
||||
// Частично перезаписывает заказ
|
||||
// обновляет объём второго напитка
|
||||
PATCH /v1/orders/{id}
|
||||
{
|
||||
"items": [null, {
|
||||
"volume": "800ml"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{ /* изменения приняты */ }
|
||||
```
|
||||
|
||||
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными?
|
||||
|
||||
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
|
||||
|
||||
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
|
||||
* повышенные размеры запросов и, как следствие, расход трафика;
|
||||
* необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
|
||||
* невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
|
||||
|
||||
Все эти соображения, однако, на поверку оказываются мнимыми:
|
||||
* причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
|
||||
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
|
||||
* это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
|
||||
* существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
|
||||
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
|
||||
* кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
|
||||
|
||||
**Лучше**: разделить эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
|
||||
|
||||
```
|
||||
// Создаёт заказ из двух напитков
|
||||
POST /v1/orders/
|
||||
{
|
||||
"parameters": {
|
||||
"delivery_address"
|
||||
}
|
||||
"items": [{
|
||||
"recipe": "lungo",
|
||||
}, {
|
||||
"recipe": "latte",
|
||||
"milk_type": "oats"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{
|
||||
"order_id",
|
||||
"created_at",
|
||||
"parameters": {
|
||||
"delivery_address"
|
||||
}
|
||||
"items": [
|
||||
{ "item_id", "status"},
|
||||
{ "item_id", "status"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
// Изменяет параметры,
|
||||
// относящиеся ко всему заказу
|
||||
PUT /v1/orders/{id}/parameters
|
||||
{ "delivery_address" }
|
||||
→
|
||||
{ "delivery_address" }
|
||||
```
|
||||
|
||||
```
|
||||
// Частично перезаписывает заказ
|
||||
// обновляет объём одного напитка
|
||||
PUT /v1/orders/{id}/items/{item_id}
|
||||
{
|
||||
// Все поля передаются, даже если
|
||||
// изменилось только какое-то одно
|
||||
"recipe", "volume", "milk_type"
|
||||
}
|
||||
→
|
||||
{ "recipe", "volume", "milk_type" }
|
||||
```
|
||||
|
||||
```
|
||||
// Удаляет один из напитков в заказе
|
||||
DELETE /v1/orders/{id}/items/{item_id}
|
||||
```
|
||||
|
||||
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
|
||||
|
||||
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?).
|
||||
|
||||
Также в ответах операций `PUT` можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования).
|
||||
|
||||
**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
|
||||
|
||||
**Ещё лучше**: разработать формат описания атомарных изменений.
|
||||
|
||||
```
|
||||
POST /v1/order/changes
|
||||
X-Idempotency-Token: <токен идемпотентности>
|
||||
{
|
||||
"changes": [{
|
||||
"type": "set",
|
||||
"field": "delivery_address",
|
||||
"value": <новое значение>
|
||||
}, {
|
||||
"type": "unset_item_field",
|
||||
"item_id",
|
||||
"field": "volume"
|
||||
}],
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения.
|
23
src/ru/drafts/03-Раздел II. Паттерны API/Трафик.md
Normal file
23
src/ru/drafts/03-Раздел II. Паттерны API/Трафик.md
Normal file
@@ -0,0 +1,23 @@
|
||||
##### Считайте трафик
|
||||
|
||||
В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.
|
||||
|
||||
Три основные причины раздувания объёма трафика достаточно очевидны:
|
||||
* не предусмотрен постраничный перебор данных;
|
||||
* не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.);
|
||||
* клиент слишком часто запрашивает данные и/или слишком мало их кэширует.
|
||||
|
||||
Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:
|
||||
|
||||
* не злоупотребляйте асинхронными интерфейсами;
|
||||
* с одной стороны, они позволяют нивелировать многие технические проблемы с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;
|
||||
* с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;
|
||||
|
||||
* объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`);
|
||||
* да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);
|
||||
|
||||
* если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);
|
||||
|
||||
* если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.
|
||||
|
||||
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
|
@@ -0,0 +1,84 @@
|
||||
##### Избегайте неявного приведения типов
|
||||
|
||||
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
|
||||
|
||||
```
|
||||
const orderParams = {
|
||||
contactless_delivery: false
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
|
||||
|
||||
```
|
||||
if (
|
||||
Type(
|
||||
orderParams.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
orderParams
|
||||
.contactless_delivery == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`.
|
||||
|
||||
**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
|
||||
|
||||
Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
const orderParams = {
|
||||
force_contact_delivery: true
|
||||
};
|
||||
const order = api.createOrder(
|
||||
orderParams
|
||||
);
|
||||
```
|
||||
|
||||
Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Создаёт пользователя
|
||||
POST /v1/users
|
||||
{ … }
|
||||
→
|
||||
// Пользователи создаются по умолчанию
|
||||
// с указанием лимита трат в месяц
|
||||
{
|
||||
"spending_monthly_limit_usd": "100",
|
||||
…
|
||||
}
|
||||
// Для отмены лимита требуется
|
||||
// указать значение null
|
||||
PUT /v1/users/{id}
|
||||
{
|
||||
"spending_monthly_limit_usd": null,
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
POST /v1/users
|
||||
{
|
||||
// true — у пользователя снят
|
||||
// лимит трат в месяц
|
||||
// false — лимит не снят
|
||||
// (значение по умолчанию)
|
||||
"abolish_spending_limit": false,
|
||||
// Необязательное поле, имеет смысл
|
||||
// только если предыдущий флаг
|
||||
// имеет значение false
|
||||
"spending_monthly_limit_usd": "100",
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.
|
||||
|
@@ -1,43 +1,43 @@
|
||||
1. Зачем предоставлять API
|
||||
* возможности интеграции с основным сервисом
|
||||
* непрофильные сервисы
|
||||
* концепция API-first
|
||||
2. Выгоды наличия API и виды монетизации
|
||||
* API к монетизируемым сервисам (+ Affiliate API)
|
||||
* on-premise
|
||||
* freemium-модели
|
||||
* лимиты
|
||||
* ограничения на типы использования
|
||||
* техническая поддержка
|
||||
* премиум-функциональность
|
||||
* маркетплейсы
|
||||
* рекламные модели
|
||||
* сбор данных / UGC
|
||||
* контакт с брендом
|
||||
* терраформирование
|
||||
3. Прямая и обратная API-пирамида Маслоу
|
||||
4. Виды API
|
||||
* API
|
||||
* SDK
|
||||
* виджеты
|
||||
* embedded (включая картинки)
|
||||
5. Бизнес-аудитория, её интересы и места обитания
|
||||
* SLA
|
||||
* безопасность
|
||||
* идентификация пользователей, ведение статистики, защита публичных ключей
|
||||
* контакты и оповещения
|
||||
6. Разработчики, их интересы и места обитания
|
||||
* новички vs продвинутые
|
||||
* OpenSource
|
||||
7. Документация
|
||||
* референс
|
||||
* tutorial
|
||||
* how-to
|
||||
* примеры
|
||||
* песочница
|
||||
8. Поддержка пользователей
|
||||
* форумы
|
||||
* поддержка разработчиков
|
||||
* работа с комьюнити
|
||||
* обратная связь
|
||||
9. Продуктовое управление разработкой API
|
||||
Раздел I
|
||||
* описание конечных советов — оставить только кодстайл
|
||||
Раздел II
|
||||
* API-first подход
|
||||
* выбор поддерживаемых стандартов
|
||||
* существующие стандарты (описания) взаимодействия через API
|
||||
* JSON / OpenAPI
|
||||
* XML-RPC / WSDL
|
||||
* JSON-RPC
|
||||
* GraphQL
|
||||
* GRPC
|
||||
* клиентские библиотеки
|
||||
* хелперы, обратная совместимость, работа с умолчаниями
|
||||
* синхронное и асинхронное взаимодействие
|
||||
* push- и poll-модели
|
||||
* сильная и слабая консистентность
|
||||
* машиночитаемое API
|
||||
* observability
|
||||
* перебор списков, курсоры
|
||||
* объёмы передаваемых данных
|
||||
* сжатие
|
||||
* кэширование
|
||||
* ошибки
|
||||
* разрешимость
|
||||
* сведение к умолчанию
|
||||
* ошибка для пользователя vs ошибка для разработчика
|
||||
* деградация
|
||||
* мониторинг состояния
|
||||
Раздел III
|
||||
* о терминологии
|
||||
* введение в HTTP
|
||||
* REST, определение и реальность
|
||||
* плюсы и минусы разработки HTTP API
|
||||
* проблема статус-кодов
|
||||
* трактовка методов
|
||||
* CRUD
|
||||
* кодстайл
|
||||
* domain/path/query/body
|
||||
Раздел IV
|
||||
* постановка проблемы
|
||||
* реюз кода vs реюз поведения
|
||||
* трехстороннее взаимодействие
|
||||
* асинхронность, разделяемые ресурсы
|
Reference in New Issue
Block a user