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,490 @@
|
|
|
1
|
+
# A REST API with a database
|
|
2
|
+
|
|
3
|
+
This example builds a service that keeps a list of tasks: an HTTP API to create tasks, list them a page at a time,
|
|
4
|
+
mark them done and delete them, stored in a SQLite database. It starts from the `service` template and shows the
|
|
5
|
+
everyday parts of a lakutata service: an entity and its migration, the DTOs that validate the input, an exception with
|
|
6
|
+
its HTTP status, a controller, and the tests.
|
|
7
|
+
|
|
8
|
+
| Method | Route | What it does |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `GET` | `/tasks?done=false&limit=10&offset=0` | Lists the tasks, a page at a time, the done ones or the others |
|
|
11
|
+
| `GET` | `/tasks/:id` | Gives a task |
|
|
12
|
+
| `POST` | `/tasks` | Creates a task: `{"title": "Write the report"}` |
|
|
13
|
+
| `PATCH` | `/tasks/:id` | Changes its title or marks it done: `{"done": true}` |
|
|
14
|
+
| `DELETE` | `/tasks/:id` | Deletes it |
|
|
15
|
+
|
|
16
|
+
## 1. Create the project
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @lakutata/cli create --name tasks --template service --yes
|
|
20
|
+
cd tasks
|
|
21
|
+
npm install better-sqlite3 -w app
|
|
22
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
23
|
+
echo '*.sqlite' >> .gitignore
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The project is a workspace whose service is `packages/app` ([ProjectStructure](../ProjectStructure.md)). The service
|
|
27
|
+
uses SQLite through the `better-sqlite3` driver, installed here; any other database of
|
|
28
|
+
[DatabaseDrivers](../DatabaseDrivers.md) works the same, with its own driver and options. The example controller of the
|
|
29
|
+
template is removed, and the database file is kept out of git.
|
|
30
|
+
|
|
31
|
+
## 2. The entity
|
|
32
|
+
|
|
33
|
+
**`packages/app/src/entities/Task.ts`**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import {Column, CreateDateColumn, Entity, PrimaryGeneratedColumn} from 'lakutata/decorator/orm'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A task: what to do, and whether it is done
|
|
40
|
+
*/
|
|
41
|
+
@Entity('tasks')
|
|
42
|
+
export class Task {
|
|
43
|
+
@PrimaryGeneratedColumn()
|
|
44
|
+
public id: number
|
|
45
|
+
|
|
46
|
+
@Column({type: 'varchar', length: 200})
|
|
47
|
+
public title: string
|
|
48
|
+
|
|
49
|
+
@Column({default: false})
|
|
50
|
+
public done: boolean
|
|
51
|
+
|
|
52
|
+
@CreateDateColumn()
|
|
53
|
+
public createdAt: Date
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
An entity is a class whose decorators (from `lakutata/decorator/orm`) describe its table: `tasks`, with an id given by
|
|
58
|
+
the database, a title, whether it is done, and the date it was created on ([Entities](../Entities.md)).
|
|
59
|
+
|
|
60
|
+
## 3. The input
|
|
61
|
+
|
|
62
|
+
**`packages/app/src/dtos/TaskDTO.ts`**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import {DTO} from 'lakutata'
|
|
66
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
67
|
+
import {PaginationSearchDTO} from 'lakutata/dtos'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A task by its id (the :id of the route: text, converted by strict(false))
|
|
71
|
+
*/
|
|
72
|
+
export class TaskIdDTO extends DTO {
|
|
73
|
+
@Expect(DTO.Number().integer().positive().strict(false).required())
|
|
74
|
+
public id: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A new task
|
|
79
|
+
*/
|
|
80
|
+
export class CreateTaskDTO extends DTO {
|
|
81
|
+
@Expect(DTO.String().trim().strict(false).min(1).max(200).required())
|
|
82
|
+
public title: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The changes of a task
|
|
87
|
+
*/
|
|
88
|
+
export class UpdateTaskDTO extends TaskIdDTO {
|
|
89
|
+
@Expect(DTO.String().trim().strict(false).min(1).max(200).optional())
|
|
90
|
+
public title?: string
|
|
91
|
+
|
|
92
|
+
@Expect(DTO.Boolean().optional())
|
|
93
|
+
public done?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A page of the tasks (limit, offset), the done ones or the others
|
|
98
|
+
*/
|
|
99
|
+
export class TaskSearchDTO extends PaginationSearchDTO {
|
|
100
|
+
@Expect(DTO.Boolean().strict(false).optional())
|
|
101
|
+
public done?: boolean
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each action validates its input with a DTO before it runs, and refuses invalid input with a 400 error
|
|
106
|
+
([DTOValidation](../DTOValidation.md)):
|
|
107
|
+
|
|
108
|
+
- the input of an HTTP action merges the route parameters, the body and the query string;
|
|
109
|
+
- the route parameters and the query string are text: `.strict(false)` converts `'1'` to the number 1 and `'false'` to
|
|
110
|
+
the boolean, which the schemas otherwise refuse;
|
|
111
|
+
- `.trim()` removes the spaces around the title only with `.strict(false)`: a strict schema refuses them instead;
|
|
112
|
+
- `TaskSearchDTO` extends `PaginationSearchDTO` (from `lakutata/dtos`), whose `limit` (10 by default) and `offset` (0)
|
|
113
|
+
select a page.
|
|
114
|
+
|
|
115
|
+
## 4. The exception
|
|
116
|
+
|
|
117
|
+
**`packages/app/src/exceptions/TaskNotFoundException.ts`**
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import {Exception} from 'lakutata'
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The task of the request does not exist
|
|
124
|
+
*/
|
|
125
|
+
export class TaskNotFoundException extends Exception {
|
|
126
|
+
public errno: string = 'E_TASK_NOT_FOUND'
|
|
127
|
+
|
|
128
|
+
public readonly statusCode: number = 404
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
An exception has an `errno`, the code that clients test, and the HTTP status it is answered with
|
|
133
|
+
([Exceptions](../Exceptions.md)). Its message is given when it is thrown.
|
|
134
|
+
|
|
135
|
+
## 5. The controller
|
|
136
|
+
|
|
137
|
+
**`packages/app/src/controllers/TaskController.ts`**
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
141
|
+
import {type Database} from 'lakutata/com/database'
|
|
142
|
+
import {DELETE, GET, PATCH, POST, Prefix} from 'lakutata/decorator/ctrl'
|
|
143
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
144
|
+
import {type PaginationResultDTO, paginator} from 'lakutata/dtos'
|
|
145
|
+
import {type Repository} from 'lakutata/orm'
|
|
146
|
+
import {Task} from '../entities/Task.js'
|
|
147
|
+
import {CreateTaskDTO, TaskIdDTO, TaskSearchDTO, UpdateTaskDTO} from '../dtos/TaskDTO.js'
|
|
148
|
+
import {TaskNotFoundException} from '../exceptions/TaskNotFoundException.js'
|
|
149
|
+
|
|
150
|
+
@Prefix({route: '/tasks'})
|
|
151
|
+
export class TaskController extends Controller {
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The database of the service (the component "database" of its options)
|
|
155
|
+
*/
|
|
156
|
+
@Inject('database')
|
|
157
|
+
protected readonly database: Database
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The tasks of the database
|
|
161
|
+
*/
|
|
162
|
+
protected get tasks(): Repository<Task> {
|
|
163
|
+
return this.database.getRepository(Task)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A task, or a 404 error
|
|
168
|
+
* @param id
|
|
169
|
+
*/
|
|
170
|
+
protected async find(id: number): Promise<Task> {
|
|
171
|
+
const task: Task | null = await this.tasks.findOneBy({id: id})
|
|
172
|
+
if (!task) throw new TaskNotFoundException(`The task ${id} does not exist`)
|
|
173
|
+
return task
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* GET /tasks?done=false&limit=10&offset=0
|
|
178
|
+
* @param input
|
|
179
|
+
*/
|
|
180
|
+
@GET('/', TaskSearchDTO)
|
|
181
|
+
public async list(input: TaskSearchDTO): Promise<PaginationResultDTO<Task>> {
|
|
182
|
+
return await paginator(input, async (limit: number, offset: number) => {
|
|
183
|
+
const [items, total] = await this.tasks.findAndCount({
|
|
184
|
+
where: input.done === undefined ? {} : {done: input.done},
|
|
185
|
+
order: {id: 'ASC'},
|
|
186
|
+
take: limit,
|
|
187
|
+
skip: offset
|
|
188
|
+
})
|
|
189
|
+
return {items: items, total: total}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* GET /tasks/1
|
|
195
|
+
* @param input
|
|
196
|
+
*/
|
|
197
|
+
@GET('/:id', TaskIdDTO)
|
|
198
|
+
public async get(input: TaskIdDTO): Promise<Task> {
|
|
199
|
+
return await this.find(input.id)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* POST /tasks {"title": "Write the report"}
|
|
204
|
+
* @param input
|
|
205
|
+
*/
|
|
206
|
+
@POST('/', CreateTaskDTO)
|
|
207
|
+
public async create(input: CreateTaskDTO): Promise<Task> {
|
|
208
|
+
return await this.tasks.save(this.tasks.create({title: input.title}))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* PATCH /tasks/1 {"done": true}
|
|
213
|
+
* @param input
|
|
214
|
+
*/
|
|
215
|
+
@PATCH('/:id', UpdateTaskDTO)
|
|
216
|
+
public async update(input: UpdateTaskDTO): Promise<Task> {
|
|
217
|
+
const task: Task = await this.find(input.id)
|
|
218
|
+
if (input.title !== undefined) task.title = input.title
|
|
219
|
+
if (input.done !== undefined) task.done = input.done
|
|
220
|
+
return await this.tasks.save(task)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* DELETE /tasks/1
|
|
225
|
+
* @param input
|
|
226
|
+
*/
|
|
227
|
+
@DELETE('/:id', TaskIdDTO)
|
|
228
|
+
public async remove(input: TaskIdDTO): Promise<void> {
|
|
229
|
+
await this.tasks.remove(await this.find(input.id))
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- `@Prefix({route: '/tasks'})` puts the routes of the controller under `/tasks`; each action has its verb, its route
|
|
235
|
+
and its DTO ([HTTP](../HTTP.md)).
|
|
236
|
+
- `@Inject('database')` gives the controller the `database` component of the service: a TypeORM connection, whose
|
|
237
|
+
repositories read and write the entities ([Database](../Database.md)).
|
|
238
|
+
- `paginator()` (from `lakutata/dtos`) builds the page: the items, and `meta` with their count, the total, the limit
|
|
239
|
+
and the offset.
|
|
240
|
+
- An action returns its result; a thrown exception is the error of the request.
|
|
241
|
+
|
|
242
|
+
## 6. The options of the service
|
|
243
|
+
|
|
244
|
+
**`packages/app/src/config/Config.ts`**
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import path from 'node:path'
|
|
248
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
249
|
+
import {buildDatabaseOptions} from 'lakutata/com/database'
|
|
250
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
251
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
252
|
+
import compression from 'compression'
|
|
253
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
254
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
255
|
+
import {TaskController} from '../controllers/TaskController.js'
|
|
256
|
+
import {Task} from '../entities/Task.js'
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The options of the service
|
|
260
|
+
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
|
|
261
|
+
* @param database the file of the SQLite database, the DATABASE environment variable or tasks.sqlite by default
|
|
262
|
+
* (":memory:" for a database in memory)
|
|
263
|
+
*/
|
|
264
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), database: string = process.env.DATABASE ?? 'tasks.sqlite'): ApplicationOptions {
|
|
265
|
+
return {
|
|
266
|
+
id: 'tasks',
|
|
267
|
+
name: 'tasks',
|
|
268
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
269
|
+
components: {
|
|
270
|
+
database: buildDatabaseOptions({
|
|
271
|
+
type: 'better-sqlite3',
|
|
272
|
+
database: database,
|
|
273
|
+
entities: [Task],
|
|
274
|
+
//The migrations compiled with the service (build/migrations), run when it connects
|
|
275
|
+
migrations: [path.join(import.meta.dirname, '..', 'migrations', '*.js')],
|
|
276
|
+
migrationsRun: true
|
|
277
|
+
}),
|
|
278
|
+
entrypoint: buildEntrypoints({
|
|
279
|
+
controllers: [HealthController, TaskController],
|
|
280
|
+
http: expressHTTPEntrypoint({
|
|
281
|
+
port: port,
|
|
282
|
+
//The results and the errors are {errno, errMsg, data}; the errors keep their status (400, 404…)
|
|
283
|
+
respond: jsonEnvelope(),
|
|
284
|
+
body: {limit: '1mb'},
|
|
285
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
286
|
+
middlewares: [authenticate],
|
|
287
|
+
before: (app): void => void app.use(compression())
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
},
|
|
291
|
+
//The database is connected (and migrated) before the HTTP server listens
|
|
292
|
+
bootstrap: ['database', 'entrypoint']
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
This is the `Config.ts` of the template, with:
|
|
298
|
+
|
|
299
|
+
- the `database` component: the SQLite file (`tasks.sqlite` in the directory the service runs in, or the `DATABASE`
|
|
300
|
+
environment variable), its entities, and its migrations, run when it connects;
|
|
301
|
+
- the `TaskController` instead of the example controller;
|
|
302
|
+
- `jsonEnvelope()` without `errorStatus`: the results and the errors are `{errno, errMsg, data}`, and the errors keep
|
|
303
|
+
their status (400, 401, 404), which REST clients expect;
|
|
304
|
+
- a body of 1 MB at most (the template allows 100 MB), more than a task needs;
|
|
305
|
+
- `bootstrap: ['database', 'entrypoint']`: the database is connected and migrated before the HTTP server listens.
|
|
306
|
+
|
|
307
|
+
The template's middleware `Authenticate.ts` stays: when the `API_TOKEN` environment variable is set, the requests must
|
|
308
|
+
carry `Authorization: Bearer <API_TOKEN>`.
|
|
309
|
+
|
|
310
|
+
## 7. The migration
|
|
311
|
+
|
|
312
|
+
The table is created by a migration, generated from the entities:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npm run migration:generate
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
✔ Migrations generated: packages/app/src/migrations/1792247417843-database.ts
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The command builds and starts the service, compares its entities with the database, and writes the migration (its
|
|
323
|
+
name holds the time it is generated):
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { MigrationInterface, QueryRunner } from "lakutata/orm";
|
|
327
|
+
|
|
328
|
+
export class Migrations1792247417843 implements MigrationInterface {
|
|
329
|
+
name = 'Migrations1792247417843'
|
|
330
|
+
|
|
331
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
332
|
+
await queryRunner.query(`
|
|
333
|
+
CREATE TABLE "tasks" (
|
|
334
|
+
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
335
|
+
"title" varchar(200) NOT NULL,
|
|
336
|
+
"done" boolean NOT NULL DEFAULT (0),
|
|
337
|
+
"createdAt" datetime NOT NULL DEFAULT (datetime('now'))
|
|
338
|
+
)
|
|
339
|
+
`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
343
|
+
await queryRunner.query(`
|
|
344
|
+
DROP TABLE "tasks"
|
|
345
|
+
`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
It is compiled with the service, and run when the service connects to a database that has not run it. After a change
|
|
352
|
+
of the entities, generate the next one the same way ([Migrations](../Migrations.md)).
|
|
353
|
+
|
|
354
|
+
## 8. Run it
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
npm run dev
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
In another terminal:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
curl -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Write the report"}'
|
|
364
|
+
# {"errno":null,"errMsg":null,"data":{"title":"Write the report","id":1,"done":false,"createdAt":"2026-10-17T14:33:07.000Z"}}
|
|
365
|
+
|
|
366
|
+
curl -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Book the room"}'
|
|
367
|
+
curl -X PATCH localhost:8080/tasks/1 -H 'Content-Type: application/json' -d '{"done": true}'
|
|
368
|
+
|
|
369
|
+
curl 'localhost:8080/tasks?done=false&limit=5'
|
|
370
|
+
# {"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}}}
|
|
371
|
+
|
|
372
|
+
curl -i localhost:8080/tasks/9
|
|
373
|
+
# HTTP/1.1 404 Not Found
|
|
374
|
+
# {"errno":"E_TASK_NOT_FOUND","errMsg":"The task 9 does not exist","data":null}
|
|
375
|
+
|
|
376
|
+
curl -i -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": ""}'
|
|
377
|
+
# HTTP/1.1 400 Bad Request
|
|
378
|
+
# {"errno":"E_INVALID_VALUE","errMsg":"\"title\" is not allowed to be empty","data":null}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## 9. Test it
|
|
382
|
+
|
|
383
|
+
The test of the template is replaced by a test of the tasks. It starts the service with a database in memory, migrated
|
|
384
|
+
when it starts, and calls its routes:
|
|
385
|
+
|
|
386
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
import {after, before, describe, it} from 'node:test'
|
|
390
|
+
import assert from 'node:assert'
|
|
391
|
+
import {Application} from 'lakutata'
|
|
392
|
+
import {config} from '../config/Config.js'
|
|
393
|
+
|
|
394
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18081)
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A request to the service: its status and its {errno, errMsg, data}
|
|
398
|
+
* @param method
|
|
399
|
+
* @param route
|
|
400
|
+
* @param body
|
|
401
|
+
*/
|
|
402
|
+
async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
|
|
403
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
|
|
404
|
+
method: method,
|
|
405
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
406
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
407
|
+
})
|
|
408
|
+
const envelope = await response.json() as { errno: string | null, data: any }
|
|
409
|
+
return {status: response.status, errno: envelope.errno, data: envelope.data}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
describe('tasks', (): void => {
|
|
413
|
+
|
|
414
|
+
let app: Application
|
|
415
|
+
|
|
416
|
+
before(async (): Promise<void> => {
|
|
417
|
+
process.env.API_TOKEN = 'test-token'
|
|
418
|
+
//A database in memory, migrated when the service starts
|
|
419
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
420
|
+
Application.run(config(PORT, ':memory:')).onLaunched((launched: Application): void => resolve(launched))
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
after(async (): Promise<void> => {
|
|
425
|
+
await app?.dispose()
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('answers the health check without authentication', async (): Promise<void> => {
|
|
429
|
+
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('refuses the requests without authentication', async (): Promise<void> => {
|
|
433
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}/tasks`)
|
|
434
|
+
assert.strictEqual(response.status, 401)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('creates, lists, updates and deletes the tasks', async (): Promise<void> => {
|
|
438
|
+
const created = await request('POST', '/tasks', {title: 'Write the report'})
|
|
439
|
+
assert.strictEqual(created.status, 200)
|
|
440
|
+
assert.strictEqual(created.data.title, 'Write the report')
|
|
441
|
+
assert.strictEqual(created.data.done, false)
|
|
442
|
+
await request('POST', '/tasks', {title: 'Book the room'})
|
|
443
|
+
const updated = await request('PATCH', `/tasks/${created.data.id}`, {done: true})
|
|
444
|
+
assert.strictEqual(updated.data.done, true)
|
|
445
|
+
const open = await request('GET', '/tasks?done=false&limit=5')
|
|
446
|
+
assert.deepStrictEqual(open.data.items.map((task: { title: string }): string => task.title), ['Book the room'])
|
|
447
|
+
assert.deepStrictEqual(open.data.meta, {count: 1, total: 1, limit: 5, offset: 0})
|
|
448
|
+
assert.strictEqual((await request('DELETE', `/tasks/${created.data.id}`)).status, 200)
|
|
449
|
+
const missing = await request('GET', `/tasks/${created.data.id}`)
|
|
450
|
+
assert.strictEqual(missing.status, 404)
|
|
451
|
+
assert.strictEqual(missing.errno, 'E_TASK_NOT_FOUND')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('refuses an invalid task', async (): Promise<void> => {
|
|
455
|
+
const invalid = await request('POST', '/tasks', {title: ''})
|
|
456
|
+
assert.strictEqual(invalid.status, 400)
|
|
457
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
npm test
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
app │ ✔ answers the health check without authentication
|
|
468
|
+
app │ ✔ refuses the requests without authentication
|
|
469
|
+
app │ ✔ creates, lists, updates and deletes the tasks
|
|
470
|
+
app │ ✔ refuses an invalid task
|
|
471
|
+
app │ ℹ pass 4
|
|
472
|
+
✔ test ran in app
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## 10. In production
|
|
476
|
+
|
|
477
|
+
- `npm start` runs the built service (`NODE_ENV=production` sets its mode), and `npm run docker:build` makes its image
|
|
478
|
+
(the template's `Dockerfile`). Give `DATABASE` a path on a volume, so that the tasks outlive the container.
|
|
479
|
+
- The `installer` feature of the template (`lakutata create --with installer`) makes installers that run the service at
|
|
480
|
+
the start of the system ([Installers](../Installers.md)).
|
|
481
|
+
- Another database is another driver and other options of `buildDatabaseOptions()`; generate the migrations against
|
|
482
|
+
it.
|
|
483
|
+
|
|
484
|
+
<!--
|
|
485
|
+
check:
|
|
486
|
+
create: tasks service
|
|
487
|
+
remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
488
|
+
run: npm run migration:generate
|
|
489
|
+
run: npm test
|
|
490
|
+
-->
|