248 lines
6.3 KiB
JavaScript
248 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* API inventory checker
|
|
* - Enumerates /api routes from unified-server + mounted routers
|
|
* - Detects duplicate method+path combinations
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const SERVER_FILE = path.join(ROOT, 'backend', 'unified-server.js');
|
|
|
|
function readFileSafe(filePath) {
|
|
try {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveRequirePath(baseFile, requirePath) {
|
|
const baseDir = path.dirname(baseFile);
|
|
const raw = path.resolve(baseDir, requirePath);
|
|
const candidates = [
|
|
raw,
|
|
`${raw}.js`,
|
|
path.join(raw, 'index.js')
|
|
];
|
|
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
}
|
|
|
|
function normalizePath(routePath) {
|
|
if (!routePath) return '/';
|
|
|
|
let normalized = routePath.replace(/\/+/g, '/');
|
|
normalized = normalized.replace(/\/\/+/, '/');
|
|
|
|
// Preserve wildcard endings, but collapse duplicate slashes.
|
|
normalized = normalized.replace(/\/+/g, '/');
|
|
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function joinPaths(basePath, subPath) {
|
|
const base = normalizePath(basePath);
|
|
const sub = normalizePath(subPath || '/');
|
|
|
|
if (sub === '/' || sub === '') return base;
|
|
if (base === '/') return sub;
|
|
|
|
return normalizePath(`${base}/${sub.replace(/^\//, '')}`);
|
|
}
|
|
|
|
function parseDirectApiRoutes(serverSource) {
|
|
const routes = [];
|
|
const regex = /app\.(get|post|put|delete|patch|options|head|all)\(\s*['"`]([^'"`]+)['"`]/g;
|
|
|
|
let match;
|
|
while ((match = regex.exec(serverSource)) !== null) {
|
|
const method = match[1].toUpperCase();
|
|
const routePath = match[2];
|
|
|
|
if (!routePath.startsWith('/api')) continue;
|
|
|
|
routes.push({
|
|
method,
|
|
path: normalizePath(routePath),
|
|
source: 'backend/unified-server.js'
|
|
});
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
function parseMountedRouters(serverSource) {
|
|
const mounts = [];
|
|
const regex = /app\.use\(\s*['"`]([^'"`]+)['"`]\s*,\s*require\(\s*['"`]([^'"`]+)['"`]\s*\)\s*\)/g;
|
|
|
|
let match;
|
|
while ((match = regex.exec(serverSource)) !== null) {
|
|
const mountPath = match[1];
|
|
const requirePath = match[2];
|
|
|
|
if (!mountPath.startsWith('/api')) continue;
|
|
|
|
const routerFile = resolveRequirePath(SERVER_FILE, requirePath);
|
|
if (!routerFile) {
|
|
mounts.push({ mountPath, routerFile: null, requirePath });
|
|
continue;
|
|
}
|
|
|
|
mounts.push({ mountPath, routerFile, requirePath });
|
|
}
|
|
|
|
return mounts;
|
|
}
|
|
|
|
function parseRouterFile(routerFile, mountPath) {
|
|
const source = readFileSafe(routerFile);
|
|
if (!source) return [];
|
|
|
|
const routes = [];
|
|
const regex = /router\.(get|post|put|delete|patch|options|head|all)\(\s*['"`]([^'"`]+)['"`]/g;
|
|
|
|
let match;
|
|
while ((match = regex.exec(source)) !== null) {
|
|
const method = match[1].toUpperCase();
|
|
const localPath = match[2];
|
|
const fullPath = joinPaths(mountPath, localPath);
|
|
|
|
routes.push({
|
|
method,
|
|
path: fullPath,
|
|
source: path.relative(ROOT, routerFile)
|
|
});
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
function findDuplicates(routes) {
|
|
const map = new Map();
|
|
|
|
routes.forEach((route) => {
|
|
const key = `${route.method} ${route.path}`;
|
|
const existing = map.get(key) || [];
|
|
existing.push(route);
|
|
map.set(key, existing);
|
|
});
|
|
|
|
const duplicates = [];
|
|
for (const [key, entries] of map.entries()) {
|
|
if (entries.length > 1) {
|
|
duplicates.push({ key, entries });
|
|
}
|
|
}
|
|
|
|
return duplicates;
|
|
}
|
|
|
|
function printInventory(routes, duplicates, unresolvedMounts) {
|
|
console.log(`API inventory: ${routes.length} endpoint(s)`);
|
|
if (unresolvedMounts.length > 0) {
|
|
console.log('');
|
|
console.log('Unresolved mounted routers:');
|
|
unresolvedMounts.forEach((mount) => {
|
|
console.log(`- ${mount.mountPath} -> ${mount.requirePath}`);
|
|
});
|
|
}
|
|
|
|
console.log('');
|
|
if (duplicates.length === 0) {
|
|
console.log('No duplicate method+path routes detected.');
|
|
} else {
|
|
console.log(`Duplicate method+path routes detected: ${duplicates.length}`);
|
|
duplicates.forEach((dup) => {
|
|
console.log(`- ${dup.key}`);
|
|
dup.entries.forEach((entry) => {
|
|
console.log(` - ${entry.source}`);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function printFullInventory(routes, duplicates, unresolvedMounts) {
|
|
console.log(`API inventory: ${routes.length} endpoint(s)`);
|
|
console.log('');
|
|
|
|
routes
|
|
.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`))
|
|
.forEach((route) => {
|
|
console.log(`${route.method.padEnd(7)} ${route.path} (${route.source})`);
|
|
});
|
|
|
|
if (unresolvedMounts.length > 0) {
|
|
console.log('');
|
|
console.log('Unresolved mounted routers:');
|
|
unresolvedMounts.forEach((mount) => {
|
|
console.log(`- ${mount.mountPath} -> ${mount.requirePath}`);
|
|
});
|
|
}
|
|
|
|
console.log('');
|
|
if (duplicates.length === 0) {
|
|
console.log('No duplicate method+path routes detected.');
|
|
} else {
|
|
console.log(`Duplicate method+path routes detected: ${duplicates.length}`);
|
|
duplicates.forEach((dup) => {
|
|
console.log(`- ${dup.key}`);
|
|
dup.entries.forEach((entry) => {
|
|
console.log(` - ${entry.source}`);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const args = new Set(process.argv.slice(2));
|
|
const failOnDuplicates = args.has('--check') || args.has('--fail-on-duplicates');
|
|
const fullOutput = args.has('--full');
|
|
|
|
const serverSource = readFileSafe(SERVER_FILE);
|
|
if (!serverSource) {
|
|
console.error(`Unable to read ${SERVER_FILE}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const directRoutes = parseDirectApiRoutes(serverSource);
|
|
const mounts = parseMountedRouters(serverSource);
|
|
|
|
const mountedRoutes = [];
|
|
const unresolvedMounts = [];
|
|
|
|
mounts.forEach((mount) => {
|
|
if (!mount.routerFile) {
|
|
unresolvedMounts.push(mount);
|
|
return;
|
|
}
|
|
|
|
mountedRoutes.push(...parseRouterFile(mount.routerFile, mount.mountPath));
|
|
});
|
|
|
|
const allRoutes = [...directRoutes, ...mountedRoutes];
|
|
const duplicates = findDuplicates(allRoutes);
|
|
|
|
if (fullOutput) {
|
|
printFullInventory(allRoutes, duplicates, unresolvedMounts);
|
|
} else {
|
|
printInventory(allRoutes, duplicates, unresolvedMounts);
|
|
}
|
|
|
|
if (failOnDuplicates && duplicates.length > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
if (unresolvedMounts.length > 0) {
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
main();
|