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.
Files changed (43) hide show
  1. package/README.md +614 -913
  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/metal-orm/index.d.ts +1 -1
  11. package/dist/adapter/metal-orm/types.d.ts +23 -0
  12. package/dist/adapter/native/controllers.d.ts +3 -0
  13. package/dist/adapter/native/controllers.js +2 -25
  14. package/dist/adapter/native/index.js +14 -1
  15. package/dist/adapter/native/types.d.ts +3 -0
  16. package/dist/core/auth.d.ts +33 -3
  17. package/dist/core/auth.js +74 -22
  18. package/dist/core/openapi.d.ts +2 -0
  19. package/dist/core/openapi.js +19 -1
  20. package/examples/bearer-auth-swagger/app.ts +28 -0
  21. package/examples/bearer-auth-swagger/auth.controller.ts +45 -0
  22. package/examples/bearer-auth-swagger/index.ts +20 -0
  23. package/examples/bearer-auth-swagger/session.dtos.ts +19 -0
  24. package/package.json +3 -1
  25. package/src/adapter/express/controllers.ts +23 -18
  26. package/src/adapter/express/index.ts +12 -1
  27. package/src/adapter/express/types.ts +13 -10
  28. package/src/adapter/fastify/controllers.ts +16 -41
  29. package/src/adapter/fastify/index.ts +27 -13
  30. package/src/adapter/fastify/types.ts +13 -10
  31. package/src/adapter/metal-orm/index.ts +3 -0
  32. package/src/adapter/metal-orm/types.ts +25 -0
  33. package/src/adapter/native/controllers.ts +16 -41
  34. package/src/adapter/native/index.ts +28 -15
  35. package/src/adapter/native/types.ts +13 -10
  36. package/src/core/auth.ts +134 -56
  37. package/src/core/openapi.ts +22 -1
  38. package/tests/e2e/bearer-auth.e2e.test.ts +158 -0
  39. package/tests/typecheck/query-params.typecheck.ts +42 -0
  40. package/tests/unit/auth.test.ts +96 -12
  41. package/tests/unit/openapi-parameters.test.ts +54 -6
  42. package/tsconfig.typecheck.json +8 -0
  43. package/vitest.config.ts +47 -7
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,1151 +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";
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
- ### `BaseService.list` Before/After (Boilerplate Reduction)
549
+ @Dto()
550
+ class UserDto {
551
+ @Field(t.string())
552
+ id!: string;
643
553
 
644
- Before:
554
+ @Field(t.string())
555
+ @Transform(Transforms.toLowerCase)
556
+ email!: string;
645
557
 
646
- ```typescript
647
- async list(query: Record<string, unknown>) {
648
- const { page, pageSize } = parsePagination(query, this.listConfig);
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
- return withSession(this.createSession, async (session) => {
659
- const qb = applyFilter(this.baseQuery().orderBy(this.ref.id, direction), this.entity, filters);
660
- const paged = await qb.executePaged(session, { page, pageSize });
661
- return toPagedResponse(paged);
662
- });
562
+ @Field(t.string())
563
+ @Expose({ name: "display_name" })
564
+ name!: string;
663
565
  }
664
- ```
665
566
 
666
- After:
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
- Migration note:
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
- ### Deep Relation Filters
572
+ ## File Uploads
711
573
 
712
- `parseFilter` now accepts nested relation paths, so you can filter deep chains like Alpha → Bravo → Charlie → Delta. If
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
- // alpha.entity.ts
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
- @Column(col.notNull(col.int()))
742
- alphaId!: number;
743
-
744
- @BelongsTo({ target: () => Alpha, foreignKey: "alphaId" })
745
- alpha!: BelongsToReference<Alpha>;
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
- @HasMany({ target: () => Charlie, foreignKey: "bravoId" })
748
- charlies!: HasManyCollection<Charlie>;
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
- @Entity({ tableName: "charlies" })
752
- export class Charlie {
753
- @PrimaryKey(col.autoIncrement(col.int()))
754
- id!: number;
755
-
756
- @Column(col.notNull(col.int()))
757
- score!: number;
758
-
759
- @Column(col.notNull(col.int()))
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
- @Column(col.int())
763
- deltaId?: number | null;
609
+ Uploaded file info contains `originalName`, `mimeType`, `size`, `buffer`, `path`, and `fieldName`.
764
610
 
765
- @BelongsTo({ target: () => Bravo, foreignKey: "bravoId" })
766
- bravo!: BelongsToReference<Bravo>;
611
+ ## Raw, SSE, and Streaming
767
612
 
768
- @BelongsTo({ target: () => Delta, foreignKey: "deltaId" })
769
- delta?: BelongsToReference<Delta>;
770
- }
613
+ ### Raw Responses
771
614
 
772
- @Entity({ tableName: "deltas" })
773
- export class Delta {
774
- @PrimaryKey(col.autoIncrement(col.int()))
775
- id!: number;
615
+ ```typescript
616
+ import { Controller, Get, Raw, ok } from "adorn-api";
617
+ import fs from "node:fs/promises";
776
618
 
777
- @Column(col.notNull(col.text()))
778
- 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
+ }
779
626
  }
780
627
  ```
781
628
 
629
+ ### Server-Sent Events
630
+
782
631
  ```typescript
783
- // alpha.controller.ts (filtering)
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
- const ALPHA_FILTERS = {
789
- deltaNameContains: {
790
- field: "bravos.some.charlies.some.delta.some.name",
791
- operator: "contains"
792
- },
793
- deltaIsMissing: {
794
- field: "bravos.some.charlies.some.delta",
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
- } as const satisfies Record<string, FilterMapping<Alpha>>;
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
- Example query string: `?deltaNameContains=core&charlieScoreGte=90`
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
- // category.dtos.ts
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
- ```typescript
841
- // category.controller.ts
842
- import { Controller, Get, Returns } from "adorn-api";
843
- import { CategoryThreadedTreeSchema } from "./category.dtos";
844
-
845
- @Controller("/categories")
846
- class CategoryController {
847
- @Get("/tree")
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
- ## Configuration
662
+ ## Health, Logging, and Lifecycle
856
663
 
857
- ### Express App Options
664
+ ### Health Checks
858
665
 
859
666
  ```typescript
860
- createExpressApp({
861
- // Required
862
- controllers: [UserController],
863
-
864
- // Optional
865
- cors: true, // Enable CORS with default options or configure
866
- jsonBody: true, // Parse JSON bodies (default: true)
867
- inputCoercion: "safe", // Input coercion mode ("safe" or "strict")
868
- validation: { // Validation configuration
869
- enabled: true, // Enable validation (default: true)
870
- mode: "strict" // Validation mode: "strict" or "safe"
871
- },
872
- multipart: { // File upload configuration
873
- dest: "./uploads",
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
- ## Schema Types
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
- // Pick specific fields from an existing DTO
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
- // Merge multiple DTOs
917
- @MergeDto([UserDto, AddressDto])
918
- export class UserWithAddressDto {}
919
- ```
920
-
921
- ## Error Handling
689
+ const logger = createLogger({
690
+ level: "info",
691
+ transport: prettyTransport
692
+ });
922
693
 
923
- Define structured error responses:
694
+ logger.info("Application booted");
924
695
 
925
- ```typescript
926
- import { Controller, Get, ReturnsError, t } from "adorn-api";
927
-
928
- @Controller("/")
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
- ## Lifecycle Hooks
702
+ ### Lifecycle Hooks
947
703
 
948
704
  ```typescript
949
705
  import {
950
- OnApplicationBootstrap,
951
- OnShutdown
706
+ lifecycleRegistry,
707
+ type OnApplicationBootstrap,
708
+ type OnApplicationShutdown
952
709
  } from "adorn-api";
953
710
 
954
- class DatabaseService implements OnApplicationBootstrap, OnShutdown {
711
+ class DatabaseService implements OnApplicationBootstrap, OnApplicationShutdown {
955
712
  async onApplicationBootstrap() {
956
- console.log("Connecting to database...");
957
- // Initialize database connection
713
+ await db.connect();
958
714
  }
959
715
 
960
- async onShutdown(signal?: string) {
961
- console.log(`Shutting down (${signal})...`);
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
- ## Validation
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
- ### Validation Configuration
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
- ### Validation Errors
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
- Invalid requests automatically return structured validation errors:
730
+ ### Entity DTOs
990
731
 
991
732
  ```typescript
992
- // Example error response
993
- {
994
- "statusCode": 400,
995
- "message": "Validation failed",
996
- "errors": [
997
- {
998
- "field": "name",
999
- "message": "must be at least 1 character long",
1000
- "value": "",
1001
- "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"
1002
750
  },
1003
- {
1004
- "field": "email",
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
- console.log(ValidationErrorCode.FORMAT_EMAIL); // "FORMAT_EMAIL"
1021
- 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;
1022
774
  ```
1023
775
 
1024
- ### Manual Validation
1025
-
1026
- You can also manually validate data using the `validate` function:
776
+ ### Paged Lists
1027
777
 
1028
778
  ```typescript
1029
- import { validate, ValidationErrors, t } from "adorn-api";
1030
-
1031
- const data = { name: "", email: "invalid" };
1032
- const errors = validate(data, t.object({
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
- if (errors.length > 0) {
1038
- 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
+ }
1039
803
  }
1040
804
  ```
1041
805
 
1042
- ## Transformers
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 { Dto, Field, Transform, t } from "adorn-api";
1050
-
1051
- @Dto()
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
- @Field(t.dateTime())
1058
- @Transform((value) => value.toISOString())
1059
- createdAt!: Date;
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
- ### Built-in Transforms
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
- @Field(t.dateTime())
1085
- @Transform(Transforms.toISOString)
1086
- birthDate!: Date;
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
- ### Conditional Transforms with Groups
835
+ ### Filters and Sort
1091
836
 
1092
- Apply transforms only to specific serialization groups:
837
+ Use generated `filterMappings`, `sortableColumns`, and `listConfig` where possible. Manual parsers are also public:
1093
838
 
1094
839
  ```typescript
1095
- import { Dto, Field, Transform, Expose, t } from "adorn-api";
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
- @Field(t.string())
1108
- @Expose({ groups: ["internal"] })
1109
- @Transform((value) => "[REDACTED]", { groups: ["external"] })
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
- ### Custom Transform Functions
847
+ `parseSort` accepts `sortDirection=asc|desc` and legacy `sortOrder=ASC|DESC`. `sortDirection` wins when both are present.
1115
848
 
1116
- Create custom transform functions:
849
+ Deep relation filters are supported through typed Metal ORM field paths such as:
1117
850
 
1118
851
  ```typescript
1119
- import { Dto, Field, Transform, t } from "adorn-api";
1120
-
1121
- const toCurrency = (value: number, currency: string = "USD") => {
1122
- return new Intl.NumberFormat("en-US", {
1123
- style: "currency",
1124
- currency
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
- ### Serialization with Options
1144
-
1145
- Control serialization with custom options:
860
+ ### Tree DTOs
1146
861
 
1147
862
  ```typescript
1148
- import { serialize, createSerializer } from "adorn-api";
1149
- import { UserDto } from "./user.dtos";
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
- // Create a preset serializer
1169
- const adminSerializer = createSerializer({ groups: ["admin"] });
1170
- 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
+ });
1171
877
  ```
1172
878
 
1173
- ### Exclude Fields
1174
-
1175
- Control which fields are excluded or exposed:
879
+ ## Examples
1176
880
 
1177
- ```typescript
1178
- import { Dto, Field, Exclude, Expose, t } from "adorn-api";
881
+ Run examples with:
1179
882
 
1180
- @Dto()
1181
- export class UserDto {
1182
- @Field(t.string())
1183
- 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
1184
903
 
1185
- @Field(t.string())
1186
- @Exclude() // Always exclude from serialization
1187
- password!: string;
904
+ ## Testing
1188
905
 
1189
- @Field(t.string())
1190
- @Expose({ name: "email_address" }) // Rename field in output
1191
- email!: string;
906
+ The project uses Vitest and SuperTest.
1192
907
 
1193
- @Field(t.string())
1194
- @Exclude({ groups: ["public"] }) // Exclude from public group
1195
- internalComment!: string;
1196
- }
908
+ ```bash
909
+ npm run build
910
+ npm test
911
+ npm run typecheck:tests
1197
912
  ```
1198
913
 
1199
- ## Examples
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, it, expect } from "vitest";
917
+ import { describe, expect, it } from "vitest";
1219
918
  import request from "supertest";
1220
919
  import { createApp } from "./app";
1221
920
 
1222
- describe("User API", () => {
1223
- it("should get user by id", async () => {
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/1")
926
+ .get("/users/3f0f4d0f-1cb1-4cf1-9c32-3d4bce1b3f36")
1228
927
  .expect(200);
1229
-
1230
- expect(response.body).toEqual({
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
- ## License
934
+ ## Public Entry Points
1240
935
 
1241
- MIT
936
+ The package exports:
1242
937
 
1243
- ## 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`
1244
943
 
1245
- Contributions are welcome! Please feel free to submit a Pull Request.
944
+ ## License
945
+
946
+ MIT