Files
opendance-web/web-server/scripts/check-routes.mjs
T

106 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
/**
* 校验 contracts/rest-api.yaml 中声明的路由与 NestJS 控制器实际实现的路由一致。
*
* 双向检查:
* - 契约中声明但未实现 → 失败(契约不可落空)
* - 已实现但契约未声明 → 失败(实现不可绕过契约)
*
* 用法:node scripts/check-routes.mjs
*/
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
const specPath = join(root, '..', 'contracts', 'rest-api.yaml');
const METHODS = new Set(['get', 'post', 'patch', 'put', 'delete']);
/** 从 OpenAPI YAML 中提取 paths 下的路由(不引入 YAML 依赖,按缩进解析) */
function parseSpecRoutes(yaml) {
const lines = yaml.split(/\r?\n/);
const routes = new Set();
let inPaths = false;
let currentPath = null;
for (const line of lines) {
if (/^paths:\s*$/.test(line)) {
inPaths = true;
continue;
}
if (inPaths && /^[A-Za-z]/.test(line)) {
inPaths = false; // 离开 paths 段(进入 components 等)
currentPath = null;
continue;
}
if (!inPaths) continue;
const pathMatch = /^ {2}(\/\S*):\s*$/.exec(line);
if (pathMatch) {
currentPath = pathMatch[1].replace(/\{([^}]+)\}/g, ':$1');
continue;
}
const methodMatch = /^ {4}([a-z]+):\s*/.exec(line);
if (methodMatch && currentPath && METHODS.has(methodMatch[1])) {
routes.add(`${methodMatch[1].toUpperCase()} ${currentPath}`);
}
}
return routes;
}
/** 递归查找 *.controller.ts(避免依赖 fs.globSync 的 Node 版本差异) */
function findControllers(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...findControllers(full));
else if (entry.isFile() && entry.name.endsWith('.controller.ts')) out.push(full);
}
return out;
}
/** 从 NestJS 控制器提取路由 */
function parseControllerRoutes(dir) {
const routes = new Set();
for (const file of findControllers(dir)) {
const source = readFileSync(file, 'utf8');
const controllerMatch = /@Controller\(\s*['"]([^'"]*)['"]/.exec(source);
const prefix = controllerMatch ? `/${controllerMatch[1].replace(/^\/|\/$/g, '')}` : '';
const decorator = /@(Get|Post|Patch|Put|Delete)\(\s*(?:['"]([^'"]*)['"])?[^)]*\)/g;
let match;
while ((match = decorator.exec(source)) !== null) {
const method = match[1].toUpperCase();
const sub = (match[2] ?? '').replace(/^\/|\/$/g, '');
let full = sub ? `${prefix}/${sub}` : prefix;
if (!full) full = '/';
// Nest 路由参数是 :id,契约是 {id},统一成 :name
full = full.replace(/\{([^}]+)\}/g, ':$1').replace(/\/+$/, '') || '/';
routes.add(`${method} ${full}`);
}
}
return routes;
}
const specRoutes = parseSpecRoutes(readFileSync(specPath, 'utf8'));
const implRoutes = parseControllerRoutes(join(root, 'src'));
const missing = [...specRoutes].filter((r) => !implRoutes.has(r)).sort();
const extra = [...implRoutes].filter((r) => !specRoutes.has(r)).sort();
const problems = [];
if (missing.length) problems.push(`契约中声明但未实现:\n${missing.map((r) => ` - ${r}`).join('\n')}`);
if (extra.length) problems.push(`已实现但契约未声明:\n${extra.map((r) => ` + ${r}`).join('\n')}`);
if (problems.length) {
console.error(`✗ 路由与契约不一致(契约 ${specRoutes.size} 条 / 实现 ${implRoutes.size} 条)`);
console.error(problems.join('\n\n'));
console.error('\n请同步 contracts/rest-api.yaml 与 web-server/src/**/*.controller.ts');
process.exit(1);
}
console.log(`✓ 路由与契约一致(共 ${specRoutes.size} 条)`);