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.
- package/README.md +18 -0
- package/dist/adapter/express/types.d.ts +3 -46
- package/dist/adapter/fastify/coercion.d.ts +12 -0
- package/dist/adapter/fastify/coercion.js +289 -0
- package/dist/adapter/fastify/controllers.d.ts +7 -0
- package/dist/adapter/fastify/controllers.js +201 -0
- package/dist/adapter/fastify/index.d.ts +14 -0
- package/dist/adapter/fastify/index.js +67 -0
- package/dist/adapter/fastify/multipart.d.ts +26 -0
- package/dist/adapter/fastify/multipart.js +75 -0
- package/dist/adapter/fastify/openapi.d.ts +10 -0
- package/dist/adapter/fastify/openapi.js +76 -0
- package/dist/adapter/fastify/response-serializer.d.ts +2 -0
- package/dist/adapter/fastify/response-serializer.js +162 -0
- package/dist/adapter/fastify/types.d.ts +100 -0
- package/dist/adapter/fastify/types.js +2 -0
- package/dist/adapter/metal-orm/index.d.ts +1 -1
- package/dist/adapter/metal-orm/types.d.ts +23 -0
- package/dist/adapter/native/coercion.d.ts +12 -0
- package/dist/adapter/native/coercion.js +289 -0
- package/dist/adapter/native/controllers.d.ts +17 -0
- package/dist/adapter/native/controllers.js +215 -0
- package/dist/adapter/native/index.d.ts +14 -0
- package/dist/adapter/native/index.js +127 -0
- package/dist/adapter/native/openapi.d.ts +7 -0
- package/dist/adapter/native/openapi.js +82 -0
- package/dist/adapter/native/response-serializer.d.ts +5 -0
- package/dist/adapter/native/response-serializer.js +160 -0
- package/dist/adapter/native/router.d.ts +25 -0
- package/dist/adapter/native/router.js +68 -0
- package/dist/adapter/native/types.d.ts +77 -0
- package/dist/adapter/native/types.js +2 -0
- package/dist/core/auth.d.ts +11 -12
- package/dist/core/auth.js +2 -2
- package/dist/core/logger.d.ts +3 -4
- package/dist/core/logger.js +2 -2
- package/dist/core/streaming.d.ts +10 -10
- package/dist/core/streaming.js +31 -19
- package/dist/core/types.d.ts +102 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +16 -1
- package/examples/fastify/app.ts +16 -0
- package/examples/fastify/index.ts +21 -0
- package/package.json +24 -18
- package/src/adapter/express/controllers.ts +249 -249
- package/src/adapter/express/types.ts +121 -160
- package/src/adapter/fastify/coercion.ts +369 -0
- package/src/adapter/fastify/controllers.ts +255 -0
- package/src/adapter/fastify/index.ts +53 -0
- package/src/adapter/fastify/multipart.ts +94 -0
- package/src/adapter/fastify/openapi.ts +93 -0
- package/src/adapter/fastify/response-serializer.ts +179 -0
- package/src/adapter/fastify/types.ts +119 -0
- package/src/adapter/metal-orm/index.ts +3 -0
- package/src/adapter/metal-orm/types.ts +25 -0
- package/src/adapter/native/coercion.ts +369 -0
- package/src/adapter/native/controllers.ts +271 -0
- package/src/adapter/native/index.ts +116 -0
- package/src/adapter/native/openapi.ts +109 -0
- package/src/adapter/native/response-serializer.ts +177 -0
- package/src/adapter/native/router.ts +90 -0
- package/src/adapter/native/types.ts +96 -0
- package/src/core/auth.ts +314 -315
- package/src/core/health.ts +234 -235
- package/src/core/logger.ts +245 -247
- package/src/core/streaming.ts +342 -330
- package/src/core/types.ts +115 -0
- package/src/index.ts +46 -16
- package/tests/e2e/fastify.e2e.test.ts +174 -0
- package/tests/native.test.ts +191 -0
- package/tests/typecheck/query-params.typecheck.ts +42 -0
- package/tests/unit/openapi-parameters.test.ts +97 -97
- package/tsconfig.json +14 -13
- package/tsconfig.typecheck.json +8 -0
- package/vitest.config.ts +47 -7
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Request context provided to Fastify route handlers.
|
|
10
|
+
*/
|
|
11
|
+
export type RequestContext<
|
|
12
|
+
TBody = any,
|
|
13
|
+
TQuery extends object | undefined = Record<string, any>,
|
|
14
|
+
TParams extends object | undefined = Record<string, any>,
|
|
15
|
+
THeaders extends object | undefined = Record<string, any>,
|
|
16
|
+
TFiles extends Record<string, UploadedFileInfo | UploadedFileInfo[]> | undefined = any
|
|
17
|
+
> = CoreRequestContext<TBody, TQuery, TParams, THeaders, TFiles>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Input coercion modes.
|
|
21
|
+
*/
|
|
22
|
+
export type InputCoercionMode = "safe" | "strict";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Input coercion setting - can be a mode or disabled.
|
|
26
|
+
*/
|
|
27
|
+
export type InputCoercionSetting = InputCoercionMode | false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CORS configuration options.
|
|
31
|
+
*/
|
|
32
|
+
export interface CorsOptions {
|
|
33
|
+
/** Allowed origins. Use "*" for all, a string, array of strings, or a function for dynamic matching. */
|
|
34
|
+
origin?: string | string[] | ((origin: string | undefined) => boolean | string);
|
|
35
|
+
/** Allowed HTTP methods. Defaults to ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]. */
|
|
36
|
+
methods?: string[];
|
|
37
|
+
/** Allowed headers. Defaults to ["Content-Type", "Authorization"]. */
|
|
38
|
+
allowedHeaders?: string[];
|
|
39
|
+
/** Headers exposed to the client. */
|
|
40
|
+
exposedHeaders?: string[];
|
|
41
|
+
/** Whether to include credentials (cookies, authorization headers). Defaults to false. */
|
|
42
|
+
credentials?: boolean;
|
|
43
|
+
/** Max age in seconds for preflight cache. Defaults to 86400 (24 hours). */
|
|
44
|
+
maxAge?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Options for OpenAPI documentation UI.
|
|
49
|
+
*/
|
|
50
|
+
export interface OpenApiDocsOptions {
|
|
51
|
+
/** Path for documentation UI */
|
|
52
|
+
path?: string;
|
|
53
|
+
/** Title for documentation page */
|
|
54
|
+
title?: string;
|
|
55
|
+
/** URL for Swagger UI assets */
|
|
56
|
+
swaggerUiUrl?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* OpenAPI configuration for Fastify adapter.
|
|
61
|
+
*/
|
|
62
|
+
export interface OpenApiFastifyOptions {
|
|
63
|
+
/** OpenAPI document info */
|
|
64
|
+
info: OpenApiInfo;
|
|
65
|
+
/** Array of servers */
|
|
66
|
+
servers?: OpenApiServer[];
|
|
67
|
+
/** Path for OpenAPI JSON endpoint */
|
|
68
|
+
path?: string;
|
|
69
|
+
/** Whether to pretty-print the JSON output (defaults to false for minified output) */
|
|
70
|
+
prettyPrint?: boolean;
|
|
71
|
+
/** Documentation UI configuration */
|
|
72
|
+
docs?: boolean | OpenApiDocsOptions;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Multipart file upload configuration.
|
|
77
|
+
*/
|
|
78
|
+
export interface MultipartOptions {
|
|
79
|
+
/** Storage type: 'memory' or 'disk' (Fastify adapter mainly supports memory via @fastify/multipart) */
|
|
80
|
+
storage?: "memory" | "disk";
|
|
81
|
+
/** Directory for disk storage (defaults to OS temp directory) */
|
|
82
|
+
dest?: string;
|
|
83
|
+
/** Maximum file size in bytes (defaults to 10MB) */
|
|
84
|
+
maxFileSize?: number;
|
|
85
|
+
/** Maximum number of files per field (defaults to 10) */
|
|
86
|
+
maxFiles?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validation configuration options.
|
|
91
|
+
*/
|
|
92
|
+
export interface ValidationOptions {
|
|
93
|
+
/** Whether validation is enabled. Defaults to true. */
|
|
94
|
+
enabled?: boolean;
|
|
95
|
+
/** Validation mode. 'strict' mode fails on any validation error, 'lax' mode may allow some errors. Defaults to 'strict'. */
|
|
96
|
+
mode?: 'strict' | 'lax';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Options for creating a Fastify application adapter.
|
|
101
|
+
*/
|
|
102
|
+
export interface FastifyAdapterOptions {
|
|
103
|
+
/** Array of controller classes */
|
|
104
|
+
controllers: Constructor[];
|
|
105
|
+
/** Whether to enable JSON body parsing */
|
|
106
|
+
jsonBody?: boolean;
|
|
107
|
+
/** Max JSON body size (e.g. 1048576 for 1MB). */
|
|
108
|
+
bodyLimit?: number;
|
|
109
|
+
/** OpenAPI configuration */
|
|
110
|
+
openApi?: OpenApiFastifyOptions;
|
|
111
|
+
/** Input coercion setting */
|
|
112
|
+
inputCoercion?: InputCoercionSetting;
|
|
113
|
+
/** CORS configuration. Set to true for permissive defaults, or provide options. */
|
|
114
|
+
cors?: boolean | CorsOptions;
|
|
115
|
+
/** Multipart file upload configuration. Set to true for defaults, or provide options. */
|
|
116
|
+
multipart?: boolean | MultipartOptions;
|
|
117
|
+
/** Validation configuration. Set to false to disable validation, or provide options. */
|
|
118
|
+
validation?: boolean | ValidationOptions;
|
|
119
|
+
}
|
|
@@ -78,6 +78,7 @@ export type {
|
|
|
78
78
|
PaginationConfig,
|
|
79
79
|
PaginationOptions,
|
|
80
80
|
ParsedPagination,
|
|
81
|
+
PaginationQueryParams,
|
|
81
82
|
Filter,
|
|
82
83
|
FilterMapping,
|
|
83
84
|
FilterFieldMapping,
|
|
@@ -89,6 +90,8 @@ export type {
|
|
|
89
90
|
ParseSortOptions,
|
|
90
91
|
ParsedSort,
|
|
91
92
|
SortDirection,
|
|
93
|
+
SortingQueryParams,
|
|
94
|
+
PagedQueryParams,
|
|
92
95
|
CrudListSortTerm,
|
|
93
96
|
RunPagedListOptions,
|
|
94
97
|
ExecuteCrudListOptions,
|
|
@@ -74,6 +74,16 @@ export interface ParsedPagination {
|
|
|
74
74
|
pageSize: number;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Pagination query params for consumer-side TypeScript interfaces.
|
|
79
|
+
*/
|
|
80
|
+
export interface PaginationQueryParams {
|
|
81
|
+
/** Page number */
|
|
82
|
+
page?: number;
|
|
83
|
+
/** Page size */
|
|
84
|
+
pageSize?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
/**
|
|
78
88
|
* Filter field mapping.
|
|
79
89
|
*/
|
|
@@ -155,6 +165,21 @@ export interface ParseFilterOptions<T = Record<string, unknown>> {
|
|
|
155
165
|
*/
|
|
156
166
|
export type SortDirection = "asc" | "desc";
|
|
157
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Sorting query params for consumer-side TypeScript interfaces.
|
|
170
|
+
*/
|
|
171
|
+
export interface SortingQueryParams {
|
|
172
|
+
/** Requested sort key */
|
|
173
|
+
sortBy?: string;
|
|
174
|
+
/** Sort direction */
|
|
175
|
+
sortDirection?: SortDirection;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Combined pagination + sorting query params.
|
|
180
|
+
*/
|
|
181
|
+
export interface PagedQueryParams extends PaginationQueryParams, SortingQueryParams {}
|
|
182
|
+
|
|
158
183
|
/**
|
|
159
184
|
* Sort parsing options.
|
|
160
185
|
*/
|
|
@@ -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
|
+
}
|