lakutata 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
# Calls between services
|
|
2
|
+
|
|
3
|
+
This example builds two services, one calling the other. The `inventory` service keeps the stock of the products and
|
|
4
|
+
serves it to other services through service actions; the `orders` service has an HTTP API that places orders, and
|
|
5
|
+
reserves their stock in the inventory through a proxy. Both start from the `service` template. The example shows
|
|
6
|
+
service actions and their patterns, the HTTP service transport and a proxy that calls it, a token shared by the two
|
|
7
|
+
services, how the inventory's error reaches the client when it refuses an order, and the tests of each service.
|
|
8
|
+
|
|
9
|
+
| Service | Call | What it does |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `inventory` | `{"resource": "stock", "act": "get", "product": "lamp"}` | Gives the quantity of a product in stock |
|
|
12
|
+
| `inventory` | `{"resource": "stock", "act": "reserve", "order": "<id>", "items": [{"product": "lamp", "quantity": 2}]}` | Reserves the items of an order, all of them or none |
|
|
13
|
+
| `orders` | `POST /orders` | Places an order: `{"items": [{"product": "lamp", "quantity": 2}]}` |
|
|
14
|
+
|
|
15
|
+
## 1. Create the projects
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @lakutata/cli create --name inventory --template service --yes
|
|
19
|
+
npx @lakutata/cli create --name orders --template service --yes
|
|
20
|
+
rm inventory/packages/app/src/controllers/ExampleController.ts inventory/packages/app/src/dtos/GreetDTO.ts
|
|
21
|
+
rm orders/packages/app/src/controllers/ExampleController.ts orders/packages/app/src/dtos/GreetDTO.ts
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The two projects sit side by side, each a workspace whose service is `packages/app`
|
|
25
|
+
([ProjectStructure](../ProjectStructure.md)); the paths below start with the name of their project. They share no code:
|
|
26
|
+
the action patterns and the shape of the messages are the contract between them. Nothing is installed: the HTTP
|
|
27
|
+
service transport and its proxy are part of lakutata.
|
|
28
|
+
|
|
29
|
+
## 2. The stock
|
|
30
|
+
|
|
31
|
+
**`inventory/packages/app/src/exceptions/StockExceptions.ts`**
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import {Exception} from 'lakutata'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The product of the call does not exist
|
|
38
|
+
*/
|
|
39
|
+
export class ProductNotFoundException extends Exception {
|
|
40
|
+
public errno: string = 'E_PRODUCT_NOT_FOUND'
|
|
41
|
+
|
|
42
|
+
public readonly statusCode: number = 404
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The stock of a product is short of the quantity asked
|
|
47
|
+
*/
|
|
48
|
+
export class OutOfStockException extends Exception {
|
|
49
|
+
public errno: string = 'E_OUT_OF_STOCK'
|
|
50
|
+
|
|
51
|
+
public readonly statusCode: number = 409
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each exception has its `errno` and its status ([Exceptions](../Exceptions.md)). Its message is given when it is thrown.
|
|
56
|
+
|
|
57
|
+
**`inventory/packages/app/src/components/Stock.ts`**
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import {Component, DTO} from 'lakutata'
|
|
61
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
62
|
+
import {OutOfStockException, ProductNotFoundException} from '../exceptions/StockExceptions.js'
|
|
63
|
+
|
|
64
|
+
export type StockItem = { product: string, quantity: number }
|
|
65
|
+
|
|
66
|
+
export type StockLevel = { product: string, available: number }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The stock of the products, kept in memory, and the orders it is reserved for
|
|
70
|
+
*/
|
|
71
|
+
export class Stock extends Component {
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The quantity of each product at the start
|
|
75
|
+
*/
|
|
76
|
+
@Configurable(DTO.Object().pattern(DTO.String(), DTO.Number().integer().min(0)).default({}))
|
|
77
|
+
protected readonly products: Record<string, number>
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The available quantity of each product
|
|
81
|
+
*/
|
|
82
|
+
protected readonly levels: Map<string, number> = new Map()
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The reserved orders, with the stock levels left after each reservation
|
|
86
|
+
*/
|
|
87
|
+
protected readonly reserved: Map<string, StockLevel[]> = new Map()
|
|
88
|
+
|
|
89
|
+
protected async init(): Promise<void> {
|
|
90
|
+
Object.entries(this.products).forEach(([product, quantity]: [string, number]): unknown => this.levels.set(product, quantity))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The available quantity of a product
|
|
95
|
+
* @param product
|
|
96
|
+
*/
|
|
97
|
+
public level(product: string): StockLevel {
|
|
98
|
+
const available: number | undefined = this.levels.get(product)
|
|
99
|
+
if (available === undefined) throw new ProductNotFoundException('The product {product} does not exist', {product: product})
|
|
100
|
+
return {product: product, available: available}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reserve the items of an order: all of them or none
|
|
105
|
+
* @param order
|
|
106
|
+
* @param items
|
|
107
|
+
*/
|
|
108
|
+
public reserve(order: string, items: StockItem[]): StockLevel[] {
|
|
109
|
+
//An order already reserved (a call sent again) is not reserved twice
|
|
110
|
+
const done: StockLevel[] | undefined = this.reserved.get(order)
|
|
111
|
+
if (done) return done
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
const available: number = this.level(item.product).available
|
|
114
|
+
if (available < item.quantity) throw new OutOfStockException('{product}: {quantity} asked, {available} in stock', {...item, available: available})
|
|
115
|
+
}
|
|
116
|
+
const levels: StockLevel[] = items.map((item: StockItem): StockLevel => {
|
|
117
|
+
this.levels.set(item.product, this.level(item.product).available - item.quantity)
|
|
118
|
+
return this.level(item.product)
|
|
119
|
+
})
|
|
120
|
+
this.reserved.set(order, levels)
|
|
121
|
+
return levels
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- The stock is a component ([Objects](../Objects.md)): one instance for the whole service, while a controller lives for
|
|
127
|
+
one request. It is kept in memory, starting from the quantities of its `products` option (a `@Configurable`
|
|
128
|
+
property), which `init()` copies.
|
|
129
|
+
- `reserve()` checks every item before it changes the stock: an order is reserved whole, or not at all.
|
|
130
|
+
- The order id makes the reservation happen only once: a call sent again, after a timeout for example, gets the same
|
|
131
|
+
result and reserves nothing more.
|
|
132
|
+
- The message of `OutOfStockException` is built from the values given with it: `desk: 5 asked, 2 in stock`.
|
|
133
|
+
|
|
134
|
+
## 3. The service actions
|
|
135
|
+
|
|
136
|
+
**`inventory/packages/app/src/dtos/StockDTO.ts`**
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import {DTO} from 'lakutata'
|
|
140
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A product
|
|
144
|
+
*/
|
|
145
|
+
export class ProductDTO extends DTO {
|
|
146
|
+
@Expect(DTO.String().required())
|
|
147
|
+
public product: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A quantity of a product
|
|
152
|
+
*/
|
|
153
|
+
export class StockItemDTO extends ProductDTO {
|
|
154
|
+
@Expect(DTO.Number().integer().min(1).required())
|
|
155
|
+
public quantity: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The items of an order, each product once
|
|
160
|
+
*/
|
|
161
|
+
export class ReserveDTO extends DTO {
|
|
162
|
+
@Expect(DTO.String().required())
|
|
163
|
+
public order: string
|
|
164
|
+
|
|
165
|
+
@Expect(DTO.Array(StockItemDTO.Schema()).min(1).unique('product').required())
|
|
166
|
+
public items: StockItemDTO[]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The DTOs validate the input of the actions as they do over HTTP ([DTOValidation](../DTOValidation.md)). A message is
|
|
171
|
+
JSON, so its values keep their types: no `.strict(false)` is needed. `.unique('product')` refuses an order that lists a
|
|
172
|
+
product twice.
|
|
173
|
+
|
|
174
|
+
**`inventory/packages/app/src/controllers/StockController.ts`**
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
178
|
+
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
|
|
179
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
180
|
+
import {type Stock, type StockLevel} from '../components/Stock.js'
|
|
181
|
+
import {ProductDTO, ReserveDTO} from '../dtos/StockDTO.js'
|
|
182
|
+
|
|
183
|
+
@Prefix({pattern: {resource: 'stock'}})
|
|
184
|
+
export class StockController extends Controller {
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The stock (the component "stock" of the options)
|
|
188
|
+
*/
|
|
189
|
+
@Inject('stock')
|
|
190
|
+
protected readonly stock: Stock
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* {"resource": "stock", "act": "get", "product": "lamp"}
|
|
194
|
+
* @param input
|
|
195
|
+
*/
|
|
196
|
+
@ServiceAction({act: 'get'}, ProductDTO)
|
|
197
|
+
public async get(input: ProductDTO): Promise<StockLevel> {
|
|
198
|
+
return this.stock.level(input.product)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* {"resource": "stock", "act": "reserve", "order": "…", "items": [{"product": "lamp", "quantity": 2}]}
|
|
203
|
+
* @param input
|
|
204
|
+
*/
|
|
205
|
+
@ServiceAction({act: 'reserve'}, ReserveDTO)
|
|
206
|
+
public async reserve(input: ReserveDTO): Promise<StockLevel[]> {
|
|
207
|
+
return this.stock.reserve(input.order, input.items)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `@ServiceAction(pattern, DTO)` gives an action its pattern ([ServiceCalls](../ServiceCalls.md)). A message matches it
|
|
213
|
+
when it holds the keys of the pattern with the same values; its other keys are the input of the action.
|
|
214
|
+
- `@Prefix({pattern: {resource: 'stock'}})` merges `resource: 'stock'` into the patterns of all the controller's
|
|
215
|
+
actions ([Controllers](../Controllers.md)): the `get` action answers the message
|
|
216
|
+
`{"resource": "stock", "act": "get", "product": "lamp"}`, and its input is `{"product": "lamp"}`.
|
|
217
|
+
- What the action returns is the `payload` of the response, and the exception it throws is its `error`.
|
|
218
|
+
|
|
219
|
+
## 4. The token of the callers
|
|
220
|
+
|
|
221
|
+
**`inventory/packages/app/src/middlewares/Authenticate.ts`**
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import {Exception} from 'lakutata'
|
|
225
|
+
import {type ActionInvocation, httpRequestOfContext, type Next, type ServiceContext} from 'lakutata/com/entrypoint'
|
|
226
|
+
|
|
227
|
+
export class UnauthorizedException extends Exception {
|
|
228
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
229
|
+
|
|
230
|
+
public readonly statusCode: number = 401
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* When the environment has an API_TOKEN, the service calls must carry "Authorization: Bearer <API_TOKEN>": the services
|
|
235
|
+
* that call the inventory are given this token
|
|
236
|
+
* @param invocation
|
|
237
|
+
* @param next
|
|
238
|
+
*/
|
|
239
|
+
export async function authenticate(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
240
|
+
const token: string | undefined = process.env.API_TOKEN
|
|
241
|
+
if (!token) return next()
|
|
242
|
+
//The HTTP request that carries the call
|
|
243
|
+
const authorization: string | undefined = httpRequestOfContext(invocation.context as ServiceContext)?.headers.authorization
|
|
244
|
+
if (authorization !== `Bearer ${token}`) throw new UnauthorizedException('The caller is not authenticated')
|
|
245
|
+
return next()
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The template's middleware checked the HTTP requests; here it checks the service calls. `httpRequestOfContext()` gives
|
|
250
|
+
the HTTP request that carries a call, with its headers. When the environment has an `API_TOKEN`, a call without
|
|
251
|
+
`Authorization: Bearer <API_TOKEN>` is refused with `E_UNAUTHORIZED` before its input is validated
|
|
252
|
+
([Middlewares](../Middlewares.md)). The services that call the inventory are given this token. To let each caller reach
|
|
253
|
+
only some of the actions, give each caller its own token and add an access rule that checks the action groups
|
|
254
|
+
([Middlewares](../Middlewares.md), section 4).
|
|
255
|
+
|
|
256
|
+
## 5. The options of the inventory
|
|
257
|
+
|
|
258
|
+
**`inventory/packages/app/src/config/Config.ts`**
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
262
|
+
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
|
|
263
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
264
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
265
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
266
|
+
import {StockController} from '../controllers/StockController.js'
|
|
267
|
+
import {Stock} from '../components/Stock.js'
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The options of the service
|
|
271
|
+
* @param port the port of the HTTP server (the health check), the PORT environment variable or 8080 by default
|
|
272
|
+
* @param servicePort the port of the service calls, the SERVICE_PORT environment variable or 3000 by default
|
|
273
|
+
*/
|
|
274
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), servicePort: number = Number(process.env.SERVICE_PORT ?? 3000)): ApplicationOptions {
|
|
275
|
+
return {
|
|
276
|
+
id: 'inventory',
|
|
277
|
+
name: 'inventory',
|
|
278
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
279
|
+
components: {
|
|
280
|
+
//The stock at the start, in memory
|
|
281
|
+
stock: {class: Stock, products: {lamp: 10, desk: 3}},
|
|
282
|
+
entrypoint: buildEntrypoints({
|
|
283
|
+
controllers: [HealthController, StockController],
|
|
284
|
+
//GET /health
|
|
285
|
+
http: expressHTTPEntrypoint({port: port, respond: jsonEnvelope()}),
|
|
286
|
+
//POST / with the message as its JSON body, from the services that hold the token
|
|
287
|
+
service: httpServiceEntrypoint({port: servicePort, middlewares: [authenticate]})
|
|
288
|
+
})
|
|
289
|
+
},
|
|
290
|
+
bootstrap: ['entrypoint']
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The entrypoint has two adapters ([Entrypoint](../Entrypoint.md)):
|
|
296
|
+
|
|
297
|
+
- `http`: an Express server on `PORT`, kept for the template's health check (`GET /health`, used by the `HEALTHCHECK`
|
|
298
|
+
of the `Dockerfile`);
|
|
299
|
+
- `service`: `httpServiceEntrypoint()`, the HTTP service transport, on `SERVICE_PORT` (3000 by default). It runs its own
|
|
300
|
+
server. A call is a `POST` of the message as JSON, always answered with 200 and `{success, error, payload}`. Its
|
|
301
|
+
`middlewares` run for the calls only, so the health check needs no token.
|
|
302
|
+
|
|
303
|
+
The `stock` component starts with 10 lamps and 3 desks.
|
|
304
|
+
|
|
305
|
+
## 6. The client of the inventory
|
|
306
|
+
|
|
307
|
+
**`orders/packages/app/src/components/Inventory.ts`**
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import {Component, Exception} from 'lakutata'
|
|
311
|
+
import {type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
|
|
312
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
313
|
+
|
|
314
|
+
export type OrderItem = { product: string, quantity: number }
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The inventory's errors that refuse an order: they are passed on to the clients of the orders service
|
|
318
|
+
*/
|
|
319
|
+
const REFUSALS: unknown[] = ['E_OUT_OF_STOCK', 'E_PRODUCT_NOT_FOUND']
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The inventory does not answer, or fails
|
|
323
|
+
*/
|
|
324
|
+
export class InventoryUnavailableException extends Exception {
|
|
325
|
+
public errno: string = 'E_INVENTORY_UNAVAILABLE'
|
|
326
|
+
|
|
327
|
+
public readonly statusCode: number = 503
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The inventory, as the orders service uses it: its calls, and what their errors mean for an order
|
|
332
|
+
*/
|
|
333
|
+
export class Inventory extends Component {
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The proxy of the inventory's service actions (the component "inventoryService" of the options)
|
|
337
|
+
*/
|
|
338
|
+
@Inject('inventoryService')
|
|
339
|
+
protected readonly service: HTTPServiceProxy
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Reserve the stock of an order; reserving the same order again changes nothing
|
|
343
|
+
* @param order
|
|
344
|
+
* @param items
|
|
345
|
+
*/
|
|
346
|
+
public async reserve(order: string, items: OrderItem[]): Promise<void> {
|
|
347
|
+
try {
|
|
348
|
+
//5 seconds at most
|
|
349
|
+
await this.service.invoke({resource: 'stock', act: 'reserve', order: order, items: items}, 5000)
|
|
350
|
+
} catch (error) {
|
|
351
|
+
//A refusal is thrown as it is, with all the properties of the inventory's error (errno, errMsg, statusCode…)
|
|
352
|
+
if (error instanceof ServiceInvokeException && REFUSALS.includes(error.errno)) throw error
|
|
353
|
+
//Any other error (no response, a refused token, a message the inventory does not understand) is a failure of the orders service, not of its clients
|
|
354
|
+
throw new InventoryUnavailableException('The inventory fails: {0}', (error as Error).message)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The orders service calls the inventory through an `HTTPServiceProxy` ([ServiceCalls](../ServiceCalls.md), section 4),
|
|
361
|
+
registered in its options as the `inventoryService` component (section 8). The proxy posts the messages and reads the
|
|
362
|
+
responses. `Inventory` is a component of the orders service on top of it: it decides what the inventory's errors mean
|
|
363
|
+
for an order.
|
|
364
|
+
|
|
365
|
+
- `invoke()` takes the message and the longest wait for the response, here 5 seconds. The proxy waits 10 minutes by
|
|
366
|
+
default; an order should not wait that long, so the call gives its own limit.
|
|
367
|
+
- A refusal of an order crosses the call: the proxy throws a `ServiceInvokeException` that carries the properties of
|
|
368
|
+
the inventory's error, including its `errno`, `errMsg` and `statusCode`. `Inventory` throws it again as it is, so the
|
|
369
|
+
HTTP adapter of the orders service answers with that status and that `errno`: the client receives the inventory's
|
|
370
|
+
409 `E_OUT_OF_STOCK`.
|
|
371
|
+
- The inventory's other errors are not the client's fault. A refused token, a message that the inventory does not
|
|
372
|
+
understand, or no response at all (the proxy's `ServiceUnavailableException`) is a failure of the orders service: it
|
|
373
|
+
answers 503 `E_INVENTORY_UNAVAILABLE`.
|
|
374
|
+
- `Inventory` recognizes the refusals by their `errno`: the class of an exception does not cross the call
|
|
375
|
+
([Exceptions](../Exceptions.md), section 5).
|
|
376
|
+
- `reserve()` wraps the call in a typed method, so the controllers never build messages themselves.
|
|
377
|
+
|
|
378
|
+
## 7. The orders
|
|
379
|
+
|
|
380
|
+
**`orders/packages/app/src/dtos/OrderDTO.ts`**
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import {DTO} from 'lakutata'
|
|
384
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A quantity of a product
|
|
388
|
+
*/
|
|
389
|
+
export class OrderItemDTO extends DTO {
|
|
390
|
+
@Expect(DTO.String().required())
|
|
391
|
+
public product: string
|
|
392
|
+
|
|
393
|
+
@Expect(DTO.Number().integer().min(1).required())
|
|
394
|
+
public quantity: number
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A new order: its items, each product once
|
|
399
|
+
*/
|
|
400
|
+
export class PlaceOrderDTO extends DTO {
|
|
401
|
+
@Expect(DTO.Array(OrderItemDTO.Schema()).min(1).unique('product').required())
|
|
402
|
+
public items: OrderItemDTO[]
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The items have the shape that the inventory expects. The orders service validates them before the call, so an invalid
|
|
407
|
+
order never reaches the inventory.
|
|
408
|
+
|
|
409
|
+
**`orders/packages/app/src/controllers/OrderController.ts`**
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
import {randomUUID} from 'node:crypto'
|
|
413
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
414
|
+
import {POST, Prefix} from 'lakutata/decorator/ctrl'
|
|
415
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
416
|
+
import {type Inventory, type OrderItem} from '../components/Inventory.js'
|
|
417
|
+
import {PlaceOrderDTO} from '../dtos/OrderDTO.js'
|
|
418
|
+
|
|
419
|
+
export type Order = { id: string, items: OrderItem[] }
|
|
420
|
+
|
|
421
|
+
@Prefix({route: '/orders'})
|
|
422
|
+
export class OrderController extends Controller {
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The inventory (the component "inventory" of the options)
|
|
426
|
+
*/
|
|
427
|
+
@Inject('inventory')
|
|
428
|
+
protected readonly inventory: Inventory
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* POST /orders {"items": [{"product": "lamp", "quantity": 2}]}
|
|
432
|
+
* @param input
|
|
433
|
+
*/
|
|
434
|
+
@POST('/', PlaceOrderDTO)
|
|
435
|
+
public async place(input: PlaceOrderDTO): Promise<Order> {
|
|
436
|
+
const order: Order = {id: randomUUID(), items: input.items}
|
|
437
|
+
//A refusal of the inventory (E_OUT_OF_STOCK…) is the error of the request
|
|
438
|
+
await this.inventory.reserve(order.id, order.items)
|
|
439
|
+
return order
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
- The controller injects the `inventory` component (`@Inject('inventory')`) and calls it like any other component.
|
|
445
|
+
- The order gets its id before the call: if the call is sent again, the inventory still reserves the order only once.
|
|
446
|
+
- An error of the call becomes the error of the request, so the controller needs no `try`.
|
|
447
|
+
|
|
448
|
+
## 8. The options of the orders
|
|
449
|
+
|
|
450
|
+
**`orders/packages/app/src/config/Config.ts`**
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
454
|
+
import {buildEntrypoints, buildHTTPServiceProxy} from 'lakutata/com/entrypoint'
|
|
455
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
456
|
+
import compression from 'compression'
|
|
457
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
458
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
459
|
+
import {OrderController} from '../controllers/OrderController.js'
|
|
460
|
+
import {Inventory} from '../components/Inventory.js'
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The options of the service
|
|
464
|
+
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
|
|
465
|
+
* @param inventory the address where the inventory takes its service calls, the INVENTORY_URL environment variable or
|
|
466
|
+
* http://localhost:3000 by default
|
|
467
|
+
*/
|
|
468
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), inventory: string = process.env.INVENTORY_URL ?? 'http://localhost:3000'): ApplicationOptions {
|
|
469
|
+
return {
|
|
470
|
+
id: 'orders',
|
|
471
|
+
name: 'orders',
|
|
472
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
473
|
+
components: {
|
|
474
|
+
//The proxy of the inventory's service actions, with the token for its calls
|
|
475
|
+
inventoryService: buildHTTPServiceProxy({url: inventory, headers: {Authorization: `Bearer ${process.env.INVENTORY_TOKEN ?? ''}`}}),
|
|
476
|
+
inventory: {class: Inventory},
|
|
477
|
+
entrypoint: buildEntrypoints({
|
|
478
|
+
controllers: [HealthController, OrderController],
|
|
479
|
+
http: expressHTTPEntrypoint({
|
|
480
|
+
port: port,
|
|
481
|
+
//The results and the errors are {errno, errMsg, data}; the errors keep their status (400, 409…)
|
|
482
|
+
respond: jsonEnvelope(),
|
|
483
|
+
body: {limit: '1mb'},
|
|
484
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
485
|
+
middlewares: [authenticate],
|
|
486
|
+
before: (app): void => void app.use(compression())
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
},
|
|
490
|
+
//The proxy is created at the start: an invalid INVENTORY_URL stops the service
|
|
491
|
+
bootstrap: ['inventoryService', 'entrypoint']
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
This is the `Config.ts` of the template, with:
|
|
497
|
+
|
|
498
|
+
- the `inventoryService` component: the proxy, with the inventory's address (`INVENTORY_URL`, `http://localhost:3000`
|
|
499
|
+
by default) and the token for its calls (`INVENTORY_TOKEN`, the inventory's `API_TOKEN`) in their `Authorization`
|
|
500
|
+
header;
|
|
501
|
+
- the `inventory` component, which the controller injects;
|
|
502
|
+
- the `OrderController` instead of the example controller;
|
|
503
|
+
- `jsonEnvelope()` without `errorStatus`: the errors keep their status (400, 409, 503);
|
|
504
|
+
- a body of 1 MB at most (the template allows 100 MB), more than an order needs;
|
|
505
|
+
- `bootstrap: ['inventoryService', 'entrypoint']`: the proxy is created at the start, so an invalid `INVENTORY_URL`
|
|
506
|
+
stops the service at once, with `The option "url" of "inventoryService" (HTTPServiceProxy) is invalid`.
|
|
507
|
+
|
|
508
|
+
The template's middleware `Authenticate.ts` stays: when the orders service has an `API_TOKEN` of its own, its clients
|
|
509
|
+
must carry that token.
|
|
510
|
+
|
|
511
|
+
## 9. Run it
|
|
512
|
+
|
|
513
|
+
Start the inventory with its HTTP server on 8081, so that the orders service keeps 8080:
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
cd inventory
|
|
517
|
+
PORT=8081 API_TOKEN=inventory-secret npm run dev
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Then start the orders service in another terminal, with the inventory's token:
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
cd orders
|
|
524
|
+
INVENTORY_TOKEN=inventory-secret npm run dev
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
In a third terminal:
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}, {"product": "lamp", "quantity": 1}]}'
|
|
531
|
+
# {"errno":null,"errMsg":null,"data":{"id":"91de56de-9ca0-45d8-8197-cd2b2526dde5","items":[{"product":"desk","quantity":2},{"product":"lamp","quantity":1}]}}
|
|
532
|
+
|
|
533
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}]}'
|
|
534
|
+
# HTTP/1.1 409 Conflict
|
|
535
|
+
# {"errno":"E_OUT_OF_STOCK","errMsg":"desk: 2 asked, 1 in stock","data":null}
|
|
536
|
+
|
|
537
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "chair", "quantity": 1}]}'
|
|
538
|
+
# HTTP/1.1 404 Not Found
|
|
539
|
+
# {"errno":"E_PRODUCT_NOT_FOUND","errMsg":"The product chair does not exist","data":null}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
The inventory answers the calls that carry its token, and refuses the others:
|
|
543
|
+
|
|
544
|
+
```bash
|
|
545
|
+
curl localhost:3000 -H 'Authorization: Bearer inventory-secret' -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
|
|
546
|
+
# {"success":true,"error":null,"payload":{"product":"desk","available":1}}
|
|
547
|
+
|
|
548
|
+
curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
|
|
549
|
+
# {"success":false,"error":{…,"statusCode":401,"message":"The caller is not authenticated","errMsg":"The caller is not authenticated","err":"UnauthorizedException","errno":"E_UNAUTHORIZED"},"payload":null}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The error carries all the properties of the exception; its `stack`, `appId` and `appName` are left out here. Stop the
|
|
553
|
+
inventory (Ctrl+C) and place an order:
|
|
554
|
+
|
|
555
|
+
```bash
|
|
556
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "lamp", "quantity": 1}]}'
|
|
557
|
+
# HTTP/1.1 503 Service Unavailable
|
|
558
|
+
# {"errno":"E_INVENTORY_UNAVAILABLE","errMsg":"The inventory fails: The service http://localhost:3000 cannot be reached: connect ECONNREFUSED ::1:3000, connect ECONNREFUSED 127.0.0.1:3000","data":null}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## 10. Test it
|
|
562
|
+
|
|
563
|
+
Each service has its own tests, which replace the template's test. The inventory's tests start it, post the messages
|
|
564
|
+
of its actions and read the envelopes of the responses:
|
|
565
|
+
|
|
566
|
+
**`inventory/packages/app/src/tests/App.spec.ts`**
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
import {after, before, describe, it} from 'node:test'
|
|
570
|
+
import assert from 'node:assert'
|
|
571
|
+
import {Application} from 'lakutata'
|
|
572
|
+
import {config} from '../config/Config.js'
|
|
573
|
+
|
|
574
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
|
|
575
|
+
const SERVICE_PORT: number = PORT + 1
|
|
576
|
+
|
|
577
|
+
type Envelope = { success: boolean, error: { errno: string, errMsg: string } | null, payload?: any }
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Call a service action and return the envelope of the response
|
|
581
|
+
* @param message
|
|
582
|
+
* @param token
|
|
583
|
+
*/
|
|
584
|
+
async function call(message: Record<string, unknown>, token: string = 'test-token'): Promise<Envelope> {
|
|
585
|
+
const response: Response = await fetch(`http://127.0.0.1:${SERVICE_PORT}`, {
|
|
586
|
+
method: 'POST',
|
|
587
|
+
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
|
|
588
|
+
body: JSON.stringify(message)
|
|
589
|
+
})
|
|
590
|
+
return await response.json() as Envelope
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
describe('inventory', (): void => {
|
|
594
|
+
|
|
595
|
+
let app: Application
|
|
596
|
+
|
|
597
|
+
before(async (): Promise<void> => {
|
|
598
|
+
process.env.API_TOKEN = 'test-token'
|
|
599
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
600
|
+
Application.run(config(PORT, SERVICE_PORT)).onLaunched((launched: Application): void => resolve(launched))
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
after(async (): Promise<void> => {
|
|
605
|
+
await app?.dispose()
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('answers the health check without authentication', async (): Promise<void> => {
|
|
609
|
+
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('refuses the calls without the token', async (): Promise<void> => {
|
|
613
|
+
const refused: Envelope = await call({resource: 'stock', act: 'get', product: 'lamp'}, 'wrong-token')
|
|
614
|
+
assert.strictEqual(refused.success, false)
|
|
615
|
+
assert.strictEqual(refused.error?.errno, 'E_UNAUTHORIZED')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('gives the stock of a product', async (): Promise<void> => {
|
|
619
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 10})
|
|
620
|
+
assert.strictEqual((await call({resource: 'stock', act: 'get', product: 'chair'})).error?.errno, 'E_PRODUCT_NOT_FOUND')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('reserves all the items of an order, once', async (): Promise<void> => {
|
|
624
|
+
const message: Record<string, unknown> = {resource: 'stock', act: 'reserve', order: 'order-1', items: [{product: 'lamp', quantity: 2}, {product: 'desk', quantity: 1}]}
|
|
625
|
+
const levels: unknown = [{product: 'lamp', available: 8}, {product: 'desk', available: 2}]
|
|
626
|
+
assert.deepStrictEqual((await call(message)).payload, levels)
|
|
627
|
+
//Sent again, the call reserves nothing more
|
|
628
|
+
assert.deepStrictEqual((await call(message)).payload, levels)
|
|
629
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('reserves nothing when an item is short', async (): Promise<void> => {
|
|
633
|
+
const refused: Envelope = await call({resource: 'stock', act: 'reserve', order: 'order-2', items: [{product: 'lamp', quantity: 1}, {product: 'desk', quantity: 5}]})
|
|
634
|
+
assert.strictEqual(refused.error?.errno, 'E_OUT_OF_STOCK')
|
|
635
|
+
assert.strictEqual(refused.error?.errMsg, 'desk: 5 asked, 2 in stock')
|
|
636
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('refuses an invalid message', async (): Promise<void> => {
|
|
640
|
+
assert.strictEqual((await call({resource: 'stock', act: 'reserve', order: 'order-3', items: []})).error?.errno, 'E_INVALID_VALUE')
|
|
641
|
+
assert.strictEqual((await call({resource: 'stock', act: 'count'})).error?.errno, 'E_CONTROLLER_ACTION_NOT_FOUND')
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
The tests of the orders service do not need the inventory. They start a stand-in for it in the same process: an
|
|
647
|
+
entrypoint in a container of its own ([Objects](../Objects.md), section 5), with an action of the same pattern as the
|
|
648
|
+
inventory's. A process runs one application, here the orders service, so the stand-in is not an application. It checks
|
|
649
|
+
the token as the inventory does, and the last two tests change its token, then stop it:
|
|
650
|
+
|
|
651
|
+
**`orders/packages/app/src/tests/App.spec.ts`**
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
import {after, before, describe, it} from 'node:test'
|
|
655
|
+
import assert from 'node:assert'
|
|
656
|
+
import {Application, Container, Exception} from 'lakutata'
|
|
657
|
+
import {buildEntrypoints, Controller, Entrypoint, httpRequestOfContext, httpServiceEntrypoint, type ServiceContext} from 'lakutata/com/entrypoint'
|
|
658
|
+
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
|
|
659
|
+
import {config} from '../config/Config.js'
|
|
660
|
+
|
|
661
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
|
|
662
|
+
const INVENTORY_PORT: number = PORT + 1
|
|
663
|
+
|
|
664
|
+
class UnauthorizedException extends Exception {
|
|
665
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
666
|
+
|
|
667
|
+
public readonly statusCode: number = 401
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
class OutOfStockException extends Exception {
|
|
671
|
+
public errno: string = 'E_OUT_OF_STOCK'
|
|
672
|
+
|
|
673
|
+
public readonly statusCode: number = 409
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A stand-in for the inventory: an action with the same pattern, the token it expects, 3 lamps in stock, and the orders
|
|
678
|
+
* it has reserved
|
|
679
|
+
*/
|
|
680
|
+
@Prefix({pattern: {resource: 'stock'}})
|
|
681
|
+
class InventoryStandIn extends Controller {
|
|
682
|
+
public static token: string = 'inventory-token'
|
|
683
|
+
|
|
684
|
+
public static lamps: number = 3
|
|
685
|
+
|
|
686
|
+
public static readonly orders: string[] = []
|
|
687
|
+
|
|
688
|
+
@ServiceAction({act: 'reserve'})
|
|
689
|
+
public async reserve(input: { order: string, items: { product: string, quantity: number }[] }): Promise<unknown> {
|
|
690
|
+
const authorization: string | undefined = httpRequestOfContext(this.context as ServiceContext)?.headers.authorization
|
|
691
|
+
if (authorization !== `Bearer ${InventoryStandIn.token}`) throw new UnauthorizedException('The caller is not authenticated')
|
|
692
|
+
const quantity: number = input.items[0].quantity
|
|
693
|
+
if (quantity > InventoryStandIn.lamps) throw new OutOfStockException('lamp: {0} asked, {1} in stock', quantity, InventoryStandIn.lamps)
|
|
694
|
+
InventoryStandIn.lamps -= quantity
|
|
695
|
+
InventoryStandIn.orders.push(input.order)
|
|
696
|
+
return [{product: 'lamp', available: InventoryStandIn.lamps}]
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Place an order: the status of the response and its {errno, errMsg, data}
|
|
702
|
+
* @param body
|
|
703
|
+
*/
|
|
704
|
+
async function placeOrder(body: unknown): Promise<{ status: number, errno: string | null, errMsg: string | null, data: any }> {
|
|
705
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}/orders`, {
|
|
706
|
+
method: 'POST',
|
|
707
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
708
|
+
body: JSON.stringify(body)
|
|
709
|
+
})
|
|
710
|
+
return {status: response.status, ...await response.json() as { errno: string | null, errMsg: string | null, data: any }}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
describe('orders', (): void => {
|
|
714
|
+
|
|
715
|
+
let app: Application
|
|
716
|
+
//The stand-in runs in a container of its own, in the test process
|
|
717
|
+
const inventory: Container = new Container()
|
|
718
|
+
|
|
719
|
+
before(async (): Promise<void> => {
|
|
720
|
+
await inventory.set(Entrypoint, buildEntrypoints({
|
|
721
|
+
controllers: [InventoryStandIn],
|
|
722
|
+
service: httpServiceEntrypoint({port: INVENTORY_PORT, host: '127.0.0.1'})
|
|
723
|
+
}))
|
|
724
|
+
process.env.API_TOKEN = 'test-token'
|
|
725
|
+
process.env.INVENTORY_TOKEN = 'inventory-token'
|
|
726
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
727
|
+
Application.run(config(PORT, `http://127.0.0.1:${INVENTORY_PORT}`)).onLaunched((launched: Application): void => resolve(launched))
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
after(async (): Promise<void> => {
|
|
732
|
+
await app?.dispose()
|
|
733
|
+
await inventory.destroy()
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('places an order, its stock reserved by the inventory', async (): Promise<void> => {
|
|
737
|
+
const placed = await placeOrder({items: [{product: 'lamp', quantity: 2}]})
|
|
738
|
+
assert.strictEqual(placed.status, 200)
|
|
739
|
+
assert.deepStrictEqual(placed.data.items, [{product: 'lamp', quantity: 2}])
|
|
740
|
+
assert.deepStrictEqual(InventoryStandIn.orders, [placed.data.id])
|
|
741
|
+
assert.strictEqual(InventoryStandIn.lamps, 1)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('answers the refusal of the inventory', async (): Promise<void> => {
|
|
745
|
+
const refused = await placeOrder({items: [{product: 'lamp', quantity: 5}]})
|
|
746
|
+
assert.strictEqual(refused.status, 409)
|
|
747
|
+
assert.strictEqual(refused.errno, 'E_OUT_OF_STOCK')
|
|
748
|
+
assert.strictEqual(refused.errMsg, 'lamp: 5 asked, 1 in stock')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('refuses an invalid order without calling the inventory', async (): Promise<void> => {
|
|
752
|
+
const invalid = await placeOrder({items: []})
|
|
753
|
+
assert.strictEqual(invalid.status, 400)
|
|
754
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
755
|
+
assert.strictEqual(InventoryStandIn.orders.length, 1)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('answers 503 when the inventory refuses its token', async (): Promise<void> => {
|
|
759
|
+
InventoryStandIn.token = 'another-token'
|
|
760
|
+
const failed = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
|
|
761
|
+
assert.strictEqual(failed.status, 503)
|
|
762
|
+
assert.strictEqual(failed.errno, 'E_INVENTORY_UNAVAILABLE')
|
|
763
|
+
assert.strictEqual(failed.errMsg, 'The inventory fails: The caller is not authenticated')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('answers 503 when the inventory does not answer', async (): Promise<void> => {
|
|
767
|
+
await inventory.destroy()
|
|
768
|
+
const unavailable = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
|
|
769
|
+
assert.strictEqual(unavailable.status, 503)
|
|
770
|
+
assert.strictEqual(unavailable.errno, 'E_INVENTORY_UNAVAILABLE')
|
|
771
|
+
})
|
|
772
|
+
})
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
```bash
|
|
776
|
+
cd inventory
|
|
777
|
+
npm test
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
```
|
|
781
|
+
app │ ✔ answers the health check without authentication
|
|
782
|
+
app │ ✔ refuses the calls without the token
|
|
783
|
+
app │ ✔ gives the stock of a product
|
|
784
|
+
app │ ✔ reserves all the items of an order, once
|
|
785
|
+
app │ ✔ reserves nothing when an item is short
|
|
786
|
+
app │ ✔ refuses an invalid message
|
|
787
|
+
app │ ℹ pass 6
|
|
788
|
+
✔ test ran in app
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
```bash
|
|
792
|
+
cd ../orders
|
|
793
|
+
npm test
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
```
|
|
797
|
+
app │ ✔ places an order, its stock reserved by the inventory
|
|
798
|
+
app │ ✔ answers the refusal of the inventory
|
|
799
|
+
app │ ✔ refuses an invalid order without calling the inventory
|
|
800
|
+
app │ ✔ answers 503 when the inventory refuses its token
|
|
801
|
+
app │ ✔ answers 503 when the inventory does not answer
|
|
802
|
+
app │ ℹ pass 5
|
|
803
|
+
✔ test ran in app
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
## 11. In production
|
|
807
|
+
|
|
808
|
+
- `npm run docker:build` in each project makes its image (the template's `Dockerfile`). In its image, the inventory
|
|
809
|
+
serves its health check on `PORT` (8080) and the calls on `SERVICE_PORT` (3000). On the container network, the
|
|
810
|
+
orders service reaches it with `INVENTORY_URL=http://inventory:3000`; publish only the port of the orders service.
|
|
811
|
+
- Give both services the token from the same secret: the inventory's `API_TOKEN` and the `INVENTORY_TOKEN` of the
|
|
812
|
+
orders service. While only one of them has a new token, the orders service answers 503.
|
|
813
|
+
- The stock is kept in memory, and the orders are not kept at all: store them in a database
|
|
814
|
+
([A REST API with a database](RestAPI.md)). There, the order id, as a unique key of the reservations, keeps an order
|
|
815
|
+
from being reserved twice.
|
|
816
|
+
- Other transports serve the same actions: Socket.IO, JSON-RPC ([ServiceCalls](../ServiceCalls.md)), NATS
|
|
817
|
+
([NATS](../NATS.md)) or gRPC ([GRPC](../GRPC.md)), each an adapter of the `service` option. The proxies of NATS and
|
|
818
|
+
gRPC have the same `invoke()` and throw the same `ServiceInvokeException`: register `inventoryService` with their
|
|
819
|
+
builders instead and change the type of the proxy in `Inventory.ts`; the rest of `Inventory.ts` stays.
|
|
820
|
+
|
|
821
|
+
<!--
|
|
822
|
+
check:
|
|
823
|
+
create: inventory service
|
|
824
|
+
remove: inventory/packages/app/src/controllers/ExampleController.ts inventory/packages/app/src/dtos/GreetDTO.ts
|
|
825
|
+
create: orders service
|
|
826
|
+
remove: orders/packages/app/src/controllers/ExampleController.ts orders/packages/app/src/dtos/GreetDTO.ts
|
|
827
|
+
run: inventory: npm test
|
|
828
|
+
run: orders: npm test
|
|
829
|
+
-->
|