mirror of
https://github.com/twirl/The-API-Book.git
synced 2024-11-30 08:06:47 +02:00
syntax highlighting
This commit is contained in:
parent
ff968089ae
commit
066f31a41f
@ -37,9 +37,7 @@ Thanks [Ilya Subbotin](https://ru.linkedin.com/in/isubbotin) and [Fedor Golubev]
|
||||
|
||||
Thanks [Ira Gorelik](https://pixabay.com/users/igorelick-680927/) for the Aqueduct.
|
||||
|
||||
Thanks [ParaType](https://www.paratype.ru/) for PT Sans and PT Serif.
|
||||
|
||||
Thanks [Christian Robertson](https://twitter.com/cr64) for Roboto Mono.
|
||||
Thanks [Friedrich Althausen](http://www.grafikfritze.de/) for Vollkorn, [Christian Robertson](https://twitter.com/cr64) for Roboto Mono, and [ParaType](https://www.paratype.ru/) for PT Sans.
|
||||
|
||||
Thanks [Knut Sveidqvist and Mermaid Comminuty](https://mermaid.js.org/) for Mermaid.
|
||||
|
||||
|
Binary file not shown.
Binary file not shown.
BIN
docs/assets/Vollkorn-Italic-VariableFont_wght.ttf
Normal file
BIN
docs/assets/Vollkorn-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
docs/assets/Vollkorn-VariableFont_wght.ttf
Normal file
BIN
docs/assets/Vollkorn-VariableFont_wght.ttf
Normal file
Binary file not shown.
@ -1,12 +1,18 @@
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(../assets/PTSerif-Regular.ttf);
|
||||
src: url(/fonts/Vollkorn-VariableFont_wght.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(../assets/PTSerif-Bold.ttf);
|
||||
font-weight: bold;
|
||||
src: url(/fonts/Vollkorn-Italic-VariableFont_wght.ttf);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/Vollkorn-Italic-VariableFont_wght.ttf);
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
14
package.json
14
package.json
@ -6,18 +6,18 @@
|
||||
"repository": "github.com:twirl/The-API-Book",
|
||||
"version": "2.0.0",
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.6.1",
|
||||
"@twirl/book-builder": "0.0.24",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@jest/globals": "^29.6.4",
|
||||
"@twirl/book-builder": "0.0.25",
|
||||
"@types/jest": "^29.5.4",
|
||||
"express": "^4.18.2",
|
||||
"jest": "^29.6.1",
|
||||
"jest-environment-jsdom": "^29.6.1",
|
||||
"jest-mock-extended": "^3.0.4",
|
||||
"jest": "^29.6.4",
|
||||
"jest-environment-jsdom": "^29.6.4",
|
||||
"jest-mock-extended": "^3.0.5",
|
||||
"monaco-editor": "^0.40.0",
|
||||
"puppeteer": "^20.9.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"typescript": "^5.1.6",
|
||||
"typescript": "^5.2.2",
|
||||
"webpack": "^5.88.2"
|
||||
},
|
||||
"scripts": {
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { readFileSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { resolve as pathResolve } from 'path';
|
||||
import { init, plugins } from '@twirl/book-builder';
|
||||
import templates from '../src/templates.js';
|
||||
//import { init, plugins } from '@twirl/book-builder';
|
||||
import { init, plugins } from '../../The-Book-Builder/index.js';
|
||||
import { templates } from '../src/templates.mjs';
|
||||
import { apiHighlight } from '../src/api-highlight.mjs';
|
||||
import { buildLanding } from './build-landing.mjs';
|
||||
|
||||
const flags = process.argv.reduce((flags, v) => {
|
||||
@ -63,7 +65,10 @@ console.log(`Building langs: ${langsToBuild.join(', ')}…`);
|
||||
plugins.ast.aImg,
|
||||
plugins.ast.imgSrcResolve,
|
||||
plugins.ast.highlighter({
|
||||
languages: ['javascript', 'typescript']
|
||||
languages: ['javascript', 'typescript', 'json'],
|
||||
languageDefinitions: {
|
||||
json: apiHighlight
|
||||
}
|
||||
}),
|
||||
plugins.ast.ref,
|
||||
plugins.ast.ghTableFix,
|
||||
|
40
src/api-highlight.mjs
Normal file
40
src/api-highlight.mjs
Normal file
@ -0,0 +1,40 @@
|
||||
export const apiHighlight = (hljs) => {
|
||||
const ATTRIBUTE = {
|
||||
begin: /(?<!":\s*)"(\\.|[^\\"\r\n])*"/,
|
||||
className: 'attr'
|
||||
};
|
||||
const PUNCTUATION = {
|
||||
match: /{}[[\],:]/,
|
||||
className: 'punctuation'
|
||||
};
|
||||
const LITERALS = ['true', 'false', 'null'];
|
||||
const LITERALS_MODE = {
|
||||
scope: 'literal',
|
||||
beginKeywords: LITERALS.join(' ')
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'json',
|
||||
keywords: {
|
||||
keyword: 'GET POST PUT PATCH DELETE → …',
|
||||
literal: LITERALS
|
||||
},
|
||||
contains: [
|
||||
ATTRIBUTE,
|
||||
{
|
||||
scope: 'string',
|
||||
begin: /(?!^:\s*)"/,
|
||||
end: '"'
|
||||
},
|
||||
{
|
||||
match: /{[\w\d-_]+}|<[\w\d-_\s\\n]+>/,
|
||||
className: 'substitution'
|
||||
},
|
||||
PUNCTUATION,
|
||||
LITERALS_MODE,
|
||||
hljs.C_NUMBER_MODE,
|
||||
hljs.C_LINE_COMMENT_MODE,
|
||||
hljs.C_BLOCK_COMMENT_MODE
|
||||
]
|
||||
};
|
||||
};
|
@ -1,37 +1,17 @@
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-Regular.ttf);
|
||||
src: url(/fonts/Vollkorn-VariableFont_wght.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-Italic.ttf);
|
||||
src: url(/fonts/Vollkorn-Italic-VariableFont_wght.ttf);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-Italic.ttf);
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-Bold.ttf);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-BoldItalic.ttf);
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-serif;
|
||||
src: url(/fonts/PTSerif-BoldItalic.ttf);
|
||||
font-weight: bold;
|
||||
src: url(/fonts/Vollkorn-Italic-VariableFont_wght.ttf);
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
@ -195,7 +175,6 @@ h5 {
|
||||
}
|
||||
|
||||
body,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: local-serif, serif;
|
||||
font-size: 14pt;
|
||||
@ -263,17 +242,43 @@ a.anchor {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
table {
|
||||
border-collapse: separate;
|
||||
line-height: 24px;
|
||||
margin: 2em 0;
|
||||
text-align: left;
|
||||
font-size: 80%;
|
||||
border-spacing: 0.2em 0;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th {
|
||||
border-bottom: 1px solid gray;
|
||||
padding: 0.5em 0.7em;
|
||||
}
|
||||
|
||||
.hljs-keyword {
|
||||
color: rgb(207, 34, 46);
|
||||
}
|
||||
|
||||
.hljs-variable {
|
||||
font-weight: bold;
|
||||
color: rgb(149, 56, 0);
|
||||
}
|
||||
|
||||
.hljs-string {
|
||||
color: #2a9292;
|
||||
color: rgb(10, 48, 105);
|
||||
}
|
||||
|
||||
.hljs-comment {
|
||||
color: #655f6d;
|
||||
color: rgb(110, 119, 129);
|
||||
}
|
||||
|
||||
.hljs-attr {
|
||||
color: rgb(149, 56, 0);
|
||||
}
|
||||
|
||||
.hljs-substitution {
|
||||
color: rgb(149, 56, 0);
|
||||
}
|
||||
|
||||
ul.references,
|
||||
@ -283,12 +288,6 @@ ul.bibliography {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
a.ref sup,
|
||||
ul.references sup,
|
||||
ul.bibliography sup {
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
ul.references li,
|
||||
ul.bibliography li {
|
||||
margin-bottom: 0.5em;
|
||||
|
@ -8,7 +8,7 @@ Most of the examples of APIs will be provided in the form of JSON-over-HTTP endp
|
||||
|
||||
Let's take a look at the following example:
|
||||
|
||||
```
|
||||
```json
|
||||
// Method description
|
||||
POST /v1/bucket/{id}/some-resource⮠
|
||||
/{resource_id}
|
||||
|
@ -22,11 +22,11 @@ Each level presents a developer-facing “facet” in our API. While elaborating
|
||||
|
||||
Let's assume we have the following interface:
|
||||
|
||||
```
|
||||
```json
|
||||
// Returns the lungo recipe
|
||||
GET /v1/recipes/lungo
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Posts an order to make a lungo
|
||||
// using the specified coffee-machine,
|
||||
// and returns an order identifier
|
||||
@ -36,7 +36,7 @@ POST /v1/orders
|
||||
"recipe": "lungo"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the order
|
||||
GET /v1/orders/{id}
|
||||
```
|
||||
@ -45,7 +45,7 @@ Let's consider a question: how exactly should developers determine whether the o
|
||||
* Add a reference beverage volume to the lungo recipe
|
||||
* Add the currently prepared volume of the beverage to the order state.
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/recipes/lungo
|
||||
→
|
||||
{
|
||||
@ -53,7 +53,7 @@ GET /v1/recipes/lungo
|
||||
"volume": "100ml"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -74,7 +74,7 @@ Option I: we have a list of possible volumes fixed and introduce bogus recipes l
|
||||
|
||||
Option II: we modify an interface, pronouncing volumes stated in recipes are just the default values. We allow requesting different cup volumes while placing an order:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"coffee_machine_id",
|
||||
@ -89,7 +89,7 @@ For those orders with an arbitrary volume requested, a developer will need to ob
|
||||
|
||||
So we will get this:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -124,7 +124,7 @@ In our example with coffee readiness detection, we clearly face the situation wh
|
||||
|
||||
A naïve approach to this situation is to design an interim abstraction level as a “connecting link,” which reformulates tasks from one abstraction level into another. For example, introduce a `task` entity like that:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
…
|
||||
"volume_requested": "800ml",
|
||||
@ -146,7 +146,7 @@ A naïve approach to this situation is to design an interim abstraction level as
|
||||
|
||||
So an `order` entity will keep links to the recipe and the task, thus not dealing with other abstraction layers directly:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{
|
||||
@ -168,7 +168,7 @@ In our example let's assume that we have studied coffee machines' API specs, and
|
||||
To be more specific, let's assume those two kinds of coffee machines provide the following physical API.
|
||||
|
||||
* Coffee machines with pre-built programs:
|
||||
```
|
||||
```json
|
||||
// Returns the list of
|
||||
// available programs
|
||||
GET /programs
|
||||
@ -180,7 +180,7 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
"type": "lungo"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Starts an execution
|
||||
// of the specified program
|
||||
// and returns the execution status
|
||||
@ -200,11 +200,11 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
"volume": "200ml"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Cancels the current program
|
||||
POST /cancel
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the execution status.
|
||||
// The response format is the same
|
||||
// as in the `POST /execute` method
|
||||
@ -214,7 +214,7 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
**NB**: This API violates a number of design principles, starting with a lack of versioning; it's described in such a manner because of two reasons: (1) to demonstrate how to design a more convenient API, (2) in the real life, you will really get something like that from vendors, and this API is actually quite a sane one.
|
||||
|
||||
* Coffee machines with built-in functions:
|
||||
```
|
||||
```json
|
||||
// Returns the list of
|
||||
// available functions
|
||||
GET /functions
|
||||
@ -242,7 +242,7 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
]
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Takes arguments values
|
||||
// and starts executing a function
|
||||
POST /functions
|
||||
@ -254,7 +254,7 @@ To be more specific, let's assume those two kinds of coffee machines provide the
|
||||
}]
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the state of the sensors
|
||||
GET /sensors
|
||||
→
|
||||
@ -300,10 +300,10 @@ So we need to introduce two abstraction levels.
|
||||
|
||||
What does this mean in a practical sense? Developers will still be creating orders, dealing with high-level entities only:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"coffee_machin
|
||||
"coffee_machine",
|
||||
"recipe": "lungo",
|
||||
"volume": "800ml"
|
||||
}
|
||||
@ -313,7 +313,7 @@ POST /v1/orders
|
||||
|
||||
The `POST /orders` handler checks all order parameters, puts a hold of the corresponding sum on the user's credit card, forms a request to run, and calls the execution level. First, a correct execution program needs to be fetched:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/program-matcher
|
||||
{ "recipe", "coffee-machine" }
|
||||
→
|
||||
@ -322,7 +322,7 @@ POST /v1/program-matcher
|
||||
|
||||
Now, after obtaining the correct `program` identifier, the handler runs the program:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/programs/{id}/run
|
||||
{
|
||||
"order_id",
|
||||
@ -348,7 +348,7 @@ This approach has some benefits, like the possibility to provide different sets
|
||||
|
||||
Out of general considerations, the runtime level for the second-kind API will be private, so we are more or less free in implementing it. The easiest solution would be to develop a virtual state machine that creates a “runtime” (i.e., a stateful execution context) to run a program and control its state.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/runtimes
|
||||
{
|
||||
"coffee_machine",
|
||||
@ -359,7 +359,7 @@ POST /v1/runtimes
|
||||
{ "runtime_id", "state" }
|
||||
```
|
||||
The `program` here would look like that:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"program_id",
|
||||
"api_type",
|
||||
@ -376,7 +376,7 @@ The `program` here would look like that:
|
||||
|
||||
And the `state` like that:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
// The `runtime` status:
|
||||
// * "pending" — awaiting execution
|
||||
|
@ -46,7 +46,7 @@ Obviously, the first step is to offer a choice to the user, to make them point o
|
||||
|
||||
If we try writing pseudocode, we will get something like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Retrieve all possible recipes
|
||||
let recipes =
|
||||
api.getRecipes();
|
||||
@ -79,7 +79,7 @@ The necessity of adding a new endpoint for searching becomes obvious. To design
|
||||
|
||||
Then our new interface would look like this:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
// optional
|
||||
@ -114,7 +114,7 @@ Here:
|
||||
|
||||
Coming back to the code developers write, it would now look like that:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Searching for offers
|
||||
// matching a user's intent
|
||||
let offers = api.search(parameters);
|
||||
@ -135,7 +135,7 @@ To solve the third problem we could demand that the displayed price be included
|
||||
|
||||
One solution is to provide a special identifier to an offer. This identifier must be specified in an order creation request:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
@ -163,7 +163,7 @@ As an alternative, we could split the endpoints: one for searching, and one for
|
||||
|
||||
And one more step towards making developers' lives easier: what would an “invalid price” error look like?
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{ "offer_id", … }
|
||||
→ 409 Conflict
|
||||
@ -187,7 +187,7 @@ The main rule of error interfaces in APIs is that an error response must help a
|
||||
|
||||
In our case, the price mismatch error should look like this:
|
||||
|
||||
```
|
||||
```json
|
||||
409 Conflict
|
||||
{
|
||||
// Error kind
|
||||
@ -219,7 +219,7 @@ The only possible method of overcoming this law is decomposition. Entities shoul
|
||||
|
||||
Let's take a look at the coffee machine search function response in our API. To ensure an adequate UX of the app, quite bulky datasets are required:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [{
|
||||
// Coffee machine data
|
||||
@ -271,7 +271,7 @@ In this situation, we need to split this structure into data domains by grouping
|
||||
|
||||
Let's group them together:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"results": [{
|
||||
// Place data
|
||||
|
@ -21,7 +21,7 @@ It is important to understand that you can always introduce your own concepts. F
|
||||
The entity name should explicitly indicate what the entity does and what side effects to expect when using it.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```typescript
|
||||
// Cancels an order
|
||||
order.canceled = true;
|
||||
```
|
||||
@ -29,13 +29,13 @@ order.canceled = true;
|
||||
It is not obvious that a state field might be modified, and that this operation will cancel the order.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```typescript
|
||||
// Cancels an order
|
||||
order.cancel();
|
||||
```
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```typescript
|
||||
// Returns aggregated statistics
|
||||
// since the beginning of time
|
||||
orders.getStats()
|
||||
@ -43,7 +43,7 @@ orders.getStats()
|
||||
Even if the operation is non-modifying but computationally expensive, you should explicitly indicate that, especially if clients are charged for computational resource usage. Furthermore, default values should not be set in a way that leads to maximum resource consumption.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```typescript
|
||||
// Calculates and returns
|
||||
// aggregated statistics
|
||||
// for a specified period of time
|
||||
@ -103,17 +103,17 @@ In the 21st century, there's no need to shorten entities' names.
|
||||
**Better**: `order.getEstimatedDeliveryTime()`.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```typescript
|
||||
// Returns a pointer to the first occurrence
|
||||
// in str1 of any of the characters
|
||||
// that are part of str2
|
||||
strpbrk (str1, str2)
|
||||
strpbrk(str1, str2)
|
||||
```
|
||||
|
||||
Possibly, the author of this API thought that the abbreviation `pbrk` would mean something to readers, but that is clearly mistaken. It is also hard to understand from the signature which string (`str1` or `str2`) represents a character set.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```typescript
|
||||
str_search_for_characters(
|
||||
str,
|
||||
lookup_character_set
|
||||
@ -146,7 +146,7 @@ If an entity name is a polysemantic term itself, which could confuse developers,
|
||||
|
||||
**Bad**:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Returns a list of
|
||||
// coffee machine builtin functions
|
||||
GET /coffee-machines/{id}/functions
|
||||
@ -155,7 +155,7 @@ GET /coffee-machines/{id}/functions
|
||||
The word “function” is ambiguous. It might refer to built-in functions, but it could also mean “a piece of code,” or a state (machine is functioning).
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```typescript
|
||||
GET /v1/coffee-machines/{id}⮠
|
||||
/builtin-functions-list
|
||||
```
|
||||
@ -168,7 +168,7 @@ GET /v1/coffee-machines/{id}⮠
|
||||
**Better**: either `begin_transition` / `end_transition` or `start_transition` / `stop_transition`.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```typescript
|
||||
// Find the position of the first occurrence
|
||||
// of a substring in a string
|
||||
strpos(haystack, needle)
|
||||
@ -195,7 +195,7 @@ Improving these function signatures is left as an exercise for the reader.
|
||||
|
||||
It is also worth mentioning that mistakes in using De Morgan's laws[ref De Morgan's laws](https://en.wikipedia.org/wiki/De_Morgan's_laws) are even more common. For example, if you have two flags:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /coffee-machines/{id}/stocks
|
||||
→
|
||||
{
|
||||
@ -206,7 +206,7 @@ GET /coffee-machines/{id}/stocks
|
||||
|
||||
The condition “coffee might be prepared” would look like `has_beans && has_cup` — both flags must be true. However, if you provide the negations of both flags:
|
||||
|
||||
```
|
||||
```json
|
||||
{
|
||||
"beans_absence": false,
|
||||
"cup_absence": false
|
||||
@ -219,7 +219,7 @@ The condition “coffee might be prepared” would look like `has_beans && has_c
|
||||
|
||||
This advice contradicts the previous one, ironically. When developing APIs you frequently need to add a new optional field with a non-empty default value. For example:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const orderParams = {
|
||||
contactless_delivery: false
|
||||
};
|
||||
@ -230,13 +230,11 @@ const order = api.createOrder(
|
||||
|
||||
This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how should developers discern the explicit intention to disable the option (`false`) from not knowing if it exists (the field isn't set)? They would have to write something like:
|
||||
|
||||
```
|
||||
if (
|
||||
Type(
|
||||
orderParams.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
orderParams
|
||||
.contactless_delivery == false) {
|
||||
```typescript
|
||||
const value = orderParams
|
||||
.contactless_delivery;
|
||||
if (Type(value) == 'Boolean' &&
|
||||
value == false) {
|
||||
…
|
||||
}
|
||||
```
|
||||
@ -246,7 +244,7 @@ This practice makes the code more complicated, and it's quite easy to make mista
|
||||
If the protocol does not support resetting to default values as a first-class citizen, the universal rule is to make all new Boolean flags false by default.
|
||||
|
||||
**Better**
|
||||
```
|
||||
```typescript
|
||||
const orderParams = {
|
||||
force_contact_delivery: true
|
||||
};
|
||||
@ -258,7 +256,7 @@ const order = api.createOrder(
|
||||
If a non-Boolean field with a specially treated absence of value is to be introduced, then introduce two fields.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
// Creates a user
|
||||
POST /v1/users
|
||||
{ … }
|
||||
@ -278,7 +276,7 @@ PUT /v1/users/{id}
|
||||
```
|
||||
|
||||
**Better**
|
||||
```
|
||||
```json
|
||||
POST /v1/users
|
||||
{
|
||||
// true — user explicitly cancels
|
||||
@ -338,7 +336,7 @@ As a useful exercise, try modeling the typical lifecycle of a partner's app's ma
|
||||
If a server processes a request correctly and no exceptional situation occurs, there should be no error. Unfortunately, the antipattern of throwing errors when no results are found is widespread.
|
||||
|
||||
**Bad**
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
@ -354,7 +352,7 @@ POST /v1/coffee-machines/search
|
||||
The response implies that a client made a mistake. However, in this case, neither the customer nor the developer made any mistakes. The client cannot know beforehand whether lungo is served in this location.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"query": "lungo",
|
||||
@ -370,7 +368,7 @@ This rule can be summarized as follows: if an array is the result of the operati
|
||||
|
||||
**NB**: This pattern should also be applied in the opposite case. If an array of entities is an optional parameter in the request, the empty array and the absence of the field must be treated differently. Let's consider the example:
|
||||
|
||||
```
|
||||
```json
|
||||
// Finds all coffee recipes
|
||||
// that contain no milk
|
||||
POST /v1/recipes/search
|
||||
@ -403,7 +401,7 @@ POST /v1/offers/search
|
||||
|
||||
Now let's imagine that the first request returned an empty array of results meaning there are no known recipes that satisfy the condition. Ideally, the developer would have expected this situation and installed a guard to prevent the call to the offer search function in this case. However, we can't be 100% sure they did. If this logic is missing, the application will make the following call:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
"location",
|
||||
@ -420,7 +418,7 @@ The decision of whether to use an exception or an empty response in the previous
|
||||
This rule applies not only to empty arrays but to every restriction specified in the contract. “Silently” fixing invalid values rarely makes practical sense.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
"location": {
|
||||
@ -439,7 +437,7 @@ POST /v1/offers/search
|
||||
As we can see, the developer somehow passed the wrong latitude value (100 degrees). Yes, we can “fix” it by reducing it to the closest valid value, which is 90 degrees, but who benefits from this? The developer will never learn about this mistake, and we doubt that coffee offers in the Northern Pole vicinity are relevant to users.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"location": {
|
||||
@ -455,7 +453,7 @@ POST /v1/coffee-machines/search
|
||||
|
||||
It is also useful to proactively notify partners about behavior that appears to be a mistake:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"location": {
|
||||
@ -481,9 +479,9 @@ POST /v1/coffee-machines/search
|
||||
|
||||
If it is not possible to add such notices, we can introduce a debug mode or strict mode in which notices are escalated:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true
|
||||
?strict_mode=true
|
||||
{
|
||||
"location": {
|
||||
"latitude": 0,
|
||||
@ -503,10 +501,10 @@ POST /v1/coffee-machines/search⮠
|
||||
|
||||
If the [0, 0] coordinates are not an error, it makes sense to allow for manual bypassing of specific errors:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search⮠
|
||||
strict_mode=true⮠
|
||||
disable_errors=suspicious_coordinates
|
||||
?strict_mode=true⮠
|
||||
&disable_errors=suspicious_coordinates
|
||||
```
|
||||
|
||||
##### Default Values Must Make Sense
|
||||
@ -514,7 +512,7 @@ POST /v1/coffee-machines/search⮠
|
||||
Setting default values is one of the most powerful tools that help avoid verbosity when working with APIs. However, these values should help developers rather than hide their mistakes.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lungo"]
|
||||
@ -532,7 +530,7 @@ POST /v1/coffee-machines/search
|
||||
Formally speaking, having such behavior is feasible: why not have a “default geographical coordinates” concept? However, in reality, such policies of “silently” fixing mistakes lead to absurd situations like “the null island” — the most visited place in the world[ref Hrala, J. Welcome to Null Island, The Most 'Visited' Place on Earth That Doesn't Actually Exist](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). The more popular an API becomes, the higher the chances that partners will overlook these edge cases.
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lungo"]
|
||||
@ -549,7 +547,7 @@ POST /v1/coffee-machines/search
|
||||
It is not enough to simply validate inputs; providing proper descriptions of errors is also essential. When developers write code, they encounter problems, sometimes quite trivial, such as invalid parameter types or boundary violations. The more convenient the error responses returned by your API, the less time developers will waste struggling with them, and the more comfortable working with the API will be for them.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search
|
||||
{
|
||||
"recipes": ["lngo"],
|
||||
@ -564,7 +562,7 @@ POST /v1/coffee-machines/search
|
||||
— of course, the mistakes (typo in `"lngo"`, wrong coordinates) are obvious. But the handler checks them anyway, so why not return readable descriptions?
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"reason": "wrong_parameter_value",
|
||||
"localized_message":
|
||||
@ -601,7 +599,7 @@ It is also a good practice to return all detectable errors at once to save devel
|
||||
|
||||
##### Return Unresolvable Errors First
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"recipe": "lngo",
|
||||
@ -630,7 +628,7 @@ POST /v1/orders
|
||||
If the errors under consideration are resolvable (i.e., the user can take some actions and still get what they need), you should first notify them of those errors that will require more significant state updates.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{
|
||||
@ -678,7 +676,7 @@ POST /v1/orders
|
||||
|
||||
In complex systems, it might happen that resolving one error leads to another one, and vice versa.
|
||||
|
||||
```
|
||||
```json
|
||||
// Create an order
|
||||
// with paid delivery
|
||||
POST /v1/orders
|
||||
@ -726,7 +724,7 @@ Let's emphasize that we understand “cache” in the extended sense: which vari
|
||||
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
// Returns lungo prices including
|
||||
// delivery to the specified location
|
||||
GET /price?recipe=lungo⮠
|
||||
@ -741,7 +739,7 @@ Two questions arise:
|
||||
|
||||
**Better**: you may use standard protocol capabilities to denote cache options, such as the `Cache-Control` header. If you need caching in both temporal and spatial dimensions, you should do something like this:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
&latitude={latitude}
|
||||
@ -782,14 +780,14 @@ Let us remind the reader that idempotency is the following property: repeated ca
|
||||
If an endpoint's idempotency can not be naturally assured, explicit idempotency parameters must be added in the form of a token or a resource version.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
// Creates an order
|
||||
POST /orders
|
||||
```
|
||||
A second order will be produced if the request is repeated!
|
||||
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
// Creates an order
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <random string>
|
||||
@ -798,13 +796,13 @@ X-Idempotency-Token: <random string>
|
||||
The client must retain the `X-Idempotency-Token` in case of automated endpoint retrying. The server must check whether an order created with this token already exists.
|
||||
|
||||
**Alternatively**:
|
||||
```
|
||||
```json
|
||||
// Creates order draft
|
||||
POST /v1/orders/drafts
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Confirms the draft
|
||||
PUT /v1/orders/drafts⮠
|
||||
/{draft_id}/confirmation
|
||||
@ -819,7 +817,7 @@ It is also worth mentioning that adding idempotency tokens to naturally idempote
|
||||
|
||||
Consider the following example: imagine there is a shared resource, characterized by a revision number, and the client tries to update it.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
{
|
||||
"resource_revision": 123
|
||||
@ -833,7 +831,7 @@ The server can compare request bodies, assuming that identical requests mean ret
|
||||
|
||||
Adding the idempotency token (either directly as a random string or indirectly in the form of drafts) solves this problem.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
@ -847,7 +845,7 @@ X-Idempotency-Token: <token>
|
||||
|
||||
Or:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /resource/updates
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
@ -882,14 +880,14 @@ And just in case: all APIs must be provided over TLS 1.2 or higher (preferably 1
|
||||
It is equally important to provide interfaces to partners that minimize potential security problems for them.
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
// Allows partners to set
|
||||
// descriptions for their beverages
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the desciption
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
/recipes/lungo/info
|
||||
@ -902,7 +900,7 @@ Such an interface directly creates a stored XSS vulnerability that potential att
|
||||
In these situations, we recommend, first, sanitizing the data if it appears potentially exploitable (e.g. if it is meant to be displayed in the UI and/or is accessible through a direct link). Second, limiting the blast radius so that stored exploits in one partner's data space can't affect other partners. If the functionality of unsafe data input is still required, the risks must be explicitly addressed:
|
||||
|
||||
**Better** (though not perfect):
|
||||
```
|
||||
```json
|
||||
// Allows for setting a potentially
|
||||
// unsafe description for a beverage
|
||||
PUT /v1/partner-api/{partner-id}⮠
|
||||
@ -910,7 +908,7 @@ PUT /v1/partner-api/{partner-id}⮠
|
||||
X-Dangerously-Disable-Sanitizing: true
|
||||
"<script>alert(document.cookie)</script>"
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the potentially
|
||||
// unsafe description
|
||||
GET /v1/partner-api/{partner-id}⮠
|
||||
@ -923,7 +921,7 @@ X-Dangerously-Allow-Raw-Value: true
|
||||
One important finding is that if you allow executing scripts via the API, always prefer typed input over unsafe input:
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
```json
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Passes the full script
|
||||
@ -933,14 +931,14 @@ POST /v1/run/sql
|
||||
}
|
||||
```
|
||||
**Better**:
|
||||
```
|
||||
```json
|
||||
POST /v1/run/sql
|
||||
{
|
||||
// Passes the script template
|
||||
"query": "INSERT INTO data (name)⮠
|
||||
VALUES (?)",
|
||||
// and the parameters to set
|
||||
values: [
|
||||
"values": [
|
||||
"Robert');⮠
|
||||
DROP TABLE students;--"
|
||||
]
|
||||
|
@ -4,7 +4,7 @@ Let's summarize the current state of our API study.
|
||||
|
||||
##### Offer Search
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/offers/search
|
||||
{
|
||||
// optional
|
||||
@ -55,14 +55,14 @@ POST /v1/offers/search
|
||||
|
||||
##### Working with Recipes
|
||||
|
||||
```
|
||||
```json
|
||||
// Returns a list of recipes
|
||||
// Cursor parameter is optional
|
||||
GET /v1/recipes?cursor=<cursor>
|
||||
→
|
||||
{ "recipes", "cursor" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the recipe by its id
|
||||
GET /v1/recipes/{id}
|
||||
→
|
||||
@ -75,7 +75,7 @@ GET /v1/recipes/{id}
|
||||
|
||||
##### Working with Orders
|
||||
|
||||
```
|
||||
```json
|
||||
// Creates an order
|
||||
POST /v1/orders
|
||||
{
|
||||
@ -91,20 +91,20 @@ POST /v1/orders
|
||||
→
|
||||
{ "order_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the order by its id
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{ "order_id", "status" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Cancels the order
|
||||
POST /v1/orders/{id}/cancel
|
||||
```
|
||||
|
||||
##### Working with Programs
|
||||
|
||||
```
|
||||
```json
|
||||
// Returns an identifier of the program
|
||||
// corresponding to specific recipe
|
||||
// on specific coffee-machine
|
||||
@ -113,7 +113,7 @@ POST /v1/program-matcher
|
||||
→
|
||||
{ "program_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Return program description
|
||||
// by its id
|
||||
GET /v1/programs/{id}
|
||||
@ -134,7 +134,7 @@ GET /v1/programs/{id}
|
||||
|
||||
##### Running Programs
|
||||
|
||||
```
|
||||
```json
|
||||
// Runs the specified program
|
||||
// on the specified coffee-machine
|
||||
// with specific parameters
|
||||
@ -152,14 +152,14 @@ POST /v1/programs/{id}/run
|
||||
→
|
||||
{ "program_run_id" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Stops program running
|
||||
POST /v1/runs/{id}/cancel
|
||||
```
|
||||
|
||||
##### Managing Runtimes
|
||||
|
||||
```
|
||||
```json
|
||||
// Creates a new runtime
|
||||
POST /v1/runtimes
|
||||
{
|
||||
@ -170,7 +170,7 @@ POST /v1/runtimes
|
||||
→
|
||||
{ "runtime_id", "state" }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Returns the state
|
||||
// of the specified runtime
|
||||
GET /v1/runtimes/{runtime_id}/state
|
||||
@ -183,7 +183,7 @@ GET /v1/runtimes/{runtime_id}/state
|
||||
"variables"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Terminates the runtime
|
||||
POST /v1/runtimes/{id}/terminate
|
||||
```
|
||||
|
@ -6,7 +6,7 @@ Let's proceed to the technical problems that API developers face. We begin with
|
||||
2. Because of network issues, the request propagates to the server very slowly, and the client gets a timeout
|
||||
* Therefore, the client does not know whether the query was served or not.
|
||||
3. The client requests the current state of the system and gets an empty response as the initial request still hasn't reached the server:
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await
|
||||
api.getOngoingOrders(); // → []
|
||||
```
|
||||
@ -23,7 +23,7 @@ There are two main approaches to solving this problem: the pessimistic one (impl
|
||||
|
||||
The first approach is to literally implement standard synchronization primitives at the API level. Like this, for example:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let lock;
|
||||
try {
|
||||
// Capture the exclusive
|
||||
@ -59,7 +59,7 @@ Rather unsurprisingly, this approach sees very rare use in distributed client-se
|
||||
|
||||
A less implementation-heavy approach is to develop an optimistic concurrency control[ref Optimistic concurrency control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) system, i.e., to require clients to pass a flag proving they know the actual state of a shared resource.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Retrieve the state
|
||||
const orderState =
|
||||
await api.getOrderState();
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
The approach described in the previous chapter is in fact a trade-off: the API performance issues are traded for “normal” (i.e., expected) background errors that happen while working with the API. This is achieved by isolating the component responsible for controlling concurrency and only exposing read-only tokens in the public API. Still, the achievable throughput of the API is limited, and the only way of scaling it up is removing the strict consistency from the external API and thus allowing reading system state from read-only replicas:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Reading the state,
|
||||
// possibly from a replica
|
||||
const orderState =
|
||||
@ -26,7 +26,7 @@ As orders are created much more rarely than read, we might significantly increas
|
||||
|
||||
Choosing weak consistency instead of a strict one, however, brings some disadvantages. For instance, we might require partners to wait until they get the actual resource state to make changes — but it is quite unobvious for partners (and actually inconvenient) they must be prepared to wait for changes they made themselves to propagate.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Creates an order
|
||||
const api = await api
|
||||
.createOrder(…)
|
||||
@ -40,7 +40,7 @@ If strict consistency is not guaranteed, the second call might easily return an
|
||||
|
||||
An important pattern that helps in this situation is implementing the “read-your-writes[ref Consistency Model. Read-Your-Writes Consistency](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency)” model, i.e., guaranteeing that clients observe the changes they have just made. The consistency might be lifted to the read-your-writes level by making clients pass some token that describes the last changes known to the client.
|
||||
|
||||
```
|
||||
```typescript
|
||||
const order = await api
|
||||
.createOrder(…);
|
||||
const pendingOrders = await api.
|
||||
|
@ -6,7 +6,7 @@ We remember that this probability is equal to the ratio of time periods: getting
|
||||
|
||||
Our usage scenario looks like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrders.length == 0) {
|
||||
@ -32,7 +32,7 @@ However, what we could do to improve this timing remains unclear. Creating an or
|
||||
|
||||
What could help us here is the asynchronous operations pattern. If our goal is to reduce the collision rate, there is no need to wait until the order is *actually* created as we need to quickly propagate the knowledge that the order is *accepted for creation*. We might employ the following technique: create *a task for order creation* and return its identifier, not the order itself.
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrders.length == 0) {
|
||||
@ -79,7 +79,7 @@ However, we must stress that excessive asynchronicity, though appealing to API d
|
||||
|
||||
Therefore, despite all the advantages of the approach, we tend to recommend applying this pattern only to those cases when they are really needed (as in the example we started with when we needed to lower the probability of collisions) and having separate queues for each case. The perfect task queue solution is the one that doesn't look like a task queue. For example, we might simply make the “order creation task is accepted and awaits execution” state a separate order status and make its identifier the future identifier of the order itself:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
if (pendingOrders.length == 0) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
In the previous chapter, we concluded with the following interface that allows minimizing collisions while creating orders:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api
|
||||
.getOngoingOrders();
|
||||
→
|
||||
@ -18,7 +18,7 @@ However, an attentive reader might notice that this interface violates the recom
|
||||
|
||||
Fixing this problem is rather simple: we might introduce a limit for the items returned in the response, and allow passing filtering and sorting parameters, like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOngoingOrders({
|
||||
// The `limit` parameter
|
||||
// is optional, but there is
|
||||
@ -37,7 +37,7 @@ However, introducing limits leads to another issue: if the number of items to re
|
||||
|
||||
The standard approach is to add an `offset` parameter or a page number:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOngoingOrders({
|
||||
// The `limit` parameter
|
||||
// is optional, but there is
|
||||
@ -51,7 +51,7 @@ api.getOngoingOrders({
|
||||
|
||||
With this approach, however, other problems arise. Let us imagine three orders are being processed on behalf of the user:
|
||||
|
||||
```
|
||||
```json
|
||||
[{
|
||||
"id": 3,
|
||||
"created_iso_time": "2022-12-22T15:35",
|
||||
@ -69,7 +69,7 @@ With this approach, however, other problems arise. Let us imagine three orders a
|
||||
|
||||
A partner application requested the first page of the list:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
@ -91,7 +91,7 @@ api.getOrders({
|
||||
|
||||
Then the application requests the second page (`"limit": 2, "offset": 2`) and expects to retrieve the order with `"id": 1`. However, during the interval between the requests, another order, with `"id": 4`, happened.
|
||||
|
||||
```
|
||||
```json
|
||||
[{
|
||||
"id": 4,
|
||||
"created_iso_time": "2022-12-22T15:36",
|
||||
@ -113,7 +113,7 @@ Then the application requests the second page (`"limit": 2, "offset": 2`) and ex
|
||||
|
||||
Then upon requesting the second page of the order list, instead of getting exactly one order with `"id": 1`, the application will get the `"id": 2` order once again:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"offset": 2
|
||||
@ -133,7 +133,7 @@ These permutations are rather inconvenient in user interfaces (if let's say, the
|
||||
|
||||
The problem might easily become even more sophisticated. For example, if we add sorting by two fields, creation date and order status:
|
||||
|
||||
```
|
||||
```typescript
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
@ -172,9 +172,9 @@ The easiest case is with immutable lists, i.e., when the set of items never chan
|
||||
|
||||
The case of a list with immutable items and the operation of adding new ones is more typical. Most notably, we talk about event queues containing, for example, new messages or notifications. Let's imagine there is an endpoint in our coffee API that allows partners to retrieve the history of offers:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
limit=<limit>
|
||||
?limit=<limit>
|
||||
→
|
||||
{
|
||||
"offer_history": [{
|
||||
@ -210,15 +210,15 @@ To solve this issue, we need to rely not on an attribute that constantly changes
|
||||
|
||||
If the data storage we use for keeping list items offers the possibility of using monotonically increased identifiers (which practically means two things: (1) the DB supports auto-incremental columns and (2) there are insert locks that guarantee inserts are performed sequentially), then using the monotonous identifier is the most convenient way of organizing list traversal:
|
||||
|
||||
```
|
||||
```json
|
||||
// Retrieve the records that precede
|
||||
// the one with the given id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
newer_than=<item_id>&limit=<limit>
|
||||
?newer_than=<item_id>&limit=<limit>
|
||||
// Retrieve the records that follow
|
||||
// the one with the given id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
```
|
||||
|
||||
The first request format allows for implementing the first scenario, i.e., retrieving the fresh portion of the data. Conversely, the second format makes it possible to consistently iterate over the data to fulfill the second scenario. Importantly, the second request is cacheable as the tail of the list never changes.
|
||||
@ -233,10 +233,10 @@ Another possible anchor to rely on is the record creation date. However, this ap
|
||||
|
||||
Often, the interfaces of traversing data through stating boundaries are generalized by introducing the concept of a “cursor”:
|
||||
|
||||
```
|
||||
```json
|
||||
// Initiate list traversal
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
search
|
||||
/search
|
||||
{
|
||||
"order_by": [{
|
||||
"field": "created",
|
||||
@ -249,7 +249,7 @@ POST /v1/partners/{id}/offers/history⮠
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Get the next data chunk
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
|
||||
@ -266,10 +266,10 @@ A *cursor* might be just an encoded identifier of the last record or it might co
|
||||
|
||||
The cursor-based approach also allows adding new filters and sorting directions in a backward-compatible manner — provided you organize the data in a way that cursor-based traversal will continue working.
|
||||
|
||||
```
|
||||
```json
|
||||
// Initialize list traversal
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
search
|
||||
/search
|
||||
{
|
||||
// Add a filter by the recipe
|
||||
"filter": {
|
||||
@ -305,7 +305,7 @@ Unfortunately, it is not universally possible to organize the data in a way that
|
||||
|
||||
Sometimes, the task can be *reduced* to an immutable list if we create a snapshot of the data. In many cases, it is actually more convenient for partners to work with a snapshot that is current for a specific date as it eliminates the necessity of taking ongoing changes into account. This approach works well with accessing “cold” data storage by downloading chunks of data and putting them into “hot” storage upon request.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/archive/retrieve
|
||||
{
|
||||
"created_iso_date": {
|
||||
@ -330,7 +330,7 @@ The inverse approach to the problem is to never provide more than one page of da
|
||||
|
||||
If none of the approaches above works, our only solution is changing the subject area itself. If we can't consistently enumerate list elements, we need to find a facet of the same data that we *can* enumerate. In our example with the ongoing orders we might make an ordered list of the *events* of creating new orders:
|
||||
|
||||
```
|
||||
```json
|
||||
// Retrieve all the events older
|
||||
// than the one with the given id
|
||||
GET /v1/orders/created-history⮠
|
||||
|
@ -2,9 +2,9 @@
|
||||
|
||||
In the previous chapter, we discussed the following scenario: a partner receives information about new events occuring in the system by periodically requesting an endpoint that supports retrieving ordered lists.
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/created-history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
?older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
"orders_created_events": [{
|
||||
|
@ -6,7 +6,7 @@ One of the vexing restrictions of almost every technology mentioned in the previ
|
||||
|
||||
On the example of our coffee API:
|
||||
|
||||
```
|
||||
```json
|
||||
// Option #1: the message
|
||||
// contains all the order data
|
||||
POST /partner/webhook
|
||||
@ -24,7 +24,7 @@ Host: partners.host
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Option #2: the message body
|
||||
// contains only the notification
|
||||
// of the status change
|
||||
@ -50,7 +50,7 @@ GET /v1/orders/{id}
|
||||
{ /* full data regarding
|
||||
the order */ }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// Option #3: the API vendor
|
||||
// notifies partners that
|
||||
// several orders await their
|
||||
@ -85,7 +85,7 @@ Which option to select depends on the subject area (and on the allowed message s
|
||||
|
||||
The technique of sending only essential data in the notification has one important disadvantage, apart from more complicated data flows and increased request rate. With option \#1 implemented (i.e., the message contains all the data), we might assume that returning a success response by the subscriber is equivalent to successfully processing the state change by the partner (although it's not guaranteed if the partner uses asynchronous techniques). With options \#2 and \#3, this is certainly not the case: the partner must carry out additional actions (starting from retrieving the actual order state) to fully process the message. This implies that two separate statuses might be needed: “message received” and “message processed.” Ideally, the latter should follow the logic of the API work cycle, i.e., the partner should carry out some follow-up action upon processing the event, and this action might be treated as the “message processed” signal. In our coffee example, we can expect that the partner will either accept or reject an order after receiving the “new order” message. Then the full message processing flow will look like this:
|
||||
|
||||
```
|
||||
```json
|
||||
// The API vendor
|
||||
// notifies the partner that
|
||||
// several orders await their
|
||||
@ -98,7 +98,7 @@ Host: partners.host
|
||||
<the number of pending orders>
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// In response, the partner
|
||||
// retrieves the list of
|
||||
// pending orders
|
||||
@ -109,7 +109,7 @@ GET /v1/orders/pending
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
```
|
||||
```json
|
||||
// After the orders are processed,
|
||||
// the partners notify about this
|
||||
// by calling the specific API
|
||||
|
@ -4,7 +4,7 @@ Let's transition from *webhooks* back to developing direct-call APIs. The design
|
||||
|
||||
Let's consider a scenario where the partner notifies us about status changes that have occurred for two orders:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/bulk-status-change
|
||||
{
|
||||
"status_changes": [{
|
||||
@ -41,7 +41,7 @@ However, if we consider the situation from the partner's perspective, we realize
|
||||
Now, let's consider a scenario where the partner receives an error from the API endpoint during the third step. What would developers do in such a situation? Most probably, one of the following solutions might be implemented in the partner's code:
|
||||
|
||||
1. Unconditional retry of the request:
|
||||
```
|
||||
```typescript
|
||||
// Retrieve the ongoing orders
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
@ -89,7 +89,7 @@ Now, let's consider a scenario where the partner receives an error from the API
|
||||
**NB**: In the code sample above, we provide the “right” retry policy with exponentially increasing delays and a total limit on the number of retries, as we recommended earlier in the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter. However, be warned that real partners' code may frequently lack such precautions. For the sake of readability, we will skip this bulky construct in the following code samples.
|
||||
|
||||
2. Retrying only failed sub-requests:
|
||||
```
|
||||
```typescript
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
let changes =
|
||||
@ -130,7 +130,7 @@ Now, let's consider a scenario where the partner receives an error from the API
|
||||
```
|
||||
|
||||
3. Restarting the entire pipeline. In this case, the partner retrieves the list of pending orders anew and forms a new bulk change request:
|
||||
```
|
||||
```typescript
|
||||
do {
|
||||
const pendingOrders = await api
|
||||
.getPendingOrders();
|
||||
@ -155,7 +155,7 @@ Now, let's introduce another crucial condition to the problem statement: imagine
|
||||
|
||||
This leads us to a seemingly paradoxical conclusion: in order to ensure the partners' code continues to function *somehow* and to allow them time to address their invalid sub-requests we should adopt the least strict non-idempotent non-atomic approach to the design of the bulk state change endpoint. However, we consider this conclusion to be incorrect: the “zoo” of possible client and server implementations and the associated problems demonstrate that *bulk state change endpoints are inherently undesirable*. Such endpoints require maintaining an additional layer of logic in both server and client code, and the logic itself is quite non-obvious. The non-atomic non-idempotent bulk state changes will very soon result in nasty issues:
|
||||
|
||||
```
|
||||
```json
|
||||
// A partner issues a refund
|
||||
// and cancels the order
|
||||
POST /v1/bulk-status-change
|
||||
@ -195,7 +195,7 @@ So, our recommendations for bulk modifying endpoints are:
|
||||
|
||||
One of the approaches that helps minimize potential issues is developing a “mixed” endpoint, in which the operations that can affect each other are grouped:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/bulk-status-change
|
||||
{
|
||||
"changes": [{
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
The case of partial application of the list of changes described in the previous chapter naturally leads us to the next typical API design problem. What if the operation involves a low-level overwriting of several data fields rather than an atomic idempotent procedure (as in the case of changing the order status)? Let's take a look at the following example:
|
||||
|
||||
```
|
||||
```json
|
||||
// Creates an order
|
||||
// consisting of two beverages
|
||||
POST /v1/orders/
|
||||
@ -20,7 +20,7 @@ X-Idempotency-Token: <token>
|
||||
{ "order_id" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Partially updates the order
|
||||
// by changing the volume
|
||||
// of the second beverage
|
||||
@ -58,7 +58,7 @@ To avoid these issues, developers sometimes implement a **naïve solution**:
|
||||
|
||||
A full example of an API implementing the naïve approach would look like this:
|
||||
|
||||
```
|
||||
```json
|
||||
// Partially rewrites the order:
|
||||
// * Resets the delivery address
|
||||
// to the default values
|
||||
@ -96,17 +96,17 @@ However, upon closer examination all these conclusions seem less viable:
|
||||
|
||||
The solution could be enhanced by introducing explicit control sequences instead of relying on “magical” values and adding meta settings for the operation (such as a field name filter as it's implemented in gRPC over Protobuf[ref Protocol Buffers. Field Masks in Update Operations](https://protobuf.dev/reference/protobuf/google.protobuf/#field-masks-updates)). Here's an example:
|
||||
|
||||
```
|
||||
```json
|
||||
// Partially rewrites the order:
|
||||
// * Resets the delivery address
|
||||
// to the default values
|
||||
// * Leaves the first beverage
|
||||
// intact
|
||||
// * Removes the second beverage.
|
||||
PATCH /v1/orders/{id}?⮠
|
||||
PATCH /v1/orders/{id}⮠
|
||||
// A meta filter: which fields
|
||||
// are allowed to be modified
|
||||
field_mask=delivery_address,items
|
||||
?field_mask=delivery_address,items
|
||||
{
|
||||
// “Special” value #1:
|
||||
// reset the field
|
||||
@ -135,7 +135,7 @@ Given that the format becomes more complex and less intuitively understandable,
|
||||
|
||||
A **more consistent solution** is to split an endpoint into several idempotent sub-endpoints, each having its own independent identifier and/or address (which is usually enough to ensure the transitivity of independent operations). This approach aligns well with the decomposition principle we discussed in the “[Isolating Responsibility Areas](#api-design-isolating-responsibility)” chapter.
|
||||
|
||||
```
|
||||
```json
|
||||
// Creates an order
|
||||
// comprising two beverages
|
||||
POST /v1/orders/
|
||||
@ -164,7 +164,7 @@ POST /v1/orders/
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Changes the parameters
|
||||
// of the second order
|
||||
PUT /v1/orders/{id}/parameters
|
||||
@ -173,7 +173,7 @@ PUT /v1/orders/{id}/parameters
|
||||
{ "delivery_address" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Partially changes the order
|
||||
// by rewriting the parameters
|
||||
// of the second beverage
|
||||
@ -187,7 +187,7 @@ PUT /v1/orders/{id}/items/{item_id}
|
||||
{ "recipe", "volume", "milk_type" }
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// Deletes one of the beverages
|
||||
DELETE /v1/orders/{id}/items/{item_id}
|
||||
```
|
||||
@ -210,7 +210,7 @@ To make true collaborative editing possible, a specifically designed format for
|
||||
|
||||
In our case, we might take this direction:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/order/changes
|
||||
X-Idempotency-Token: <token>
|
||||
{
|
||||
|
@ -25,7 +25,7 @@ One cannot make a partial commitment. Either you guarantee that the code will al
|
||||
The third principle is much less obvious. Pay close attention to the code that you're suggesting developers write: are there any conventions that you consider self-evident but never wrote down?
|
||||
|
||||
**Example \#1**. Let's take a look at this order processing SDK example:
|
||||
```
|
||||
```typescript
|
||||
// Creates an order
|
||||
let order = api.createOrder();
|
||||
// Returns the order status
|
||||
@ -36,7 +36,7 @@ Let's imagine that you're struggling with scaling your service, and at some poin
|
||||
|
||||
You may say something like, “But we've never promised strict consistency in the first place” — and that is obviously not true. You may say that if, and only if, you have really described the eventual consistency in the `createOrder` docs, and all your SDK examples look like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let order = api.createOrder();
|
||||
let status;
|
||||
while (true) {
|
||||
@ -60,7 +60,7 @@ If you failed to describe the eventual consistency in the first place, then you
|
||||
|
||||
**Example \#2**. Take a look at the following code:
|
||||
|
||||
```
|
||||
```typescript
|
||||
let resolve;
|
||||
let promise = new Promise(
|
||||
function (innerResolve) {
|
||||
@ -76,7 +76,7 @@ Of course, the developers of the language standard can afford such tricks; but y
|
||||
|
||||
**Example \#3**. Imagine you're providing an animations API, which includes two independent functions:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Animates object's width,
|
||||
// beginning with the first value,
|
||||
// ending with the second
|
||||
@ -98,7 +98,7 @@ In this example, you should document the concrete contract (how often the observ
|
||||
|
||||
**Example \#4**. Imagine that customer orders are passing through a specific pipeline:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders/{id}/events/history
|
||||
→
|
||||
{ "event_history": [
|
||||
|
@ -14,7 +14,7 @@ Let us take the next logical step and suppose that partners will wish to dynamic
|
||||
|
||||
For example, we might provide a second API family (the partner-bound one) with the following methods:
|
||||
|
||||
```
|
||||
```json
|
||||
// 1. Register a new API type
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
@ -24,7 +24,7 @@ PUT /v1/api-types/{api_type}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
```json
|
||||
// 2. Provide a list of coffee machines
|
||||
// with their API types
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
@ -61,7 +61,7 @@ The universal approach to making such amendments is to consider the existing int
|
||||
More specifically, if we talk about changing available order options, we should do the following:
|
||||
1. Describe the current state. All coffee machines, plugged via the API, must support three options: sprinkling with cinnamon, changing the volume, and contactless delivery.
|
||||
2. Add a new “with options” endpoint:
|
||||
```
|
||||
```json
|
||||
PUT /v1/partners/{partner_id}⮠
|
||||
/coffee-machines-with-options
|
||||
{
|
||||
|
@ -4,7 +4,7 @@ To demonstrate the problems of strong coupling, let's move on to *interesting* t
|
||||
|
||||
So, let's add one more endpoint for registering the partner's own recipe:
|
||||
|
||||
```
|
||||
```json
|
||||
// Adds new recipe
|
||||
POST /v1/recipes
|
||||
{
|
||||
@ -24,7 +24,7 @@ At first glance, this appears to be a reasonably simple interface, explicitly de
|
||||
|
||||
The first problem is obvious to those who thoroughly read the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter: product properties must be localized. This leads us to the first change:
|
||||
|
||||
```
|
||||
```json
|
||||
"product_properties": {
|
||||
// "l10n" is the standard abbreviation
|
||||
// for "localization"
|
||||
@ -57,7 +57,7 @@ To exacerbate matters, let us state that the inverse principle is also true: hig
|
||||
|
||||
We have already identified a localization context. There is a set of languages and regions supported by our API, and there are requirements for what partners must provide to make the API work in a new region. Specifically, there must be a formatting function to represent beverage volume somewhere in our API code, either internally or within an SDK:
|
||||
|
||||
```
|
||||
```typescript
|
||||
l10n.volume.format = function(
|
||||
value, language_code, country_code
|
||||
) { … }
|
||||
@ -73,7 +73,7 @@ l10n.volume.format = function(
|
||||
|
||||
To ensure our API works correctly with a new language or region, the partner must either define this function or indicate which pre-existing implementation to use through the partner API, like this:
|
||||
|
||||
```
|
||||
```json
|
||||
// Add a general formatting rule
|
||||
// for the Russian language
|
||||
PUT /formatters/volume/ru
|
||||
@ -101,7 +101,7 @@ so the aforementioned `l10n.volume.format` function implementation can retrieve
|
||||
|
||||
Let's address the `name` and `description` problem. To reduce the coupling level, we need to formalize (probably just for ourselves) a “layout” concept. We request the provision of the `name` and `description` fields not because we theoretically need them but to present them in a specific user interface. This particular UI might have an identifier or a semantic name associated with it:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
@ -136,7 +136,7 @@ GET /v1/layouts/{layout_id}
|
||||
|
||||
Thus, the partner can decide which option better suits their needs. They can provide mandatory fields for the standard layout:
|
||||
|
||||
```
|
||||
```json
|
||||
PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
@ -147,7 +147,7 @@ Alternatively, they can create their own layout and provide the data fields it r
|
||||
|
||||
Ultimately, our interface would look like this:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
@ -156,7 +156,7 @@ POST /v1/recipes
|
||||
|
||||
This conclusion might seem highly counter-intuitive, but the absence of fields in a `Recipe` simply tells us that this entity possesses no specific semantics of its own. It serves solely as an identifier of a context, a way to indicate where to find the data needed by other entities. In the real world, we should implement a builder endpoint capable of creating all the related contexts with a single request:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
@ -191,7 +191,7 @@ POST /v1/recipe-builder
|
||||
|
||||
We should also note that providing a newly created entity identifier from the requesting side is not the best practice. However, since we decided from the very beginning to keep recipe identifiers semantically meaningful, we have to live on with this convention. Obviously, there is a risk of encountering collisions with recipe names used by different partners. Therefore, we actually need to modify this operation: either a partner must always use a pair of identifiers (e.g., the recipe id plus the partner's own id), or we need to introduce composite identifiers, as we recommended earlier in the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// The first part of the composite
|
||||
@ -212,7 +212,7 @@ Also note that this format allows us to maintain an important extensibility poin
|
||||
|
||||
**NB**: A mindful reader might have noticed that this technique was already used in our API study much earlier in the “[Separating Abstraction Levels](#api-design-separating-abstractions)” chapter regarding the “program” and “program run” entities. Indeed, we can propose an interface for retrieving commands to execute a specific recipe without the `program-matcher` endpoint, and instead, do it this way:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* A description of how to
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
In the previous chapter, we demonstrated how breaking strong coupling of components leads to decomposing entities and collapsing their public interfaces down to a reasonable minimum. But let us return to the question we previously mentioned in the “[Extending through Abstracting](#back-compat-abstracting-extending)” chapter: how should we parametrize the order preparation process implemented via a third-party API? In other words, what *is* the `order_execution_endpoint` required in the API type registration handler?
|
||||
|
||||
```
|
||||
```json
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
…
|
||||
@ -14,7 +14,7 @@ PUT /v1/api-types/{api_type}
|
||||
|
||||
From general considerations, we may assume that every such API would be capable of executing three functions: running a program with specified parameters, returning the current execution status, and finishing (canceling) the order. An obvious way to provide the common interface is to require these three functions to be executed via a remote call, let's say, like this:
|
||||
|
||||
```
|
||||
```json
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
…
|
||||
@ -67,7 +67,7 @@ In our case we need to implement the following mechanisms:
|
||||
|
||||
There are different techniques to organize this data flow (see the [corresponding chapter](#api-patterns-push-vs-poll) of the “API Patterns” Section of this book). Basically, we always have two contexts and a two-way data pipe in between. If we were developing an SDK, we would express the idea with emitting and listening events, like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
/* Partner's implementation of the program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(
|
||||
@ -125,7 +125,7 @@ One more important feature of weak coupling is that it allows an entity to have
|
||||
|
||||
It becomes obvious from what was said above that two-way weak coupling means a significant increase in code complexity on both levels, which is often redundant. In many cases, two-way event linking might be replaced with one-way linking without significant loss of design quality. That means allowing a low-level entity to call higher-level methods directly instead of generating events. Let's alter our example:
|
||||
|
||||
```
|
||||
```typescript
|
||||
/* Partner's implementation of the program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(
|
||||
@ -172,7 +172,7 @@ In conclusion, as higher-level APIs are evolving more slowly and much more consi
|
||||
|
||||
**NB**: Many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In the Redux paradigm, the code above would look like this:
|
||||
|
||||
```
|
||||
```typescript
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
@ -189,7 +189,7 @@ program.context.on(
|
||||
|
||||
Let us note that this approach *in general* doesn't contradict the weak coupling principle but violates another one — abstraction levels isolation — and therefore isn't very well suited for writing branchy APIs with high hierarchy trees. In such systems, it's still possible to use a global or quasi-global state manager, but you need to implement event or method call propagation through the hierarchy, i.e., ensure that a low-level entity always interacts with its closest higher-level neighbors only, delegating the responsibility of calling high-level or global methods to them.
|
||||
|
||||
```
|
||||
```typescript
|
||||
program.context.on(
|
||||
'takeout_requested',
|
||||
() => {
|
||||
@ -203,7 +203,7 @@ program.context.on(
|
||||
);
|
||||
```
|
||||
|
||||
```
|
||||
```typescript
|
||||
// program.context.dispatch implementation
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context calls its own
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
After reviewing the previous chapter, the reader may wonder why this dichotomy exists in the first place, i.e., why do some HTTP APIs rely on HTTP semantics, while others reject it in favor of custom arrangements, and still others are stuck somewhere in between? For example, if we consider the JSON-RPC response format,[ref JSON-RPC 2.0 Specification. Response object](https://www.jsonrpc.org/specification#response_object) we quickly notice that it could be replaced with standard HTTP protocol functionality. Instead of this:
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{
|
||||
|
@ -6,7 +6,7 @@ To describe the semantics and formats, we will refer to the brand-new RFC 9110[r
|
||||
|
||||
An HTTP request consists of (1) applying a specific verb to a URL, stating (2) the protocol version, (3) additional meta-information in headers, and (4) optionally, some content (request body):
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders HTTP/1.1
|
||||
Host: our-api-host.tld
|
||||
Content-Type: application/json
|
||||
@ -23,7 +23,7 @@ Content-Type: application/json
|
||||
|
||||
An HTTP response to such a request includes (1) the protocol version, (2) a status code with a corresponding message, (3) response headers, and (4) optionally, response content (body):
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/orders/123
|
||||
Content-Type: application/json
|
||||
@ -137,12 +137,12 @@ One parameter might be placed in different components of an HTTP request. For ex
|
||||
* A path, e.g., `/v1/{partner_id}/orders`
|
||||
* A query parameter, e.g. `/v1/orders?partner_id=<partner_id>`
|
||||
* A header value, e.g.
|
||||
```
|
||||
```json
|
||||
GET /v1/orders HTTP/1.1
|
||||
X-ApiName-Partner-Id: <partner_id>
|
||||
```
|
||||
* A field within the request body, e.g.
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/retrieve HTTP/1.1
|
||||
|
||||
{
|
||||
|
@ -20,7 +20,7 @@ We need to apply these principles to an HTTP-based interface, adhering to the le
|
||||
|
||||
Let's talk about organizing HTTP APIs based on a specific example. Imagine an application start procedure: as a rule of thumb, the application requests the current user profile and important information regarding them (in our case, ongoing orders), using the authorization token saved in the device's memory. We can propose a straightforward endpoint for this purpose:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/state HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
→
|
||||
@ -52,11 +52,11 @@ This implies that a request traverses the following path:
|
||||
It is quite obvious that in this setup, we put excessive load on the authorization service as every nested microservice now needs to query it. Even if we abolish checking the authenticity of internal requests, it won't help as services B and C can't know the identifier of the user. Naturally, this leads to the idea of propagating the once-retrieved `user_id` through the microservice mesh:
|
||||
* Gateway D receives a request and exchanges the token for `user_id` through service A
|
||||
* Gateway D queries service B:
|
||||
```
|
||||
```json
|
||||
GET /v1/profiles/{user_id}
|
||||
```
|
||||
and service C:
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user id>
|
||||
```
|
||||
|
||||
@ -85,7 +85,7 @@ Alternatively, we can rely on HTTP caching which is most likely already implemen
|
||||
|
||||
Now let's shift our attention to service C. The results retrieved from it might also be cached. However, the state of an ongoing order changes more frequently than the user's profiles, and returning an invalid state might entail objectionable consequences. However, as discussed in the “[Synchronization Strategies](#api-patterns-sync-strategies)” chapter, we need optimistic concurrency control (i.e., the resource revision) to ensure the functionality works correctly, and nothing could prevent us from using this revision as a cache key. Let service C return a tag describing the current state of the user's orders:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
@ -98,7 +98,7 @@ Then gateway D can be implemented following this scenario:
|
||||
2. Upon receiving a subsequent request:
|
||||
* Fetch the cached state, if any
|
||||
* Query service C passing the following parameters:
|
||||
```
|
||||
```json
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <revision>
|
||||
```
|
||||
@ -109,21 +109,21 @@ Then gateway D can be implemented following this scenario:
|
||||
|
||||
By employing this approach [using `ETag`s to control caching], we automatically get another pleasant bonus. We can reuse the same data in the order creation endpoint design. In the optimistic concurrency control paradigm, the client must pass the actual revision of the `orders` resource to change its state:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: <revision>
|
||||
```
|
||||
|
||||
Gateway D will add the user's identifier to the request and query service C:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
```
|
||||
|
||||
If the revision is valid and the operation is executed, service C might return the updated list of orders alongside the new revision:
|
||||
|
||||
```
|
||||
```json
|
||||
HTTP/1.1 201 Created
|
||||
Content-Location: /v1/orders?user_id=<user_id>
|
||||
ETag: <new revision>
|
||||
@ -153,12 +153,12 @@ Let us reiterate once more that we can achieve exactly the same qualities with R
|
||||
|
||||
Let's elaborate a bit on the no-authorizing service solution (or, to be more precise, the solution with the authorizing functionality being implemented as a library or a local daemon inside services B, C, and D) with all the data embedded in the authorization token itself. In this scenario, every service performs the following actions:
|
||||
1. Receives a request like this:
|
||||
```
|
||||
```json
|
||||
GET /v1/profiles/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
2. Deciphers the token and retrieves a payload. For example, in the following format:
|
||||
```
|
||||
```json
|
||||
{
|
||||
// The identifier of a user
|
||||
// who owns the token
|
||||
@ -171,7 +171,7 @@ Let's elaborate a bit on the no-authorizing service solution (or, to be more pre
|
||||
|
||||
The necessity to compare two `user_id`s might appear illogical and redundant. However, this opinion is invalid; it originates from the widespread (anti)pattern we started the chapter with, namely the stateful determining of operation parameters:
|
||||
|
||||
```
|
||||
```json
|
||||
GET /v1/profile
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
@ -185,7 +185,7 @@ The problem with this approach is that *splitting* these three operations is not
|
||||
|
||||
In the case of the “triple-stacked” access checking endpoint, our only option is implementing a new endpoint with a new interface. With stateless tokens, we might do the following:
|
||||
1. Include in the token *a list* of the users that the token allows access to:
|
||||
```
|
||||
```json
|
||||
{
|
||||
// The list of identifiers
|
||||
// of user profiles accessible
|
||||
|
@ -16,7 +16,7 @@ This convention allows for reflecting almost any API's entity nomenclature decen
|
||||
* A path parameter: `/v1/orders/{id}`
|
||||
* A query parameter: `/orders/{id}?version=1`
|
||||
* A header:
|
||||
```
|
||||
```json
|
||||
GET /orders/{id} HTTP/1.1
|
||||
X-OurCoffeeAPI-Version: 1
|
||||
```
|
||||
@ -83,7 +83,7 @@ The CRUD/HTTP correspondence might appear convenient as every resource is forced
|
||||
|
||||
Let's start with the resource creation operation. As we remember from the “[Synchronization Strategies](#api-patterns-sync-strategies)” chapter, in any important subject area, creating entities must be an idempotent procedure that ideally allows for controlling concurrency. In the HTTP API paradigm, idempotent creation could be implemented using one of the following three approaches:
|
||||
1. Through the `POST` method with passing an idempotency token (in which capacity the resource `ETag` might be employed):
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
|
||||
@ -91,7 +91,7 @@ Let's start with the resource creation operation. As we remember from the “[Sy
|
||||
```
|
||||
|
||||
2. Through the `PUT` method, implying that the entity identifier is generated by the client. Revision still could be used for controlling concurrency; however, the idempotency token is the URL itself:
|
||||
```
|
||||
```json
|
||||
PUT /v1/orders/{order_id} HTTP/1.1
|
||||
If-Match: <revision>
|
||||
|
||||
@ -100,7 +100,7 @@ Let's start with the resource creation operation. As we remember from the “[Sy
|
||||
|
||||
|
||||
3. By creating a draft with the `POST` method and then committing it with the `PUT` method:
|
||||
```
|
||||
```json
|
||||
POST /v1/drafts HTTP/1.1
|
||||
|
||||
{ … }
|
||||
@ -108,7 +108,7 @@ Let's start with the resource creation operation. As we remember from the “[Sy
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/drafts/{id}
|
||||
```
|
||||
```
|
||||
```json
|
||||
PUT /v1/drafts/{id}/commit
|
||||
If-Match: <revision>
|
||||
|
||||
|
@ -4,7 +4,7 @@ The examples of organizing HTTP APIs discussed in the previous chapters were mos
|
||||
|
||||
Imagine that some actor (a client or a gateway) tries to create a new order:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
If-Match: <revision>
|
||||
@ -70,7 +70,7 @@ Additionally, there is a third dimension to this problem in the form of webserve
|
||||
|
||||
All these observations naturally lead us to the following conclusion: if we want to use errors for diagnostics and (possibly) helping clients to recover, we need to include machine-readable metadata about the error subtype and, possibly, additional properties to the error body with a detailed description of the error. For example, as we proposed in the “[Describing Final Interfaces](#api-design-describing-interfaces)” chapter:
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/coffee-machines/search HTTP/1.1
|
||||
|
||||
{
|
||||
@ -125,7 +125,7 @@ Let us also remind the reader that the client must treat unknown `4xx` status co
|
||||
|
||||
However, for internal systems, this argumentation is wrong. To build proper monitoring and notification systems, server errors must contain machine-readable error subtypes, just like the client errors. The same approaches are applicable (either using arbitrary status codes and/or passing error kind as a header); however, this data must be stripped off by a gateway that marks the border between external and internal systems and replaced with general instructions for both developers and end users, describing actions that need to be performed upon receiving an error.
|
||||
|
||||
```
|
||||
```json
|
||||
POST /v1/orders/?user_id=<user id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
|
||||
@ -143,7 +143,7 @@ X-OurCoffeAPI-Error-Kind: db_timeout
|
||||
* which host returned an error
|
||||
*/ }
|
||||
```
|
||||
```
|
||||
```json
|
||||
// The response as returned to
|
||||
// the client. The details regarding
|
||||
// the server error are removed
|
||||
|
@ -23,7 +23,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
1. In client-server APIs, data is passed by value. To refer to some entities, specially designed identifiers need to be used. For example, if we have two sets of entities — recipes and offers — we need to build a map to understand which recipe corresponds to which offer:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Request 'lungo' and 'latte' recipes
|
||||
const recipes = await api
|
||||
.getRecipes(['lungo', 'latte']);
|
||||
@ -55,7 +55,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
This piece of code would be half as long if we received offers from the `api.search` SDK method with a *reference* to a recipe:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Request 'lungo' and 'latte' recipes
|
||||
const recipes = await api
|
||||
.getRecipes(['lungo', 'latte']);
|
||||
@ -78,7 +78,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
2. Client-server APIs are typically decomposed so that one response contains data regarding one kind of entity. Even if the endpoint is composite (i.e., allows for combining data from different sources depending on parameters), it is still the developer's responsibility to use these parameters. The code sample from the previous example would be even shorter if the SDK allowed for the initialization of all related entities:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Request offers for latte and lungo
|
||||
// in the vicinity
|
||||
const offers = await api.search({
|
||||
@ -101,7 +101,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
3. Receiving callbacks in client-server APIs, even if it is a duplex communication channel, is rather inconvenient to work with and requires object mapping as well. Even if a push model is implemented, the resulting client code will be rather bulky:
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Retrieve ongoing orders
|
||||
const orders = await api
|
||||
.getOngoingOrders();
|
||||
@ -134,7 +134,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
Once again, we face a situation where an SDK lacking important features leads to mistakes in applications that use it. It would be much more convenient for a developer if an order object allowed for subscribing to its status updates without the need to learn how it works at the transport level and how to avoid missing an event.
|
||||
|
||||
```
|
||||
```typescript
|
||||
const order = await api
|
||||
.createOrder(…)
|
||||
// No need to subscribe to
|
||||
@ -150,7 +150,7 @@ However, there are also non-trivial problems we face while developing an SDK for
|
||||
|
||||
4. Restoring after encountering business logic-bound errors is typically a complex procedure. As it can hardly be described in a machine-readable manner, client developers have to elaborate on the scenarios on their own.
|
||||
|
||||
```
|
||||
```typescript
|
||||
// Request offers
|
||||
const offers = await api
|
||||
.search(…);
|
||||
|
@ -57,7 +57,7 @@ It is very easy to demonstrate how coupling several subject areas in one entity
|
||||
|
||||
But it is not the end of the story. If the developer still wants exactly this, i.e., to show a coffee shop chain icon (if any) on the order creation button, then what should they do? Following the same logic, we should provide an even more specialized possibility to do so. For example, we can adopt the following logic: if there is a `createOrderButtonIconUrl` property in the data, the icon will be taken from this field. Developers could customize the order creation button by overwriting this `createOrderButtonIconUrl` field for every search result:
|
||||
|
||||
```
|
||||
```typescript
|
||||
const searchBox = new SearchBox({
|
||||
// For simplicity, let's allow
|
||||
// to override the search function
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/fonts/Vollkorn-Italic-VariableFont_wght.ttf
Normal file
BIN
src/fonts/Vollkorn-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
src/fonts/Vollkorn-VariableFont_wght.ttf
Normal file
BIN
src/fonts/Vollkorn-VariableFont_wght.ttf
Normal file
Binary file not shown.
@ -1,10 +1,10 @@
|
||||
const { readFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
|
||||
module.exports = {
|
||||
export const templates = {
|
||||
pageBreak: '<div class="page-break"></div>',
|
||||
|
||||
mainContent: (content) => `<section class="main-content">
|
Loading…
Reference in New Issue
Block a user