@adamtools/apifinder
Version:
Finds all api endpoints of any nextjs project and adds them into README.md , makes api documentation easier
329 lines (282 loc) • 11.4 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const { glob } = require('glob');
class ApiFinder {
constructor(projectPath = process.cwd()) {
this.projectPath = projectPath;
this.endpoints = [];
}
/**
* Main function to analyze Next.js project and find all API endpoints
*/
async analyze() {
const apiPaths = await this.findApiPaths();
for (const apiPath of apiPaths) {
const endpoint = await this.analyzeFile(apiPath);
if (endpoint) {
this.endpoints.push(endpoint);
}
}
return this.endpoints;
}
/**
* Find all API route files in the project
*/
async findApiPaths() {
const patterns = [
// Pages Router patterns - any JS/TS file in api directory
'pages/api/**/*.js',
'pages/api/**/*.ts',
'pages/api/**/*.jsx',
'pages/api/**/*.tsx',
'src/pages/api/**/*.js',
'src/pages/api/**/*.ts',
'src/pages/api/**/*.jsx',
'src/pages/api/**/*.tsx',
// App Router patterns - any JS/TS file in api directory (not just route files)
'app/api/**/*.js',
'app/api/**/*.ts',
'app/api/**/*.jsx',
'app/api/**/*.tsx',
'src/app/api/**/*.js',
'src/app/api/**/*.ts',
'src/app/api/**/*.jsx',
'src/app/api/**/*.tsx'
];
let files = [];
for (const pattern of patterns) {
const matches = await glob(pattern, { cwd: this.projectPath });
files = files.concat(matches.map(file => path.join(this.projectPath, file)));
}
// Remove duplicates
return [...new Set(files)];
}
/**
* Analyze a single API file to determine HTTP methods and route info
*/
async analyzeFile(filePath) {
try {
const content = await fs.readFile(filePath, 'utf-8');
const relativePath = path.relative(this.projectPath, filePath);
// Determine if it's App Router or Pages Router more accurately
const isAppRouter = this.determineRouterType(relativePath, content);
const route = this.extractRoute(relativePath, isAppRouter);
const methods = this.extractHttpMethods(content, isAppRouter);
return {
file: relativePath,
route,
methods,
type: isAppRouter ? 'App Router' : 'Pages Router'
};
} catch (error) {
console.warn(`Warning: Could not analyze ${filePath}:`, error.message);
return null;
}
}
/**
* Determine router type more accurately
*/
determineRouterType(filePath, content) {
// Simple path-based detection
if (filePath.includes('/app/api/')) {
return true; // App Router
}
if (filePath.includes('/pages/api/')) {
return false; // Pages Router
}
// If path detection is unclear, check content patterns
// App Router typically has named exports for HTTP methods
const appRouterPatterns = [
/export\s+(async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(/,
/export\s+const\s+(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*=/,
/export\s*{\s*[^}]*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)[^}]*}/
];
// Pages Router typically has default export with req, res parameters
const pagesRouterPatterns = [
/export\s+default\s+(async\s+)?function[^(]*\([^)]*req[^)]*res[^)]*\)/,
/export\s+default\s*\([^)]*req[^)]*res[^)]*\)\s*=>/,
/module\.exports\s*=\s*(async\s+)?function[^(]*\([^)]*req[^)]*res[^)]*\)/
];
const hasAppRouterPattern = appRouterPatterns.some(pattern => pattern.test(content));
const hasPagesRouterPattern = pagesRouterPatterns.some(pattern => pattern.test(content));
if (hasAppRouterPattern && !hasPagesRouterPattern) {
return true; // App Router
}
if (hasPagesRouterPattern && !hasAppRouterPattern) {
return false; // Pages Router
}
// Fallback: if file is in app directory, assume App Router, otherwise Pages Router
return filePath.includes('/app/');
}
/**
* Extract the API route from file path
*/
extractRoute(filePath, isAppRouter) {
let route;
if (isAppRouter) {
// App Router: app/api/users/route.js -> /api/users
route = filePath
.replace(/^(src\/)?app/, '')
.replace(/\/route\.(js|ts)$/, '')
.replace(/\/page\.(js|ts)$/, '');
} else {
// Pages Router: pages/api/users.js -> /api/users
route = filePath
.replace(/^(src\/)?pages/, '')
.replace(/\.(js|ts)$/, '')
.replace(/\/index$/, '');
}
// Handle dynamic routes
route = route.replace(/\[([^\]]+)\]/g, ':$1');
route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*$1');
return route || '/';
}
/**
* Extract HTTP methods from file content
*/
extractHttpMethods(content, isAppRouter) {
const methods = [];
if (isAppRouter) {
// App Router: named exports for HTTP methods
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
for (const method of httpMethods) {
const patterns = [
// export async function GET
new RegExp(`export\\s+async\\s+function\\s+${method}\\s*\\(`, 'gm'),
// export function GET
new RegExp(`export\\s+function\\s+${method}\\s*\\(`, 'gm'),
// export const GET =
new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(async\\s+)?\\(`, 'gm'),
// export { GET }
new RegExp(`export\\s*{[^}]*\\b${method}\\b[^}]*}`, 'gm'),
// function GET() followed by export
new RegExp(`function\\s+${method}\\s*\\([^)]*\\)\\s*{[^}]*}[\\s\\S]*?export\\s*{[^}]*\\b${method}\\b`, 'gm')
];
if (patterns.some(pattern => pattern.test(content))) {
methods.push(method);
}
}
} else {
// Pages Router: check req.method in handler
// Pattern 1: req.method === 'METHOD' or req.method == 'METHOD'
const methodChecks = content.match(/req\.method\s*[!=]==?\s*['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/gi);
if (methodChecks) {
methodChecks.forEach(match => {
const method = match.match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/i);
if (method && !methods.includes(method[1].toUpperCase())) {
methods.push(method[1].toUpperCase());
}
});
}
// Pattern 2: 'METHOD' === req.method
const reverseMethodChecks = content.match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]\s*[!=]==?\s*req\.method/gi);
if (reverseMethodChecks) {
reverseMethodChecks.forEach(match => {
const method = match.match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/i);
if (method && !methods.includes(method[1].toUpperCase())) {
methods.push(method[1].toUpperCase());
}
});
}
// Pattern 3: switch statements
const switchMatch = content.match(/switch\s*\(\s*req\.method\s*\)\s*{([^{}]*(?:{[^{}]*}[^{}]*)*)}/s);
if (switchMatch) {
const cases = switchMatch[1].match(/case\s+['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/gi);
if (cases) {
cases.forEach(caseMatch => {
const method = caseMatch.match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/i);
if (method && !methods.includes(method[1].toUpperCase())) {
methods.push(method[1].toUpperCase());
}
});
}
}
// Pattern 4: if/else chains with method checks
const ifStatements = content.match(/if\s*\([^)]*req\.method[^)]*\)/gi);
if (ifStatements) {
ifStatements.forEach(ifStatement => {
const method = ifStatement.match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/i);
if (method && !methods.includes(method[1].toUpperCase())) {
methods.push(method[1].toUpperCase());
}
});
}
// Pattern 5: allowedMethods array or similar
const allowedMethodsMatch = content.match(/allowedMethods?\s*[=:]\s*\[([^\]]*)\]/i);
if (allowedMethodsMatch) {
const methodsInArray = allowedMethodsMatch[1].match(/['"](GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)['"]/gi);
if (methodsInArray) {
methodsInArray.forEach(method => {
const cleanMethod = method.replace(/['"]/g, '').toUpperCase();
if (!methods.includes(cleanMethod)) {
methods.push(cleanMethod);
}
});
}
}
// If no specific methods found but has a handler, try to infer
if (methods.length === 0) {
const hasHandler = content.includes('export default') || content.includes('module.exports');
const hasReqRes = content.includes('req') && content.includes('res');
if (hasHandler && hasReqRes) {
// Try to guess based on common patterns
if (content.includes('req.body') || content.includes('req.json')) {
methods.push('POST');
}
if (content.includes('res.json') || content.includes('res.send')) {
if (!methods.includes('GET')) {
methods.push('GET');
}
}
// If still no methods found, default to GET
if (methods.length === 0) {
methods.push('GET');
}
}
}
}
return methods.length > 0 ? methods : ['GET']; // Default to GET if nothing found
}
/**
* Generate markdown table from endpoints
*/
generateMarkdownTable() {
if (this.endpoints.length === 0) {
return '## API Endpoints\n\nNo API endpoints found in this project.\n';
}
let markdown = '## API Endpoints\n\n';
markdown += '| Route | Methods | File | Type |\n';
markdown += '|-------|---------|------|------|\n';
// Sort endpoints by route
const sortedEndpoints = this.endpoints.sort((a, b) => a.route.localeCompare(b.route));
for (const endpoint of sortedEndpoints) {
const methods = endpoint.methods.join(', ');
markdown += `| \`${endpoint.route}\` | ${methods} | \`${endpoint.file}\` | ${endpoint.type} |\n`;
}
markdown += '\n';
return markdown;
}
/**
* Update README.md file with endpoints table
*/
async updateReadme(readmePath = 'README.md') {
const fullReadmePath = path.join(this.projectPath, readmePath);
let readmeContent = '';
if (await fs.pathExists(fullReadmePath)) {
readmeContent = await fs.readFile(fullReadmePath, 'utf-8');
}
const endpointsTable = this.generateMarkdownTable();
// Check if endpoints section already exists
const endpointsRegex = /## API Endpoints[\s\S]*?(?=\n##|\n#|$)/;
if (endpointsRegex.test(readmeContent)) {
// Replace existing section
readmeContent = readmeContent.replace(endpointsRegex, endpointsTable.trim());
} else {
// Add new section
readmeContent += (readmeContent ? '\n\n' : '') + endpointsTable;
}
await fs.writeFile(fullReadmePath, readmeContent);
return fullReadmePath;
}
}
module.exports = { ApiFinder };