Files
tilbudgivern/scripts/api-inventory.js
2026-07-05 08:10:59 +00:00

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();