adorn-api 1.0.31 → 1.0.33

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 (40) hide show
  1. package/README.md +238 -3
  2. package/dist/adapter/express/coercion.d.ts +1 -1
  3. package/dist/adapter/express/coercion.js +33 -3
  4. package/dist/adapter/express/controllers.js +13 -2
  5. package/dist/adapter/express/response-serializer.d.ts +2 -0
  6. package/dist/adapter/express/response-serializer.js +117 -0
  7. package/dist/adapter/metal-orm/field-builder.d.ts +2 -1
  8. package/dist/adapter/metal-orm/field-builder.js +127 -3
  9. package/dist/adapter/metal-orm/index.d.ts +1 -1
  10. package/dist/adapter/metal-orm/index.js +9 -1
  11. package/dist/adapter/metal-orm/utils.d.ts +29 -0
  12. package/dist/adapter/metal-orm/utils.js +39 -0
  13. package/dist/core/validation/validators/string-validator.js +20 -0
  14. package/dist/core/validation/validators/validation-utils.d.ts +6 -0
  15. package/dist/core/validation/validators/validation-utils.js +16 -0
  16. package/dist/core/validation-errors.d.ts +1 -0
  17. package/dist/core/validation-errors.js +1 -0
  18. package/examples/metal-orm-postgres/app.ts +18 -0
  19. package/examples/metal-orm-postgres/db.ts +67 -0
  20. package/examples/metal-orm-postgres/index.ts +6 -0
  21. package/examples/metal-orm-postgres/post.controller.ts +209 -0
  22. package/examples/metal-orm-postgres/post.dtos.ts +78 -0
  23. package/examples/metal-orm-postgres/post.entity.ts +24 -0
  24. package/examples/metal-orm-postgres/user.controller.helpers.ts +305 -0
  25. package/examples/metal-orm-postgres/user.controller.ts +231 -0
  26. package/examples/metal-orm-postgres/user.dtos.ts +88 -0
  27. package/examples/metal-orm-postgres/user.entity.ts +21 -0
  28. package/package.json +4 -2
  29. package/src/adapter/express/coercion.ts +35 -4
  30. package/src/adapter/express/controllers.ts +17 -2
  31. package/src/adapter/express/response-serializer.ts +133 -0
  32. package/src/adapter/metal-orm/field-builder.ts +167 -6
  33. package/src/adapter/metal-orm/index.ts +55 -46
  34. package/src/adapter/metal-orm/utils.ts +52 -0
  35. package/src/core/validation/validators/string-validator.ts +45 -18
  36. package/src/core/validation/validators/validation-utils.ts +21 -5
  37. package/src/core/validation-errors.ts +4 -3
  38. package/tests/e2e/sqlserver-metal-orm.e2e.test.ts +449 -0
  39. package/tests/unit/metal-orm.test.ts +49 -13
  40. package/tests/unit/validation.test.ts +22 -9
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
 
@@ -1,6 +1,6 @@
1
1
  import { type InputMeta } from "../../core/metadata";
2
2
  import type { InputCoercionMode } from "./types";
3
- export type InputLocation = "params" | "query";
3
+ export type InputLocation = "params" | "query" | "body";
4
4
  interface CoerceInputOptions {
5
5
  mode: InputCoercionMode;
6
6
  location: InputLocation;
@@ -57,7 +57,7 @@ function coerceValue(value, schema, mode) {
57
57
  return coerceBoolean(value);
58
58
  }
59
59
  case "string": {
60
- return coerceString(value);
60
+ return coerceString(value, schema);
61
61
  }
62
62
  case "array":
63
63
  return coerceArrayValue(value, schema, mode);
@@ -101,13 +101,37 @@ function coerceBoolean(value) {
101
101
  }
102
102
  return { value: parsed, ok: true, changed: parsed !== value };
103
103
  }
