HTTP APIs continuation
183
build-graphs.mjs
@ -1,123 +1,92 @@
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
mkdirSync
|
||||
} from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, basename } from 'path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import templates from './src/templates.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dir = process.cwd();
|
||||
const langs = (args[0] || 'en,ru').split(',');
|
||||
const target = args[1];
|
||||
const srcDir = resolve(dir, 'src');
|
||||
const graphDir = resolve(srcDir, 'graphs');
|
||||
const tmpDir = resolve(graphDir, 'tmp');
|
||||
|
||||
if (!existsSync(tmpDir)) {
|
||||
mkdirSync(tmpDir);
|
||||
}
|
||||
|
||||
build(langs, srcDir, graphDir, tmpDir, target).then(
|
||||
() => process.exit(0),
|
||||
(e) => {
|
||||
throw e;
|
||||
async function buildGraphs(langs, target, srcDir, dstDir, tmpDir) {
|
||||
if (!existsSync(tmpDir)) {
|
||||
await mkdir(tmpDir);
|
||||
}
|
||||
);
|
||||
|
||||
async function build(langs, srcDir, graphDir, tmpDir, target) {
|
||||
await buildL10n(langs, srcDir, tmpDir);
|
||||
await buildGraphs(langs, srcDir, graphDir, tmpDir, target);
|
||||
for (const lang of langs) {
|
||||
const graphDir = resolve(srcDir, lang, 'graphs');
|
||||
const targets = target
|
||||
? [resolve(graphDir, target)]
|
||||
: await getGraphList(graphDir);
|
||||
|
||||
console.log(
|
||||
`Lang=${lang}, ${targets.length} .mermaid files to process`
|
||||
);
|
||||
|
||||
for (const t of targets) {
|
||||
await buildGraph(lang, t, dstDir, tmpDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildL10n(langs, srcDir, tmpDir) {
|
||||
const l10n = langs.reduce((l10n, lang) => {
|
||||
const l10nFile = resolve(srcDir, lang, 'l10n.json');
|
||||
const contents = JSON.parse(readFileSync(l10nFile).toString('utf-8'));
|
||||
l10n[lang] = JSON.stringify(contents);
|
||||
return l10n;
|
||||
}, {});
|
||||
async function getGraphList(srcDir) {
|
||||
const files = await readdir(srcDir);
|
||||
const result = [];
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.mermaid')) {
|
||||
result.push(resolve(srcDir, file));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
resolve(tmpDir, 'l10n.js'),
|
||||
`(function(global){global.l10n={${Object.entries(l10n)
|
||||
.map(
|
||||
([lang, content]) =>
|
||||
`${lang}:JSON.parse(${JSON.stringify(content)})`
|
||||
)
|
||||
.join(',\n')}}})(this)`
|
||||
async function buildGraph(lang, target, dstDir, tmpDir) {
|
||||
const targetName = basename(target);
|
||||
console.log(
|
||||
`Processing ${target}, basename: ${targetName} dst: ${dstDir}, tmp: ${tmpDir}`
|
||||
);
|
||||
}
|
||||
|
||||
async function buildGraphs(langs, srcDir, graphDir, tmpDir, target) {
|
||||
const tasks = target
|
||||
? langs.map((lang) => ({
|
||||
lang,
|
||||
target
|
||||
}))
|
||||
: langs.reduce(
|
||||
(tasks, lang) =>
|
||||
tasks.concat(
|
||||
readdirSync(resolve(srcDir, lang, 'graphs')).map(
|
||||
(file) => ({
|
||||
lang,
|
||||
target: file.replace('.yaml', '')
|
||||
})
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
tasks.map(({ lang, target }) =>
|
||||
buildGraph({
|
||||
lang,
|
||||
target,
|
||||
yaml: readFileSync(
|
||||
resolve(srcDir, lang, 'graphs', target + '.yaml'),
|
||||
'utf-8'
|
||||
),
|
||||
graphDir,
|
||||
tmpDir
|
||||
})
|
||||
)
|
||||
const tmpFileName = resolve(tmpDir, `${targetName}.${lang}.html`);
|
||||
const graph = await readFile(target, 'utf-8');
|
||||
await writeFile(tmpFileName, templates.graphHtmlTemplate(graph));
|
||||
console.log(`Tmp file ${tmpFileName} written`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
product: 'chrome',
|
||||
defaultViewport: {
|
||||
deviceScaleFactor: 2,
|
||||
width: 1000,
|
||||
height: 1000
|
||||
}
|
||||
});
|
||||
const outFile = resolve(
|
||||
dstDir,
|
||||
`${targetName.replace('.mermaid', '')}.${lang}.png`
|
||||
);
|
||||
const page = await browser.newPage();
|
||||
await page.goto(tmpFileName, {
|
||||
waitUntil: 'networkidle0'
|
||||
});
|
||||
const body = await page.$('body');
|
||||
await body.screenshot({
|
||||
path: outFile,
|
||||
type: 'png',
|
||||
captureBeyondViewport: true
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function buildGraph({ lang, target, yaml, graphDir, tmpDir }) {
|
||||
const jsTmpFileName = `wrapped-${lang}-${target}.js`;
|
||||
writeFileSync(
|
||||
resolve(tmpDir, jsTmpFileName),
|
||||
`document.querySelector('.mermaid').innerHTML = ${JSON.stringify(
|
||||
yaml.replace(/\\n/g, '\\n')
|
||||
)};`
|
||||
);
|
||||
// console.log(` Open ${inFile}`);
|
||||
// const browser = await puppeteer.launch({
|
||||
// headless: true,
|
||||
// product: 'chrome',
|
||||
// defaultViewport: {
|
||||
// deviceScaleFactor: 2,
|
||||
// width: 1000,
|
||||
// height: 1000
|
||||
// }
|
||||
// });
|
||||
// const outFile = resolve(dir, 'src', 'img', `graph-${source}.png`);
|
||||
// const page = await browser.newPage();
|
||||
|
||||
// await page.goto(inFile, {
|
||||
// waitUntil: 'networkidle0'
|
||||
// });
|
||||
// const body = await page.$('body');
|
||||
// await body.screenshot({
|
||||
// path: outFile,
|
||||
// type: 'png',
|
||||
// captureBeyondViewport: true
|
||||
// });
|
||||
// console.log(` ${outFile} saved`);
|
||||
|
||||
// await browser.close();
|
||||
}
|
||||
buildGraphs(
|
||||
langs,
|
||||
target,
|
||||
resolve(dir, 'src'),
|
||||
resolve(dir, 'src', 'img', 'graphs'),
|
||||
resolve(dir, '.tmp')
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
})
|
||||
.finally(() => {
|
||||
console.log('Graph build done');
|
||||
process.exit(0);
|
||||
});
|
||||
|
BIN
docs/API.en.epub
BIN
docs/API.en.pdf
180
docs/API.ru.html
@ -14,6 +14,7 @@
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"build-v1": "node build-v1.mjs",
|
||||
"build-graphs": "node build-graphs.mjs",
|
||||
"build-clean": "node build.mjs --clean"
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-monospace;
|
||||
src: url(../fonts/RobotoMono-Regular.ttf);
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: local-monospace, monospace !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.position-absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
}
|
@ -58,6 +58,8 @@ URLs can be decomposed into sub-components, each of which is optional. While the
|
||||
|
||||
In HTTP requests, the scheme, host, and port are usually (but not always) omitted and presumed to be equal to the connection parameters. (Fielding actually names this arrangement one of the biggest flaws in the protocol design.)
|
||||
|
||||
Traditionally, it is implied that paths describe a strict hierarchy of resource subordination (for example, the URL of a specific coffee machine in our API could look like `places/{id}/coffee-machines/{id}`, since a coffee machine strictly belongs to one coffee shop), while query parameters express non-strict hierarchies and operation parameters (for example, the URL for searching listings could look like `search?location=<map point>`).
|
||||
|
||||
Additionally, the standard contains rules for serializing, normalizing, and comparing URLs, knowing which can be useful for an HTTP API developer.
|
||||
|
||||
##### Headers
|
@ -42,7 +42,7 @@ Of course, most of these instruments will work with APIs that utilize other para
|
||||
|
||||
Additionally, let's emphasize that the HTTP API paradigm is currently the default choice for *public* APIs. Because of the aforementioned reasons, partners can integrate an HTTP API without significant obstacles, regardless of their technological stack. Moreover, the prevalence of the technology lowers the entry barrier and the requirements for the qualification of partners' engineers.
|
||||
|
||||
The main disadvantage of HTTP APIs is that you have to rely on intermediary agents, from client frameworks to API gateways, to read the request metadata and perform actions based on it. This includes regulating timeouts and retry policies, logging, proxying, and sharding requests, among other things, without your consent. Since HTTP-related specifications are complex and the concepts of REST can be challenging to comprehend, and software engineers do not always write perfect code, these intermediary agents (including partners' developers!) will sometimes interpret HTTP metadata *incorrectly*, especially when dealing with exotic and hard-to-implement standards. Usually, one of the stated reasons for developing new RPC frameworks is the desire to make working with the protocol simple and consistent, thereby reducing the likelihood of errors when writing integration code.
|
||||
The main disadvantage of HTTP APIs is that you have to rely on intermediary agents, from client frameworks to API gateways, to read the request metadata and perform actions based on it *without your consent*. This includes regulating timeouts and retry policies, logging, proxying, and sharding requests, among other things. Since HTTP-related specifications are complex and the concepts of REST can be challenging to comprehend, and software engineers do not always write perfect code, these intermediary agents (including partners' developers!) will sometimes interpret HTTP metadata *incorrectly*, especially when dealing with exotic and hard-to-implement standards. Usually, one of the stated reasons for developing new RPC frameworks is the desire to make working with the protocol simple and consistent, thereby reducing the likelihood of errors when writing integration code.
|
||||
|
||||
#### The Question of Performance
|
||||
|
@ -0,0 +1,214 @@
|
||||
### [Organizing an HTTP API Based on the REST Principles][http-api-rest-organizing]
|
||||
|
||||
Now let's discuss the specifics: what does it mean exactly to “follow the protocol's semantics” and “develop applications in accordance to the REST architectural style.” Remember, we talk about the following principles:
|
||||
* Operations must be stateless
|
||||
* Data must be marked as cacheable or non-cacheable
|
||||
* There must be a uniform interface of communication between components
|
||||
* Network systems are layered.
|
||||
|
||||
We need to apply these principles to an HTTP-based interface, sticking to the letter and soul of the standard:
|
||||
* A URL of an operation must point to a resource the operation is applied to, and be a cache key for `GET` operations and an idempotency key for `PUT` and `DELETE` ones.
|
||||
* HTTP verbs must be used according to their semantics.
|
||||
* Properties of the operation, such as safety, cacheability, idempotency, and also the symmetry of `GET` / `PUT` / `DELETE` methods, request and response headers, response status codes, etc., must be aligned with the specification.
|
||||
|
||||
**NB**: we're deliberately skipping many nuances of the standard:
|
||||
* a caching key might be composite [include request headers] if the response contains the `Vary` header.
|
||||
* an idempotency key might composite as well if the request contains the `Range` header.
|
||||
* if there are no explicit cache control headers, the caching policy will be defined not by the HTTP verb alone, but also by the response status code, other request and response headers, and platform policies.
|
||||
|
||||
To keep the chapter size reasonable, we will not discuss these details, but we hardly recommend reading the standard thoroughly.
|
||||
|
||||
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 the important information regarding them (in our case, ongoing orders), using the authorization token saved in the device's memory. We can propose a quite straightforward endpoint for this purpose:
|
||||
|
||||
```
|
||||
GET /v1/state HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{ "profile", "orders" }
|
||||
```
|
||||
|
||||
Upon getting such a request, the server will check the validity of the token, fetch the identifier of the user `user_id`, query the database, and return the user's profile and the list of their orders.
|
||||
|
||||
This simple monolith API service violates several REST architectural principles:
|
||||
* There is no obvious solution for caching responses on the client side (the order state is being frequently updated and there is no sense in saving it)
|
||||
* The operation is stateful as the server must keep tokens in memory to retrieve the user identifier, to which the requested data is bound.
|
||||
* The system comprises a single layer, and therefore, the question of a uniform interface is meaningless.
|
||||
|
||||
While scaling the backend is not a problem, this approach works. However, with the audience and the service's functionality (and the number of software engineers working on it) growing, we sooner or later face the fact that this monolith architecture costs too much in overhead charges. Imagine we decided to decompose this single backend into four microservices:
|
||||
* Service A checks authentication tokens
|
||||
* Service B stores user accounts
|
||||
* Service C stores orders
|
||||
* Gateway Service D routes incoming requests to other microservices.
|
||||
|
||||
This implies that a request traverses the following path:
|
||||
* Gateway D receives the request and sends it to both Service C and Service D.
|
||||
* C and D call Service A to check the authentication token (passed as a proxied `Authorization` header or as an explicit request parameter) and return the requested data — the user's profile and the list of their orders.
|
||||
* Service D merges the responses and sends them back to the client.
|
||||
|
||||
[]()
|
||||
|
||||
It is quite obvious that in this setup, we put an 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 once retrieved `user_id` through the microservice mesh:
|
||||
* Gateway D receives a request and exchanges token for `user_id` through service A
|
||||
* Gateway D queries service B:
|
||||
```
|
||||
GET /v1/profiles/{user_id}
|
||||
```
|
||||
and service C:
|
||||
```
|
||||
GET /v1/orders?user_id=<user id>
|
||||
```
|
||||
|
||||
[]()
|
||||
|
||||
**NB**: we used the `/v1/orders?user_id` notation and not, let's say, `/v1/users/{user_id}/orders`, because of two reasons:
|
||||
* The orders service stores orders, not users, and it would be logical to reflect this fact in URLs
|
||||
* If in the future, we require to allow several users to share one order, the `/v1/orders?user_id` notation will better reflect the relations between entities.
|
||||
|
||||
We will discuss organizing URLs in HTTP APIs in more detail in the next chapter.
|
||||
|
||||
Now both services A and B receive the request in the form that makes it redundant to perform additional actions (identifying user through service A) to obtain the result. By doing so, we refactored the interface *allowing a microservice to stay within its area of responsibility*, thus making it compliant with the stateless constraint.
|
||||
|
||||
Let us emphasize that the difference between **stateless** and **stateful** approaches is not clearly defined. Microservice B stores the client state (i.e., the user profile) and therefore is stateful according to Fielding's dissertation. However, we rather intuitively agree that storing profiles and just checking token validity is a better approach than doing all the same operations plus having the token cache. In fact, we rather embrace the *logical* principle of separating abstraction levels which we discussed in detail in the [corresponding chapter](#api-design-separating-abstractions):
|
||||
* **Microservices should be designed to clearly outline their responsibility area and to avoid storing data belonging to other abstraction levels**
|
||||
* External entities should be just context identifiers, and microservices should not interpret them
|
||||
* If operations with external data are unavoidable (for example, the authority making a call must be checked), the **operations must be organized in a way that reduces them into checking the data integrity**.
|
||||
|
||||
In our example, we might get rid of unnecessary calls to service A in a different manner — by using stateless tokens, for example, employing the [JWT standard](https://www.rfc-editor.org/rfc/rfc7519). Then services B and C would be capable of deciphering tokens and extracting user identifiers on their own.
|
||||
|
||||
Let us make a step further and notice that the user profile rarely changes, so there is no need to retrieve it each time as we might cache it at the gateway level. To do so, we must form a cache key which is essentially the client identifier. We can do it taking a long way:
|
||||
* Before requesting service B, generate a cache key and probe the cache
|
||||
* If the data is in the cache, respond with the cached snapshot; if it is not, query service B and cache the response.
|
||||
|
||||
Alternatively, we can rely on HTTP caching which is most likely already implemented in the framework we use or is easily added as a plugin. In this scenario, gateway D requests the `/v1/profiles/{user_id}` resource in service B, retrieves the data alongside the cache control headers, and caches it locally.
|
||||
|
||||
Now let's avert 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 an optimistic concurrency control (i.e., the resource revision) to make the functionality work correctly, and nothing could prevent us from using this revision as a cache key. Let service C return us a tag describing the current state of the user's orders:
|
||||
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
ETag: <revision>
|
||||
…
|
||||
```
|
||||
|
||||
Then gateway D might be implemented following this scenario:
|
||||
1. Cache the `GET /v1/orders?user_id=<user_id>` response using a URL as a cache key
|
||||
2. Upon receiving a subsequent request:
|
||||
* Fetch the cached state, if any
|
||||
* Query service C passing the following parameters:
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <revision>
|
||||
```
|
||||
* If service C responds with `304 Not Modified`, return the cached state
|
||||
* If service C responds with new version of the data, cache it and then return to the client.
|
||||
|
||||
[]()
|
||||
|
||||
By employing this approach [with using URLs as caching and idempotency keys], we automatically get another pleasant bonus. We can reuse the same data in the order creation endpoint design. In optimistic concurrency control paradigm, the client must pass an actual revision of the `orders` resource to change its state:
|
||||
|
||||
```
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: <revision>
|
||||
```
|
||||
|
||||
Gateway D will add the user's identifier to the request and query service C:
|
||||
|
||||
```
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <revision>
|
||||
```
|
||||
|
||||
If the revision is actual and the operation is executed, service C might return the updated list of orders alongside the new revision:
|
||||
|
||||
```
|
||||
HTTP/1.1 201 Created
|
||||
Content-Location: /v1/orders?user_id=<user_id>
|
||||
ETag: <new revision>
|
||||
|
||||
{ /* The updated list of orders */ }
|
||||
```
|
||||
|
||||
and gateway D will update the cache with the actual data snapshot.
|
||||
|
||||
[]()
|
||||
|
||||
**Importantly**, after this API refactoring, we end up with a system in which we can *remove gateway D* and make the client itself perform its duty. Nothing prevents the client from:
|
||||
* Storing `user_id` on its side (or retrieve it from the token, if the format allows it) as well as the last known `ETag` of the order list
|
||||
* Instead of a single `GET /v1/state` request perform two HTTP calls (`GET /v1/profiles/{user_id}` and `GET /v1/orders?user_id=<user_id>`) which might be multiplexed thanks to HTTP/2
|
||||
* Caching the result on its side using standard libraries and/or plugins.
|
||||
|
||||
From the perspective of implementing services B and C, the presence of a gateway affects nothing, with an exception of security checks. Vice versa, we might add a nested gateway to, let's say, split order storage into “cold” and “hot” ones, or make either service B or C work as a gateway themselves.
|
||||
|
||||
If we refer to the beginning of the chapter, we will find that we designed a system fully compliant with the REST architectural principles:
|
||||
* Requests to services contain all the data needed to process the request
|
||||
* The interaction interface is uniform to the extent that we might freely transfer gateway functions to the client or another intermediary agent
|
||||
* Every resource is marked as cacheable
|
||||
|
||||
Let us reiterate once more that we can achieve exactly the same qualities with RPC protocols by designing formats for describing caching policies, resource versions, reading and modifying operation metadata, etc. However, the author of this book would firstly, express doubts regarding the quality of such a custom solution and secondly, emphasize the considerable amount of code needed to be written to realize all the functionality stated above.
|
||||
|
||||
**NB**: passing variables as either query parameters or path fragments affects not only readability. If gateway D is implemented as a stateless proxy with a declarative configuration, than receiving a request like:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
|
||||
and transforming into a pair of nested sub-requests:
|
||||
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/orders?user_id=<user_id>`
|
||||
|
||||
would be much more convenient than extracting identifiers from the path or some header and putting them into query parameters. The former operation [replacing one path with another] is easily described declaratively and is supported by most server software out of the box. And vice versa, retrieving data from various components and rebuilding requests is a complex functionality that most likely requires a gateway supporting scripting languages and/or plugins for such manipulations. conversely, automated creation of monitoring panels in serives like Prometheus+Grafana bundle is much easier to organize by path prefix than by a synthetic key computed from request parameters.
|
||||
|
||||
All this leads us to a conclusion than maintaining identical URL structure when only path changes while custom parameters passed in queries will lead to even more uniform interface, although less readable and semantical. In internal systems, preferring convenience of usage over readability is sometimes an obvious decision. In public APIs, we would rather discourage implementing this approach.
|
||||
|
||||
#### Authorizing Stateless Requests
|
||||
|
||||
Let's elaborate a bit over the solution without an authorizing service (or, to be more precise, with authorizing functionality being implemented as a library or a local SDK within 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:
|
||||
```
|
||||
GET /v1/profiles/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
2. Deciphers the token and retrieves a payload. For example, in the following format:
|
||||
```
|
||||
{
|
||||
// The identifier of a user
|
||||
// who owns the token
|
||||
"user_id",
|
||||
// Token creation timestamp
|
||||
"iat"
|
||||
}
|
||||
```
|
||||
3. Checks that the permissions stated in the token payload match the operation parameters (in our case, compares `user_id` passed as a query parameter with `user_id` encrypted in the token itself) and decides on the validity of the operation.
|
||||
|
||||
The necessity to compare two `user_id`s might appear illogical and redundant. However, this opinion is invalid; it originates in the widespread (anti)pattern we started the chapter with, namely the stateful determining of operation parameters:
|
||||
|
||||
```
|
||||
GET /v1/profile
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Such an endpoint effectively performs all three access control operations in one place:
|
||||
* *Authenticates* the user by searching the passed token in the token storage
|
||||
* *Identifies* the user by retrieving the identifier bound to the token
|
||||
* *Authorizes* the operation by enriching its parameters and *implicitly* stipulating that users always have access to their own data.
|
||||
|
||||
The problem with this approach is that *splitting* these three operations is not possible. Let us remind the reader about the authorization options we described in the “[Authenticating Partners and Authorizing API Calls](#api-patterns-aa)” chapter: in a complex enough system we will have to solve the problem of allowing user X to make actions on behalf of user Y. For example, if we sell the functionality of ordering beverages as a B2B API, the CEO of the partner company might want to control (personally or programmatically) the orders the employees make.
|
||||
|
||||
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:
|
||||
```
|
||||
{
|
||||
// The list of identifiers
|
||||
// of user profiles accessible
|
||||
// with the token
|
||||
"user_ids",
|
||||
// Token creation timestamp
|
||||
"iat"
|
||||
}
|
||||
```
|
||||
2. Modify the permission-checking procedure (i.e., make changes in the code of a local SDK or a daemon) so that it allows performing the action if the `user_id` query parameter value is included in the `user_ids` list from the token payload.
|
||||
|
||||
This approach might be further enhanced by introducing granular permissions to carry out specific actions, access levels, additional ACL service calls, etc.
|
||||
|
||||
Importantly, the visible redundancy of the format ceases to exist: `user_id` in the request is now not duplicated in the token payload as these identifiers carry different semantics: *on which resource* the operation is performed against *who* performs it. The two often coincide, but this coincidence is just a special case. Unfortunately, this doesn't negate the fact that it's quite easy simply to forget to implement this unobvious check in the code. This is the way.
|
@ -1 +0,0 @@
|
||||
### HTTP API Organization Principles
|
19
src/en/graphs/http-api-organizing-01.mermaid
Normal file
@ -0,0 +1,19 @@
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant D as Gateway D
|
||||
participant A as Authorization<br/>Service A
|
||||
participant B as User Profiles<br/>Service B
|
||||
participant C as Order<br/>Service C
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
par
|
||||
D->>+B: GET /v1/profile<br/><token>
|
||||
B->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-B: { status, user_id }
|
||||
B-->>-D: { status, profile }
|
||||
and
|
||||
D->>+C: GET /v1/orders<br/><token>
|
||||
C->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-C: { status, user_id }
|
||||
C-->>-D: { status, orders }
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
17
src/en/graphs/http-api-organizing-02.mermaid
Normal file
@ -0,0 +1,17 @@
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant D as Gateway D
|
||||
participant A as Authorization<br/>Service A
|
||||
participant B as User Profiles<br/>Service B
|
||||
participant C as Order<br/>Service C
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/>#60;token#62;
|
||||
A-->>-D: { status, user_id }
|
||||
par
|
||||
D->>+B: GET /v1/profiles/{user_id}
|
||||
B-->>-D: { profile }
|
||||
and
|
||||
D->>+C: GET /v1/orders?user_id=#60;user_id#62;
|
||||
C-->>-D: { orders }
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
24
src/en/graphs/http-api-organizing-03.mermaid
Normal file
@ -0,0 +1,24 @@
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant D as Gateway D
|
||||
participant A as Authorization<br/>Service A
|
||||
participant B as User Profiles<br/>Service B
|
||||
participant C as Order<br/>Service C
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-D: { status, user_id }
|
||||
par
|
||||
alt Cached profile found
|
||||
D->>+B: GET /v1/profiles/{user_id}
|
||||
B-->>-D: 200 OK<br/>Cache-Control: #60;parameters#62;<br/>{ profile }
|
||||
end
|
||||
and
|
||||
D->>+C: GET /v1/orders?user_id=#60;user_id#62;<br/>If-None-Match: #60;revision#62;
|
||||
alt Wrong revision
|
||||
C-->>D: 200 OK<br/>ETag: #60;revision#62;<br/>{ orders }
|
||||
else Actual revision
|
||||
C-->>D: 304 Not Modified
|
||||
end
|
||||
deactivate C
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
17
src/en/graphs/http-api-organizing-04.mermaid
Normal file
@ -0,0 +1,17 @@
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant D as Gateway D
|
||||
participant A as Authorization<br/>Service A
|
||||
participant B as User Profiles<br/>Service B
|
||||
participant C as Order<br/>Service C
|
||||
U->>+D: POST /v1/orders HTTP/1.1<br/>If-Match: #60;revision#62;<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-D: { status, user_id }
|
||||
D->>+C: POST /v1/orders?user_id=#60;user_id#62;<br/>If-Match: #60;revision#62;
|
||||
alt Actual revision
|
||||
C-->>D: 201 Created<br/>Content-Location: /v1/orders?user_id=<user_id><br/>ETag: #60;New revision#62;<br/>{ orders }
|
||||
else Wrong Revision
|
||||
C-->>D: 409 Conflict
|
||||
end
|
||||
deactivate C
|
||||
D-->>-U: 201 Created<br/>ETag: #60;New revision#62;<br/>{ orders }
|
@ -3,7 +3,7 @@
|
||||
"author": "Sergey Konstantinov",
|
||||
"chapter": "Chapter",
|
||||
"toc": "Table of Contents",
|
||||
"description": "API-first development is one of the hottest technical topics nowadays since many companies have started to realize that APIs serves as a multiplier to their opportunities — but it amplifies the design mistakes as well. This book is written to share expertise and describe best practices in designing and developing APIs. It comprises six sections dedicated to the following topics: the API design, API patterns, maintaining backward compatibility, HTTP API & REST, SDK and UI libraries, API product management.",
|
||||
"description": "API-first development is one of the hottest technical topics nowadays since many companies have started to realize that APIs serves as a multiplier to their opportunities — but it amplifies the design mistakes as well. This book is written to share expertise and describe best practices in designing and developing APIs. It comprises six sections dedicated to the following topics: the API design, API patterns, maintaining backward compatibility, HTTP APIs & REST, SDKs and UI libraries, API product management.",
|
||||
"publisher": "Sergey Konstantinov",
|
||||
"copyright": "© Sergey Konstantinov, 2023",
|
||||
"locale": "en_US",
|
||||
@ -17,6 +17,7 @@
|
||||
],
|
||||
"imageCredit": "Photo by <a href=\"http://linkedin.com/in/zloylos/\">Denis Hananein</a>"
|
||||
},
|
||||
"ctl": "Click to enlarge",
|
||||
"landingFile": "index.html",
|
||||
"url": "https://twirl.github.io/The-API-Book/",
|
||||
"favicon": "/img/favicon.png",
|
||||
@ -77,8 +78,8 @@
|
||||
"<ul><li>The API design</li>",
|
||||
"<li>API patterns</li>",
|
||||
"<li>Backward compatibility</li>",
|
||||
"<li>HTTP API & REST</li>",
|
||||
"<li>SDK and UI libraries</li>",
|
||||
"<li>HTTP APIs & REST architectural principles</li>",
|
||||
"<li>SDKs and UI libraries</li>",
|
||||
"<li>API product management.</ul>",
|
||||
"<p class=\"text-align-left\">Illustrations & inspiration by Maria Konstantinova · <a href=\"https://www.instagram.com/art.mari.ka/\">art.mari.ka</a></p>",
|
||||
"<img class=\"cc-by-nc-img\" alt=\"Creative Commons «Attribution-NonCommercial» Logo\" src=\"https://i.creativecommons.org/l/by-nc/4.0/88x31.png\"/>",
|
||||
@ -99,8 +100,8 @@
|
||||
"<ul><li>— The API design</li>",
|
||||
"<li>— API patterns</li>",
|
||||
"<li>— Backward compatibility</li>",
|
||||
"<li>— HTTP API & REST</li>",
|
||||
"<li>— SDK and UI libraries</li>",
|
||||
"<li>— HTTP APIs & REST architectural principles</li>",
|
||||
"<li>— SDKs and UI libraries</li>",
|
||||
"<li>— API product management.</ul>",
|
||||
"<p>Illustration & inspiration: <a href=\"https://www.instagram.com/art.mari.ka/\">art.mari.ka</a>.</p>"
|
||||
],
|
||||
|
@ -1,25 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="../css/graph.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="mermaid"></div>
|
||||
<script type="text/javascript" src="tmp/l10n.js"></script>
|
||||
<script type="text/javascript">
|
||||
const params = document.location.href
|
||||
.split('?', 2)[1]
|
||||
.split('&')
|
||||
.reduce((params, str) => {
|
||||
const [key, value] = str.split('=');
|
||||
params[key] = decodeURIComponent(value);
|
||||
return params;
|
||||
}, {});
|
||||
|
||||
document.write(
|
||||
`<script type="text/javascript" src="tmp/wrapped-${params.lang}-${params.graph}.js">\<\/script>`
|
||||
);
|
||||
</script>
|
||||
<script src="lib/mermaid.min.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1 +0,0 @@
|
||||
document.querySelector('.mermaid').innerHTML = "flowchart TB\n subgraph API рецептов\n a1[[GET /v1/recipes]]\n end\n";
|
BIN
src/img/graphs/http-api-organizing-01.en.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
src/img/graphs/http-api-organizing-01.ru.png
Normal file
After Width: | Height: | Size: 138 KiB |
BIN
src/img/graphs/http-api-organizing-02.en.png
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
src/img/graphs/http-api-organizing-02.ru.png
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
src/img/graphs/http-api-organizing-03.en.png
Normal file
After Width: | Height: | Size: 179 KiB |
BIN
src/img/graphs/http-api-organizing-03.ru.png
Normal file
After Width: | Height: | Size: 182 KiB |
BIN
src/img/graphs/http-api-organizing-04.en.png
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
src/img/graphs/http-api-organizing-04.ru.png
Normal file
After Width: | Height: | Size: 162 KiB |
@ -59,6 +59,8 @@ URL принято раскладывать на составляющие, ка
|
||||
|
||||
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения). (Это соглашение, кстати, Филдинг считает самой большой проблемой дизайна протокола.)
|
||||
|
||||
Традиционно считается, что части пути описывают строгую иерархию подчинения ресурсов (например, URL конкретной кофе-машины в нашем API мог бы выглядеть как `/places/{id}/coffee-machines/{id}`, поскольку кофе-машина принадлежит строго одной кофейне), а через запрос выражаются нестрогие иерархии и параметры операций (например, URL поиска предложений мог бы выглядеть как `/search?location=<точка на карте>`).
|
||||
|
||||
Также стандарт содержит правила сериализации, нормализации и сравнения URL, которые в целом полезно знать разработчику HTTP API.
|
||||
|
||||
##### Заголовки
|
||||
@ -71,6 +73,13 @@ URL принято раскладывать на составляющие, ка
|
||||
|
||||
Помимо прочего заголовки используются как управляющие конструкции — это т.н. «content negotiation», т.е. договорённость клиента и сервера о формате ответа (через заголовки `Accept*`) и условные запросы, позволяющие сэкономить трафик на возврате ответа целиком или частично (через заголовки `If-*`-заголовки, такие как `If-Range`, `If-Modified-Since` и так далее).
|
||||
|
||||
Стандарт предписывает как минимум один обязательный заголовок — `Host`. Ещё несколько заголовков необязательны, но на практике почти всегда используются:
|
||||
* `Accept`, `Accept-Encoding`, `Content-Type` и `Content-Encoding` для описания форматов данных запроса и ответа;
|
||||
* `Date` для синхронизации часов клиента и сервера;
|
||||
* `Content-Length` для описания размера передаваемых данных (некоторые прокси вообще не пропускают запросы без `Content-Length`).
|
||||
|
||||
В дальнейших примерах мы эти заголовки будем опускать для лучшей читабельности.
|
||||
|
||||
##### HTTP-глаголы
|
||||
|
||||
Важнейшая составляющая HTTP запроса — это глагол (метод), описывающий операцию, применяемую к ресурсу. RFC 9110 стандартизирует восемь глаголов — `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `CONNECT`, `OPTIONS` и `TRACE` — из которых нас как разработчиков API интересует первые четыре. `CONNECT`, `OPTIONS` и `TRACE` — технические методы, которые очень редко используются в HTTP API (за исключением `OPTIONS`, который необходимо реализовать, если необходим доступ к API из браузера). Теоретически, `HEAD` (метод получения *только метаданных*, то есть заголовков, ресурса) мог бы быть весьма полезен в HTTP API, но по неизвестным нам причинам практически в этом смысле не используется.
|
||||
|
@ -76,7 +76,7 @@ HTTP/1.1 200 OK
|
||||
|
||||
Однако, во многих случаях (включая настоящую книгу) разработчики предпочитают текстовый JSON бинарным Protobuf (Flatbuffers, Thrift, Avro и т.д.) по очень простой причине: JSON очень легко и удобно читать. Во-первых, он текстовый и не требует дополнительной расшифровки; во-вторых, имена полей включены в сам файл. Если сообщение в формате protobuf невозможно прочитать без `.proto`-файла, то по JSON-документу почти всегда можно попытаться понять, что за данные в нём описаны. В совокупности с тем, что при разработке HTTP API мы также стараемся следовать стандартной семантике всей остальной обвязки, в итоге мы получаем API, запросы и ответы к которому (по крайней мере в теории) удобно читаются и интуитивно понятны.
|
||||
|
||||
Помимо человекочитаемости у JSON есть ещё одно важное преимущество: он максимально формален. В нём нет никаких конструкций, которые могут быть по-разном истолкованы в разных архитектурах (с точностью до ограничений на длины чисел и строк), и при этом он удобно ложится в нативные структуры данных (индексные и ассоциативные массивы) почти любого языка программирования. С этой точки зрения у нас фактически не было никакого другого выбора, какой ещё формат данных мы могли бы использовать при написании примеров кода для этой книги.
|
||||
Помимо человекочитаемости у JSON есть ещё одно важное преимущество: он максимально формален. В нём нет никаких конструкций, которые могут быть по-разному истолкованы в разных архитектурах (с точностью до ограничений на длины чисел и строк), и при этом он удобно ложится в нативные структуры данных (индексные и ассоциативные массивы) почти любого языка программирования. С этой точки зрения у нас фактически не было никакого другого выбора, какой ещё формат данных мы могли бы использовать при написании примеров кода для этой книги.
|
||||
|
||||
В случае же разработки API менее общего назначения, мы рекомендуем подходить к выбору формата по тому же принципу:
|
||||
* взвесить накладные расходы на подготовку и внедрение инструментов чтения бинарных форматов и расшифровки бинарных протоколов против накладных расходов на неоптимальную передачу данных;
|
||||
|
@ -1 +1,214 @@
|
||||
### Принципы организации HTTP API
|
||||
### [Организация HTTP API согласно принципам REST][http-api-rest-organizing]
|
||||
|
||||
Перейдём теперь к конкретике: что конкретно означает «следовать семантике протокола» и «разрабатывать приложение в соответствии с архитектурным стилем REST». Напомним, речь идёт о следующих принципах:
|
||||
* операции должны быть stateless;
|
||||
* данные должны размечаться как кэшируемые или некэшируемые;
|
||||
* интерфейсы взаимодействия между компонентами должны быть стандартизированы;
|
||||
* сетевые системы многослойны.
|
||||
|
||||
Эти принципы мы должны применить к протоколу HTTP, соблюдая дух и букву стандарта:
|
||||
* URL операции должен идентифицировать ресурс, к которому применяется действие, и быть ключом кэширования для `GET` и ключом идемпотентности — для `PUT` и `DELETE`;
|
||||
* HTTP-глаголы должны использоваться в соответствии с их семантикой;
|
||||
* свойства операции (безопасность, кэшируемость, идемпотентность, а также симметрия `GET` / `PUT` / `DELETE`-методов), заголовки запросов и ответов, статус-коды ответов должны соответствовать спецификации.
|
||||
|
||||
**NB**: мы намеренно опускаем многие тонкости стандарта:
|
||||
* ключ кэширования фактически является составным [включает в себя заголовки запроса], если в ответе содержится заголовок `Vary`;
|
||||
* ключ идемпотентности также может быть составным, если в запросе содержится заголовок `Range`;
|
||||
* политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус-кодом и другими заголовками запроса и ответа, а также политиками платформы;
|
||||
|
||||
— в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем, но стандарт всё-таки рекомендуем внимательно прочитать.
|
||||
|
||||
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
|
||||
|
||||
```
|
||||
GET /v1/state HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
|
||||
{ "profile", "orders" }
|
||||
```
|
||||
|
||||
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт профиль пользователя и список его заказов.
|
||||
|
||||
Подобный простой монолитный API-сервис нарушает сразу несколько архитектурных принципов REST:
|
||||
* нет очевидного способа кэшировать ответ на клиенте (данные о заказе часто меняются и их нет смысла сохранять);
|
||||
* операция является stateful, т.к. сервер должен хранить токены в памяти, чтобы извлечь из них идентификатор клиента (к которому привязаны запрошенные данные);
|
||||
* система однослойна (и таким образом вопрос об унифицированном интерфейсе бессмыслен).
|
||||
|
||||
Пока вопросы масштабирования бэкенда нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей и функциональности сервиса (а также количества программистов, над ним работающим), мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится. Допустим, мы приняли решение декомпозировать единый бэкенд на четыре микросервиса:
|
||||
* сервис A, проверяющий авторизационные токены;
|
||||
* сервис B, хранящий профили пользователей;
|
||||
* сервис C, хранящий заказы пользователей;
|
||||
* сервис-гейтвей D, который маршрутизирует запросы между другими микросервисами.
|
||||
|
||||
Таким образом, запрос будет проходить по следующему пути:
|
||||
* гейтвей D получит запрос и отправит его в сервисы B и C;
|
||||
* сервисы B и C обратятся к сервису A, проверят токен (переданный через проксирование заголовка `Authorization` или как явный параметр запроса), и вернут данные по запросу — профиль пользователя и список его заказов;
|
||||
* сервис D скомбинирует ответы сервисов B и C и вернёт их клиенту.
|
||||
|
||||
[]()
|
||||
|
||||
Нетрудно заметить, что мы тем самым создаём излишнюю нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный `user_id` передавался остальным сервисам по цепочке:
|
||||
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
|
||||
* гейтвей D обращается к сервису B
|
||||
```
|
||||
GET /v1/profiles/{user_id}
|
||||
```
|
||||
и к сервису C
|
||||
```
|
||||
GET /v1/orders?user_id=<user id>
|
||||
```
|
||||
|
||||
[]()
|
||||
|
||||
**NB**: мы использовали нотацию `/v1/orders?user_id`, а не, допустим, `/v1/users/{user_id}/orders` по двум причинам:
|
||||
* сервис текущих заказов хранит заказы, а не пользователей — логично если URL будет это отражать;
|
||||
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями.
|
||||
|
||||
Более подробно о принципах формирования URL в HTTP API мы поговорим в следующей главе.
|
||||
|
||||
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он *не требует от (микро)сервиса обращаться за данными за пределами его области ответственности*, добившись соответствия stateless-принципу.
|
||||
|
||||
Отметим, что вопрос о разнице между **stateless** и **stateful** подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы скорее интуитивно соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о *логическом* принципе разделения уровней абстракции, который мы подробно обсуждали в [соответствующей главе](#api-design-separating-abstractions):
|
||||
* **микросервисы разрабатываются так, чтобы иметь чётко очерченную зону ответственности и не хранить данные, относящиеся к другим уровням абстракции**;
|
||||
* такие «внешние» данные являются лишь идентификаторами контекстов, и сам микросервис никак их не трактует;
|
||||
* если всё же какие-то дополнительные операции с внешними данными требуется производить (например, проверять, авторизована ли запрашивающая сторона на выполнение операции), то следует **организовать операцию так, чтобы свести её к проверке целостности переданных данных**.
|
||||
|
||||
В нашем примере мы могли бы избавиться от лишних запросов к сервису A иначе — начав использовать stateless-токены, например, по [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519). Тогда сервисы B и C смогут сами раскодировать токен и извлечь идентификатор пользователя.
|
||||
|
||||
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы организовать кэш профилей на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
|
||||
* перед обращением в сервис B составить ключ и обратиться к кэшу;
|
||||
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
|
||||
|
||||
А можем просто положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `/v1/profiles/{user_id}` в сервисе B, получит данные и заголовки с параметрами кэширования, и сохранит их локально.
|
||||
|
||||
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще профиля пользователя, и возврат неверного состояния может приводить к крайне неприятным последствиям. Вспомним, однако, описанный нами в главе «[Стратегии синхронизации](#api-patterns-sync-strategies)» паттерн оптимистичного управления параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам тэг, соответствующий текущему состоянию заказов пользователя:
|
||||
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 OK
|
||||
ETag: <ревизия>
|
||||
…
|
||||
```
|
||||
|
||||
И тогда гейтвей D при выполнении запроса может:
|
||||
1. Закэшировать результат выполнения `GET /v1/orders?user_id=<user_id>`, использовав URL как ключ кэша
|
||||
2. При получении повторного запроса:
|
||||
* найти закэшированное состояние, если оно есть;
|
||||
* отправить запрос к сервису C вида
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <ревизия>
|
||||
```
|
||||
* если сервис C отвечает статусом `304 Not Modified`, вернуть данные из кэша;
|
||||
* если сервис C отвечает новой версией данных, сохранить её в кэш и вернуть обновленный результат клиенту.
|
||||
|
||||
[]()
|
||||
|
||||
Использовав такое решение [с формированием URL как ключа кэширования и идемпотентности], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Если мы используем оптимистичное управление параллелизмом, то клиент должен передать в запросе актуальную ревизию ресурса `orders`:
|
||||
|
||||
```
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису C:
|
||||
|
||||
```
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса C обновлённый список заказов и его ревизию:
|
||||
|
||||
```
|
||||
HTTP/1.1 201 Created
|
||||
Content-Location: /v1/orders?user_id=<user_id>
|
||||
ETag: <новая ревизия>
|
||||
|
||||
{ /* обновлённый список текущих заказов */ }
|
||||
```
|
||||
|
||||
и обновить кэш в соответствии с новыми данными.
|
||||
|
||||
[]()
|
||||
|
||||
**Важно**: обратите внимание на то, что, после всех преобразований, мы получили систему, в которой мы можем *убрать гейтвей D* и возложить его функции непосредственно на клиентский код. В самом деле, ничто не мешает клиенту:
|
||||
* хранить на своей стороне `user_id` (либо извлекать его из токена, если формат позволяет) и последний полученный `ETag` состояния списка заказов;
|
||||
* вместо одного запроса `GET /v1/state` сделать два запроса (`GET /v1/profiles/{user_id}` и `GET /v1/orders?user_id=<user_id>`), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;
|
||||
* поддерживать на своей стороне кэширование результатов обоих запросов с помощью стандартных библиотек и/или плагинов.
|
||||
|
||||
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет кроме механики авторизации запросов. Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища, или заставить какой-то из сервисов B или C работать в качестве гейтвея.
|
||||
|
||||
Если мы теперь обратимся к началу главы, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:
|
||||
* запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;
|
||||
* интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту или другому промежуточному агенту;
|
||||
* политика кэширования каждого вида данных размечена.
|
||||
|
||||
Повторимся, что мы можем добиться того же самого, использовав RPC-протоколы или разработав свой формат описания статуса операции, параметров кэширования, версионирования ресурсов, приписывания и чтения метаданных и параметров операции. Но автор этой книги позволит себе, во-первых, высказать некоторые сомнения в качестве получившегося решения, и, во-вторых, отметить значительное количество кода, которое придётся написать для реализации всего вышеперечисленного.
|
||||
|
||||
**NB**: отметим, что передача параметров в виде пути или query-параметра в URL влияет не только на читабельность. Если представить, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией, то получать от клиента запрос в виде:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
|
||||
и преобразовывать в пару вложенных запросов
|
||||
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/orders?user_id=<user_id>`
|
||||
|
||||
гораздо удобнее, чем извлекать идентификатор из path и преобразовывать его в query-параметр. Первую операцию [замена одного path целиком на другой] достаточно просто описать декларативно, и в большинстве ПО для веб-серверов она поддерживается из коробки. Напротив, извлечение данных из разных компонентов и полная пересборка запроса — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana гораздо проще организовать по path, чем вычленять из данных запроса какой-то синтетический ключ группировки запросов.
|
||||
|
||||
Всё это приводит нас к соображению, что поддержание одинаковой структуры URL, в которой меняется только путь или домен, а параметры всегда находятся в query и именуются одинаково, приводит к ещё более унифицированному интерфейсу, хотя бы и в ущерб читабельности и семантичности URL. Во многих внутренних системах выбор в пользу удобства выглядит самоочевидным, хотя во внешних API мы бы такой подход не рекомендовали.
|
||||
|
||||
#### Авторизация stateless-запросов
|
||||
|
||||
Рассмотрим подробнее подход, в котором авторизационного сервиса A фактически нет (точнее, он имплементируется как библиотека или локальный демон в составе сервисов B, C и D), и все необходимые данные зашифрованы в самом токене авторизации. Тогда каждый сервис должен выполнять следующие действия:
|
||||
1. Получить запрос вида
|
||||
```
|
||||
GET /v1/profiles/{user_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
2. Расшифровать токен и получить вложенные данные, например, в следующем виде:
|
||||
```
|
||||
{
|
||||
// Идентификатор пользователя-
|
||||
// владельца токена
|
||||
"user_id",
|
||||
// Таймстемп создания токена
|
||||
"iat"
|
||||
}
|
||||
```
|
||||
3. Проверить, что указанные в данных токена права доступа соответствуют параметрам операции — в данном случае сравнить `user_id`, переданный как query-параметр, и `user_id`, содержащийся в токене — и вынести решение о (не)допустимости операции.
|
||||
|
||||
Требование передавать `user_id` дважды и потом сравнивать две копии друг с другом может показаться нелогичным и избыточным. Однако это мнение ошибочно, и проистекает из широко распространённого (анти)паттерна, с описания которого мы начали главу, а именно — stateful-определение параметров операции:
|
||||
|
||||
```
|
||||
GET /v1/profile
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Такой эндпойнт фактически выполняет все три операции контроля доступа:
|
||||
* *аутентифицирует* пользователя путём поиска токена в кэше токенов;
|
||||
* *идентифицирует* пользователя путём извлечения связанного с токеном идентификатора;
|
||||
* *авторизует* операцию, дополнив её параметры и *неявно* предполагая, что пользователь всегда имеет доступ к своим собственным данным.
|
||||
|
||||
Проблема с таким подходом заключается в том, что *разделить* эти операции не представляется возможным. Вспомним описанные нами в главе «[Аутентификация партнёров и авторизация вызовов API](#api-patterns-aa)» варианты авторизации вызовов API: в любой достаточно сложной системе нам придётся разрешать пользователю X выполнять действия от имени пользователя Y — например, если мы продаем функциональность заказа кофе как B2B API, и директор компании-партнёра желает лично или программно контролировать заказы, сделанные сотрудниками компании.
|
||||
|
||||
В случае «тройственного» эндпойнта проверки доступа мы можем только разработать новый эндпойнт с новым интерфейсом. В случае stateless-токенов мы можем поступить так:
|
||||
1. Зашифровать в токене *список* пользователей, доступ к которым возможен через предъявление настоящего токена:
|
||||
```
|
||||
{
|
||||
// Идентификаторы пользователей,
|
||||
// доступ к профилям которых
|
||||
// разрешён с настоящим токеном
|
||||
"user_ids",
|
||||
// Таймстемп создания токена
|
||||
"iat"
|
||||
}
|
||||
```
|
||||
2. Изменить проверку авторизации (=внести изменения в код локального SDK или демона) так, чтобы она разрешала выполнение операции, если `user_id` в query-параметре содержится в списке `user_ids` токена.
|
||||
|
||||
Этот подход можно в дальнейшем усложнять: добавлять гранулярные разрешения выполнять конкретные операции, вводить уровни доступа, проверку прав в реальном времени через дополнительный вызов ACL-сервиса и так далее.
|
||||
|
||||
Важно, что кажущаяся избыточность перестала быть таковой: `user_id` в запросе теперь не дублируется в данных токена; эти идентификаторы имеют разный смысл: *над каким ресурсом* исполняется операция и *кто* исполняет операцию. Совпадение этих двух сущностей — пусть частотный, но всё же частный случай. Что, к сожалению, не отменяет его неочевидности и возможности легко забыть выполнить проверку в коде. Таков путь.
|
@ -1,160 +0,0 @@
|
||||
### Принципы организации HTTP API
|
||||
|
||||
Перейдём теперь к конкретике: что конкретно означает «следовать семантике протокола» и «разрабатывать приложение в соответствии с архитектурным стилем REST». Напомним, речь идёт о следующих принципах:
|
||||
* операции должны быть stateless;
|
||||
* данные должны размечаться как кэшируемые или некэшируемые;
|
||||
* интерфейсы взаимодействия между компонентами должны быть стандартизированы;
|
||||
* сетевые системы многослойны;
|
||||
|
||||
эти принципы мы должны применить к протоколу HTTP, соблюдая дух и букву стандарта:
|
||||
|
||||
* URL операции должен идентифицировать ресурс, к которому применяется действие, и быть ключом кэширования для `GET` и ключом идемпотентности — для `PUT` и `DELETE`;
|
||||
* HTTP-глаголы должны использоваться в соответствии с их семантикой;
|
||||
* свойства операции (безопасность, кэшируемость, идемпотентность, а также симметрия `GET` / `PUT` / `DELETE`-методов), заголовки запросов и ответов, статус-коды ответов должны соответствовать спецификации.
|
||||
|
||||
**NB**: мы намеренно опускаем многие тонкости стандарта:
|
||||
* ключ кэширования фактически является составным [включает в себя заголовки запроса], если в ответе содержится заголовок `Vary`;
|
||||
* ключ идемпотентности также может быть составным, если в запросе содержится заголовок `Range`;
|
||||
* политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус-кодом и другими заголовками запроса и ответа, а также политиками платформы;
|
||||
|
||||
— в рамках HTTP API использование подобных техник является скорее экзотикой, поэтому в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем.
|
||||
|
||||
Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
|
||||
|
||||
```
|
||||
GET /v1/state HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
→
|
||||
HTTP/1.1 200 Ok
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"profile",
|
||||
"orders"
|
||||
}
|
||||
```
|
||||
|
||||
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт профиль пользователя и список его заказов.
|
||||
|
||||
Подобный простой API нарушает сразу несколько архитектурных принципов REST:
|
||||
* нет кэширования (и оно вряд ли возможно, так как в одном ответе совмещены разнородные данные);
|
||||
* операция является stateful, т.к. сервер должен хранить токены в памяти, чтобы извлечь из них идентификатор клиента (к которому привязаны запрошенные данные);
|
||||
* система однослойна (и таким образом вопрос об унифицированном интерфейсе бессмыслен).
|
||||
|
||||
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится. Допустим, мы приняли решение декомпозировать единый бэкенд на четыре микросервиса:
|
||||
* сервис A, проверяющий авторизационные токены;
|
||||
* сервис B, хранящий профили пользователей;
|
||||
* сервис C, хранящий заказы пользователей;
|
||||
* сервис-гейтвей D, который маршрутизирует запросы между другими микросервисами.
|
||||
|
||||
Таким образом, запрос будет проходить по следующему пути:
|
||||
* гейтвей D получит запрос и отправит его в сервисы B и C;
|
||||
* сервисы B и C обратятся к сервису A, проверят токен, и вернут данные по запросу;
|
||||
* сервис D скомбинирует ответы сервисов B и C и вернёт их пользователю.
|
||||
|
||||
Нетрудно заметить, что мы тем самым создаём нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя они не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный `user_id` передавался остальным сервисам по цепочке:
|
||||
|
||||
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
|
||||
* гейтвей D обращается к сервису B
|
||||
```
|
||||
GET /v1/profiles
|
||||
X-OurApi-User-Id: <user id>
|
||||
```
|
||||
и к сервису C
|
||||
```
|
||||
GET /v1/orders
|
||||
X-OurApi-User-Id: <user id>
|
||||
```
|
||||
|
||||
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519) — для данного кейса это неважно, поскольку `user_id` всё равно остаётся частью HTTP-заголовка.
|
||||
|
||||
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он *не требует от (микро)сервиса обращаться за данными за пределами его области ответственности*, добившись соответствия stateless-принципу.
|
||||
|
||||
Вопрос о том, в чём разница между **stateless** и **stateful** подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы скорее интуитивно соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о *логическом* принципе изоляции уровней абстракции, который мы подробно обсуждали в соответствующей главе:
|
||||
* микросервисы разрабатываются так, чтобы не хранить данные, не относящиеся к другим уровням абстракции;
|
||||
* такие «внешние» данные являются лишь идентификаторами контекстов, и сам микросервис никак их не трактует;
|
||||
* если всё же какие-то дополнительные операции с внешними данными требуется производить (например, проверять, авторизована ли запрашивающая сторона на выполнение операции), то следует *организовать передачу данных так, чтобы свести операцию к проверке целостности переданных данных* (в нашем примере — использовать подписывание запросов вместо хранения копии базы данных токенов).
|
||||
|
||||
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
|
||||
* перед обращением в сервис B составить ключ и обратиться к кэшу;
|
||||
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
|
||||
|
||||
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `/v1/profiles/{user_id}` в сервисе B и получит данные либо из кэша, либо непосредственно из сервиса.
|
||||
|
||||
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще, чем список завершённых заказов. Вспомним, однако, описанное нами в разделе «Паттерны API» оптимистичное управление параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам токен, соответствующий текущему состоянию заказов пользователя:
|
||||
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 Ok
|
||||
ETag: <ревизия>
|
||||
…
|
||||
```
|
||||
|
||||
И тогда гейтвей D при выполнении запроса может:
|
||||
|
||||
1. Закэшировать результат выполнения `GET /v1/orders?user_id=<user_id>`, использовав URL как ключ кэша
|
||||
2. При получении повторного запроса:
|
||||
* найти закэшированное состояние, если оно есть
|
||||
* отправить запрос к сервису B вида
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <ревизия>
|
||||
```
|
||||
* если сервис B отвечает статусом 304 Not Modified, вернуть данные из кэша
|
||||
* если сервис B отвечает новой версией данных, сохранить её в кэш и вернуть
|
||||
|
||||
**NB**: мы использовали нотацию `/v1/orders?user_id`, а не, допустим, `/v1/users/{user_id}/orders` по двум причинам:
|
||||
* сервис текущих заказов хранит заказы, а не пользователи — логично если URL будет это отражать;
|
||||
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями (напомним, путь традиционно используется для индикации строгой иерархии).
|
||||
|
||||
Подчеркнём ещё раз: стандарт не определяет, каким образом формируются URL — как разбивать путь на части (и разбивать ли вообще), в каких случаях передавать параметр в query, а в каких в path и т.д. — поэтому path и query формируются сугубо из удобства чтения и использования. Если представить, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией, то было бы гораздо удобнее получать от клиента запрос в виде:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
и преобразовывать в пару вложенных запросов
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/ongoing-orders?user_id=<user_id>`
|
||||
поскольку эту операцию [замена одного path целиком на другой] достаточно описать в конфигурации, и в большинстве ПО для веб-серверов она поддерживается из коробки. Напротив, извлечение данных из разных частей запроса и полная пересборка URL — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana гораздо проще организовать по path, чем вычленять из данных запроса какой-то синтетический ключ группировки запросов.
|
||||
|
||||
Таким образом, мы не настаиваем на этом решении [организации доступа к заказам пользователя через манипуляцию path в URL с передачей идентификатора пользователя в виде query-параметра] как на единственно верном, хотя он в большинстве случаев упрощает и организацию гейтвеев и мониторингов: в первую очередь нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходит любой формат, лишь бы он задавал однозначное соответствие URL списку заказов конкретного пользователя.
|
||||
|
||||
Использовав такое решение [с формированием URL как ключа кэширования и идемпотентности], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
|
||||
|
||||
```
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису B:
|
||||
|
||||
```
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса B:
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Location: /v1/orders?user_id<user_id>
|
||||
ETag: <новая ревизия>
|
||||
|
||||
{ /* обновлённый список текущих заказов */ }
|
||||
```
|
||||
|
||||
и обновить кэш в соответствии с новыми данными.
|
||||
|
||||
**Важно**: обратите внимание на то, что, после всех преобразований, мы получили систему, в которой мы можем *убрать гейтвей D* и возложить его функции непосредственно на клиентский код. В самом деле, ничто не мешает клиенту:
|
||||
* хранить на своей стороне `user_id` (либо извлекать его из токена, если формат позволяет) и последний полученный ETag состояния списка заказов;
|
||||
* вместо одного запроса `GET /v1/state` сделать два запроса (`GET /v1/profiles/{user_id}` и `GET /v1/orders?user_id=<user_id>`), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;
|
||||
* поддерживать на своей стороне кэширование результатов обоих запросов.
|
||||
|
||||
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет (особенно если мы используем stateless-токены). Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища.
|
||||
|
||||
Если мы теперь обратимся к началу главы, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:
|
||||
* запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;
|
||||
* интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту и обратно;
|
||||
* политика кэширования каждого вида данных размечена.
|
||||
|
||||
Важнейшее качество, которое следование семантике HTTP придаёт нашему сервису — это унификация кода различных агентов системы. Клиент и гейтвей почти полностью идентичны и взаимозаменяемы, что позволяет понятным и предсказуемым образом наращивать номенклатуру сервисов и горизонтально, и вертикально.
|
||||
|
||||
**NB**: повторимся, что мы можем добиться того же самого, использовав RPC-протоколы или разработав свой формат описания статуса операции, параметров кэширования, версионирования ресурсов, приписывания и чтения метаданных и параметров операции, а также реализовав гейтвей D поверх RPC-протокола с чтением полного тела запросов и ответов и интерпретацией всех придуманных нами (или вендором RPC-протокола) форматов данных. Но автор этой книги позволит себе, во-первых, высказать некоторые сомнения в качестве получившегося решения, и, во-вторых, отметить огромное количество кода, которое придётся написать для реализации всего вышеперечисленного.
|
@ -1,4 +0,0 @@
|
||||
flowchart TB
|
||||
subgraph API рецептов
|
||||
a1[[GET /v1/recipes]]
|
||||
end
|
19
src/ru/graphs/http-api-organizing-01.mermaid
Normal file
@ -0,0 +1,19 @@
|
||||
sequenceDiagram
|
||||
participant U as Клиент
|
||||
participant D as Гейтвей D
|
||||
participant A as Сервис A<br/>(авторизации)
|
||||
participant B as Сервис B<br/>(профилей)
|
||||
participant C as Сервис C<br/>(заказов)
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
par
|
||||
D->>+B: GET /v1/profile<br/><token>
|
||||
B->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-B: { status, user_id }
|
||||
B-->>-D: { status, profile }
|
||||
and
|
||||
D->>+C: GET /v1/orders<br/><token>
|
||||
C->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-C: { status, user_id }
|
||||
C-->>-D: { status, orders }
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
17
src/ru/graphs/http-api-organizing-02.mermaid
Normal file
@ -0,0 +1,17 @@
|
||||
sequenceDiagram
|
||||
participant U as Клиент
|
||||
participant D as Гейтвей D
|
||||
participant A as Сервис A<br/>(авторизации)
|
||||
participant B as Сервис B<br/>(профилей)
|
||||
participant C as Сервис C<br/>(заказов)
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/>#60;token#62;
|
||||
A-->>-D: { status, user_id }
|
||||
par
|
||||
D->>+B: GET /v1/profiles/{user_id}
|
||||
B-->>-D: { profile }
|
||||
and
|
||||
D->>+C: GET /v1/orders?user_id=#60;user_id#62;
|
||||
C-->>-D: { orders }
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
24
src/ru/graphs/http-api-organizing-03.mermaid
Normal file
@ -0,0 +1,24 @@
|
||||
sequenceDiagram
|
||||
participant U as Клиент
|
||||
participant D as Гейтвей D
|
||||
participant A as Сервис A<br/>(авторизации)
|
||||
participant B as Сервис B<br/>(профилей)
|
||||
participant C as Сервис C<br/>(заказов)
|
||||
U->>+D: GET /v1/state<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-D: { status, user_id }
|
||||
par
|
||||
alt Профиль не найден в кэше
|
||||
D->>+B: GET /v1/profiles/{user_id}
|
||||
B-->>-D: 200 OK<br/>Cache-Control: #60;параметры#62;<br/>{ profile }
|
||||
end
|
||||
and
|
||||
D->>+C: GET /v1/orders?user_id=#60;user_id#62;<br/>If-None-Match: #60;ревизия#62;
|
||||
alt Неверная ревизия
|
||||
C-->>D: 200 OK<br/>ETag: #60;ревизия#62;<br/>{ orders }
|
||||
else Ревизия актуальна
|
||||
C-->>D: 304 Not Modified
|
||||
end
|
||||
deactivate C
|
||||
end
|
||||
D-->>-U: { profile, orders }
|
17
src/ru/graphs/http-api-organizing-04.mermaid
Normal file
@ -0,0 +1,17 @@
|
||||
sequenceDiagram
|
||||
participant U as Клиент
|
||||
participant D as Гейтвей D
|
||||
participant A as Сервис A<br/>(авторизации)
|
||||
participant B as Сервис B<br/>(профилей)
|
||||
participant C as Сервис C<br/>(заказов)
|
||||
U->>+D: POST /v1/orders HTTP/1.1<br/>If-Match: #60;ревизия#62;<br/>Authorization: Bearer #60;token#62;
|
||||
D->>+A: GET /v1/auth<br/><token>
|
||||
A-->>-D: { status, user_id }
|
||||
D->>+C: POST /v1/orders?user_id=#60;user_id#62;<br/>If-Match: #60;ревизия#62;
|
||||
alt Ревизия актуальна
|
||||
C-->>D: 201 Created<br/>Content-Location: /v1/orders?user_id=<user_id><br/>ETag: #60;новая ревизия#62;<br/>{ orders }
|
||||
else Неверная ревизия
|
||||
C-->>D: 409 Conflict
|
||||
end
|
||||
deactivate C
|
||||
D-->>-U: 201 Created<br/>ETag: #60;новая ревизия#62;<br/>{ orders }
|
@ -18,6 +18,7 @@
|
||||
],
|
||||
"imageCredit": "Фото: <a href=\"http://linkedin.com/in/zloylos/\">Denis Hananein</a>"
|
||||
},
|
||||
"ctl": "Нажмите для увеличения",
|
||||
"url": "https://twirl.github.io/The-API-Book/index.ru.html",
|
||||
"favicon": "/img/favicon.png",
|
||||
"sidePanel": {
|
||||
|
@ -1,6 +1,9 @@
|
||||
const { readFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
|
||||
module.exports = {
|
||||
pageBreak: '<div class="page-break"></div>',
|
||||
|
||||
@ -228,5 +231,70 @@ module.exports = {
|
||||
${l10n.landing.footer.join('\n')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
},
|
||||
aImg: ({ src, href, title, alt, l10n, className = 'img-wrapper' }) => {
|
||||
const fullTitle = escapeHtml(
|
||||
`${title}${title.at(-1).match(/[\.\?\!\)]/) ? ' ' : '. '} ${
|
||||
alt == 'PD' ? l10n.publicDomain : `${l10n.imageCredit}: ${alt}`
|
||||
}`
|
||||
);
|
||||
return `<div class="${escapeHtml(
|
||||
className
|
||||
)}"><a href="${src}" target="_blank"><img src="${escapeHtml(
|
||||
src
|
||||
)}" alt="${fullTitle}" title="${fullTitle}"/></a><h6>${escapeHtml(
|
||||
title
|
||||
)}. ${
|
||||
alt == 'CTL'
|
||||
? l10n.ctl
|
||||
: `${escapeHtml(l10n.imageCredit)}: ${
|
||||
href
|
||||
? `<a href="${escapeHtml(href)}">${escapeHtml(
|
||||
alt
|
||||
)}</a>`
|
||||
: escapeHtml(alt)
|
||||
}`
|
||||
}</h6></div>`;
|
||||
},
|
||||
graphHtmlTemplate: (graph) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-monospace;
|
||||
src: url(../src/fonts/RobotoMono-Regular.ttf);
|
||||
}
|
||||
|
||||
.actor-line {
|
||||
stroke: lightgray;
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mermaid">${graph
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</div>
|
||||
<script src="../src/scripts/mermaid.min.js"></script>
|
||||
<script>mermaid.initialize({
|
||||
theme: 'neutral',
|
||||
fontFamily: 'local-monospace, monospace',
|
||||
fontSize: 14,
|
||||
sequence: {
|
||||
diagramMarginX: 20,
|
||||
diagramMarginY: 10,
|
||||
actorMargin: 5,
|
||||
mirrorActors: false,
|
||||
showSequenceNumbers: true
|
||||
}
|
||||
});</script>
|
||||
</body>
|
||||
</html>`
|
||||
};
|
||||
|