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
package/doc/en/HTTP.md
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# Lakutata HTTP
|
|
2
|
+
|
|
3
|
+
The HTTP adapters serve the HTTP actions of the controllers (`@GET`, `@POST`…) with Express or Fastify. This guide
|
|
4
|
+
covers what is particular to HTTP: the two adapters and their options, the routes, how a request becomes the input of
|
|
5
|
+
an action, the responses, the errors, and the features of the web framework itself. The controllers and their actions
|
|
6
|
+
are described in [Controllers](Controllers.md), the middlewares and the access control in
|
|
7
|
+
[Middlewares](Middlewares.md), and the starting and stopping of the adapters in [Entrypoint](Entrypoint.md).
|
|
8
|
+
|
|
9
|
+
## 1. A first HTTP API
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install lakutata express
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import {Application, DTO, Exception} from 'lakutata'
|
|
17
|
+
import {buildEntrypoints, Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
|
|
18
|
+
import {DELETE, GET, POST} from 'lakutata/decorator/ctrl'
|
|
19
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
20
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
21
|
+
|
|
22
|
+
class OrderIdDTO extends DTO {
|
|
23
|
+
//A route parameter is text: strict(false) converts '1' to 1
|
|
24
|
+
@Expect(DTO.Number().integer().strict(false).required())
|
|
25
|
+
public id: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class NewOrderDTO extends DTO {
|
|
29
|
+
@Expect(DTO.String().required())
|
|
30
|
+
public product: string
|
|
31
|
+
|
|
32
|
+
@Expect(DTO.Number().integer().min(1).default(1))
|
|
33
|
+
public quantity: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class OrderNotFoundException extends Exception {
|
|
37
|
+
public errno: string = 'E_ORDER_NOT_FOUND'
|
|
38
|
+
|
|
39
|
+
public statusCode: number = 404
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Order = { id: number, product: string, quantity: number }
|
|
43
|
+
|
|
44
|
+
const orders: Map<number, Order> = new Map()
|
|
45
|
+
|
|
46
|
+
class OrderController extends Controller {
|
|
47
|
+
@POST('/orders', NewOrderDTO)
|
|
48
|
+
public async create(input: NewOrderDTO): Promise<StatusResult> {
|
|
49
|
+
const order: Order = {id: orders.size + 1, product: input.product, quantity: input.quantity}
|
|
50
|
+
orders.set(order.id, order)
|
|
51
|
+
return Result.status(201, order, {Location: `/orders/${order.id}`})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@GET('/orders/:id', OrderIdDTO)
|
|
55
|
+
public async get(input: OrderIdDTO): Promise<Order> {
|
|
56
|
+
const order: Order | undefined = orders.get(input.id)
|
|
57
|
+
if (!order) throw new OrderNotFoundException('Order {id} not found', {id: input.id})
|
|
58
|
+
return order
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@DELETE('/orders/:id', OrderIdDTO)
|
|
62
|
+
public async remove(input: OrderIdDTO): Promise<void> {
|
|
63
|
+
orders.delete(input.id)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Application.run(() => ({
|
|
68
|
+
id: 'orders.app',
|
|
69
|
+
name: 'Orders',
|
|
70
|
+
components: {
|
|
71
|
+
entrypoint: buildEntrypoints({
|
|
72
|
+
controllers: [OrderController],
|
|
73
|
+
http: expressHTTPEntrypoint({port: 8080})
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
bootstrap: ['entrypoint']
|
|
77
|
+
}))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
$ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"product": "lamp"}'
|
|
82
|
+
HTTP/1.1 201 Created
|
|
83
|
+
Location: /orders/1
|
|
84
|
+
|
|
85
|
+
{"id":1,"product":"lamp","quantity":1}
|
|
86
|
+
$ curl localhost:8080/orders/1
|
|
87
|
+
{"id":1,"product":"lamp","quantity":1}
|
|
88
|
+
$ curl -i -X DELETE localhost:8080/orders/1
|
|
89
|
+
HTTP/1.1 204 No Content
|
|
90
|
+
$ curl -i localhost:8080/orders/1
|
|
91
|
+
HTTP/1.1 404 Not Found
|
|
92
|
+
|
|
93
|
+
{"errno":"E_ORDER_NOT_FOUND","errMsg":"Order 1 not found"}
|
|
94
|
+
$ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{}'
|
|
95
|
+
HTTP/1.1 400 Bad Request
|
|
96
|
+
|
|
97
|
+
{"errno":"E_INVALID_VALUE","errMsg":"\"product\" is required"}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 2. Express or Fastify
|
|
101
|
+
|
|
102
|
+
Both adapters serve the same actions, with the same results:
|
|
103
|
+
|
|
104
|
+
| | Express | Fastify |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Adapter | `expressHTTPEntrypoint()`, from `lakutata/adapter/express` | `fastifyHTTPEntrypoint()`, from `lakutata/adapter/fastify` |
|
|
107
|
+
| Install | `express` (4 or 5) | `fastify` (5) |
|
|
108
|
+
|
|
109
|
+
The options of `expressHTTPEntrypoint()`:
|
|
110
|
+
|
|
111
|
+
| Option | Default | Meaning |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `port` | `80` | The port; `0` picks a free port. A port already in use fails the application's start |
|
|
114
|
+
| `host` | all the interfaces | The address to listen on, such as `'127.0.0.1'` for the local machine only |
|
|
115
|
+
| `name` | | The name of the adapter, to tell several adapters apart |
|
|
116
|
+
| `body` | `{limit: '1mb'}` | The size limit of the JSON and URL-encoded bodies (`'5mb'`, or a number of bytes); `false` parses no body |
|
|
117
|
+
| `server` | | The options of the Node.js HTTP server: `keepAliveTimeout`, `headersTimeout`, `requestTimeout`… |
|
|
118
|
+
| `app` | a new application | An existing Express application to add the actions to |
|
|
119
|
+
| `input` | the query and the body | Builds the input from the request (section 4) |
|
|
120
|
+
| `respond` | `JSONResponder` | Writes the results and the errors (section 6) |
|
|
121
|
+
| `middlewares` | | The middlewares of the actions of this adapter, run after those of the entrypoint |
|
|
122
|
+
| `before` | | `(app, context) => …`: adds the application's own middlewares and routes (section 7) |
|
|
123
|
+
| `fallback` | | Answers the requests outside the paths of the actions, such as the pages of a web UI (section 7) |
|
|
124
|
+
| `stop` | | Called when the adapter stops, once its server is closed |
|
|
125
|
+
| `heartbeat` | `15000` | How often a comment keeps the event streams alive, in milliseconds (section 5) |
|
|
126
|
+
|
|
127
|
+
`fastifyHTTPEntrypoint()` takes the same options, except `body`, `server` and `app`, replaced by `fastify`: a Fastify
|
|
128
|
+
instance, or the options of the instance to create (`bodyLimit`, 1 MiB by default; `trustProxy`; `connectionTimeout`,
|
|
129
|
+
`keepAliveTimeout`, `requestTimeout`; `logger`…).
|
|
130
|
+
|
|
131
|
+
Where they differ:
|
|
132
|
+
|
|
133
|
+
| | Express | Fastify |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| URL-encoded form bodies | Parsed | Refused with 415 |
|
|
136
|
+
| `this.context.request` | The Express request (`ip`, `get()`, `hostname`…) | The Node.js request |
|
|
137
|
+
| A wildcard route | `/files/*path`: the input gets `path: ['docs', 'a.txt']` | `/files/*`: the input gets `'*': 'docs/a.txt'` |
|
|
138
|
+
| Other HTTP methods than `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` | Accepted | Registered with `app.addHttpMethod()` in `before`; otherwise the start fails |
|
|
139
|
+
| A web UI as `fallback` | `staticUI()` (section 7) | A handler of your own |
|
|
140
|
+
| The `errno` of the errors of the framework | `E_UNKNOWN` | Its codes, such as `FST_ERR_CTP_BODY_TOO_LARGE` |
|
|
141
|
+
| A given application or instance, when the application stops | Left as it is | Closed |
|
|
142
|
+
|
|
143
|
+
When the application stops (see [Entrypoint](Entrypoint.md), section 4), the adapters stop accepting connections and end
|
|
144
|
+
the event streams; Fastify also answers 503 to the requests that still arrive on the open connections. The requests in
|
|
145
|
+
progress get the `shutdownTimeout` of the entrypoint (after it, they are answered with 503), then up to one second to
|
|
146
|
+
send their responses, before the remaining connections are closed, the WebSockets included.
|
|
147
|
+
|
|
148
|
+
## 3. Routes
|
|
149
|
+
|
|
150
|
+
| Decorator | Methods |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `@GET(route)`, `@POST`, `@PUT`, `@PATCH`, `@DELETE`, `@HEAD`, `@OPTIONS` | The method of its name |
|
|
153
|
+
| `@HTTPAction(route, method)` | One method (`'purge'`), or a list (`['get', 'post']`) |
|
|
154
|
+
|
|
155
|
+
Each of them then takes the DTO of the input and the options of the action, both optional (see
|
|
156
|
+
[Controllers](Controllers.md), section 3).
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import {DTO} from 'lakutata'
|
|
160
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
161
|
+
import {GET, HTTPAction, Prefix} from 'lakutata/decorator/ctrl'
|
|
162
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
163
|
+
|
|
164
|
+
class SearchDTO extends DTO {
|
|
165
|
+
@Expect(DTO.String().required())
|
|
166
|
+
public keyword: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Prefix({route: '/api'})
|
|
170
|
+
class LibraryController extends Controller {
|
|
171
|
+
//GET /api/search?keyword=lamp, or POST /api/search with the body {"keyword": "lamp"}
|
|
172
|
+
@HTTPAction('/search', ['get', 'post'], SearchDTO)
|
|
173
|
+
public async search(input: SearchDTO): Promise<string[]> {
|
|
174
|
+
return [input.keyword]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
//GET /api/files/docs/guide.pdf with Express: input.path is ['docs', 'guide.pdf'] (with Fastify: '/files/*' and input['*'])
|
|
178
|
+
@GET('/files/*path')
|
|
179
|
+
public async file(input: { path: string[] }): Promise<string> {
|
|
180
|
+
return input.path.join('/')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- A route uses the syntax of the web framework: `:id` is a parameter with both; the wildcards differ (section 2).
|
|
186
|
+
- `@Prefix({route})` puts a prefix before the routes of a controller (see [Controllers](Controllers.md), section 6).
|
|
187
|
+
- A `GET` action also answers the `HEAD` requests of its route, without a body, unless the route has a `HEAD` action.
|
|
188
|
+
- A request whose method has no action on its route is answered with 404 (`E_ROUTE_NOT_FOUND`), not 405. Nothing
|
|
189
|
+
answers `OPTIONS` by itself: the preflight requests of the browsers fail until a CORS middleware answers them
|
|
190
|
+
(section 7).
|
|
191
|
+
|
|
192
|
+
## 4. The input
|
|
193
|
+
|
|
194
|
+
By default, the input of an action is the query, then the properties of the body over it, then the route parameters
|
|
195
|
+
over both:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
POST /orders/7?id=1¬e=a with the body {"id": 5, "note": "b"} → {id: '7', note: 'b'}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The DTO of the action then validates it, and removes the keys it does not declare. `this.context.data` is the input
|
|
202
|
+
before the validation, without the route parameters.
|
|
203
|
+
|
|
204
|
+
- **Text.** The values of the query and the route parameters are strings: a schema of a number or a boolean needs
|
|
205
|
+
`.strict(false)` to accept `'42'` or `'true'`.
|
|
206
|
+
- **The query.** A key given several times is an array (`?tag=a&tag=b` → `tag: ['a', 'b']`). With Express 5 and
|
|
207
|
+
Fastify, the brackets do not nest: `?filter[status]=open` gives the key `'filter[status]'`.
|
|
208
|
+
- **JSON bodies.** An array gives no usable input (Express spreads its items under numbered keys, Fastify ignores it):
|
|
209
|
+
send a list inside an object (`{"items": [...]}`).
|
|
210
|
+
- **Forms.** Express parses the URL-encoded bodies, the brackets nested (`a[b]=1` → `a: {b: '1'}`); Fastify refuses them
|
|
211
|
+
with 415. Neither parses `multipart/form-data`, the file uploads (section 7).
|
|
212
|
+
- **Limits.** A body over the limit is refused with 413, malformed JSON with 400.
|
|
213
|
+
|
|
214
|
+
The `input` option builds the input from the request. For example, to give the actions the address of the client and
|
|
215
|
+
its language (only the DTOs that declare `ip` and `language` keep them):
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
219
|
+
import type {Request} from 'express'
|
|
220
|
+
|
|
221
|
+
export const http = expressHTTPEntrypoint({
|
|
222
|
+
port: 8080,
|
|
223
|
+
input: (request: Request): Record<string, unknown> => ({
|
|
224
|
+
...request.query,
|
|
225
|
+
...request.body,
|
|
226
|
+
ip: request.ip,
|
|
227
|
+
language: request.get('accept-language')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The route parameters are always merged over the result. With Express, an action can also read the request itself:
|
|
233
|
+
`(this.context as HTTPContext).request as unknown as Request` gives the Express request, with `ip`, `get()` and the
|
|
234
|
+
properties that its middlewares add. With Fastify, `this.context.request` is the Node.js request: give the action
|
|
235
|
+
`request.ip` through `input`.
|
|
236
|
+
|
|
237
|
+
## 5. The response
|
|
238
|
+
|
|
239
|
+
- A value is sent as JSON; `undefined` is answered with 204.
|
|
240
|
+
- `Result.status(status, data?, headers?)` sends a status and headers with the data, or without data.
|
|
241
|
+
- `Result.file(source, options?)` sends a file: its path (relative to the working directory), a `Buffer` or a stream.
|
|
242
|
+
`filename` makes it a download; `contentType` sets its type, otherwise taken from the extension of its name; `etag`
|
|
243
|
+
sends `ETag` and answers 304 when the `If-None-Match` of the request matches it.
|
|
244
|
+
- `Result.events(producer)` sends Server-Sent Events.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import {Readable} from 'node:stream'
|
|
248
|
+
import {Controller, Result, type EventsResult, type FileResult, type StatusResult} from 'lakutata/com/entrypoint'
|
|
249
|
+
import {GET, POST} from 'lakutata/decorator/ctrl'
|
|
250
|
+
|
|
251
|
+
class ExportController extends Controller {
|
|
252
|
+
//A download
|
|
253
|
+
@GET('/exports/orders.csv')
|
|
254
|
+
public async orders(): Promise<FileResult> {
|
|
255
|
+
return Result.file(Readable.from(['id,product\n', '1,lamp\n']), {contentType: 'text/csv', filename: 'orders.csv'})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
//The progress of a job, until it ends or the client leaves
|
|
259
|
+
@GET('/exports/progress')
|
|
260
|
+
public async progress(): Promise<EventsResult> {
|
|
261
|
+
return Result.events(async (send, signal: AbortSignal): Promise<void> => {
|
|
262
|
+
for (let percent: number = 0; percent <= 100 && !signal.aborted; percent += 25) {
|
|
263
|
+
send({percent: percent}, 'progress')
|
|
264
|
+
await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 1000))
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
//A redirection
|
|
270
|
+
@POST('/logout')
|
|
271
|
+
public async logout(): Promise<StatusResult> {
|
|
272
|
+
return Result.status(303, undefined, {Location: '/'})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The events are written as the standard defines them: the data as JSON (a string as it is, one `data:` line per line),
|
|
278
|
+
with the event's id and name before it when they are given:
|
|
279
|
+
|
|
280
|
+
```text
|
|
281
|
+
id: 1
|
|
282
|
+
event: tick
|
|
283
|
+
data: {"n":1}
|
|
284
|
+
|
|
285
|
+
data: line one
|
|
286
|
+
data: line two
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
- A comment (`: ping`) is sent every `heartbeat` milliseconds, so that the proxies keep the connection open.
|
|
290
|
+
- The stream ends when the producer returns or fails; a failure sends nothing more, the headers being already sent.
|
|
291
|
+
`signal` is aborted when the client leaves or the application stops.
|
|
292
|
+
- The producer runs after the scope of the request is destroyed: it should use components, not the providers of the
|
|
293
|
+
request.
|
|
294
|
+
|
|
295
|
+
An action can also write the response itself through `this.context.response`, the Node.js response. Once the action has
|
|
296
|
+
sent the headers, the adapter writes neither its result nor its error. When the client leaves before the response,
|
|
297
|
+
`this.signal` is aborted, and nothing is written.
|
|
298
|
+
|
|
299
|
+
## 6. Errors
|
|
300
|
+
|
|
301
|
+
An error is answered with a status and `{errno, errMsg}`:
|
|
302
|
+
|
|
303
|
+
- the status is the first client error (4xx) found in the `statusCode` or `status` of the error or of its causes
|
|
304
|
+
(`cause`), otherwise its own status when it is between 400 and 599, otherwise 500;
|
|
305
|
+
- `errno` and `errMsg` come from the error itself: its `errno`, otherwise its `code`, otherwise `E_UNKNOWN`; its
|
|
306
|
+
`errMsg`, otherwise its `message`. So `new Error('Lookup failed', {cause: notFound})` is answered with the 404 of its
|
|
307
|
+
cause, and `{"errno": "E_UNKNOWN", "errMsg": "Lookup failed"}`.
|
|
308
|
+
|
|
309
|
+
| Status | `errno` | When |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| 400 | `E_INVALID_VALUE` | The DTO refused the input |
|
|
312
|
+
| 400 | `E_UNKNOWN` (Express), `FST_ERR_CTP_INVALID_JSON_BODY` (Fastify) | The JSON of the body is malformed |
|
|
313
|
+
| 403 | `E_ACCESS_DENY` | The access control refused the request (see [Middlewares](Middlewares.md)) |
|
|
314
|
+
| 404 | `E_ROUTE_NOT_FOUND` | No action has this route and method |
|
|
315
|
+
| 413 | `E_UNKNOWN` (Express), `FST_ERR_CTP_BODY_TOO_LARGE` (Fastify) | The body is over the limit |
|
|
316
|
+
| 415 | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | Fastify does not parse this type of body |
|
|
317
|
+
| 503 | `E_ENTRYPOINT_STOPPED` | The application is stopping |
|
|
318
|
+
| 500 | The error's | Any other error |
|
|
319
|
+
|
|
320
|
+
An exception of the application gives its own `errno` and `statusCode`, as `OrderNotFoundException` in section 1.
|
|
321
|
+
|
|
322
|
+
**The envelope.** `jsonEnvelope()` (Express) and `fastifyJSONEnvelope()` (Fastify) wrap every response in
|
|
323
|
+
`{errno, errMsg, data}`:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
327
|
+
|
|
328
|
+
export const http = expressHTTPEntrypoint({
|
|
329
|
+
port: 8080,
|
|
330
|
+
//{"errno": null, "errMsg": null, "data": {...}, "timestamp": 1792209357257}
|
|
331
|
+
respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
|
|
332
|
+
})
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- A result is `{errno: null, errMsg: null, data}`, with the status 200 when there is no data (`data` is `null`).
|
|
336
|
+
- An error is `{errno, errMsg, data: null}`, with its status. `errorStatus` gives the status of every error, such as
|
|
337
|
+
200 for the clients that read the errors from the envelope. `actionErrorStatus` gives the status of the errors of the
|
|
338
|
+
actions only (their validation, their access control, their exceptions): a route not found and the errors of the
|
|
339
|
+
middlewares of the web framework keep theirs.
|
|
340
|
+
- `extra(request)` adds fields to every response, after `errno`, `errMsg` and `data`, which they never replace.
|
|
341
|
+
|
|
342
|
+
**A responder of your own** is an object with two methods, given to `respond`: `success(response, data, request)` and
|
|
343
|
+
`error(response, error, request, origin)`. `origin` is `'action'` when the request reached an action, `'request'` when
|
|
344
|
+
it failed before (a middleware of the web framework, a route not found). `statusOfError()` and `describeError()`, from
|
|
345
|
+
the adapter's subpath, compute the status and the `{errno, errMsg}` described above. The files and the event streams do
|
|
346
|
+
not go through the responder.
|
|
347
|
+
|
|
348
|
+
## 7. The features of the web framework
|
|
349
|
+
|
|
350
|
+
The `before` hook receives the Express application or the Fastify instance before the routes of the actions are added.
|
|
351
|
+
Its middlewares, plugins and routes come first: a route of its own takes precedence over an action with the same path.
|
|
352
|
+
Its second argument gives:
|
|
353
|
+
|
|
354
|
+
| Property | What it is |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `module` | The module of the entrypoint, to get objects: `await module.getObject('db')` |
|
|
357
|
+
| `server` | The HTTP server, not listening yet: a WebSocket server can be attached to it |
|
|
358
|
+
| `routes` | The routes of the actions, each with its methods |
|
|
359
|
+
| `actionPrefixes`, `isActionPath(path)` | The first segments of the routes of the actions, and whether a path is under one of them |
|
|
360
|
+
|
|
361
|
+
A failure of `before` fails the application's start. With Express, the body parsers are added after `before`: its
|
|
362
|
+
middlewares do not see a parsed body.
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import compression from 'compression'
|
|
366
|
+
import cors from 'cors'
|
|
367
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
368
|
+
import type {Express, Request, Response} from 'express'
|
|
369
|
+
|
|
370
|
+
export const http = expressHTTPEntrypoint({
|
|
371
|
+
port: 8080,
|
|
372
|
+
before: (app: Express): void => {
|
|
373
|
+
//Behind a reverse proxy on the same machine: request.ip and request.protocol come from its X-Forwarded-* headers
|
|
374
|
+
app.set('trust proxy', 'loopback')
|
|
375
|
+
//Answers the preflight requests of the browsers, and adds the CORS headers to the responses
|
|
376
|
+
app.use(cors({origin: 'https://app.example.com'}))
|
|
377
|
+
app.use(compression())
|
|
378
|
+
app.get('/health', (_request: Request, response: Response): void => void response.send('ok'))
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
With Fastify, register the plugins in an `async` hook (`await app.register(fastifyCors, {origin: …})`, from
|
|
384
|
+
`@fastify/cors`), and trust the proxy with `fastify: {trustProxy: true}`.
|
|
385
|
+
|
|
386
|
+
**HTTPS.** The adapters serve HTTP. Put a reverse proxy in front of the application (nginx, Caddy, a load balancer) to
|
|
387
|
+
serve HTTPS, and trust it as above.
|
|
388
|
+
|
|
389
|
+
### Uploads
|
|
390
|
+
|
|
391
|
+
The adapters do not parse `multipart/form-data`. With Express, add a parser such as `multer` to the route in `before`,
|
|
392
|
+
and give its file to the input:
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
import multer from 'multer'
|
|
396
|
+
import {DTO} from 'lakutata'
|
|
397
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
398
|
+
import {POST} from 'lakutata/decorator/ctrl'
|
|
399
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
400
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
401
|
+
import type {Express, Request} from 'express'
|
|
402
|
+
|
|
403
|
+
class AvatarDTO extends DTO {
|
|
404
|
+
@Expect(DTO.String().required())
|
|
405
|
+
public user: string
|
|
406
|
+
|
|
407
|
+
//The file of multer: {originalname, mimetype, size, buffer…}
|
|
408
|
+
@Expect(DTO.Object().required())
|
|
409
|
+
public file: Express.Multer.File
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
class AvatarController extends Controller {
|
|
413
|
+
@POST('/avatars', AvatarDTO)
|
|
414
|
+
public async upload(input: AvatarDTO): Promise<{ name: string, size: number }> {
|
|
415
|
+
return {name: input.file.originalname, size: input.file.buffer.length}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const upload: multer.Multer = multer({storage: multer.memoryStorage(), limits: {fileSize: 5 * 1024 * 1024}})
|
|
420
|
+
|
|
421
|
+
export const http = expressHTTPEntrypoint({
|
|
422
|
+
port: 8080,
|
|
423
|
+
before: (app: Express): void => {
|
|
424
|
+
//Parses the form of POST /avatars, then the request goes on to the action
|
|
425
|
+
app.post('/avatars', upload.single('file'))
|
|
426
|
+
},
|
|
427
|
+
input: (request: Request): Record<string, unknown> => ({...request.query, ...request.body, file: request.file})
|
|
428
|
+
})
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The errors of multer, such as a file over its limit (`LIMIT_FILE_SIZE`), have no status: they are answered with 500.
|
|
432
|
+
|
|
433
|
+
With Fastify, register `@fastify/multipart` with `attachFieldsToBody: 'keyValues'` in `before`: the fields and the
|
|
434
|
+
files are then properties of the body, and so of the input. A file is a `Buffer` (declare it `DTO.Binary()`), without
|
|
435
|
+
its name; a file over the limit is refused with 413.
|
|
436
|
+
|
|
437
|
+
### A web UI
|
|
438
|
+
|
|
439
|
+
`fallback` answers the requests outside the paths of the actions (`isActionPath()` above): with Express, `staticUI()`
|
|
440
|
+
serves a built single-page application, such as the `dist` directory of a Vite build:
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import {expressHTTPEntrypoint, staticUI} from 'lakutata/adapter/express'
|
|
444
|
+
|
|
445
|
+
export const http = expressHTTPEntrypoint({
|
|
446
|
+
port: 8080,
|
|
447
|
+
fallback: staticUI('./ui/dist')
|
|
448
|
+
})
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
- A file of the directory is sent as it is. The files with a content hash in their name (`index-B2x9fQ1a.js`) are
|
|
452
|
+
cached by the browsers for a year (`assetsMaxAge`); the other files are checked again on each request.
|
|
453
|
+
- A `GET` request for a page (accepting HTML, its path without an extension) that matches no file is answered with the
|
|
454
|
+
`index.html` of the application, whose router handles the path. `spa: false` turns this off, and `index` names
|
|
455
|
+
another file.
|
|
456
|
+
- The other requests that match no file, such as a missing script, are answered with 404.
|
|
457
|
+
- The paths under the routes of the actions (`/api/…`) never reach the fallback: a request there that matches no action
|
|
458
|
+
is answered with 404.
|
|
459
|
+
|
|
460
|
+
With Fastify, `fallback` is a handler of your own, `(request, reply) => …`, which receives every request outside the
|
|
461
|
+
paths of the actions.
|
|
462
|
+
|
|
463
|
+
## 8. From lakutata 2.x
|
|
464
|
+
|
|
465
|
+
An HTTP entrypoint function of 2.x (`buildHTTPEntrypoint`) keeps working: it is called in the startup of the entrypoint
|
|
466
|
+
(the objects it gets, such as an authenticator injecting the entrypoint, are available) and awaited, so the application
|
|
467
|
+
launches once its server listens. To move it to `expressHTTPEntrypoint`:
|
|
468
|
+
|
|
469
|
+
- the middlewares and the routes of the application (CORS, compression, a health check, the authenticator, a web UI) go
|
|
470
|
+
into its `before` hook, which gets the objects with `module.getObject()`; the body parsers are its `body` option
|
|
471
|
+
(`{limit: '100mb'}`); the `keepAliveTimeout` and `headersTimeout` of the server are its `server` option;
|
|
472
|
+
- the input built from the request (the query, the body, a value of the session) is its `input` option;
|
|
473
|
+
- the `{errno, errMsg, data}` envelope is `jsonEnvelope()`. The applications of 2.x usually answered the errors of the
|
|
474
|
+
actions with 200, and the errors of their Express middlewares (`next(error)`: the 401 of an authenticator) with their
|
|
475
|
+
status: `jsonEnvelope({actionErrorStatus: 200})` does the same. `errorStatus: 200` would answer the 401 with 200 too,
|
|
476
|
+
and the clients that go to the login page on a 401 would miss it;
|
|
477
|
+
- the other fields of the envelope of the application (`timestamp`, `nonce`, a request id) come from its `extra` option,
|
|
478
|
+
called for each response, results and errors alike. No responder of your own is needed to keep them.
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
482
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
483
|
+
import type {Express, NextFunction, Request, Response} from 'express'
|
|
484
|
+
|
|
485
|
+
class Unauthorized extends Error {
|
|
486
|
+
public readonly errno: string = 'E_UNAUTHORIZED'
|
|
487
|
+
|
|
488
|
+
public readonly statusCode: number = 401
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export const entrypoint = buildEntrypoints({
|
|
492
|
+
controllers: [],
|
|
493
|
+
http: expressHTTPEntrypoint({
|
|
494
|
+
port: 3000,
|
|
495
|
+
body: {limit: '100mb'},
|
|
496
|
+
before: async (app: Express): Promise<void> => {
|
|
497
|
+
app.get('/health', (_request: Request, response: Response): unknown => response.json({status: 'ok'}))
|
|
498
|
+
//The authenticator of the requests: its errors keep their status (401)
|
|
499
|
+
app.use((request: Request, _response: Response, next: NextFunction): void => request.headers.authorization ? next() : next(new Unauthorized('Not logged in')))
|
|
500
|
+
},
|
|
501
|
+
//The errors of the actions answered with 200 in the envelope, as in 2.x, with its timestamp
|
|
502
|
+
respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
With Fastify, `fastifyJSONEnvelope()` takes the same options, and the authenticator is a hook of Fastify.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
`lakutata/utils` (package `@lakutata/utils`) exports the helpers the framework and its applications share: strings,
|
|
4
4
|
objects, arrays and sets, hashes and encodings, UUIDs, IP addresses, globs, statistics, and `Time`
|
|
5
|
-
(see [Time](Time.md)). They are plain functions and classes with no
|
|
6
|
-
inside an application or not
|
|
7
|
-
the details
|
|
5
|
+
(see [Time](Time.md)). They are plain functions and classes with no dependencies: import them wherever they are needed,
|
|
6
|
+
inside an application or not; nothing needs to be registered. The README of `@lakutata/utils` and the type declarations
|
|
7
|
+
of each helper give the details, including the errors they throw.
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
10
|
import {Application, Component, Time} from 'lakutata'
|
|
@@ -52,7 +52,7 @@ Application
|
|
|
52
52
|
| Helper | What it does |
|
|
53
53
|
|---|---|
|
|
54
54
|
| `ObjectPath.get()`, `.set()`, `.has()` | Reads, sets and tests a nested property by its path (`'a.b[0].c'`) |
|
|
55
|
-
| `cloneDeep(value)` |
|
|
55
|
+
| `cloneDeep(value)` | A deep copy that keeps classes, collections and circular references |
|
|
56
56
|
| `objectHash(value, options?)` | A hash of a value by its content, whatever the order of its keys |
|
|
57
57
|
| `sortArray(array, ...options)` | Sorts an array in place, by its items or their properties (`by`, `order`, `customOrders`, `computed`) |
|
|
58
58
|
| `sortKeys(object, options?)` | A copy of a plain object with its keys sorted by a comparison (`deep`, `compare`) |
|
|
@@ -97,9 +97,9 @@ console.log(uniqueArray([3, 1, 3, 2]), Object.keys(sortObject({b: 1, a: 2})))
|
|
|
97
97
|
| `MessagePack` | Serializes values to and from MessagePack bytes or strings |
|
|
98
98
|
| `UUID` | Generates (`v1`, `v3`, `v4`, `v5`, `v6`), validates, parses and stringifies UUIDs |
|
|
99
99
|
| `URLBuilder` | Parses a URL of any scheme into its attributes, or builds one from them |
|
|
100
|
-
| `hexToUnsigned`, `unsignedToHex(value, bits = 32)` |
|
|
101
|
-
| `hexToSigned`, `signedToHex(value, bits = 32)` |
|
|
102
|
-
| `hexToIEEE754`, `ieee754ToHex` |
|
|
100
|
+
| `hexToUnsigned`, `unsignedToHex(value, bits = 32)` | Converts between hexadecimal and unsigned integers (8 to 128 bits) |
|
|
101
|
+
| `hexToSigned`, `signedToHex(value, bits = 32)` | Converts between hexadecimal and signed integers (two's complement) |
|
|
102
|
+
| `hexToIEEE754`, `ieee754ToHex` | Converts between hexadecimal and IEEE 754 floating point numbers |
|
|
103
103
|
| `IP`, `IPv4`, `IPv6` | Validate, convert and compare IP addresses and CIDR blocks |
|
|
104
104
|
|
|
105
105
|
```typescript
|
|
@@ -122,11 +122,11 @@ console.log(UUID.validate(UUID.v4()))
|
|
|
122
122
|
|---|---|
|
|
123
123
|
| `delay(ms)` | Waits for a number of milliseconds |
|
|
124
124
|
| `gracefulExit` | Exits the process once cleanup functions have run |
|
|
125
|
-
| `pathExists(path)`, `isPath(text)` | Whether a file or directory exists; whether a string
|
|
125
|
+
| `pathExists(path)`, `isPath(text)` | Whether a file or directory exists; whether a string looks like a path |
|
|
126
126
|
| `Glob(patterns, options?)`, `GlobSync`, `isGlobString` | The files matching glob patterns; whether a string is a glob pattern |
|
|
127
127
|
| `isPromise`, `isPromiseLike`, `isAbortError` | Whether a value is a promise or a thenable; whether an error is an abort error of Node.js |
|
|
128
128
|
| `isSymbol`, `isNativeFunction`, `isHTML`, `isXML` | Type guards |
|
|
129
|
-
| `As<T>(value)`, `DevNull(...args)` | A cast for the type checker only; a function
|
|
129
|
+
| `As<T>(value)`, `DevNull(...args)` | A cast for the type checker only; a function that ignores its arguments |
|
|
130
130
|
|
|
131
131
|
```typescript
|
|
132
132
|
import {delay, Glob, pathExists, Statistics} from 'lakutata/utils'
|
|
@@ -141,5 +141,5 @@ console.log(Statistics.median(latencies), Statistics.quantile(latencies, 0.9))
|
|
|
141
141
|
//13.5 90
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
The guards (`
|
|
145
|
-
(`RangeError`, `TypeError`), and `templating()` a `MissingValueError`.
|
|
144
|
+
The type guards (`is…`) return `false` instead of throwing; the other helpers throw standard JavaScript errors
|
|
145
|
+
(`RangeError`, `TypeError`), and `templating()` throws a `MissingValueError`.
|