UNPKG

api-scout

Version:

🔍 Automatically scout, discover and generate beautiful interactive API documentation from your codebase. Supports Express.js, NestJS, FastAPI, Spring Boot with interactive testing and security analysis.

385 lines (311 loc) 11.1 kB
const fs = require('fs-extra'); const path = require('path'); class SpringParser { constructor(options = {}) { this.options = options; } async parseFile(filePath) { const content = await fs.readFile(filePath, 'utf8'); const results = { endpoints: [], schemas: [] }; try { // Parse Spring Boot annotations and methods results.endpoints = this.extractSpringEndpoints(content, filePath); results.schemas = this.extractJavaModels(content, filePath); } catch (error) { console.warn(`⚠️ Failed to parse Spring file ${filePath}:`, error.message); } return results; } extractSpringEndpoints(content, filePath) { const endpoints = []; const lines = content.split('\n'); // Find controller classes const controllerMatch = content.match(/@RestController|@Controller/); if (!controllerMatch) return endpoints; // Extract base path from class-level RequestMapping const classBasePath = this.extractClassBasePath(content); // Find method-level mappings const methodPatterns = { '@GetMapping': 'GET', '@PostMapping': 'POST', '@PutMapping': 'PUT', '@DeleteMapping': 'DELETE', '@PatchMapping': 'PATCH', '@RequestMapping': null // Will be determined from method attribute }; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); for (const [annotation, httpMethod] of Object.entries(methodPatterns)) { if (line.includes(annotation)) { const endpoint = this.parseEndpointAnnotation( lines, i, annotation, httpMethod, classBasePath, filePath ); if (endpoint) { endpoints.push(endpoint); } } } } return endpoints; } extractClassBasePath(content) { const classMapping = content.match(/@RequestMapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["']/); return classMapping ? classMapping[1] : ''; } parseEndpointAnnotation(lines, annotationLine, annotation, httpMethod, basePath, filePath) { const line = lines[annotationLine].trim(); // Extract path from annotation let path = ''; const pathMatch = line.match(/\(\s*(?:value\s*=\s*)?["']([^"']+)["']/); if (pathMatch) { path = pathMatch[1]; } // Handle @RequestMapping with method attribute if (annotation === '@RequestMapping') { const methodMatch = line.match(/method\s*=\s*RequestMethod\.(\w+)/); if (methodMatch) { httpMethod = methodMatch[1]; } else { httpMethod = 'GET'; // Default } } // Combine base path and method path const fullPath = this.combinePaths(basePath, path); // Find the method definition const methodInfo = this.extractMethodInfo(lines, annotationLine + 1); if (!methodInfo.name) return null; return { method: httpMethod, path: fullPath, file: filePath, line: annotationLine + 1, description: this.extractJavaDocComment(lines, annotationLine - 1), parameters: this.extractMethodParameters(methodInfo), produces: this.extractProduces(line), consumes: this.extractConsumes(line), methodName: methodInfo.name, returnType: methodInfo.returnType }; } extractMethodInfo(lines, startLine) { let methodLine = startLine; // Find the method definition while (methodLine < lines.length) { const line = lines[methodLine].trim(); // Skip empty lines and annotations if (!line || line.startsWith('@')) { methodLine++; continue; } // Look for method signature const methodMatch = line.match(/(?:public|private|protected)?\s*(\w+(?:<[^>]+>)?)\s+(\w+)\s*\(([^)]*)\)/); if (methodMatch) { const returnType = methodMatch[1]; const name = methodMatch[2]; const params = methodMatch[3]; return { returnType, name, parameters: this.parseJavaParameters(params), startLine: methodLine }; } methodLine++; // Don't search too far if (methodLine - startLine > 5) break; } return { name: null, returnType: null, parameters: [] }; } parseJavaParameters(paramString) { if (!paramString.trim()) return []; const params = []; const paramList = paramString.split(','); for (const param of paramList) { const trimmed = param.trim(); if (trimmed) { const paramInfo = this.parseJavaParameter(trimmed); if (paramInfo) { params.push(paramInfo); } } } return params; } parseJavaParameter(paramStr) { // Handle annotations like @RequestParam, @PathVariable, @RequestBody const annotations = []; let cleanParam = paramStr; // Extract annotations const annotationMatches = paramStr.match(/@\w+(?:\([^)]*\))?/g); if (annotationMatches) { annotations.push(...annotationMatches); cleanParam = paramStr.replace(/@\w+(?:\([^)]*\))?/g, '').trim(); } // Parse type and name const typeNameMatch = cleanParam.match(/(\w+(?:<[^>]+>)?)\s+(\w+)/); if (!typeNameMatch) return null; const type = typeNameMatch[1]; const name = typeNameMatch[2]; return { name, type, annotations, in: this.determineParameterLocation(annotations), required: this.isParameterRequired(annotations) }; } determineParameterLocation(annotations) { for (const annotation of annotations) { if (annotation.includes('@PathVariable')) return 'path'; if (annotation.includes('@RequestParam')) return 'query'; if (annotation.includes('@RequestBody')) return 'body'; if (annotation.includes('@RequestHeader')) return 'header'; } return 'query'; // Default } isParameterRequired(annotations) { for (const annotation of annotations) { if (annotation.includes('required = false')) return false; } return true; // Default to required } extractMethodParameters(methodInfo) { return methodInfo.parameters.map(param => ({ name: param.name, type: this.javaTypeToOpenAPI(param.type), in: param.in, required: param.required, description: `${param.type} parameter` })); } extractProduces(line) { const producesMatch = line.match(/produces\s*=\s*["']([^"']+)["']/); return producesMatch ? [producesMatch[1]] : ['application/json']; } extractConsumes(line) { const consumesMatch = line.match(/consumes\s*=\s*["']([^"']+)["']/); return consumesMatch ? [consumesMatch[1]] : ['application/json']; } extractJavaDocComment(lines, startLine) { // Look backwards for JavaDoc comments let currentLine = startLine; const commentLines = []; while (currentLine >= 0) { const line = lines[currentLine].trim(); if (line === '*/') { // End of JavaDoc, start collecting currentLine--; continue; } if (line.startsWith('/**')) { // Start of JavaDoc, we're done break; } if (line.startsWith('*')) { commentLines.unshift(line.substring(1).trim()); } currentLine--; // Don't search too far back if (startLine - currentLine > 10) break; } return commentLines.length > 0 ? commentLines.join('\n') : null; } extractJavaModels(content, filePath) { const models = []; const lines = content.split('\n'); // Look for entity classes, DTOs, etc. const classPattern = /(?:@Entity|@Table|class\s+\w+.*DTO|class\s+\w+.*Entity|class\s+\w+.*Model)/g; let match; while ((match = classPattern.exec(content)) !== null) { const lineNumber = this.getLineNumber(content, match.index); const classInfo = this.extractJavaClassInfo(lines, lineNumber); if (classInfo.name) { const model = { name: classInfo.name, type: 'object', file: filePath, line: lineNumber, properties: this.extractClassProperties(lines, lineNumber) }; models.push(model); } } return models; } extractJavaClassInfo(lines, startLine) { for (let i = startLine; i < Math.min(startLine + 5, lines.length); i++) { const line = lines[i].trim(); const classMatch = line.match(/class\s+(\w+)/); if (classMatch) { return { name: classMatch[1], line: i }; } } return { name: null }; } extractClassProperties(lines, classLine) { const properties = {}; let currentLine = classLine + 1; while (currentLine < lines.length) { const line = lines[currentLine].trim(); // Stop at next class definition or end of class if (line.startsWith('class ') || line === '}') { break; } // Look for field declarations const fieldMatch = line.match(/(?:private|public|protected)?\s*(\w+(?:<[^>]+>)?)\s+(\w+);/); if (fieldMatch) { const type = fieldMatch[1]; const name = fieldMatch[2]; properties[name] = { type: this.javaTypeToOpenAPI(type), description: `${type} field` }; } currentLine++; } return properties; } javaTypeToOpenAPI(javaType) { const typeMap = { 'String': 'string', 'Integer': 'integer', 'int': 'integer', 'Long': 'integer', 'long': 'integer', 'Double': 'number', 'double': 'number', 'Float': 'number', 'float': 'number', 'Boolean': 'boolean', 'boolean': 'boolean', 'List': 'array', 'ArrayList': 'array', 'Set': 'array', 'Map': 'object', 'HashMap': 'object', 'Date': 'string', 'LocalDateTime': 'string', 'LocalDate': 'string' }; // Handle generic types const genericMatch = javaType.match(/(\w+)<.+>/); if (genericMatch) { return typeMap[genericMatch[1]] || 'object'; } return typeMap[javaType] || 'object'; } combinePaths(basePath, methodPath) { if (!basePath) return methodPath || '/'; if (!methodPath) return basePath; const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; const method = methodPath.startsWith('/') ? methodPath : '/' + methodPath; return base + method; } getLineNumber(content, index) { return content.substring(0, index).split('\n').length; } } module.exports = SpringParser;