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.
Files changed (36) hide show
  1. package/README.md +613 -930
  2. package/dist/adapter/express/controllers.d.ts +3 -1
  3. package/dist/adapter/express/controllers.js +4 -1
  4. package/dist/adapter/express/index.js +5 -1
  5. package/dist/adapter/express/types.d.ts +3 -0
  6. package/dist/adapter/fastify/controllers.d.ts +3 -1
  7. package/dist/adapter/fastify/controllers.js +2 -25
  8. package/dist/adapter/fastify/index.js +7 -1
  9. package/dist/adapter/fastify/types.d.ts +3 -0
  10. package/dist/adapter/native/controllers.d.ts +3 -0
  11. package/dist/adapter/native/controllers.js +2 -25
  12. package/dist/adapter/native/index.js +14 -1
  13. package/dist/adapter/native/types.d.ts +3 -0
  14. package/dist/core/auth.d.ts +33 -3
  15. package/dist/core/auth.js +74 -22
  16. package/dist/core/openapi.d.ts +2 -0
  17. package/dist/core/openapi.js +19 -1
  18. package/examples/bearer-auth-swagger/app.ts +28 -0
  19. package/examples/bearer-auth-swagger/auth.controller.ts +45 -0
  20. package/examples/bearer-auth-swagger/index.ts +20 -0
  21. package/examples/bearer-auth-swagger/session.dtos.ts +19 -0
  22. package/package.json +1 -1
  23. package/src/adapter/express/controllers.ts +23 -18
  24. package/src/adapter/express/index.ts +12 -1
  25. package/src/adapter/express/types.ts +13 -10
  26. package/src/adapter/fastify/controllers.ts +16 -41
  27. package/src/adapter/fastify/index.ts +27 -13
  28. package/src/adapter/fastify/types.ts +13 -10
  29. package/src/adapter/native/controllers.ts +16 -41
  30. package/src/adapter/native/index.ts +28 -15
  31. package/src/adapter/native/types.ts +13 -10
  32. package/src/core/auth.ts +134 -56
  33. package/src/core/openapi.ts +22 -1
  34. package/tests/e2e/bearer-auth.e2e.test.ts +158 -0
  35. package/tests/unit/auth.test.ts +96 -12
  36. package/tests/unit/openapi-parameters.test.ts +54 -6
package/README.md CHANGED
@@ -1,23 +1,24 @@
1
1
  # Adorn API
2
2
 
