20 Rules of Designing Program Interfaces
NB: this is an expanded and updated compilation of two previous publications.
When all entities, their responsibilities, 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 thousand 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 a 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 the ‘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. Use globally unique identifiers
It’s considered good form to use globally unique strings as entity identifiers, either semantic (i.e. “lungo” for beverage types) or random ones (i.e. UUID-4). It might turn out to be extremely useful if you need to merge data from several sources under single identifier.
In general, we tend to advice using urn-like identifiers, e.g. urn:order:<uuid>
(or just order:<uuid>
). That helps a lot in dealing with legacy systems with different identifiers attached to the same entity. Namespaces in urns help to understand quickly which identifier is used, and is there a usage mistake.
One important implication: never use increasing numbers as external identifiers. Apart from abovementioned reasons, it allows counting how many entities of each type there are in the system. You competitors will be able to calculate a precise number of orders you have each day, for example.
NB: this book often use short identifiers like “123” in code examples; that’s for reading the book on small screens convenience, do not replicate this practice in a real-world API.
9. System state must be observable by clients
This rule could be reformulated as ‘don’t make clients guess’.
Bad:
// Creates an order and returns its id
POST /v1/orders
{ … }
→
{ "order_id" }// Returns an order by its id
GET /v1/orders/{id}
// The order isn't confirmed
// and awaits checking
→ 404 Not Found
— though the operation looks to be executed successfully, the client must store order id and recurrently check GET /v1/orders/{id}
state. This pattern is bad per se, but gets even worse when we consider two cases:
- clients might lose the id, if system failure happened in between sending the request and getting the response, or if app data storage was damaged or cleansed;
- customers can’t use another device; in fact, the knowledge of orders being created is bound to a specific user agent.
In both cases customers might consider order creating failed, and make a duplicate order, with all the consequences to be blamed on you.
Better:
// Creates an order and returns it
POST /v1/orders
{ <order parameters> }
→
{
"order_id",
// The order is created in explicit
// «checking» status
"status": "checking",
…
}// Returns an order by its id
GET /v1/orders/{id}
→
{ "order_id", "status" … }// Returns all customer's orders
// in all statuses
GET /v1/users/{id}/orders
10. Avoid double negations
Bad: "dont_call_me": false
— humans are bad at perceiving double negation; make mistakes.
Better: "prohibit_calling": true
or "avoid_calling": true
— it's easier to read, though you shouldn't deceive yourself. Avoid semantical double negations, even if you've found a ‘negative’ word without ‘negative’ prefix.
Also worth mentioning, that making mistakes in de Morgan’s laws usage is even simpler. For example, if you have two flags:
GET /coffee-machines/{id}/stocks
→
{
"has_beans": true,
"has_cup": true
}
‘Coffee might be prepared’ condition would look like has_beans && has_cup
— both flags must be true. However, if you provide the negations of both flags:
{
"beans_absence": false,
"cup_absence": false
}
— then developers will have to evaluate one of !beans_absence && !cup_absence
⇔ !(beans_absence || cup_absence)
conditions, and in this transition people tend to make mistakes. Avoiding double negations helps little, and regretfully only general advice could be given: avoid the situations, when developers have to evaluate such flags.
11. Avoid implicit type conversion
This advice is opposite to the previous one, ironically. When developing APIs you frequently need to add a new optional field with non-empty default value. For example:
POST /v1/orders
{}
→
{
"contactless_delivery": true
}
New contactless_delivery
option isn't required, but its default value is true
. A question arises: how developers should discern explicit intention to abolish the option (false
) from knowing not it exists (field isn't set). They have to write something like:
if (Type(order.contactless_delivery) == 'Boolean' &&
order.contactless_delivery == false) { … }
This practice makes the code more complicated, and it’s quite easy to make mistakes, which will effectively treat the field in a quite opposite manner. Same could happen if some special values (i.e. null
or -1
) to denote value absence are used.
The universal rule to deal with such situations is to make all new Boolean flags being false by default.
Better
POST /v1/orders
{}
→
{
"force_contact_delivery": false
}
If a non-Boolean field with specially treated value absence is to be introduced, then introduce two fields.
Bad:
// Creates a user
POST /users
{ … }
→
// Users are created with a monthly
// spending limit set by default
{
…
"spending_monthly_limit_usd": "100"
}
// To cancel the limit null value is used
POST /users
{
…
"spending_monthly_limit_usd": null
}
Better
POST /users
{
// true — user explicitly cancels
// monthly spending limit
// false — limit isn't canceled
// (default value)
"abolish_spending_limit": false,
// Non-required field
// Only present if the previous flag
// is set to false
"spending_monthly_limit_usd": "100",
…
}
NB: the contradiction with the previous rule lies in the necessity of introducing ‘negative’ flags (the ‘no limit’ flag), which we had to rename to abolish_spending_limit
. Though it's a decent name for a negative flag, its semantics is still unobvious, and developers will have to read the docs. That's the way.
12. Avoid partial updates
Bad:
// Return the order state
// by its id
GET /v1/orders/123
→
{
"order_id",
"delivery_address",
"client_phone_number",
"client_phone_number_ext",
"updated_at"
}
// Partially rewrites the order
PATCH /v1/orders/123
{ "delivery_address" }
→
{ "delivery_address" }
— this approach is usually chosen to lessen request and response body sizes, plus it allows to implement collaborative editing cheaply. Both these advantages are imaginary.
In first, sparing bytes on semantic data is seldom needed in modern apps. Network packets sizes (MTU, Maximum Transmission Unit) are more than a kilobyte right now; shortening responses is useless while they’re less then a kilobyte.
Excessive network traffic usually occurs if:
- no data pagination is provided;
- no limits on field values are set;
- binary data is transmitted (graphics, audio, video, etc.)
Transferring only a subset of fields solves none of these problems, in the best case just masks them. More viable approach comprise:
- making separate endpoints for ‘heavy’ data;
- introducing pagination and field value length limits;
- stopping saving bytes in all other cases.
In second, shortening response sizes will backfire exactly with sploiling collaborative editing: one client won’t see the changes the other client has made. 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.
In third, this approach might work if you need to rewrite a field’s value. But how to unset the field, return its value to the default state? For example, how to remove client_phone_number_ext
?
In such cases special values are often being used, like null
. But as we discussed above, this is a defective practice. Another variant is prohibiting non-required fields, but that would pose considerable obstacles in a way of expanding the API.
Better: one of the following two strategies might be used.
Option #1: splitting the endpoints. Editable fields are grouped and taken out as separate endpoints. This approach also matches well against the decomposition principle we discussed in the previous chapter.
// Return the order state
// by its id
GET /v1/orders/123
→
{
"order_id",
"delivery_details": {
"address"
},
"client_details": {
"phone_number",
"phone_number_ext"
},
"updated_at"
}
// Fully rewrite order delivery options
PUT /v1/orders/123/delivery-details
{ "address" }
// Fully rewrite order customer data
PUT /v1/orders/123/client-details
{ "phone_number" }
Omitting client_phone_number_ext
in PUT client-details
request would be sufficient to remove it. This approach also helps to separate constant and calculated fields (order_id
and updated_at
) from editable ones, thus getting rid of ambiguous situations (what happens if a client tries to rewrite the updated_at
field?). You may also return the entire order
entity from PUT
endpoints (however, there should be some naming convention for that).
Option 2: design a format for atomic changes.
POST /v1/order/changes
X-Idempotency-Token: <see next paragraph>
{
"changes": [{
"type": "set",
"field": "delivery_address",
"value": <new value>
}, {
"type": "unset",
"field": "client_phone_number_ext"
}]
}
This approach is much harder to implement, but it’s the only viable method to implement collaborative editing, since it’s explicitly reflects what a user was actually doing with entity representation. With data exposed in such a format you might actually implement offline editing, when user changes are accumulated and then sent at once, while the server automatically resolves conflicts by ‘rebasing’ the changes.
13. 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_id
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 advanced optimizations possible. 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 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.
14. Avoid non-atomic operations
There is a common problem with implementing the changes list approach: what to do, if some changes were successfully applied, while others are not? The rule is simple: if you may ensure the atomicity (e.g. either apply all changes or none of them) — do it.
Bad:
// Returns a list of recipes
GET /v1/recipes
→
{
"recipes": [{
"id": "lungo",
"volume": "200ml"
}, {
"id": "latte",
"volume": "300ml"
}]
}
// Changes recipes' parameters
PATCH /v1/recipes
{
"changes": [{
"id": "lungo",
"volume": "300ml"
}, {
"id": "latte",
"volume": "-1ml"
}]
}
→ 400 Bad Request
// Re-reading the list
GET /v1/recipes
→
{
"recipes": [{
"id": "lungo",
// This value changed
"volume": "300ml"
}, {
"id": "latte",
// and this did not
"volume": "300ml"
}]
}
— there is no way how client might learn that failed operation was actually partially applied. Even if there is an indication of this fact in the response, the client still cannot tell, whether lungo volume changed because of the request, or some other client changed it.
If you can’t guarantee the atomicity of an operation, you should elaborate in details how to deal with it. There must be a separate status for each individual change.
Better:
PATCH /v1/recipes
{
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "-1ml"
}]
}
// You may actually return
// a ‘partial success’ status
// if the protocol allows it
→ 200 OK
{
"changes": [{
"change_id",
"occurred_at",
"recipe_id": "lungo",
"status": "success"
}, {
"change_id",
"occurred_at",
"recipe_id": "latte",
"status": "fail",
"error"
}]
}
Here:
change_id
is a unique identifier of each atomic change;occurred_at
is a moment of time when the change was actually applied;error
field contains the error data related to the specific change.
Might be of use:
- introducing
sequence_id
parameters in the request to guarantee execution order and to align item order in response with the requested one; - expose a separate
/changes-history
endpoint for clients to get the history of applied changes even if the app crashed while getting partial success response or there was a network timeout.
Non-atomic changes are undesirable because they erode the idempotency concept. Let’s take a look at the example:
PATCH /v1/recipes
{
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
}
→ 200 OK
{
"changes": [{
…
"status": "success"
}, {
…
"status": "fail",
"error": {
"reason": "too_many_requests"
}
}]
}
Imagine the client failed to get a response because of a network error, and it repeats the request:
PATCH /v1/recipes
{
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
}
→ 200 OK
{
"changes": [{
…
"status": "success"
}, {
…
"status": "success",
}]
}
To the client, everything looks normal: changes were applied, and the last response got is always actual. But the resource state after the first request was inherently different from the resource state after the second one, which contradicts the very definition of ‘idempotency’.
It would be more correct if the server did nothing upon getting the second request with the same idempotency token, and returned the same status list breakdown. But it implies that storing these breakdowns must be implemented.
Just in case: nested operations must be idempotent themselves. If they are not, separate idempotency tokens must be generated for each nested operation.
15. Specify caching policies
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"
}
}
}
16. 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.
- 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 thelimit
? 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.
- 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. - What cache parameters to set for this endpoint?
None could be set: repeating the request with the samelimit
andoffset
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.
NB: some sources discourage this approach because in this case user can’t see a list of all pages and can’t choose an arbitrary one. We should note here that:
- such a case (pages list and page selection) exists if we deal with user interfaces; we could hardly imagine a program interface which needs to provide an access to random data pages;
- if we still talk about an API to some application, which has a ‘paging’ user control, then a proper approach would be to prepare ‘paging’ data on server, including generating links to pages;
- cursor-based solution doesn’t prohibit using
offset
/limit
; nothing could stop us from creating a dual interface, which might serve bothGET /items?cursor=…
andGET /items?offset=…&limit=…
requests; - finally, if there is a necessity to provide an access to arbitrary pages in user interface, we should ask ourselves a question, which problem is being solved that way; probably, users use this functionality to find something: a specific element on the list, or the position they ended while working with the list last time; probably, we should provide more convenient controls to solve those tasks than accessing data pages by their indexes.
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.
17. 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": -90,
"max": 90
},
"message":
"'position.latitude' value must fall within [-90, 90] interval"
}
]
}
}
It is also a good practice to return all detectable errors at once to spare developers’ time.
18. Maintain a proper error sequence
In first, always return unresolvable errors before the resolvable ones:
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 409 Conflict
{
"reason": "offer_expired"
}
// Request repeats
// with the renewed offer
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 400 Bad Request
{
"reason": "recipe_unknown"
}
— what was the point of renewing the offer if the order cannot be created anyway?
In second, maintain such a sequence of unresolvable errors which leads to a minimal amount of customers’ and developers’ irritation.
Bad:
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.10" }]
}
→
409 Conflict
{
"reason": "price_changed",
"details": [{ "item_id": "123", "actual_price": "0.20" }]
}
// Request repeats
// with an actual price
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.20" }]
}
→
409 Conflict
{
"reason": "order_limit_exceeded",
"localized_message": "Order limit exceeded"
}
— what was the point of showing the price changed dialog, if the user still can’t make an order, even if the price is right? When one of the concurrent orders finishes, and the user is able to commit another one, prices, items availability, and other order parameters will likely need another correction.
In third, draw a chart: which error resolution might lead to the emergence of another one. Otherwise you might eventually return the same error several times, or worse, make a cycle of errors.
// Create an order
// with a payed delivery
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "1000.00",
"total": "10000.00"
}
→ 409 Conflict
// Error: if the order sum
// is more than 9000 tögrögs,
// delivery must be free
{
"reason": "delivery_is_free"
}
// Create an order
// with a free delivery
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "0.00",
"total": "9000.00"
}
→ 409 Conflict
// Error: munimal order sum
// is 10000 tögrögs
{
"reason": "below_minimal_sum",
"currency_code": "MNT",
"minimal_sum": "10000.00"
}
You may note that in this setup the error can’t resolved in one step: this situation must be elaborated over, and either order calculation parameters must be changed (discounts should not be counted against the minimal order sum), or a special type of error must be introduced.
19. No results is a result
If a server processed a request correctly and no exceptional situation occurred — there must be no error. Regretfully, an antipattern is widespread — of throwing errors when zero results are found.
Bad
POST /search
{
"query": "lungo",
"location": <customer's location>
}
→ 404 Not Found
{
"localized_message":
"No one makes lungo nearby"
}
4xx
statuses imply that a client made a mistake. But no mistakes were made by either a customer or a developer: a client cannot know whether the lungo is served in this location beforehand.
Better:
POST /search
{
"query": "lungo",
"location": <customer's location>
}
→ 200 OK
{
"results": []
}
This rule might be reduced to: if an array is the result of the operation, than emptiness of that array is not a mistake, but a correct response. (Of course, if empty array is acceptable semantically; empty coordinates array is a mistake for sure.)
20. 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 rule #19: 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.
This is the content of the Chapter 11 of the book I’m writing. The work continues on Github. I’d appreciate if you share it on reddit, for I personally can’t do that.