adorn-api 1.1.12 → 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 +614 -913
- 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/metal-orm/index.d.ts +1 -1
- package/dist/adapter/metal-orm/types.d.ts +23 -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 +3 -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/metal-orm/index.ts +3 -0
- package/src/adapter/metal-orm/types.ts +25 -0
- 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/typecheck/query-params.typecheck.ts +42 -0
- package/tests/unit/auth.test.ts +96 -12
- package/tests/unit/openapi-parameters.test.ts +54 -6
- package/tsconfig.typecheck.json +8 -0
- package/vitest.config.ts +47 -7
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,1151 +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";
|
|
617
|
-
|
|
618
|
-
@Controller("/users")
|
|
619
|
-
export class UserController {
|
|
620
|
-
@Get("/")
|
|
621
|
-
@Query(UserQueryDto)
|
|
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`.
|
|
547
|
+
import { Dto, Exclude, Expose, Field, Transform, Transforms, serialize, t } from "adorn-api";
|
|
641
548
|
|
|
642
|
-
|
|
549
|
+
@Dto()
|
|
550
|
+
class UserDto {
|
|
551
|
+
@Field(t.string())
|
|
552
|
+
id!: string;
|
|
643
553
|
|
|
644
|
-
|
|
554
|
+
@Field(t.string())
|
|
555
|
+
@Transform(Transforms.toLowerCase)
|
|
556
|
+
email!: string;
|
|
645
557
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const filters = parseFilter(query, this.listConfig.filterMappings);
|
|
650
|
-
const sort = parseSort(query, this.listConfig.sortableColumns, {
|
|
651
|
-
defaultSortBy: this.listConfig.defaultSortBy,
|
|
652
|
-
defaultSortDirection: this.listConfig.defaultSortDirection,
|
|
653
|
-
sortByKey: this.listConfig.sortByKey,
|
|
654
|
-
sortDirectionKey: this.listConfig.sortDirectionKey
|
|
655
|
-
});
|
|
656
|
-
const direction = sort?.sortDirection === "desc" ? "DESC" : "ASC";
|
|
558
|
+
@Field(t.string())
|
|
559
|
+
@Exclude()
|
|
560
|
+
passwordHash!: string;
|
|
657
561
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
return toPagedResponse(paged);
|
|
662
|
-
});
|
|
562
|
+
@Field(t.string())
|
|
563
|
+
@Expose({ name: "display_name" })
|
|
564
|
+
name!: string;
|
|
663
565
|
}
|
|
664
|
-
```
|
|
665
566
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
```typescript
|
|
669
|
-
async list(query: Record<string, unknown>) {
|
|
670
|
-
return withSession(this.createSession, async (session) =>
|
|
671
|
-
runPagedList({
|
|
672
|
-
query,
|
|
673
|
-
target: this.entity,
|
|
674
|
-
qb: () => this.baseQuery(),
|
|
675
|
-
session,
|
|
676
|
-
...this.listConfig
|
|
677
|
-
})
|
|
678
|
-
);
|
|
679
|
-
}
|
|
567
|
+
const output = serialize(user);
|
|
680
568
|
```
|
|
681
569
|
|
|
682
|
-
|
|
683
|
-
- Existing `parsePagination`, `parseFilter`, and `parseSort` remain unchanged and can still be used manually.
|
|
684
|
-
- `runPagedList`/`executeCrudList` is additive and optional; no breaking API changes.
|
|
685
|
-
- For sortable fields that are not direct columns of the base table, pass `allowedSortColumns` with explicit metal-orm sort terms.
|
|
686
|
-
|
|
687
|
-
### Sort Order Compatibility (`sortOrder` / `sortDirection`)
|
|
688
|
-
|
|
689
|
-
`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.
|
|
690
|
-
|
|
691
|
-
**Precedence**: `sortDirection` > `sortOrder` > default. Direction values are case-insensitive.
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
// Client sends: ?sortBy=name&sortOrder=DESC
|
|
695
|
-
const sort = parseSort(query, sortableColumns);
|
|
696
|
-
// → { sortBy: "name", sortDirection: "desc", field: "name" }
|
|
697
|
-
|
|
698
|
-
// Client sends both: ?sortBy=name&sortDirection=asc&sortOrder=DESC
|
|
699
|
-
const sort2 = parseSort(query, sortableColumns);
|
|
700
|
-
// → { sortBy: "name", sortDirection: "asc", field: "name" } (sortDirection wins)
|
|
701
|
-
|
|
702
|
-
// Custom sortOrder key:
|
|
703
|
-
const sort3 = parseSort({
|
|
704
|
-
query,
|
|
705
|
-
sortableColumns,
|
|
706
|
-
sortOrderKey: "order" // reads from query.order instead of query.sortOrder
|
|
707
|
-
});
|
|
708
|
-
```
|
|
570
|
+
Use `createSerializer({ groups: [...] })` when you need reusable serialization presets.
|
|
709
571
|
|
|
710
|
-
|
|
572
|
+
## File Uploads
|
|
711
573
|
|
|
712
|
-
|
|
713
|
-
you type your filter mappings with `FilterMapping`, VS Code will enforce relation quantifiers like `some`, `every`, or
|
|
714
|
-
`none` for relation filters, matching Metal ORM's runtime rules.
|
|
574
|
+
Enable multipart on the adapter and declare file fields on the route:
|
|
715
575
|
|
|
716
576
|
```typescript
|
|
717
|
-
|
|
718
|
-
import { BelongsTo, Column, Entity, HasMany, PrimaryKey, col } from "metal-orm";
|
|
719
|
-
import type { BelongsToReference, HasManyCollection } from "metal-orm";
|
|
720
|
-
|
|
721
|
-
@Entity({ tableName: "alphas" })
|
|
722
|
-
export class Alpha {
|
|
723
|
-
@PrimaryKey(col.autoIncrement(col.int()))
|
|
724
|
-
id!: number;
|
|
725
|
-
|
|
726
|
-
@Column(col.notNull(col.text()))
|
|
727
|
-
name!: string;
|
|
728
|
-
|
|
729
|
-
@HasMany({ target: () => Bravo, foreignKey: "alphaId" })
|
|
730
|
-
bravos!: HasManyCollection<Bravo>;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
@Entity({ tableName: "bravos" })
|
|
734
|
-
export class Bravo {
|
|
735
|
-
@PrimaryKey(col.autoIncrement(col.int()))
|
|
736
|
-
id!: number;
|
|
737
|
-
|
|
738
|
-
@Column(col.notNull(col.text()))
|
|
739
|
-
code!: string;
|
|
577
|
+
import { Controller, Post, Returns, UploadedFile, UploadedFiles, t } from "adorn-api";
|
|
740
578
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
@
|
|
745
|
-
|
|
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
|
+
}
|
|
746
591
|
|
|
747
|
-
@
|
|
748
|
-
|
|
592
|
+
@Post("/gallery")
|
|
593
|
+
@UploadedFiles("files", t.file({ accept: ["image/*"] }))
|
|
594
|
+
async gallery(ctx: any) {
|
|
595
|
+
return { count: ctx.files.files.length };
|
|
596
|
+
}
|
|
749
597
|
}
|
|
750
598
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
bravoId!: number;
|
|
599
|
+
await createExpressApp({
|
|
600
|
+
controllers: [UploadController],
|
|
601
|
+
multipart: {
|
|
602
|
+
storage: "memory",
|
|
603
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
604
|
+
maxFiles: 10
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
```
|
|
761
608
|
|
|
762
|
-
|
|
763
|
-
deltaId?: number | null;
|
|
609
|
+
Uploaded file info contains `originalName`, `mimeType`, `size`, `buffer`, `path`, and `fieldName`.
|
|
764
610
|
|
|
765
|
-
|
|
766
|
-
bravo!: BelongsToReference<Bravo>;
|
|
611
|
+
## Raw, SSE, and Streaming
|
|
767
612
|
|
|
768
|
-
|
|
769
|
-
delta?: BelongsToReference<Delta>;
|
|
770
|
-
}
|
|
613
|
+
### Raw Responses
|
|
771
614
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
id!: number;
|
|
615
|
+
```typescript
|
|
616
|
+
import { Controller, Get, Raw, ok } from "adorn-api";
|
|
617
|
+
import fs from "node:fs/promises";
|
|
776
618
|
|
|
777
|
-
|
|
778
|
-
|
|
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
|
+
}
|
|
779
626
|
}
|
|
780
627
|
```
|
|
781
628
|
|
|
629
|
+
### Server-Sent Events
|
|
630
|
+
|
|
782
631
|
```typescript
|
|
783
|
-
|
|
784
|
-
import { parseFilter, type FilterMapping } from "adorn-api";
|
|
785
|
-
import { applyFilter, selectFromEntity, type WhereInput } from "metal-orm";
|
|
786
|
-
import { Alpha } from "./alpha.entity";
|
|
632
|
+
import { Controller, Get, Sse } from "adorn-api";
|
|
787
633
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
operator: "isEmpty"
|
|
796
|
-
},
|
|
797
|
-
charlieScoreGte: {
|
|
798
|
-
field: "bravos.some.charlies.some.score",
|
|
799
|
-
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();
|
|
800
641
|
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const filters = parseFilter(
|
|
804
|
-
(ctx.query ?? {}) as Record<string, unknown>,
|
|
805
|
-
ALPHA_FILTERS
|
|
806
|
-
);
|
|
807
|
-
|
|
808
|
-
const query = applyFilter(
|
|
809
|
-
selectFromEntity(Alpha),
|
|
810
|
-
Alpha,
|
|
811
|
-
filters as WhereInput<typeof Alpha>
|
|
812
|
-
);
|
|
642
|
+
}
|
|
813
643
|
```
|
|
814
644
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
### Tree DTOs (Nested Set / MPTT)
|
|
818
|
-
|
|
819
|
-
Metal ORM's tree helpers map cleanly into Adorn. Use `createMetalTreeDtoClasses` to generate DTOs for tree nodes,
|
|
820
|
-
node results, threaded trees, and tree lists. These schemas are included in OpenAPI automatically.
|
|
645
|
+
### Streaming
|
|
821
646
|
|
|
822
647
|
```typescript
|
|
823
|
-
|
|
824
|
-
import { createMetalTreeDtoClasses } from "adorn-api";
|
|
825
|
-
import { CategoryDto } from "./category.dtos";
|
|
826
|
-
import { Category } from "./category.entity";
|
|
827
|
-
|
|
828
|
-
export const {
|
|
829
|
-
node: CategoryNodeDto,
|
|
830
|
-
nodeResult: CategoryNodeResultDto,
|
|
831
|
-
threadedNode: CategoryThreadedNodeDto,
|
|
832
|
-
treeListEntry: CategoryTreeListEntryDto,
|
|
833
|
-
treeListSchema: CategoryTreeListSchema,
|
|
834
|
-
threadedTreeSchema: CategoryThreadedTreeSchema
|
|
835
|
-
} = createMetalTreeDtoClasses(Category, {
|
|
836
|
-
entityDto: CategoryDto
|
|
837
|
-
});
|
|
838
|
-
```
|
|
648
|
+
import { Controller, Get, Streaming } from "adorn-api";
|
|
839
649
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
@Returns(CategoryThreadedTreeSchema)
|
|
849
|
-
async tree() {
|
|
850
|
-
// 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();
|
|
851
658
|
}
|
|
852
659
|
}
|
|
853
660
|
```
|
|
854
661
|
|
|
855
|
-
##
|
|
662
|
+
## Health, Logging, and Lifecycle
|
|
856
663
|
|
|
857
|
-
###
|
|
664
|
+
### Health Checks
|
|
858
665
|
|
|
859
666
|
```typescript
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
limits: { fileSize: 50 * 1024 * 1024 }
|
|
875
|
-
},
|
|
876
|
-
openApi: {
|
|
877
|
-
info: {
|
|
878
|
-
title: "My API",
|
|
879
|
-
version: "1.0.0",
|
|
880
|
-
description: "API documentation"
|
|
881
|
-
},
|
|
882
|
-
path: "/openapi.json", // OpenAPI schema endpoint
|
|
883
|
-
docs: true // Serve Swagger UI
|
|
884
|
-
}
|
|
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
|
+
]
|
|
885
681
|
});
|
|
886
682
|
```
|
|
887
683
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
The `t` object provides a rich set of schema types:
|
|
891
|
-
|
|
892
|
-
- Primitives: `t.string()`, `t.number()`, `t.integer()`, `t.boolean()`
|
|
893
|
-
- Formats: `t.uuid()`, `t.dateTime()`
|
|
894
|
-
- Complex: `t.array()`, `t.object()`, `t.record()`
|
|
895
|
-
- Combinators: `t.union()`, `t.enum()`, `t.literal()`
|
|
896
|
-
- Special: `t.ref()`, `t.any()`, `t.null()`, `t.file()`
|
|
897
|
-
- Modifiers: `t.optional()`, `t.nullable()`
|
|
898
|
-
|
|
899
|
-
## DTO Composition
|
|
900
|
-
|
|
901
|
-
Reuse and compose DTOs with these decorators:
|
|
684
|
+
### Logging
|
|
902
685
|
|
|
903
686
|
```typescript
|
|
904
|
-
|
|
905
|
-
@PickDto(UserDto, ["id", "name"])
|
|
906
|
-
export class UserSummaryDto {}
|
|
907
|
-
|
|
908
|
-
// Omit specific fields from an existing DTO
|
|
909
|
-
@OmitDto(UserDto, ["password"])
|
|
910
|
-
export class PublicUserDto {}
|
|
911
|
-
|
|
912
|
-
// Make all fields optional
|
|
913
|
-
@PartialDto(UserDto)
|
|
914
|
-
export class UpdateUserDto {}
|
|
687
|
+
import { createLogger, prettyTransport, requestLogger } from "adorn-api";
|
|
915
688
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
## Error Handling
|
|
689
|
+
const logger = createLogger({
|
|
690
|
+
level: "info",
|
|
691
|
+
transport: prettyTransport
|
|
692
|
+
});
|
|
922
693
|
|
|
923
|
-
|
|
694
|
+
logger.info("Application booted");
|
|
924
695
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
class ErrorController {
|
|
930
|
-
@Get("/error")
|
|
931
|
-
@ReturnsError({
|
|
932
|
-
status: 404,
|
|
933
|
-
schema: t.object({
|
|
934
|
-
code: t.string(),
|
|
935
|
-
message: t.string(),
|
|
936
|
-
details: t.optional(t.record(t.any()))
|
|
937
|
-
}),
|
|
938
|
-
description: "Resource not found"
|
|
939
|
-
})
|
|
940
|
-
async notFound() {
|
|
941
|
-
throw new HttpError(404, "Resource not found", { code: "NOT_FOUND" });
|
|
942
|
-
}
|
|
943
|
-
}
|
|
696
|
+
app.use(requestLogger({
|
|
697
|
+
transport: prettyTransport,
|
|
698
|
+
skip: ["/health/live"]
|
|
699
|
+
}));
|
|
944
700
|
```
|
|
945
701
|
|
|
946
|
-
|
|
702
|
+
### Lifecycle Hooks
|
|
947
703
|
|
|
948
704
|
```typescript
|
|
949
705
|
import {
|
|
950
|
-
|
|
951
|
-
|
|
706
|
+
lifecycleRegistry,
|
|
707
|
+
type OnApplicationBootstrap,
|
|
708
|
+
type OnApplicationShutdown
|
|
952
709
|
} from "adorn-api";
|
|
953
710
|
|
|
954
|
-
class DatabaseService implements OnApplicationBootstrap,
|
|
711
|
+
class DatabaseService implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
955
712
|
async onApplicationBootstrap() {
|
|
956
|
-
|
|
957
|
-
// Initialize database connection
|
|
713
|
+
await db.connect();
|
|
958
714
|
}
|
|
959
715
|
|
|
960
|
-
async
|
|
961
|
-
|
|
962
|
-
// Cleanup resources
|
|
716
|
+
async onApplicationShutdown() {
|
|
717
|
+
await db.close();
|
|
963
718
|
}
|
|
964
719
|
}
|
|
965
720
|
|
|
966
|
-
// Register the service
|
|
967
|
-
import { lifecycleRegistry } from "adorn-api";
|
|
968
721
|
lifecycleRegistry.register(new DatabaseService());
|
|
969
722
|
```
|
|
970
723
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
Adorn API provides automatic request validation and a comprehensive validation system for your DTOs and schemas.
|
|
724
|
+
Use `shutdownExpressApp`, `shutdownFastifyApp`, or `shutdownNativeApp` to trigger shutdown hooks and clear the lifecycle registry.
|
|
974
725
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
```typescript
|
|
978
|
-
createExpressApp({
|
|
979
|
-
controllers: [UserController],
|
|
980
|
-
validation: {
|
|
981
|
-
enabled: true, // Enable validation (default: true)
|
|
982
|
-
mode: "strict" // Validation mode: "strict" or "safe"
|
|
983
|
-
}
|
|
984
|
-
});
|
|
985
|
-
```
|
|
726
|
+
## Metal ORM
|
|
986
727
|
|
|
987
|
-
|
|
728
|
+
Adorn includes optional helpers for Metal ORM projects. They generate DTOs, OpenAPI schemas, filters, pagination, sorting, and CRUD controllers from entity metadata.
|
|
988
729
|
|
|
989
|
-
|
|
730
|
+
### Entity DTOs
|
|
990
731
|
|
|
991
732
|
```typescript
|
|
992
|
-
|
|
993
|
-
{
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
"
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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"
|
|
1002
750
|
},
|
|
1003
|
-
{
|
|
1004
|
-
|
|
1005
|
-
"message": "must be a valid email",
|
|
1006
|
-
"value": "invalid-email",
|
|
1007
|
-
"code": "FORMAT_EMAIL"
|
|
751
|
+
options: {
|
|
752
|
+
labelField: "name"
|
|
1008
753
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
### Validation Error Codes
|
|
1014
|
-
|
|
1015
|
-
Adorn API provides machine-readable error codes for programmatic error handling:
|
|
1016
|
-
|
|
1017
|
-
```typescript
|
|
1018
|
-
import { ValidationErrorCode } from "adorn-api";
|
|
754
|
+
},
|
|
755
|
+
errors: true
|
|
756
|
+
});
|
|
1019
757
|
|
|
1020
|
-
|
|
1021
|
-
|
|
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;
|
|
1022
774
|
```
|
|
1023
775
|
|
|
1024
|
-
###
|
|
1025
|
-
|
|
1026
|
-
You can also manually validate data using the `validate` function:
|
|
776
|
+
### Paged Lists
|
|
1027
777
|
|
|
1028
778
|
```typescript
|
|
1029
|
-
import {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
name: t.string({ minLength: 1 }),
|
|
1034
|
-
email: t.string({ format: "email" })
|
|
1035
|
-
}));
|
|
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";
|
|
1036
783
|
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
+
}
|
|
1039
803
|
}
|
|
1040
804
|
```
|
|
1041
805
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
Transform fields during serialization with custom transform functions or built-in transform utilities.
|
|
1045
|
-
|
|
1046
|
-
### Basic Transform
|
|
806
|
+
### CRUD Controller Factory
|
|
1047
807
|
|
|
1048
808
|
```typescript
|
|
1049
|
-
import {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
export class UserDto {
|
|
1053
|
-
@Field(t.string())
|
|
1054
|
-
@Transform((value) => value.toUpperCase())
|
|
1055
|
-
name!: string;
|
|
809
|
+
import { createCrudController } from "adorn-api";
|
|
810
|
+
import { userCrudDtos } from "./user.dtos";
|
|
811
|
+
import { UserCrudService } from "./user.service";
|
|
1056
812
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
+
});
|
|
1061
823
|
```
|
|
1062
824
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
Adorn API includes common transform functions:
|
|
1066
|
-
|
|
1067
|
-
```typescript
|
|
1068
|
-
import { Dto, Field, Transform, Transforms, t } from "adorn-api";
|
|
1069
|
-
|
|
1070
|
-
@Dto()
|
|
1071
|
-
export class UserDto {
|
|
1072
|
-
@Field(t.string())
|
|
1073
|
-
@Transform(Transforms.toLowerCase)
|
|
1074
|
-
email!: string;
|
|
1075
|
-
|
|
1076
|
-
@Field(t.number())
|
|
1077
|
-
@Transform(Transforms.round(2))
|
|
1078
|
-
price!: number;
|
|
1079
|
-
|
|
1080
|
-
@Field(t.string())
|
|
1081
|
-
@Transform(Transforms.mask(4)) // Mask all but last 4 characters
|
|
1082
|
-
creditCard!: string;
|
|
825
|
+
Generated routes:
|
|
1083
826
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1089
834
|
|
|
1090
|
-
###
|
|
835
|
+
### Filters and Sort
|
|
1091
836
|
|
|
1092
|
-
|
|
837
|
+
Use generated `filterMappings`, `sortableColumns`, and `listConfig` where possible. Manual parsers are also public:
|
|
1093
838
|
|
|
1094
839
|
```typescript
|
|
1095
|
-
import {
|
|
1096
|
-
|
|
1097
|
-
@Dto()
|
|
1098
|
-
export class UserDto {
|
|
1099
|
-
@Field(t.string())
|
|
1100
|
-
name!: string;
|
|
1101
|
-
|
|
1102
|
-
@Field(t.string())
|
|
1103
|
-
@Expose({ groups: ["admin"] })
|
|
1104
|
-
@Transform((value) => Transforms.mask(2), { groups: ["admin"] })
|
|
1105
|
-
phoneNumber!: string;
|
|
840
|
+
import { parseFilter, parsePagination, parseSort } from "adorn-api";
|
|
1106
841
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
internalNote!: string;
|
|
1111
|
-
}
|
|
842
|
+
const pagination = parsePagination(ctx.query);
|
|
843
|
+
const filters = parseFilter(ctx.query, USER_FILTER_MAPPINGS);
|
|
844
|
+
const sort = parseSort(ctx.query, USER_SORTABLE_COLUMNS);
|
|
1112
845
|
```
|
|
1113
846
|
|
|
1114
|
-
|
|
847
|
+
`parseSort` accepts `sortDirection=asc|desc` and legacy `sortOrder=ASC|DESC`. `sortDirection` wins when both are present.
|
|
1115
848
|
|
|
1116
|
-
|
|
849
|
+
Deep relation filters are supported through typed Metal ORM field paths such as:
|
|
1117
850
|
|
|
1118
851
|
```typescript
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
}).format(value);
|
|
1126
|
-
};
|
|
1127
|
-
|
|
1128
|
-
@Dto()
|
|
1129
|
-
export class ProductDto {
|
|
1130
|
-
@Field(t.string())
|
|
1131
|
-
name!: string;
|
|
1132
|
-
|
|
1133
|
-
@Field(t.number())
|
|
1134
|
-
@Transform(toCurrency)
|
|
1135
|
-
price!: number;
|
|
1136
|
-
|
|
1137
|
-
@Field(t.number())
|
|
1138
|
-
@Transform((value) => toCurrency(value, "EUR"))
|
|
1139
|
-
priceEUR!: number;
|
|
1140
|
-
}
|
|
852
|
+
const filters = {
|
|
853
|
+
deltaNameContains: {
|
|
854
|
+
field: "bravos.some.charlies.some.delta.some.name",
|
|
855
|
+
operator: "contains"
|
|
856
|
+
}
|
|
857
|
+
} as const;
|
|
1141
858
|
```
|
|
1142
859
|
|
|
1143
|
-
###
|
|
1144
|
-
|
|
1145
|
-
Control serialization with custom options:
|
|
860
|
+
### Tree DTOs
|
|
1146
861
|
|
|
1147
862
|
```typescript
|
|
1148
|
-
import {
|
|
1149
|
-
import {
|
|
1150
|
-
|
|
1151
|
-
const user = new UserDto();
|
|
1152
|
-
user.name = "John Doe";
|
|
1153
|
-
user.phoneNumber = "123-456-7890";
|
|
1154
|
-
user.internalNote = "This is an internal note";
|
|
1155
|
-
|
|
1156
|
-
// Basic serialization
|
|
1157
|
-
const basic = serialize(user);
|
|
1158
|
-
// Output: { name: "John Doe" }
|
|
1159
|
-
|
|
1160
|
-
// Admin group serialization
|
|
1161
|
-
const admin = serialize(user, { groups: ["admin"] });
|
|
1162
|
-
// Output: { name: "John Doe", phoneNumber: "********90" }
|
|
1163
|
-
|
|
1164
|
-
// External group serialization
|
|
1165
|
-
const external = serialize(user, { groups: ["external"] });
|
|
1166
|
-
// Output: { name: "John Doe", internalNote: "[REDACTED]" }
|
|
863
|
+
import { createMetalTreeDtoClasses } from "adorn-api";
|
|
864
|
+
import { CategoryDto } from "./category.dtos";
|
|
865
|
+
import { Category } from "./category.entity";
|
|
1167
866
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
+
});
|
|
1171
877
|
```
|
|
1172
878
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
Control which fields are excluded or exposed:
|
|
879
|
+
## Examples
|
|
1176
880
|
|
|
1177
|
-
|
|
1178
|
-
import { Dto, Field, Exclude, Expose, t } from "adorn-api";
|
|
881
|
+
Run examples with:
|
|
1179
882
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
|
1184
903
|
|
|
1185
|
-
|
|
1186
|
-
@Exclude() // Always exclude from serialization
|
|
1187
|
-
password!: string;
|
|
904
|
+
## Testing
|
|
1188
905
|
|
|
1189
|
-
|
|
1190
|
-
@Expose({ name: "email_address" }) // Rename field in output
|
|
1191
|
-
email!: string;
|
|
906
|
+
The project uses Vitest and SuperTest.
|
|
1192
907
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
908
|
+
```bash
|
|
909
|
+
npm run build
|
|
910
|
+
npm test
|
|
911
|
+
npm run typecheck:tests
|
|
1197
912
|
```
|
|
1198
913
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
Check out the `examples/` directory for more comprehensive examples:
|
|
1202
|
-
|
|
1203
|
-
- `basic/` - Simple API with controllers and DTOs
|
|
1204
|
-
- `restful/` - RESTful API with complete CRUD operations
|
|
1205
|
-
- `metal-orm-sqlite/` - Metal ORM integration with SQLite
|
|
1206
|
-
- `metal-orm-tree/` - Metal ORM tree (nested set) DTO + OpenAPI integration
|
|
1207
|
-
- `metal-orm-deep-filters/` - Deep relation filtering example (Alpha → Bravo → Charlie → Delta)
|
|
1208
|
-
- `metal-orm-sqlite-music/` - Complex relations with Metal ORM
|
|
1209
|
-
- `streaming/` - SSE and streaming responses
|
|
1210
|
-
- `openapi/` - OpenAPI documentation customization
|
|
1211
|
-
- `validation/` - Comprehensive validation examples with various schema types
|
|
1212
|
-
|
|
1213
|
-
## Testing
|
|
1214
|
-
|
|
1215
|
-
Adorn API works great with testing frameworks like Vitest and SuperTest. Here's an example:
|
|
914
|
+
Example app test:
|
|
1216
915
|
|
|
1217
916
|
```typescript
|
|
1218
|
-
import { describe,
|
|
917
|
+
import { describe, expect, it } from "vitest";
|
|
1219
918
|
import request from "supertest";
|
|
1220
919
|
import { createApp } from "./app";
|
|
1221
920
|
|
|
1222
|
-
describe("
|
|
1223
|
-
it("
|
|
921
|
+
describe("Users API", () => {
|
|
922
|
+
it("gets a user", async () => {
|
|
1224
923
|
const app = await createApp();
|
|
1225
|
-
|
|
924
|
+
|
|
1226
925
|
const response = await request(app)
|
|
1227
|
-
.get("/users/
|
|
926
|
+
.get("/users/3f0f4d0f-1cb1-4cf1-9c32-3d4bce1b3f36")
|
|
1228
927
|
.expect(200);
|
|
1229
|
-
|
|
1230
|
-
expect(response.body).
|
|
1231
|
-
id: "1",
|
|
1232
|
-
name: "Ada Lovelace",
|
|
1233
|
-
nickname: "Ada"
|
|
1234
|
-
});
|
|
928
|
+
|
|
929
|
+
expect(response.body.name).toBe("Ada Lovelace");
|
|
1235
930
|
});
|
|
1236
931
|
});
|
|
1237
932
|
```
|
|
1238
933
|
|
|
1239
|
-
##
|
|
934
|
+
## Public Entry Points
|
|
1240
935
|
|
|
1241
|
-
|
|
936
|
+
The package exports:
|
|
1242
937
|
|
|
1243
|
-
|
|
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`
|
|
1244
943
|
|
|
1245
|
-
|
|
944
|
+
## License
|
|
945
|
+
|
|
946
|
+
MIT
|