/** * OpenAPI Specification Generator * * Generates OpenAPI 3.1 specification from registered controllers and their metadata */ import { ControllerRegistry } from '../decorators/decorators.registry.js'; import { combinePaths } from '../decorators/decorators.metadata.js'; import type { IControllerMetadata, IRouteMetadata, IOpenApiRouteMeta, IOpenApiControllerMeta, TJsonSchema, } from '../decorators/decorators.types.js'; import type { IOpenApiSpec, IOpenApiPathItem, IOpenApiOperation, IOpenApiParameter, IOpenApiRequestBody, IOpenApiResponse, IOpenApiGeneratorOptions, IOpenApiSecurityScheme, IOpenApiTag, } from './openapi.types.js'; /** * OpenAPI Specification Generator */ export class OpenApiGenerator { private options: IOpenApiGeneratorOptions; private schemas: Map = new Map(); constructor(options: IOpenApiGeneratorOptions) { this.options = options; } /** * Register a reusable schema in components/schemas */ addSchema(name: string, schema: TJsonSchema): this { this.schemas.set(name, schema); return this; } /** * Generate the complete OpenAPI specification */ generate(): IOpenApiSpec { const spec: IOpenApiSpec = { openapi: '3.1.0', info: this.options.info, servers: this.options.servers ?? [], paths: {}, components: { schemas: Object.fromEntries(this.schemas), securitySchemes: this.options.securitySchemes ?? {}, }, tags: this.options.tags ?? [], }; if (this.options.externalDocs) { spec.externalDocs = this.options.externalDocs; } // Collect all unique tags const collectedTags = new Set(); // Get all registered controllers const controllers = ControllerRegistry.getControllers(); for (const { metadata } of controllers) { this.processController(spec, metadata, collectedTags); } // Add collected tags that aren't already in spec.tags const existingTagNames = new Set(spec.tags?.map(t => t.name) ?? []); for (const tag of collectedTags) { if (!existingTagNames.has(tag)) { spec.tags!.push({ name: tag }); } } return spec; } /** * Generate and return as JSON string */ toJSON(pretty = true): string { return JSON.stringify(this.generate(), null, pretty ? 2 : 0); } /** * Process a single controller and add its routes to the spec */ private processController( spec: IOpenApiSpec, metadata: IControllerMetadata, collectedTags: Set ): void { const controllerOpenApi = metadata.openapi ?? {}; // Collect controller tags if (controllerOpenApi.tags) { for (const tag of controllerOpenApi.tags) { collectedTags.add(tag); } } // Process each route for (const [, route] of metadata.routes) { const fullPath = combinePaths(metadata.basePath, route.path); const openApiPath = this.convertPathToOpenApi(fullPath); // Initialize path if not exists if (!spec.paths[openApiPath]) { spec.paths[openApiPath] = {}; } // Build operation const operation = this.buildOperation(route, controllerOpenApi, collectedTags); // Add to appropriate HTTP method const method = route.method.toLowerCase(); if (method === 'all') { // 'ALL' applies to all standard methods const methods = ['get', 'post', 'put', 'delete', 'patch'] as const; for (const m of methods) { (spec.paths[openApiPath] as any)[m] = { ...operation }; } } else { (spec.paths[openApiPath] as any)[method] = operation; } } } /** * Build an OpenAPI operation from route metadata */ private buildOperation( route: IRouteMetadata, controllerMeta: IOpenApiControllerMeta, collectedTags: Set ): IOpenApiOperation { const routeOpenApi = route.openapi ?? {}; const operationMeta = routeOpenApi.operation ?? {}; const operation: IOpenApiOperation = { summary: operationMeta.summary ?? `${route.method} ${route.path}`, description: operationMeta.description, operationId: operationMeta.operationId ?? this.generateOperationId(route), deprecated: operationMeta.deprecated, tags: controllerMeta.tags ? [...controllerMeta.tags] : [], parameters: [], responses: {}, }; if (operationMeta.externalDocs) { operation.externalDocs = operationMeta.externalDocs; } // Collect tags for (const tag of operation.tags ?? []) { collectedTags.add(tag); } // Add path parameters (auto-detect from path pattern) const pathParams = this.extractPathParams(route.path); for (const paramName of pathParams) { const paramMeta = routeOpenApi.params?.get(paramName); operation.parameters!.push({ name: paramName, in: 'path', required: true, description: paramMeta?.description ?? `Path parameter: ${paramName}`, schema: paramMeta?.schema ?? { type: 'string' }, example: paramMeta?.example, deprecated: paramMeta?.deprecated, }); } // Add query parameters if (routeOpenApi.query) { for (const [name, meta] of routeOpenApi.query) { operation.parameters!.push({ name, in: 'query', required: meta.required ?? false, description: meta.description, schema: meta.schema ?? { type: 'string' }, example: meta.example, allowEmptyValue: meta.allowEmptyValue, deprecated: meta.deprecated, }); } } // Add header parameters if (routeOpenApi.headers) { for (const [name, meta] of routeOpenApi.headers) { operation.parameters!.push({ name, in: 'header', required: meta.required ?? false, description: meta.description, schema: meta.schema ?? { type: 'string' }, example: meta.example, deprecated: meta.deprecated, }); } } // Remove empty parameters array if (operation.parameters!.length === 0) { delete operation.parameters; } // Add request body if (routeOpenApi.requestBody) { operation.requestBody = { description: routeOpenApi.requestBody.description, required: routeOpenApi.requestBody.required !== false, content: { 'application/json': { schema: routeOpenApi.requestBody.schema, example: routeOpenApi.requestBody.example, }, }, }; } // Add responses if (routeOpenApi.responses && routeOpenApi.responses.size > 0) { for (const [status, responseMeta] of routeOpenApi.responses) { const response: IOpenApiResponse = { description: responseMeta.description, }; if (responseMeta.schema) { response.content = { 'application/json': { schema: responseMeta.schema, example: responseMeta.example, }, }; } if (responseMeta.headers) { response.headers = {}; for (const [headerName, headerMeta] of Object.entries(responseMeta.headers)) { response.headers[headerName] = { description: headerMeta.description, schema: headerMeta.schema, } as IOpenApiParameter; } } operation.responses[status.toString()] = response; } } else { // Default response operation.responses['200'] = { description: 'Successful response', }; } // Add security requirements const security = routeOpenApi.security ?? controllerMeta.security; if (security && security.length > 0) { operation.security = security; } return operation; } /** * Convert Express-style path to OpenAPI path format * :id -> {id} */ private convertPathToOpenApi(path: string): string { return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}'); } /** * Extract parameter names from path pattern */ private extractPathParams(path: string): string[] { const matches = path.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)/g); return Array.from(matches, m => m[1]); } /** * Generate a unique operation ID from route metadata */ private generateOperationId(route: IRouteMetadata): string { const methodName = String(route.methodName); // Convert camelCase to snake_case and sanitize return methodName .replace(/[^a-zA-Z0-9]/g, '_') .replace(/([a-z])([A-Z])/g, '$1_$2') .toLowerCase(); } }