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,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
|
+
}
|