You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-07-12 22:50:21 +02:00
Chapter 11 extended and translated
This commit is contained in:
@ -16,7 +16,14 @@ See full license in LICENSE.md file or at [Creative Commons Website](http://crea
|
||||
|
||||
Right now Section I (‘API Design’) is finished. The Section is lacking readable schemes, I'll draw them later.
|
||||
|
||||
Book will contain two more sections.
|
||||
TODO for Section I:
|
||||
* double negations;
|
||||
* eventual consistency;
|
||||
* truthy default values;
|
||||
* partial updates;
|
||||
* errors order.
|
||||
|
||||
The book will contain two more sections.
|
||||
* Section II ‘Backwards Compatibility’ will cover growth issues. Major themes are:
|
||||
* major sources of problems leading to backwards compatibility breach;
|
||||
* interface decomposing allowing for third-party plugins and switching underlying technologies;
|
||||
|
BIN
docs/API.en.epub
BIN
docs/API.en.epub
Binary file not shown.
399
docs/API.en.html
399
docs/API.en.html
@ -776,7 +776,7 @@ Here and throughout we indicate resolvable problems with <code>409 Conflict</cod
|
||||
{
|
||||
"coffee_machine_type": "drip_coffee_maker",
|
||||
"coffee_machine_brand",
|
||||
"place_name": "Кафе «Ромашка»",
|
||||
"place_name": "The Chamomile",
|
||||
// Coordinates of a place
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
@ -845,5 +845,400 @@ Here and throughout we indicate resolvable problems with <code>409 Conflict</cod
|
||||
</code></pre>
|
||||
<p>Such decomposed API is much easier to read than a long sheet of different attributes. Furthermore, it's probably better to group even more entities in advance. For example, <code>place</code> and <code>route</code> could be joined in a single <code>location</code> structure, or <code>offer</code> and <code>pricing</code> might be combined in a some generalized object.</p>
|
||||
<p>It is important to say that readability is achieved not only by simply grouping the entities. Decomposing must be performed in such a manner that a developer, while reading the interface, instantly understands: ‘here is the place description of no interest to me right now, no need to traverse deeper’. If the data fields needed to complete some action are split into different composites, the readability degrades, not improves.</p>
|
||||
<p>Proper decomposition also helps extending and evolving the API. We'll discuss the subject in the Section II.</p><div class="page-break"></div></article>
|
||||
<p>Proper decomposition also helps extending and evolving the API. We'll discuss the subject in the Section II.</p><div class="page-break"></div><h3>Chapter 11. Describing Final Interfaces</h3><p>When all entities, their responsibility, and relations to each other are defined, we proceed to developing the API itself. We are to describe the objects, fields, methods, and functions nomenclature in details. In this chapter we're giving purely practical advice on making APIs usable and understandable.</p>
|
||||
<p>Important assertion at number 0:</p>
|
||||
<h5 id="0rulesarejustgeneralizations">0. Rules are just generalizations</h5>
|
||||
<p>Rules are not to be applied unconditionally. They are not making thinking redundant. Every rule has a rational reason to exist. If your situation doesn't justify following the rule — then you shouldn't do it.</p>
|
||||
<p>For example, demanding a specification being consistent exists to help developers spare time on reading docs. If you <em>need</em> developers to read some entity's doc, it is totally rational to make its signature deliberately inconsistent.</p>
|
||||
<p>This idea applies to every concept listed below. If you get an unusable, bulky, unobvious API because you follow the rules, it's a motive to revise the rules (or the API).</p>
|
||||
<p>It is important to understand that you always can introduce the concepts of your own. For example, some frameworks willfully reject paired <code>set_entity</code> / <code>get_entity</code> methods in a favor of a single <code>entity()</code> method, with an optional argument. The crucial part is being systematic in applying the concept. If it's rendered into life, you must apply it to every single API method, or at the very least elaborate a naming rule to discern such polymorphic methods from regular ones.</p>
|
||||
<h5 id="1explicitisalwaysbetterthanimplicit">1. Explicit is always better than implicit</h5>
|
||||
<p>Entity's 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
|
||||
</code></pre>
|
||||
<p>It's quite a surprise that accessing the <code>cancellation</code> resource (what is it?) with non-modifying <code>GET</code> method actually cancels an order.</p>
|
||||
<p><strong>Better</strong>: </p>
|
||||
<pre><code>// Cancels an order
|
||||
POST /orders/cancel
|
||||
</code></pre>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns aggregated statistics
|
||||
// since the beginning of time
|
||||
GET /orders/statistics
|
||||
</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
|
||||
// for a specified period of time
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "begin_date", "end_date" }
|
||||
</code></pre>
|
||||
<p><strong>Try to design function signatures to be absolutely transparent about what the function does, what arguments 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>
|
||||
<p><strong>1.1.</strong> If the operation is modifying, it must be obvious from the signature. In particular, there might be no modifying operations using <code>GET</code> verb.</p>
|
||||
<p><strong>1.2.</strong> If your API's nomenclature contains both synchronous and asynchronous operations, then (a)synchronicity must be apparent from signatures, <strong>or</strong> a naming convention must exist.</p>
|
||||
<h5 id="2specifywhichstandardsareused">2. Specify which standards are used</h5>
|
||||
<p>Regretfully, the humanity is unable to agree on the most trivial things, like which day starts the week, to say nothing about more sophisticated standards.</p>
|
||||
<p>So <em>always</em> specify exactly which standard is applied. Exceptions are possible, if you 100% sure that only one standard for this entity exists in the world, and every person on Earth is totally aware of it.</p>
|
||||
<p><strong>Bad</strong>: <code>"date": "11/12/2020"</code> — there are tons of date formatting standards; you can't even tell which number means the day number and which number means the month.</p>
|
||||
<p><strong>Better</strong>: <code>"iso_date": "2020-11-12"</code>.</p>
|
||||
<p><strong>Bad</strong>: <code>"duration": 5000</code> — five thousands of what?</p>
|
||||
<p><strong>Better</strong>:<br />
|
||||
<code>"duration_ms": 5000</code><br />
|
||||
or<br />
|
||||
<code>"duration": "5000ms"</code><br />
|
||||
or<br />
|
||||
<code>"duration": {"unit": "ms", "value": 5000}</code>.</p>
|
||||
<p>One particular implication from this rule is that money sums must <em>always</em> be accompanied with 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 with frustration there is a ‘serenity notepad’ to be discussed in Section II.</p>
|
||||
<h5 id="3keepfractionalnumbersprecisionintact">3. Keep fractional numbers precision intact</h5>
|
||||
<p>If the protocol allows, fractional numbers with fixed precision (like money sums) must be represented as a specially designed type like Decimal or its equivalent.</p>
|
||||
<p>If there is no Decimal type in the protocol (for instance, JSON doesn't have one), you should either use integers (e.g. apply a fixed multiplicator) or strings.</p>
|
||||
<h5 id="4entitiesmusthaveconcretenames">4. Entities must have concrete names</h5>
|
||||
<p>Avoid single amoeba-like words, such as get, apply, make.</p>
|
||||
<p><strong>Bad</strong>: <code>user.get()</code> — hard to guess what is actually returned.</p>
|
||||
<p><strong>Better</strong>: <code>user.get_id()</code>.</p>
|
||||
<h5 id="5dontsparetheletters">5. Don't spare the letters</h5>
|
||||
<p>In XXI century there's no need to shorten entities' names.</p>
|
||||
<p><strong>Bad</strong>: <code>order.time()</code> — unclear, what time is actually returned: order creation time, order preparation time, order waiting time?…</p>
|
||||
<p><strong>Better</strong>: <code>order.get_estimated_delivery_time()</code></p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns a pointer to the first occurrence
|
||||
// in str1 of any of the characters
|
||||
// that are part of str2
|
||||
strpbrk (str1, str2)
|
||||
</code></pre>
|
||||
<p>Possibly, an author of this API thought that <code>pbrk</code> abbreviature would mean something to readers; clearly mistaken. Also it's hard to tell from the signature which string (<code>str1</code> or <code>str2</code>) stands for a character set.</p>
|
||||
<p><strong>Better</strong>: <code>str_search_for_characters (lookup_character_set, str)</code><br />
|
||||
— though it's highly disputable whether this function should exist at all; a feature-rich search function would be much more convenient. Also, shortening <code>string</code> to <code>str</code> bears no practical sense, regretfully being a routine in many subject areas.</p>
|
||||
<h5 id="6namingimpliestyping">6. Naming implies typing</h5>
|
||||
<p>Field named <code>recipe</code> must be of <code>Recipe</code> type. Field named <code>recipe_id</code> must contain a recipe identifier which we could find within <code>Recipe</code> entity.</p>
|
||||
<p>Same for primitive types. Arrays must be named in a plural form or as collective nouns, i.e. <code>objects</code>, <code>children</code>. If that's impossible, better add a prefix or a postfix to avoid doubt.</p>
|
||||
<p><strong>Bad</strong>: <code>GET /news</code> — unclear whether a specific news item is returned, or a list of them.</p>
|
||||
<p><strong>Better</strong>: <code>GET /news-list</code>.</p>
|
||||
<p>Similarly, if a Boolean value is expected, entity naming must describe some qualitative state, i.e. <code>is_ready</code>, <code>open_now</code>.</p>
|
||||
<p><strong>Bad</strong>: <code>"task.status": true</code><br />
|
||||
— statuses are not explicitly binary; also such API isn't extendable.</p>
|
||||
<p><strong>Better</strong>: <code>"task.is_finished": true</code>.</p>
|
||||
<p>Specific platforms imply specific additions to this rule with regard to first class citizen types they provide. For examples, entities of <code>Date</code> type (if such type is present) would benefit from being indicated with <code>_at</code> or <code>_date</code> postfix, i.e. <code>created_at</code>, <code>occurred_at</code>.</p>
|
||||
<p>If entity name is a polysemantic term itself, which could confuse developers, better add an extra prefix or postfix to avoid misunderstanding.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns a list of coffee machine builtin functions
|
||||
GET /coffee-machines/{id}/functions
|
||||
</code></pre>
|
||||
<p>Word ‘function’ is many-valued. It could mean builtin functions, but also ‘a piece of code’, or a state (machine is functioning).</p>
|
||||
<p><strong>Better</strong>: <code>GET /v1/coffee-machines/{id}/builtin-functions-list</code></p>
|
||||
<h5 id="7matchingentitiesmusthavematchingnamesandbehavealike">7. Matching entities must have matching names and behave alike</h5>
|
||||
<p><strong>Bad</strong>: <code>begin_transition</code> / <code>stop_transition</code><br />
|
||||
— <code>begin</code> and <code>stop</code> doesn't match; developers will have to dig into the docs.</p>
|
||||
<p><strong>Better</strong>: either <code>begin_transition</code> / <code>end_transition</code> or <code>start_transition</code> / <code>stop_transition</code>.</p>
|
||||
<p><strong>Bad</strong>: </p>
|
||||
<pre><code>// Find the position of the first occurrence
|
||||
// of a substring in a string
|
||||
strpos(haystack, needle)
|
||||
</code></pre>
|
||||
<pre><code>// Replace all occurrences
|
||||
// of the search string with the replacement string
|
||||
str_replace(needle, replace, haystack)
|
||||
</code></pre>
|
||||
<p>Several rules are violated:</p>
|
||||
<ul>
|
||||
<li>inconsistent underscore using;</li>
|
||||
<li>functionally close methods have different <code>needle</code>/<code>haystack</code> argument order;</li>
|
||||
<li>first function finds the first occurrence while second one finds them all, and there is no way to deduce that fact out of the function signatures.</li>
|
||||
</ul>
|
||||
<p>We're leaving the exercise of making these signatures better to the reader.</p>
|
||||
<h5 id="8clientsmustalwaysknowfullsystemstate">8. Clients must always know full system state</h5>
|
||||
<p>This rule could be reformulated as ‘don't make clients guess’.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Creates a comment and returns its id
|
||||
POST /comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id" }
|
||||
</code></pre>
|
||||
<pre><code>// Returns a comment by its id
|
||||
GET /comments/{id}
|
||||
→
|
||||
{
|
||||
// The comment isn't published
|
||||
// until the captcha is solved
|
||||
"published": false,
|
||||
"action_required": "solve_captcha",
|
||||
"content"
|
||||
}
|
||||
</code></pre>
|
||||
<p>— though the operation pretends to be successful, clients must perform an additional action to understand the comment's real state. In between <code>POST /comments</code> and <code>GET /comments/{id}</code> calls client remains in ‘Schrödinger's cat’ state: it is unknown whether the comment is published or not, and how to display this state to a user.</p>
|
||||
<p><strong>Better</strong>:</p>
|
||||
<pre><code>// Creates a comment and returns it
|
||||
POST /v1/comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id", "published", "action_required", "content" }
|
||||
</code></pre>
|
||||
<pre><code>// Returns a comment by its id
|
||||
GET /v1/comments/{id}
|
||||
→
|
||||
{ /* exactly the same format,
|
||||
as in `POST /comments` reponse */
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>Generally speaking, in 9 cases out of 10 it is better to return a full entity state from any modifying operation, sharing the format with read access endpoint. Actually, you should <em>always</em> do this unless response size affects performance.</p>
|
||||
<p>Same observation applies to filling default values either. Don't make client guess what default values are, or, even worse, hardcode them — return the values of all non-required fields in creation / rewriting endpoints response.</p>
|
||||
<h5 id="9idempotency">9. Idempotency</h5>
|
||||
<p>All API operations must be idempotent. Let us recall that idempotency is the following property: repeated calls to the same function with the same parameters don't change the resource state. Since we're discussing client-server interaction in a first place, repeating request in case of network failure isn't an exception, but a norm of life.</p>
|
||||
<p>If endpoint's idempotency can't be assured naturally, explicit idempotency parameters must be added, in a form of either a token or a resource version.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Creates an order
|
||||
POST /orders
|
||||
</code></pre>
|
||||
<p>Second order will be produced if the request is repeated!</p>
|
||||
<p><strong>Better</strong>:</p>
|
||||
<pre><code>// Creates an order
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <random string>
|
||||
</code></pre>
|
||||
<p>A client on its side must retain <code>X-Idempotency-Token</code> in case of automated endpoint retrying. A server on its side must check whether an order created with this token exists.</p>
|
||||
<p><strong>An alternative</strong>:</p>
|
||||
<pre><code>// Creates order draft
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
</code></pre>
|
||||
<pre><code>// Confirms the draft
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
{ "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 idempotency token.</p>
|
||||
<p>Confirming drafts is a naturally idempotent operation, with <code>draft_if</code> being its idempotency key.</p>
|
||||
<p>Also worth mentioning that adding idempotency tokens to naturally idempotent handlers isn't meaningless either, since it allows to distinguish two situations:</p>
|
||||
<ul>
|
||||
<li>a client didn't get the response because of some network issues, and is now repeating the request;</li>
|
||||
<li>a client's mistaken, trying to make conflicting changes.</li>
|
||||
</ul>
|
||||
<p>Consider the following example: imagine there is a shared resource, characterized by a revision number, and a client tries updating it.</p>
|
||||
<pre><code>POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
</code></pre>
|
||||
<p>The server retrieves the actual resource revision and find it to be 124. How to respond correctly? <code>409 Conflict</code> might be returned, but then the client will be forced to understand the nature of the conflict and somehow resolve it, potentially confusing the user. It's also unwise to fragment conflict resolving algorithms, allowing each client to implement it independently.</p>
|
||||
<p>The server may compare request bodies, assuming that identical <code>updates</code> values means retrying, but this assumption might be dangerously wrong (for example if the resource is a counter of some kind, then repeating identical requests are routine).</p>
|
||||
<p>Adding idempotency token (either directly as a random string, or indirectly in a form of drafts) solves this problem.</p>
|
||||
<pre><code>POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 201 Created
|
||||
</code></pre>
|
||||
<p>— the server found out that the same token was used in creating revision 124, which means the client is retrying the request.</p>
|
||||
<p>Or:</p>
|
||||
<pre><code>POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 409 Conflict
|
||||
</code></pre>
|
||||
<p>— the server found out that a different token was used in creating revision 124, which means an access conflict.</p>
|
||||
<p>Furthermore, adding idempotency tokens not only resolves the issue, but also makes possible to make an advanced optimization. If the server detects an access conflict, it could try to resolve it, ‘rebasing’ the update like modern version control systems do, and return <code>200 OK</code> instead of <code>409 Conflict</code>. This logics dramatically improves user experience, being fully backwards compatible (providing your API embraces the rule #9) and avoiding conflict resolving code fragmentation.</p>
|
||||
<p>Also, be warned: clients are bad at implementing idempotency tokens. Two problems are common:</p>
|
||||
<ul>
|
||||
<li>you can't really expect that clients generate truly random tokens — they may share the same seed or simply use weak algorithms or entropy sources; therefore you must put constraints on token checking: token must be unique to specific user and resource, not globally;</li>
|
||||
<li>clients tend to misunderstand the concept and either generate new tokens each time they repeat the request (which deteriorates the UX, but otherwise healthy) or conversely use one token in several requests (not healthy at all and could lead to catastrophic disasters; another reason to implement the suggestion in the previous clause); writing detailed doc and/or client library is highly recommended.</li>
|
||||
</ul>
|
||||
<h5 id="10caching">10. Caching</h5>
|
||||
<p>Client-server interaction usually implies that network and server resources are limited, therefore caching operation results on client devices is a standard practice.</p>
|
||||
<p>So it's highly desirable to make caching options clear, if not from functions' signatures then at least from docs.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns lungo price in cafes
|
||||
// closest to the specified location
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
</code></pre>
|
||||
<p>Two questions arise:</p>
|
||||
<ul>
|
||||
<li>until when the price is valid?</li>
|
||||
<li>in what vicinity of the location the price is valid?</li>
|
||||
</ul>
|
||||
<p><strong>Better</strong>: you may use standard protocol capabilities to denote cache options, like <code>Cache-Control</code> header. If you need caching in both temporal and spatial dimensions, you should do something like that:</p>
|
||||
<pre><code>// Returns an offer: for what money sum
|
||||
// our service commits to make a lungo
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"conditions": {
|
||||
// Until when the price is valid
|
||||
"valid_until",
|
||||
// What vicinity the price is valid within
|
||||
// * city
|
||||
// * geographical object
|
||||
// * …
|
||||
"valid_within"
|
||||
}
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<h5 id="11paginationfiltrationandcursors">11. Pagination, filtration, and cursors</h5>
|
||||
<p>Any endpoints returning data collections must be paginated. No exclusions exist.</p>
|
||||
<p>Any paginated endpoint must provide an interface to iterate over all the data.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an index
|
||||
// equals to `offset`
|
||||
GET /v1/records?limit=10&offset=100
|
||||
</code></pre>
|
||||
<p>At the first glance, this the most standard way of organizing the pagination in APIs. But let's ask some questions to ourselves. </p>
|
||||
<ol>
|
||||
<li>How clients could learn about new records being added in the beginning of the list?
|
||||
Obviously a client could only retry the initial request (<code>offset=0</code>) and compare identifiers to those it already knows. But what if the number of new records exceeds the <code>limit</code>? Imagine the situation:<ul>
|
||||
<li>the client process records sequentially;</li>
|
||||
<li>some problem occurred, and a batch of new records awaits processing;</li>
|
||||
<li>the client requests new records (<code>offset=0</code>) but can't find any known records on the first page;</li>
|
||||
<li>the client continues iterating over records, page by page, until it finds the last known identifier; all this time the order processing is idle;</li>
|
||||
<li>the client might never start processing, being preoccupied with chaotic page requests to restore records sequence.</li></ul></li>
|
||||
<li>What happens if some record is deleted from the head of the list?<br />
|
||||
Easy: the client will miss one record and will never learn this.</li>
|
||||
<li>What cache parameters to set for this endpoint?<br />
|
||||
None could be set: repeating the request with the same <code>limit</code> and <code>offset</code> each time produces new records set.</li>
|
||||
</ol>
|
||||
<p><strong>Better</strong>: in such unidirectional lists the pagination must use that key which implies the order. Like this:</p>
|
||||
<pre><code>// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an identifier
|
||||
// following the specified one
|
||||
GET /v1/records?older_than={record_id}&limit=10
|
||||
// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an identifier
|
||||
// preceding the specified one
|
||||
GET /v1/records?newer_than={record_id}&limit=10
|
||||
</code></pre>
|
||||
<p>With the pagination organized like that, clients never bothers about record being added or removed in the processed part of the list: they continue to iterate over the records, either getting new ones (using <code>newer_than</code>) or older ones (using <code>older_than</code>). If there is no record removal operation, clients may easily cache responses — the URL will always return the same record set.</p>
|
||||
<p>Another way to organize such lists is returning a <code>cursor</code> to be used instead of <code>record_id</code>, making interfaces more versatile.</p>
|
||||
<pre><code>// Initial data request
|
||||
POST /v1/records/list
|
||||
{
|
||||
// Some additional filtering options
|
||||
"filter": {
|
||||
"category": "some_category",
|
||||
"created_date": {
|
||||
"older_than": "2020-12-07"
|
||||
}
|
||||
}
|
||||
}
|
||||
→
|
||||
{
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<pre><code>// Follow-up requests
|
||||
GET /v1/records?cursor=<cursor value>
|
||||
{ "records", "cursor" }
|
||||
</code></pre>
|
||||
<p>One advantage of this approach is the possibility to keep initial request parameters (i.e. <code>filter</code> in our example) embedded into the cursor itself, thus not copying them in follow-up requests. It might be especially actual if the initial request prepares full dataset, for example, moving it from a ‘cold’ storage to a ‘hot’ one (then <code>cursor</code> might simply contain the encoded dataset id and the offset).</p>
|
||||
<p>There are several approaches to implementing cursors (for example, making single endpoint for initial and follow-up requests, returning the first data portion in the first response). As usual, the crucial part is maintaining consistency across all such endpoints.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>// Returns a limited number of records
|
||||
// sorted by a specified field in a specified order
|
||||
// starting with a record with an index
|
||||
// equals to `offset`
|
||||
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
</code></pre>
|
||||
<p>Sorting by the date of modification usually means that data might be modified. In other words, some records might change after the first data chunk is returned, but before the next chunk is requested. Modified record will simply disappear from the listing because of moving to the first page. Clients will never get those records which were changed during the iteration process, even if the <code>cursor</code> scheme is implemented, and they never learn the sheer fact of such an omission. Also, this particular interface isn't extendable as there is no way to add sorting by two or more fields.</p>
|
||||
<p><strong>Better</strong>: there is no general solution to this problem in this formulation. Listing records by modification time will always be unpredictably volatile, so we have to change the approach itself; we have two options.</p>
|
||||
<p><strong>Option one</strong>: fix the records order at the moment we've got initial request, e.g. our server produces the entire list and stores it in immutable form:</p>
|
||||
<pre><code>// Creates a view based on the parameters passed
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
</code></pre>
|
||||
<pre><code>// Returns a portion of the view
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
</code></pre>
|
||||
<p>Since the produced view is immutable, an 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"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<p>This scheme's downsides are the necessity to create separate indexed event storage, and the multiplication of data items, since for a single record many events might exist.</p>
|
||||
<h5 id="12errorsmustbeinformative">12. Errors must be informative</h5>
|
||||
<p>While writing the code developers face problems, many of them quite trivial, like invalid parameter type or some boundary violation. The more convenient are error responses your API return, the less time developers waste in struggling with it, and the more comfortable is working with the API.</p>
|
||||
<p><strong>Bad</strong>:</p>
|
||||
<pre><code>POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{}
|
||||
</code></pre>
|
||||
<p>— of course, the mistakes (typo in <code>"lngo"</code> and wrong coordinates) are obvious. But the handler checks them anyway, why not return readable descriptions?</p>
|
||||
<p><strong>Better</strong>:</p>
|
||||
<pre><code>{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Something is wrong. Contact the developer of the app."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Unknown value: 'lngo'. Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"constraints": {
|
||||
"min": -180,
|
||||
"max": 180
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value must fall within [-180, 180] interval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>It is also a good practice to return all detectable errors at once to spare developers' time.</p>
|
||||
<h5 id="13localizationandinternationalization">13. Localization and internationalization</h5>
|
||||
<p>All endpoints must accept language parameters (for example, in a form of the <code>Accept-Language</code> header), even if they are not being used currently.</p>
|
||||
<p>It is important to understand that user's language and user's jurisdiction are different things. Your API working cycle must always store user's location. It might be stated either explicitly (requests contain geographical coordinates) or implicitly (initial location-bound request initiates session creation which stores the location), bit no correct localization is possible in absence of location data. In most cases reducing the location to just a country code is enough.</p>
|
||||
<p>The thing is that lots of parameters potentially affecting data formats depend not on language, but user location. To name a few: number formatting (integer and fractional part delimiter, digit groups delimiter), date formatting, first day of week, keyboard layout, measurement units system (which might be non-decimal!), etc. In some situations you need to store two locations: user residence location and user ‘viewport’. For example, if US citizen is planning a European trip, it's convenient to show prices in local currency, but measure distances in miles and feet.</p>
|
||||
<p>Sometimes explicit location passing is not enough since there are lots of territorial conflicts in a world. How the API should behave when user coordinates lie within disputed regions is a legal matter, regretfully. Author of this books once had to implement a ‘state A territory according to state B official position’ concept.</p>
|
||||
<p><strong>Important</strong>: mark a difference between localization for end users and localization for developers. Take a look at the example in #12 rule: <code>localized_message</code> is meant for the user; the app should show it if there is no specific handler for this error exists in code. This message must be written in user's language and formatted according to user's location. But <code>details.checks_failed[].message</code> is meant to be read by developers examining the problem. So it must be written and formatted in a manner which suites developers best. In a software development world it usually means ‘in English’.</p>
|
||||
<p>Worth mentioning is that <code>localized_</code> prefix in the example is used to differentiate messages to users from messages to developers. A concept like that must be, of course, explicitly stated in your API docs.</p>
|
||||
<p>And one more thing: all strings must be UTF-8, no exclusions.</p><div class="page-break"></div></article>
|
||||
</body></html>
|
BIN
docs/API.en.pdf
BIN
docs/API.en.pdf
Binary file not shown.
BIN
docs/API.ru.epub
BIN
docs/API.ru.epub
Binary file not shown.
@ -850,12 +850,12 @@ app.display(coffeeMachines);
|
||||
<p>Важно, что читабельность достигается не просто снижением количества сущностей на одном уровне. Декомпозиция должна производиться таким образом, чтобы разработчик при чтении интерфейса сразу понимал: так, вот здесь находится описание заведения, оно мне пока неинтересно и углубляться в эту ветку я пока не буду. Если перемешать данные, которые одновременно в моменте нужны для выполнения действия, по разным композитам — это только ухудшит читабельность, а не улучшит.</p>
|
||||
<p>Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чем мы поговорим в разделе II.</p><div class="page-break"></div><h3>Глава 11. Описание конечных интерфейсов</h3><p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
|
||||
<p>Важное уточнение под номером ноль:</p>
|
||||
<h4 id="0">0. Правила — это всего лишь обобщения</h4>
|
||||
<h5 id="0">0. Правила — это всего лишь обобщения</h5>
|
||||
<p>Правила не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не надо.</p>
|
||||
<p>Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам <em>необходимо</em>, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.</p>
|
||||
<p>Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобное, громоздкое, неочевидное API — это повод пересмотреть правила (или API).</p>
|
||||
<p>Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов <code>set_entity</code> / <code>get_entity</code> в пользу одного метода <code>entity</code> с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.</p>
|
||||
<h4 id="1">1. Явное лучше неявного</h4>
|
||||
<h5 id="1">1. Явное лучше неявного</h5>
|
||||
<p>Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.</p>
|
||||
<p><strong>Плохо</strong>: </p>
|
||||
<pre><code>// Отменяет заказ
|
||||
@ -880,7 +880,7 @@ POST /v1/orders/statistics/aggregate
|
||||
<p>Два важных следствия:</p>
|
||||
<p><strong>1.1.</strong> Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за <code>GET</code>.</p>
|
||||
<p><strong>1.2.</strong> Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, <strong>либо</strong> должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.</p>
|
||||
<h4 id="2">2. Указывайте использованные стандарты</h4>
|
||||
<h5 id="2">2. Указывайте использованные стандарты</h5>
|
||||
<p>К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.</p>
|
||||
<p>Поэтому <em>всегда</em> указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.</p>
|
||||
<p><strong>Плохо</strong>: <code>"date": "11/12/2020"</code> — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц.</p>
|
||||
@ -894,14 +894,14 @@ POST /v1/orders/statistics/aggregate
|
||||
<code>"duration": {"unit": "ms", "value": 5000}</code>.</p>
|
||||
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
|
||||
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
|
||||
<h4 id="3">3. Сохраняйте точность дробных чисел</h4>
|
||||
<h5 id="3">3. Сохраняйте точность дробных чисел</h5>
|
||||
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
|
||||
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.</p>
|
||||
<h4 id="4">4. Сущности должны именоваться конкретно</h4>
|
||||
<h5 id="4">4. Сущности должны именоваться конкретно</h5>
|
||||
<p>Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.</p>
|
||||
<p><strong>Плохо</strong>: <code>user.get()</code> — неочевидно, что конкретно будет возвращено.</p>
|
||||
<p><strong>Хорошо</strong>: <code>user.get_id()</code>.</p>
|
||||
<h4 id="5">5. Не экономьте буквы</h4>
|
||||
<h5 id="5">5. Не экономьте буквы</h5>
|
||||
<p>В XXI веке давно уже нет нужды называть переменные покороче.</p>
|
||||
<p><strong>Плохо</strong>: <code>order.time()</code> — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…</p>
|
||||
<p><strong>Хорошо</strong>: <code>order.get_estimated_delivery_time()</code></p>
|
||||
@ -913,7 +913,7 @@ strpbrk (str1, str2)
|
||||
<p>Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.</p>
|
||||
<p><strong>Хорошо</strong>: <code>str_search_for_characters (lookup_character_set, str)</code><br />
|
||||
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</p>
|
||||
<h4 id="6">6. Тип поля должен быть ясен из его названия</h4>
|
||||
<h5 id="6">6. Тип поля должен быть ясен из его названия</h5>
|
||||
<p>Если поле называется <code>recipe</code> — мы ожидаем, что его значением является сущность типа <code>Recipe</code>. Если поле называется <code>recipe_id</code> — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности <code>Recipe</code>.</p>
|
||||
<p>То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — <code>objects</code>, <code>children</code>; если это невозможно (термин неисчисляемый), следует добавить префикс или постфикс, не оставляющий сомнений.</p>
|
||||
<p><strong>Плохо</strong>: <code>GET /news</code> — неясно, будет ли получена какая-то конкретная новость или массив новостей.</p>
|
||||
@ -929,7 +929,7 @@ GET /coffee-machines/{id}/functions
|
||||
</code></pre>
|
||||
<p>Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).</p>
|
||||
<p><strong>Хорошо</strong>: <code>GET /v1/coffee-machines/{id}/builtin-functions-list</code></p>
|
||||
<h4 id="7">7. Подобные сущности должны называться подобно и вести себя подобным образом</h4>
|
||||
<h5 id="7">7. Подобные сущности должны называться подобно и вести себя подобным образом</h5>
|
||||
<p><strong>Плохо</strong>: <code>begin_transition</code> / <code>stop_transition</code><br />
|
||||
— <code>begin</code> и <code>stop</code> — непарные термины; разработчик будет вынужден рыться в документации.</p>
|
||||
<p><strong>Хорошо</strong>: <code>begin_transition</code> / <code>end_transition</code> либо <code>start_transition</code> / <code>stop_transition</code>.</p>
|
||||
@ -949,7 +949,7 @@ str_replace(needle, replace, haystack)
|
||||
<li>первый из методов находит только первое вхождение строки <code>needle</code>, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.</li>
|
||||
</ul>
|
||||
<p>Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.</p>
|
||||
<h4 id="8">8. Клиент всегда должен знать полное состояние системы</h4>
|
||||
<h5 id="8">8. Клиент всегда должен знать полное состояние системы</h5>
|
||||
<p>Правило можно ещё сформулировать так: не заставляйте клиент гадать.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
<pre><code>// Создаёт комментарий и возвращает его id
|
||||
@ -986,7 +986,8 @@ GET /v1/comments/{id}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.</p>
|
||||
<h4 id="9">9. Идемпотентность</h4>
|
||||
<p>То же соображение применимо и к значениям по умолчанию. Не заставляйте клиент гадать эти значения, или хуже — хардкодить их. Возвращайте заполненные значения необязательных полей в ответе операции создания (перезаписи) сущности.</p>
|
||||
<h5 id="9">9. Идемпотентность</h5>
|
||||
<p>Все операции должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.</p>
|
||||
<p>Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
@ -1002,7 +1003,7 @@ X-Idempotency-Token: <случайная строка>
|
||||
<p>Клиент на своей стороне запоминает <code>X-Idempotency-Token</code>, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.</p>
|
||||
<p><strong>Альтернатива</strong>:</p>
|
||||
<pre><code>// Создаёт черновик заказа
|
||||
POST /v1/orders
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
</code></pre>
|
||||
@ -1012,7 +1013,47 @@ PUT /v1/orders/drafts/{draft_id}
|
||||
</code></pre>
|
||||
<p>Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё <code>draft_id</code> играет роль ключа идемпотентности.</p>
|
||||
<h4 id="10">10. Кэширование</h4>
|
||||
<p>Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так нативно идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации:</p>
|
||||
<ul>
|
||||
<li>клиент не получил ответ из-за сетевых проблем и пытается повторить запрос;</li>
|
||||
<li>клиент ошибся, пытаясь применить конфликтующие изменения.</li>
|
||||
</ul>
|
||||
<p>Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.</p>
|
||||
<pre><code>POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Сервер извлекает актуальный номер ревизии и обнаруживает, что он равен 124. Как ответить правильно? Можно просто вернуть <code>409 Conflict</code>, но тогда клиент будет вынужден попытаться выяснить причину конфликта и как-то решить его, потенциально запутав пользователя. К тому же, фрагментировать алгоритмы разрешения конфликтов, разрешая каждому клиенту реализовать какой-то свой — плохая идея.</p>
|
||||
<p>Сервер мог бы попытаться сравнить значения поля <code>updates</code>, предполагая, что одинаковые значения означают перезапрос, но это предположение будет опасно неверным (например, если ресурс представляет собой счётчик, то последовательные запросы с идентичным телом нормальны).</p>
|
||||
<p>Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему</p>
|
||||
<pre><code>POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 201 Created
|
||||
</code></pre>
|
||||
<p>— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос.</p>
|
||||
<p>Или:</p>
|
||||
<pre><code>POST /resource/updates
|
||||
X-Idempotency-Token: <токен>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 409 Conflict
|
||||
</code></pre>
|
||||
<p>— сервер обнаружил, что ревизия 123 была создана с другим токеном, значит имеет место быть конфликт общего доступа к ресурсу.</p>
|
||||
<p>Более того, добавление токена идемпотентности не только решает эту проблему, но и позволяет в будущем сделать продвинутые оптимизации. Если сервер обнаруживает конфликт общего доступа, он может попытаться решить его, «перебазировав» обновление, как это делают современные системы контроля версий, и вернуть <code>200 OK</code> вместо <code>409 Conflict</code>. Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима (если, конечно, вы следовали правилу #9 при разработке API) и предотвращает фрагментацию кода разрешения конфликтов.</p>
|
||||
<p>Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно:</p>
|
||||
<ul>
|
||||
<li>нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;</li>
|
||||
<li>клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.</li>
|
||||
</ul>
|
||||
<h5 id="10">10. Кэширование</h5>
|
||||
<p>В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием.</p>
|
||||
<p>Желательно в такой ситуации внести ясность; если не из сигнатур операций, то хотя бы из документации должно быть понятно, каким образом можно кэшировать результат.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
@ -1050,7 +1091,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<h4 id="11">11. Пагинация, фильтрация и курсоры</h4>
|
||||
<h5 id="11">11. Пагинация, фильтрация и курсоры</h5>
|
||||
<p>Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.</p>
|
||||
<p>Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
@ -1059,7 +1100,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
// начиная с записи с номером offset
|
||||
GET /v1/records?limit=10&offset=100
|
||||
</code></pre>
|
||||
<p>На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса:</p>
|
||||
<p>На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.</p>
|
||||
<ol>
|
||||
<li>Каким образом клиент узнает о появлении новых записей в начале списка?<br />
|
||||
Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает <code>limit</code>? Представим себе ситуацию:<ul>
|
||||
@ -1119,8 +1160,7 @@ GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=1
|
||||
</code></pre>
|
||||
<p>Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такое API нерасширяемо — невозможно добавить сортировку по двум или более полям.</p>
|
||||
<p><strong>Хорошо</strong>: в представленной постановке задача, вообще говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.</p>
|
||||
<ul>
|
||||
<li><p>Фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:</p>
|
||||
<p><strong>Вариант 1</strong>: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:</p>
|
||||
<pre><code>// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
@ -1134,8 +1174,8 @@ POST /v1/record-views
|
||||
<pre><code>// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
</code></pre>
|
||||
<p>Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).</p></li>
|
||||
<li><p>Гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:</p>
|
||||
<p>Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).</p>
|
||||
<p><strong>Вариант 2</strong>: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:</p>
|
||||
<pre><code>POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
@ -1149,9 +1189,8 @@ GET /v1/record-views/{id}?cursor={cursor}
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки, а также появление множества событий для одной записи, если данные меняются часто.</p></li>
|
||||
</ul>
|
||||
<h4 id="12">12. Ошибки должны быть информативными</h4>
|
||||
<p>Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.</p>
|
||||
<h5 id="12">12. Ошибки должны быть информативными</h5>
|
||||
<p>При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.</p>
|
||||
<p><strong>Плохо</strong>:</p>
|
||||
<pre><code>POST /v1/coffee-machines/search
|
||||
@ -1166,6 +1205,7 @@ GET /v1/record-views/{id}?cursor={cursor}
|
||||
{}
|
||||
</code></pre>
|
||||
<p>— да, конечно, допущенные ошибки (опечатка в <code>"lngo"</code> и неправильные координаты) очевидны. Но раз наш сервер все равно их проверяет, почему не вернуть описание ошибок в читаемом виде?</p>
|
||||
<p><strong>Хорошо</strong>:</p>
|
||||
<pre><code>{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
@ -1193,7 +1233,7 @@ GET /v1/record-views/{id}?cursor={cursor}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.</p>
|
||||
<h4 id="13">13. Локализация и интернационализация</h4>
|
||||
<h5 id="13">13. Локализация и интернационализация</h5>
|
||||
<p>Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка <code>Accept-Language</code>), даже если на текущем этапе нужды в локализации нет.</p>
|
||||
<p>Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.</p>
|
||||
<p>Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.</p>
|
||||
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -202,7 +202,7 @@ Let's take a look at a simple example: what coffee machine search function retur
|
||||
{
|
||||
"coffee_machine_type": "drip_coffee_maker",
|
||||
"coffee_machine_brand",
|
||||
"place_name": "Кафе «Ромашка»",
|
||||
"place_name": "The Chamomile",
|
||||
// Coordinates of a place
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
|
529
src/en/clean-copy/02-Section I. The API Design/05.md
Normal file
529
src/en/clean-copy/02-Section I. The API Design/05.md
Normal file
@ -0,0 +1,529 @@
|
||||
### Describing Final Interfaces
|
||||
|
||||
When all entities, their responsibility, and relations to each other are defined, we proceed to developing the API itself. We are to describe the objects, fields, methods, and functions nomenclature in details. In this chapter we're giving purely practical advice on making APIs usable and understandable.
|
||||
|
||||
Important assertion at number 0:
|
||||
|
||||
##### 0. Rules are just generalizations
|
||||
|
||||
Rules are not to be applied unconditionally. They are not making thinking redundant. Every rule has a rational reason to exist. If your situation doesn't justify following the rule — then you shouldn't do it.
|
||||
|
||||
For example, demanding a specification being consistent exists to help developers spare time on reading docs. If you *need* developers to read some entity's doc, it is totally rational to make its signature deliberately inconsistent.
|
||||
|
||||
This idea applies to every concept listed below. If you get an unusable, bulky, unobvious API because you follow the rules, it's a motive to revise the rules (or the API).
|
||||
|
||||
It is important to understand that you always can introduce the concepts of your own. For example, some frameworks willfully reject paired `set_entity` / `get_entity` methods in a favor of a single `entity()` method, with an optional argument. The crucial part is being systematic in applying the concept. If it's rendered into life, you must apply it to every single API method, or at the very least elaborate a naming rule to discern such polymorphic methods from regular ones.
|
||||
|
||||
##### 1. Explicit is always better than implicit
|
||||
|
||||
Entity's name must explicitly tell what it does and what side effects to expect while using it.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Cancels an order
|
||||
GET /orders/cancellation
|
||||
```
|
||||
It's quite a surprise that accessing the `cancellation` resource (what is it?) with non-modifying `GET` method actually cancels an order.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
// Cancels an order
|
||||
POST /orders/cancel
|
||||
```
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns aggregated statistics
|
||||
// since the beginning of time
|
||||
GET /orders/statistics
|
||||
```
|
||||
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
|
||||
// for a specified period of time
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "begin_date", "end_date" }
|
||||
```
|
||||
|
||||
**Try to design function signatures to be absolutely transparent about what the function does, what arguments 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.
|
||||
|
||||
Two important implications:
|
||||
|
||||
**1.1.** If the operation is modifying, it must be obvious from the signature. In particular, there might be no modifying operations using `GET` verb.
|
||||
|
||||
**1.2.** If your API's nomenclature contains both synchronous and asynchronous operations, then (a)synchronicity must be apparent from signatures, **or** a naming convention must exist.
|
||||
|
||||
##### 2. Specify which standards are used
|
||||
|
||||
Regretfully, the humanity is unable to agree on the most trivial things, like which day starts the week, to say nothing about more sophisticated standards.
|
||||
|
||||
So *always* specify exactly which standard is applied. Exceptions are possible, if you 100% sure that only one standard for this entity exists in the world, and every person on Earth is totally aware of it.
|
||||
|
||||
**Bad**: `"date": "11/12/2020"` — there are tons of date formatting standards; you can't even tell which number means the day number and which number means the month.
|
||||
|
||||
**Better**: `"iso_date": "2020-11-12"`.
|
||||
|
||||
**Bad**: `"duration": 5000` — five thousands of what?
|
||||
|
||||
**Better**:
|
||||
`"duration_ms": 5000`
|
||||
or
|
||||
`"duration": "5000ms"`
|
||||
or
|
||||
`"duration": {"unit": "ms", "value": 5000}`.
|
||||
|
||||
One particular implication from this rule is that money sums must *always* be accompanied with currency code.
|
||||
|
||||
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 with frustration there is a ‘serenity notepad’ to be discussed in Section II.
|
||||
|
||||
##### 3. Keep fractional numbers precision intact
|
||||
|
||||
If the protocol allows, fractional numbers with fixed precision (like money sums) must be represented as a specially designed type like Decimal or its equivalent.
|
||||
|
||||
If there is no Decimal type in the protocol (for instance, JSON doesn't have one), you should either use integers (e.g. apply a fixed multiplicator) or strings.
|
||||
|
||||
##### 4. Entities must have concrete names
|
||||
|
||||
Avoid single amoeba-like words, such as get, apply, make.
|
||||
|
||||
**Bad**: `user.get()` — hard to guess what is actually returned.
|
||||
|
||||
**Better**: `user.get_id()`.
|
||||
|
||||
##### 5. Don't spare the letters
|
||||
|
||||
In XXI century there's no need to shorten entities' names.
|
||||
|
||||
**Bad**: `order.time()` — unclear, what time is actually returned: order creation time, order preparation time, order waiting time?…
|
||||
|
||||
**Better**: `order.get_estimated_delivery_time()`
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a pointer to the first occurrence
|
||||
// in str1 of any of the characters
|
||||
// that are part of str2
|
||||
strpbrk (str1, str2)
|
||||
```
|
||||
|
||||
Possibly, an author of this API thought that `pbrk` abbreviature would mean something to readers; clearly mistaken. Also it's hard to tell from the signature which string (`str1` or `str2`) stands for a character set.
|
||||
|
||||
**Better**: `str_search_for_characters (lookup_character_set, str)`
|
||||
— though it's highly disputable whether this function should exist at all; a feature-rich search function would be much more convenient. Also, shortening `string` to `str` bears no practical sense, regretfully being a routine in many subject areas.
|
||||
|
||||
##### 6. Naming implies typing
|
||||
|
||||
Field named `recipe` must be of `Recipe` type. Field named `recipe_id` must contain a recipe identifier which we could find within `Recipe` entity.
|
||||
|
||||
Same for primitive types. Arrays must be named in a plural form or as collective nouns, i.e. `objects`, `children`. If that's impossible, better add a prefix or a postfix to avoid doubt.
|
||||
|
||||
**Bad**: `GET /news` — unclear whether a specific news item is returned, or a list of them.
|
||||
|
||||
**Better**: `GET /news-list`.
|
||||
|
||||
Similarly, if a Boolean value is expected, entity naming must describe some qualitative state, i.e. `is_ready`, `open_now`.
|
||||
|
||||
**Bad**: `"task.status": true`
|
||||
— statuses are not explicitly binary; also such API isn't extendable.
|
||||
|
||||
**Better**: `"task.is_finished": true`.
|
||||
|
||||
Specific platforms imply specific additions to this rule with regard to first class citizen types they provide. For examples, entities of `Date` type (if such type is present) would benefit from being indicated with `_at` or `_date` postfix, i.e. `created_at`, `occurred_at`.
|
||||
|
||||
If entity name is a polysemantic term itself, which could confuse developers, better add an extra prefix or postfix to avoid misunderstanding.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a list of coffee machine builtin functions
|
||||
GET /coffee-machines/{id}/functions
|
||||
```
|
||||
Word ‘function’ is many-valued. It could mean builtin functions, but also ‘a piece of code’, or a state (machine is functioning).
|
||||
|
||||
**Better**: `GET /v1/coffee-machines/{id}/builtin-functions-list`
|
||||
|
||||
##### 7. Matching entities must have matching names and behave alike
|
||||
|
||||
**Bad**: `begin_transition` / `stop_transition`
|
||||
— `begin` and `stop` doesn't match; developers will have to dig into the docs.
|
||||
|
||||
**Better**: either `begin_transition` / `end_transition` or `start_transition` / `stop_transition`.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Find the position of the first occurrence
|
||||
// of a substring in a string
|
||||
strpos(haystack, needle)
|
||||
```
|
||||
```
|
||||
// Replace all occurrences
|
||||
// of the search string with the replacement string
|
||||
str_replace(needle, replace, haystack)
|
||||
```
|
||||
Several rules are violated:
|
||||
* inconsistent underscore using;
|
||||
* functionally close methods have different `needle`/`haystack` argument order;
|
||||
* first function finds the first occurrence while second one finds them all, and there is no way to deduce that fact out of the function signatures.
|
||||
|
||||
We're leaving the exercise of making these signatures better to the reader.
|
||||
|
||||
##### 8. Clients must always know full system state
|
||||
|
||||
This rule could be reformulated as ‘don't make clients guess’.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Creates a comment and returns its id
|
||||
POST /comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id" }
|
||||
```
|
||||
```
|
||||
// Returns a comment by its id
|
||||
GET /comments/{id}
|
||||
→
|
||||
{
|
||||
// The comment isn't published
|
||||
// until the captcha is solved
|
||||
"published": false,
|
||||
"action_required": "solve_captcha",
|
||||
"content"
|
||||
}
|
||||
```
|
||||
— though the operation pretends to be successful, clients must perform an additional action to understand the comment's real state. In between `POST /comments` and `GET /comments/{id}` calls client remains in ‘Schrödinger's cat’ state: it is unknown whether the comment is published or not, and how to display this state to a user.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
// Creates a comment and returns it
|
||||
POST /v1/comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id", "published", "action_required", "content" }
|
||||
```
|
||||
```
|
||||
// Returns a comment by its id
|
||||
GET /v1/comments/{id}
|
||||
→
|
||||
{ /* exactly the same format,
|
||||
as in `POST /comments` reponse */
|
||||
…
|
||||
}
|
||||
```
|
||||
Generally speaking, in 9 cases out of 10 it is better to return a full entity state from any modifying operation, sharing the format with read access endpoint. Actually, you should *always* do this unless response size affects performance.
|
||||
|
||||
Same observation applies to filling default values either. Don't make client guess what default values are, or, even worse, hardcode them — return the values of all non-required fields in creation / rewriting endpoints response.
|
||||
|
||||
##### 9. Idempotency
|
||||
|
||||
All API operations must be idempotent. Let us recall that idempotency is the following property: repeated calls to the same function with the same parameters don't change the resource state. Since we're discussing client-server interaction in a first place, repeating request in case of network failure isn't an exception, but a norm of life.
|
||||
|
||||
If endpoint's idempotency can't be assured naturally, explicit idempotency parameters must be added, in a form of either a token or a resource version.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Creates an order
|
||||
POST /orders
|
||||
```
|
||||
Second order will be produced if the request is repeated!
|
||||
|
||||
**Better**:
|
||||
```
|
||||
// Creates an order
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <random string>
|
||||
```
|
||||
A client on its side must retain `X-Idempotency-Token` in case of automated endpoint retrying. A server on its side must check whether an order created with this token exists.
|
||||
|
||||
**An alternative**:
|
||||
```
|
||||
// Creates order draft
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
```
|
||||
// Confirms the draft
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
{ "confirmed": true }
|
||||
```
|
||||
|
||||
Creating order drafts is a non-binding operation since it doesn't entail any consequences, so it's fine to create drafts without idempotency token.
|
||||
|
||||
Confirming drafts is a naturally idempotent operation, with `draft_if` being its idempotency key.
|
||||
|
||||
Also worth mentioning that adding idempotency tokens to naturally idempotent handlers isn't meaningless either, since it allows to distinguish two situations:
|
||||
* a client didn't get the response because of some network issues, and is now repeating the request;
|
||||
* a client's mistaken, trying to make conflicting changes.
|
||||
|
||||
Consider the following example: imagine there is a shared resource, characterized by a revision number, and a client tries updating it.
|
||||
```
|
||||
POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
```
|
||||
The server retrieves the actual resource revision and find it to be 124. How to respond correctly? `409 Conflict` might be returned, but then the client will be forced to understand the nature of the conflict and somehow resolve it, potentially confusing the user. It's also unwise to fragment conflict resolving algorithms, allowing each client to implement it independently.
|
||||
|
||||
The server may compare request bodies, assuming that identical `updates` values means retrying, but this assumption might be dangerously wrong (for example if the resource is a counter of some kind, then repeating identical requests are routine).
|
||||
|
||||
Adding idempotency token (either directly as a random string, or indirectly in a form of drafts) solves this problem.
|
||||
```
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 201 Created
|
||||
```
|
||||
— the server found out that the same token was used in creating revision 124, which means the client is retrying the request.
|
||||
|
||||
Or:
|
||||
```
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
"resource_revision": 123
|
||||
"updates"
|
||||
}
|
||||
→ 409 Conflict
|
||||
```
|
||||
— the server found out that a different token was used in creating revision 124, which means an access conflict.
|
||||
|
||||
Furthermore, adding idempotency tokens not only resolves the issue, but also makes possible to make an advanced optimization. If the server detects an access conflict, it could try to resolve it, ‘rebasing’ the update like modern version control systems do, and return `200 OK` instead of `409 Conflict`. This logics dramatically improves user experience, being fully backwards compatible (providing your API embraces the rule \#9) and avoiding conflict resolving code fragmentation.
|
||||
|
||||
Also, be warned: clients are bad at implementing idempotency tokens. Two problems are common:
|
||||
* you can't really expect that clients generate truly random tokens — they may share the same seed or simply use weak algorithms or entropy sources; therefore you must put constraints on token checking: token must be unique to specific user and resource, not globally;
|
||||
* clients tend to misunderstand the concept and either generate new tokens each time they repeat the request (which deteriorates the UX, but otherwise healthy) or conversely use one token in several requests (not healthy at all and could lead to catastrophic disasters; another reason to implement the suggestion in the previous clause); writing detailed doc and/or client library is highly recommended.
|
||||
|
||||
##### 10. Caching
|
||||
|
||||
Client-server interaction usually implies that network and server resources are limited, therefore caching operation results on client devices is a standard practice.
|
||||
|
||||
So it's highly desirable to make caching options clear, if not from functions' signatures then at least from docs.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns lungo price in cafes
|
||||
// closest to the specified location
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
```
|
||||
Two questions arise:
|
||||
* until when the price is valid?
|
||||
* in what vicinity of the location the price is valid?
|
||||
|
||||
**Better**: you may use standard protocol capabilities to denote cache options, like `Cache-Control` header. If you need caching in both temporal and spatial dimensions, you should do something like that:
|
||||
```
|
||||
// Returns an offer: for what money sum
|
||||
// our service commits to make a lungo
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"conditions": {
|
||||
// Until when the price is valid
|
||||
"valid_until",
|
||||
// What vicinity the price is valid within
|
||||
// * city
|
||||
// * geographical object
|
||||
// * …
|
||||
"valid_within"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### 11. Pagination, filtration, and cursors
|
||||
|
||||
Any endpoints returning data collections must be paginated. No exclusions exist.
|
||||
|
||||
Any paginated endpoint must provide an interface to iterate over all the data.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an index
|
||||
// equals to `offset`
|
||||
GET /v1/records?limit=10&offset=100
|
||||
```
|
||||
|
||||
At the first glance, this the most standard way of organizing the pagination in APIs. But let's ask some questions to ourselves.
|
||||
1. How clients could learn about new records being added in the beginning of the list?
|
||||
Obviously a client could only retry the initial request (`offset=0`) and compare identifiers to those it already knows. But what if the number of new records exceeds the `limit`? Imagine the situation:
|
||||
* the client process records sequentially;
|
||||
* some problem occurred, and a batch of new records awaits processing;
|
||||
* the client requests new records (`offset=0`) but can't find any known records on the first page;
|
||||
* the client continues iterating over records, page by page, until it finds the last known identifier; all this time the order processing is idle;
|
||||
* the client might never start processing, being preoccupied with chaotic page requests to restore records sequence.
|
||||
2. What happens if some record is deleted from the head of the list?
|
||||
Easy: the client will miss one record and will never learn this.
|
||||
3. What cache parameters to set for this endpoint?
|
||||
None could be set: repeating the request with the same `limit` and `offset` each time produces new records set.
|
||||
|
||||
**Better**: in such unidirectional lists the pagination must use that key which implies the order. Like this:
|
||||
```
|
||||
// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an identifier
|
||||
// following the specified one
|
||||
GET /v1/records?older_than={record_id}&limit=10
|
||||
// Returns a limited number of records
|
||||
// sorted by creation date
|
||||
// starting with a record with an identifier
|
||||
// preceding the specified one
|
||||
GET /v1/records?newer_than={record_id}&limit=10
|
||||
```
|
||||
With the pagination organized like that, clients never bothers about record being added or removed in the processed part of the list: they continue to iterate over the records, either getting new ones (using `newer_than`) or older ones (using `older_than`). If there is no record removal operation, clients may easily cache responses — the URL will always return the same record set.
|
||||
|
||||
Another way to organize such lists is returning a `cursor` to be used instead of `record_id`, making interfaces more versatile.
|
||||
```
|
||||
// Initial data request
|
||||
POST /v1/records/list
|
||||
{
|
||||
// Some additional filtering options
|
||||
"filter": {
|
||||
"category": "some_category",
|
||||
"created_date": {
|
||||
"older_than": "2020-12-07"
|
||||
}
|
||||
}
|
||||
}
|
||||
→
|
||||
{
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
```
|
||||
// Follow-up requests
|
||||
GET /v1/records?cursor=<cursor value>
|
||||
{ "records", "cursor" }
|
||||
```
|
||||
One advantage of this approach is the possibility to keep initial request parameters (i.e. `filter` in our example) embedded into the cursor itself, thus not copying them in follow-up requests. It might be especially actual if the initial request prepares full dataset, for example, moving it from a ‘cold’ storage to a ‘hot’ one (then `cursor` might simply contain the encoded dataset id and the offset).
|
||||
|
||||
There are several approaches to implementing cursors (for example, making single endpoint for initial and follow-up requests, returning the first data portion in the first response). As usual, the crucial part is maintaining consistency across all such endpoints.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a limited number of records
|
||||
// sorted by a specified field in a specified order
|
||||
// starting with a record with an index
|
||||
// equals to `offset`
|
||||
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
```
|
||||
|
||||
Sorting by the date of modification usually means that data might be modified. In other words, some records might change after the first data chunk is returned, but before the next chunk is requested. Modified record will simply disappear from the listing because of moving to the first page. Clients will never get those records which were changed during the iteration process, even if the `cursor` scheme is implemented, and they never learn the sheer fact of such an omission. Also, this particular interface isn't extendable as there is no way to add sorting by two or more fields.
|
||||
|
||||
**Better**: there is no general solution to this problem in this formulation. Listing records by modification time will always be unpredictably volatile, so we have to change the approach itself; we have two options.
|
||||
|
||||
**Option one**: fix the records order at the moment we've got initial request, e.g. our server produces the entire list and stores it in immutable form:
|
||||
|
||||
```
|
||||
// Creates a view based on the parameters passed
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
```
|
||||
```
|
||||
// Returns a portion of the view
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
```
|
||||
|
||||
Since the produced view is immutable, an access to it might be organized in any form, including a limit-offset scheme, cursors, `Range` header, etc. However there is a downside: records modified after the view was generated will be misplaced or outdated.
|
||||
|
||||
**Option two**: guarantee a strict records order, for example, by introducing a concept of record change events:
|
||||
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Optional
|
||||
"cursor"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
|
||||
This scheme's downsides are the necessity to create separate indexed event storage, and the multiplication of data items, since for a single record many events might exist.
|
||||
|
||||
##### 12. Errors must be informative
|
||||
|
||||
While writing the code developers face problems, many of them quite trivial, like invalid parameter type or some boundary violation. The more convenient are error responses your API return, the less time developers waste in struggling with it, and the more comfortable is working with the API.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
"position": {
|
||||
"latitude": 110,
|
||||
"longitude": 55
|
||||
}
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{}
|
||||
```
|
||||
— of course, the mistakes (typo in `"lngo"` and wrong coordinates) are obvious. But the handler checks them anyway, why not return readable descriptions?
|
||||
|
||||
**Better**:
|
||||
```
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
"Something is wrong. Contact the developer of the app."
|
||||
"details": {
|
||||
"checks_failed": [
|
||||
{
|
||||
"field": "recipe",
|
||||
"error_type": "wrong_value",
|
||||
"message":
|
||||
"Unknown value: 'lngo'. Did you mean 'lungo'?"
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"constraints": {
|
||||
"min": -180,
|
||||
"max": 180
|
||||
},
|
||||
"message":
|
||||
"'position.latitude' value must fall within [-180, 180] interval"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
It is also a good practice to return all detectable errors at once to spare developers' time.
|
||||
|
||||
##### 13. Localization and internationalization
|
||||
|
||||
All endpoints must accept language parameters (for example, in a form of the `Accept-Language` header), even if they are not being used currently.
|
||||
|
||||
It is important to understand that user's language and user's jurisdiction are different things. Your API working cycle must always store user's location. It might be stated either explicitly (requests contain geographical coordinates) or implicitly (initial location-bound request initiates session creation which stores the location), bit no correct localization is possible in absence of location data. In most cases reducing the location to just a country code is enough.
|
||||
|
||||
The thing is that lots of parameters potentially affecting data formats depend not on language, but user location. To name a few: number formatting (integer and fractional part delimiter, digit groups delimiter), date formatting, first day of week, keyboard layout, measurement units system (which might be non-decimal!), etc. In some situations you need to store two locations: user residence location and user ‘viewport’. For example, if US citizen is planning a European trip, it's convenient to show prices in local currency, but measure distances in miles and feet.
|
||||
|
||||
Sometimes explicit location passing is not enough since there are lots of territorial conflicts in a world. How the API should behave when user coordinates lie within disputed regions is a legal matter, regretfully. Author of this books once had to implement a ‘state A territory according to state B official position’ concept.
|
||||
|
||||
**Important**: mark a difference between localization for end users and localization for developers. Take a look at the example in \#12 rule: `localized_message` is meant for the user; the app should show it if there is no specific handler for this error exists in code. This message must be written in user's language and formatted according to user's location. But `details.checks_failed[].message` is meant to be read by developers examining the problem. So it must be written and formatted in a manner which suites developers best. In a software development world it usually means ‘in English’.
|
||||
|
||||
Worth mentioning is that `localized_` prefix in the example is used to differentiate messages to users from messages to developers. A concept like that must be, of course, explicitly stated in your API docs.
|
||||
|
||||
And one more thing: all strings must be UTF-8, no exclusions.
|
@ -4,7 +4,7 @@
|
||||
|
||||
Важное уточнение под номером ноль:
|
||||
|
||||
#### 0. Правила — это всего лишь обобщения
|
||||
##### 0. Правила — это всего лишь обобщения
|
||||
|
||||
Правила не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не надо.
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.
|
||||
|
||||
#### 1. Явное лучше неявного
|
||||
##### 1. Явное лучше неявного
|
||||
|
||||
Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.
|
||||
|
||||
@ -53,7 +53,7 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
|
||||
|
||||
#### 2. Указывайте использованные стандарты
|
||||
##### 2. Указывайте использованные стандарты
|
||||
|
||||
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.
|
||||
|
||||
@ -76,13 +76,13 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
|
||||
|
||||
#### 3. Сохраняйте точность дробных чисел
|
||||
##### 3. Сохраняйте точность дробных чисел
|
||||
|
||||
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
|
||||
|
||||
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
|
||||
|
||||
#### 4. Сущности должны именоваться конкретно
|
||||
##### 4. Сущности должны именоваться конкретно
|
||||
|
||||
Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.
|
||||
|
||||
@ -90,7 +90,7 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
**Хорошо**: `user.get_id()`.
|
||||
|
||||
#### 5. Не экономьте буквы
|
||||
##### 5. Не экономьте буквы
|
||||
|
||||
В XXI веке давно уже нет нужды называть переменные покороче.
|
||||
|
||||
@ -109,7 +109,7 @@ strpbrk (str1, str2)
|
||||
**Хорошо**: `str_search_for_characters (lookup_character_set, str)`
|
||||
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
|
||||
|
||||
#### 6. Тип поля должен быть ясен из его названия
|
||||
##### 6. Тип поля должен быть ясен из его названия
|
||||
|
||||
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
|
||||
|
||||
@ -138,7 +138,7 @@ GET /coffee-machines/{id}/functions
|
||||
|
||||
**Хорошо**: `GET /v1/coffee-machines/{id}/builtin-functions-list`
|
||||
|
||||
#### 7. Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
##### 7. Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
|
||||
**Плохо**: `begin_transition` / `stop_transition`
|
||||
— `begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации.
|
||||
@ -163,7 +163,7 @@ str_replace(needle, replace, haystack)
|
||||
|
||||
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
|
||||
|
||||
#### 8. Клиент всегда должен знать полное состояние системы
|
||||
##### 8. Клиент всегда должен знать полное состояние системы
|
||||
|
||||
Правило можно ещё сформулировать так: не заставляйте клиент гадать.
|
||||
|
||||
@ -208,7 +208,9 @@ GET /v1/comments/{id}
|
||||
```
|
||||
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
|
||||
|
||||
#### 9. Идемпотентность
|
||||
То же соображение применимо и к значениям по умолчанию. Не заставляйте клиент гадать эти значения, или хуже — хардкодить их. Возвращайте заполненные значения необязательных полей в ответе операции создания (перезаписи) сущности.
|
||||
|
||||
##### 9. Идемпотентность
|
||||
|
||||
Все операции должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
|
||||
|
||||
@ -232,7 +234,7 @@ X-Idempotency-Token: <случайная строка>
|
||||
**Альтернатива**:
|
||||
```
|
||||
// Создаёт черновик заказа
|
||||
POST /v1/orders
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
@ -244,7 +246,54 @@ PUT /v1/orders/drafts/{draft_id}
|
||||
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё `draft_id` играет роль ключа идемпотентности.
|
||||
|
||||
#### 10. Кэширование
|
||||
Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так нативно идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации:
|
||||
* клиент не получил ответ из-за сетевых проблем и пытается повторить запрос;
|
||||
* клиент ошибся, пытаясь применить конфликтующие изменения.
|
||||
|
||||
Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить.
|
||||
```
|
||||
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`. Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима (если, конечно, вы следовали правилу \#9 при разработке API) и предотвращает фрагментацию кода разрешения конфликтов.
|
||||
|
||||
Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно:
|
||||
* нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
|
||||
* клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
|
||||
|
||||
##### 10. Кэширование
|
||||
|
||||
В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием.
|
||||
|
||||
@ -287,7 +336,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
}
|
||||
```
|
||||
|
||||
#### 11. Пагинация, фильтрация и курсоры
|
||||
##### 11. Пагинация, фильтрация и курсоры
|
||||
|
||||
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
||||
|
||||
@ -300,7 +349,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
// начиная с записи с номером offset
|
||||
GET /v1/records?limit=10&offset=100
|
||||
```
|
||||
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса:
|
||||
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.
|
||||
1. Каким образом клиент узнает о появлении новых записей в начале списка?
|
||||
Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает `limit`? Представим себе ситуацию:
|
||||
* клиент обрабатывает записи в порядке поступления;
|
||||
@ -366,46 +415,47 @@ GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такое API нерасширяемо — невозможно добавить сортировку по двум или более полям.
|
||||
|
||||
**Хорошо**: в представленной постановке задача, вообще говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
|
||||
* Фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
|
||||
|
||||
```
|
||||
// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
```
|
||||
```
|
||||
// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
```
|
||||
**Вариант 1**: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
|
||||
|
||||
Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
```
|
||||
// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
```
|
||||
```
|
||||
// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
```
|
||||
|
||||
* Гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
"cursor"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
|
||||
Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки, а также появление множества событий для одной записи, если данные меняются часто.
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
"cursor"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
|
||||
#### 12. Ошибки должны быть информативными
|
||||
Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.
|
||||
|
||||
##### 12. Ошибки должны быть информативными
|
||||
|
||||
При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.
|
||||
|
||||
@ -423,6 +473,8 @@ POST /v1/coffee-machines/search
|
||||
{}
|
||||
```
|
||||
— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер все равно их проверяет, почему не вернуть описание ошибок в читаемом виде?
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
@ -452,7 +504,7 @@ POST /v1/coffee-machines/search
|
||||
```
|
||||
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
|
||||
|
||||
#### 13. Локализация и интернационализация
|
||||
##### 13. Локализация и интернационализация
|
||||
|
||||
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
|
||||
|
||||
|
Reference in New Issue
Block a user