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.
310 lines (243 loc) • 9.14 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
class FastAPIParser {
constructor(options = {}) {
this.options = options;
}
async parseFile(filePath) {
const content = await fs.readFile(filePath, 'utf8');
const results = {
endpoints: [],
schemas: []
};
try {
// Parse FastAPI decorators and functions
results.endpoints = this.extractFastAPIEndpoints(content, filePath);
results.schemas = this.extractPydanticModels(content, filePath);
} catch (error) {
console.warn(`⚠️ Failed to parse FastAPI file ${filePath}:`, error.message);
}
return results;
}
extractFastAPIEndpoints(content, filePath) {
const endpoints = [];
const lines = content.split('\n');
// Regex patterns for FastAPI decorators
const patterns = {
get: /@app\.get\s*\(\s*["']([^"']+)["']/g,
post: /@app\.post\s*\(\s*["']([^"']+)["']/g,
put: /@app\.put\s*\(\s*["']([^"']+)["']/g,
delete: /@app\.delete\s*\(\s*["']([^"']+)["']/g,
patch: /@app\.patch\s*\(\s*["']([^"']+)["']/g
};
for (const [method, pattern] of Object.entries(patterns)) {
let match;
pattern.lastIndex = 0; // Reset regex
while ((match = pattern.exec(content)) !== null) {
const path = match[1];
const lineNumber = this.getLineNumber(content, match.index);
// Extract function definition following the decorator
const functionInfo = this.extractFunctionInfo(lines, lineNumber);
const endpoint = {
method: method.toUpperCase(),
path,
file: filePath,
line: lineNumber,
description: functionInfo.docstring,
parameters: this.extractParametersFromFunction(functionInfo),
response: this.extractResponseInfo(functionInfo),
tags: this.extractTags(lines, lineNumber - 1)
};
endpoints.push(endpoint);
}
}
return endpoints;
}
extractFunctionInfo(lines, decoratorLine) {
let functionLine = decoratorLine;
// Find the function definition after the decorator
while (functionLine < lines.length && !lines[functionLine].trim().startsWith('def ')) {
functionLine++;
}
if (functionLine >= lines.length) {
return { name: null, params: [], docstring: null };
}
const functionDef = lines[functionLine].trim();
const functionMatch = functionDef.match(/def\s+(\w+)\s*\(([^)]*)\)/);
if (!functionMatch) {
return { name: null, params: [], docstring: null };
}
const name = functionMatch[1];
const params = this.parseParameterString(functionMatch[2]);
const docstring = this.extractDocstring(lines, functionLine + 1);
return { name, params, docstring, startLine: functionLine };
}
parseParameterString(paramString) {
if (!paramString.trim()) return [];
const params = [];
const paramList = paramString.split(',');
for (const param of paramList) {
const trimmed = param.trim();
if (trimmed) {
const [name, type] = this.parseParameter(trimmed);
params.push({ name, type });
}
}
return params;
}
parseParameter(paramStr) {
// Handle type hints: param: int = None
const typeMatch = paramStr.match(/(\w+):\s*([^=]+)(?:\s*=\s*(.+))?/);
if (typeMatch) {
const name = typeMatch[1];
const type = typeMatch[2].trim();
const defaultValue = typeMatch[3];
return [name, { type, default: defaultValue, required: !defaultValue }];
}
// Simple parameter without type hint
const simpleMatch = paramStr.match(/(\w+)(?:\s*=\s*(.+))?/);
if (simpleMatch) {
const name = simpleMatch[1];
const defaultValue = simpleMatch[2];
return [name, { type: 'any', default: defaultValue, required: !defaultValue }];
}
return [paramStr.trim(), { type: 'any', required: true }];
}
extractParametersFromFunction(functionInfo) {
const parameters = [];
for (const param of functionInfo.params) {
if (param.name === 'self') continue; // Skip self parameter
parameters.push({
name: param.name,
type: param.type?.type || 'string',
required: param.type?.required !== false,
default: param.type?.default,
in: this.determineParameterLocation(param.name, param.type)
});
}
return parameters;
}
determineParameterLocation(name, type) {
// Simple heuristics for parameter location
if (type?.type?.includes('Query')) return 'query';
if (type?.type?.includes('Path')) return 'path';
if (type?.type?.includes('Body')) return 'body';
if (type?.type?.includes('Header')) return 'header';
// Default based on common patterns
if (name.includes('id') || name.includes('Id')) return 'path';
return 'query';
}
extractDocstring(lines, startLine) {
if (startLine >= lines.length) return null;
const line = lines[startLine].trim();
// Check for triple quotes
if (line.startsWith('"""') || line.startsWith("'''")) {
const quote = line.startsWith('"""') ? '"""' : "'''";
let docstring = '';
let currentLine = startLine;
// Single line docstring
if (line.length > 3 && line.endsWith(quote)) {
return line.slice(3, -3).trim();
}
// Multi-line docstring
currentLine++;
while (currentLine < lines.length) {
const currentLineText = lines[currentLine];
if (currentLineText.trim().endsWith(quote)) {
docstring += currentLineText.replace(quote, '').trim();
break;
}
docstring += currentLineText.trim() + '\n';
currentLine++;
}
return docstring.trim();
}
return null;
}
extractPydanticModels(content, filePath) {
const models = [];
const lines = content.split('\n');
// Look for Pydantic model classes
const classPattern = /class\s+(\w+)\s*\(\s*BaseModel\s*\):/g;
let match;
while ((match = classPattern.exec(content)) !== null) {
const className = match[1];
const lineNumber = this.getLineNumber(content, match.index);
const model = {
name: className,
type: 'object',
file: filePath,
line: lineNumber,
properties: this.extractModelProperties(lines, lineNumber)
};
models.push(model);
}
return models;
}
extractModelProperties(lines, classLine) {
const properties = {};
let currentLine = classLine + 1;
while (currentLine < lines.length) {
const line = lines[currentLine].trim();
// Stop at next class or function definition
if (line.startsWith('class ') || line.startsWith('def ') ||
(line && !line.startsWith(' ') && !line.startsWith('\t') && line !== '')) {
break;
}
// Parse property definition
const propMatch = line.match(/(\w+):\s*([^=]+)(?:\s*=\s*(.+))?/);
if (propMatch) {
const name = propMatch[1];
const type = propMatch[2].trim();
const defaultValue = propMatch[3];
properties[name] = {
type: this.pythonTypeToOpenAPI(type),
required: !defaultValue,
default: defaultValue
};
}
currentLine++;
}
return properties;
}
pythonTypeToOpenAPI(pythonType) {
const typeMap = {
'str': 'string',
'int': 'integer',
'float': 'number',
'bool': 'boolean',
'list': 'array',
'dict': 'object',
'List': 'array',
'Dict': 'object',
'Optional': 'string', // Simplified
'Union': 'string' // Simplified
};
return typeMap[pythonType] || 'string';
}
extractTags(lines, decoratorLine) {
// Look for tags in decorator parameters
if (decoratorLine >= 0 && decoratorLine < lines.length) {
const decoratorLine = lines[decoratorLine];
const tagsMatch = decoratorLine.match(/tags\s*=\s*\[([^\]]+)\]/);
if (tagsMatch) {
return tagsMatch[1].split(',').map(tag => tag.trim().replace(/['"]/g, ''));
}
}
return [];
}
extractResponseInfo(functionInfo) {
// Simple response extraction from docstring
if (functionInfo.docstring) {
const returnMatch = functionInfo.docstring.match(/Returns?:\s*(.+)/i);
if (returnMatch) {
return { description: returnMatch[1].trim() };
}
}
return { description: 'Successful response' };
}
getLineNumber(content, index) {
return content.substring(0, index).split('\n').length;
}
}
module.exports = FastAPIParser;