adorn-api 1.1.11 → 1.1.13

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 (75) hide show
  1. package/README.md +18 -0
  2. package/dist/adapter/express/types.d.ts +3 -46
  3. package/dist/adapter/fastify/coercion.d.ts +12 -0
  4. package/dist/adapter/fastify/coercion.js +289 -0
  5. package/dist/adapter/fastify/controllers.d.ts +7 -0
  6. package/dist/adapter/fastify/controllers.js +201 -0
  7. package/dist/adapter/fastify/index.d.ts +14 -0
  8. package/dist/adapter/fastify/index.js +67 -0
  9. package/dist/adapter/fastify/multipart.d.ts +26 -0
  10. package/dist/adapter/fastify/multipart.js +75 -0
  11. package/dist/adapter/fastify/openapi.d.ts +10 -0
  12. package/dist/adapter/fastify/openapi.js +76 -0
  13. package/dist/adapter/fastify/response-serializer.d.ts +2 -0
  14. package/dist/adapter/fastify/response-serializer.js +162 -0
  15. package/dist/adapter/fastify/types.d.ts +100 -0
  16. package/dist/adapter/fastify/types.js +2 -0
  17. package/dist/adapter/metal-orm/index.d.ts +1 -1
  18. package/dist/adapter/metal-orm/types.d.ts +23 -0
  19. package/dist/adapter/native/coercion.d.ts +12 -0
  20. package/dist/adapter/native/coercion.js +289 -0
  21. package/dist/adapter/native/controllers.d.ts +17 -0
  22. package/dist/adapter/native/controllers.js +215 -0
  23. package/dist/adapter/native/index.d.ts +14 -0
  24. package/dist/adapter/native/index.js +127 -0
  25. package/dist/adapter/native/openapi.d.ts +7 -0
  26. package/dist/adapter/native/openapi.js +82 -0
  27. package/dist/adapter/native/response-serializer.d.ts +5 -0
  28. package/dist/adapter/native/response-serializer.js +160 -0
  29. package/dist/adapter/native/router.d.ts +25 -0
  30. package/dist/adapter/native/router.js +68 -0
  31. package/dist/adapter/native/types.d.ts +77 -0
  32. package/dist/adapter/native/types.js +2 -0
  33. package/dist/core/auth.d.ts +11 -12
  34. package/dist/core/auth.js +2 -2
  35. package/dist/core/logger.d.ts +3 -4
  36. package/dist/core/logger.js +2 -2
  37. package/dist/core/streaming.d.ts +10 -10
  38. package/dist/core/streaming.js +31 -19
  39. package/dist/core/types.d.ts +102 -0
  40. package/dist/index.d.ts +6 -1
  41. package/dist/index.js +16 -1
  42. package/examples/fastify/app.ts +16 -0
  43. package/examples/fastify/index.ts +21 -0
  44. package/package.json +24 -18
  45. package/src/adapter/express/controllers.ts +249 -249
  46. package/src/adapter/express/types.ts +121 -160
  47. package/src/adapter/fastify/coercion.ts +369 -0
  48. package/src/adapter/fastify/controllers.ts +255 -0
  49. package/src/adapter/fastify/index.ts +53 -0
  50. package/src/adapter/fastify/multipart.ts +94 -0
  51. package/src/adapter/fastify/openapi.ts +93 -0
  52. package/src/adapter/fastify/response-serializer.ts +179 -0
  53. package/src/adapter/fastify/types.ts +119 -0
  54. package/src/adapter/metal-orm/index.ts +3 -0
  55. package/src/adapter/metal-orm/types.ts +25 -0
  56. package/src/adapter/native/coercion.ts +369 -0
  57. package/src/adapter/native/controllers.ts +271 -0
  58. package/src/adapter/native/index.ts +116 -0
  59. package/src/adapter/native/openapi.ts +109 -0
  60. package/src/adapter/native/response-serializer.ts +177 -0
  61. package/src/adapter/native/router.ts +90 -0
  62. package/src/adapter/native/types.ts +96 -0
  63. package/src/core/auth.ts +314 -315
  64. package/src/core/health.ts +234 -235
  65. package/src/core/logger.ts +245 -247
  66. package/src/core/streaming.ts +342 -330
  67. package/src/core/types.ts +115 -0
  68. package/src/index.ts +46 -16
  69. package/tests/e2e/fastify.e2e.test.ts +174 -0
  70. package/tests/native.test.ts +191 -0
  71. package/tests/typecheck/query-params.typecheck.ts +42 -0
  72. package/tests/unit/openapi-parameters.test.ts +97 -97
  73. package/tsconfig.json +14 -13
  74. package/tsconfig.typecheck.json +8 -0
  75. package/vitest.config.ts +47 -7
