adorn-api 1.1.13 → 1.1.14
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 +613 -930
- package/dist/adapter/express/controllers.d.ts +3 -1
- package/dist/adapter/express/controllers.js +4 -1
- package/dist/adapter/express/index.js +5 -1
- package/dist/adapter/express/types.d.ts +3 -0
- package/dist/adapter/fastify/controllers.d.ts +3 -1
- package/dist/adapter/fastify/controllers.js +2 -25
- package/dist/adapter/fastify/index.js +7 -1
- package/dist/adapter/fastify/types.d.ts +3 -0
- package/dist/adapter/native/controllers.d.ts +3 -0
- package/dist/adapter/native/controllers.js +2 -25
- package/dist/adapter/native/index.js +14 -1
- package/dist/adapter/native/types.d.ts +3 -0
- package/dist/core/auth.d.ts +33 -3
- package/dist/core/auth.js +74 -22
- package/dist/core/openapi.d.ts +2 -0
- package/dist/core/openapi.js +19 -1
- package/examples/bearer-auth-swagger/app.ts +28 -0
- package/examples/bearer-auth-swagger/auth.controller.ts +45 -0
- package/examples/bearer-auth-swagger/index.ts +20 -0
- package/examples/bearer-auth-swagger/session.dtos.ts +19 -0
- package/package.json +1 -1
- package/src/adapter/express/controllers.ts +23 -18
- package/src/adapter/express/index.ts +12 -1
- package/src/adapter/express/types.ts +13 -10
- package/src/adapter/fastify/controllers.ts +16 -41
- package/src/adapter/fastify/index.ts +27 -13
- package/src/adapter/fastify/types.ts +13 -10
- package/src/adapter/native/controllers.ts +16 -41
- package/src/adapter/native/index.ts +28 -15
- package/src/adapter/native/types.ts +13 -10
- package/src/core/auth.ts +134 -56
- package/src/core/openapi.ts +22 -1
- package/tests/e2e/bearer-auth.e2e.test.ts +158 -0
- package/tests/unit/auth.test.ts +96 -12
- package/tests/unit/openapi-parameters.test.ts +54 -6
package/README.md
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
# Adorn API
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Decorator-first API framework for TypeScript with Express, Fastify, native Node HTTP, OpenAPI 3.1 generation, request validation, Bearer auth, file uploads, streaming, and optional Metal ORM helpers.
|
|
4
|
+
|
|
5
|
+
Adorn is designed for APIs where the route contract should live beside the handler: controllers, DTOs, schemas, validation, serialization, and OpenAPI are all derived from the same decorators.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- 🏗️ **Lifecycle Hooks**: Application bootstrap and shutdown lifecycle events
|
|
9
|
+
- Controller and route decorators: `@Controller`, `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`
|
|
10
|
+
- DTO decorators: `@Dto`, `@Field`, `@PickDto`, `@OmitDto`, `@PartialDto`, `@MergeDto`
|
|
11
|
+
- Schema builder: `t.string`, `t.uuid`, `t.integer`, `t.object`, `t.array`, `t.file`, and more
|
|
12
|
+
- OpenAPI 3.1 JSON and Swagger UI
|
|
13
|
+
- Express, Fastify, and native Node HTTP adapters
|
|
14
|
+
- Built-in Bearer token auth for `@Auth`, `@Roles`, `@AllRoles`, and `@Public`
|
|
15
|
+
- Runtime validation for body, query, params, and headers
|
|
16
|
+
- Input coercion for params/query/body
|
|
17
|
+
- Multipart file uploads
|
|
18
|
+
- Raw responses, SSE, and streaming responses
|
|
19
|
+
- Response serialization with `@Expose`, `@Exclude`, and `@Transform`
|
|
20
|
+
- Health checks, request logging, and lifecycle hooks
|
|
21
|
+
- Metal ORM DTO, CRUD, pagination, filtering, sorting, and tree helpers
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
23
24
|
|
|
@@ -25,17 +26,31 @@ A modern, decorator-first web framework built on Express with built-in OpenAPI 3
|
|
|
25
26
|
npm install adorn-api
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Adorn uses Stage 3 decorators and `Symbol.metadata`. The package polyfills `Symbol.metadata` on import when the runtime does not provide it.
|
|
30
|
+
|
|
31
|
+
Recommended TypeScript settings:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"compilerOptions": {
|
|
36
|
+
"target": "ES2022",
|
|
37
|
+
"moduleResolution": "Node",
|
|
38
|
+
"experimentalDecorators": false,
|
|
39
|
+
"emitDecoratorMetadata": false,
|
|
40
|
+
"useDefineForClassFields": true,
|
|
41
|
+
"strict": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
29
45
|
|
|
30
46
|
## Quick Start
|
|
31
47
|
|
|
32
|
-
###
|
|
48
|
+
### DTOs
|
|
33
49
|
|
|
34
50
|
```typescript
|
|
35
|
-
// user.dtos.ts
|
|
36
51
|
import { Dto, Field, OmitDto, PickDto, t } from "adorn-api";
|
|
37
52
|
|
|
38
|
-
@Dto({ description: "User
|
|
53
|
+
@Dto({ description: "User returned by the API." })
|
|
39
54
|
export class UserDto {
|
|
40
55
|
@Field(t.uuid({ description: "User identifier." }))
|
|
41
56
|
id!: string;
|
|
@@ -47,17 +62,20 @@ export class UserDto {
|
|
|
47
62
|
nickname?: string;
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
export interface CreateUserDto extends Omit<UserDto, "id"> {}
|
|
66
|
+
|
|
50
67
|
@OmitDto(UserDto, ["id"])
|
|
51
68
|
export class CreateUserDto {}
|
|
52
69
|
|
|
70
|
+
export interface UserParamsDto extends Pick<UserDto, "id"> {}
|
|
71
|
+
|
|
53
72
|
@PickDto(UserDto, ["id"])
|
|
54
73
|
export class UserParamsDto {}
|
|
55
74
|
```
|
|
56
75
|
|
|
57
|
-
###
|
|
76
|
+
### Controller
|
|
58
77
|
|
|
59
78
|
```typescript
|
|
60
|
-
// user.controller.ts
|
|
61
79
|
import {
|
|
62
80
|
Body,
|
|
63
81
|
Controller,
|
|
@@ -95,1169 +113,834 @@ export class UserController {
|
|
|
95
113
|
}
|
|
96
114
|
```
|
|
97
115
|
|
|
98
|
-
###
|
|
116
|
+
### App
|
|
99
117
|
|
|
100
118
|
```typescript
|
|
101
|
-
// app.ts
|
|
102
119
|
import { createExpressApp } from "adorn-api";
|
|
103
120
|
import { UserController } from "./user.controller";
|
|
104
121
|
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
async function start() {
|
|
123
|
+
const app = await createExpressApp({
|
|
107
124
|
controllers: [UserController],
|
|
108
125
|
openApi: {
|
|
109
126
|
info: {
|
|
110
|
-
title: "
|
|
127
|
+
title: "Users API",
|
|
111
128
|
version: "1.0.0"
|
|
112
129
|
},
|
|
113
130
|
docs: true
|
|
114
131
|
}
|
|
115
132
|
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// index.ts
|
|
119
|
-
import { createApp } from "./app";
|
|
120
133
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
app.listen(PORT, () => {
|
|
126
|
-
console.log(`Server running at http://localhost:${PORT}`);
|
|
127
|
-
console.log(`OpenAPI documentation: http://localhost:${PORT}/openapi.json`);
|
|
134
|
+
app.listen(3000, () => {
|
|
135
|
+
console.log("API: http://localhost:3000");
|
|
136
|
+
console.log("Docs: http://localhost:3000/docs");
|
|
137
|
+
console.log("OpenAPI: http://localhost:3000/openapi.json");
|
|
128
138
|
});
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
start().catch(error => {
|
|
132
|
-
console.error(
|
|
141
|
+
start().catch((error) => {
|
|
142
|
+
console.error(error);
|
|
133
143
|
process.exit(1);
|
|
134
144
|
});
|
|
135
145
|
```
|
|
136
146
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
### Controllers
|
|
140
|
-
|
|
141
|
-
Controllers are classes decorated with `@Controller()` that group related API endpoints. Each controller has a base path and contains route handlers.
|
|
147
|
+
Run the bundled basic example:
|
|
142
148
|
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
export class UserController {
|
|
146
|
-
// Routes go here
|
|
147
|
-
}
|
|
149
|
+
```bash
|
|
150
|
+
npm run example -- basic
|
|
148
151
|
```
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
## Adapters
|
|
151
154
|
|
|
152
|
-
|
|
155
|
+
### Express
|
|
153
156
|
|
|
154
157
|
```typescript
|
|
155
|
-
|
|
156
|
-
@Params(UserParamsDto)
|
|
157
|
-
@Returns(UserDto)
|
|
158
|
-
async getOne(ctx: RequestContext) {
|
|
159
|
-
// Route handler logic
|
|
160
|
-
}
|
|
161
|
-
```
|
|
158
|
+
import { createExpressApp } from "adorn-api";
|
|
162
159
|
|
|
163
|
-
|
|
160
|
+
const app = await createExpressApp({
|
|
161
|
+
controllers: [UserController],
|
|
162
|
+
cors: {
|
|
163
|
+
origin: "https://app.example.com",
|
|
164
|
+
credentials: true
|
|
165
|
+
},
|
|
166
|
+
jsonBody: true,
|
|
167
|
+
jsonLimit: "1mb",
|
|
168
|
+
inputCoercion: "safe",
|
|
169
|
+
validation: { enabled: true, mode: "strict" },
|
|
170
|
+
multipart: {
|
|
171
|
+
storage: "memory",
|
|
172
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
173
|
+
maxFiles: 10
|
|
174
|
+
},
|
|
175
|
+
openApi: {
|
|
176
|
+
info: { title: "API", version: "1.0.0" },
|
|
177
|
+
path: "/openapi.json",
|
|
178
|
+
docs: { path: "/docs" }
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
```
|
|
164
182
|
|
|
165
|
-
|
|
183
|
+
### Fastify
|
|
166
184
|
|
|
167
185
|
```typescript
|
|
168
|
-
|
|
169
|
-
export class UserDto {
|
|
170
|
-
@Field(t.uuid({ description: "Unique identifier" }))
|
|
171
|
-
id!: string;
|
|
186
|
+
import { createFastifyApp } from "adorn-api";
|
|
172
187
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
const app = await createFastifyApp({
|
|
189
|
+
controllers: [UserController],
|
|
190
|
+
bodyLimit: 1_048_576,
|
|
191
|
+
cors: true,
|
|
192
|
+
inputCoercion: "safe",
|
|
193
|
+
multipart: true,
|
|
194
|
+
openApi: {
|
|
195
|
+
info: { title: "API", version: "1.0.0" },
|
|
196
|
+
docs: true
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await app.listen({ port: 3000 });
|
|
176
201
|
```
|
|
177
202
|
|
|
178
|
-
###
|
|
203
|
+
### Native Node HTTP
|
|
179
204
|
|
|
180
|
-
|
|
205
|
+
```typescript
|
|
206
|
+
import { createNativeApp } from "adorn-api";
|
|
181
207
|
|
|
182
|
-
|
|
208
|
+
const app = await createNativeApp({
|
|
209
|
+
controllers: [UserController],
|
|
210
|
+
bodyLimit: 1_048_576,
|
|
211
|
+
openApi: {
|
|
212
|
+
info: { title: "API", version: "1.0.0" },
|
|
213
|
+
docs: true
|
|
214
|
+
}
|
|
215
|
+
});
|
|
183
216
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
- `ctx.headers` - Request headers
|
|
189
|
-
- `ctx.req` - The raw Express request
|
|
190
|
-
- `ctx.res` - The raw Express response
|
|
191
|
-
- `ctx.sse` - SSE emitter (for SSE routes)
|
|
192
|
-
- `ctx.stream` - Streaming writer (for streaming routes)
|
|
217
|
+
app.listen(3000, () => {
|
|
218
|
+
console.log("Native API running on http://localhost:3000");
|
|
219
|
+
});
|
|
220
|
+
```
|
|
193
221
|
|
|
194
|
-
##
|
|
222
|
+
## Request Context
|
|
195
223
|
|
|
196
|
-
|
|
224
|
+
Every handler receives a `RequestContext`:
|
|
197
225
|
|
|
198
226
|
```typescript
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const interval = setInterval(() => {
|
|
210
|
-
count++;
|
|
211
|
-
emitter.emit("message", {
|
|
212
|
-
id: count,
|
|
213
|
-
timestamp: new Date().toISOString(),
|
|
214
|
-
message: `Event ${count}`
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
if (count >= 5) {
|
|
218
|
-
clearInterval(interval);
|
|
219
|
-
emitter.close();
|
|
220
|
-
}
|
|
221
|
-
}, 1000);
|
|
222
|
-
|
|
223
|
-
ctx.req.on("close", () => {
|
|
224
|
-
clearInterval(interval);
|
|
225
|
-
emitter.close();
|
|
226
|
-
});
|
|
227
|
-
}
|
|
227
|
+
interface RequestContext<TBody, TQuery, TParams, THeaders, TFiles> {
|
|
228
|
+
req: any;
|
|
229
|
+
res: any;
|
|
230
|
+
body: TBody;
|
|
231
|
+
query: TQuery;
|
|
232
|
+
params: TParams;
|
|
233
|
+
headers: THeaders;
|
|
234
|
+
files: TFiles;
|
|
235
|
+
sse?: SseEmitterInterface;
|
|
236
|
+
stream?: StreamWriterInterface;
|
|
228
237
|
}
|
|
229
238
|
```
|
|
230
239
|
|
|
231
|
-
|
|
240
|
+
Use adapter-specific aliases when useful:
|
|
232
241
|
|
|
233
242
|
```typescript
|
|
234
|
-
import {
|
|
243
|
+
import type {
|
|
244
|
+
ExpressRequestContext,
|
|
245
|
+
FastifyRequestContext,
|
|
246
|
+
NativeRequestContext
|
|
247
|
+
} from "adorn-api";
|
|
248
|
+
```
|
|
235
249
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
## Controllers and Decorators
|
|
251
|
+
|
|
252
|
+
### Route Definition
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
@Controller({ path: "/tasks", tags: ["Tasks"] })
|
|
256
|
+
class TaskController {
|
|
257
|
+
@Get("/:id")
|
|
258
|
+
@Doc({ summary: "Get a task" })
|
|
259
|
+
@Params(t.object({ id: t.uuid() }))
|
|
260
|
+
@Query(t.object({ includeHistory: t.optional(t.boolean()) }))
|
|
261
|
+
@Headers(t.object({ "x-request-id": t.optional(t.string()) }))
|
|
262
|
+
@Returns(TaskDto)
|
|
263
|
+
async getTask(ctx: RequestContext) {
|
|
264
|
+
return findTask(ctx.params.id);
|
|
250
265
|
}
|
|
251
266
|
}
|
|
252
267
|
```
|
|
253
268
|
|
|
254
|
-
|
|
269
|
+
Available route decorators:
|
|
255
270
|
|
|
256
|
-
|
|
271
|
+
- HTTP methods: `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`
|
|
272
|
+
- Inputs: `@Body`, `@Query`, `@Params`, `@Headers`
|
|
273
|
+
- Outputs: `@Returns`, `@ReturnsError`, `@Errors`
|
|
274
|
+
- Docs: `@Doc`, controller `tags`
|
|
275
|
+
- Auth: `@Auth`, `@Roles`, `@AllRoles`, `@Public`
|
|
276
|
+
- Files and streams: `@UploadedFile`, `@UploadedFiles`, `@Raw`, `@Sse`, `@Streaming`
|
|
257
277
|
|
|
258
|
-
|
|
259
|
-
import { Controller, Get, Raw, Params, ok, type RequestContext } from "adorn-api";
|
|
260
|
-
import fs from "fs/promises";
|
|
278
|
+
### HTTP Responses
|
|
261
279
|
|
|
262
|
-
|
|
263
|
-
class FileController {
|
|
264
|
-
@Get("/report.pdf")
|
|
265
|
-
@Raw({ contentType: "application/pdf", description: "Download PDF report" })
|
|
266
|
-
async downloadPdf(ctx: RequestContext) {
|
|
267
|
-
const buffer = await fs.readFile("report.pdf");
|
|
268
|
-
return ok(buffer);
|
|
269
|
-
}
|
|
280
|
+
Return a plain value for the default status, or return `HttpResponse` helpers when status/headers matter:
|
|
270
281
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
282
|
+
```typescript
|
|
283
|
+
import { created, noContent, ok, redirect } from "adorn-api";
|
|
284
|
+
|
|
285
|
+
@Post("/")
|
|
286
|
+
@Returns({ status: 201, schema: TaskDto })
|
|
287
|
+
async create(ctx: RequestContext<CreateTaskDto>) {
|
|
288
|
+
return created(await createTask(ctx.body));
|
|
277
289
|
}
|
|
278
|
-
```
|
|
279
290
|
|
|
280
|
-
|
|
291
|
+
@Delete("/:id")
|
|
292
|
+
@Returns({ status: 204 })
|
|
293
|
+
async remove() {
|
|
294
|
+
return noContent();
|
|
295
|
+
}
|
|
281
296
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
@Get("/download/:filename")
|
|
286
|
-
@Raw({ contentType: "application/octet-stream" })
|
|
287
|
-
async download(ctx: RequestContext) {
|
|
288
|
-
const buffer = await fs.readFile(`uploads/${ctx.params.filename}`);
|
|
289
|
-
return new HttpResponse(200, buffer, {
|
|
290
|
-
"Content-Disposition": `attachment; filename="${ctx.params.filename}"`
|
|
291
|
-
});
|
|
297
|
+
@Get("/legacy")
|
|
298
|
+
async legacy() {
|
|
299
|
+
return redirect("/tasks", 301);
|
|
292
300
|
}
|
|
293
301
|
```
|
|
294
302
|
|
|
295
|
-
|
|
303
|
+
Throw structured HTTP errors:
|
|
296
304
|
|
|
297
305
|
```typescript
|
|
298
|
-
import {
|
|
306
|
+
import { badRequest, forbidden, notFound, unauthorized } from "adorn-api";
|
|
299
307
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
@Post("/")
|
|
303
|
-
@UploadedFile("file", t.file({ accept: ["image/*"], maxSize: 5 * 1024 * 1024 }))
|
|
304
|
-
@Returns({ status: 200, schema: t.string() })
|
|
305
|
-
async uploadFile(ctx: any) {
|
|
306
|
-
const file = ctx.files?.file[0];
|
|
307
|
-
return `File uploaded: ${file.originalname}`;
|
|
308
|
-
}
|
|
308
|
+
if (!task) {
|
|
309
|
+
notFound("Task not found");
|
|
309
310
|
}
|
|
310
311
|
```
|
|
311
312
|
|
|
312
|
-
|
|
313
|
+
Available helpers: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `unprocessableEntity`, `tooManyRequests`, `serviceUnavailable`, and `internalServerError`.
|
|
313
314
|
|
|
314
|
-
|
|
315
|
-
Transformer decorators such as `@Email`, `@Length`, `@Pattern`, and `@Alphanumeric` are reflected in the generated DTO schemas (validation + OpenAPI).
|
|
315
|
+
## Schemas and DTOs
|
|
316
316
|
|
|
317
|
-
###
|
|
317
|
+
### Schema Builder
|
|
318
|
+
|
|
319
|
+
The `t` builder creates runtime validation and OpenAPI schemas:
|
|
318
320
|
|
|
319
321
|
```typescript
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
const TaskSchema = t.object({
|
|
323
|
+
id: t.uuid(),
|
|
324
|
+
title: t.string({ minLength: 1, maxLength: 120 }),
|
|
325
|
+
status: t.enum(["todo", "doing", "done"]),
|
|
326
|
+
priority: t.integer({ minimum: 1, maximum: 5 }),
|
|
327
|
+
tags: t.array(t.string(), { uniqueItems: true }),
|
|
328
|
+
metadata: t.record(t.any()),
|
|
329
|
+
dueAt: t.nullable(t.dateTime()),
|
|
330
|
+
attachment: t.optional(t.file({ accept: ["application/pdf"] }))
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Available schema helpers:
|
|
322
335
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
336
|
+
- Primitives: `t.string`, `t.number`, `t.integer`, `t.boolean`
|
|
337
|
+
- Formats: `t.uuid`, `t.dateTime`, `t.bytes`
|
|
338
|
+
- Containers: `t.object`, `t.array`, `t.record`
|
|
339
|
+
- Composition: `t.enum`, `t.literal`, `t.union`, `t.ref`
|
|
340
|
+
- Utility: `t.any`, `t.null`, `t.file`, `t.optional`, `t.nullable`
|
|
327
341
|
|
|
328
|
-
|
|
342
|
+
Common options include `description`, `title`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `optional`, and `nullable`.
|
|
343
|
+
|
|
344
|
+
### DTO Composition
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
@Dto()
|
|
348
|
+
class UserDto {
|
|
349
|
+
@Field(t.uuid())
|
|
350
|
+
id!: string;
|
|
351
|
+
|
|
352
|
+
@Field(t.string())
|
|
329
353
|
name!: string;
|
|
330
354
|
|
|
331
|
-
@
|
|
332
|
-
|
|
355
|
+
@Field(t.string())
|
|
356
|
+
passwordHash!: string;
|
|
333
357
|
}
|
|
334
|
-
```
|
|
335
358
|
|
|
336
|
-
|
|
359
|
+
@PickDto(UserDto, ["id", "name"])
|
|
360
|
+
class PublicUserDto {}
|
|
337
361
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
import { createMetalCrudDtoClasses, t } from "adorn-api";
|
|
341
|
-
import { User } from "./user.entity";
|
|
362
|
+
@OmitDto(UserDto, ["id", "passwordHash"])
|
|
363
|
+
class CreateUserDto {}
|
|
342
364
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
params: UserParamsDto,
|
|
349
|
-
queryDto: UserQueryDto,
|
|
350
|
-
optionsQueryDto: UserOptionsQueryDto,
|
|
351
|
-
pagedResponseDto: UserPagedResponseDto,
|
|
352
|
-
optionDto: UserOptionDto,
|
|
353
|
-
optionsDto: UserOptionsDto,
|
|
354
|
-
errors: UserErrors,
|
|
355
|
-
filterMappings: USER_FILTER_MAPPINGS,
|
|
356
|
-
sortableColumns: USER_SORTABLE_COLUMNS,
|
|
357
|
-
listConfig: USER_LIST_CONFIG
|
|
358
|
-
} = createMetalCrudDtoClasses(User, {
|
|
359
|
-
mutationExclude: ["id", "createdAt"],
|
|
360
|
-
query: {
|
|
361
|
-
filters: {
|
|
362
|
-
nameContains: {
|
|
363
|
-
schema: t.string({ minLength: 1 }),
|
|
364
|
-
field: "name",
|
|
365
|
-
operator: "contains"
|
|
366
|
-
},
|
|
367
|
-
emailContains: {
|
|
368
|
-
schema: t.string({ minLength: 1 }),
|
|
369
|
-
field: "email",
|
|
370
|
-
operator: "contains"
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
sortableColumns: {
|
|
374
|
-
id: "id",
|
|
375
|
-
name: "name",
|
|
376
|
-
createdAt: "createdAt"
|
|
377
|
-
},
|
|
378
|
-
options: {
|
|
379
|
-
labelField: "name"
|
|
380
|
-
}
|
|
381
|
-
},
|
|
382
|
-
errors: true
|
|
383
|
-
});
|
|
365
|
+
@PartialDto(CreateUserDto)
|
|
366
|
+
class UpdateUserDto {}
|
|
367
|
+
|
|
368
|
+
@MergeDto([PublicUserDto, ProfileDto])
|
|
369
|
+
class UserProfileDto {}
|
|
384
370
|
```
|
|
385
371
|
|
|
386
|
-
|
|
372
|
+
Composition decorators can override schema, optionality, descriptions, name, and `additionalProperties`.
|
|
373
|
+
|
|
374
|
+
## Authentication
|
|
375
|
+
|
|
376
|
+
Decorate controllers or routes with `@Auth`. `@Roles` and `@AllRoles` imply authentication. `@Public` overrides controller-level auth for one route.
|
|
387
377
|
|
|
388
378
|
```typescript
|
|
389
|
-
// user.controller.ts
|
|
390
379
|
import {
|
|
380
|
+
Auth,
|
|
391
381
|
Controller,
|
|
392
382
|
Get,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
Body,
|
|
399
|
-
Query,
|
|
400
|
-
Returns,
|
|
401
|
-
runPagedList,
|
|
402
|
-
t,
|
|
383
|
+
Public,
|
|
384
|
+
Roles,
|
|
385
|
+
createExpressApp,
|
|
386
|
+
getUser,
|
|
387
|
+
type AuthUser,
|
|
403
388
|
type RequestContext
|
|
404
389
|
} from "adorn-api";
|
|
405
|
-
import { createSession } from "./db";
|
|
406
|
-
import { User } from "./user.entity";
|
|
407
|
-
import {
|
|
408
|
-
UserDto,
|
|
409
|
-
CreateUserDto,
|
|
410
|
-
ReplaceUserDto,
|
|
411
|
-
UpdateUserDto,
|
|
412
|
-
UserParamsDto,
|
|
413
|
-
UserQueryDto,
|
|
414
|
-
UserOptionsQueryDto,
|
|
415
|
-
UserPagedResponseDto,
|
|
416
|
-
UserOptionsDto,
|
|
417
|
-
UserErrors,
|
|
418
|
-
USER_FILTER_MAPPINGS,
|
|
419
|
-
USER_SORTABLE_COLUMNS
|
|
420
|
-
} from "./user.dtos";
|
|
421
390
|
|
|
422
|
-
@
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
@
|
|
426
|
-
@
|
|
427
|
-
|
|
428
|
-
|
|
391
|
+
@Auth()
|
|
392
|
+
@Controller("/account")
|
|
393
|
+
class AccountController {
|
|
394
|
+
@Get("/health")
|
|
395
|
+
@Public()
|
|
396
|
+
health() {
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
429
399
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
target: User,
|
|
434
|
-
qb: () => User.select(),
|
|
435
|
-
session,
|
|
436
|
-
filterMappings: USER_FILTER_MAPPINGS,
|
|
437
|
-
sortableColumns: USER_SORTABLE_COLUMNS,
|
|
438
|
-
defaultSortBy: "id"
|
|
439
|
-
});
|
|
440
|
-
} finally {
|
|
441
|
-
await session.dispose();
|
|
442
|
-
}
|
|
400
|
+
@Get("/me")
|
|
401
|
+
me(ctx: RequestContext) {
|
|
402
|
+
return getUser<AuthUser>(ctx.req);
|
|
443
403
|
}
|
|
444
404
|
|
|
445
|
-
@Get("/
|
|
446
|
-
@
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const query = (ctx.query ?? {}) as Record<string, unknown>;
|
|
450
|
-
const { page, pageSize } = parsePagination(query);
|
|
451
|
-
const filters = parseFilter(query, USER_FILTER_MAPPINGS);
|
|
452
|
-
// run your options query using the same mappings + generated DTOs
|
|
453
|
-
return {
|
|
454
|
-
items: [],
|
|
455
|
-
totalItems: 0,
|
|
456
|
-
page,
|
|
457
|
-
pageSize,
|
|
458
|
-
totalPages: 1,
|
|
459
|
-
hasNextPage: false,
|
|
460
|
-
hasPrevPage: false
|
|
461
|
-
};
|
|
405
|
+
@Get("/admin")
|
|
406
|
+
@Roles("admin")
|
|
407
|
+
adminOnly() {
|
|
408
|
+
return { ok: true };
|
|
462
409
|
}
|
|
410
|
+
}
|
|
463
411
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
await session.dispose();
|
|
412
|
+
const app = await createExpressApp({
|
|
413
|
+
controllers: [AccountController],
|
|
414
|
+
bearerAuth: {
|
|
415
|
+
async verifyToken(token, req) {
|
|
416
|
+
if (token === "admin-token") {
|
|
417
|
+
return { id: "admin-1", roles: ["admin"] };
|
|
418
|
+
}
|
|
419
|
+
if (token === "user-token") {
|
|
420
|
+
return { id: "user-1", roles: ["user"] };
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
476
423
|
}
|
|
477
424
|
}
|
|
478
|
-
|
|
479
|
-
// Other CRUD operations...
|
|
480
|
-
}
|
|
425
|
+
});
|
|
481
426
|
```
|
|
482
427
|
|
|
483
|
-
|
|
428
|
+
Bearer auth reads only:
|
|
484
429
|
|
|
485
|
-
|
|
430
|
+
```text
|
|
431
|
+
Authorization: Bearer <token>
|
|
432
|
+
```
|
|
486
433
|
|
|
487
|
-
|
|
434
|
+
`verifyToken` is intentionally application-owned. Use it to verify JWTs, opaque tokens, API keys, or session tokens. Returning `null` means the request is unauthenticated.
|
|
488
435
|
|
|
489
|
-
|
|
490
|
-
// user.controller.ts
|
|
491
|
-
import { createCrudController } from "adorn-api";
|
|
492
|
-
import { userCrudDtos } from "./user.dtos";
|
|
493
|
-
import { UserCrudService } from "./user.service";
|
|
436
|
+
Protected routes are emitted in OpenAPI with:
|
|
494
437
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
438
|
+
```json
|
|
439
|
+
{
|
|
440
|
+
"components": {
|
|
441
|
+
"securitySchemes": {
|
|
442
|
+
"bearerAuth": {
|
|
443
|
+
"type": "http",
|
|
444
|
+
"scheme": "bearer"
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
505
449
|
```
|
|
506
450
|
|
|
507
|
-
|
|
508
|
-
- `GET /`
|
|
509
|
-
- `GET /options` (optional)
|
|
510
|
-
- `GET /:id`
|
|
511
|
-
- `POST /`
|
|
512
|
-
- `PUT /:id` (optional)
|
|
513
|
-
- `PATCH /:id` (optional)
|
|
514
|
-
- `DELETE /:id` (optional)
|
|
451
|
+
CORS is not enabled automatically. Server-to-server clients do not need CORS. Browser clients still need explicit `cors` configuration.
|
|
515
452
|
|
|
516
|
-
|
|
453
|
+
Try the Swagger auth example:
|
|
517
454
|
|
|
518
|
-
|
|
455
|
+
```bash
|
|
456
|
+
npm run example -- bearer-auth-swagger
|
|
457
|
+
```
|
|
519
458
|
|
|
520
|
-
|
|
521
|
-
@Controller("/users")
|
|
522
|
-
class UserController {
|
|
523
|
-
@Get("/")
|
|
524
|
-
@Query(UserQueryDto)
|
|
525
|
-
@Returns(UserPagedResponseDto)
|
|
526
|
-
async list(ctx: RequestContext<unknown, UserQueryDto>) { ... }
|
|
459
|
+
Then open `http://localhost:3001/docs` and use `user-token` or `admin-token` in Swagger Authorize.
|
|
527
460
|
|
|
528
|
-
|
|
529
|
-
@Params(UserParamsDto)
|
|
530
|
-
@Returns(UserDto)
|
|
531
|
-
@UserErrors
|
|
532
|
-
async getById(ctx: RequestContext<unknown, undefined, UserParamsDto>) { ... }
|
|
461
|
+
## OpenAPI and Swagger UI
|
|
533
462
|
|
|
534
|
-
|
|
535
|
-
@Body(CreateUserDto)
|
|
536
|
-
@Returns({ status: 201, schema: UserDto })
|
|
537
|
-
async create(ctx: RequestContext<CreateUserDto>) { ... }
|
|
463
|
+
Adapters can serve OpenAPI JSON and Swagger UI:
|
|
538
464
|
|
|
539
|
-
|
|
540
|
-
|
|
465
|
+
```typescript
|
|
466
|
+
await createExpressApp({
|
|
467
|
+
controllers: [UserController],
|
|
468
|
+
openApi: {
|
|
469
|
+
info: {
|
|
470
|
+
title: "Users API",
|
|
471
|
+
version: "1.0.0",
|
|
472
|
+
description: "Public API contract"
|
|
473
|
+
},
|
|
474
|
+
servers: [{ url: "https://api.example.com", description: "Production" }],
|
|
475
|
+
path: "/openapi.json",
|
|
476
|
+
prettyPrint: true,
|
|
477
|
+
docs: {
|
|
478
|
+
path: "/docs",
|
|
479
|
+
title: "Users API Docs"
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
});
|
|
541
483
|
```
|
|
542
484
|
|
|
543
|
-
|
|
485
|
+
You can also build the document without starting an HTTP server:
|
|
544
486
|
|
|
545
487
|
```typescript
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
488
|
+
import { buildOpenApi } from "adorn-api";
|
|
489
|
+
|
|
490
|
+
const document = buildOpenApi({
|
|
491
|
+
info: { title: "Users API", version: "1.0.0" },
|
|
492
|
+
controllers: [UserController]
|
|
551
493
|
});
|
|
552
494
|
```
|
|
553
495
|
|
|
554
|
-
|
|
555
|
-
- Use `createCrudController` when routes follow standard CRUD and behavior lives in a service.
|
|
556
|
-
- Use a manual controller when route contracts diverge (custom status/body shape, non-standard params, upload/stream/raw endpoints, or route-level auth/doc decorators not shared by all CRUD routes).
|
|
557
|
-
- For extra endpoints, keep the generated CRUD controller and add a second manual controller for custom routes on the same base path.
|
|
496
|
+
OpenAPI generation includes:
|
|
558
497
|
|
|
559
|
-
|
|
498
|
+
- DTO schemas under `components.schemas`
|
|
499
|
+
- query, path, header, body, multipart, and response schemas
|
|
500
|
+
- route summaries/descriptions/tags from `@Doc`
|
|
501
|
+
- Bearer security schemes for protected routes
|
|
502
|
+
- raw, SSE, and streaming content types
|
|
503
|
+
|
|
504
|
+
## Validation and Coercion
|
|
505
|
+
|
|
506
|
+
Validation runs for `@Body`, `@Query`, `@Params`, and `@Headers` unless disabled:
|
|
560
507
|
|
|
561
508
|
```typescript
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
509
|
+
await createExpressApp({
|
|
510
|
+
controllers: [UserController],
|
|
511
|
+
validation: { enabled: true, mode: "strict" },
|
|
512
|
+
inputCoercion: "safe"
|
|
566
513
|
});
|
|
567
|
-
|
|
568
|
-
const USER_FILTER_MAPPINGS = {
|
|
569
|
-
nameContains: { field: "name", operator: "contains" as const }
|
|
570
|
-
};
|
|
571
514
|
```
|
|
572
515
|
|
|
573
|
-
|
|
516
|
+
Invalid input returns a structured 400 response with field-level errors.
|
|
517
|
+
|
|
518
|
+
Manual validation is also available:
|
|
574
519
|
|
|
575
520
|
```typescript
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
filters: {
|
|
582
|
-
nameContains: {
|
|
583
|
-
schema: t.string({ minLength: 1 }),
|
|
584
|
-
field: "name",
|
|
585
|
-
operator: "contains"
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
521
|
+
import { ValidationErrors, t, validate } from "adorn-api";
|
|
522
|
+
|
|
523
|
+
const schema = t.object({
|
|
524
|
+
email: t.string({ format: "email" }),
|
|
525
|
+
age: t.integer({ minimum: 18 })
|
|
589
526
|
});
|
|
527
|
+
|
|
528
|
+
const errors = validate(data, schema);
|
|
529
|
+
if (errors.length) {
|
|
530
|
+
throw new ValidationErrors(errors);
|
|
531
|
+
}
|
|
590
532
|
```
|
|
591
533
|
|
|
592
|
-
|
|
593
|
-
- `createMetalCrudDtoClasses` now generates query/options/paged/error artifacts directly.
|
|
594
|
-
- Query filter definitions now include schema + operator + field mapping in one `query.filters` block.
|
|
595
|
-
- Sort allowlist now lives in `query.sortableColumns` and feeds both DTO schemas and runtime metadata.
|
|
596
|
-
- Generated outputs now include `queryDto`, `optionsQueryDto`, `pagedResponseDto`, `optionDto`, `optionsDto`, `errors`, `filterMappings`, and `sortableColumns`.
|
|
597
|
-
- Consumers no longer need internal `dist/...` imports for query/filter metadata types; all relevant types/utilities are publicly exported from `adorn-api`.
|
|
534
|
+
Input coercion can be:
|
|
598
535
|
|
|
599
|
-
|
|
536
|
+
- `"safe"`: coerce common values such as `"1"` to `1` and `"true"` to `true`
|
|
537
|
+
- `"strict"`: stricter conversion rules
|
|
538
|
+
- `false`: disabled
|
|
600
539
|
|
|
601
|
-
|
|
540
|
+
Low-level coercion helpers are exported as `coerce`, `parseNumber`, `parseInteger`, `parseBoolean`, and `parseId`.
|
|
541
|
+
|
|
542
|
+
## Serialization
|
|
543
|
+
|
|
544
|
+
Response serialization respects DTO schemas and transformation decorators:
|
|
602
545
|
|
|
603
546
|
```typescript
|
|
604
|
-
|
|
605
|
-
import {
|
|
606
|
-
Controller, Get, Query, Returns,
|
|
607
|
-
runPagedList,
|
|
608
|
-
type RequestContext
|
|
609
|
-
} from "adorn-api";
|
|
610
|
-
import { createSession } from "./db";
|
|
611
|
-
import { User } from "./user.entity";
|
|
612
|
-
import {
|
|
613
|
-
UserQueryDto,
|
|
614
|
-
UserPagedResponseDto,
|
|
615
|
-
USER_LIST_CONFIG
|
|
616
|
-
} from "./user.dtos";
|
|
547
|
+
import { Dto, Exclude, Expose, Field, Transform, Transforms, serialize, t } from "adorn-api";
|
|
617
548
|
|
|
618
|
-
@
|
|
619
|
-
|
|
620
|
-
@
|
|
621
|
-
|
|
622
|
-
@Returns(UserPagedResponseDto)
|
|
623
|
-
async list(ctx: RequestContext<unknown, UserQueryDto>) {
|
|
624
|
-
const session = createSession();
|
|
625
|
-
try {
|
|
626
|
-
return await runPagedList({
|
|
627
|
-
query: (ctx.query ?? {}) as Record<string, unknown>,
|
|
628
|
-
target: User,
|
|
629
|
-
qb: () => User.select(),
|
|
630
|
-
session,
|
|
631
|
-
...USER_LIST_CONFIG
|
|
632
|
-
});
|
|
633
|
-
} finally {
|
|
634
|
-
await session.dispose();
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
The `listConfig` object contains: `filterMappings`, `sortableColumns`, `defaultSortBy`, `defaultSortDirection`, `defaultPageSize`, `maxPageSize`, `sortByKey`, and `sortDirectionKey`.
|
|
641
|
-
|
|
642
|
-
### Type-Only Query Interfaces
|
|
549
|
+
@Dto()
|
|
550
|
+
class UserDto {
|
|
551
|
+
@Field(t.string())
|
|
552
|
+
id!: string;
|
|
643
553
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
- `PagedQueryParams`
|
|
554
|
+
@Field(t.string())
|
|
555
|
+
@Transform(Transforms.toLowerCase)
|
|
556
|
+
email!: string;
|
|
648
557
|
|
|
649
|
-
|
|
650
|
-
|
|
558
|
+
@Field(t.string())
|
|
559
|
+
@Exclude()
|
|
560
|
+
passwordHash!: string;
|
|
651
561
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
562
|
+
@Field(t.string())
|
|
563
|
+
@Expose({ name: "display_name" })
|
|
564
|
+
name!: string;
|
|
655
565
|
}
|
|
566
|
+
|
|
567
|
+
const output = serialize(user);
|
|
656
568
|
```
|
|
657
569
|
|
|
658
|
-
`
|
|
570
|
+
Use `createSerializer({ groups: [...] })` when you need reusable serialization presets.
|
|
659
571
|
|
|
660
|
-
|
|
572
|
+
## File Uploads
|
|
661
573
|
|
|
662
|
-
|
|
574
|
+
Enable multipart on the adapter and declare file fields on the route:
|
|
663
575
|
|
|
664
576
|
```typescript
|
|
665
|
-
|
|
666
|
-
const { page, pageSize } = parsePagination(query, this.listConfig);
|
|
667
|
-
const filters = parseFilter(query, this.listConfig.filterMappings);
|
|
668
|
-
const sort = parseSort(query, this.listConfig.sortableColumns, {
|
|
669
|
-
defaultSortBy: this.listConfig.defaultSortBy,
|
|
670
|
-
defaultSortDirection: this.listConfig.defaultSortDirection,
|
|
671
|
-
sortByKey: this.listConfig.sortByKey,
|
|
672
|
-
sortDirectionKey: this.listConfig.sortDirectionKey
|
|
673
|
-
});
|
|
674
|
-
const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
|
|
675
|
-
|
|
676
|
-
return withSession(this.createSession, async (session) => {
|
|
677
|
-
const qb = applyFilter(this.baseQuery().orderBy(this.ref.id, direction), this.entity, filters);
|
|
678
|
-
const paged = await qb.executePaged(session, { page, pageSize });
|
|
679
|
-
return toPagedResponse(paged);
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
```
|
|
577
|
+
import { Controller, Post, Returns, UploadedFile, UploadedFiles, t } from "adorn-api";
|
|
683
578
|
|
|
684
|
-
|
|
579
|
+
@Controller("/uploads")
|
|
580
|
+
class UploadController {
|
|
581
|
+
@Post("/avatar")
|
|
582
|
+
@UploadedFile("file", t.file({ accept: ["image/*"], maxSize: 5 * 1024 * 1024 }))
|
|
583
|
+
@Returns(t.object({ originalName: t.string(), size: t.integer() }))
|
|
584
|
+
async avatar(ctx: any) {
|
|
585
|
+
const file = ctx.files.file;
|
|
586
|
+
return {
|
|
587
|
+
originalName: file.originalName,
|
|
588
|
+
size: file.size
|
|
589
|
+
};
|
|
590
|
+
}
|
|
685
591
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
target: this.entity,
|
|
692
|
-
qb: () => this.baseQuery(),
|
|
693
|
-
session,
|
|
694
|
-
...this.listConfig
|
|
695
|
-
})
|
|
696
|
-
);
|
|
592
|
+
@Post("/gallery")
|
|
593
|
+
@UploadedFiles("files", t.file({ accept: ["image/*"] }))
|
|
594
|
+
async gallery(ctx: any) {
|
|
595
|
+
return { count: ctx.files.files.length };
|
|
596
|
+
}
|
|
697
597
|
}
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
Migration note:
|
|
701
|
-
- Existing `parsePagination`, `parseFilter`, and `parseSort` remain unchanged and can still be used manually.
|
|
702
|
-
- `runPagedList`/`executeCrudList` is additive and optional; no breaking API changes.
|
|
703
|
-
- For sortable fields that are not direct columns of the base table, pass `allowedSortColumns` with explicit metal-orm sort terms.
|
|
704
|
-
|
|
705
|
-
### Sort Order Compatibility (`sortOrder` / `sortDirection`)
|
|
706
|
-
|
|
707
|
-
`parseSort` now accepts both `sortDirection` (lowercase `asc`/`desc`) and `sortOrder` (uppercase `ASC`/`DESC`). This avoids the need for a custom helper when integrating with clients that send uppercase sort orders.
|
|
708
|
-
|
|
709
|
-
**Precedence**: `sortDirection` > `sortOrder` > default. Direction values are case-insensitive.
|
|
710
598
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// → { sortBy: "name", sortDirection: "asc", field: "name" } (sortDirection wins)
|
|
719
|
-
|
|
720
|
-
// Custom sortOrder key:
|
|
721
|
-
const sort3 = parseSort({
|
|
722
|
-
query,
|
|
723
|
-
sortableColumns,
|
|
724
|
-
sortOrderKey: "order" // reads from query.order instead of query.sortOrder
|
|
599
|
+
await createExpressApp({
|
|
600
|
+
controllers: [UploadController],
|
|
601
|
+
multipart: {
|
|
602
|
+
storage: "memory",
|
|
603
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
604
|
+
maxFiles: 10
|
|
605
|
+
}
|
|
725
606
|
});
|
|
726
607
|
```
|
|
727
608
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
`parseFilter` now accepts nested relation paths, so you can filter deep chains like Alpha → Bravo → Charlie → Delta. If
|
|
731
|
-
you type your filter mappings with `FilterMapping`, VS Code will enforce relation quantifiers like `some`, `every`, or
|
|
732
|
-
`none` for relation filters, matching Metal ORM's runtime rules.
|
|
733
|
-
|
|
734
|
-
```typescript
|
|
735
|
-
// alpha.entity.ts
|
|
736
|
-
import { BelongsTo, Column, Entity, HasMany, PrimaryKey, col } from "metal-orm";
|
|
737
|
-
import type { BelongsToReference, HasManyCollection } from "metal-orm";
|
|
609
|
+
Uploaded file info contains `originalName`, `mimeType`, `size`, `buffer`, `path`, and `fieldName`.
|
|
738
610
|
|
|
739
|
-
|
|
740
|
-
export class Alpha {
|
|
741
|
-
@PrimaryKey(col.autoIncrement(col.int()))
|
|
742
|
-
id!: number;
|
|
611
|
+
## Raw, SSE, and Streaming
|
|
743
612
|
|
|
744
|
-
|
|
745
|
-
name!: string;
|
|
746
|
-
|
|
747
|
-
@HasMany({ target: () => Bravo, foreignKey: "alphaId" })
|
|
748
|
-
bravos!: HasManyCollection<Bravo>;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
@Entity({ tableName: "bravos" })
|
|
752
|
-
export class Bravo {
|
|
753
|
-
@PrimaryKey(col.autoIncrement(col.int()))
|
|
754
|
-
id!: number;
|
|
755
|
-
|
|
756
|
-
@Column(col.notNull(col.text()))
|
|
757
|
-
code!: string;
|
|
758
|
-
|
|
759
|
-
@Column(col.notNull(col.int()))
|
|
760
|
-
alphaId!: number;
|
|
761
|
-
|
|
762
|
-
@BelongsTo({ target: () => Alpha, foreignKey: "alphaId" })
|
|
763
|
-
alpha!: BelongsToReference<Alpha>;
|
|
764
|
-
|
|
765
|
-
@HasMany({ target: () => Charlie, foreignKey: "bravoId" })
|
|
766
|
-
charlies!: HasManyCollection<Charlie>;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
@Entity({ tableName: "charlies" })
|
|
770
|
-
export class Charlie {
|
|
771
|
-
@PrimaryKey(col.autoIncrement(col.int()))
|
|
772
|
-
id!: number;
|
|
773
|
-
|
|
774
|
-
@Column(col.notNull(col.int()))
|
|
775
|
-
score!: number;
|
|
776
|
-
|
|
777
|
-
@Column(col.notNull(col.int()))
|
|
778
|
-
bravoId!: number;
|
|
779
|
-
|
|
780
|
-
@Column(col.int())
|
|
781
|
-
deltaId?: number | null;
|
|
782
|
-
|
|
783
|
-
@BelongsTo({ target: () => Bravo, foreignKey: "bravoId" })
|
|
784
|
-
bravo!: BelongsToReference<Bravo>;
|
|
785
|
-
|
|
786
|
-
@BelongsTo({ target: () => Delta, foreignKey: "deltaId" })
|
|
787
|
-
delta?: BelongsToReference<Delta>;
|
|
788
|
-
}
|
|
613
|
+
### Raw Responses
|
|
789
614
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
id!: number;
|
|
615
|
+
```typescript
|
|
616
|
+
import { Controller, Get, Raw, ok } from "adorn-api";
|
|
617
|
+
import fs from "node:fs/promises";
|
|
794
618
|
|
|
795
|
-
|
|
796
|
-
|
|
619
|
+
@Controller("/files")
|
|
620
|
+
class FileController {
|
|
621
|
+
@Get("/report.pdf")
|
|
622
|
+
@Raw({ contentType: "application/pdf", description: "Download PDF report" })
|
|
623
|
+
async report() {
|
|
624
|
+
return ok(await fs.readFile("report.pdf"));
|
|
625
|
+
}
|
|
797
626
|
}
|
|
798
627
|
```
|
|
799
628
|
|
|
629
|
+
### Server-Sent Events
|
|
630
|
+
|
|
800
631
|
```typescript
|
|
801
|
-
|
|
802
|
-
import { parseFilter, type FilterMapping } from "adorn-api";
|
|
803
|
-
import { applyFilter, selectFromEntity, type WhereInput } from "metal-orm";
|
|
804
|
-
import { Alpha } from "./alpha.entity";
|
|
632
|
+
import { Controller, Get, Sse } from "adorn-api";
|
|
805
633
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
operator: "isEmpty"
|
|
814
|
-
},
|
|
815
|
-
charlieScoreGte: {
|
|
816
|
-
field: "bravos.some.charlies.some.score",
|
|
817
|
-
operator: "gte"
|
|
634
|
+
@Controller("/events")
|
|
635
|
+
class EventsController {
|
|
636
|
+
@Get("/")
|
|
637
|
+
@Sse({ description: "Event stream" })
|
|
638
|
+
async stream(ctx: any) {
|
|
639
|
+
ctx.sse.send({ message: "connected" });
|
|
640
|
+
ctx.sse.close();
|
|
818
641
|
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
const filters = parseFilter(
|
|
822
|
-
(ctx.query ?? {}) as Record<string, unknown>,
|
|
823
|
-
ALPHA_FILTERS
|
|
824
|
-
);
|
|
825
|
-
|
|
826
|
-
const query = applyFilter(
|
|
827
|
-
selectFromEntity(Alpha),
|
|
828
|
-
Alpha,
|
|
829
|
-
filters as WhereInput<typeof Alpha>
|
|
830
|
-
);
|
|
642
|
+
}
|
|
831
643
|
```
|
|
832
644
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
### Tree DTOs (Nested Set / MPTT)
|
|
836
|
-
|
|
837
|
-
Metal ORM's tree helpers map cleanly into Adorn. Use `createMetalTreeDtoClasses` to generate DTOs for tree nodes,
|
|
838
|
-
node results, threaded trees, and tree lists. These schemas are included in OpenAPI automatically.
|
|
645
|
+
### Streaming
|
|
839
646
|
|
|
840
647
|
```typescript
|
|
841
|
-
|
|
842
|
-
import { createMetalTreeDtoClasses } from "adorn-api";
|
|
843
|
-
import { CategoryDto } from "./category.dtos";
|
|
844
|
-
import { Category } from "./category.entity";
|
|
845
|
-
|
|
846
|
-
export const {
|
|
847
|
-
node: CategoryNodeDto,
|
|
848
|
-
nodeResult: CategoryNodeResultDto,
|
|
849
|
-
threadedNode: CategoryThreadedNodeDto,
|
|
850
|
-
treeListEntry: CategoryTreeListEntryDto,
|
|
851
|
-
treeListSchema: CategoryTreeListSchema,
|
|
852
|
-
threadedTreeSchema: CategoryThreadedTreeSchema
|
|
853
|
-
} = createMetalTreeDtoClasses(Category, {
|
|
854
|
-
entityDto: CategoryDto
|
|
855
|
-
});
|
|
856
|
-
```
|
|
648
|
+
import { Controller, Get, Streaming } from "adorn-api";
|
|
857
649
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
@Returns(CategoryThreadedTreeSchema)
|
|
867
|
-
async tree() {
|
|
868
|
-
// return threaded tree data
|
|
650
|
+
@Controller("/exports")
|
|
651
|
+
class ExportController {
|
|
652
|
+
@Get("/ndjson")
|
|
653
|
+
@Streaming({ contentType: "application/x-ndjson" })
|
|
654
|
+
async ndjson(ctx: any) {
|
|
655
|
+
ctx.stream.writeJsonLine({ id: 1 });
|
|
656
|
+
ctx.stream.writeJsonLine({ id: 2 });
|
|
657
|
+
ctx.stream.close();
|
|
869
658
|
}
|
|
870
659
|
}
|
|
871
660
|
```
|
|
872
661
|
|
|
873
|
-
##
|
|
662
|
+
## Health, Logging, and Lifecycle
|
|
874
663
|
|
|
875
|
-
###
|
|
664
|
+
### Health Checks
|
|
876
665
|
|
|
877
666
|
```typescript
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
limits: { fileSize: 50 * 1024 * 1024 }
|
|
893
|
-
},
|
|
894
|
-
openApi: {
|
|
895
|
-
info: {
|
|
896
|
-
title: "My API",
|
|
897
|
-
version: "1.0.0",
|
|
898
|
-
description: "API documentation"
|
|
899
|
-
},
|
|
900
|
-
path: "/openapi.json", // OpenAPI schema endpoint
|
|
901
|
-
docs: true // Serve Swagger UI
|
|
902
|
-
}
|
|
667
|
+
import {
|
|
668
|
+
createHealthController,
|
|
669
|
+
databaseIndicator,
|
|
670
|
+
memoryIndicator
|
|
671
|
+
} from "adorn-api";
|
|
672
|
+
|
|
673
|
+
const HealthController = createHealthController({
|
|
674
|
+
path: "/health",
|
|
675
|
+
indicators: [
|
|
676
|
+
memoryIndicator({ degradedMB: 512, unhealthyMB: 1024 }),
|
|
677
|
+
databaseIndicator("database", async () => {
|
|
678
|
+
await db.ping();
|
|
679
|
+
})
|
|
680
|
+
]
|
|
903
681
|
});
|
|
904
682
|
```
|
|
905
683
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
The `t` object provides a rich set of schema types:
|
|
909
|
-
|
|
910
|
-
- Primitives: `t.string()`, `t.number()`, `t.integer()`, `t.boolean()`
|
|
911
|
-
- Formats: `t.uuid()`, `t.dateTime()`
|
|
912
|
-
- Complex: `t.array()`, `t.object()`, `t.record()`
|
|
913
|
-
- Combinators: `t.union()`, `t.enum()`, `t.literal()`
|
|
914
|
-
- Special: `t.ref()`, `t.any()`, `t.null()`, `t.file()`
|
|
915
|
-
- Modifiers: `t.optional()`, `t.nullable()`
|
|
916
|
-
|
|
917
|
-
## DTO Composition
|
|
918
|
-
|
|
919
|
-
Reuse and compose DTOs with these decorators:
|
|
684
|
+
### Logging
|
|
920
685
|
|
|
921
686
|
```typescript
|
|
922
|
-
|
|
923
|
-
@PickDto(UserDto, ["id", "name"])
|
|
924
|
-
export class UserSummaryDto {}
|
|
925
|
-
|
|
926
|
-
// Omit specific fields from an existing DTO
|
|
927
|
-
@OmitDto(UserDto, ["password"])
|
|
928
|
-
export class PublicUserDto {}
|
|
929
|
-
|
|
930
|
-
// Make all fields optional
|
|
931
|
-
@PartialDto(UserDto)
|
|
932
|
-
export class UpdateUserDto {}
|
|
933
|
-
|
|
934
|
-
// Merge multiple DTOs
|
|
935
|
-
@MergeDto([UserDto, AddressDto])
|
|
936
|
-
export class UserWithAddressDto {}
|
|
937
|
-
```
|
|
687
|
+
import { createLogger, prettyTransport, requestLogger } from "adorn-api";
|
|
938
688
|
|
|
939
|
-
|
|
689
|
+
const logger = createLogger({
|
|
690
|
+
level: "info",
|
|
691
|
+
transport: prettyTransport
|
|
692
|
+
});
|
|
940
693
|
|
|
941
|
-
|
|
694
|
+
logger.info("Application booted");
|
|
942
695
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
class ErrorController {
|
|
948
|
-
@Get("/error")
|
|
949
|
-
@ReturnsError({
|
|
950
|
-
status: 404,
|
|
951
|
-
schema: t.object({
|
|
952
|
-
code: t.string(),
|
|
953
|
-
message: t.string(),
|
|
954
|
-
details: t.optional(t.record(t.any()))
|
|
955
|
-
}),
|
|
956
|
-
description: "Resource not found"
|
|
957
|
-
})
|
|
958
|
-
async notFound() {
|
|
959
|
-
throw new HttpError(404, "Resource not found", { code: "NOT_FOUND" });
|
|
960
|
-
}
|
|
961
|
-
}
|
|
696
|
+
app.use(requestLogger({
|
|
697
|
+
transport: prettyTransport,
|
|
698
|
+
skip: ["/health/live"]
|
|
699
|
+
}));
|
|
962
700
|
```
|
|
963
701
|
|
|
964
|
-
|
|
702
|
+
### Lifecycle Hooks
|
|
965
703
|
|
|
966
704
|
```typescript
|
|
967
705
|
import {
|
|
968
|
-
|
|
969
|
-
|
|
706
|
+
lifecycleRegistry,
|
|
707
|
+
type OnApplicationBootstrap,
|
|
708
|
+
type OnApplicationShutdown
|
|
970
709
|
} from "adorn-api";
|
|
971
710
|
|
|
972
|
-
class DatabaseService implements OnApplicationBootstrap,
|
|
711
|
+
class DatabaseService implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
973
712
|
async onApplicationBootstrap() {
|
|
974
|
-
|
|
975
|
-
// Initialize database connection
|
|
713
|
+
await db.connect();
|
|
976
714
|
}
|
|
977
715
|
|
|
978
|
-
async
|
|
979
|
-
|
|
980
|
-
// Cleanup resources
|
|
716
|
+
async onApplicationShutdown() {
|
|
717
|
+
await db.close();
|
|
981
718
|
}
|
|
982
719
|
}
|
|
983
720
|
|
|
984
|
-
// Register the service
|
|
985
|
-
import { lifecycleRegistry } from "adorn-api";
|
|
986
721
|
lifecycleRegistry.register(new DatabaseService());
|
|
987
722
|
```
|
|
988
723
|
|
|
989
|
-
|
|
724
|
+
Use `shutdownExpressApp`, `shutdownFastifyApp`, or `shutdownNativeApp` to trigger shutdown hooks and clear the lifecycle registry.
|
|
990
725
|
|
|
991
|
-
|
|
726
|
+
## Metal ORM
|
|
992
727
|
|
|
993
|
-
|
|
728
|
+
Adorn includes optional helpers for Metal ORM projects. They generate DTOs, OpenAPI schemas, filters, pagination, sorting, and CRUD controllers from entity metadata.
|
|
994
729
|
|
|
995
|
-
|
|
996
|
-
createExpressApp({
|
|
997
|
-
controllers: [UserController],
|
|
998
|
-
validation: {
|
|
999
|
-
enabled: true, // Enable validation (default: true)
|
|
1000
|
-
mode: "strict" // Validation mode: "strict" or "safe"
|
|
1001
|
-
}
|
|
1002
|
-
});
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
### Validation Errors
|
|
1006
|
-
|
|
1007
|
-
Invalid requests automatically return structured validation errors:
|
|
730
|
+
### Entity DTOs
|
|
1008
731
|
|
|
1009
732
|
```typescript
|
|
1010
|
-
|
|
1011
|
-
{
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
"
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
733
|
+
import { createMetalCrudDtoClasses, t } from "adorn-api";
|
|
734
|
+
import { User } from "./user.entity";
|
|
735
|
+
|
|
736
|
+
export const userCrudDtos = createMetalCrudDtoClasses(User, {
|
|
737
|
+
mutationExclude: ["id", "createdAt"],
|
|
738
|
+
query: {
|
|
739
|
+
filters: {
|
|
740
|
+
nameContains: {
|
|
741
|
+
schema: t.string({ minLength: 1 }),
|
|
742
|
+
field: "name",
|
|
743
|
+
operator: "contains"
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
sortableColumns: {
|
|
747
|
+
id: "id",
|
|
748
|
+
name: "name",
|
|
749
|
+
createdAt: "createdAt"
|
|
1020
750
|
},
|
|
1021
|
-
{
|
|
1022
|
-
|
|
1023
|
-
"message": "must be a valid email",
|
|
1024
|
-
"value": "invalid-email",
|
|
1025
|
-
"code": "FORMAT_EMAIL"
|
|
751
|
+
options: {
|
|
752
|
+
labelField: "name"
|
|
1026
753
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
### Validation Error Codes
|
|
1032
|
-
|
|
1033
|
-
Adorn API provides machine-readable error codes for programmatic error handling:
|
|
1034
|
-
|
|
1035
|
-
```typescript
|
|
1036
|
-
import { ValidationErrorCode } from "adorn-api";
|
|
754
|
+
},
|
|
755
|
+
errors: true
|
|
756
|
+
});
|
|
1037
757
|
|
|
1038
|
-
|
|
1039
|
-
|
|
758
|
+
export const {
|
|
759
|
+
response: UserDto,
|
|
760
|
+
create: CreateUserDto,
|
|
761
|
+
replace: ReplaceUserDto,
|
|
762
|
+
update: UpdateUserDto,
|
|
763
|
+
params: UserParamsDto,
|
|
764
|
+
queryDto: UserQueryDto,
|
|
765
|
+
optionsQueryDto: UserOptionsQueryDto,
|
|
766
|
+
pagedResponseDto: UserPagedResponseDto,
|
|
767
|
+
optionDto: UserOptionDto,
|
|
768
|
+
optionsDto: UserOptionsDto,
|
|
769
|
+
errors: UserErrors,
|
|
770
|
+
filterMappings: USER_FILTER_MAPPINGS,
|
|
771
|
+
sortableColumns: USER_SORTABLE_COLUMNS,
|
|
772
|
+
listConfig: USER_LIST_CONFIG
|
|
773
|
+
} = userCrudDtos;
|
|
1040
774
|
```
|
|
1041
775
|
|
|
1042
|
-
###
|
|
1043
|
-
|
|
1044
|
-
You can also manually validate data using the `validate` function:
|
|
776
|
+
### Paged Lists
|
|
1045
777
|
|
|
1046
778
|
```typescript
|
|
1047
|
-
import {
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
name: t.string({ minLength: 1 }),
|
|
1052
|
-
email: t.string({ format: "email" })
|
|
1053
|
-
}));
|
|
779
|
+
import { Controller, Get, Query, Returns, runPagedList, type RequestContext } from "adorn-api";
|
|
780
|
+
import { createSession } from "./db";
|
|
781
|
+
import { User } from "./user.entity";
|
|
782
|
+
import { UserPagedResponseDto, UserQueryDto, USER_LIST_CONFIG } from "./user.dtos";
|
|
1054
783
|
|
|
1055
|
-
|
|
1056
|
-
|
|
784
|
+
@Controller("/users")
|
|
785
|
+
class UserController {
|
|
786
|
+
@Get("/")
|
|
787
|
+
@Query(UserQueryDto)
|
|
788
|
+
@Returns(UserPagedResponseDto)
|
|
789
|
+
async list(ctx: RequestContext<unknown, UserQueryDto>) {
|
|
790
|
+
const session = createSession();
|
|
791
|
+
try {
|
|
792
|
+
return await runPagedList({
|
|
793
|
+
query: (ctx.query ?? {}) as Record<string, unknown>,
|
|
794
|
+
target: User,
|
|
795
|
+
qb: () => User.select(),
|
|
796
|
+
session,
|
|
797
|
+
...USER_LIST_CONFIG
|
|
798
|
+
});
|
|
799
|
+
} finally {
|
|
800
|
+
await session.dispose();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
1057
803
|
}
|
|
1058
804
|
```
|
|
1059
805
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
Transform fields during serialization with custom transform functions or built-in transform utilities.
|
|
1063
|
-
|
|
1064
|
-
### Basic Transform
|
|
806
|
+
### CRUD Controller Factory
|
|
1065
807
|
|
|
1066
808
|
```typescript
|
|
1067
|
-
import {
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
export class UserDto {
|
|
1071
|
-
@Field(t.string())
|
|
1072
|
-
@Transform((value) => value.toUpperCase())
|
|
1073
|
-
name!: string;
|
|
809
|
+
import { createCrudController } from "adorn-api";
|
|
810
|
+
import { userCrudDtos } from "./user.dtos";
|
|
811
|
+
import { UserCrudService } from "./user.service";
|
|
1074
812
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
813
|
+
export const UserController = createCrudController({
|
|
814
|
+
path: "/users",
|
|
815
|
+
service: new UserCrudService(),
|
|
816
|
+
dtos: userCrudDtos,
|
|
817
|
+
entityName: "User",
|
|
818
|
+
withOptionsRoute: true,
|
|
819
|
+
withReplace: true,
|
|
820
|
+
withPatch: true,
|
|
821
|
+
withDelete: true
|
|
822
|
+
});
|
|
1079
823
|
```
|
|
1080
824
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
Adorn API includes common transform functions:
|
|
1084
|
-
|
|
1085
|
-
```typescript
|
|
1086
|
-
import { Dto, Field, Transform, Transforms, t } from "adorn-api";
|
|
1087
|
-
|
|
1088
|
-
@Dto()
|
|
1089
|
-
export class UserDto {
|
|
1090
|
-
@Field(t.string())
|
|
1091
|
-
@Transform(Transforms.toLowerCase)
|
|
1092
|
-
email!: string;
|
|
1093
|
-
|
|
1094
|
-
@Field(t.number())
|
|
1095
|
-
@Transform(Transforms.round(2))
|
|
1096
|
-
price!: number;
|
|
1097
|
-
|
|
1098
|
-
@Field(t.string())
|
|
1099
|
-
@Transform(Transforms.mask(4)) // Mask all but last 4 characters
|
|
1100
|
-
creditCard!: string;
|
|
825
|
+
Generated routes:
|
|
1101
826
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
827
|
+
- `GET /`
|
|
828
|
+
- `GET /options` when `withOptionsRoute` is true
|
|
829
|
+
- `GET /:id`
|
|
830
|
+
- `POST /`
|
|
831
|
+
- `PUT /:id` when `withReplace` is true
|
|
832
|
+
- `PATCH /:id` when `withPatch` is true
|
|
833
|
+
- `DELETE /:id` when `withDelete` is true
|
|
1107
834
|
|
|
1108
|
-
###
|
|
835
|
+
### Filters and Sort
|
|
1109
836
|
|
|
1110
|
-
|
|
837
|
+
Use generated `filterMappings`, `sortableColumns`, and `listConfig` where possible. Manual parsers are also public:
|
|
1111
838
|
|
|
1112
839
|
```typescript
|
|
1113
|
-
import {
|
|
1114
|
-
|
|
1115
|
-
@Dto()
|
|
1116
|
-
export class UserDto {
|
|
1117
|
-
@Field(t.string())
|
|
1118
|
-
name!: string;
|
|
1119
|
-
|
|
1120
|
-
@Field(t.string())
|
|
1121
|
-
@Expose({ groups: ["admin"] })
|
|
1122
|
-
@Transform((value) => Transforms.mask(2), { groups: ["admin"] })
|
|
1123
|
-
phoneNumber!: string;
|
|
840
|
+
import { parseFilter, parsePagination, parseSort } from "adorn-api";
|
|
1124
841
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
internalNote!: string;
|
|
1129
|
-
}
|
|
842
|
+
const pagination = parsePagination(ctx.query);
|
|
843
|
+
const filters = parseFilter(ctx.query, USER_FILTER_MAPPINGS);
|
|
844
|
+
const sort = parseSort(ctx.query, USER_SORTABLE_COLUMNS);
|
|
1130
845
|
```
|
|
1131
846
|
|
|
1132
|
-
|
|
847
|
+
`parseSort` accepts `sortDirection=asc|desc` and legacy `sortOrder=ASC|DESC`. `sortDirection` wins when both are present.
|
|
1133
848
|
|
|
1134
|
-
|
|
849
|
+
Deep relation filters are supported through typed Metal ORM field paths such as:
|
|
1135
850
|
|
|
1136
851
|
```typescript
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
}).format(value);
|
|
1144
|
-
};
|
|
1145
|
-
|
|
1146
|
-
@Dto()
|
|
1147
|
-
export class ProductDto {
|
|
1148
|
-
@Field(t.string())
|
|
1149
|
-
name!: string;
|
|
1150
|
-
|
|
1151
|
-
@Field(t.number())
|
|
1152
|
-
@Transform(toCurrency)
|
|
1153
|
-
price!: number;
|
|
1154
|
-
|
|
1155
|
-
@Field(t.number())
|
|
1156
|
-
@Transform((value) => toCurrency(value, "EUR"))
|
|
1157
|
-
priceEUR!: number;
|
|
1158
|
-
}
|
|
852
|
+
const filters = {
|
|
853
|
+
deltaNameContains: {
|
|
854
|
+
field: "bravos.some.charlies.some.delta.some.name",
|
|
855
|
+
operator: "contains"
|
|
856
|
+
}
|
|
857
|
+
} as const;
|
|
1159
858
|
```
|
|
1160
859
|
|
|
1161
|
-
###
|
|
1162
|
-
|
|
1163
|
-
Control serialization with custom options:
|
|
860
|
+
### Tree DTOs
|
|
1164
861
|
|
|
1165
862
|
```typescript
|
|
1166
|
-
import {
|
|
1167
|
-
import {
|
|
1168
|
-
|
|
1169
|
-
const user = new UserDto();
|
|
1170
|
-
user.name = "John Doe";
|
|
1171
|
-
user.phoneNumber = "123-456-7890";
|
|
1172
|
-
user.internalNote = "This is an internal note";
|
|
1173
|
-
|
|
1174
|
-
// Basic serialization
|
|
1175
|
-
const basic = serialize(user);
|
|
1176
|
-
// Output: { name: "John Doe" }
|
|
1177
|
-
|
|
1178
|
-
// Admin group serialization
|
|
1179
|
-
const admin = serialize(user, { groups: ["admin"] });
|
|
1180
|
-
// Output: { name: "John Doe", phoneNumber: "********90" }
|
|
1181
|
-
|
|
1182
|
-
// External group serialization
|
|
1183
|
-
const external = serialize(user, { groups: ["external"] });
|
|
1184
|
-
// Output: { name: "John Doe", internalNote: "[REDACTED]" }
|
|
863
|
+
import { createMetalTreeDtoClasses } from "adorn-api";
|
|
864
|
+
import { CategoryDto } from "./category.dtos";
|
|
865
|
+
import { Category } from "./category.entity";
|
|
1185
866
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
867
|
+
export const {
|
|
868
|
+
node: CategoryNodeDto,
|
|
869
|
+
nodeResult: CategoryNodeResultDto,
|
|
870
|
+
threadedNode: CategoryThreadedNodeDto,
|
|
871
|
+
treeListEntry: CategoryTreeListEntryDto,
|
|
872
|
+
treeListSchema: CategoryTreeListSchema,
|
|
873
|
+
threadedTreeSchema: CategoryThreadedTreeSchema
|
|
874
|
+
} = createMetalTreeDtoClasses(Category, {
|
|
875
|
+
entityDto: CategoryDto
|
|
876
|
+
});
|
|
1189
877
|
```
|
|
1190
878
|
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
Control which fields are excluded or exposed:
|
|
879
|
+
## Examples
|
|
1194
880
|
|
|
1195
|
-
|
|
1196
|
-
import { Dto, Field, Exclude, Expose, t } from "adorn-api";
|
|
881
|
+
Run examples with:
|
|
1197
882
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
883
|
+
```bash
|
|
884
|
+
npm run example -- <name>
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
Available examples:
|
|
888
|
+
|
|
889
|
+
- `basic`: Express API with DTOs and OpenAPI
|
|
890
|
+
- `bearer-auth-swagger`: Bearer token auth in Swagger UI
|
|
891
|
+
- `fastify`: Fastify adapter
|
|
892
|
+
- `openapi`: build and print an OpenAPI document
|
|
893
|
+
- `restful`: in-memory REST CRUD
|
|
894
|
+
- `streaming`: SSE and streaming routes
|
|
895
|
+
- `validation`: schema validation examples
|
|
896
|
+
- `metal-orm`: baseline Metal ORM example
|
|
897
|
+
- `metal-orm-collection-lawsuit`: collection/relation scenario with Metal ORM
|
|
898
|
+
- `metal-orm-sqlite`: Metal ORM with SQLite
|
|
899
|
+
- `metal-orm-postgres`: Metal ORM with Postgres
|
|
900
|
+
- `metal-orm-sqlite-music`: richer Metal ORM relations
|
|
901
|
+
- `metal-orm-deep-filters`: nested relation filters
|
|
902
|
+
- `metal-orm-tree`: tree DTO generation
|
|
1202
903
|
|
|
1203
|
-
|
|
1204
|
-
@Exclude() // Always exclude from serialization
|
|
1205
|
-
password!: string;
|
|
904
|
+
## Testing
|
|
1206
905
|
|
|
1207
|
-
|
|
1208
|
-
@Expose({ name: "email_address" }) // Rename field in output
|
|
1209
|
-
email!: string;
|
|
906
|
+
The project uses Vitest and SuperTest.
|
|
1210
907
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
908
|
+
```bash
|
|
909
|
+
npm run build
|
|
910
|
+
npm test
|
|
911
|
+
npm run typecheck:tests
|
|
1215
912
|
```
|
|
1216
913
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
Check out the `examples/` directory for more comprehensive examples:
|
|
1220
|
-
|
|
1221
|
-
- `basic/` - Simple API with controllers and DTOs
|
|
1222
|
-
- `restful/` - RESTful API with complete CRUD operations
|
|
1223
|
-
- `metal-orm-sqlite/` - Metal ORM integration with SQLite
|
|
1224
|
-
- `metal-orm-tree/` - Metal ORM tree (nested set) DTO + OpenAPI integration
|
|
1225
|
-
- `metal-orm-deep-filters/` - Deep relation filtering example (Alpha → Bravo → Charlie → Delta)
|
|
1226
|
-
- `metal-orm-sqlite-music/` - Complex relations with Metal ORM
|
|
1227
|
-
- `streaming/` - SSE and streaming responses
|
|
1228
|
-
- `openapi/` - OpenAPI documentation customization
|
|
1229
|
-
- `validation/` - Comprehensive validation examples with various schema types
|
|
1230
|
-
|
|
1231
|
-
## Testing
|
|
1232
|
-
|
|
1233
|
-
Adorn API works great with testing frameworks like Vitest and SuperTest. Here's an example:
|
|
914
|
+
Example app test:
|
|
1234
915
|
|
|
1235
916
|
```typescript
|
|
1236
|
-
import { describe,
|
|
917
|
+
import { describe, expect, it } from "vitest";
|
|
1237
918
|
import request from "supertest";
|
|
1238
919
|
import { createApp } from "./app";
|
|
1239
920
|
|
|
1240
|
-
describe("
|
|
1241
|
-
it("
|
|
921
|
+
describe("Users API", () => {
|
|
922
|
+
it("gets a user", async () => {
|
|
1242
923
|
const app = await createApp();
|
|
1243
|
-
|
|
924
|
+
|
|
1244
925
|
const response = await request(app)
|
|
1245
|
-
.get("/users/
|
|
926
|
+
.get("/users/3f0f4d0f-1cb1-4cf1-9c32-3d4bce1b3f36")
|
|
1246
927
|
.expect(200);
|
|
1247
|
-
|
|
1248
|
-
expect(response.body).
|
|
1249
|
-
id: "1",
|
|
1250
|
-
name: "Ada Lovelace",
|
|
1251
|
-
nickname: "Ada"
|
|
1252
|
-
});
|
|
928
|
+
|
|
929
|
+
expect(response.body.name).toBe("Ada Lovelace");
|
|
1253
930
|
});
|
|
1254
931
|
});
|
|
1255
932
|
```
|
|
1256
933
|
|
|
1257
|
-
##
|
|
934
|
+
## Public Entry Points
|
|
1258
935
|
|
|
1259
|
-
|
|
936
|
+
The package exports:
|
|
1260
937
|
|
|
1261
|
-
|
|
938
|
+
- Core decorators, schemas, OpenAPI, errors, responses, validation, coercion, serialization, auth, lifecycle, streaming, health, and logger helpers
|
|
939
|
+
- Express adapter: `createExpressApp`, `attachExpressControllers`, `attachExpressOpenApi`, `shutdownExpressApp`
|
|
940
|
+
- Fastify adapter: `createFastifyApp`, `attachFastifyControllers`, `attachFastifyOpenApi`, `shutdownFastifyApp`
|
|
941
|
+
- Native adapter: `createNativeApp`, `attachNativeControllers`, `attachNativeOpenApi`, `shutdownNativeApp`
|
|
942
|
+
- Metal ORM helpers from `adorn-api`
|
|
1262
943
|
|
|
1263
|
-
|
|
944
|
+
## License
|
|
945
|
+
|
|
946
|
+
MIT
|