mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-05-25 22:08:06 +02:00
style fix
This commit is contained in:
parent
408a5ad9d6
commit
3678ea946c
1544
docs/API.en.html
1544
docs/API.en.html
File diff suppressed because it is too large
Load Diff
@ -101,7 +101,8 @@ A naïve approach to this situation is to design an interim abstraction level as
|
||||
"status": "executing",
|
||||
"operations": [
|
||||
// description of commands
|
||||
// being executed on a physical coffee machine
|
||||
// being executed on
|
||||
// a physical coffee machine
|
||||
]
|
||||
}
|
||||
…
|
||||
@ -131,7 +132,8 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
}
|
||||
```
|
||||
```
|
||||
// Starts an execution of a specified program
|
||||
// Starts an execution
|
||||
// of a specified program
|
||||
// and returns execution status
|
||||
POST /execute
|
||||
{
|
||||
@ -154,7 +156,8 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
```
|
||||
```
|
||||
// Returns execution status.
|
||||
// The format is the same as in `POST /execute`
|
||||
// The format is the same
|
||||
// as in the `POST /execute` method
|
||||
GET /execution/status
|
||||
```
|
||||
|
||||
@ -174,9 +177,13 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
// * pour_water
|
||||
// * discard_cup
|
||||
"type": "set_cup",
|
||||
// Arguments available to each operation.
|
||||
// To keep it simple, let's limit these to one:
|
||||
// * volume — a volume of a cup, coffee, or water
|
||||
// Arguments available
|
||||
// to each operation.
|
||||
// To keep it simple,
|
||||
// let's limit these to one:
|
||||
// * volume
|
||||
// — a volume of a cup,
|
||||
// coffee, or water
|
||||
"arguments": ["volume"]
|
||||
},
|
||||
…
|
||||
@ -189,7 +196,10 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
POST /functions
|
||||
{
|
||||
"type": "set_cup",
|
||||
"arguments": [{ "name": "volume", "value": "300ml" }]
|
||||
"arguments": [{
|
||||
"name": "volume",
|
||||
"value": "300ml"
|
||||
}]
|
||||
}
|
||||
```
|
||||
```
|
||||
@ -276,7 +286,7 @@ POST /v1/programs/{id}/run
|
||||
|
||||
Please note that knowing the coffee machine API kind isn't required at all; that's why we're making abstractions! We could possibly make interfaces more specific, implementing different `run` and `match` endpoints for different coffee machines:
|
||||
* `POST /v1/program-matcher/{api_type}`
|
||||
* `POST /v1/programs/{api_type}/{program_id}/run`
|
||||
* `POST /v1/{api_type}/programs/{id}/run`
|
||||
|
||||
This approach has some benefits, like the possibility to provide different sets of parameters, specific to the API kind. But we see no need for such fragmentation. `run` method handler is capable of extracting all the program metadata and performing one of two actions:
|
||||
* call `POST /execute` physical API method, passing internal program identifier — for the first API kind;
|
||||
@ -286,7 +296,11 @@ Out of general concerns runtime level for the second-kind API will be private, s
|
||||
|
||||
```
|
||||
POST /v1/runtimes
|
||||
{ "coffee_machine", "program", "parameters" }
|
||||
{
|
||||
"coffee_machine",
|
||||
"program",
|
||||
"parameters"
|
||||
}
|
||||
→
|
||||
{ "runtime_id", "state" }
|
||||
```
|
||||
@ -315,14 +329,20 @@ And the `state` like that:
|
||||
// * "finished" — all operations done
|
||||
"status": "ready_waiting",
|
||||
// Command being currently executed.
|
||||
// Similar to line numbers in computer programs
|
||||
// Similar to line numbers
|
||||
// in computer programs
|
||||
"command_sequence_id",
|
||||
// How the execution concluded:
|
||||
// * "success" — beverage prepared and taken
|
||||
// * "terminated" — execution aborted
|
||||
// * "technical_error" — preparation error
|
||||
// * "waiting_time_exceeded" — beverage prepared,
|
||||
// but not taken; timed out then disposed
|
||||
// * "success"
|
||||
// — beverage prepared and taken
|
||||
// * "terminated"
|
||||
// — execution aborted
|
||||
// * "technical_error"
|
||||
// — preparation error
|
||||
// * "waiting_time_exceeded"
|
||||
// — beverage prepared,
|
||||
// but not taken;
|
||||
// timed out then disposed
|
||||
"resolution": "success",
|
||||
// All variables values,
|
||||
// including sensors state
|
||||
|
@ -47,16 +47,25 @@ Obviously, the first step is offering a choice to a user, to make them point out
|
||||
If we try writing pseudocode, we will get something like that:
|
||||
```
|
||||
// Retrieve all possible recipes
|
||||
let recipes = api.getRecipes();
|
||||
// Retrieve a list of all available coffee machines
|
||||
let coffeeMachines = api.getCoffeeMachines();
|
||||
let recipes =
|
||||
api.getRecipes();
|
||||
// Retrieve a list of
|
||||
// all available coffee machines
|
||||
let coffeeMachines =
|
||||
api.getCoffeeMachines();
|
||||
// Build a spatial index
|
||||
let coffeeMachineRecipesIndex = buildGeoIndex(recipes, coffeeMachines);
|
||||
// Select coffee machines matching user's needs
|
||||
let matchingCoffeeMachines = coffeeMachineRecipesIndex.query(
|
||||
parameters,
|
||||
{ "sort_by": "distance" }
|
||||
);
|
||||
let coffeeMachineRecipesIndex =
|
||||
buildGeoIndex(
|
||||
recipes,
|
||||
coffeeMachines
|
||||
);
|
||||
// Select coffee machines
|
||||
// matching user's needs
|
||||
let matchingCoffeeMachines =
|
||||
coffeeMachineRecipesIndex.query(
|
||||
parameters,
|
||||
{ "sort_by": "distance" }
|
||||
);
|
||||
// Finally, show offers to user
|
||||
app.display(coffeeMachines);
|
||||
```
|
||||
@ -82,7 +91,12 @@ POST /v1/offers/search
|
||||
→
|
||||
{
|
||||
"results": [
|
||||
{ "coffee_machine", "place", "distance", "offer" }
|
||||
{
|
||||
"coffee_machine",
|
||||
"place",
|
||||
"distance",
|
||||
"offer"
|
||||
}
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
@ -119,12 +133,15 @@ One solution is to provide a special identifier to an offer. This identifier mus
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"coffee_machine", "place", "distance",
|
||||
"coffee_machine",
|
||||
"place",
|
||||
"distance",
|
||||
"offer": {
|
||||
"id",
|
||||
"price",
|
||||
"currency_code",
|
||||
// Date and time when the offer expires
|
||||
// Date and time
|
||||
// when the offer expires
|
||||
"valid_until"
|
||||
}
|
||||
}
|
||||
@ -142,11 +159,9 @@ And one more step towards making developers' life easier: how an ‘invalid pric
|
||||
|
||||
```
|
||||
POST /v1/orders
|
||||
{ … "offer_id" …}
|
||||
{ "offer_id", … }
|
||||
→ 409 Conflict
|
||||
{
|
||||
"message": "Invalid price"
|
||||
}
|
||||
{ "message": "Invalid price" }
|
||||
```
|
||||
|
||||
Formally speaking, this error response is enough: users get the ‘Invalid price’ message, and they have to repeat the order. But from a UX point of view that would be a horrible decision: the user hasn't made any mistakes, and this message isn't helpful at all.
|
||||
@ -172,7 +187,8 @@ In our case, the price mismatch error should look like this:
|
||||
// Error kind
|
||||
"reason": "offer_invalid",
|
||||
"localized_message":
|
||||
"Something goes wrong. Try restarting the app."
|
||||
"Something goes wrong.⮠
|
||||
Try restarting the app."
|
||||
"details": {
|
||||
// What's wrong exactly?
|
||||
// Which validity checks failed?
|
||||
@ -198,40 +214,37 @@ The only possible method of overcoming this law is decomposition. Entities shoul
|
||||
Let's take a look at a simple example: what the coffee machine search function returns. To ensure an adequate UX of the app, quite bulky datasets are required.
|
||||
```
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"coffee_machine_id",
|
||||
"coffee_machine_type": "drip_coffee_maker",
|
||||
"coffee_machine_brand",
|
||||
"place_name": "The Chamomile",
|
||||
// Coordinates of a place
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
"place_open_now",
|
||||
"working_hours",
|
||||
// Walking route parameters
|
||||
"walking_distance",
|
||||
"walking_time",
|
||||
// How to find the place
|
||||
"place_location_tip",
|
||||
"offers": [
|
||||
{
|
||||
"recipe": "lungo",
|
||||
"recipe_name": "Our brand new Lungo®™",
|
||||
"recipe_description",
|
||||
"volume": "800ml",
|
||||
"offer_id",
|
||||
"offer_valid_until",
|
||||
"localized_price": "Just $19 for a large coffee cup",
|
||||
"price": "19.00",
|
||||
"currency_code": "USD",
|
||||
"estimated_waiting_time": "20s"
|
||||
},
|
||||
…
|
||||
]
|
||||
},
|
||||
…
|
||||
]
|
||||
"results": [{
|
||||
"coffee_machine_id",
|
||||
"coffee_machine_type":
|
||||
"drip_coffee_maker",
|
||||
"coffee_machine_brand",
|
||||
"place_name": "The Chamomile",
|
||||
// Coordinates of a place
|
||||
"place_location_latitude",
|
||||
"place_location_longitude",
|
||||
"place_open_now",
|
||||
"working_hours",
|
||||
// Walking route parameters
|
||||
"walking_distance",
|
||||
"walking_time",
|
||||
// How to find the place
|
||||
"place_location_tip",
|
||||
"offers": [{
|
||||
"recipe": "lungo",
|
||||
"recipe_name":
|
||||
"Our brand new Lungo®™",
|
||||
"recipe_description",
|
||||
"volume": "800ml",
|
||||
"offer_id",
|
||||
"offer_valid_until",
|
||||
"localized_price":
|
||||
"Just $19 for a large coffee cup",
|
||||
"price": "19.00",
|
||||
"currency_code": "USD",
|
||||
"estimated_waiting_time": "20s"
|
||||
}, …]
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
@ -256,16 +269,30 @@ Let's try to group it together:
|
||||
// Coffee machine properties
|
||||
"coffee-machine": { "id", "brand", "type" },
|
||||
// Route data
|
||||
"route": { "distance", "duration", "location_tip" },
|
||||
"route": {
|
||||
"distance",
|
||||
"duration",
|
||||
"location_tip"
|
||||
},
|
||||
"offers": [{
|
||||
// Recipe data
|
||||
"recipe": { "id", "name", "description" },
|
||||
"recipe": {
|
||||
"id",
|
||||
"name",
|
||||
"description"
|
||||
},
|
||||
// Recipe specific options
|
||||
"options": { "volume" },
|
||||
"options":
|
||||
{ "volume" },
|
||||
// Offer metadata
|
||||
"offer": { "id", "valid_until" },
|
||||
"offer":
|
||||
{ "id", "valid_until" },
|
||||
// Pricing
|
||||
"pricing": { "currency_code", "price", "localized_price" },
|
||||
"pricing": {
|
||||
"currency_code",
|
||||
"price",
|
||||
"localized_price"
|
||||
},
|
||||
"estimated_waiting_time"
|
||||
}, …]
|
||||
}, …]
|
||||
|
@ -16,7 +16,7 @@ It is important to understand that you always can introduce concepts of your own
|
||||
|
||||
#### Ensuring readability and consistency
|
||||
|
||||
The most important task for the API vendor is to make code written atop of the API by third-party developers easily readable and maintainable. Remember, that the law of large numbers works against you: if some concept or a signature might be treated wrong, they will be inevitably treated wrong by a number of partners, and this number will be increasing with the API popularity growth.
|
||||
The most important task for the API vendor is to make code written by third-party developers atop of the API easily readable and maintainable. Remember that the law of large numbers works against you: if some concept or a signature might be treated wrong, they will be inevitably treated wrong by a number of partners, and this number will be increasing with the API popularity growth.
|
||||
|
||||
##### Explicit is always better than implicit
|
||||
|
||||
@ -142,7 +142,8 @@ If an entity name is a polysemantic term itself, which could confuse developers,
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a list of coffee machine builtin functions
|
||||
// Returns a list of
|
||||
// coffee machine builtin functions
|
||||
GET /coffee-machines/{id}/functions
|
||||
```
|
||||
Word ‘function’ is many-valued. It could mean built-in functions, but also ‘a piece of code’, or a state (machine is functioning).
|
||||
@ -164,7 +165,8 @@ strpos(haystack, needle)
|
||||
```
|
||||
```
|
||||
// Replace all occurrences
|
||||
// of the search string with the replacement string
|
||||
// of the search string
|
||||
// with the replacement string
|
||||
str_replace(needle, replace, haystack)
|
||||
```
|
||||
Several rules are violated:
|
||||
@ -217,8 +219,12 @@ POST /v1/orders
|
||||
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (field isn't set). They have to write something like:
|
||||
|
||||
```
|
||||
if (Type(order.contactless_delivery) == 'Boolean' &&
|
||||
order.contactless_delivery == false) { … }
|
||||
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 an opposite manner. The same could happen if some special values (i.e. `null` or `-1`) to denote value absence are used.
|
||||
@ -348,7 +354,8 @@ POST /v1/coffee-machines/search
|
||||
},
|
||||
{
|
||||
"field": "position.latitude",
|
||||
"error_type": "constraint_violation",
|
||||
"error_type":
|
||||
"constraint_violation",
|
||||
"constraints": {
|
||||
"min": -90,
|
||||
"max": 90
|
||||
@ -476,7 +483,7 @@ You may note that in this setup the error can't be resolved in one step: this si
|
||||
|
||||
#### Developing machine-readable interfaces
|
||||
|
||||
In pursuit of the API clarity for humans, we frequently forget that it's not developers themselves who interact with the endpoints, but the code they've written. Many concepts that work well with the user interface, are badly suited for the program ones: specifically, developers can't make decisions based on textual information, and they can't ‘refresh’ the state in case of some confusing situation.
|
||||
In pursuit of the API clarity for humans, we frequently forget that it's not developers themselves who interact with the endpoints, but the code they've written. Many concepts that work well with user interfaces, are badly suited for the program ones: specifically, developers can't make decisions based on textual information, and they can't ‘refresh’ the state in case of some confusing situation.
|
||||
|
||||
##### The system state must be observable by clients
|
||||
|
||||
@ -639,12 +646,14 @@ At the first glance, this is the most standard way of organizing the pagination
|
||||
// sorted by creation date
|
||||
// starting with a record with an identifier
|
||||
// following the specified one
|
||||
GET /v1/records?older_than={record_id}&limit=10
|
||||
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
|
||||
GET /v1/records⮠
|
||||
?newer_than={record_id}&limit=10
|
||||
```
|
||||
|
||||
With the pagination organized like that, clients never bother about records 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.
|
||||
@ -686,7 +695,8 @@ There are several approaches to implementing cursors (for example, making a sing
|
||||
**Bad**:
|
||||
```
|
||||
// Returns a limited number of records
|
||||
// sorted by a specified field in a specified order
|
||||
// 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⮠
|
||||
@ -703,9 +713,10 @@ Sorting by the date of modification usually means that data might be modified. I
|
||||
// Creates a view based on the parameters passed
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
sort_by: [{
|
||||
"field": "date_modified",
|
||||
"order": "desc"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
@ -713,7 +724,8 @@ POST /v1/record-views
|
||||
|
||||
```
|
||||
// Returns a portion of the view
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
GET /v1/record-views/{id}⮠
|
||||
?cursor={cursor}
|
||||
```
|
||||
|
||||
Since the produced view is immutable, 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.
|
||||
@ -747,7 +759,7 @@ If the protocol allows, fractional numbers with fixed precision (like money sums
|
||||
|
||||
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.
|
||||
|
||||
If conversion to float number will certainly lead to losing the precision (let's say if we translate ‘20 minutes’ into hours as a decimal fraction), it's better to either stick to a fully precise format (e.g. opt for `00:20` instead of `0.33333…`) or to provide an SDK to work with this data, or as a last resort describe the rounding principles in the documentation.
|
||||
If conversion to a float number will certainly lead to losing the precision (let's say if we translate ‘20 minutes’ into hours as a decimal fraction), it's better to either stick to a fully precise format (e.g. opt for `00:20` instead of `0.33333…`) or to provide an SDK to work with this data, or as a last resort describe the rounding principles in the documentation.
|
||||
|
||||
##### All API operations must be idempotent
|
||||
|
||||
@ -989,9 +1001,9 @@ Just in case: nested operations must be idempotent themselves. If they are not,
|
||||
|
||||
If the author of this book was given a dollar each time he had to implement the additional security protocol invented by someone, he would already retire. The API developers' passion for signing request parameters or introducing complex schemes of exchanging passwords for tokens is as obvious as meaningless.
|
||||
|
||||
**First**, almost all security-enhancing procedures for every kind of operation *are already developed*. There is no need to re-thinking them anew, just take the existing approach and implement it. No self-invented algorithm for request signature checking provides the same level of preventing [Man-in-the-Middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) as a TLS connection with mutual certificate pinning.
|
||||
**First**, almost all security-enhancing procedures for every kind of operation *are already invented*. There is no need to re-think them anew; just take the existing approach and implement it. No self-invented algorithm for request signature checking provides the same level of preventing [Man-in-the-Middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) as a TLS connection with mutual certificate pinning.
|
||||
|
||||
**Second**, it's quite presumptuously (and dangerous) to assume you're an expert in security. New attack vectors come every day, and being aware of all the actual threats is a full-day job. If you do something different during workdays, the security system designed by you will contain vulnerabilities that you have never heard about — for example, your password-checking algorithm might be susceptible to the [timing attack]((https://en.wikipedia.org/wiki/Timing_attack), and your web-server, to the [request splitting attack](https://capec.mitre.org/data/definitions/105.html).
|
||||
**Second**, it's quite presumptuous (and dangerous) to assume you're an expert in security. New attack vectors come every day, and being aware of all the actual threats is a full-day job. If you do something different during workdays, the security system designed by you will contain vulnerabilities that you have never heard about — for example, your password-checking algorithm might be susceptible to the [timing attack](https://en.wikipedia.org/wiki/Timing_attack), and your web-server, to the [request splitting attack](https://capec.mitre.org/data/definitions/105.html).
|
||||
|
||||
##### Explicitly declare technical restrictions
|
||||
|
||||
@ -1014,14 +1026,14 @@ If the first two problems are solved by applying pure technical measures (see th
|
||||
|
||||
* do not rely too heavily on asynchronous interfaces;
|
||||
* on one side, they allow tackling many technical problems related to the API performance, which, in turn, allows for maintaining backwards compatibility: if some method is asynchronous from the very beginning, the latencies and the data consistency models might be easily tuned if needed;
|
||||
* from the other side, the number of requests clients generate becomes hardly predicable, as a client needs to make some number of attempts to get the result which might not be known in advance;
|
||||
* from the other side, the number of requests clients generate becomes hardly predicable, as a client in order to retrieve a result needs to make some unpredictable number of attempts;
|
||||
|
||||
* declare an explicit retry policy (for example, with the `Retry-After` header);
|
||||
* yes, some partners will ignore it as developers will get too lazy to implement it, but some will not (especially if you provide the SDKs as well);
|
||||
|
||||
* if you expect a significant number of asynchronous operations in the API, allow developers to choose between the poll model (clients make repeated requests to an endpoint to check the asynchronous procedure status) and the push model (the server notifies clients of status changes, for example, via webhooks or server-push mechanics);
|
||||
|
||||
* if some entity comprises both ‘lightweight’ data (let's say, the name and the description of the recipe) and ‘heavy’ data (let's say, the promo picture of the beverage which might easily be a hundred times larger than text fields) it's better to split endpoints and pass only a reference to the ‘heavy’ data (a link to the image, in our case) — this will allow at least setting different cache policies for different kinds of data.
|
||||
* if some entity comprises both ‘lightweight’ data (let's say, the name and the description of the recipe) and ‘heavy’ data (let's say, the promo picture of the beverage which might easily be a hundred times larger than the text fields), it's better to split endpoints and pass only a reference to the ‘heavy’ data (a link to the image, in our case) — this will allow at least setting different cache policies for different kinds of data.
|
||||
|
||||
As a useful exercise, try modeling the typical lifecycle of a partner's app's main functionality (for example, making a single order) to count the number of requests and the amount of traffic that it takes.
|
||||
|
||||
@ -1063,20 +1075,20 @@ PATCH /v1/orders/{id}
|
||||
|
||||
This signature is bad per se as it's unreadable. What does `null` as the first array element mean — is it a deletion of an element or an indication that no actions are needed towards it? What happens with the fields that are not stated in the update operation body (`delivery_address`, `milk_type`) — will they be reset to defaults, or stay unchanged?
|
||||
|
||||
The nastiest part is that whatever option you choose, the number of problems will only multiply further. Let's say we agreed that the `{ "items":[null, {…}] }` statement means that the first element of the array is left untouched, e.g. no changes are needed. Then, how shall we encode its deletion? Invent one more ‘magical’ value meaning ‘remove it’? Similarly, if the fields that are not explicitly mentioned retain their value — how to reset them to defaults?
|
||||
The nastiest part is that whatever option you choose, the number of problems will only multiply further. Let's say we agreed that the `{"items":[null, {…}]}` statement means that the first element of the array is left untouched, e.g. no changes are needed. Then, how shall we encode its deletion? Invent one more ‘magical’ value meaning ‘remove it’? Similarly, if the fields that are not explicitly mentioned retain their value — how to reset them to defaults?
|
||||
|
||||
**The simple solution** is always rewriting the data entirely, e.g. to require passing the entire object, to replace the current state with it, and to return the full state as a result of the operation. This obvious solution is frequently rejected with the following reasoning:
|
||||
* increased requests sizes and therefore, the amount of traffic;
|
||||
* the necessity to detect which fields are changed (for instance, to generate proper state change events for subscribers);
|
||||
* the inability of cooperative editing when two clients are editing different object properties simultaneously.
|
||||
* the inability of organizing cooperative editing when two clients are editing different object properties simultaneously.
|
||||
|
||||
However, if we take a deeper look, all three disadvantages are actually imaginative:
|
||||
However, if we take a deeper look, all these disadvantages are actually imaginative:
|
||||
* the reasons for increasing the amount of traffic were described in the previous paragraphs, and serving extra fields is not one of them (and if it is, it's rather a rationale to decompose the endpoint);
|
||||
* the concept of sending only those fields that changed is in fact about shifting the responsibility of change detection to clients;
|
||||
* it doesn't make the task any easier, and also introduces the problem of client code fragmentation as several independent implementations of the change detection algorithm will occur;
|
||||
* furthermore, the existence of the client algorithm for finding the fields that changed doesn't mean that the server might skip implementing it as client developers might make mistakes or simply spare the effort and always send all the fields;
|
||||
* finally, this naïve approach to organizing collaborative editing works only with transitive changes (e.g. the final result does not depend on the order in which the operations were executed), and in our case, it's already not true: deletion of the first element and editing the second element are non-transitive;
|
||||
* often, in addition to sparing traffic on requests, the same concept is applied to responses as well, returning empty bodies for modifying operations; thus two clients making simultaneous edits do not see one another's changes.
|
||||
* finally, this naïve approach to organizing collaborative editing works only with transitive changes (e.g. if the final result does not depend on the order in which the operations were executed), and in our case, it's already not true: deletion of the first element and editing the second element are non-transitive;
|
||||
* often, in addition to sparing traffic on requests, the same concept is applied to responses as well, e.g. no data is returned for modifying operations; thus two clients making simultaneous edits do not see one another's changes.
|
||||
|
||||
**Better**: split the functionality. This also correlates well with the [decomposition principle](#chapter-10) we've discussed in the previous chapter.
|
||||
|
||||
@ -1137,13 +1149,13 @@ PUT /v1/orders/{id}/items/{item_id}
|
||||
DELETE /v1/orders/{id}/items/{item_id}
|
||||
```
|
||||
|
||||
Now to reset `volume` to its default value it's enough to omit it in the `PUT /items/{item_id}` request body. Also, the operations of deleting one item while simultaneously modifying another one are now idempotent.
|
||||
Now to reset `volume` to its default value it's enough to omit it in the `PUT /items/{item_id}` request body. Also, the operations of deleting one item while simultaneously modifying another one are now transitive.
|
||||
|
||||
This approach also allows for separating non-mutable and calculated fields (in our case, `created_at` and `status`) from editable ones without creating ambiguous situations (what should happen if a client tries to change the `created_at` field?)
|
||||
|
||||
It is also possible to return full order objects from `PUT` endpoints instead of just the sub-resource that was overwritten (though it requires some naming convention).
|
||||
|
||||
**NB**: while decomposing endpoints, the idea of splitting them into mutable and non-mutable data often looks tempting. Then it's possible to make the latter infinitely cacheable and never bother with pagination ordering and update format consistency. The plan looks solid on paper, but with the API expansion, it frequently happens that immutable fields eventually cease being immutable, and the entire concept not only stops working properly but even starts looking like a design flaw. We would rather recommend designating data as immutable in one of the two cases: (1) making them editable will really mean breaking backwards compatibility, or (2) the link to the resource (for example, an image) is served via the API as well, and you do possess the capability of making those links persistent (e.g. you might generate a new link to the image instead of rewriting the contents of the old one).
|
||||
**NB**: while decomposing endpoints, the idea of splitting them into mutable and non-mutable data often looks tempting. It makes possible to mark the latter as infinitely cacheable and never bother about pagination ordering and update format consistency. The plan looks solid on paper, but with the API expansion, it frequently happens that immutable fields eventually cease being immutable, and the entire concept not only stops working properly but even starts looking like a design flaw. We would rather recommend designating data as immutable in one of the two cases: (1) making them editable will really mean breaking backwards compatibility, or (2) the link to the resource (for example, an image) is served via the API as well, and you do possess the capability of making those links persistent (e.g. you might generate a new link to the image instead of rewriting the contents of the old one).
|
||||
|
||||
**Even better**: design a format for atomic changes.
|
||||
|
||||
@ -1182,11 +1194,11 @@ One important implication: **never use increasing numbers as external identifier
|
||||
|
||||
##### Stipulate future restrictions
|
||||
|
||||
With the API popularity growth, it will inevitably become necessary to introduce technical means of preventing illicit API usage, such as displaying captcha, setting honeypots, raising the ‘too many requests’ exceptions, installing anti-DDoS proxies, etc. All these things cannot be done if the corresponding errors and messages were not described in the docs from the very beginning.
|
||||
With the API popularity growth, it will inevitably become necessary to introduce technical means of preventing illicit API usage, such as displaying captchas, setting honeypots, raising the ‘too many requests’ exceptions, installing anti-DDoS proxies, etc. All these things cannot be done if the corresponding errors and messages were not described in the docs from the very beginning.
|
||||
|
||||
You are not obliged to actually generate those exceptions, but you might stipulate this possibility in the terms of service. For example, you might describe the `429 Too Many Requests` error or captcha redirect, but implement the functionality when it's actually needed.
|
||||
|
||||
It is extremely important to leave room for multi-factored authentication (such as TOTP, SMS, or 3D-secure-like technologies) in case it's possible to make payments through the API. In this case, it's a must-have from the very beginning.
|
||||
It is extremely important to leave room for multi-factored authentication (such as TOTP, SMS, or 3D-secure-like technologies) if it's possible to make payments through the API. In this case, it's a must-have from the very beginning.
|
||||
|
||||
##### Don't provide endpoints for mass downloading of sensitive data
|
||||
|
||||
|
@ -18,20 +18,33 @@ POST /v1/offers/search
|
||||
{
|
||||
"results": [{
|
||||
// Place data
|
||||
"place": { "name", "location" },
|
||||
"place":
|
||||
{ "name", "location" },
|
||||
// Coffee machine properties
|
||||
"coffee-machine": { "id", "brand", "type" },
|
||||
"coffee-machine":
|
||||
{ "id", "brand", "type" },
|
||||
// Route data
|
||||
"route": { "distance", "duration", "location_tip" },
|
||||
"route": {
|
||||
"distance",
|
||||
"duration",
|
||||
"location_tip"
|
||||
},
|
||||
"offers": [{
|
||||
// Recipe data
|
||||
"recipe": { "id", "name", "description" },
|
||||
"recipe":
|
||||
{ "id", "name", "description" },
|
||||
// Recipe specific options
|
||||
"options": { "volume" },
|
||||
"options":
|
||||
{ "volume" },
|
||||
// Offer metadata
|
||||
"offer": { "id", "valid_until" },
|
||||
"offer":
|
||||
{ "id", "valid_until" },
|
||||
// Pricing
|
||||
"pricing": { "currency_code", "price", "localized_price" },
|
||||
"pricing": {
|
||||
"currency_code",
|
||||
"price",
|
||||
"localized_price"
|
||||
},
|
||||
"estimated_waiting_time"
|
||||
}, …]
|
||||
}, …],
|
||||
@ -52,7 +65,11 @@ GET /v1/recipes?cursor=<cursor>
|
||||
// Returns the recipe by its id
|
||||
GET /v1/recipes/{id}
|
||||
→
|
||||
{ "recipe_id", "name", "description" }
|
||||
{
|
||||
"recipe_id",
|
||||
"name",
|
||||
"description"
|
||||
}
|
||||
```
|
||||
|
||||
##### Working with orders
|
||||
@ -144,7 +161,11 @@ POST /v1/runs/{id}/cancel
|
||||
```
|
||||
// Creates a new runtime
|
||||
POST /v1/runtimes
|
||||
{ "coffee_machine_id", "program_id", "parameters" }
|
||||
{
|
||||
"coffee_machine_id",
|
||||
"program_id",
|
||||
"parameters"
|
||||
}
|
||||
→
|
||||
{ "runtime_id", "state" }
|
||||
```
|
||||
|
@ -83,11 +83,16 @@ Of course, the developers of the language standard can afford such tricks; but y
|
||||
|
||||
```
|
||||
// Animates object's width,
|
||||
// beginning with first value, ending with second
|
||||
// beginning with first value,
|
||||
// ending with second
|
||||
// in a specified time period
|
||||
object.animateWidth('100px', '500px', '1s');
|
||||
object.animateWidth(
|
||||
'100px', '500px', '1s'
|
||||
);
|
||||
// Observes object's width changes
|
||||
object.observe('widthchange', observerFunction);
|
||||
object.observe(
|
||||
'widthchange', observerFunction
|
||||
);
|
||||
```
|
||||
|
||||
A question arises: how frequently and at what time fractions the `observerFunction` will be called? Let's assume in the first SDK version we emulated step-by-step animation at 10 frames per second: then the `observerFunction` will be called 10 times, getting values '140px', '180px', etc., up to '500px'. But then in a new API version, we switched to implementing both functions atop of a system's native functionality — and so you simply don't know, when and how frequently the `observerFunction` will be called.
|
||||
|
@ -65,7 +65,8 @@ More specifically, if we talk about changing available order options, we should
|
||||
|
||||
2. Add new ‘with-options’ endpoint:
|
||||
```
|
||||
PUT /v1/partners/{partner_id}/coffee-machines-with-options
|
||||
PUT /v1/partners/{partner_id}⮠
|
||||
/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"id",
|
||||
|
@ -179,8 +179,14 @@ POST /v1/recipe-builder
|
||||
// Add all the formatters needed
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
{
|
||||
"language_code",
|
||||
"template"
|
||||
}, {
|
||||
"language_code",
|
||||
"country_code",
|
||||
"template"
|
||||
}
|
||||
]
|
||||
},
|
||||
// Other actions needed to be done
|
||||
@ -203,7 +209,8 @@ POST /v1/recipes/custom
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
"id":
|
||||
"my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -91,21 +91,28 @@ There are different techniques to organize this data flow, but, basically, we al
|
||||
```
|
||||
/* Partner's implementation of the program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
// When the cup is ready for takeout,
|
||||
// emit corresponding event
|
||||
// for higher-level entity to catch it
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
registerProgramRunHandler(
|
||||
apiType,
|
||||
(program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
// When the cup is ready for takeout,
|
||||
// emit corresponding event for
|
||||
// a higher-level entity to catch it
|
||||
execution.context
|
||||
.emit('takeout_ready');
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
@ -139,30 +146,38 @@ It becomes obvious from what was said above that two-way weak coupling means a s
|
||||
```
|
||||
/* Partner's implementation of program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
/* When the order is ready for takeout,
|
||||
signalize about that, but not
|
||||
with event emitting */
|
||||
// execution.context.emit('takeout_ready')
|
||||
program.context.set('takeout_ready');
|
||||
// Or even more rigidly
|
||||
// program.setTakeoutReady();
|
||||
registerProgramRunHandler(
|
||||
apiType,
|
||||
(program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
/* When the order is ready
|
||||
for takeout, signalize about that
|
||||
by calling a method, not
|
||||
with event emitting */
|
||||
// execution.context
|
||||
// .emit('takeout_ready')
|
||||
program.context
|
||||
.set('takeout_ready');
|
||||
// Or even more rigidly
|
||||
// program.setTakeoutReady();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
/* Since we're modifying parent context
|
||||
instead of emitting events, we don't
|
||||
actually need to return anything */
|
||||
// return execution.context;
|
||||
});
|
||||
}
|
||||
/* Since we're modifying parent context
|
||||
instead of emitting events, we don't
|
||||
actually need to return anything */
|
||||
// return execution.context;
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Again, this solution might look counter-intuitive, since we efficiently returned to strong coupling via strictly defined methods. But there is an important difference: we're making all this stuff up because we expect alternative implementations of the *lower* abstraction level. Situations with different realizations of *higher* abstraction levels emerging are, of course, possible, but quite rare. The tree of alternative implementations usually grows from top to bottom.
|
||||
|
Loading…
x
Reference in New Issue
Block a user