feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
This commit is contained in:
306
ts/openapi/openapi.generator.ts
Normal file
306
ts/openapi/openapi.generator.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user