lakutata 3.0.0 → 3.1.1

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.
Files changed (121) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +511 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +63 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +508 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/WebRTC.md +1364 -0
  50. package/doc/en/Workspaces.md +189 -0
  51. package/doc/en/examples/BackgroundJobs.md +677 -0
  52. package/doc/en/examples/CommandLineTool.md +664 -0
  53. package/doc/en/examples/DesktopApp.md +809 -0
  54. package/doc/en/examples/DeviceService.md +607 -0
  55. package/doc/en/examples/MCPServer.md +653 -0
  56. package/doc/en/examples/Meeting.md +573 -0
  57. package/doc/en/examples/README.md +17 -0
  58. package/doc/en/examples/RestAPI.md +490 -0
  59. package/doc/en/examples/ServiceCalls.md +829 -0
  60. package/doc/en/examples/WebApp.md +568 -0
  61. package/doc/zh/Application.md +75 -0
  62. package/doc/zh/CLI.md +250 -0
  63. package/doc/zh/Cache.md +328 -0
  64. package/doc/zh/Command.md +78 -0
  65. package/doc/zh/ContainerdIntegration.md +9 -9
  66. package/doc/zh/Controllers.md +228 -0
  67. package/doc/zh/DBus.md +257 -0
  68. package/doc/zh/DTOValidation.md +49 -19
  69. package/doc/zh/Database.md +175 -0
  70. package/doc/zh/DatabaseDrivers.md +261 -0
  71. package/doc/zh/Decorator.md +25 -18
  72. package/doc/zh/DockerIntegration.md +6 -6
  73. package/doc/zh/Entities.md +224 -0
  74. package/doc/zh/Entrypoint.md +193 -0
  75. package/doc/zh/Exceptions.md +158 -0
  76. package/doc/zh/GRPC.md +226 -0
  77. package/doc/zh/GettingStarted.md +148 -0
  78. package/doc/zh/HTTP.md +446 -0
  79. package/doc/zh/HelperFunctions.md +2 -2
  80. package/doc/zh/I18n.md +26 -26
  81. package/doc/zh/Injection.md +129 -0
  82. package/doc/zh/Installers.md +232 -0
  83. package/doc/zh/Logger.md +188 -0
  84. package/doc/zh/MCP.md +265 -0
  85. package/doc/zh/Middlewares.md +198 -0
  86. package/doc/zh/Migrations.md +178 -0
  87. package/doc/zh/MongoEmbedded.md +118 -0
  88. package/doc/zh/Monitor.md +145 -0
  89. package/doc/zh/Mutex.md +10 -10
  90. package/doc/zh/NATS.md +207 -0
  91. package/doc/zh/NativeAddons.md +180 -0
  92. package/doc/zh/Objects.md +119 -0
  93. package/doc/zh/Overview.md +47 -71
  94. package/doc/zh/Packaging.md +154 -0
  95. package/doc/zh/ProjectStructure.md +102 -0
  96. package/doc/zh/Queue.md +214 -0
  97. package/doc/zh/Schedules.md +193 -0
  98. package/doc/zh/Security.md +6 -6
  99. package/doc/zh/ServiceCalls.md +462 -0
  100. package/doc/zh/Time.md +2 -2
  101. package/doc/zh/UpgradingFrom1.md +347 -0
  102. package/doc/zh/UpgradingFrom2.md +230 -0
  103. package/doc/zh/WebRTC.md +1049 -0
  104. package/doc/zh/Workspaces.md +150 -0
  105. package/doc/zh/examples/BackgroundJobs.md +618 -0
  106. package/doc/zh/examples/CommandLineTool.md +618 -0
  107. package/doc/zh/examples/DesktopApp.md +748 -0
  108. package/doc/zh/examples/DeviceService.md +546 -0
  109. package/doc/zh/examples/MCPServer.md +604 -0
  110. package/doc/zh/examples/Meeting.md +524 -0
  111. package/doc/zh/examples/README.md +15 -0
  112. package/doc/zh/examples/RestAPI.md +458 -0
  113. package/doc/zh/examples/ServiceCalls.md +765 -0
  114. package/doc/zh/examples/WebApp.md +520 -0
  115. package/package.json +46 -17
  116. package/doc/en/ActionPattern.md +0 -408
  117. package/doc/en/Core.md +0 -312
  118. package/doc/en/ORMIntegration.md +0 -341
  119. package/doc/zh/ActionPattern.md +0 -334
  120. package/doc/zh/Core.md +0 -256
  121. 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
+ -->