@cinnabun/openapi 0.0.1
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/__tests__/openapi-generator.test.ts +194 -0
- package/__tests__/openapi-module.test.ts +115 -0
- package/__tests__/route-scanner.test.ts +120 -0
- package/__tests__/schema-converter.test.ts +73 -0
- package/dist/decorators/api-operation.d.ts +2 -0
- package/dist/decorators/api-operation.js +6 -0
- package/dist/decorators/api-property.d.ts +2 -0
- package/dist/decorators/api-property.js +6 -0
- package/dist/decorators/api-response.d.ts +4 -0
- package/dist/decorators/api-response.js +6 -0
- package/dist/decorators/api-tags.d.ts +1 -0
- package/dist/decorators/api-tags.js +6 -0
- package/dist/generator/openapi-generator.d.ts +25 -0
- package/dist/generator/openapi-generator.js +166 -0
- package/dist/generator/route-scanner.d.ts +19 -0
- package/dist/generator/route-scanner.js +40 -0
- package/dist/generator/schema-converter.d.ts +5 -0
- package/dist/generator/schema-converter.js +9 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +9 -0
- package/dist/interfaces/openapi-options.d.ts +17 -0
- package/dist/interfaces/openapi-options.js +1 -0
- package/dist/metadata/openapi-storage.d.ts +40 -0
- package/dist/metadata/openapi-storage.js +51 -0
- package/dist/openapi.module.d.ts +6 -0
- package/dist/openapi.module.js +103 -0
- package/package.json +27 -0
- package/src/decorators/api-operation.ts +16 -0
- package/src/decorators/api-property.ts +16 -0
- package/src/decorators/api-response.ts +14 -0
- package/src/decorators/api-tags.ts +7 -0
- package/src/generator/openapi-generator.ts +213 -0
- package/src/generator/route-scanner.ts +92 -0
- package/src/generator/schema-converter.ts +21 -0
- package/src/index.ts +14 -0
- package/src/interfaces/openapi-options.ts +11 -0
- package/src/metadata/openapi-storage.ts +108 -0
- package/src/openapi.module.ts +91 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { OpenApiModuleOptions } from "../interfaces/openapi-options.js";
|
|
2
|
+
import type { ScannedRoute } from "./route-scanner.js";
|
|
3
|
+
import { convertZodSchema } from "./schema-converter.js";
|
|
4
|
+
|
|
5
|
+
export interface OpenApiSpec {
|
|
6
|
+
openapi: string;
|
|
7
|
+
info: { title: string; description?: string; version: string };
|
|
8
|
+
servers?: { url: string; description?: string }[];
|
|
9
|
+
tags?: { name: string; description?: string }[];
|
|
10
|
+
paths: Record<string, Record<string, any>>;
|
|
11
|
+
components: {
|
|
12
|
+
schemas: Record<string, any>;
|
|
13
|
+
securitySchemes?: Record<string, any>;
|
|
14
|
+
};
|
|
15
|
+
security?: Record<string, string[]>[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateSpec(
|
|
19
|
+
options: OpenApiModuleOptions,
|
|
20
|
+
routes: ScannedRoute[],
|
|
21
|
+
): OpenApiSpec {
|
|
22
|
+
const spec: OpenApiSpec = {
|
|
23
|
+
openapi: "3.1.0",
|
|
24
|
+
info: {
|
|
25
|
+
title: options.title,
|
|
26
|
+
version: options.version,
|
|
27
|
+
},
|
|
28
|
+
paths: {},
|
|
29
|
+
components: {
|
|
30
|
+
schemas: {},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (options.description) {
|
|
35
|
+
spec.info.description = options.description;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.servers?.length) {
|
|
39
|
+
spec.servers = options.servers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Collect all tags from options + routes
|
|
43
|
+
const tagSet = new Map<string, string | undefined>();
|
|
44
|
+
if (options.tags) {
|
|
45
|
+
for (const t of options.tags) {
|
|
46
|
+
tagSet.set(t.name, t.description);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const route of routes) {
|
|
50
|
+
for (const tag of route.tags) {
|
|
51
|
+
if (!tagSet.has(tag)) tagSet.set(tag, undefined);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (tagSet.size > 0) {
|
|
55
|
+
spec.tags = Array.from(tagSet.entries()).map(([name, description]) =>
|
|
56
|
+
description ? { name, description } : { name },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Bearer auth
|
|
61
|
+
if (options.bearerAuth) {
|
|
62
|
+
spec.components.securitySchemes = {
|
|
63
|
+
bearerAuth: {
|
|
64
|
+
type: "http",
|
|
65
|
+
scheme: "bearer",
|
|
66
|
+
bearerFormat: "JWT",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
spec.security = [{ bearerAuth: [] }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build paths
|
|
73
|
+
for (const route of routes) {
|
|
74
|
+
const openApiPath = convertPathParams(route.fullPath);
|
|
75
|
+
const method = route.httpMethod.toLowerCase();
|
|
76
|
+
|
|
77
|
+
if (!spec.paths[openApiPath]) {
|
|
78
|
+
spec.paths[openApiPath] = {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const operation: Record<string, any> = {};
|
|
82
|
+
|
|
83
|
+
// Tags
|
|
84
|
+
if (route.tags.length > 0) {
|
|
85
|
+
operation.tags = route.tags;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Operation metadata
|
|
89
|
+
if (route.operation) {
|
|
90
|
+
if (route.operation.summary) operation.summary = route.operation.summary;
|
|
91
|
+
if (route.operation.description)
|
|
92
|
+
operation.description = route.operation.description;
|
|
93
|
+
if (route.operation.deprecated) operation.deprecated = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Parameters from @Param and @Query
|
|
97
|
+
const parameters = buildParameters(route);
|
|
98
|
+
if (parameters.length > 0) {
|
|
99
|
+
operation.parameters = parameters;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Request body from validation schema
|
|
103
|
+
if (route.validationSchema?.body) {
|
|
104
|
+
const schemaName = `${route.controllerName}_${route.methodKey}_Body`;
|
|
105
|
+
const { jsonSchema, componentName } = convertZodSchema(
|
|
106
|
+
route.validationSchema.body,
|
|
107
|
+
schemaName,
|
|
108
|
+
);
|
|
109
|
+
spec.components.schemas[componentName] = jsonSchema;
|
|
110
|
+
|
|
111
|
+
operation.requestBody = {
|
|
112
|
+
required: true,
|
|
113
|
+
content: {
|
|
114
|
+
"application/json": {
|
|
115
|
+
schema: { $ref: `#/components/schemas/${componentName}` },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Responses
|
|
122
|
+
operation.responses = buildResponses(route);
|
|
123
|
+
|
|
124
|
+
spec.paths[openApiPath][method] = operation;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return spec;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function convertPathParams(path: string): string {
|
|
131
|
+
// Convert Express-style :param to OpenAPI {param}
|
|
132
|
+
return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildParameters(route: ScannedRoute): any[] {
|
|
136
|
+
const parameters: any[] = [];
|
|
137
|
+
|
|
138
|
+
for (const param of route.params) {
|
|
139
|
+
if (param.source === "param" && param.name) {
|
|
140
|
+
parameters.push({
|
|
141
|
+
name: param.name,
|
|
142
|
+
in: "path",
|
|
143
|
+
required: true,
|
|
144
|
+
schema: { type: "string" },
|
|
145
|
+
});
|
|
146
|
+
} else if (param.source === "query" && param.name) {
|
|
147
|
+
parameters.push({
|
|
148
|
+
name: param.name,
|
|
149
|
+
in: "query",
|
|
150
|
+
required: false,
|
|
151
|
+
schema: { type: "string" },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Also add query schema from validation if available
|
|
157
|
+
if (route.validationSchema?.query) {
|
|
158
|
+
const { jsonSchema } = convertZodSchema(route.validationSchema.query);
|
|
159
|
+
if (
|
|
160
|
+
jsonSchema.type === "object" &&
|
|
161
|
+
jsonSchema.properties &&
|
|
162
|
+
typeof jsonSchema.properties === "object"
|
|
163
|
+
) {
|
|
164
|
+
const required = (jsonSchema.required as string[]) ?? [];
|
|
165
|
+
for (const [name, propSchema] of Object.entries(
|
|
166
|
+
jsonSchema.properties as Record<string, any>,
|
|
167
|
+
)) {
|
|
168
|
+
// Skip if already added from @Query decorator
|
|
169
|
+
if (parameters.some((p) => p.name === name && p.in === "query"))
|
|
170
|
+
continue;
|
|
171
|
+
parameters.push({
|
|
172
|
+
name,
|
|
173
|
+
in: "query",
|
|
174
|
+
required: required.includes(name),
|
|
175
|
+
schema: propSchema,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return parameters;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildResponses(route: ScannedRoute): Record<string, any> {
|
|
185
|
+
const responses: Record<string, any> = {};
|
|
186
|
+
|
|
187
|
+
if (route.responses.length > 0) {
|
|
188
|
+
for (const resp of route.responses) {
|
|
189
|
+
responses[String(resp.status)] = {
|
|
190
|
+
description: resp.description,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Default response based on HTTP code or method
|
|
195
|
+
const code = route.httpCode ?? getDefaultStatusCode(route.httpMethod);
|
|
196
|
+
responses[String(code)] = {
|
|
197
|
+
description: "Successful response",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return responses;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getDefaultStatusCode(method: string): number {
|
|
205
|
+
switch (method) {
|
|
206
|
+
case "POST":
|
|
207
|
+
return 201;
|
|
208
|
+
case "DELETE":
|
|
209
|
+
return 204;
|
|
210
|
+
default:
|
|
211
|
+
return 200;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
metadataStorage,
|
|
3
|
+
type RouteMetadata,
|
|
4
|
+
type ParamMetadata,
|
|
5
|
+
type HttpMethod,
|
|
6
|
+
} from "@cinnabun/core";
|
|
7
|
+
import {
|
|
8
|
+
openApiMetadataStorage,
|
|
9
|
+
type ApiOperationMetadata,
|
|
10
|
+
type ApiResponseMetadata,
|
|
11
|
+
} from "../metadata/openapi-storage.js";
|
|
12
|
+
|
|
13
|
+
export interface ScannedRoute {
|
|
14
|
+
httpMethod: HttpMethod;
|
|
15
|
+
fullPath: string;
|
|
16
|
+
controllerName: string;
|
|
17
|
+
methodKey: string;
|
|
18
|
+
params: ParamMetadata[];
|
|
19
|
+
validationSchema?: { body?: any; query?: any; params?: any };
|
|
20
|
+
httpCode?: number;
|
|
21
|
+
operation?: ApiOperationMetadata;
|
|
22
|
+
responses: ApiResponseMetadata[];
|
|
23
|
+
tags: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function scanRoutes(
|
|
27
|
+
excludeControllers?: Function[],
|
|
28
|
+
): ScannedRoute[] {
|
|
29
|
+
const routes: ScannedRoute[] = [];
|
|
30
|
+
const excludeNames = new Set(
|
|
31
|
+
(excludeControllers ?? []).map((c) => c.name),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
for (const route of metadataStorage.routes) {
|
|
35
|
+
const controllerName = route.target.name;
|
|
36
|
+
|
|
37
|
+
// Skip excluded controllers (e.g. OpenApiController itself)
|
|
38
|
+
if (excludeNames.has(controllerName)) continue;
|
|
39
|
+
|
|
40
|
+
const basePath = metadataStorage.getControllerPath(route.target);
|
|
41
|
+
const fullPath = normalizePath(basePath + route.path);
|
|
42
|
+
|
|
43
|
+
const params = metadataStorage.getParamsFor(
|
|
44
|
+
route.target,
|
|
45
|
+
route.methodKey,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const validationSchema = metadataStorage.getValidationSchema(
|
|
49
|
+
route.target,
|
|
50
|
+
route.methodKey,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const httpCode = metadataStorage.getHttpCode(
|
|
54
|
+
route.target,
|
|
55
|
+
route.methodKey,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const operation = openApiMetadataStorage.getOperation(
|
|
59
|
+
route.target,
|
|
60
|
+
route.methodKey,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const responses = openApiMetadataStorage.getResponses(
|
|
64
|
+
route.target,
|
|
65
|
+
route.methodKey,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const tags = openApiMetadataStorage.getTags(route.target);
|
|
69
|
+
|
|
70
|
+
routes.push({
|
|
71
|
+
httpMethod: route.httpMethod,
|
|
72
|
+
fullPath,
|
|
73
|
+
controllerName,
|
|
74
|
+
methodKey: route.methodKey,
|
|
75
|
+
params,
|
|
76
|
+
validationSchema,
|
|
77
|
+
httpCode,
|
|
78
|
+
operation,
|
|
79
|
+
responses,
|
|
80
|
+
tags,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return routes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizePath(path: string): string {
|
|
88
|
+
// Ensure leading slash, collapse double slashes
|
|
89
|
+
let normalized = "/" + path.replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
|
90
|
+
if (normalized === "") normalized = "/";
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
|
+
|
|
3
|
+
export interface ConvertedSchema {
|
|
4
|
+
jsonSchema: Record<string, unknown>;
|
|
5
|
+
componentName: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function convertZodSchema(
|
|
9
|
+
schema: any,
|
|
10
|
+
name?: string,
|
|
11
|
+
): ConvertedSchema {
|
|
12
|
+
const raw = zodToJsonSchema(schema, { target: "openApi3" });
|
|
13
|
+
const jsonSchema = { ...raw } as Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
// Remove $schema key — not needed inside OpenAPI spec
|
|
16
|
+
delete jsonSchema.$schema;
|
|
17
|
+
|
|
18
|
+
const componentName = name ?? "Schema";
|
|
19
|
+
|
|
20
|
+
return { jsonSchema, componentName };
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { OpenApiModule } from "./openapi.module.js";
|
|
2
|
+
export type { OpenApiModuleOptions } from "./interfaces/openapi-options.js";
|
|
3
|
+
export { ApiOperation } from "./decorators/api-operation.js";
|
|
4
|
+
export { ApiResponse } from "./decorators/api-response.js";
|
|
5
|
+
export { ApiTags } from "./decorators/api-tags.js";
|
|
6
|
+
export { ApiProperty } from "./decorators/api-property.js";
|
|
7
|
+
export type { ApiOperationMetadata, ApiResponseMetadata, ApiPropertyMetadata } from "./metadata/openapi-storage.js";
|
|
8
|
+
export { openApiMetadataStorage } from "./metadata/openapi-storage.js";
|
|
9
|
+
export { scanRoutes } from "./generator/route-scanner.js";
|
|
10
|
+
export type { ScannedRoute } from "./generator/route-scanner.js";
|
|
11
|
+
export { generateSpec } from "./generator/openapi-generator.js";
|
|
12
|
+
export type { OpenApiSpec } from "./generator/openapi-generator.js";
|
|
13
|
+
export { convertZodSchema } from "./generator/schema-converter.js";
|
|
14
|
+
export type { ConvertedSchema } from "./generator/schema-converter.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface OpenApiModuleOptions {
|
|
2
|
+
title: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
version: string;
|
|
5
|
+
basePath?: string;
|
|
6
|
+
docsPath?: string;
|
|
7
|
+
jsonPath?: string;
|
|
8
|
+
servers?: { url: string; description?: string }[];
|
|
9
|
+
tags?: { name: string; description?: string }[];
|
|
10
|
+
bearerAuth?: boolean;
|
|
11
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export interface ApiOperationMetadata {
|
|
2
|
+
summary?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
deprecated?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ApiResponseMetadata {
|
|
8
|
+
status: number;
|
|
9
|
+
description: string;
|
|
10
|
+
type?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiPropertyMetadata {
|
|
14
|
+
type?: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
example?: unknown;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CurrentUserParamMetadata {
|
|
21
|
+
target: Function;
|
|
22
|
+
methodKey: string;
|
|
23
|
+
parameterIndex: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class OpenApiMetadataStorage {
|
|
27
|
+
private operations = new Map<string, ApiOperationMetadata>();
|
|
28
|
+
private responses = new Map<string, ApiResponseMetadata[]>();
|
|
29
|
+
private tags = new Map<Function, string[]>();
|
|
30
|
+
private properties = new Map<string, Map<string, ApiPropertyMetadata>>();
|
|
31
|
+
|
|
32
|
+
private key(target: Function, methodKey: string): string {
|
|
33
|
+
return `${target.name}.${methodKey}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private classKey(target: Function, propertyKey: string): string {
|
|
37
|
+
return `${target.name}.${propertyKey}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setOperation(
|
|
41
|
+
target: Function,
|
|
42
|
+
methodKey: string,
|
|
43
|
+
metadata: ApiOperationMetadata,
|
|
44
|
+
): void {
|
|
45
|
+
this.operations.set(this.key(target, methodKey), metadata);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getOperation(
|
|
49
|
+
target: Function,
|
|
50
|
+
methodKey: string,
|
|
51
|
+
): ApiOperationMetadata | undefined {
|
|
52
|
+
return this.operations.get(this.key(target, methodKey));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
addResponse(
|
|
56
|
+
target: Function,
|
|
57
|
+
methodKey: string,
|
|
58
|
+
metadata: ApiResponseMetadata,
|
|
59
|
+
): void {
|
|
60
|
+
const k = this.key(target, methodKey);
|
|
61
|
+
const existing = this.responses.get(k) ?? [];
|
|
62
|
+
existing.push(metadata);
|
|
63
|
+
this.responses.set(k, existing);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getResponses(
|
|
67
|
+
target: Function,
|
|
68
|
+
methodKey: string,
|
|
69
|
+
): ApiResponseMetadata[] {
|
|
70
|
+
return this.responses.get(this.key(target, methodKey)) ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setTags(target: Function, tags: string[]): void {
|
|
74
|
+
this.tags.set(target, tags);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getTags(target: Function): string[] {
|
|
78
|
+
return this.tags.get(target) ?? [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setProperty(
|
|
82
|
+
target: Function,
|
|
83
|
+
propertyKey: string,
|
|
84
|
+
metadata: ApiPropertyMetadata,
|
|
85
|
+
): void {
|
|
86
|
+
const k = target.name ?? target.constructor?.name;
|
|
87
|
+
if (!this.properties.has(k)) {
|
|
88
|
+
this.properties.set(k, new Map());
|
|
89
|
+
}
|
|
90
|
+
this.properties.get(k)!.set(propertyKey, metadata);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getProperties(
|
|
94
|
+
target: Function,
|
|
95
|
+
): Map<string, ApiPropertyMetadata> | undefined {
|
|
96
|
+
const k = target.name ?? target.constructor?.name;
|
|
97
|
+
return this.properties.get(k);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
reset(): void {
|
|
101
|
+
this.operations.clear();
|
|
102
|
+
this.responses.clear();
|
|
103
|
+
this.tags.clear();
|
|
104
|
+
this.properties.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const openApiMetadataStorage = new OpenApiMetadataStorage();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Module, RestController, GetMapping } from "@cinnabun/core";
|
|
2
|
+
import type { OpenApiModuleOptions } from "./interfaces/openapi-options.js";
|
|
3
|
+
import { scanRoutes } from "./generator/route-scanner.js";
|
|
4
|
+
import { generateSpec, type OpenApiSpec } from "./generator/openapi-generator.js";
|
|
5
|
+
|
|
6
|
+
let moduleOptions: OpenApiModuleOptions | null = null;
|
|
7
|
+
let cachedSpec: OpenApiSpec | null = null;
|
|
8
|
+
|
|
9
|
+
export class OpenApiModule {
|
|
10
|
+
static forRoot(options: OpenApiModuleOptions): Function {
|
|
11
|
+
moduleOptions = options;
|
|
12
|
+
cachedSpec = null;
|
|
13
|
+
|
|
14
|
+
const docsPath = options.docsPath ?? "/api-docs";
|
|
15
|
+
const jsonPath = options.jsonPath ?? `${docsPath}/json`;
|
|
16
|
+
const basePath = docsPath.replace(/\/$/, "") || "/";
|
|
17
|
+
const specRoute = jsonPath.startsWith(basePath)
|
|
18
|
+
? jsonPath.slice(basePath.length) || "/json"
|
|
19
|
+
: "/json";
|
|
20
|
+
|
|
21
|
+
// Dynamically create controller with configured paths
|
|
22
|
+
@RestController(docsPath)
|
|
23
|
+
class DynamicOpenApiController {
|
|
24
|
+
@GetMapping(specRoute.startsWith("/") ? specRoute : `/${specRoute}`)
|
|
25
|
+
getSpec() {
|
|
26
|
+
if (!cachedSpec) {
|
|
27
|
+
const routes = scanRoutes([DynamicOpenApiController]);
|
|
28
|
+
cachedSpec = generateSpec(options, routes);
|
|
29
|
+
}
|
|
30
|
+
return cachedSpec;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@GetMapping("/")
|
|
34
|
+
getSwaggerUI() {
|
|
35
|
+
const html = buildSwaggerUIHtml(options.title, jsonPath);
|
|
36
|
+
return new Response(html, {
|
|
37
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Module({
|
|
43
|
+
controllers: [DynamicOpenApiController],
|
|
44
|
+
providers: [],
|
|
45
|
+
exports: [],
|
|
46
|
+
})
|
|
47
|
+
class OpenApiDynamicModule {}
|
|
48
|
+
|
|
49
|
+
return OpenApiDynamicModule;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static getOptions(): OpenApiModuleOptions {
|
|
53
|
+
if (!moduleOptions) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"OpenApiModule not initialized. Call OpenApiModule.forRoot() first.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return moduleOptions;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static resetCache(): void {
|
|
62
|
+
cachedSpec = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildSwaggerUIHtml(title: string, specUrl: string): string {
|
|
67
|
+
return `<!DOCTYPE html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="UTF-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
72
|
+
<title>${title} - API Docs</title>
|
|
73
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div id="swagger-ui"></div>
|
|
77
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
78
|
+
<script>
|
|
79
|
+
SwaggerUIBundle({
|
|
80
|
+
url: '${specUrl}',
|
|
81
|
+
dom_id: '#swagger-ui',
|
|
82
|
+
presets: [
|
|
83
|
+
SwaggerUIBundle.presets.apis,
|
|
84
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
85
|
+
],
|
|
86
|
+
layout: 'BaseLayout'
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>`;
|
|
91
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"emitDecoratorMetadata": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"types": ["bun"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["src/__tests__"]
|
|
18
|
+
}
|