307 lines
8.5 KiB
TypeScript
307 lines
8.5 KiB
TypeScript
/**
|
|
* 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<string, TJsonSchema> = 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<string>();
|
|
|
|
// 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<string>
|
|
): 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<string>
|
|
): 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();
|
|
}
|
|
}
|