@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.
Files changed (39) hide show
  1. package/__tests__/openapi-generator.test.ts +194 -0
  2. package/__tests__/openapi-module.test.ts +115 -0
  3. package/__tests__/route-scanner.test.ts +120 -0
  4. package/__tests__/schema-converter.test.ts +73 -0
  5. package/dist/decorators/api-operation.d.ts +2 -0
  6. package/dist/decorators/api-operation.js +6 -0
  7. package/dist/decorators/api-property.d.ts +2 -0
  8. package/dist/decorators/api-property.js +6 -0
  9. package/dist/decorators/api-response.d.ts +4 -0
  10. package/dist/decorators/api-response.js +6 -0
  11. package/dist/decorators/api-tags.d.ts +1 -0
  12. package/dist/decorators/api-tags.js +6 -0
  13. package/dist/generator/openapi-generator.d.ts +25 -0
  14. package/dist/generator/openapi-generator.js +166 -0
  15. package/dist/generator/route-scanner.d.ts +19 -0
  16. package/dist/generator/route-scanner.js +40 -0
  17. package/dist/generator/schema-converter.d.ts +5 -0
  18. package/dist/generator/schema-converter.js +9 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +9 -0
  21. package/dist/interfaces/openapi-options.d.ts +17 -0
  22. package/dist/interfaces/openapi-options.js +1 -0
  23. package/dist/metadata/openapi-storage.d.ts +40 -0
  24. package/dist/metadata/openapi-storage.js +51 -0
  25. package/dist/openapi.module.d.ts +6 -0
  26. package/dist/openapi.module.js +103 -0
  27. package/package.json +27 -0
  28. package/src/decorators/api-operation.ts +16 -0
  29. package/src/decorators/api-property.ts +16 -0
  30. package/src/decorators/api-response.ts +14 -0
  31. package/src/decorators/api-tags.ts +7 -0
  32. package/src/generator/openapi-generator.ts +213 -0
  33. package/src/generator/route-scanner.ts +92 -0
  34. package/src/generator/schema-converter.ts +21 -0
  35. package/src/index.ts +14 -0
  36. package/src/interfaces/openapi-options.ts +11 -0
  37. package/src/metadata/openapi-storage.ts +108 -0
  38. package/src/openapi.module.ts +91 -0
  39. 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
+ }