104
- function coerceString(value) {
104
+ function coerceString(value, schema) {
105
+ if (!isPresent(value)) {
106
+ return { value, ok: true, changed: false };
107
+ }
108
+ if (schema.format === "date" || schema.format === "date-time") {
109
+ const parsed = parseDateValue(value);
110
+ if (!parsed) {
111
+ return { value, ok: false, changed: false };
112
+ }
113
+ return { value: parsed, ok: true, changed: parsed !== value };
114
+ }
105
115
  const parsed = coerce_1.coerce.string(value);
106
116
  if (parsed === undefined) {
107
117
  return { value, ok: true, changed: false };
108
118
  }
109
119
  return { value: parsed, ok: true, changed: parsed !== value };
110
120
  }
121
+ function parseDateValue(value) {
122
+ if (value instanceof Date) {
123
+ return Number.isNaN(value.getTime()) ? undefined : value;
124
+ }
125
+ const text = coerce_1.coerce.string(value);
126
+ if (text === undefined) {
127
+ return undefined;
128
+ }
129
+ const parsed = new Date(text);
130
+ if (Number.isNaN(parsed.getTime())) {
131
+ return undefined;
132
+ }
133
+ return parsed;
134
+ }
111
135
  function coerceArrayValue(value, schema, mode) {
112
136
  if (value === undefined || value === null) {
113
137
  return { value, ok: true, changed: false };
@@ -241,7 +265,13 @@ function isPresent(value) {
241
265
  return coerce_1.coerce.string(value) !== undefined;
242
266
  }
243
267
  function buildInvalidMessage(location, fields) {
244
- const label = location === "params" ? "path parameter" : "query parameter";
268
+ let label = "query parameter";
269
+ if (location === "params") {
270
+ label = "path parameter";
271
+ }
272
+ else if (location === "body") {
273
+ label = "request body field";
274
+ }
245
275
  const suffix = fields.length > 1 ? "s" : "";
246
276
  return `Invalid ${label}${suffix}: ${fields.join(", ")}.`;
247
277
  }
@@ -4,6 +4,7 @@ exports.attachControllers = attachControllers;
4
4
  const metadata_1 = require("../../core/metadata");
5
5
  const errors_1 = require("../../core/errors");
6
6
  const coercion_1 = require("./coercion");
7
+ const response_serializer_1 = require("./response-serializer");
7
8
  const multipart_1 = require("./multipart");
8
9
  const lifecycle_1 = require("../../core/lifecycle");
9
10
  const streaming_1 = require("../../core/streaming");
@@ -38,6 +39,9 @@ async function attachControllers(app, controllers, inputCoercion = "safe", multi
38
39
  const coerceQuery = inputCoercion === false
39
40
  ? undefined
40
41
  : (0, coercion_1.createInputCoercer)(route.query, { mode: inputCoercion, location: "query" });
42
+ const coerceBody = inputCoercion === false
43
+ ? undefined
44
+ : (0, coercion_1.createInputCoercer)(route.body, { mode: inputCoercion, location: "body" });
41
45
  // Build middleware chain
42
46
  const middlewares = [];
43
47
  // Add multipart middleware if route has file uploads
@@ -54,7 +58,7 @@ async function attachControllers(app, controllers, inputCoercion = "safe", multi
54
58
  const ctx = {
55
59
  req,
56
60
  res,
57
- body: req.body,
61
+ body: coerceBody ? coerceBody(req.body) : req.body,
58
62
  query: coerceQuery ? coerceQuery(req.query) : req.query,
59
63
  params: coerceParams ? coerceParams(req.params) : req.params,
60
64
  headers: req.headers,
@@ -94,7 +98,9 @@ async function attachControllers(app, controllers, inputCoercion = "safe", multi
94
98
  res.status(defaultStatus(route)).end();
95
99
  return;
96
100
  }
97
- res.status(defaultStatus(route)).json(result);
101
+ const responseSchema = getResponseSchema(route);
102
+ const output = responseSchema ? (0, response_serializer_1.serializeResponse)(result, responseSchema) : result;
103
+ res.status(defaultStatus(route)).json(output);
98
104
  }
99
105
  catch (error) {
100
106
  if ((0, validation_errors_1.isValidationErrors)(error)) {
@@ -118,6 +124,11 @@ function defaultStatus(route) {
118
124
  const success = responses.find((response) => !response.error && response.status < 400);
119
125
  return success?.status ?? 200;
120
126
  }
127
+ function getResponseSchema(route) {
128
+ const responses = route.responses ?? [];
129
+ const success = responses.find((response) => !response.error && response.status < 400);
130
+ return success?.schema;
131
+ }
121
132
  function sendValidationError(res, error) {
122
133
  if (res.headersSent) {
123
134
  return;
@@ -0,0 +1,2 @@
1
+ import type { SchemaSource } from "../../core/schema";
2
+ export declare function serializeResponse(value: unknown, schema: SchemaSource): unknown;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serializeResponse = serializeResponse;
4
+ const metadata_1 = require("../../core/metadata");
5
+ function serializeResponse(value, schema) {
6
+ if (value === null || value === undefined) {
7
+ return value;
8
+ }
9
+ if (isSchemaNode(schema)) {
10
+ return serializeWithSchema(value, schema);
11
+ }
12
+ return serializeWithDto(value, schema);
13
+ }
14
+ function serializeWithDto(value, dto) {
15
+ if (value === null || value === undefined) {
16
+ return value;
17
+ }
18
+ if (Array.isArray(value)) {
19
+ return value.map((entry) => serializeWithDto(entry, dto));
20
+ }
21
+ if (!isPlainObject(value)) {
22
+ return value;
23
+ }
24
+ const meta = (0, metadata_1.getDtoMeta)(dto);
25
+ if (!meta) {
26
+ return value;
27
+ }
28
+ const output = { ...value };
29
+ for (const [name, field] of Object.entries(meta.fields)) {
30
+ if (name in value) {
31
+ output[name] = serializeWithSchema(value[name], field.schema);
32
+ }
33
+ }
34
+ return output;
35
+ }
36
+ function serializeWithSchema(value, schema) {
37
+ if (value === null || value === undefined) {
38
+ return value;
39
+ }
40
+ switch (schema.kind) {
41
+ case "string":
42
+ return serializeString(value, schema.format);
43
+ case "array":
44
+ if (!Array.isArray(value)) {
45
+ return value;
46
+ }
47
+ return value.map((entry) => serializeWithSchema(entry, schema.items));
48
+ case "object":
49
+ return serializeObject(value, schema.properties);
50
+ case "record":
51
+ if (!isPlainObject(value)) {
52
+ return value;
53
+ }
54
+ return serializeRecord(value, schema.values);
55
+ case "ref":
56
+ return serializeWithDto(value, schema.dto);
57
+ case "union":
58
+ return serializeUnion(value, schema.anyOf);
59
+ default:
60
+ return value;
61
+ }
62
+ }
63
+ function serializeString(value, format) {
64
+ if (!(value instanceof Date)) {
65
+ return value;
66
+ }
67
+ if (Number.isNaN(value.getTime())) {
68
+ return value;
69
+ }
70
+ if (format === "date") {
71
+ return value.toISOString().slice(0, 10);
72
+ }
73
+ if (format === "date-time") {
74
+ return value.toISOString();
75
+ }
76
+ return value;
77
+ }
78
+ function serializeObject(value, properties) {
79
+ if (!isPlainObject(value)) {
80
+ return value;
81
+ }
82
+ const output = { ...value };
83
+ if (!properties) {
84
+ return output;
85
+ }
86
+ for (const [key, schema] of Object.entries(properties)) {
87
+ if (key in value) {
88
+ output[key] = serializeWithSchema(value[key], schema);
89
+ }
90
+ }
91
+ return output;
92
+ }
93
+ function serializeRecord(value, schema) {
94
+ const output = { ...value };
95
+ for (const [key, entry] of Object.entries(value)) {
96
+ output[key] = serializeWithSchema(entry, schema);
97
+ }
98
+ return output;
99
+ }
100
+ function serializeUnion(value, options) {
101
+ for (const option of options) {
102
+ const serialized = serializeWithSchema(value, option);
103
+ if (serialized !== value) {
104
+ return serialized;
105
+ }
106
+ }
107
+ return value;
108
+ }
109
+ function isSchemaNode(value) {
110
+ return !!value && typeof value === "object" && "kind" in value;
111
+ }
112
+ function isPlainObject(value) {
113
+ return (value !== null &&
114
+ typeof value === "object" &&
115
+ !Array.isArray(value) &&
116
+ !(value instanceof Date));
117
+ }
@@ -1,3 +1,4 @@
1
+ import { type TransformerMetadata } from "metal-orm";
1
2
  import type { FieldMeta } from "../../core/metadata";
2
3
  import type { FieldOverride } from "../../core/decorators";
3
4
  export type MetalDtoMode = "response" | "create" | "update";
@@ -13,7 +14,7 @@ export interface MetalDtoOptions {
13
14
  strict?: boolean;
14
15
  }
15
16
  export declare function buildFields(target: any, options: MetalDtoOptions): Record<string, FieldMeta>;
16
- export declare function buildFieldMeta(col: any, mode: MetalDtoMode): FieldMeta;
17
+ export declare function buildFieldMeta(col: any, mode: MetalDtoMode, transformer?: TransformerMetadata): FieldMeta;
17
18
  /**
18
19
  * Validates that an entity has properly loaded metadata (columns).
19
20
  * Throws an error with helpful message if validation fails.