import { Injectable, Logger } from '@nestjs/common'; import { OpenAPISpec } from '../../common/interfaces/swagger.interface'; import { AppError, ErrorCode } from '../../common/errors/app.error'; interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; } @Injectable() export class OpenApiValidatorService { private readonly logger = new Logger(OpenApiValidatorService.name); validateAndFix(spec: OpenAPISpec): OpenAPISpec { this.logger.log('Validating and fixing OpenAPI specification'); const result = this.validate(spec); if (result.errors.length > 0) { this.logger.warn(`Found ${result.errors.length} validation errors, attempting to fix...`); spec = this.autoFix(spec, result.errors); const revalidate = this.validate(spec); if (revalidate.errors.length > 0) { this.logger.error('Unable to auto-fix all errors', revalidate.errors); throw new AppError( `OpenAPI validation failed: ${revalidate.errors.join(', ')}`, ErrorCode.SWAGGER_GENERATION_ERROR, { service: 'OpenApiValidatorService', function: 'validateAndFix', payload: { errors: revalidate.errors }, }, ); } } if (result.warnings.length > 0) { this.logger.warn(`Found ${result.warnings.length} warnings`, result.warnings); } this.logger.log('OpenAPI specification validated successfully'); return spec; } private validate(spec: OpenAPISpec): ValidationResult { const errors: string[] = []; const warnings: string[] = []; if (!spec.openapi) { errors.push('Missing required field: openapi'); } else if (!spec.openapi.startsWith('3.0')) { errors.push(`Unsupported OpenAPI version: ${spec.openapi}`); } if (!spec.info) { errors.push('Missing required field: info'); } else { if (!spec.info.title) { errors.push('Missing required field: info.title'); } if (!spec.info.version) { errors.push('Missing required field: info.version'); } } if (!spec.paths || Object.keys(spec.paths).length === 0) { errors.push('Missing or empty paths object'); } else { this.validatePaths(spec.paths, errors, warnings); } if (!spec.servers || spec.servers.length === 0) { warnings.push('No servers defined'); } return { valid: errors.length === 0, errors, warnings }; } private validatePaths( paths: Record, errors: string[], warnings: string[], ): void { for (const [path, pathItem] of Object.entries(paths)) { if (!path.startsWith('/')) { errors.push(`Path must start with /: ${path}`); } const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; let hasOperation = false; for (const method of methods) { if (pathItem[method]) { hasOperation = true; this.validateOperation(path, method, pathItem[method], errors, warnings); } } if (!hasOperation) { warnings.push(`Path ${path} has no operations`); } } } private validateOperation( path: string, method: string, operation: any, errors: string[], warnings: string[], ): void { const opId = `${method.toUpperCase()} ${path}`; if (!operation.responses) { errors.push(`${opId}: Missing required field 'responses'`); } else if (Object.keys(operation.responses).length === 0) { errors.push(`${opId}: Responses object is empty`); } if (operation.parameters) { for (const [idx, param] of operation.parameters.entries()) { if (!param.name) { errors.push(`${opId}: Parameter ${idx} missing 'name'`); } if (!param.in) { errors.push(`${opId}: Parameter ${idx} missing 'in'`); } if (!param.schema) { errors.push(`${opId}: Parameter ${param.name || idx} missing 'schema'`); } } } if (!operation.operationId) { warnings.push(`${opId}: Missing operationId`); } if (!operation.description && !operation.summary) { warnings.push(`${opId}: Missing description and summary`); } } private autoFix(spec: OpenAPISpec, errors: string[]): OpenAPISpec { const fixed = JSON.parse(JSON.stringify(spec)); if (errors.includes('Missing required field: openapi')) { fixed.openapi = '3.0.0'; this.logger.log('Fixed: Added openapi version 3.0.0'); } if (errors.includes('Missing required field: info')) { fixed.info = { title: 'API Documentation', version: '1.0.0', }; this.logger.log('Fixed: Added default info object'); } else { if (errors.includes('Missing required field: info.title')) { fixed.info.title = 'API Documentation'; this.logger.log('Fixed: Added default title'); } if (errors.includes('Missing required field: info.version')) { fixed.info.version = '1.0.0'; this.logger.log('Fixed: Added default version'); } } if (errors.some((e) => e.includes('Path must start with /'))) { const newPaths: Record = {}; for (const [path, pathItem] of Object.entries(fixed.paths)) { const fixedPath = path.startsWith('/') ? path : `/${path}`; newPaths[fixedPath] = pathItem; } fixed.paths = newPaths; this.logger.log('Fixed: Corrected path formats'); } for (const [path, pathItem] of Object.entries(fixed.paths)) { const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; for (const method of methods) { if ((pathItem as any)[method]) { const operation = (pathItem as any)[method]; if (!operation.responses) { operation.responses = { '200': { description: 'Successful response', }, }; this.logger.log(`Fixed: Added default responses for ${method.toUpperCase()} ${path}`); } if (operation.parameters) { for (const param of operation.parameters) { if (!param.schema) { param.schema = { type: 'string' }; } } } } } } return fixed; } sanitizeSpec(spec: OpenAPISpec): OpenAPISpec { const sanitized = JSON.parse(JSON.stringify(spec)); for (const [path, pathItem] of Object.entries(sanitized.paths)) { const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; for (const method of methods) { if ((pathItem as any)[method]) { const operation = (pathItem as any)[method]; if (operation.parameters) { operation.parameters = operation.parameters.filter( (p: any) => p.name && p.in && p.schema, ); } for (const [statusCode, response] of Object.entries(operation.responses || {})) { if (!(response as any).description) { (response as any).description = `Response with status ${statusCode}`; } } } } } return sanitized; } }