dockugen
Version:
Auto-generate API documentation for Node.js apps - Zero config, multi-framework support
335 lines (279 loc) • 11.1 kB
JavaScript
const fs = require('fs');
const path = require('path');
const glob = require('glob');
class EnhancedNestJSScanner {
constructor() {
this.projectRoot = process.cwd();
this.routes = [];
this.controllers = new Map();
this.dtos = new Map();
this.parameters = new Map();
}
scan(options = {}) {
console.log(
'🔍 Enhanced NestJS Scanner: Scanning for API routes with advanced DTO detection...'
);
// Auto-detect project structure
const srcDir = this.findSrcDir();
console.log(`📁 Source directory: ${srcDir}`);
console.log(`🚀 Framework detected: NestJS (Enhanced)`);
// Scan controllers, routes, and DTOs
this.scanControllers(srcDir);
this.scanRoutes(srcDir);
this.scanDTOs(srcDir);
this.scanParameters(srcDir);
// Remove duplicates
this.routes = this.removeDuplicates(this.routes);
console.log(`✅ Found ${this.routes.length} unique routes`);
console.log(`✅ Found ${this.dtos.size} DTOs`);
console.log(`✅ Found ${this.parameters.size} parameter definitions`);
// Convert Map to plain object for better serialization
const dtosObject = {};
for (const [key, value] of this.dtos.entries()) {
dtosObject[key] = value;
}
return {
routes: this.routes,
dtos: dtosObject
};
}
findSrcDir() {
const possibleDirs = ['src', 'app', 'routes', 'controllers', 'api', 'lib'];
for (const dir of possibleDirs) {
const fullPath = path.join(this.projectRoot, dir);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
return fullPath;
}
}
return this.projectRoot;
}
scanControllers(srcDir) {
const controllerFiles = glob.sync('**/*.controller.ts', {
cwd: srcDir,
absolute: true,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
console.log(`📄 Found ${controllerFiles.length} controller files`);
controllerFiles.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf8');
const controllerName = path.basename(file, '.controller.ts');
// Extract controller base path
const controllerMatch = content.match(/@Controller\(['"`]([^'"`]*)['"`]\)/);
const basePath = controllerMatch ? controllerMatch[1] : '';
this.controllers.set(controllerName, {
file: path.relative(this.projectRoot, file),
basePath,
content
});
} catch (error) {
console.warn(`⚠️ Error reading controller ${file}: ${error.message}`);
}
});
}
scanRoutes(srcDir) {
const controllerFiles = glob.sync('**/*.controller.ts', {
cwd: srcDir,
absolute: true,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
controllerFiles.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf8');
const controllerName = path.basename(file, '.controller.ts');
const controller = this.controllers.get(controllerName);
if (!controller) return;
// Extract HTTP method decorators with better pattern matching
const httpMethods = ['Get', 'Post', 'Put', 'Delete', 'Patch', 'Options', 'Head', 'All'];
httpMethods.forEach(method => {
// Find all method decorators
const methodRegex = new RegExp(`@${method}\\(['"]([^'"]*)['"]\\)`, 'g');
let match;
while ((match = methodRegex.exec(content)) !== null) {
const [, routePath] = match;
// Look for the method definition after this decorator
const afterDecorator = content.substring(match.index + match[0].length);
// Find method definition - look for async/function declaration
// Support both Promise<> and simple return types
const methodPatterns = [
// Pattern for Promise<> return types
new RegExp(`\\s*(?:async\\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*\\(([^)]*)\\)\\s*:\\s*Promise<[^>]*>`, 'g'),
// Pattern for simple return types
new RegExp(`\\s*(?:async\\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*\\(([^)]*)\\)\\s*:\\s*([^{]+)`, 'g')
];
let methodFound = false;
for (const pattern of methodPatterns) {
let methodMatch;
while ((methodMatch = pattern.exec(afterDecorator)) !== null) {
const [, methodName, paramsString] = methodMatch;
// Only process if this looks like a real method (not a comment or other text)
if (methodName && methodName.length > 0 && !methodName.includes('@')) {
const fullPath = path.join(controller.basePath, routePath).replace(/\\/g, '/');
// Parse method parameters
const params = this.parseMethodParameters(paramsString);
this.routes.push({
method: method.toUpperCase(),
path: fullPath || '/',
controller: controllerName,
file: controller.file,
methodName,
parameters: params
});
methodFound = true;
break;
}
}
if (methodFound) break;
}
}
});
} catch (error) {
console.warn(`⚠️ Error parsing routes in ${file}: ${error.message}`);
}
});
}
parseMethodParameters(paramString) {
const params = {
body: null,
query: [],
params: [],
headers: []
};
// Parse @Body() parameter
const bodyMatch = paramString.match(/@Body\(\)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([a-zA-Z_$][a-zA-Z0-9_$<>[\],\s]*)/);
if (bodyMatch) {
const [, paramName, paramType] = bodyMatch;
// Clean up the type (remove Promise, etc.)
const cleanType = paramType.replace(/Promise<[^>]*>/, '').trim();
params.body = {
name: paramName,
type: cleanType,
required: true
};
}
// Parse @Query() parameters
const queryMatches = paramString.matchAll(/@Query\(['"`]([^'"`]*)['"`]\s*\)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([a-zA-Z_$][a-zA-Z0-9_$<>[\],\s]*)/g);
for (const match of queryMatches) {
const [, queryName, paramName, paramType] = match;
params.query.push({
name: queryName,
paramName,
type: paramType.trim(),
required: false
});
}
// Parse @Param() parameters
const paramMatches = paramString.matchAll(/@Param\(['"`]([^'"`]*)['"`]\s*\)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([a-zA-Z_$][a-zA-Z0-9_$<>[\],\s]*)/g);
for (const match of paramMatches) {
const [, paramName, variableName, paramType] = match;
params.params.push({
name: paramName,
paramName: variableName,
type: paramType.trim(),
required: true
});
}
// Parse @Headers() parameters
const headerMatches = paramString.matchAll(/@Headers\(['"`]([^'"`]*)['"`]\s*\)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([a-zA-Z_$][a-zA-Z0-9_$<>[\],\s]*)/g);
for (const match of headerMatches) {
const [, headerName, paramName, paramType] = match;
params.headers.push({
name: headerName,
paramName,
type: paramType.trim(),
required: false
});
}
return params;
}
scanDTOs(srcDir) {
const dtoFiles = glob.sync('**/*.dto.ts', {
cwd: srcDir,
absolute: true,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
console.log(`📄 Found ${dtoFiles.length} DTO files`);
dtoFiles.forEach(file => {
try {
const content = fs.readFileSync(file, 'utf8');
const dtoName = path.basename(file, '.dto.ts');
// Extract class definition - look for all classes in the file
const classMatches = content.matchAll(/export\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g);
for (const classMatch of classMatches) {
const className = classMatch[1];
console.log(`Processing DTO class: ${className} from file: ${file}`);
const properties = this.parseDTOProperties(content);
console.log(`Found ${properties.length} properties for ${className}`);
this.dtos.set(className, {
name: className,
file: path.relative(this.projectRoot, file),
properties
});
}
} catch (error) {
console.warn(`⚠️ Error reading DTO ${file}: ${error.message}`);
}
});
}
parseDTOProperties(content) {
const properties = [];
// Use regex with multiline flag to handle decorators that span multiple lines
const propertyPattern = /@([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)\s*\n\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*([^;]+)/g;
let match;
while ((match = propertyPattern.exec(content)) !== null) {
const [, decorator, propertyName, propertyType] = match;
// Determine if property is required based on decorator
const isRequired = this.isRequiredDecorator(decorator);
const isOptional = this.isOptionalDecorator(decorator);
// Parse the property type
const cleanType = this.cleanPropertyType(propertyType);
properties.push({
name: propertyName,
type: cleanType,
required: isRequired,
optional: isOptional,
decorator: decorator
});
}
return properties;
}
isRequiredDecorator(decorator) {
const requiredDecorators = [
'MandatoryString', 'MandatoryNumber', 'MandatoryBoolean', 'MandatoryDate',
'MandatoryArray', 'MandatoryObject', 'MandatoryNested', 'MandatoryEnum',
'ApiProperty', 'IsString', 'IsNumber', 'IsBoolean', 'IsDate', 'IsArray', 'IsObject'
];
return requiredDecorators.includes(decorator);
}
isOptionalDecorator(decorator) {
const optionalDecorators = [
'OptionalString', 'OptionalNumber', 'OptionalBoolean', 'OptionalDate',
'OptionalArray', 'OptionalObject', 'OptionalNested', 'OptionalEnum',
'ApiPropertyOptional', 'IsOptional'
];
return optionalDecorators.includes(decorator);
}
cleanPropertyType(typeString) {
return typeString
.replace(/\|/g, ' | ')
.replace(/\[\]/g, '[]')
.replace(/<[^>]*>/g, '')
.trim();
}
scanParameters(srcDir) {
// This method can be extended to scan for additional parameter definitions
// like validation rules, custom decorators, etc.
}
removeDuplicates(routes) {
const seen = new Set();
return routes.filter(route => {
const key = `${route.method}:${route.path}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
}
module.exports = EnhancedNestJSScanner;