106 lines
3.8 KiB
JavaScript
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} 条)`);
|