3
- A modern, decorator-first web framework built on Express with built-in OpenAPI 3.1 schema generation, designed for rapid API development with excellent Type safety and developer experience.
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
- - **Decorator-First API Definition**: Define controllers and DTOs with intuitive decorators
8
- - 📚 **Automatic OpenAPI 3.1 Generation**: API documentation is generated from your code
9
- - 🔌 **Express Integration**: Built on top of Express for familiarity and extensibility
10
- - 🎯 **Type-Safe Data Transfer Objects**: Define schemas with TypeScript for compile-time checks
11
- - 🔄 **DTO Composition**: Reuse and compose DTOs with PickDto, OmitDto, PartialDto, and MergeDto
12
- - 📦 **Metal ORM Integration**: First-class support for Metal ORM with auto-generated CRUD DTOs, transformer-aware schema generation, and tree DTOs for nested set (MPTT) models
13
- - 🚀 **Streaming Support**: Server-Sent Events (SSE) and streaming responses
14
- - 🔧 **Raw Responses**: Return binary data, files, and non-JSON content with the `@Raw` decorator
15
- - 📝 **Request Validation**: Automatic validation of request bodies, params, query, and headers
16
- - 🔧 **Transformers**: Custom field transformations with @Transform decorator and built-in transform functions
17
- - **Error Handling**: Structured error responses with error DTO support
18
- - 💾 **File Uploads**: Easy handling of file uploads with multipart form data
19
- - 🌐 **CORS Support**: Built-in CORS configuration
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
- Note: Adorn uses Stage 3 decorator metadata (`Symbol.metadata`). If the runtime does not provide it, Adorn polyfills `Symbol.metadata` on import to keep decorator metadata consistent.
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
- ### 1. Define DTOs
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 record returned by the API." })
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
- ### 2. Create a Controller
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
- ### 3. Bootstrap the Application
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
- export async function createApp() {
106
- return createExpressApp({
122
+ async function start() {
123
+ const app = await createExpressApp({
107
124
  controllers: [UserController],
108
125
  openApi: {
109
126
  info: {
110
- title: "Adorn API",
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
- async function start() {
122
- const app = await createApp();
123
- const PORT = 3000;
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("Failed to start server:", error);
141
+ start().catch((error) => {
142
+ console.error(error);
133
143
  process.exit(1);
134
144
  });
135
145
  ```
136
146
 
137
- ## Core Concepts
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
- ```typescript
144
- @Controller("/api/v1/users")
145
- export class UserController {
146
- // Routes go here
147
- }
149
+ ```bash
150
+ npm run example -- basic
148
151
  ```
149
152
 
150
- ### Routes
153
+ ## Adapters
151
154
 
152
- Routes are methods decorated with HTTP verb decorators like `@Get()`, `@Post()`, `@Put()`, `@Patch()`, or `@Delete()`.
155
+ ### Express
153
156
 
154
157
  ```typescript
155
- @Get("/:id")
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
- ### DTOs (Data Transfer Objects)
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
- DTOs define the shape of data sent to and from your API. They provide validation, documentation, and type safety.
183
+ ### Fastify
166
184
 
167
185
  ```typescript
168
- @Dto({ description: "User data" })
169
- export class UserDto {
170
- @Field(t.uuid({ description: "Unique identifier" }))
171
- id!: string;
186
+ import { createFastifyApp } from "adorn-api";
172
187
 
173
- @Field(t.string({ minLength: 2, maxLength: 100 }))
174
- name!: string;
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
- ### Stage 3 Decorator Metadata
203
+ ### Native Node HTTP
179
204
 
180
- Adorn relies on Stage 3 decorator metadata (`Symbol.metadata`) to connect information across decorators (DTO fields, routes, params, etc.). If the runtime does not provide it, Adorn polyfills `Symbol.metadata` on import so decorators share a consistent metadata object.
205
+ ```typescript
206
+ import { createNativeApp } from "adorn-api";
181
207
 
182
- ### Request Context
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
- Each route handler receives a `RequestContext` object that provides access to:
185
- - `ctx.body` - The request body (validated and typed)
186
- - `ctx.params` - Route parameters
187
- - `ctx.query` - Query parameters
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
- ## Advanced Features
222
+ ## Request Context
195
223
 
196
- ### Server-Sent Events (SSE)
224
+ Every handler receives a `RequestContext`:
197
225
 
198
226
  ```typescript
199
- import { Controller, Get, Sse } from "adorn-api";
200
-
201
- @Controller("/events")
202
- class EventsController {
203
- @Get("/")
204
- @Sse({ description: "Real-time events stream" })
205
- async streamEvents(ctx: any) {
206
- const emitter = ctx.sse;
207
-
208
- let count = 0;
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
- ### Streaming Responses
240
+ Use adapter-specific aliases when useful:
232
241
 
233
242
  ```typescript
234
- import { Controller, Get, Streaming } from "adorn-api";
243
+ import type {
244
+ ExpressRequestContext,
245
+ FastifyRequestContext,
246
+ NativeRequestContext
247
+ } from "adorn-api";
248
+ ```
235
249
 
236
- @Controller("/streaming")
237
- class StreamingController {
238
- @Get("/")
239
- @Streaming({ contentType: "text/plain" })
240
- async streamText(ctx: any) {
241
- const writer = ctx.stream;
242
- const data = ["First line", "Second line", "Third line"];
243
-
244
- for (let i = 0; i < data.length; i++) {
245
- writer.writeLine(data[i]);
246
- await new Promise(resolve => setTimeout(resolve, 500));
247
- }
248
-
249
- writer.close();
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
- ### Raw Responses
269
+ Available route decorators:
255
270
 
256
- Use the `@Raw()` decorator to return binary data (files, images, PDFs, etc.) without JSON serialization. The response body is sent with `res.send()` instead of `res.json()`.
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
- ```typescript
259
- import { Controller, Get, Raw, Params, ok, type RequestContext } from "adorn-api";
260
- import fs from "fs/promises";
278
+ ### HTTP Responses
261
279
 
262
- @Controller("/files")
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
- @Get("/avatar/:id")
272
- @Raw({ contentType: "image/png" })
273
- async getAvatar(ctx: RequestContext) {
274
- const image = await fs.readFile(`avatars/${ctx.params.id}.png`);
275
- return image;
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
- You can also set custom headers (e.g. `Content-Disposition`) via `HttpResponse`:
291
+ @Delete("/:id")
292
+ @Returns({ status: 204 })
293
+ async remove() {
294
+ return noContent();
295
+ }
281
296
 
282
- ```typescript
283
- import { HttpResponse } from "adorn-api";
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
- ### File Uploads
303
+ Throw structured HTTP errors:
296
304
 
297
305
  ```typescript
298
- import { Controller, Post, UploadedFile, Returns, t } from "adorn-api";
306
+ import { badRequest, forbidden, notFound, unauthorized } from "adorn-api";
299
307
 
300
- @Controller("/uploads")
301
- class UploadController {
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
- ## Metal ORM Integration
313
+ Available helpers: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `unprocessableEntity`, `tooManyRequests`, `serviceUnavailable`, and `internalServerError`.
313
314
 
314
- Adorn API has first-class support for Metal ORM, providing automatic CRUD DTO generation.
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
- ### 1. Define Entities
317
+ ### Schema Builder
318
+
319
+ The `t` builder creates runtime validation and OpenAPI schemas:
318
320
 
319
321
  ```typescript
320
- // user.entity.ts
321
- import { Entity, PrimaryKey, Property } from "metal-orm";
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
- @Entity("users")
324
- export class User {
325
- @PrimaryKey()
326
- id!: number;
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
- @Property()
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
- @Property({ nullable: true })
332
- nickname?: string;
355
+ @Field(t.string())
356
+ passwordHash!: string;
333
357
  }
334
- ```
335
358
 
336
- ### 2. Generate CRUD DTOs
359
+ @PickDto(UserDto, ["id", "name"])
360
+ class PublicUserDto {}
337
361
 
338
- ```typescript
339
- // user.dtos.ts
340
- import { createMetalCrudDtoClasses, t } from "adorn-api";
341
- import { User } from "./user.entity";
362
+ @OmitDto(UserDto, ["id", "passwordHash"])
363
+ class CreateUserDto {}
342
364
 
343
- export const {
344
- response: UserDto,
345
- create: CreateUserDto,
346
- replace: ReplaceUserDto,
347
- update: UpdateUserDto,
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
- ### 3. Create a CRUD Controller
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
- Post,
394
- Put,
395
- Patch,
396
- Delete,
397
- Params,
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
- @Controller("/users")
423
- export class UserController {
424
- @Get("/")
425
- @Query(UserQueryDto)
426
- @Returns(UserPagedResponseDto)
427
- async list(ctx: RequestContext<unknown, UserQueryDto>) {
428
- const session = createSession();
391
+ @Auth()
392
+ @Controller("/account")
393
+ class AccountController {
394
+ @Get("/health")
395
+ @Public()
396
+ health() {
397
+ return { ok: true };
398
+ }
429
399
 
430
- try {
431
- return await runPagedList({
432
- query: (ctx.query ?? {}) as Record<string, unknown>,
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("/options")
446
- @Query(UserOptionsQueryDto)
447
- @Returns(UserOptionsDto)
448
- async options(ctx: RequestContext<unknown, UserOptionsQueryDto>) {
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
- @Get("/:id")
465
- @Params({ id: t.integer() })
466
- @Returns(UserDto)
467
- @UserErrors
468
- async getOne(ctx: RequestContext<unknown, undefined, { id: string }>) {
469
- const session = createSession();
470
-
471
- try {
472
- const user = await session.find(User, parseInt(ctx.params.id));
473
- return user;
474
- } finally {
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
- ### Migration Guide (Breaking)
428
+ Bearer auth reads only:
484
429
 
485
- ### CRUD Controller Factory (`createCrudController`)
430
+ ```text
431
+ Authorization: Bearer <token>
432
+ ```
486
433
 
487
- When your controller only wires DTOs + service calls, you can generate the full CRUD controller and remove decorator boilerplate.
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
- ```typescript
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
- export const UserController = createCrudController({
496
- path: "/users",
497
- service: UserCrudService, // class or instance
498
- dtos: userCrudDtos, // result of createMetalCrudDtoClasses(...)
499
- entityName: "User", // used by parseIdOrThrow messages
500
- withOptionsRoute: true,
501
- withReplace: true,
502
- withPatch: true,
503
- withDelete: true
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
- Generated routes:
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
- The factory applies the correct `@Query/@Body/@Params/@Returns` schemas and also propagates `dtos.errors` to all `/:id` routes.
453
+ Try the Swagger auth example:
517
454
 
518
- Before (manual, repeated decorators/status/schema wiring):
455
+ ```bash
456
+ npm run example -- bearer-auth-swagger
457
+ ```
519
458
 
520
- ```typescript
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
- @Get("/:id")
529
- @Params(UserParamsDto)
530
- @Returns(UserDto)
531
- @UserErrors
532
- async getById(ctx: RequestContext<unknown, undefined, UserParamsDto>) { ... }
461
+ ## OpenAPI and Swagger UI
533
462
 
534
- @Post("/")
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
- // put/patch/delete/options...
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
- After (factory + service):
485
+ You can also build the document without starting an HTTP server:
544
486
 
545
487
  ```typescript
546
- export const UserController = createCrudController({
547
- path: "/users",
548
- service: new UserCrudService(),
549
- dtos: userCrudDtos,
550
- entityName: "User"
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
- When to use factory vs manual controller:
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
- Before (duplicated config):
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
- const UserQueryDto = createPagedFilterQueryDtoClass({
563
- filters: {
564
- nameContains: { schema: t.string(), operator: "contains" }
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
- After (single source of truth):
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
- const {
577
- queryDto: UserQueryDto,
578
- filterMappings: USER_FILTER_MAPPINGS
579
- } = createMetalCrudDtoClasses(User, {
580
- query: {
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
- Breaking changes summary:
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
- ### Using `listConfig` (Zero-Duplication Service Layer)
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
- `createMetalCrudDtoClasses` now exposes a `listConfig` object that bundles all filter/sort/pagination config needed by your service layer. No more re-declaring mappings in your repository:
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
- // user.controller.ts using listConfig directly
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
- @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`.
641
-
642
- ### Type-Only Query Interfaces
549
+ @Dto()
550
+ class UserDto {
551
+ @Field(t.string())
552
+ id!: string;
643
553
 
644
- For consumer projects that need pure TypeScript interfaces (without creating extra DTO classes), Adorn exports:
645
- - `PaginationQueryParams`
646
- - `SortingQueryParams`
647
- - `PagedQueryParams`
554
+ @Field(t.string())
555
+ @Transform(Transforms.toLowerCase)
556
+ email!: string;
648
557
 
649
- ```typescript
650
- import type { PagedQueryParams } from "adorn-api";
558
+ @Field(t.string())
559
+ @Exclude()
560
+ passwordHash!: string;
651
561
 
652
- interface CaixaEntradaQueryDto extends PagedQueryParams {
653
- usuarioId?: number;
654
- lido?: boolean;
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
- `sortDirection` is the official typed sort field (`"asc" | "desc"`). `sortOrder` remains parser compatibility for legacy/external clients.
570
+ Use `createSerializer({ groups: [...] })` when you need reusable serialization presets.
659
571
 
660
- ### `BaseService.list` Before/After (Boilerplate Reduction)
572
+ ## File Uploads
661
573
 
662
- Before:
574
+ Enable multipart on the adapter and declare file fields on the route:
663
575
 
664
576
  ```typescript
665
- async list(query: Record<string, unknown>) {
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
- After:
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
- ```typescript
687
- async list(query: Record<string, unknown>) {
688
- return withSession(this.createSession, async (session) =>
689
- runPagedList({
690
- query,
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
- ```typescript
712
- // Client sends: ?sortBy=name&sortOrder=DESC
713
- const sort = parseSort(query, sortableColumns);
714
- // → { sortBy: "name", sortDirection: "desc", field: "name" }
715
-
716
- // Client sends both: ?sortBy=name&sortDirection=asc&sortOrder=DESC
717
- const sort2 = parseSort(query, sortableColumns);
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
- ### Deep Relation Filters
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
- @Entity({ tableName: "alphas" })
740
- export class Alpha {
741
- @PrimaryKey(col.autoIncrement(col.int()))
742
- id!: number;
611
+ ## Raw, SSE, and Streaming
743
612
 
744
- @Column(col.notNull(col.text()))
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
- @Entity({ tableName: "deltas" })
791
- export class Delta {
792
- @PrimaryKey(col.autoIncrement(col.int()))
793
- id!: number;
615
+ ```typescript
616
+ import { Controller, Get, Raw, ok } from "adorn-api";
617
+ import fs from "node:fs/promises";
794
618
 
795
- @Column(col.notNull(col.text()))
796
- name!: string;
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
- // alpha.controller.ts (filtering)
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
- const ALPHA_FILTERS = {
807
- deltaNameContains: {
808
- field: "bravos.some.charlies.some.delta.some.name",
809
- operator: "contains"
810
- },
811
- deltaIsMissing: {
812
- field: "bravos.some.charlies.some.delta",
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
- } as const satisfies Record<string, FilterMapping<Alpha>>;
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
- Example query string: `?deltaNameContains=core&charlieScoreGte=90`
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
- // category.dtos.ts
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
- ```typescript
859
- // category.controller.ts
860
- import { Controller, Get, Returns } from "adorn-api";
861
- import { CategoryThreadedTreeSchema } from "./category.dtos";
862
-
863
- @Controller("/categories")
864
- class CategoryController {
865
- @Get("/tree")
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
- ## Configuration
662
+ ## Health, Logging, and Lifecycle
874
663
 
875
- ### Express App Options
664
+ ### Health Checks
876
665
 
877
666
  ```typescript
878
- createExpressApp({
879
- // Required
880
- controllers: [UserController],
881
-
882
- // Optional
883
- cors: true, // Enable CORS with default options or configure
884
- jsonBody: true, // Parse JSON bodies (default: true)
885
- inputCoercion: "safe", // Input coercion mode ("safe" or "strict")
886
- validation: { // Validation configuration
887
- enabled: true, // Enable validation (default: true)
888
- mode: "strict" // Validation mode: "strict" or "safe"
889
- },
890
- multipart: { // File upload configuration
891
- dest: "./uploads",
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
- ## Schema Types
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
- // Pick specific fields from an existing DTO
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
- ## Error Handling
689
+ const logger = createLogger({
690
+ level: "info",
691
+ transport: prettyTransport
692
+ });
940
693
 
941
- Define structured error responses:
694
+ logger.info("Application booted");
942
695
 
943
- ```typescript
944
- import { Controller, Get, ReturnsError, t } from "adorn-api";
945
-
946
- @Controller("/")
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
- ## Lifecycle Hooks
702
+ ### Lifecycle Hooks
965
703
 
966
704
  ```typescript
967
705
  import {
968
- OnApplicationBootstrap,
969
- OnShutdown
706
+ lifecycleRegistry,
707
+ type OnApplicationBootstrap,
708
+ type OnApplicationShutdown
970
709
  } from "adorn-api";
971
710
 
972
- class DatabaseService implements OnApplicationBootstrap, OnShutdown {
711
+ class DatabaseService implements OnApplicationBootstrap, OnApplicationShutdown {
973
712
  async onApplicationBootstrap() {
974
- console.log("Connecting to database...");
975
- // Initialize database connection
713
+ await db.connect();
976
714
  }
977
715
 
978
- async onShutdown(signal?: string) {
979
- console.log(`Shutting down (${signal})...`);
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
- ## Validation
724
+ Use `shutdownExpressApp`, `shutdownFastifyApp`, or `shutdownNativeApp` to trigger shutdown hooks and clear the lifecycle registry.
990
725
 
991
- Adorn API provides automatic request validation and a comprehensive validation system for your DTOs and schemas.
726
+ ## Metal ORM
992
727
 
993
- ### Validation Configuration
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
- ```typescript
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
- // Example error response
1011
- {
1012
- "statusCode": 400,
1013
- "message": "Validation failed",
1014
- "errors": [
1015
- {
1016
- "field": "name",
1017
- "message": "must be at least 1 character long",
1018
- "value": "",
1019
- "code": "STRING_MIN_LENGTH"
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
- "field": "email",
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
- console.log(ValidationErrorCode.FORMAT_EMAIL); // "FORMAT_EMAIL"
1039
- console.log(ValidationErrorCode.STRING_MIN_LENGTH); // "STRING_MIN_LENGTH"
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
- ### Manual Validation
1043
-
1044
- You can also manually validate data using the `validate` function:
776
+ ### Paged Lists
1045
777
 
1046
778
  ```typescript
1047
- import { validate, ValidationErrors, t } from "adorn-api";
1048
-
1049
- const data = { name: "", email: "invalid" };
1050
- const errors = validate(data, t.object({
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
- if (errors.length > 0) {
1056
- throw new ValidationErrors(errors);
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
- ## Transformers
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 { Dto, Field, Transform, t } from "adorn-api";
1068
-
1069
- @Dto()
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
- @Field(t.dateTime())
1076
- @Transform((value) => value.toISOString())
1077
- createdAt!: Date;
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
- ### Built-in Transforms
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
- @Field(t.dateTime())
1103
- @Transform(Transforms.toISOString)
1104
- birthDate!: Date;
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
- ### Conditional Transforms with Groups
835
+ ### Filters and Sort
1109
836
 
1110
- Apply transforms only to specific serialization groups:
837
+ Use generated `filterMappings`, `sortableColumns`, and `listConfig` where possible. Manual parsers are also public:
1111
838
 
1112
839
  ```typescript
1113
- import { Dto, Field, Transform, Expose, t } from "adorn-api";
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
- @Field(t.string())
1126
- @Expose({ groups: ["internal"] })
1127
- @Transform((value) => "[REDACTED]", { groups: ["external"] })
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
- ### Custom Transform Functions
847
+ `parseSort` accepts `sortDirection=asc|desc` and legacy `sortOrder=ASC|DESC`. `sortDirection` wins when both are present.
1133
848
 
1134
- Create custom transform functions:
849
+ Deep relation filters are supported through typed Metal ORM field paths such as:
1135
850
 
1136
851
  ```typescript
1137
- import { Dto, Field, Transform, t } from "adorn-api";
1138
-
1139
- const toCurrency = (value: number, currency: string = "USD") => {
1140
- return new Intl.NumberFormat("en-US", {
1141
- style: "currency",
1142
- currency
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
- ### Serialization with Options
1162
-
1163
- Control serialization with custom options:
860
+ ### Tree DTOs
1164
861
 
1165
862
  ```typescript
1166
- import { serialize, createSerializer } from "adorn-api";
1167
- import { UserDto } from "./user.dtos";
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
- // Create a preset serializer
1187
- const adminSerializer = createSerializer({ groups: ["admin"] });
1188
- const adminData = adminSerializer(user);
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
- ### Exclude Fields
1192
-
1193
- Control which fields are excluded or exposed:
879
+ ## Examples
1194
880
 
1195
- ```typescript
1196
- import { Dto, Field, Exclude, Expose, t } from "adorn-api";
881
+ Run examples with:
1197
882
 
1198
- @Dto()
1199
- export class UserDto {
1200
- @Field(t.string())
1201
- name!: string;
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
- @Field(t.string())
1204
- @Exclude() // Always exclude from serialization
1205
- password!: string;
904
+ ## Testing
1206
905
 
1207
- @Field(t.string())
1208
- @Expose({ name: "email_address" }) // Rename field in output
1209
- email!: string;
906
+ The project uses Vitest and SuperTest.
1210
907
 
1211
- @Field(t.string())
1212
- @Exclude({ groups: ["public"] }) // Exclude from public group
1213
- internalComment!: string;
1214
- }
908
+ ```bash
909
+ npm run build
910
+ npm test
911
+ npm run typecheck:tests
1215
912
  ```
1216
913
 
1217
- ## Examples
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, it, expect } from "vitest";
917
+ import { describe, expect, it } from "vitest";
1237
918
  import request from "supertest";
1238
919
  import { createApp } from "./app";
1239
920
 
1240
- describe("User API", () => {
1241
- it("should get user by id", async () => {
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/1")
926
+ .get("/users/3f0f4d0f-1cb1-4cf1-9c32-3d4bce1b3f36")
1246
927
  .expect(200);
1247
-
1248
- expect(response.body).toEqual({
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
- ## License
934
+ ## Public Entry Points
1258
935
 
1259
- MIT
936
+ The package exports:
1260
937
 
1261
- ## Contributing
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
- Contributions are welcome! Please feel free to submit a Pull Request.
944
+ ## License
945
+
946
+ MIT