adorn-api 1.0.31 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,10 +9,11 @@ A modern, decorator-first web framework built on Express with built-in OpenAPI 3
9
9
  - 🔌 **Express Integration**: Built on top of Express for familiarity and extensibility
10
10
  - 🎯 **Type-Safe Data Transfer Objects**: Define schemas with TypeScript for compile-time checks
11
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
12
+ - 📦 **Metal ORM Integration**: First-class support for Metal ORM with auto-generated CRUD DTOs, including transformer-aware schema generation
13
13
  - 🚀 **Streaming Support**: Server-Sent Events (SSE) and streaming responses
14
14
  - 📝 **Request Validation**: Automatic validation of request bodies, params, query, and headers
15
- - 🔒 **Error Handling**: Structured error responses with error DTO support
15
+ - 🔧 **Transformers**: Custom field transformations with @Transform decorator and built-in transform functions
16
+ - **Error Handling**: Structured error responses with error DTO support
16
17
  - 💾 **File Uploads**: Easy handling of file uploads with multipart form data
17
18
  - 🌐 **CORS Support**: Built-in CORS configuration
18
19
  - 🏗️ **Lifecycle Hooks**: Application bootstrap and shutdown lifecycle events
@@ -262,7 +263,8 @@ class UploadController {
262
263
 
263
264
  ## Metal ORM Integration
264
265
 
265
- Adorn API has first-class support for Metal ORM, providing automatic CRUD DTO generation.
266
+ Adorn API has first-class support for Metal ORM, providing automatic CRUD DTO generation.
267
+ Transformer decorators such as `@Email`, `@Length`, `@Pattern`, and `@Alphanumeric` are reflected in the generated DTO schemas (validation + OpenAPI).
266
268
 
267
269
  ### 1. Define Entities
268
270
 
@@ -384,6 +386,10 @@ createExpressApp({
384
386
  cors: true, // Enable CORS with default options or configure
385
387
  jsonBody: true, // Parse JSON bodies (default: true)
386
388
  inputCoercion: "safe", // Input coercion mode ("safe" or "strict")
389
+ validation: { // Validation configuration
390
+ enabled: true, // Enable validation (default: true)
391
+ mode: "strict" // Validation mode: "strict" or "safe"
392
+ },
387
393
  multipart: { // File upload configuration
388
394
  dest: "./uploads",
389
395
  limits: { fileSize: 50 * 1024 * 1024 }
@@ -483,6 +489,234 @@ import { lifecycleRegistry } from "adorn-api";
483
489
  lifecycleRegistry.register(new DatabaseService());
484
490
  ```
485
491
 
492
+ ## Validation
493
+
494
+ Adorn API provides automatic request validation and a comprehensive validation system for your DTOs and schemas.
495
+
496
+ ### Validation Configuration
497
+
498
+ ```typescript
499
+ createExpressApp({
500
+ controllers: [UserController],
501
+ validation: {
502
+ enabled: true, // Enable validation (default: true)
503
+ mode: "strict" // Validation mode: "strict" or "safe"
504
+ }
505
+ });
506
+ ```
507
+
508
+ ### Validation Errors
509
+
510
+ Invalid requests automatically return structured validation errors:
511
+
512
+ ```typescript
513
+ // Example error response
514
+ {
515
+ "statusCode": 400,
516
+ "message": "Validation failed",
517
+ "errors": [
518
+ {
519
+ "field": "name",
520
+ "message": "must be at least 1 character long",
521
+ "value": "",
522
+ "code": "STRING_MIN_LENGTH"
523
+ },
524
+ {
525
+ "field": "email",
526
+ "message": "must be a valid email",
527
+ "value": "invalid-email",
528
+ "code": "FORMAT_EMAIL"
529
+ }
530
+ ]
531
+ }
532
+ ```
533
+
534
+ ### Validation Error Codes
535
+
536
+ Adorn API provides machine-readable error codes for programmatic error handling:
537
+
538
+ ```typescript
539
+ import { ValidationErrorCode } from "adorn-api";
540
+
541
+ console.log(ValidationErrorCode.FORMAT_EMAIL); // "FORMAT_EMAIL"
542
+ console.log(ValidationErrorCode.STRING_MIN_LENGTH); // "STRING_MIN_LENGTH"
543
+ ```
544
+
545
+ ### Manual Validation
546
+
547
+ You can also manually validate data using the `validate` function:
548
+
549
+ ```typescript
550
+ import { validate, ValidationErrors, t } from "adorn-api";
551
+
552
+ const data = { name: "", email: "invalid" };
553
+ const errors = validate(data, t.object({
554
+ name: t.string({ minLength: 1 }),
555
+ email: t.string({ format: "email" })
556
+ }));
557
+
558
+ if (errors.length > 0) {
559
+ throw new ValidationErrors(errors);
560
+ }
561
+ ```
562
+
563
+ ## Transformers
564
+
565
+ Transform fields during serialization with custom transform functions or built-in transform utilities.
566
+
567
+ ### Basic Transform
568
+
569
+ ```typescript
570
+ import { Dto, Field, Transform, t } from "adorn-api";
571
+
572
+ @Dto()
573
+ export class UserDto {
574
+ @Field(t.string())
575
+ @Transform((value) => value.toUpperCase())
576
+ name!: string;
577
+
578
+ @Field(t.dateTime())
579
+ @Transform((value) => value.toISOString())
580
+ createdAt!: Date;
581
+ }
582
+ ```
583
+
584
+ ### Built-in Transforms
585
+
586
+ Adorn API includes common transform functions:
587
+
588
+ ```typescript
589
+ import { Dto, Field, Transform, Transforms, t } from "adorn-api";
590
+
591
+ @Dto()
592
+ export class UserDto {
593
+ @Field(t.string())
594
+ @Transform(Transforms.toLowerCase)
595
+ email!: string;
596
+
597
+ @Field(t.number())
598
+ @Transform(Transforms.round(2))
599
+ price!: number;
600
+
601
+ @Field(t.string())
602
+ @Transform(Transforms.mask(4)) // Mask all but last 4 characters
603
+ creditCard!: string;
604
+
605
+ @Field(t.dateTime())
606
+ @Transform(Transforms.toISOString)
607
+ birthDate!: Date;
608
+ }
609
+ ```
610
+
611
+ ### Conditional Transforms with Groups
612
+
613
+ Apply transforms only to specific serialization groups:
614
+
615
+ ```typescript
616
+ import { Dto, Field, Transform, Expose, t } from "adorn-api";
617
+
618
+ @Dto()
619
+ export class UserDto {
620
+ @Field(t.string())
621
+ name!: string;
622
+
623
+ @Field(t.string())
624
+ @Expose({ groups: ["admin"] })
625
+ @Transform((value) => Transforms.mask(2), { groups: ["admin"] })
626
+ phoneNumber!: string;
627
+
628
+ @Field(t.string())
629
+ @Expose({ groups: ["internal"] })
630
+ @Transform((value) => "[REDACTED]", { groups: ["external"] })
631
+ internalNote!: string;
632
+ }
633
+ ```
634
+
635
+ ### Custom Transform Functions
636
+
637
+ Create custom transform functions:
638
+
639
+ ```typescript
640
+ import { Dto, Field, Transform, t } from "adorn-api";
641
+
642
+ const toCurrency = (value: number, currency: string = "USD") => {
643
+ return new Intl.NumberFormat("en-US", {
644
+ style: "currency",
645
+ currency
646
+ }).format(value);
647
+ };
648
+
649
+ @Dto()
650
+ export class ProductDto {
651
+ @Field(t.string())
652
+ name!: string;
653
+
654
+ @Field(t.number())
655
+ @Transform(toCurrency)
656
+ price!: number;
657
+
658
+ @Field(t.number())
659
+ @Transform((value) => toCurrency(value, "EUR"))
660
+ priceEUR!: number;
661
+ }
662
+ ```
663
+
664
+ ### Serialization with Options
665
+
666
+ Control serialization with custom options:
667
+
668
+ ```typescript
669
+ import { serialize, createSerializer } from "adorn-api";
670
+ import { UserDto } from "./user.dtos";
671
+
672
+ const user = new UserDto();
673
+ user.name = "John Doe";
674
+ user.phoneNumber = "123-456-7890";
675
+ user.internalNote = "This is an internal note";
676
+
677
+ // Basic serialization
678
+ const basic = serialize(user);
679
+ // Output: { name: "John Doe" }
680
+
681
+ // Admin group serialization
682
+ const admin = serialize(user, { groups: ["admin"] });
683
+ // Output: { name: "John Doe", phoneNumber: "********90" }
684
+
685
+ // External group serialization
686
+ const external = serialize(user, { groups: ["external"] });
687
+ // Output: { name: "John Doe", internalNote: "[REDACTED]" }
688
+
689
+ // Create a preset serializer
690
+ const adminSerializer = createSerializer({ groups: ["admin"] });
691
+ const adminData = adminSerializer(user);
692
+ ```
693
+
694
+ ### Exclude Fields
695
+
696
+ Control which fields are excluded or exposed:
697
+
698
+ ```typescript
699
+ import { Dto, Field, Exclude, Expose, t } from "adorn-api";
700
+
701
+ @Dto()
702
+ export class UserDto {
703
+ @Field(t.string())
704
+ name!: string;
705
+
706
+ @Field(t.string())
707
+ @Exclude() // Always exclude from serialization
708
+ password!: string;
709
+
710
+ @Field(t.string())
711
+ @Expose({ name: "email_address" }) // Rename field in output
712
+ email!: string;
713
+
714
+ @Field(t.string())
715
+ @Exclude({ groups: ["public"] }) // Exclude from public group
716
+ internalComment!: string;
717
+ }
718
+ ```
719
+
486
720
  ## Examples
487
721
 
488
722
  Check out the `examples/` directory for more comprehensive examples:
@@ -493,6 +727,7 @@ Check out the `examples/` directory for more comprehensive examples:
493
727
  - `metal-orm-sqlite-music/` - Complex relations with Metal ORM
494
728
  - `streaming/` - SSE and streaming responses
495
729
  - `openapi/` - OpenAPI documentation customization
730
+ - `validation/` - Comprehensive validation examples with various schema types
496
731
 
497
732
  ## Testing
498
733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adorn-api",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
4
4
  "description": "Decorator-first web framework with OpenAPI 3.1 schema generation.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "express": "^4.19.2",
18
- "metal-orm": "^1.0.91"
18
+ "metal-orm": "^1.0.94"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/express": "^4.17.21",
@@ -1,10 +1,12 @@
1
- import type { ColumnDef } from "metal-orm";
2
1
  import {
3
2
  columnTypeToOpenApiFormat,
4
3
  columnTypeToOpenApiType,
5
- getColumnMap
4
+ getColumnMap,
5
+ getDecoratorMetadata,
6
+ type ColumnDef,
7
+ type TransformerMetadata
6
8
  } from "metal-orm";
7
- import type { SchemaNode } from "../../core/schema";
9
+ import type { SchemaNode, StringSchema } from "../../core/schema";
8
10
  import { t } from "../../core/schema";
9
11
  import type { FieldMeta } from "../../core/metadata";
10
12
  import type { FieldOverride } from "../../core/decorators";
@@ -46,6 +48,7 @@ export function buildFields(
46
48
  const include = options.include ? new Set(options.include) : undefined;
47
49
  const exclude = options.exclude ? new Set(options.exclude) : undefined;
48
50
  const mode = options.mode ?? "response";
51
+ const transformerMetadata = getTransformerMetadata(target);
49
52
  const fields: Record<string, FieldMeta> = {};
50
53
 
51
54
  for (const [name, col] of Object.entries(columns)) {
@@ -59,7 +62,7 @@ export function buildFields(
59
62
  continue;
60
63
  }
61
64
 
62
- fields[name] = buildFieldMeta(col, mode);
65
+ fields[name] = buildFieldMeta(col, mode, transformerMetadata?.[name]);
63
66
  }
64
67
 
65
68
  if (options.overrides) {
@@ -69,11 +72,16 @@ export function buildFields(
69
72
  return fields;
70
73
  }
71
74
 
72
- export function buildFieldMeta(col: any, mode: MetalDtoMode): FieldMeta {
75
+ export function buildFieldMeta(
76
+ col: any,
77
+ mode: MetalDtoMode,
78
+ transformer?: TransformerMetadata
79
+ ): FieldMeta {
73
80
  let schema = columnToSchemaNode(col);
74
81
  if (!col.notNull) {
75
82
  schema = t.nullable(schema);
76
83
  }
84
+ schema = applyTransformerMetadata(schema, transformer, mode);
77
85
 
78
86
  const optional = isOptional(col, mode);
79
87
  const field: FieldMeta = { schema };
@@ -206,6 +214,141 @@ function isSchemaNode(value: unknown): value is SchemaNode {
206
214
  return !!value && typeof value === "object" && "kind" in (value as SchemaNode);
207
215
  }
208
216
 
217
+ function getTransformerMetadata(
218
+ target: any
219
+ ): Record<string, TransformerMetadata> | undefined {
220
+ if (typeof target !== "function") {
221
+ return undefined;
222
+ }
223
+ const meta = getDecoratorMetadata(target);
224
+ if (!meta?.transformers?.length) {
225
+ return undefined;
226
+ }
227
+ const output: Record<string, TransformerMetadata> = {};
228
+ for (const entry of meta.transformers) {
229
+ output[entry.propertyName] = entry.metadata;
230
+ }
231
+ return output;
232
+ }
233
+
234
+ function applyTransformerMetadata(
235
+ schema: SchemaNode,
236
+ transformer: TransformerMetadata | undefined,
237
+ mode: MetalDtoMode
238
+ ): SchemaNode {
239
+ if (!transformer || !shouldApplyTransformers(transformer, mode)) {
240
+ return schema;
241
+ }
242
+ if (schema.kind !== "string") {
243
+ return schema;
244
+ }
245
+ const stringSchema = schema as StringSchema;
246
+ for (const validator of transformer.validators ?? []) {
247
+ applyStringValidator(stringSchema, validator);
248
+ }
249
+ return stringSchema;
250
+ }
251
+
252
+ function shouldApplyTransformers(
253
+ transformer: TransformerMetadata,
254
+ mode: MetalDtoMode
255
+ ): boolean {
256
+ if (transformer.executionOrder === "both") {
257
+ return true;
258
+ }
259
+ if (mode === "response") {
260
+ return transformer.executionOrder === "after-load";
261
+ }
262
+ return transformer.executionOrder === "before-save";
263
+ }
264
+
265
+ function applyStringValidator(
266
+ schema: StringSchema,
267
+ validator: { name?: string }
268
+ ): void {
269
+ const name = validator.name?.toLowerCase();
270
+ switch (name) {
271
+ case "email":
272
+ if (!schema.format || schema.format === "email") {
273
+ schema.format = "email";
274
+ }
275
+ break;
276
+ case "length":
277
+ applyLengthValidator(schema, validator);
278
+ break;
279
+ case "pattern":
280
+ applyPatternValidator(schema, validator);
281
+ break;
282
+ case "alphanumeric":
283
+ applyAlphanumericValidator(schema, validator);
284
+ break;
285
+ default:
286
+ break;
287
+ }
288
+ }
289
+
290
+ function applyLengthValidator(
291
+ schema: StringSchema,
292
+ validator: { [key: string]: unknown }
293
+ ): void {
294
+ const options = (validator as { options?: { min?: number; max?: number; exact?: number } }).options;
295
+ if (!options) {
296
+ return;
297
+ }
298
+ if (typeof options.exact === "number") {
299
+ schema.minLength = options.exact;
300
+ schema.maxLength = options.exact;
301
+ return;
302
+ }
303
+ if (typeof options.min === "number") {
304
+ schema.minLength = schema.minLength !== undefined
305
+ ? Math.max(schema.minLength, options.min)
306
+ : options.min;
307
+ }
308
+ if (typeof options.max === "number") {
309
+ schema.maxLength = schema.maxLength !== undefined
310
+ ? Math.min(schema.maxLength, options.max)
311
+ : options.max;
312
+ }
313
+ }
314
+
315
+ function applyPatternValidator(
316
+ schema: StringSchema,
317
+ validator: { [key: string]: unknown }
318
+ ): void {
319
+ if (schema.pattern) {
320
+ return;
321
+ }
322
+ const options = (validator as { options?: { pattern?: RegExp } }).options;
323
+ if (!options?.pattern) {
324
+ return;
325
+ }
326
+ schema.pattern = options.pattern.source;
327
+ }
328
+
329
+ function applyAlphanumericValidator(
330
+ schema: StringSchema,
331
+ validator: { [key: string]: unknown }
332
+ ): void {
333
+ if (schema.pattern) {
334
+ return;
335
+ }
336
+ const options = (validator as {
337
+ options?: { allowSpaces?: boolean; allowUnderscores?: boolean; allowHyphens?: boolean }
338
+ }).options;
339
+ const extras: string[] = [];
340
+ if (options?.allowSpaces) {
341
+ extras.push(" ");
342
+ }
343
+ if (options?.allowUnderscores) {
344
+ extras.push("_");
345
+ }
346
+ if (options?.allowHyphens) {
347
+ extras.push("-");
348
+ }
349
+ schema.pattern = `^[a-zA-Z0-9${extras.join("")}]*$`;
350
+ }
351
+
209
352
  /**
210
353
  * Validates that an entity has properly loaded metadata (columns).
211
354
  * Throws an error with helpful message if validation fails.
@@ -1,3 +1,9 @@
1
+ // Ensure standard decorator metadata is available for metal-orm transformers.
2
+ const symbolMetadata = (Symbol as { metadata?: symbol }).metadata;
3
+ if (!symbolMetadata) {
4
+ (Symbol as { metadata?: symbol }).metadata = Symbol("Symbol.metadata");
5
+ }
6
+
1
7
  export {
2
8
  MetalDto
3
9
  } from "./dto";
@@ -12,7 +12,7 @@ import {
12
12
  createMetalDtoOverrides
13
13
  } from "../../src/adapter/metal-orm/index";
14
14
  import { HttpError } from "../../src/core/errors";
15
- import { Column, Entity, PrimaryKey, col } from "metal-orm";
15
+ import { Alphanumeric, Column, Email, Entity, Length, Pattern, PrimaryKey, col } from "metal-orm";
16
16
  import { getDtoMeta } from "../../src/core/metadata";
17
17
 
18
18
  describe("metal-orm helpers", () => {
@@ -240,20 +240,20 @@ describe("metal-orm helpers", () => {
240
240
  });
241
241
  });
242
242
 
243
- describe("createMetalCrudDtos", () => {
244
- @Entity({ tableName: "crud_dto_entities" })
245
- class CrudDtoEntity {
246
- @PrimaryKey(col.autoIncrement(col.int()))
243
+ describe("createMetalCrudDtos", () => {
244
+ @Entity({ tableName: "crud_dto_entities" })
245
+ class CrudDtoEntity {
246
+ @PrimaryKey(col.autoIncrement(col.int()))
247
247
  id!: number;
248
248
 
249
249
  @Column(col.notNull(col.text()))
250
250
  name!: string;
251
251
 
252
252
  @Column(col.text())
253
- nickname?: string | null;
254
- }
255
-
256
- it("creates CRUD DTO decorators with defaults", () => {
253
+ nickname?: string | null;
254
+ }
255
+
256
+ it("creates CRUD DTO decorators with defaults", () => {
257
257
  const crud = createMetalCrudDtos(CrudDtoEntity, {
258
258
  mutationExclude: ["id"]
259
259
  });
@@ -277,10 +277,46 @@ describe("metal-orm helpers", () => {
277
277
 
278
278
  expect(responseMeta?.fields.id).toBeDefined();
279
279
  expect(createMeta?.fields.id).toBeUndefined();
280
- expect(updateMeta?.fields.name?.optional).toBe(true);
281
- expect(Object.keys(paramsMeta?.fields ?? {})).toEqual(["id"]);
282
- });
283
- });
280
+ expect(updateMeta?.fields.name?.optional).toBe(true);
281
+ expect(Object.keys(paramsMeta?.fields ?? {})).toEqual(["id"]);
282
+ });
283
+
284
+ @Entity({ tableName: "transformer_entities" })
285
+ class TransformerEntity {
286
+ @PrimaryKey(col.autoIncrement(col.int()))
287
+ id!: number;
288
+
289
+ @Column(col.varchar(50))
290
+ @Length({ min: 2, max: 10 })
291
+ name!: string;
292
+
293
+ @Column(col.text())
294
+ @Pattern({ pattern: /^[A-Z]+$/ })
295
+ code!: string;
296
+
297
+ @Column(col.text())
298
+ @Email()
299
+ email!: string;
300
+
301
+ @Column(col.text())
302
+ @Alphanumeric({ allowHyphens: true })
303
+ slug!: string;
304
+ }
305
+
306
+ it("maps transformer validators into string schemas", () => {
307
+ const crud = createMetalCrudDtos(TransformerEntity);
308
+
309
+ @crud.create
310
+ class CreateTransformerDto {}
311
+
312
+ const meta = getDtoMeta(CreateTransformerDto);
313
+ expect((meta?.fields.email?.schema as any).format).toBe("email");
314
+ expect((meta?.fields.name?.schema as any).minLength).toBe(2);
315
+ expect((meta?.fields.name?.schema as any).maxLength).toBe(10);
316
+ expect((meta?.fields.code?.schema as any).pattern).toBe("^[A-Z]+$");
317
+ expect((meta?.fields.slug?.schema as any).pattern).toBe("^[a-zA-Z0-9-]*$");
318
+ });
319
+ });
284
320
 
285
321
  describe("createMetalCrudDtoClasses", () => {
286
322
  @Entity({ tableName: "crud_dto_class_entities" })