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
@@ -0,0 +1,271 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Constructor, RequestContext } from "../../core/types";
3
+ import type { SchemaSource } from "../../core/schema";
4
+ import { getControllerMeta } from "../../core/metadata";
5
+ import { getRouteAuthMeta } from "../../core/auth";
6
+ import { isHttpError, HttpError } from "../../core/errors";
7
+ import { isHttpResponse } from "../../core/response";
8
+ import type { InputCoercionSetting, ValidationOptions, RequestContext as NativeRequestContext } from "./types";
9
+ import { createInputCoercer } from "./coercion";
10
+ import { serializeResponse } from "./response-serializer";
11
+ import { lifecycleRegistry } from "../../core/lifecycle";
12
+ import { createSseEmitter, createStreamWriter } from "../../core/streaming";
13
+ import { validate } from "../../core/validation";
14
+ import { ValidationErrors, isValidationErrors } from "../../core/validation-errors";
15
+ import { Router } from "./router";
16
+
17
+ /**
18
+ * Registers controllers with a native application router.
19
+ */
20
+ export async function registerControllers(
21
+ router: Router,
22
+ controllers: Constructor[]
23
+ ): Promise<void> {
24
+ for (const controller of controllers) {
25
+ const meta = getControllerMeta(controller);
26
+ if (!meta) {
27
+ throw new Error(`Controller "${controller.name}" is missing @Controller decorator.`);
28
+ }
29
+ const instance = new controller();
30
+ lifecycleRegistry.register(instance);
31
+ await lifecycleRegistry.callOnModuleInit(instance);
32
+
33
+ for (const route of meta.routes) {
34
+ router.add(instance, route, meta.basePath);
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Dispatches a native request to the appropriate controller handler.
41
+ */
42
+ export async function dispatchRequest(
43
+ req: IncomingMessage,
44
+ res: ServerResponse,
45
+ match: any,
46
+ options: {
47
+ inputCoercion: InputCoercionSetting;
48
+ validation?: boolean | ValidationOptions;
49
+ body?: any;
50
+ query?: Record<string, any>;
51
+ }
52
+ ): Promise<void> {
53
+ const { controller: instance, route, params: rawParams } = match;
54
+ const { inputCoercion, validation, body: rawBody, query: rawQuery } = options;
55
+
56
+ const handler = instance[route.handlerName];
57
+ if (typeof handler !== "function") {
58
+ throw new Error(`Handler "${String(route.handlerName)}" is not a function.`);
59
+ }
60
+
61
+ const coerceParams = inputCoercion === false
62
+ ? undefined
63
+ : createInputCoercer<Record<string, any>>(
64
+ route.params,
65
+ { mode: inputCoercion, location: "params" }
66
+ );
67
+ const coerceQuery = inputCoercion === false
68
+ ? undefined
69
+ : createInputCoercer<Record<string, any>>(route.query, { mode: inputCoercion, location: "query" });
70
+ const coerceBody = inputCoercion === false
71
+ ? undefined
72
+ : createInputCoercer<Record<string, any>>(route.body, { mode: inputCoercion, location: "body" });
73
+
74
+ const isValidationEnabled = validation !== false && (validation as ValidationOptions)?.enabled !== false;
75
+
76
+ const authMeta = getRouteAuthMeta(instance.constructor as Constructor, route.handlerName);
77
+
78
+ try {
79
+ // Apply auth guard if metadata exists
80
+ if (authMeta && authMeta.requiresAuth && !authMeta.isPublic) {
81
+ const user = (req as any).user;
82
+ if (!user) {
83
+ throw new HttpError(401, "Unauthorized");
84
+ }
85
+
86
+ if (authMeta.roles?.length) {
87
+ const hasRole = authMeta.roles.some((role: string) => user.roles?.includes(role));
88
+ if (!hasRole) {
89
+ throw new HttpError(403, "Insufficient permissions");
90
+ }
91
+ }
92
+
93
+ if (authMeta.allRoles?.length) {
94
+ const hasAllRoles = authMeta.allRoles.every((role: string) => user.roles?.includes(role));
95
+ if (!hasAllRoles) {
96
+ throw new HttpError(403, "Insufficient permissions");
97
+ }
98
+ }
99
+
100
+ if (authMeta.guard) {
101
+ const allowed = await authMeta.guard(user, req);
102
+ if (!allowed) {
103
+ throw new HttpError(403, "Access denied by guard");
104
+ }
105
+ }
106
+ }
107
+
108
+ const body = (coerceBody && rawBody) ? coerceBody(rawBody) : rawBody;
109
+ const query = (coerceQuery && rawQuery) ? coerceQuery(rawQuery) : rawQuery;
110
+ const params = (coerceParams && rawParams) ? coerceParams(rawParams) : rawParams;
111
+
112
+ if (isValidationEnabled) {
113
+ const validationErrors = [];
114
+
115
+ if (route.body) {
116
+ const bodyErrors = validate(body, route.body.schema);
117
+ validationErrors.push(...bodyErrors);
118
+ }
119
+
120
+ if (route.query) {
121
+ const queryErrors = validate(query, route.query.schema);
122
+ validationErrors.push(...queryErrors);
123
+ }
124
+
125
+ if (route.params) {
126
+ const paramsErrors = validate(params, route.params.schema);
127
+ validationErrors.push(...paramsErrors);
128
+ }
129
+
130
+ if (route.headers) {
131
+ const headersErrors = validate(req.headers, route.headers.schema);
132
+ validationErrors.push(...headersErrors);
133
+ }
134
+
135
+ if (validationErrors.length > 0) {
136
+ throw new ValidationErrors(validationErrors);
137
+ }
138
+ }
139
+
140
+ const ctx = {
141
+ req,
142
+ res,
143
+ body,
144
+ query,
145
+ params,
146
+ headers: req.headers,
147
+ files: undefined, // Native adapter doesn't support multipart yet
148
+ sse: route.sse ? createSseEmitter(res) : undefined,
149
+ stream: route.streaming || route.sse ? createStreamWriter(res) : undefined
150
+ } as unknown as NativeRequestContext;
151
+
152
+ const result = await (handler as (...args: any[]) => any).call(instance, ctx);
153
+
154
+ if (res.writableEnded || route.sse || route.streaming) {
155
+ return;
156
+ }
157
+
158
+ if (isHttpResponse(result)) {
159
+ if (result.headers) {
160
+ for (const [key, value] of Object.entries(result.headers)) {
161
+ if (value !== undefined) {
162
+ res.setHeader(key, value);
163
+ }
164
+ }
165
+ }
166
+
167
+ if (result.body === undefined) {
168
+ res.statusCode = result.status;
169
+ res.end();
170
+ } else if (route.raw) {
171
+ if (!res.getHeader("Content-Type")) {
172
+ const ct = getResponseContentType(route) ?? "application/octet-stream";
173
+ res.setHeader("Content-Type", ct);
174
+ }
175
+ res.statusCode = result.status;
176
+ res.end(result.body);
177
+ } else {
178
+ const responseSchema = getResponseSchemaForStatus(route, result.status);
179
+ const output = responseSchema ? serializeResponse(result.body, responseSchema) : result.body;
180
+ res.statusCode = result.status;
181
+ res.setHeader("Content-Type", "application/json");
182
+ res.end(JSON.stringify(output));
183
+ }
184
+ return;
185
+ }
186
+
187
+ if (result === undefined) {
188
+ res.statusCode = defaultStatus(route);
189
+ res.end();
190
+ return;
191
+ }
192
+
193
+ if (route.raw) {
194
+ if (!res.getHeader("Content-Type")) {
195
+ const ct = getResponseContentType(route) ?? "application/octet-stream";
196
+ res.setHeader("Content-Type", ct);
197
+ }
198
+ res.statusCode = defaultStatus(route);
199
+ res.end(result);
200
+ } else {
201
+ const responseSchema = getResponseSchema(route);
202
+ const output = responseSchema ? serializeResponse(result, responseSchema) : result;
203
+ res.statusCode = defaultStatus(route);
204
+ res.setHeader("Content-Type", "application/json");
205
+ res.end(JSON.stringify(output));
206
+ }
207
+ } catch (error) {
208
+ if (isValidationErrors(error)) {
209
+ res.statusCode = error.status;
210
+ res.setHeader("Content-Type", "application/json");
211
+ res.end(JSON.stringify(error.body));
212
+ return;
213
+ }
214
+ if (isHttpError(error)) {
215
+ if (error.headers) {
216
+ for (const [key, value] of Object.entries(error.headers)) {
217
+ if (value !== undefined) {
218
+ res.setHeader(key, value);
219
+ }
220
+ }
221
+ }
222
+ const body = error.body ?? { message: error.message };
223
+ res.statusCode = error.status;
224
+ res.setHeader("Content-Type", "application/json");
225
+ res.end(JSON.stringify(body));
226
+ return;
227
+ }
228
+
229
+ console.error("Unhandled error:", error);
230
+ res.statusCode = 500;
231
+ res.setHeader("Content-Type", "application/json");
232
+ res.end(JSON.stringify({ message: "Internal server error" }));
233
+ }
234
+ }
235
+
236
+ function defaultStatus(route: {
237
+ responses?: Array<{ status: number; error?: boolean }>;
238
+ }): number {
239
+ const responses = route.responses ?? [];
240
+ const success = responses.find(
241
+ (response) => !response.error && response.status < 400
242
+ );
243
+ return success?.status ?? 200;
244
+ }
245
+
246
+ function getResponseSchema(route: {
247
+ responses?: Array<{ status: number; error?: boolean; schema?: SchemaSource }>;
248
+ }): SchemaSource | undefined {
249
+ const responses = route.responses ?? [];
250
+ const success = responses.find((response) => !response.error && response.status < 400);
251
+ return success?.schema;
252
+ }
253
+
254
+ function getResponseContentType(route: {
255
+ responses?: Array<{ status: number; error?: boolean; contentType?: string }>;
256
+ }): string | undefined {
257
+ const responses = route.responses ?? [];
258
+ const success = responses.find((r) => !r.error && r.status < 400);
259
+ return success?.contentType;
260
+ }
261
+
262
+ function getResponseSchemaForStatus(
263
+ route: {
264
+ responses?: Array<{ status: number; error?: boolean; schema?: SchemaSource }>;
265
+ },
266
+ status: number
267
+ ): SchemaSource | undefined {
268
+ const responses = route.responses ?? [];
269
+ const response = responses.find((r) => r.status === status);
270
+ return response?.schema;
271
+ }
@@ -0,0 +1,116 @@
1
+ import { createServer, IncomingMessage, ServerResponse } from "node:http";
2
+ import type { NativeAdapterOptions, NativeApp } from "./types";
3
+ import { registerControllers, dispatchRequest } from "./controllers";
4
+ import { registerOpenApi } from "./openapi";
5
+ import { lifecycleRegistry } from "../../core/lifecycle";
6
+ import { Router } from "./router";
7
+
8
+ export * from "./types";
9
+ export { registerControllers as attachControllers } from "./controllers";
10
+ export { registerOpenApi as attachOpenApi } from "./openapi";
11
+
12
+ /**
13
+ * Creates a native Node.js application with Adorn controllers.
14
+ * @param options - Native adapter options
15
+ * @returns Native application object
16
+ */
17
+ export async function createNativeApp(options: NativeAdapterOptions): Promise<NativeApp> {
18
+ const router = new Router();
19
+ const inputCoercion = options.inputCoercion ?? "safe";
20
+
21
+ await registerControllers(router, options.controllers);
22
+
23
+ if (options.openApi) {
24
+ registerOpenApi(router, options.controllers, options.openApi);
25
+ }
26
+
27
+ const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
28
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
29
+ const match = router.match(req.method || "GET", url.pathname);
30
+
31
+ if (!match) {
32
+ res.statusCode = 404;
33
+ res.setHeader("Content-Type", "application/json");
34
+ res.end(JSON.stringify({ message: `Not Found: ${req.method} ${url.pathname}` }));
35
+ return;
36
+ }
37
+
38
+ const query: Record<string, any> = {};
39
+ url.searchParams.forEach((value, key) => {
40
+ if (query[key]) {
41
+ if (Array.isArray(query[key])) {
42
+ query[key].push(value);
43
+ } else {
44
+ query[key] = [query[key], value];
45
+ }
46
+ } else {
47
+ query[key] = value;
48
+ }
49
+ });
50
+
51
+ let body: any = undefined;
52
+ if (options.jsonBody !== false && (req.method === "POST" || req.method === "PUT" || req.method === "PATCH")) {
53
+ try {
54
+ body = await parseJsonBody(req, options.bodyLimit);
55
+ } catch (error) {
56
+ res.statusCode = 400;
57
+ res.setHeader("Content-Type", "application/json");
58
+ res.end(JSON.stringify({ message: "Invalid JSON body" }));
59
+ return;
60
+ }
61
+ }
62
+
63
+ await dispatchRequest(req, res, match, {
64
+ inputCoercion,
65
+ validation: options.validation,
66
+ body,
67
+ query
68
+ });
69
+ };
70
+
71
+ await lifecycleRegistry.callOnApplicationBootstrap();
72
+
73
+ return {
74
+ handle,
75
+ listen: (port: number, callback?: () => void) => {
76
+ const server = createServer(handle);
77
+ return server.listen(port, callback);
78
+ }
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Trigger shutdown hooks for graceful application shutdown.
84
+ */
85
+ export async function shutdownApp(signal?: string): Promise<void> {
86
+ await lifecycleRegistry.callShutdownHooks(signal);
87
+ lifecycleRegistry.clear();
88
+ }
89
+
90
+ async function parseJsonBody(req: IncomingMessage, limit?: number): Promise<any> {
91
+ return new Promise((resolve, reject) => {
92
+ let data = "";
93
+ let size = 0;
94
+ req.on("data", (chunk) => {
95
+ data += chunk;
96
+ size += chunk.length;
97
+ if (limit && size > limit) {
98
+ reject(new Error("Body too large"));
99
+ }
100
+ });
101
+ req.on("end", () => {
102
+ if (!data) {
103
+ resolve(undefined);
104
+ return;
105
+ }
106
+ try {
107
+ resolve(JSON.parse(data));
108
+ } catch (error) {
109
+ reject(error);
110
+ }
111
+ });
112
+ req.on("error", (err) => {
113
+ reject(err);
114
+ });
115
+ });
116
+ }
@@ -0,0 +1,109 @@
1
+ import { buildOpenApi } from "../../core/openapi";
2
+ import type { Constructor } from "../../core/types";
3
+ import type { OpenApiNativeOptions } from "./types";
4
+ import type { Router } from "./router";
5
+
6
+ /**
7
+ * Registers OpenAPI endpoints with a native application router.
8
+ */
9
+ export function registerOpenApi(
10
+ router: Router,
11
+ controllers: Constructor[],
12
+ options: OpenApiNativeOptions
13
+ ): void {
14
+ const openApiPath = normalizePath(options.path, "/openapi.json");
15
+ const document = buildOpenApi({
16
+ info: options.info,
17
+ servers: options.servers,
18
+ controllers
19
+ });
20
+
21
+ router.add(
22
+ {
23
+ getOpenApi: async () => {
24
+ return document;
25
+ }
26
+ },
27
+ {
28
+ handlerName: "getOpenApi",
29
+ httpMethod: "get",
30
+ path: openApiPath,
31
+ responses: [{ status: 200, description: "OpenAPI JSON" }]
32
+ } as any,
33
+ ""
34
+ );
35
+
36
+ if (!options.docs) {
37
+ return;
38
+ }
39
+
40
+ const docsOptions = typeof options.docs === "object" ? options.docs : {};
41
+ const docsPath = normalizePath(docsOptions.path, "/docs");
42
+ const title = docsOptions.title ?? `${options.info.title} Docs`;
43
+ const swaggerUiUrl = (docsOptions.swaggerUiUrl ?? "https://unpkg.com/swagger-ui-dist@5").replace(
44
+ /\/+$/,
45
+ ""
46
+ );
47
+
48
+ const html = buildSwaggerUiHtml({ title, swaggerUiUrl, openApiPath });
49
+
50
+ router.add(
51
+ {
52
+ getDocs: async () => {
53
+ return html;
54
+ }
55
+ },
56
+ {
57
+ handlerName: "getDocs",
58
+ httpMethod: "get",
59
+ path: docsPath,
60
+ raw: true,
61
+ responses: [{ status: 200, contentType: "text/html", description: "Swagger UI" }]
62
+ } as any,
63
+ ""
64
+ );
65
+ }
66
+
67
+ function normalizePath(path: string | undefined, fallback: string): string {
68
+ if (!path) {
69
+ return fallback;
70
+ }
71
+ return path.startsWith("/") ? path : `/${path}`;
72
+ }
73
+
74
+ function buildSwaggerUiHtml(options: {
75
+ title: string;
76
+ swaggerUiUrl: string;
77
+ openApiPath: string;
78
+ }): string {
79
+ return `<!doctype html>
80
+ <html lang="en">
81
+ <head>
82
+ <meta charset="utf-8" />
83
+ <title>${options.title}</title>
84
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
85
+ <link rel="stylesheet" href="${options.swaggerUiUrl}/swagger-ui.css" />
86
+ <style>
87
+ body {
88
+ margin: 0;
89
+ background: #f6f6f6;
90
+ }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <div id="swagger-ui"></div>
95
+ <script src="${options.swaggerUiUrl}/swagger-ui-bundle.js"></script>
96
+ <script>
97
+ window.onload = () => {
98
+ window.ui = SwaggerUIBundle({
99
+ url: "${options.openApiPath}",
100
+ dom_id: "#swagger-ui",
101
+ deepLinking: true,
102
+ presets: [SwaggerUIBundle.presets.apis],
103
+ layout: "BaseLayout"
104
+ });
105
+ };
106
+ </script>
107
+ </body>
108
+ </html>`;
109
+ }
@@ -0,0 +1,177 @@
1
+ import type { SchemaNode, SchemaSource } from "../../core/schema";
2
+ import type { DtoConstructor } from "../../core/types";
3
+ import { getDtoMeta } from "../../core/metadata";
4
+
5
+ /**
6
+ * Serializes a response body based on the provided schema.
7
+ */
8
+ export function serializeResponse(value: unknown, schema: SchemaSource): unknown {
9
+ if (value === null || value === undefined) {
10
+ return value;
11
+ }
12
+ if (isSchemaNode(schema)) {
13
+ return serializeWithSchema(value, schema);
14
+ }
15
+ return serializeWithDto(value, schema);
16
+ }
17
+
18
+ function serializeWithDto(value: unknown, dto: DtoConstructor): unknown {
19
+ if (value === null || value === undefined) {
20
+ return value;
21
+ }
22
+ if (Array.isArray(value)) {
23
+ return value.map((entry) => serializeWithDto(entry, dto));
24
+ }
25
+ const plainValue = toPlainObject(value);
26
+ if (!plainValue) {
27
+ return value;
28
+ }
29
+ const meta = getDtoMeta(dto);
30
+ if (!meta) {
31
+ return plainValue;
32
+ }
33
+ const output: Record<string, unknown> = { ...plainValue };
34
+ for (const [name, field] of Object.entries(meta.fields)) {
35
+ if (name in plainValue) {
36
+ output[name] = serializeWithSchema(plainValue[name], field.schema);
37
+ }
38
+ }
39
+ return output;
40
+ }
41
+
42
+ function serializeWithSchema(value: unknown, schema: SchemaNode): unknown {
43
+ if (value === null || value === undefined) {
44
+ return value;
45
+ }
46
+ switch (schema.kind) {
47
+ case "string":
48
+ return serializeString(value, schema.format);
49
+ case "array":
50
+ if (!Array.isArray(value)) {
51
+ return value;
52
+ }
53
+ return value.map((entry) => serializeWithSchema(entry, schema.items));
54
+ case "object":
55
+ return serializeObject(value, schema.properties);
56
+ case "record":
57
+ if (!isPlainObject(value)) {
58
+ return value;
59
+ }
60
+ return serializeRecord(value as Record<string, unknown>, schema.values);
61
+ case "ref":
62
+ return serializeWithDto(value, schema.dto);
63
+ case "union":
64
+ return serializeUnion(value, schema.anyOf);
65
+ default:
66
+ return value;
67
+ }
68
+ }
69
+
70
+ function serializeString(value: unknown, format: string | undefined): unknown {
71
+ if (format === "byte" && Buffer.isBuffer(value)) {
72
+ return value.toString("base64");
73
+ }
74
+ if (!(value instanceof Date)) {
75
+ return value;
76
+ }
77
+ if (Number.isNaN(value.getTime())) {
78
+ return value;
79
+ }
80
+ if (format === "date") {
81
+ return value.toISOString().slice(0, 10);
82
+ }
83
+ if (format === "date-time") {
84
+ return value.toISOString();
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function serializeObject(
90
+ value: unknown,
91
+ properties: Record<string, SchemaNode> | undefined
92
+ ): unknown {
93
+ const plainValue = toPlainObject(value);
94
+ if (!plainValue) {
95
+ return value;
96
+ }
97
+ const output: Record<string, unknown> = { ...plainValue };
98
+ if (!properties) {
99
+ return output;
100
+ }
101
+ for (const [key, schema] of Object.entries(properties)) {
102
+ if (key in plainValue) {
103
+ output[key] = serializeWithSchema(plainValue[key], schema);
104
+ }
105
+ }
106
+ return output;
107
+ }
108
+
109
+ function serializeRecord(
110
+ value: Record<string, unknown>,
111
+ schema: SchemaNode
112
+ ): Record<string, unknown> {
113
+ const output: Record<string, unknown> = { ...value };
114
+ for (const [key, entry] of Object.entries(value)) {
115
+ output[key] = serializeWithSchema(entry, schema);
116
+ }
117
+ return output;
118
+ }
119
+
120
+ function serializeUnion(value: unknown, options: SchemaNode[]): unknown {
121
+ for (const option of options) {
122
+ const serialized = serializeWithSchema(value, option);
123
+ if (serialized !== value) {
124
+ return serialized;
125
+ }
126
+ }
127
+ return value;
128
+ }
129
+
130
+ function isSchemaNode(value: unknown): value is SchemaNode {
131
+ return !!value && typeof value === "object" && "kind" in (value as SchemaNode);
132
+ }
133
+
134
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
135
+ return (
136
+ value !== null &&
137
+ typeof value === "object" &&
138
+ !Array.isArray(value) &&
139
+ !(value instanceof Date)
140
+ );
141
+ }
142
+
143
+ function toPlainObject(value: unknown): Record<string, unknown> | null {
144
+ if (value !== null &&
145
+ typeof value === "object" &&
146
+ typeof (value as { toJSON?: () => unknown }).toJSON === "function") {
147
+ const jsonResult = (value as { toJSON: () => unknown }).toJSON();
148
+ return jsonResult as Record<string, unknown>;
149
+ }
150
+
151
+ if (typeof value === "object" && typeof (value as Record<string, unknown>).load === "function") {
152
+ const wrapper = value as { current: unknown; loaded: boolean; load: () => unknown };
153
+ if (wrapper.current !== undefined && wrapper.current !== null) {
154
+ return toPlainObject(wrapper.current);
155
+ }
156
+ if (wrapper.loaded) {
157
+ return null;
158
+ }
159
+ return null;
160
+ }
161
+ if (isPlainObject(value)) {
162
+ return value;
163
+ }
164
+ if (typeof value === "object") {
165
+ const result: Record<string, unknown> = {};
166
+ for (const key of Object.getOwnPropertyNames(value)) {
167
+ if (key.startsWith('_') || key === 'constructor' || key === 'prototype') continue;
168
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
169
+ if (descriptor && descriptor.enumerable) {
170
+ const propertyValue = (value as Record<string, unknown>)[key];
171
+ result[key] = propertyValue;
172
+ }
173
+ }
174
+ return result;
175
+ }
176
+ return null;
177
+ }