@@ -1,160 +1,121 @@
1
- import type { Request, Response } from "express";
2
- import type { Constructor } from "../../core/types";
3
- import type { OpenApiInfo, OpenApiServer } from "../../core/openapi";
4
- import type { SseEmitter, StreamWriter } from "../../core/streaming";
5
-
6
- /**
7
- * Uploaded file information from multipart form data.
8
- */
9
- export interface UploadedFileInfo {
10
- /** Original filename as provided by the client */
11
- originalName: string;
12
- /** MIME type of the file */
13
- mimeType: string;
14
- /** Size of the file in bytes */
15
- size: number;
16
- /** File buffer (when using memory storage) */
17
- buffer?: Buffer;
18
- /** Path to the file on disk (when using disk storage) */
19
- path?: string;
20
- /** Field name from the form */
21
- fieldName: string;
22
- }
23
-
24
- /**
25
- * Request context provided to route handlers.
26
- */
27
- export interface RequestContext<
28
- TBody = unknown,
29
- TQuery extends object | undefined = Record<string, unknown>,
30
- TParams extends object | undefined = Record<string, string | number | boolean | undefined>,
31
- THeaders extends object | undefined = Record<string, string | string[] | undefined>,
32
- TFiles extends Record<string, UploadedFileInfo | UploadedFileInfo[]> | undefined = undefined
33
- > {
34
- /** Express request object */
35
- req: Request;
36
- /** Express response object */
37
- res: Response;
38
- /** Parsed request body */
39
- body: TBody;
40
- /** Parsed query parameters */
41
- query: TQuery;
42
- /** Parsed path parameters */
43
- params: TParams;
44
- /** Request headers */
45
- headers: THeaders;
46
- /** Uploaded files (when using multipart handling) */
47
- files: TFiles;
48
- /**
49
- * Server-Sent Events emitter for streaming events to client.
50
- * Only available on routes marked with @Sse decorator.
51
- */
52
- sse?: SseEmitter;
53
- /**
54
- * Stream writer for streaming responses.
55
- * Available on routes marked with @Streaming or @Sse decorator.
56
- */
57
- stream?: StreamWriter;
58
- }
59
-
60
- /**
61
- * Input coercion modes.
62
- */
63
- export type InputCoercionMode = "safe" | "strict";
64
-
65
- /**
66
- * Input coercion setting - can be a mode or disabled.
67
- */
68
- export type InputCoercionSetting = InputCoercionMode | false;
69
-
70
- /**
71
- * CORS configuration options.
72
- */
73
- export interface CorsOptions {
74
- /** Allowed origins. Use "*" for all, a string, array of strings, or a function for dynamic matching. */
75
- origin?: string | string[] | ((origin: string | undefined) => boolean | string);
76
- /** Allowed HTTP methods. Defaults to ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]. */
77
- methods?: string[];
78
- /** Allowed headers. Defaults to ["Content-Type", "Authorization"]. */
79
- allowedHeaders?: string[];
80
- /** Headers exposed to the client. */
81
- exposedHeaders?: string[];
82
- /** Whether to include credentials (cookies, authorization headers). Defaults to false. */
83
- credentials?: boolean;
84
- /** Max age in seconds for preflight cache. Defaults to 86400 (24 hours). */
85
- maxAge?: number;
86
- }
87
-
88
- /**
89
- * Options for OpenAPI documentation UI.
90
- */
91
- export interface OpenApiDocsOptions {
92
- /** Path for documentation UI */
93
- path?: string;
94
- /** Title for documentation page */
95
- title?: string;
96
- /** URL for Swagger UI assets */
97
- swaggerUiUrl?: string;
98
- }
99
-
100
- /**
101
- * OpenAPI configuration for Express adapter.
102
- */
103
- export interface OpenApiExpressOptions {
104
- /** OpenAPI document info */
105
- info: OpenApiInfo;
106
- /** Array of servers */
107
- servers?: OpenApiServer[];
108
- /** Path for OpenAPI JSON endpoint */
109
- path?: string;
110
- /** Whether to pretty-print the JSON output (defaults to false for minified output) */
111
- prettyPrint?: boolean;
112
- /** Documentation UI configuration */
113
- docs?: boolean | OpenApiDocsOptions;
114
- }
115
-
116
- /**
117
- * Multipart file upload configuration.
118
- */
119
- export interface MultipartOptions {
120
- /** Storage type: 'memory' or 'disk' */
121
- storage?: "memory" | "disk";
122
- /** Directory for disk storage (defaults to OS temp directory) */
123
- dest?: string;
124
- /** Maximum file size in bytes (defaults to 10MB) */
125
- maxFileSize?: number;
126
- /** Maximum number of files per field (defaults to 10) */
127
- maxFiles?: number;
128
- }
129
-
130
- /**
131
- * Validation configuration options.
132
- */
133
- export interface ValidationOptions {
134
- /** Whether validation is enabled. Defaults to true. */
135
- enabled?: boolean;
136
- /** Validation mode. 'strict' mode fails on any validation error, 'lax' mode may allow some errors. Defaults to 'strict'. */
137
- mode?: 'strict' | 'lax';
138
- }
139
-
140
- /**
141
- * Options for creating an Express application adapter.
142
- */
143
- export interface ExpressAdapterOptions {
144
- /** Array of controller classes */
145
- controllers: Constructor[];
146
- /** Whether to enable JSON body parsing */
147
- jsonBody?: boolean;
148
- /** Max JSON body size (e.g. "50mb"). Defaults to Express's "100kb". */
149
- jsonLimit?: string;
150
- /** OpenAPI configuration */
151
- openApi?: OpenApiExpressOptions;
152
- /** Input coercion setting */
153
- inputCoercion?: InputCoercionSetting;
154
- /** CORS configuration. Set to true for permissive defaults, or provide options. */
155
- cors?: boolean | CorsOptions;
156
- /** Multipart file upload configuration. Set to true for defaults, or provide options. */
157
- multipart?: boolean | MultipartOptions;
158
- /** Validation configuration. Set to false to disable validation, or provide options. */
159
- validation?: boolean | ValidationOptions;
160
- }
1
+ import type {
2
+ Constructor,
3
+ RequestContext as CoreRequestContext,
4
+ UploadedFileInfo
5
+ } from "../../core/types";
6
+ import type { OpenApiInfo, OpenApiServer } from "../../core/openapi";
7
+
8
+ export { UploadedFileInfo };
9
+
10
+ /**
11
+ * Request context provided to route handlers.
12
+ */
13
+ export type RequestContext<
14
+ TBody = any,
15
+ TQuery extends object | undefined = Record<string, any>,
16
+ TParams extends object | undefined = Record<string, any>,
17
+ THeaders extends object | undefined = Record<string, any>,
18
+ TFiles extends Record<string, UploadedFileInfo | UploadedFileInfo[]> | undefined = any
19
+ > = CoreRequestContext<TBody, TQuery, TParams, THeaders, TFiles>;
20
+
21
+ /**
22
+ * Input coercion modes.
23
+ */
24
+ export type InputCoercionMode = "safe" | "strict";
25
+
26
+ /**
27
+ * Input coercion setting - can be a mode or disabled.
28
+ */
29
+ export type InputCoercionSetting = InputCoercionMode | false;
30
+
31
+ /**
32
+ * CORS configuration options.
33
+ */
34
+ export interface CorsOptions {
35
+ /** Allowed origins. Use "*" for all, a string, array of strings, or a function for dynamic matching. */
36
+ origin?: string | string[] | ((origin: string | undefined) => boolean | string);
37
+ /** Allowed HTTP methods. Defaults to ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]. */
38
+ methods?: string[];
39
+ /** Allowed headers. Defaults to ["Content-Type", "Authorization"]. */
40
+ allowedHeaders?: string[];
41
+ /** Headers exposed to the client. */
42
+ exposedHeaders?: string[];
43
+ /** Whether to include credentials (cookies, authorization headers). Defaults to false. */
44
+ credentials?: boolean;
45
+ /** Max age in seconds for preflight cache. Defaults to 86400 (24 hours). */
46
+ maxAge?: number;
47
+ }
48
+
49
+ /**
50
+ * Options for OpenAPI documentation UI.
51
+ */
52
+ export interface OpenApiDocsOptions {
53
+ /** Path for documentation UI */
54
+ path?: string;
55
+ /** Title for documentation page */
56
+ title?: string;
57
+ /** URL for Swagger UI assets */
58
+ swaggerUiUrl?: string;
59
+ }
60
+
61
+ /**
62
+ * OpenAPI configuration for Express adapter.
63
+ */
64
+ export interface OpenApiExpressOptions {
65
+ /** OpenAPI document info */
66
+ info: OpenApiInfo;
67
+ /** Array of servers */
68
+ servers?: OpenApiServer[];
69
+ /** Path for OpenAPI JSON endpoint */
70
+ path?: string;
71
+ /** Whether to pretty-print the JSON output (defaults to false for minified output) */
72
+ prettyPrint?: boolean;
73
+ /** Documentation UI configuration */
74
+ docs?: boolean | OpenApiDocsOptions;
75
+ }
76
+
77
+ /**
78
+ * Multipart file upload configuration.
79
+ */
80
+ export interface MultipartOptions {
81
+ /** Storage type: 'memory' or 'disk' */
82
+ storage?: "memory" | "disk";
83
+ /** Directory for disk storage (defaults to OS temp directory) */
84
+ dest?: string;
85
+ /** Maximum file size in bytes (defaults to 10MB) */
86
+ maxFileSize?: number;
87
+ /** Maximum number of files per field (defaults to 10) */
88
+ maxFiles?: number;
89
+ }
90
+
91
+ /**
92
+ * Validation configuration options.
93
+ */
94
+ export interface ValidationOptions {
95
+ /** Whether validation is enabled. Defaults to true. */
96
+ enabled?: boolean;
97
+ /** Validation mode. 'strict' mode fails on any validation error, 'lax' mode may allow some errors. Defaults to 'strict'. */
98
+ mode?: 'strict' | 'lax';
99
+ }
100
+
101
+ /**
102
+ * Options for creating an Express application adapter.
103
+ */
104
+ export interface ExpressAdapterOptions {
105
+ /** Array of controller classes */
106
+ controllers: Constructor[];
107
+ /** Whether to enable JSON body parsing */
108
+ jsonBody?: boolean;
109
+ /** Max JSON body size (e.g. "50mb"). Defaults to Express's "100kb". */
110
+ jsonLimit?: string;
111
+ /** OpenAPI configuration */
112
+ openApi?: OpenApiExpressOptions;
113
+ /** Input coercion setting */
114
+ inputCoercion?: InputCoercionSetting;
115
+ /** CORS configuration. Set to true for permissive defaults, or provide options. */
116
+ cors?: boolean | CorsOptions;
117
+ /** Multipart file upload configuration. Set to true for defaults, or provide options. */
118
+ multipart?: boolean | MultipartOptions;
119
+ /** Validation configuration. Set to false to disable validation, or provide options. */
120
+ validation?: boolean | ValidationOptions;
121
+ }
@@ -0,0 +1,369 @@
1
+ import { getDtoMeta, type InputMeta } from "../../core/metadata";
2
+ import type {
3
+ SchemaNode,
4
+ SchemaSource,
5
+ StringSchema,
6
+ ArraySchema,
7
+ NumberSchema,
8
+ ObjectSchema,
9
+ RecordSchema,
10
+ RefSchema,
11
+ UnionSchema
12
+ } from "../../core/schema";
13
+ import { coerce } from "../../core/coerce";
14
+ import { HttpError } from "../../core/errors";
15
+ import type { InputCoercionMode } from "./types";
16
+ import type { DtoConstructor } from "../../core/types";
17
+
18
+ export type InputLocation = "params" | "query" | "body";
19
+
20
+ interface CoerceInputOptions {
21
+ mode: InputCoercionMode;
22
+ location: InputLocation;
23
+ }
24
+
25
+ interface CoerceField {
26
+ name: string;
27
+ schema: SchemaNode;
28
+ }
29
+
30
+ interface CoerceResult {
31
+ value: unknown;
32
+ invalidFields: string[];
33
+ }
34
+
35
+ interface CoerceOutcome {
36
+ value: unknown;
37
+ ok: boolean;
38
+ changed: boolean;
39
+ }
40
+
41
+ /**
42
+ * Creates an input coercer function for the given input metadata.
43
+ */
44
+ export function createInputCoercer<T extends Record<string, unknown> = Record<string, unknown>>(
45
+ input: InputMeta | undefined,
46
+ options: CoerceInputOptions
47
+ ): ((value: T) => T) | undefined {
48
+ if (!input) {
49
+ return undefined;
50
+ }
51
+ const fields = extractFields(input.schema);
52
+ if (!fields.length) {
53
+ return undefined;
54
+ }
55
+ return (value: T): T => {
56
+ const result = coerceRecord(value, fields, options.mode);
57
+ if (options.mode === "strict" && result.invalidFields.length) {
58
+ throw new HttpError(400, buildInvalidMessage(options.location, result.invalidFields));
59
+ }
60
+ return result.value as T;
61
+ };
62
+ }
63
+
64
+ function coerceRecord(
65
+ value: unknown,
66
+ fields: CoerceField[],
67
+ mode: InputCoercionMode
68
+ ): CoerceResult {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return { value, invalidFields: [] };
71
+ }
72
+ const input = value as Record<string, unknown>;
73
+ let changed = false;
74
+ const output: Record<string, unknown> = { ...input };
75
+ const invalidFields: string[] = [];
76
+ for (const field of fields) {
77
+ if (!(field.name in input)) {
78
+ continue;
79
+ }
80
+ const original = input[field.name];
81
+ const result = coerceValue(original, field.schema, mode);
82
+ if (!result.ok && mode === "strict") {
83
+ invalidFields.push(field.name);
84
+ }
85
+ if (result.changed) {
86
+ output[field.name] = result.value;
87
+ changed = true;
88
+ }
89
+ }
90
+ return { value: changed ? output : value, invalidFields };
91
+ }
92
+
93
+ function coerceValue(
94
+ value: unknown,
95
+ schema: SchemaNode,
96
+ mode: InputCoercionMode
97
+ ): CoerceOutcome {
98
+ switch (schema.kind) {
99
+ case "integer":
100
+ return coerceNumber(value, schema, true);
101
+ case "number":
102
+ return coerceNumber(value, schema, false);
103
+ case "boolean": {
104
+ return coerceBoolean(value);
105
+ }
106
+ case "string": {
107
+ return coerceString(value, schema);
108
+ }
109
+ case "array":
110
+ return coerceArrayValue(value, schema, mode);
111
+ case "object":
112
+ return coerceObjectValue(value, schema, mode);
113
+ case "record":
114
+ return coerceRecordValue(value, schema, mode);
115
+ case "ref":
116
+ return coerceRefValue(value, schema, mode);
117
+ case "union":
118
+ return coerceUnionValue(value, schema, mode);
119
+ default:
120
+ return { value, ok: true, changed: false };
121
+ }
122
+ }
123
+
124
+ function coerceNumber(value: unknown, schema: NumberSchema, integer: boolean): CoerceOutcome {
125
+ if (!isPresent(value)) {
126
+ return { value, ok: true, changed: false };
127
+ }
128
+ const parsed = integer
129
+ ? coerce.integer(value, { min: schema.minimum, max: schema.maximum })
130
+ : coerce.number(value, { min: schema.minimum, max: schema.maximum });
131
+ if (parsed === undefined) {
132
+ return { value, ok: false, changed: false };
133
+ }
134
+ if (schema.exclusiveMinimum !== undefined && parsed <= schema.exclusiveMinimum) {
135
+ return { value, ok: false, changed: false };
136
+ }
137
+ if (schema.exclusiveMaximum !== undefined && parsed >= schema.exclusiveMaximum) {
138
+ return { value, ok: false, changed: false };
139
+ }
140
+ return { value: parsed, ok: true, changed: parsed !== value };
141
+ }
142
+
143
+ function coerceBoolean(value: unknown): CoerceOutcome {
144
+ if (!isPresent(value)) {
145
+ return { value, ok: true, changed: false };
146
+ }
147
+ const parsed = coerce.boolean(value);
148
+ if (parsed === undefined) {
149
+ return { value, ok: false, changed: false };
150
+ }
151
+ return { value: parsed, ok: true, changed: parsed !== value };
152
+ }
153
+
154
+ function coerceString(value: unknown, schema: StringSchema): CoerceOutcome {
155
+ if (!isPresent(value)) {
156
+ return { value, ok: true, changed: false };
157
+ }
158
+ if (schema.format === "date" || schema.format === "date-time") {
159
+ const parsed = parseDateValue(value);
160
+ if (!parsed) {
161
+ return { value, ok: false, changed: false };
162
+ }
163
+ return { value: parsed, ok: true, changed: parsed !== value };
164
+ }
165
+ const parsed = coerce.string(value);
166
+ if (parsed === undefined) {
167
+ return { value, ok: true, changed: false };
168
+ }
169
+ return { value: parsed, ok: true, changed: parsed !== value };
170
+ }
171
+
172
+ function parseDateValue(value: unknown): Date | undefined {
173
+ if (value instanceof Date) {
174
+ return Number.isNaN(value.getTime()) ? undefined : value;
175
+ }
176
+ const text = coerce.string(value);
177
+ if (text === undefined) {
178
+ return undefined;
179
+ }
180
+ const parsed = new Date(text);
181
+ if (Number.isNaN(parsed.getTime())) {
182
+ return undefined;
183
+ }
184
+ return parsed;
185
+ }
186
+
187
+ function coerceArrayValue(
188
+ value: unknown,
189
+ schema: ArraySchema,
190
+ mode: InputCoercionMode
191
+ ): CoerceOutcome {
192
+ if (value === undefined || value === null) {
193
+ return { value, ok: true, changed: false };
194
+ }
195
+ let input: unknown[];
196
+ let changed: boolean;
197
+ if (Array.isArray(value)) {
198
+ input = value;
199
+ changed = false;
200
+ } else if (typeof value === "string" && value.includes(",")) {
201
+ input = value.split(",").map((s) => s.trim());
202
+ changed = true;
203
+ } else {
204
+ input = [value];
205
+ changed = true;
206
+ }
207
+ let ok = true;
208
+ const output = input.map((entry) => {
209
+ const result = coerceValue(entry, schema.items, mode);
210
+ if (!result.ok) {
211
+ ok = false;
212
+ }
213
+ if (result.changed) {
214
+ changed = true;
215
+ }
216
+ return result.value;
217
+ });
218
+ return { value: changed ? output : value, ok, changed };
219
+ }
220
+
221
+ function coerceObjectValue(
222
+ value: unknown,
223
+ schema: ObjectSchema,
224
+ mode: InputCoercionMode
225
+ ): CoerceOutcome {
226
+ if (value === undefined || value === null) {
227
+ return { value, ok: true, changed: false };
228
+ }
229
+ if (typeof value !== "object" || Array.isArray(value)) {
230
+ return { value, ok: mode === "safe", changed: false };
231
+ }
232
+ const properties = schema.properties ?? {};
233
+ const fields = Object.entries(properties).map(([name, fieldSchema]) => ({
234
+ name,
235
+ schema: fieldSchema
236
+ }));
237
+ if (!fields.length) {
238
+ return { value, ok: true, changed: false };
239
+ }
240
+ const result = coerceRecord(value, fields, mode);
241
+ return {
242
+ value: result.value,
243
+ ok: result.invalidFields.length === 0,
244
+ changed: result.value !== value
245
+ };
246
+ }
247
+
248
+ function coerceRecordValue(
249
+ value: unknown,
250
+ schema: RecordSchema,
251
+ mode: InputCoercionMode
252
+ ): CoerceOutcome {
253
+ if (value === undefined || value === null) {
254
+ return { value, ok: true, changed: false };
255
+ }
256
+ if (typeof value !== "object" || Array.isArray(value)) {
257
+ return { value, ok: mode === "safe", changed: false };
258
+ }
259
+ const input = value as Record<string, unknown>;
260
+ let changed = false;
261
+ let ok = true;
262
+ const output: Record<string, unknown> = { ...input };
263
+ for (const [key, entry] of Object.entries(input)) {
264
+ const result = coerceValue(entry, schema.values, mode);
265
+ if (!result.ok) {
266
+ ok = false;
267
+ }
268
+ if (result.changed) {
269
+ output[key] = result.value;
270
+ changed = true;
271
+ }
272
+ }
273
+ return { value: changed ? output : value, ok, changed };
274
+ }
275
+
276
+ function coerceRefValue(
277
+ value: unknown,
278
+ schema: RefSchema,
279
+ mode: InputCoercionMode
280
+ ): CoerceOutcome {
281
+ if (value === undefined || value === null) {
282
+ return { value, ok: true, changed: false };
283
+ }
284
+ if (typeof value !== "object" || Array.isArray(value)) {
285
+ return { value, ok: mode === "safe", changed: false };
286
+ }
287
+ const meta = getDtoMetaSafe(schema.dto);
288
+ const fields = Object.entries(meta.fields).map(([name, field]) => ({
289
+ name,
290
+ schema: field.schema
291
+ }));
292
+ if (!fields.length) {
293
+ return { value, ok: true, changed: false };
294
+ }
295
+ const result = coerceRecord(value, fields, mode);
296
+ return {
297
+ value: result.value,
298
+ ok: result.invalidFields.length === 0,
299
+ changed: result.value !== value
300
+ };
301
+ }
302
+
303
+ function coerceUnionValue(
304
+ value: unknown,
305
+ schema: UnionSchema,
306
+ mode: InputCoercionMode
307
+ ): CoerceOutcome {
308
+ let fallback: CoerceOutcome | undefined;
309
+ for (const option of schema.anyOf) {
310
+ const result = coerceValue(value, option, mode);
311
+ if (!result.ok) {
312
+ continue;
313
+ }
314
+ if (result.changed) {
315
+ return result;
316
+ }
317
+ fallback ??= result;
318
+ }
319
+ if (fallback) {
320
+ return fallback;
321
+ }
322
+ return { value, ok: mode === "safe", changed: false };
323
+ }
324
+
325
+ function extractFields(schema: SchemaSource): CoerceField[] {
326
+ if (isSchemaNode(schema)) {
327
+ if (schema.kind === "object" && schema.properties) {
328
+ return Object.entries(schema.properties).map(([name, fieldSchema]) => ({
329
+ name,
330
+ schema: fieldSchema
331
+ }));
332
+ }
333
+ return [];
334
+ }
335
+ const meta = getDtoMetaSafe(schema);
336
+ return Object.entries(meta.fields).map(([name, field]) => ({
337
+ name,
338
+ schema: field.schema
339
+ }));
340
+ }
341
+
342
+ function getDtoMetaSafe(dto: DtoConstructor): {
343
+ fields: Record<string, { schema: SchemaNode }>;
344
+ } {
345
+ const meta = getDtoMeta(dto);
346
+ if (!meta) {
347
+ throw new Error(`DTO "${dto.name}" is missing @Dto decorator.`);
348
+ }
349
+ return meta;
350
+ }
351
+
352
+ function isSchemaNode(value: unknown): value is SchemaNode {
353
+ return !!value && typeof value === "object" && "kind" in (value as SchemaNode);
354
+ }
355
+
356
+ function isPresent(value: unknown): boolean {
357
+ return coerce.string(value) !== undefined;
358
+ }
359
+
360
+ function buildInvalidMessage(location: InputLocation, fields: string[]): string {
361
+ let label = "query parameter";
362
+ if (location === "params") {
363
+ label = "path parameter";
364
+ } else if (location === "body") {
365
+ label = "request body field";
366
+ }
367
+ const suffix = fields.length > 1 ? "s" : "";
368
+ return `Invalid ${label}${suffix}: ${fields.join(", ")}.`;
369
+ }