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,255 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
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, MultipartOptions, ValidationOptions } from "./types";
9
+ import { createInputCoercer } from "./coercion";
10
+ import { serializeResponse } from "./response-serializer";
11
+ import {
12
+ extractFiles,
13
+ hasFileUploads,
14
+ normalizeMultipartOptions
15
+ } from "./multipart";
16
+ import { lifecycleRegistry } from "../../core/lifecycle";
17
+ import { createSseEmitter, createStreamWriter } from "../../core/streaming";
18
+ import { validate } from "../../core/validation";
19
+ import { ValidationErrors, isValidationErrors } from "../../core/validation-errors";
20
+
21
+ /**
22
+ * Attaches controllers to a Fastify application.
23
+ */
24
+ export async function attachControllers(
25
+ app: FastifyInstance,
26
+ controllers: Constructor[],
27
+ inputCoercion: InputCoercionSetting = "safe",
28
+ multipart?: boolean | MultipartOptions,
29
+ validation?: boolean | ValidationOptions
30
+ ): Promise<void> {
31
+ const multipartOptions = normalizeMultipartOptions(multipart);
32
+
33
+ for (const controller of controllers) {
34
+ const meta = getControllerMeta(controller);
35
+ if (!meta) {
36
+ throw new Error(`Controller "${controller.name}" is missing @Controller decorator.`);
37
+ }
38
+ const instance = new controller();
39
+ lifecycleRegistry.register(instance);
40
+ await lifecycleRegistry.callOnModuleInit(instance);
41
+
42
+ for (const route of meta.routes) {
43
+ const path = joinPaths(meta.basePath, route.path);
44
+ const handler = instance[route.handlerName as keyof typeof instance];
45
+
46
+ if (typeof handler !== "function") {
47
+ throw new Error(`Handler "${String(route.handlerName)}" is not a function on ${controller.name}.`);
48
+ }
49
+
50
+ const coerceParams = inputCoercion === false
51
+ ? undefined
52
+ : createInputCoercer<Record<string, any>>(
53
+ route.params,
54
+ { mode: inputCoercion, location: "params" }
55
+ );
56
+ const coerceQuery = inputCoercion === false
57
+ ? undefined
58
+ : createInputCoercer<Record<string, any>>(route.query, { mode: inputCoercion, location: "query" });
59
+ const coerceBody = inputCoercion === false
60
+ ? undefined
61
+ : createInputCoercer<Record<string, any>>(route.body, { mode: inputCoercion, location: "body" });
62
+
63
+ const isValidationEnabled = validation !== false && (validation as ValidationOptions)?.enabled !== false;
64
+
65
+ const authMeta = getRouteAuthMeta(controller, route.handlerName);
66
+
67
+ app.route({
68
+ method: route.httpMethod.toUpperCase() as any,
69
+ url: path,
70
+ handler: async (req: FastifyRequest, reply: FastifyReply) => {
71
+ try {
72
+ // Apply auth guard if metadata exists
73
+ if (authMeta && authMeta.requiresAuth && !authMeta.isPublic) {
74
+ const user = (req as any).user || (req.raw as any).user;
75
+ if (!user) {
76
+ throw new HttpError(401, "Unauthorized");
77
+ }
78
+
79
+ if (authMeta.roles?.length) {
80
+ const hasRole = authMeta.roles.some((role: string) => user.roles?.includes(role));
81
+ if (!hasRole) {
82
+ throw new HttpError(403, "Insufficient permissions");
83
+ }
84
+ }
85
+
86
+ if (authMeta.allRoles?.length) {
87
+ const hasAllRoles = authMeta.allRoles.every((role: string) => user.roles?.includes(role));
88
+ if (!hasAllRoles) {
89
+ throw new HttpError(403, "Insufficient permissions");
90
+ }
91
+ }
92
+
93
+ if (authMeta.guard) {
94
+ const allowed = await authMeta.guard(user, req);
95
+ if (!allowed) {
96
+ throw new HttpError(403, "Access denied by guard");
97
+ }
98
+ }
99
+ }
100
+
101
+ let files: any = undefined;
102
+ if (multipartOptions && hasFileUploads(route.files)) {
103
+ files = await extractFiles(req);
104
+ }
105
+
106
+ const body = req.body;
107
+ const query = (coerceQuery && req.query && Object.keys(req.query as any).length > 0) ? coerceQuery(req.query as any) : req.query;
108
+ const params = (coerceParams && req.params && Object.keys(req.params as any).length > 0) ? coerceParams(req.params as any) : req.params;
109
+
110
+ if (isValidationEnabled) {
111
+ const validationErrors = [];
112
+
113
+ if (route.body) {
114
+ const bodyErrors = validate(body, route.body.schema);
115
+ validationErrors.push(...bodyErrors);
116
+ }
117
+
118
+ if (route.query) {
119
+ const queryErrors = validate(query, route.query.schema);
120
+ validationErrors.push(...queryErrors);
121
+ }
122
+
123
+ if (route.params) {
124
+ const paramsErrors = validate(params, route.params.schema);
125
+ validationErrors.push(...paramsErrors);
126
+ }
127
+
128
+ if (route.headers) {
129
+ const headersErrors = validate(req.headers, route.headers.schema);
130
+ validationErrors.push(...headersErrors);
131
+ }
132
+
133
+ if (validationErrors.length > 0) {
134
+ throw new ValidationErrors(validationErrors);
135
+ }
136
+ }
137
+
138
+ const ctx = {
139
+ req,
140
+ res: reply.raw,
141
+ body,
142
+ query,
143
+ params,
144
+ headers: req.headers,
145
+ files,
146
+ sse: route.sse ? createSseEmitter(reply.raw) : undefined,
147
+ stream: route.streaming || route.sse ? createStreamWriter(reply.raw) : undefined
148
+ } as unknown as RequestContext;
149
+
150
+ const result = await (handler as (...args: any[]) => any).call(instance, ctx);
151
+
152
+ if (reply.sent || route.sse || route.streaming) {
153
+ return;
154
+ }
155
+
156
+ if (isHttpResponse(result)) {
157
+ if (result.headers) {
158
+ reply.headers(result.headers);
159
+ }
160
+
161
+ if (result.body === undefined) {
162
+ reply.status(result.status).send();
163
+ } else if (route.raw) {
164
+ if (!reply.getHeader("Content-Type")) {
165
+ const ct = getResponseContentType(route) ?? "application/octet-stream";
166
+ reply.type(ct);
167
+ }
168
+ reply.status(result.status).send(result.body);
169
+ } else {
170
+ const responseSchema = getResponseSchemaForStatus(route, result.status);
171
+ const output = responseSchema ? serializeResponse(result.body, responseSchema) : result.body;
172
+ reply.status(result.status).send(output);
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (result === undefined) {
178
+ reply.status(defaultStatus(route)).send();
179
+ return;
180
+ }
181
+
182
+ if (route.raw) {
183
+ if (!reply.getHeader("Content-Type")) {
184
+ const ct = getResponseContentType(route) ?? "application/octet-stream";
185
+ reply.type(ct);
186
+ }
187
+ reply.status(defaultStatus(route)).send(result);
188
+ } else {
189
+ const responseSchema = getResponseSchema(route);
190
+ const output = responseSchema ? serializeResponse(result, responseSchema) : result;
191
+ reply.status(defaultStatus(route)).send(output);
192
+ }
193
+ } catch (error) {
194
+ if (isValidationErrors(error)) {
195
+ reply.status(error.status).send(error.body);
196
+ return;
197
+ }
198
+ if (isHttpError(error)) {
199
+ if (error.headers) {
200
+ reply.headers(error.headers);
201
+ }
202
+ const body = error.body ?? { message: error.message };
203
+ reply.status(error.status).send(body);
204
+ return;
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+ });
210
+ }
211
+ }
212
+ }
213
+
214
+ function defaultStatus(route: {
215
+ responses?: Array<{ status: number; error?: boolean }>;
216
+ }): number {
217
+ const responses = route.responses ?? [];
218
+ const success = responses.find(
219
+ (response) => !response.error && response.status < 400
220
+ );
221
+ return success?.status ?? 200;
222
+ }
223
+
224
+ function getResponseSchema(route: {
225
+ responses?: Array<{ status: number; error?: boolean; schema?: SchemaSource }>;
226
+ }): SchemaSource | undefined {
227
+ const responses = route.responses ?? [];
228
+ const success = responses.find((response) => !response.error && response.status < 400);
229
+ return success?.schema;
230
+ }
231
+
232
+ function getResponseContentType(route: {
233
+ responses?: Array<{ status: number; error?: boolean; contentType?: string }>;
234
+ }): string | undefined {
235
+ const responses = route.responses ?? [];
236
+ const success = responses.find((r) => !r.error && r.status < 400);
237
+ return success?.contentType;
238
+ }
239
+
240
+ function getResponseSchemaForStatus(
241
+ route: {
242
+ responses?: Array<{ status: number; error?: boolean; schema?: SchemaSource }>;
243
+ },
244
+ status: number
245
+ ): SchemaSource | undefined {
246
+ const responses = route.responses ?? [];
247
+ const response = responses.find((r) => r.status === status);
248
+ return response?.schema;
249
+ }
250
+
251
+ function joinPaths(base: string, path: string): string {
252
+ const normalizedBase = base.replace(/\/+$/, "");
253
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
254
+ return `${normalizedBase}${normalizedPath}`;
255
+ }
@@ -0,0 +1,53 @@
1
+ import fastify from "fastify";
2
+ import type { FastifyAdapterOptions } from "./types";
3
+ import { attachControllers } from "./controllers";
4
+ import { attachOpenApi } from "./openapi";
5
+ import { lifecycleRegistry } from "../../core/lifecycle";
6
+ import cors from "@fastify/cors";
7
+ import multipart from "@fastify/multipart";
8
+
9
+ export * from "./types";
10
+ export { attachControllers } from "./controllers";
11
+ export { attachOpenApi } from "./openapi";
12
+
13
+ /**
14
+ * Creates a Fastify application with Adorn controllers.
15
+ * @param options - Fastify adapter options
16
+ * @returns Configured Fastify application
17
+ */
18
+ export async function createFastifyApp(options: FastifyAdapterOptions): Promise<any> {
19
+ const app = fastify({
20
+ bodyLimit: options.bodyLimit
21
+ });
22
+
23
+ if (options.cors) {
24
+ app.register(cors, options.cors === true ? {} : options.cors);
25
+ }
26
+
27
+ if (options.multipart) {
28
+ app.register(multipart, {
29
+ limits: {
30
+ fileSize: typeof options.multipart === "object" ? options.multipart.maxFileSize : undefined
31
+ }
32
+ });
33
+ }
34
+
35
+ const inputCoercion = options.inputCoercion ?? "safe";
36
+ await attachControllers(app, options.controllers, inputCoercion, options.multipart, options.validation);
37
+
38
+ if (options.openApi) {
39
+ attachOpenApi(app, options.controllers, options.openApi);
40
+ }
41
+
42
+ await lifecycleRegistry.callOnApplicationBootstrap();
43
+
44
+ return app;
45
+ }
46
+
47
+ /**
48
+ * Trigger shutdown hooks for graceful application shutdown.
49
+ */
50
+ export async function shutdownApp(signal?: string): Promise<void> {
51
+ await lifecycleRegistry.callShutdownHooks(signal);
52
+ lifecycleRegistry.clear();
53
+ }
@@ -0,0 +1,94 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import type { UploadedFileMeta } from "../../core/metadata";
3
+ import type { UploadedFileInfo } from "../../core/types";
4
+ import type { MultipartOptions } from "./types";
5
+
6
+ /**
7
+ * Normalized multipart options with defaults applied.
8
+ */
9
+ export interface NormalizedMultipartOptions {
10
+ storage: "memory" | "disk";
11
+ dest: string;
12
+ maxFileSize: number;
13
+ maxFiles: number;
14
+ }
15
+
16
+ const DEFAULT_OPTIONS: NormalizedMultipartOptions = {
17
+ storage: "memory",
18
+ dest: "",
19
+ maxFileSize: 10 * 1024 * 1024, // 10MB
20
+ maxFiles: 10
21
+ };
22
+
23
+ /**
24
+ * Normalizes multipart options with defaults.
25
+ */
26
+ export function normalizeMultipartOptions(
27
+ options: boolean | MultipartOptions | undefined
28
+ ): NormalizedMultipartOptions | undefined {
29
+ if (!options) {
30
+ return undefined;
31
+ }
32
+ if (options === true) {
33
+ return DEFAULT_OPTIONS;
34
+ }
35
+ return {
36
+ storage: options.storage ?? DEFAULT_OPTIONS.storage,
37
+ dest: options.dest ?? DEFAULT_OPTIONS.dest,
38
+ maxFileSize: options.maxFileSize ?? DEFAULT_OPTIONS.maxFileSize,
39
+ maxFiles: options.maxFiles ?? DEFAULT_OPTIONS.maxFiles
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Extracts uploaded files from the Fastify request.
45
+ * For Fastify, files are expected to be attached to the request by @fastify/multipart
46
+ */
47
+ export async function extractFiles(
48
+ req: FastifyRequest
49
+ ): Promise<Record<string, UploadedFileInfo | UploadedFileInfo[]> | undefined> {
50
+ const parts = (req as any).parts();
51
+ const filesMap: Record<string, UploadedFileInfo | UploadedFileInfo[]> = {};
52
+
53
+ const fields: Record<string, any> = {};
54
+
55
+ for await (const part of parts) {
56
+ if ((part as any).file) {
57
+ const buffer = await (part as any).toBuffer();
58
+ const info: UploadedFileInfo = {
59
+ originalName: (part as any).filename,
60
+ mimeType: (part as any).mimetype,
61
+ size: buffer.length,
62
+ buffer: buffer,
63
+ fieldName: (part as any).fieldname
64
+ };
65
+
66
+ if (filesMap[(part as any).fieldname]) {
67
+ if (Array.isArray(filesMap[(part as any).fieldname])) {
68
+ (filesMap[(part as any).fieldname] as UploadedFileInfo[]).push(info);
69
+ } else {
70
+ filesMap[(part as any).fieldname] = [filesMap[(part as any).fieldname] as UploadedFileInfo, info];
71
+ }
72
+ } else {
73
+ filesMap[(part as any).fieldname] = info;
74
+ }
75
+ } else {
76
+ // It's a field
77
+ fields[(part as any).fieldname] = (part as any).value;
78
+ }
79
+ }
80
+
81
+ // Merge fields into body if it's a multipart request
82
+ if (Object.keys(fields).length > 0) {
83
+ Object.assign(req.body ?? (req.body = {}), fields);
84
+ }
85
+
86
+ return Object.keys(filesMap).length > 0 ? filesMap : undefined;
87
+ }
88
+
89
+ /**
90
+ * Checks if a route has file uploads configured.
91
+ */
92
+ export function hasFileUploads(files: UploadedFileMeta[] | undefined): boolean {
93
+ return !!files && files.length > 0;
94
+ }
@@ -0,0 +1,93 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { Constructor } from "../../core/types";
3
+ import { buildOpenApi } from "../../core/openapi";
4
+ import type { OpenApiFastifyOptions } from "./types";
5
+
6
+ /**
7
+ * Attaches OpenAPI endpoints to a Fastify application.
8
+ * @param app - Fastify application instance
9
+ * @param controllers - Array of controller classes
10
+ * @param options - OpenAPI options
11
+ */
12
+ export function attachOpenApi(
13
+ app: FastifyInstance,
14
+ controllers: Constructor[],
15
+ options: OpenApiFastifyOptions
16
+ ): void {
17
+ const openApiPath = normalizePath(options.path, "/openapi.json");
18
+ const document = buildOpenApi({
19
+ info: options.info,
20
+ servers: options.servers,
21
+ controllers
22
+ });
23
+
24
+ app.get(openApiPath, (_req, reply) => {
25
+ if (options.prettyPrint) {
26
+ reply.header("Content-Type", "application/json");
27
+ reply.send(JSON.stringify(document, null, 2));
28
+ } else {
29
+ reply.send(document);
30
+ }
31
+ });
32
+
33
+ if (!options.docs) {
34
+ return;
35
+ }
36
+
37
+ const docsOptions = typeof options.docs === "object" ? options.docs : {};
38
+ const docsPath = normalizePath(docsOptions.path, "/docs");
39
+ const title = docsOptions.title ?? `${options.info.title} Docs`;
40
+ const swaggerUiUrl = (docsOptions.swaggerUiUrl ?? "https://unpkg.com/swagger-ui-dist@5").replace(
41
+ /\/+$/,
42
+ ""
43
+ );
44
+
45
+ const html = buildSwaggerUiHtml({ title, swaggerUiUrl, openApiPath });
46
+ app.get(docsPath, (_req, reply) => {
47
+ reply.type("text/html").send(html);
48
+ });
49
+ }
50
+
51
+ function normalizePath(path: string | undefined, fallback: string): string {
52
+ if (!path) {
53
+ return fallback;
54
+ }
55
+ return path.startsWith("/") ? path : `/${path}`;
56
+ }
57
+
58
+ function buildSwaggerUiHtml(options: {
59
+ title: string;
60
+ swaggerUiUrl: string;
61
+ openApiPath: string;
62
+ }): string {
63
+ return `<!doctype html>
64
+ <html lang="en">
65
+ <head>
66
+ <meta charset="utf-8" />
67
+ <title>${options.title}</title>
68
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
69
+ <link rel="stylesheet" href="${options.swaggerUiUrl}/swagger-ui.css" />
70
+ <style>
71
+ body {
72
+ margin: 0;
73
+ background: #f6f6f6;
74
+ }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <div id="swagger-ui"></div>
79
+ <script src="${options.swaggerUiUrl}/swagger-ui-bundle.js"></script>
80
+ <script>
81
+ window.onload = () => {
82
+ window.ui = SwaggerUIBundle({
83
+ url: "${options.openApiPath}",
84
+ dom_id: "#swagger-ui",
85
+ deepLinking: true,
86
+ presets: [SwaggerUIBundle.presets.apis],
87
+ layout: "BaseLayout"
88
+ });
89
+ };
90
+ </script>
91
+ </body>
92
+ </html>`;
93
+ }
@@ -0,0 +1,179 @@
1
+ import type { SchemaNode, SchemaSource } from "../../core/schema";
2
+ import type { DtoConstructor } from "../../core/types";
3
+ import { getDtoMeta } from "../../core/metadata";
4
+
5
+ export function serializeResponse(value: unknown, schema: SchemaSource): unknown {
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
+
15
+ function serializeWithDto(value: unknown, dto: DtoConstructor): unknown {
16
+ if (value === null || value === undefined) {
17
+ return value;
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return value.map((entry) => serializeWithDto(entry, dto));
21
+ }
22
+ const plainValue = toPlainObject(value);
23
+ if (!plainValue) {
24
+ return value;
25
+ }
26
+ const meta = getDtoMeta(dto);
27
+ if (!meta) {
28
+ return plainValue;
29
+ }
30
+ const output: Record<string, unknown> = { ...plainValue };
31
+ for (const [name, field] of Object.entries(meta.fields)) {
32
+ if (name in plainValue) {
33
+ output[name] = serializeWithSchema(plainValue[name], field.schema);
34
+ }
35
+ }
36
+ return output;
37
+ }
38
+
39
+ function serializeWithSchema(value: unknown, schema: SchemaNode): unknown {
40
+ if (value === null || value === undefined) {
41
+ return value;
42
+ }
43
+ switch (schema.kind) {
44
+ case "string":
45
+ return serializeString(value, schema.format);
46
+ case "array":
47
+ if (!Array.isArray(value)) {
48
+ return value;
49
+ }
50
+ return value.map((entry) => serializeWithSchema(entry, schema.items));
51
+ case "object":
52
+ return serializeObject(value, schema.properties);
53
+ case "record":
54
+ if (!isPlainObject(value)) {
55
+ return value;
56
+ }
57
+ return serializeRecord(value as Record<string, unknown>, schema.values);
58
+ case "ref":
59
+ return serializeWithDto(value, schema.dto);
60
+ case "union":
61
+ return serializeUnion(value, schema.anyOf);
62
+ default:
63
+ return value;
64
+ }
65
+ }
66
+
67
+ function serializeString(value: unknown, format: string | undefined): unknown {
68
+ if (format === "byte" && Buffer.isBuffer(value)) {
69
+ return value.toString("base64");
70
+ }
71
+ if (!(value instanceof Date)) {
72
+ return value;
73
+ }
74
+ if (Number.isNaN(value.getTime())) {
75
+ return value;
76
+ }
77
+ if (format === "date") {
78
+ return value.toISOString().slice(0, 10);
79
+ }
80
+ if (format === "date-time") {
81
+ return value.toISOString();
82
+ }
83
+ return value;
84
+ }
85
+
86
+ function serializeObject(
87
+ value: unknown,
88
+ properties: Record<string, SchemaNode> | undefined
89
+ ): unknown {
90
+ const plainValue = toPlainObject(value);
91
+ if (!plainValue) {
92
+ return value;
93
+ }
94
+ const output: Record<string, unknown> = { ...plainValue };
95
+ if (!properties) {
96
+ return output;
97
+ }
98
+ for (const [key, schema] of Object.entries(properties)) {
99
+ if (key in plainValue) {
100
+ output[key] = serializeWithSchema(plainValue[key], schema);
101
+ }
102
+ }
103
+ return output;
104
+ }
105
+
106
+ function serializeRecord(
107
+ value: Record<string, unknown>,
108
+ schema: SchemaNode
109
+ ): Record<string, unknown> {
110
+ const output: Record<string, unknown> = { ...value };
111
+ for (const [key, entry] of Object.entries(value)) {
112
+ output[key] = serializeWithSchema(entry, schema);
113
+ }
114
+ return output;
115
+ }
116
+
117
+ function serializeUnion(value: unknown, options: SchemaNode[]): unknown {
118
+ for (const option of options) {
119
+ const serialized = serializeWithSchema(value, option);
120
+ if (serialized !== value) {
121
+ return serialized;
122
+ }
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function isSchemaNode(value: unknown): value is SchemaNode {
128
+ return !!value && typeof value === "object" && "kind" in (value as SchemaNode);
129
+ }
130
+
131
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
132
+ return (
133
+ value !== null &&
134
+ typeof value === "object" &&
135
+ !Array.isArray(value) &&
136
+ !(value instanceof Date)
137
+ );
138
+ }
139
+
140
+ function toPlainObject(value: unknown): Record<string, unknown> | null {
141
+ // 1. Check if value has custom toJSON method (e.g., metal-orm entities)
142
+ if (value !== null &&
143
+ typeof value === "object" &&
144
+ typeof (value as { toJSON?: () => unknown }).toJSON === "function") {
145
+ // Use the custom toJSON which handles circular refs and includes properly
146
+ const jsonResult = (value as { toJSON: () => unknown }).toJSON();
147
+ return jsonResult as Record<string, unknown>;
148
+ }
149
+
150
+ // 2. Handle lazy-load wrappers (BelongsToReference)
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
+ // 3. Handle plain objects
162
+ if (isPlainObject(value)) {
163
+ return value;
164
+ }
165
+ // 4. Convert class instances to plain objects
166
+ if (typeof value === "object") {
167
+ const result: Record<string, unknown> = {};
168
+ for (const key of Object.getOwnPropertyNames(value)) {
169
+ if (key.startsWith('_') || key === 'constructor' || key === 'prototype') continue;
170
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
171
+ if (descriptor && descriptor.enumerable) {
172
+ const propertyValue = (value as Record<string, unknown>)[key];
173
+ result[key] = propertyValue;
174
+ }
175
+ }
176
+ return result;
177
+ }
178
+ return null;
179
+ }