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,458 @@
|
|
|
1
|
+
# 带数据库的 REST API
|
|
2
|
+
|
|
3
|
+
这个示例构建一个管理任务列表的服务:通过 HTTP API 创建任务、分页列出任务、把任务标为已完成以及删除任务,数据保存在 SQLite 数据库中。它从 `service` 模板开始,展示 lakutata 服务中最常用的部分:实体及其迁移、校验输入的 DTO、带 HTTP 状态码的异常、控制器,以及测试。
|
|
4
|
+
|
|
5
|
+
| 方法 | 路由 | 作用 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `GET` | `/tasks?done=false&limit=10&offset=0` | 分页列出任务,可以只列已完成或未完成的 |
|
|
8
|
+
| `GET` | `/tasks/:id` | 取一个任务 |
|
|
9
|
+
| `POST` | `/tasks` | 创建任务:`{"title": "Write the report"}` |
|
|
10
|
+
| `PATCH` | `/tasks/:id` | 修改标题,或标为已完成:`{"done": true}` |
|
|
11
|
+
| `DELETE` | `/tasks/:id` | 删除任务 |
|
|
12
|
+
|
|
13
|
+
## 1. 创建项目
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @lakutata/cli create --name tasks --template service --yes
|
|
17
|
+
cd tasks
|
|
18
|
+
npm install better-sqlite3 -w app
|
|
19
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
20
|
+
echo '*.sqlite' >> .gitignore
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
项目是一个工作区,服务在 `packages/app` 中(见 [ProjectStructure](../ProjectStructure.md))。服务通过 `better-sqlite3` 驱动使用 SQLite,所以要安装这个驱动;[DatabaseDrivers](../DatabaseDrivers.md) 中的其他数据库用法相同,只是驱动和选项不同。这里还删掉了模板中的示例控制器,并让 git 忽略数据库文件。
|
|
24
|
+
|
|
25
|
+
## 2. 实体
|
|
26
|
+
|
|
27
|
+
**`packages/app/src/entities/Task.ts`**
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import {Column, CreateDateColumn, Entity, PrimaryGeneratedColumn} from 'lakutata/decorator/orm'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 一个任务:要做什么,以及是否已经完成
|
|
34
|
+
*/
|
|
35
|
+
@Entity('tasks')
|
|
36
|
+
export class Task {
|
|
37
|
+
@PrimaryGeneratedColumn()
|
|
38
|
+
public id: number
|
|
39
|
+
|
|
40
|
+
@Column({type: 'varchar', length: 200})
|
|
41
|
+
public title: string
|
|
42
|
+
|
|
43
|
+
@Column({default: false})
|
|
44
|
+
public done: boolean
|
|
45
|
+
|
|
46
|
+
@CreateDateColumn()
|
|
47
|
+
public createdAt: Date
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
实体是一个类,它的装饰器(来自 `lakutata/decorator/orm`)描述对应的表:`tasks` 表有一个由数据库生成的 id、一个标题、是否已完成,以及创建时间(见 [Entities](../Entities.md))。
|
|
52
|
+
|
|
53
|
+
## 3. 输入
|
|
54
|
+
|
|
55
|
+
**`packages/app/src/dtos/TaskDTO.ts`**
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import {DTO} from 'lakutata'
|
|
59
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
60
|
+
import {PaginationSearchDTO} from 'lakutata/dtos'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 按 id 指定的任务(路由中的 :id 是文本,由 strict(false) 转换)
|
|
64
|
+
*/
|
|
65
|
+
export class TaskIdDTO extends DTO {
|
|
66
|
+
@Expect(DTO.Number().integer().positive().strict(false).required())
|
|
67
|
+
public id: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 新任务
|
|
72
|
+
*/
|
|
73
|
+
export class CreateTaskDTO extends DTO {
|
|
74
|
+
@Expect(DTO.String().trim().strict(false).min(1).max(200).required())
|
|
75
|
+
public title: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 任务的修改
|
|
80
|
+
*/
|
|
81
|
+
export class UpdateTaskDTO extends TaskIdDTO {
|
|
82
|
+
@Expect(DTO.String().trim().strict(false).min(1).max(200).optional())
|
|
83
|
+
public title?: string
|
|
84
|
+
|
|
85
|
+
@Expect(DTO.Boolean().optional())
|
|
86
|
+
public done?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 一页任务(limit、offset),可以只取已完成或未完成的
|
|
91
|
+
*/
|
|
92
|
+
export class TaskSearchDTO extends PaginationSearchDTO {
|
|
93
|
+
@Expect(DTO.Boolean().strict(false).optional())
|
|
94
|
+
public done?: boolean
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
每个动作在运行前都会用 DTO 校验输入,不合法的输入会得到 400 错误(见 [DTOValidation](../DTOValidation.md)):
|
|
99
|
+
|
|
100
|
+
- HTTP 动作的输入由路由参数、请求体和查询字符串合并而成;
|
|
101
|
+
- 路由参数和查询字符串都是文本:`.strict(false)` 把 `'1'` 转换为数字 1,把 `'false'` 转换为布尔值,否则这些规则会拒绝文本;
|
|
102
|
+
- 只有加上 `.strict(false)`,`.trim()` 才会去掉标题首尾的空格;严格模式下的规则会直接拒绝这样的标题;
|
|
103
|
+
- `TaskSearchDTO` 继承自 `PaginationSearchDTO`(来自 `lakutata/dtos`),用它的 `limit`(默认 10)和 `offset`(默认 0)选择一页。
|
|
104
|
+
|
|
105
|
+
## 4. 异常
|
|
106
|
+
|
|
107
|
+
**`packages/app/src/exceptions/TaskNotFoundException.ts`**
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import {Exception} from 'lakutata'
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 请求的任务不存在
|
|
114
|
+
*/
|
|
115
|
+
export class TaskNotFoundException extends Exception {
|
|
116
|
+
public errno: string = 'E_TASK_NOT_FOUND'
|
|
117
|
+
|
|
118
|
+
public readonly statusCode: number = 404
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
异常有一个 `errno`,客户端用它判断是哪种错误;还有回复这个错误时使用的 HTTP 状态码(见 [Exceptions](../Exceptions.md))。异常的消息在抛出时给出。
|
|
123
|
+
|
|
124
|
+
## 5. 控制器
|
|
125
|
+
|
|
126
|
+
**`packages/app/src/controllers/TaskController.ts`**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
130
|
+
import {type Database} from 'lakutata/com/database'
|
|
131
|
+
import {DELETE, GET, PATCH, POST, Prefix} from 'lakutata/decorator/ctrl'
|
|
132
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
133
|
+
import {type PaginationResultDTO, paginator} from 'lakutata/dtos'
|
|
134
|
+
import {type Repository} from 'lakutata/orm'
|
|
135
|
+
import {Task} from '../entities/Task.js'
|
|
136
|
+
import {CreateTaskDTO, TaskIdDTO, TaskSearchDTO, UpdateTaskDTO} from '../dtos/TaskDTO.js'
|
|
137
|
+
import {TaskNotFoundException} from '../exceptions/TaskNotFoundException.js'
|
|
138
|
+
|
|
139
|
+
@Prefix({route: '/tasks'})
|
|
140
|
+
export class TaskController extends Controller {
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 服务的数据库(选项中的 "database" 组件)
|
|
144
|
+
*/
|
|
145
|
+
@Inject('database')
|
|
146
|
+
protected readonly database: Database
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 数据库中的任务
|
|
150
|
+
*/
|
|
151
|
+
protected get tasks(): Repository<Task> {
|
|
152
|
+
return this.database.getRepository(Task)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 取一个任务,不存在时抛出 404 错误
|
|
157
|
+
* @param id
|
|
158
|
+
*/
|
|
159
|
+
protected async find(id: number): Promise<Task> {
|
|
160
|
+
const task: Task | null = await this.tasks.findOneBy({id: id})
|
|
161
|
+
if (!task) throw new TaskNotFoundException(`The task ${id} does not exist`)
|
|
162
|
+
return task
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* GET /tasks?done=false&limit=10&offset=0
|
|
167
|
+
* @param input
|
|
168
|
+
*/
|
|
169
|
+
@GET('/', TaskSearchDTO)
|
|
170
|
+
public async list(input: TaskSearchDTO): Promise<PaginationResultDTO<Task>> {
|
|
171
|
+
return await paginator(input, async (limit: number, offset: number) => {
|
|
172
|
+
const [items, total] = await this.tasks.findAndCount({
|
|
173
|
+
where: input.done === undefined ? {} : {done: input.done},
|
|
174
|
+
order: {id: 'ASC'},
|
|
175
|
+
take: limit,
|
|
176
|
+
skip: offset
|
|
177
|
+
})
|
|
178
|
+
return {items: items, total: total}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* GET /tasks/1
|
|
184
|
+
* @param input
|
|
185
|
+
*/
|
|
186
|
+
@GET('/:id', TaskIdDTO)
|
|
187
|
+
public async get(input: TaskIdDTO): Promise<Task> {
|
|
188
|
+
return await this.find(input.id)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* POST /tasks {"title": "Write the report"}
|
|
193
|
+
* @param input
|
|
194
|
+
*/
|
|
195
|
+
@POST('/', CreateTaskDTO)
|
|
196
|
+
public async create(input: CreateTaskDTO): Promise<Task> {
|
|
197
|
+
return await this.tasks.save(this.tasks.create({title: input.title}))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* PATCH /tasks/1 {"done": true}
|
|
202
|
+
* @param input
|
|
203
|
+
*/
|
|
204
|
+
@PATCH('/:id', UpdateTaskDTO)
|
|
205
|
+
public async update(input: UpdateTaskDTO): Promise<Task> {
|
|
206
|
+
const task: Task = await this.find(input.id)
|
|
207
|
+
if (input.title !== undefined) task.title = input.title
|
|
208
|
+
if (input.done !== undefined) task.done = input.done
|
|
209
|
+
return await this.tasks.save(task)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* DELETE /tasks/1
|
|
214
|
+
* @param input
|
|
215
|
+
*/
|
|
216
|
+
@DELETE('/:id', TaskIdDTO)
|
|
217
|
+
public async remove(input: TaskIdDTO): Promise<void> {
|
|
218
|
+
await this.tasks.remove(await this.find(input.id))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- `@Prefix({route: '/tasks'})` 把控制器的路由放在 `/tasks` 之下;每个动作有自己的 HTTP 方法、路由和 DTO(见 [HTTP](../HTTP.md))。
|
|
224
|
+
- `@Inject('database')` 把服务的 `database` 组件交给控制器:这是一个 TypeORM 连接,通过它的仓储读写实体(见 [Database](../Database.md))。
|
|
225
|
+
- `paginator()`(来自 `lakutata/dtos`)构建一页结果:其中的条目,以及 `meta`,包括条目数、总数、limit 和 offset。
|
|
226
|
+
- 动作返回的值就是结果;抛出的异常就是这个请求的错误。
|
|
227
|
+
|
|
228
|
+
## 6. 服务的选项
|
|
229
|
+
|
|
230
|
+
**`packages/app/src/config/Config.ts`**
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
import path from 'node:path'
|
|
234
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
235
|
+
import {buildDatabaseOptions} from 'lakutata/com/database'
|
|
236
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
237
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
238
|
+
import compression from 'compression'
|
|
239
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
240
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
241
|
+
import {TaskController} from '../controllers/TaskController.js'
|
|
242
|
+
import {Task} from '../entities/Task.js'
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 服务的选项
|
|
246
|
+
* @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
|
|
247
|
+
* @param database SQLite 数据库文件,默认取环境变量 DATABASE,没有时为 tasks.sqlite(":memory:" 表示内存中的数据库)
|
|
248
|
+
*/
|
|
249
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), database: string = process.env.DATABASE ?? 'tasks.sqlite'): ApplicationOptions {
|
|
250
|
+
return {
|
|
251
|
+
id: 'tasks',
|
|
252
|
+
name: 'tasks',
|
|
253
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
254
|
+
components: {
|
|
255
|
+
database: buildDatabaseOptions({
|
|
256
|
+
type: 'better-sqlite3',
|
|
257
|
+
database: database,
|
|
258
|
+
entities: [Task],
|
|
259
|
+
//随服务一起编译的迁移(build/migrations),连接数据库时运行
|
|
260
|
+
migrations: [path.join(import.meta.dirname, '..', 'migrations', '*.js')],
|
|
261
|
+
migrationsRun: true
|
|
262
|
+
}),
|
|
263
|
+
entrypoint: buildEntrypoints({
|
|
264
|
+
controllers: [HealthController, TaskController],
|
|
265
|
+
http: expressHTTPEntrypoint({
|
|
266
|
+
port: port,
|
|
267
|
+
//结果和错误都是 {errno, errMsg, data},错误保留各自的状态码(400、404 等)
|
|
268
|
+
respond: jsonEnvelope(),
|
|
269
|
+
body: {limit: '1mb'},
|
|
270
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
271
|
+
middlewares: [authenticate],
|
|
272
|
+
before: (app): void => void app.use(compression())
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
//先连接数据库(并运行迁移),再让 HTTP 服务器开始监听
|
|
277
|
+
bootstrap: ['database', 'entrypoint']
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
这是模板中的 `Config.ts`,改动如下:
|
|
283
|
+
|
|
284
|
+
- 新增 `database` 组件:SQLite 数据库文件(服务运行目录下的 `tasks.sqlite`,或者环境变量 `DATABASE` 指定的文件)、它的实体,以及连接时运行的迁移;
|
|
285
|
+
- 用 `TaskController` 替换示例控制器;
|
|
286
|
+
- `jsonEnvelope()` 不再设置 `errorStatus`:结果和错误都是 `{errno, errMsg, data}`,错误保留各自的状态码(400、401、404),这是 REST 客户端所期望的;
|
|
287
|
+
- 请求体最多 1 MB(模板允许 100 MB),对任务来说足够了;
|
|
288
|
+
- `bootstrap: ['database', 'entrypoint']`:先连接数据库并运行迁移,再让 HTTP 服务器开始监听。
|
|
289
|
+
|
|
290
|
+
模板中的中间件 `Authenticate.ts` 保留不变:设置了环境变量 `API_TOKEN` 时,请求必须带上 `Authorization: Bearer <API_TOKEN>`。
|
|
291
|
+
|
|
292
|
+
## 7. 迁移
|
|
293
|
+
|
|
294
|
+
表由迁移创建,迁移根据实体生成:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
npm run migration:generate
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
✔ Migrations generated: packages/app/src/migrations/1792247417843-database.ts
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
这个命令会构建并启动服务,把实体和数据库对比,然后写出迁移(文件名中带有生成的时间):
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
import { MigrationInterface, QueryRunner } from "lakutata/orm";
|
|
308
|
+
|
|
309
|
+
export class Migrations1792247417843 implements MigrationInterface {
|
|
310
|
+
name = 'Migrations1792247417843'
|
|
311
|
+
|
|
312
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
313
|
+
await queryRunner.query(`
|
|
314
|
+
CREATE TABLE "tasks" (
|
|
315
|
+
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
316
|
+
"title" varchar(200) NOT NULL,
|
|
317
|
+
"done" boolean NOT NULL DEFAULT (0),
|
|
318
|
+
"createdAt" datetime NOT NULL DEFAULT (datetime('now'))
|
|
319
|
+
)
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
324
|
+
await queryRunner.query(`
|
|
325
|
+
DROP TABLE "tasks"
|
|
326
|
+
`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
迁移和服务一起编译。服务连接的数据库还没运行过这个迁移时,就会运行它。实体改动后,用同样的方法生成下一个迁移(见 [Migrations](../Migrations.md))。
|
|
333
|
+
|
|
334
|
+
## 8. 运行
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
npm run dev
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
在另一个终端中:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
curl -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Write the report"}'
|
|
344
|
+
# {"errno":null,"errMsg":null,"data":{"title":"Write the report","id":1,"done":false,"createdAt":"2026-10-17T14:33:07.000Z"}}
|
|
345
|
+
|
|
346
|
+
curl -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Book the room"}'
|
|
347
|
+
curl -X PATCH localhost:8080/tasks/1 -H 'Content-Type: application/json' -d '{"done": true}'
|
|
348
|
+
|
|
349
|
+
curl 'localhost:8080/tasks?done=false&limit=5'
|
|
350
|
+
# {"errno":null,"errMsg":null,"data":{"items":[{"id":2,"title":"Book the room","done":false,"createdAt":"2026-10-17T14:33:07.000Z"}],"meta":{"count":1,"total":1,"limit":5,"offset":0}}}
|
|
351
|
+
|
|
352
|
+
curl -i localhost:8080/tasks/9
|
|
353
|
+
# HTTP/1.1 404 Not Found
|
|
354
|
+
# {"errno":"E_TASK_NOT_FOUND","errMsg":"The task 9 does not exist","data":null}
|
|
355
|
+
|
|
356
|
+
curl -i -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": ""}'
|
|
357
|
+
# HTTP/1.1 400 Bad Request
|
|
358
|
+
# {"errno":"E_INVALID_VALUE","errMsg":"\"title\" is not allowed to be empty","data":null}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## 9. 测试
|
|
362
|
+
|
|
363
|
+
模板中的测试换成了针对任务的测试。它用内存中的数据库启动服务(启动时运行迁移),然后调用各个路由:
|
|
364
|
+
|
|
365
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import {after, before, describe, it} from 'node:test'
|
|
369
|
+
import assert from 'node:assert'
|
|
370
|
+
import {Application} from 'lakutata'
|
|
371
|
+
import {config} from '../config/Config.js'
|
|
372
|
+
|
|
373
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18081)
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 向服务发一个请求,返回状态码和 {errno, errMsg, data}
|
|
377
|
+
* @param method
|
|
378
|
+
* @param route
|
|
379
|
+
* @param body
|
|
380
|
+
*/
|
|
381
|
+
async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
|
|
382
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
|
|
383
|
+
method: method,
|
|
384
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
385
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
386
|
+
})
|
|
387
|
+
const envelope = await response.json() as { errno: string | null, data: any }
|
|
388
|
+
return {status: response.status, errno: envelope.errno, data: envelope.data}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
describe('tasks', (): void => {
|
|
392
|
+
|
|
393
|
+
let app: Application
|
|
394
|
+
|
|
395
|
+
before(async (): Promise<void> => {
|
|
396
|
+
process.env.API_TOKEN = 'test-token'
|
|
397
|
+
//内存中的数据库,服务启动时运行迁移
|
|
398
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
399
|
+
Application.run(config(PORT, ':memory:')).onLaunched((launched: Application): void => resolve(launched))
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
after(async (): Promise<void> => {
|
|
404
|
+
await app?.dispose()
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('answers the health check without authentication', async (): Promise<void> => {
|
|
408
|
+
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('refuses the requests without authentication', async (): Promise<void> => {
|
|
412
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}/tasks`)
|
|
413
|
+
assert.strictEqual(response.status, 401)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('creates, lists, updates and deletes the tasks', async (): Promise<void> => {
|
|
417
|
+
const created = await request('POST', '/tasks', {title: 'Write the report'})
|
|
418
|
+
assert.strictEqual(created.status, 200)
|
|
419
|
+
assert.strictEqual(created.data.title, 'Write the report')
|
|
420
|
+
assert.strictEqual(created.data.done, false)
|
|
421
|
+
await request('POST', '/tasks', {title: 'Book the room'})
|
|
422
|
+
const updated = await request('PATCH', `/tasks/${created.data.id}`, {done: true})
|
|
423
|
+
assert.strictEqual(updated.data.done, true)
|
|
424
|
+
const open = await request('GET', '/tasks?done=false&limit=5')
|
|
425
|
+
assert.deepStrictEqual(open.data.items.map((task: { title: string }): string => task.title), ['Book the room'])
|
|
426
|
+
assert.deepStrictEqual(open.data.meta, {count: 1, total: 1, limit: 5, offset: 0})
|
|
427
|
+
assert.strictEqual((await request('DELETE', `/tasks/${created.data.id}`)).status, 200)
|
|
428
|
+
const missing = await request('GET', `/tasks/${created.data.id}`)
|
|
429
|
+
assert.strictEqual(missing.status, 404)
|
|
430
|
+
assert.strictEqual(missing.errno, 'E_TASK_NOT_FOUND')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('refuses an invalid task', async (): Promise<void> => {
|
|
434
|
+
const invalid = await request('POST', '/tasks', {title: ''})
|
|
435
|
+
assert.strictEqual(invalid.status, 400)
|
|
436
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
npm test
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
```
|
|
446
|
+
app │ ✔ answers the health check without authentication
|
|
447
|
+
app │ ✔ refuses the requests without authentication
|
|
448
|
+
app │ ✔ creates, lists, updates and deletes the tasks
|
|
449
|
+
app │ ✔ refuses an invalid task
|
|
450
|
+
app │ ℹ pass 4
|
|
451
|
+
✔ test ran in app
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## 10. 在生产环境中
|
|
455
|
+
|
|
456
|
+
- `npm start` 运行构建好的服务(`NODE_ENV=production` 决定它的模式),`npm run docker:build` 用模板中的 `Dockerfile` 构建镜像。请把 `DATABASE` 指向卷上的路径,这样容器重建后任务数据仍然保留。
|
|
457
|
+
- 模板的 `installer` 功能(`lakutata create --with installer`)可以制作安装包,让服务随系统启动(见 [Installers](../Installers.md))。
|
|
458
|
+
- 换用其他数据库,就是换一个驱动和 `buildDatabaseOptions()` 的选项;迁移要针对那个数据库生成。
|