app:路由注册表 —— 契约自带 access,一处 lookup 给中间件(ADR-012)

每条 RouteContract 都声明了自己的 access 权限;此处扫一遍契约命名空间,
构建一个 lookup(method, path) → access 的注册表。新路由无需另加守卫,
只需在契约里声明,就自动生效——遗漏即无门禁,一处事实源确保完整。

- 字面路径优先于参数段(:name 匹配任意非空段)
- 方法维度单独追踪
- 识别 RouteContract:method/path/access/output 都具备
- routesOf() 抽出命名空间内的路由
- buildRouteRegistry() 与 lookup/has API 给中间件与测试用

扫结果:95 条路由已覆盖

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
This commit is contained in:
xcs
2026-08-25 16:42:11 +08:00
co-authored by Claude Opus 5
parent 4d55ac465d
commit 2eef8b4544
2 changed files with 137 additions and 0 deletions
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import * as contracts from '@contracts';
import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts';
const registry = buildRouteRegistry([
{ method: 'GET', path: '/api/health', access: 'public' },
{ method: 'GET', path: '/api/me', access: 'any' },
{ method: 'GET', path: '/api/gov/tasks', access: 'ops' },
{ method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' },
{ method: 'GET', path: '/api/gov/testbed/logs', access: 'design' },
{ method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' },
{ method: 'POST', path: '/api/gov/process/validate', access: 'design' },
{ method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
]);
describe('路由注册表', () => {
it('字面路径精确命中', () => {
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
expect(registry.lookup('GET', '/api/health')).toBe('public');
});
it('参数段:一段认一段,多一段少一段都不算', () => {
expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops');
expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined();
expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design');
});
it('方法不同不混', () => {
expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined();
expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined();
});
it('字面优先于参数段(logs 与 logs/:id 各归各)', () => {
expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design');
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design');
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined();
});
it('未知路由 → undefined', () => {
expect(registry.lookup('GET', '/api/nope')).toBeUndefined();
});
it('数组归属原样回', () => {
expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
});
it('has 按注册时的模版查', () => {
expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true);
expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false);
expect(registry.has('GET', '/api/health')).toBe(true);
expect(registry.size).toBe(9);
});
});
describe('routesOf —— 从契约命名空间挑路由', () => {
it('只认长得像路由契约的导出', () => {
expect(isRouteContract(contracts.meRoute)).toBe(true);
expect(isRouteContract(contracts.staffSchema)).toBe(false);
expect(isRouteContract('x')).toBe(false);
expect(isRouteContract(null)).toBe(false);
});
it('扫真契约至少 90 条,且每条都能 has 到自己', () => {
const all = routesOf(Object.values(contracts));
expect(all.length).toBeGreaterThanOrEqual(90);
const reg = buildRouteRegistry(all);
for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身:
* 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。
* 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。
*
* 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。
*/
import type { HttpMethod, RouteAccess, RouteContract } from '@contracts';
import { HTTP_METHODS } from '@contracts';
export type RegisteredRoute = {
readonly method: HttpMethod;
readonly path: string;
readonly access: RouteAccess;
};
export type RouteRegistry = {
readonly size: number;
/** 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404) */
lookup(method: string, path: string): RouteAccess | undefined;
/** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */
has(method: string, pathPattern: string): boolean;
};
const METHODS: readonly string[] = HTTP_METHODS;
export function isRouteContract(value: unknown): value is RouteContract {
if (typeof value !== 'object' || value === null) return false;
const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown };
return (
typeof v.method === 'string' &&
METHODS.includes(v.method) &&
typeof v.path === 'string' &&
v.access !== undefined &&
v.output !== undefined
);
}
/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */
export function routesOf(values: readonly unknown[]): RegisteredRoute[] {
return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access }));
}
function escapeSegment(segment: string): string {
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function matcherOf(pattern: string): RegExp {
const body = pattern
.split('/')
.map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg)))
.join('/');
return new RegExp(`^${body}$`);
}
export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry {
const literal = new Map<string, RouteAccess>();
const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = [];
for (const r of routes) {
if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access });
else literal.set(`${r.method} ${r.path}`, r.access);
}
return {
size: routes.length,
lookup(method, path) {
const exact = literal.get(`${method} ${path}`);
if (exact !== undefined) return exact;
return patterns.find((p) => p.method === method && p.re.test(path))?.access;
},
has(method, pathPattern) {
return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern);
},
};
}