1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-07-12 22:50:21 +02:00

fresh build

This commit is contained in:
Sergey Konstantinov
2022-09-28 22:45:34 +03:00
parent 9ecaab9478
commit a8758a91bc
8 changed files with 101 additions and 91 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1480,24 +1480,27 @@ The invalid price error is resolvable: a client could obtain a new price offer a
<p>Entity name must explicitly tell what it does and what side effects to expect while using it.</p>
<p><strong>Bad</strong>:</p>
<pre><code>// Cancels an order
GET /orders/cancellation
order.canceled = true;
</code></pre>
<p>It's quite a surprise that accessing the <code>cancellation</code> resource (what is it?) with the non-modifying <code>GET</code> method actually cancels an order.</p>
<p>It's unobvious that a state field might be set, and that this operation will cancel the order.</p>
<p><strong>Better</strong>:</p>
<pre><code>// Cancels an order
POST /orders/cancel
order.cancel();
</code></pre>
<p><strong>Bad</strong>:</p>
<pre><code>// Returns aggregated statistics
// since the beginning of time
GET /orders/statistics
orders.getStats()
</code></pre>
<p>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.</p>
<p><strong>Better</strong>:</p>
<pre><code>// Returns aggregated statistics
<pre><code>// 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
});
</code></pre>
<p><strong>Try to design function signatures to be absolutely transparent about what the function does, what arguments it takes, and what's the result</strong>. While reading a code working with your API, it must be easy to understand what it does without reading docs.</p>
<p>Two important implications:</p>
@ -1514,6 +1517,8 @@ POST /v1/orders/statistics/aggregate
or<br>
<code>"duration": "5000ms"</code><br>
or<br>
<code>"iso_duration": "PT5S"</code><br>
or<br>
<code>"duration": {"unit": "ms", "value": 5000}</code>.</p>
<p>One particular implication of this rule is that money sums must <em>always</em> be accompanied by a currency code.</p>
<p>It is also worth saying that in some areas the situation with standards is so spoiled that, whatever you do, someone got upset. A ‘classical’ example is geographical coordinates order (latitude-longitude vs longitude-latitude). Alas, the only working method of fighting frustration there is the ‘Serenity Notepad’ to be discussed in Section II.</p>
@ -1601,27 +1606,33 @@ str_replace(needle, replace, haystack)
<p>— then developers will have to evaluate the flag <code>!beans_absence &#x26;&#x26; !cup_absence</code> which is equivalent to <code>!(beans_absence || cup_absence)</code> conditions, and in this transition, people tend to make mistakes. Avoiding double negations helps little, and regretfully only general advice could be given: avoid the situations when developers have to evaluate such flags.</p>
<h5><a href="#chapter-11-paragraph-8" id="chapter-11-paragraph-8" class="anchor">8. Avoid implicit type conversion</a></h5>
<p>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:</p>
<pre><code>POST /v1/orders
{}
{ "contactless_delivery": true }
<pre><code>const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
</code></pre>
<p>This new <code>contactless_delivery</code> option isn't required, but its default value is <code>true</code>. A question arises: how developers should discern explicit intention to abolish the option (<code>false</code>) from knowing not it exists (field isn't set). They have to write something like:</p>
<pre><code>if (Type(
order.contactless_delivery
) == 'Boolean' &#x26;&#x26;
order.contactless_delivery == false) {
<p>This new <code>contactless_delivery</code> option isn't required, but its default value is <code>true</code>. A question arises: how developers should discern explicit intention to abolish the option (<code>false</code>) from knowing not it exists (the field isn't set). They have to write something like:</p>
<pre><code>if (
Type(
orderParams.contactless_delivery
) == 'Boolean' &#x26;&#x26;
orderParams
.contactless_delivery == false) {
}
</code></pre>
<p>This practice makes the code more complicated, and it's quite easy to make mistakes, which will effectively treat the field in an opposite manner. The same could happen if some special values (i.e. <code>null</code> or <code>-1</code>) to denote value absence are used.</p>
<p><strong>NB</strong>: this observation is not valid if both the platform and the protocol unambiguously support special tokens to reset a field to its default value with zero abstraction overhead. However, full and consistent support of this functionality rarely sees implementation. Arguably, the only example of such an API among those being popular nowadays is SQL: the language has the <code>NULL</code> concept, and default field values functionality, and the support for operations like <code>UPDATE … SET field = DEFAULT</code> (in most dialects). Though working with the protocol is still complicated (for example, in many dialects there is no simple method of getting back those values reset by an <code>UPDATE … DEFAULT</code> query), SQL features working with defaults conveniently enough to use this functionality as is.</p>
<p>If the protocol does not support resetting to default values as a first-class citizen, the universal rule is to make all new Boolean flags false by default.</p>
<p><strong>Better</strong></p>
<pre><code>POST /v1/orders
{}
{ "force_contact_delivery": false }
<pre><code>const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
</code></pre>
<p>If a non-Boolean field with specially treated value absence is to be introduced, then introduce two fields.</p>
<p><strong>Bad</strong>:</p>
@ -2039,11 +2050,9 @@ GET /v1/record-views/{id}⮠
</code></pre>
<p>Since the produced view is immutable, access to it might be organized in any form, including a limit-offset scheme, cursors, <code>Range</code> header, etc. However, there is a downside: records modified after the view was generated will be misplaced or outdated.</p>
<p><strong>Option two</strong>: guarantee a strict records order, for example, by introducing a concept of record change events:</p>
<pre><code>POST /v1/records/modified/list
{
// Optional
"cursor"
}
<pre><code>// `cursor` is optional
GET /v1/records/modified/list⮠
?[cursor={cursor}]
{
"modified": [
@ -2080,7 +2089,8 @@ POST /v1/orders/drafts
{ "draft_id" }
</code></pre>
<pre><code>// Confirms the draft
PUT /v1/orders/drafts/{draft_id}
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
</code></pre>
<p>Creating order drafts is a non-binding operation since it doesn't entail any consequences, so it's fine to create drafts without the idempotency token.</p>
@ -2129,7 +2139,7 @@ X-Idempotency-Token: &#x3C;token>
<p>There is a common problem with implementing the changes list approach: what to do if some changes were successfully applied, while others are not? The rule is simple: if you may ensure the atomicity (e.g. either apply all changes or none of them) — do it.</p>
<p><strong>Bad</strong>:</p>
<pre><code>// Returns a list of recipes
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@ -2141,8 +2151,7 @@ GET /v1/recipes
}]
}
// Changes recipes' parameters
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
@ -2150,10 +2159,10 @@ PATCH /v1/recipes
"id": "latte",
"volume": "-1ml"
}]
}
400 Bad Request
});
→ Bad Request
// Re-reading the list
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@ -2170,8 +2179,7 @@ GET /v1/recipes
<p>— there is no way how the client might learn that failed operation was actually partially applied. Even if there is an indication of this fact in the response, the client still cannot tell, whether the lungo volume changed because of the request, or if some other client changed it.</p>
<p>If you can't guarantee the atomicity of an operation, you should elaborate in detail on how to deal with it. There must be a separate status for each individual change.</p>
<p><strong>Better</strong>:</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
@ -2179,11 +2187,11 @@ GET /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",
@ -2211,8 +2219,7 @@ GET /v1/recipes
<li>expose a separate <code>/changes-history</code> endpoint for clients to get the history of applied changes even if the app crashed while getting a partial success response or there was a network timeout.</li>
</ul>
<p>Non-atomic changes are undesirable because they erode the idempotency concept. Let's take a look at the example:</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@ -2221,8 +2228,8 @@ GET /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@ -2238,8 +2245,7 @@ GET /v1/recipes
}
</code></pre>
<p>Imagine the client failed to get a response because of a network error, and it repeats the request:</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@ -2248,8 +2254,8 @@ GET /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@ -2698,7 +2704,6 @@ POST /v1/runtimes/{id}/terminate
</ul>
</li>
</ol>
<p>We will address these questions in more detail in the next chapters. Additionally, in Section III we will also discuss, how to communicate to customers about new releases and discontinued supporting of older versions, and how to stimulate them to adopt new API versions.</p>
<h4>Simultaneous access to several API versions</h4>
<p>In modern professional software development, especially if we talk about internal APIs, a new API version usually fully replaces the previous one. If some problems are found, it might be rolled back (by releasing the previous version), but the two builds never co-exist. However, in the case of public APIs, the more the number of partner integrations is, the more dangerous this approach becomes.</p>
<p>Indeed, with the growth of the number of users, the ‘rollback the API version in case of problems’ paradigm becomes increasingly destructive. To a partner, the optimal solution is rigidly referencing the specific API version — the one that had been tested (ideally, at the same time having the API vendor somehow seamlessly fixing security issues and making their software compliant with newly introduced legislation).</p>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1487,24 +1487,26 @@ app.display(offers);
<p>Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Отменяет заказ
GET /orders/cancellation
order.canceled = true;
</code></pre>
<p>Неочевидно, что достаточно просто обращения к сущности <code>cancellation</code> (что это?), тем более немодифицирующим методом <code>GET</code>, чтобы отменить заказ.</p>
<p>Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ.</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>// Отменяет заказ
POST /orders/cancel
order.cancel();
</code></pre>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает агрегированную
// статистику заказов за всё время
GET /orders/statistics
orders.getStats()
</code></pre>
<p>Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>// Возвращает агрегированную
<pre><code>// Вычисляет и возвращает агрегированную
// статистику заказов за указанный период
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
orders.calculateAggregatedStats({
begin_date: &#x3C;начало периода>
end_date: &#x3C;конец_периода>
});
</code></pre>
<p><strong>Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает</strong>. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.</p>
<p>Два важных следствия:</p>
@ -1518,7 +1520,9 @@ POST /v1/orders/statistics/aggregate
<p><strong>Хорошо</strong>:<br>
<code>"duration_ms": 5000</code><br>
либо<br>
<code>"duration": "5000ms"</code><br>
<code>"duration": "5000ms"</code>
либо
<code>"iso_duration": "PT5S"</code>
либо</p>
<pre><code>"duration": {
"unit": "ms",
@ -1617,16 +1621,20 @@ str_replace(needle, replace, haystack)
<p>— то разработчику потребуется вычислить флаг <code>!beans_absence &#x26;&#x26; !cup_absence</code>, что эквивалентно <code>!(beans_absence || cup_absence)</code>, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».</p>
<h5><a href="#chapter-11-paragraph-8" id="chapter-11-paragraph-8" class="anchor">8. Избегайте неявного приведения типов</a></h5>
<p>Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:</p>
<pre><code>POST /v1/orders
{ … }
{ "contactless_delivery": true }
<pre><code>const orderParams = {
contactless_delivery: false
};
const order = api.createOrder(
orderParams
);
</code></pre>
<p>Новая опция <code>contactless_delivery</code> является необязательной, однако её значение по умолчанию — <code>true</code>. Возникает вопрос, каким образом разработчик должен отличить явное <em>нежелание</em> пользоваться опцией (<code>false</code>) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:</p>
<pre><code>if (Type(
order.contactless_delivery
) == 'Boolean' &#x26;&#x26;
order.contactless_delivery == false) {
<pre><code>if (
Type(
orderParams.contactless_delivery
) == 'Boolean' &#x26;&#x26;
orderParams
.contactless_delivery == false) {
}
</code></pre>
@ -1634,10 +1642,12 @@ str_replace(needle, replace, haystack)
<p><strong>NB</strong>. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция <code>NULL</code>, и значения полей по умолчанию, и поддержка операций вида <code>UPDATE … SET field = DEFAULT</code> (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил <code>UPDATE … DEFAULT</code>), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.</p>
<p>Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.</p>
<p><strong>Хорошо</strong></p>
<pre><code>POST /v1/orders
{}
{ "force_contact_delivery": false }
<pre><code>const orderParams = {
force_contact_delivery: true
};
const order = api.createOrder(
orderParams
);
</code></pre>
<p>Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.</p>
<p><strong>Плохо</strong>:</p>
@ -2058,11 +2068,9 @@ GET /v1/record-views/{id}⮠
</code></pre>
<p>Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).</p>
<p><strong>Вариант 2</strong>: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:</p>
<pre><code>POST /v1/records/modified/list
{
// Опционально
"cursor"
}
<pre><code>// Курсор опционален
GET /v1/records/modified/list⮠
?[cursor={cursor}]
{
"modified": [
@ -2099,7 +2107,8 @@ POST /v1/orders/drafts
{ "draft_id" }
</code></pre>
<pre><code>// Подтверждает черновик заказа
PUT /v1/orders/drafts/{draft_id}
PUT /v1/orders/drafts
/{draft_id}/confirmation
{ "confirmed": true }
</code></pre>
<p>Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
@ -2148,7 +2157,7 @@ X-Idempotency-Token: &#x3C;токен>
<p>С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает список рецептов
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@ -2161,8 +2170,7 @@ GET /v1/recipes
}
// Изменяет параметры
PATCH /v1/recipes
{
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
@ -2170,11 +2178,12 @@ PATCH /v1/recipes
"id": "latte",
"volume": "-1ml"
}]
}
400 Bad Request
});
Bad Request
// Перечитываем список
GET /v1/recipes
api.getRecipes();
{
"recipes": [{
@ -2191,8 +2200,7 @@ GET /v1/recipes
<p>— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.</p>
<p>Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.</p>
<p><strong>Лучше</strong>:</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
@ -2200,11 +2208,11 @@ GET /v1/recipes
"recipe_id": "latte",
"volume": "-1ml"
}]
}
});
// Можно воспользоваться статусом
// «частичного успеха»,
// если он предусмотрен протоколом
200 OK
{
"changes": [{
"change_id",
@ -2232,8 +2240,7 @@ GET /v1/recipes
<li>предоставить отдельный эндпойнт <code>/changes-history</code>, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.</li>
</ul>
<p>Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@ -2242,8 +2249,8 @@ GET /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@ -2259,8 +2266,7 @@ GET /v1/recipes
}
</code></pre>
<p>Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.</p>
<pre><code>PATCH /v1/recipes
{
<pre><code>api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
@ -2269,8 +2275,8 @@ GET /v1/recipes
"recipe_id": "latte",
"volume": "400ml"
}]
}
200 OK
});
{
"changes": [{
@ -2720,7 +2726,6 @@ POST /v1/runtimes/{id}/terminate
</ul>
</li>
</ol>
<p>Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.</p>
<h4>Одновременный доступ к нескольким минорным версиям API</h4>
<p>В современной промышленной разработке, особенно если мы говорим о внутренних API, новая версия, как правило, полностью заменяет предыдущую. Если в новой версии обнаруживаются критические ошибки, она может быть откачена (путём релиза предыдущей версии), но одновременно две сборки не сосуществуют. В случае публичных API такой подход становится тем более опасным, чем больше партнёров используют API.</p>
<p>В самом деле, с ростом количества потребителей подход «откатить проблемную версию API в случае массовых жалоб» становится всё более деструктивным. Для партнёров, вообще говоря, оптимальным вариантом является жёсткая фиксация той версии API, для которой функциональность приложения была протестирована (и чтобы поставщик API при этом как-то незаметно исправлял возможные проблемы с информационной безопасностью и приводил своё ПО в соответствие с вновь возникающими законами).</p>

Binary file not shown.