/** * 路由注册表(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)。 * **只认 `HTTP_METHODS` 那五个方法,归一是调用方的职责** —— 比如 HEAD:框架把它 * 改写成 GET 再分发,这里照字面查必然 miss,中间件必须先归一再来查。 */ 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}$`); } /** * 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容 * (参数段与任何段兼容;字面段只与相同的字面兼容)。 */ function canCollide(a: string, b: string): boolean { const sa = a.split('/'); const sb = b.split('/'); if (sa.length !== sb.length) return false; return sa.every((seg, i) => { const other = sb[i] ?? ''; if (seg.startsWith(':') || other.startsWith(':')) return true; return seg === other; }); } export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { const literal = new Map(); 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 }); continue; } /* * 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)` * 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。 * 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。 */ const key = `${r.method} ${r.path}`; if (literal.has(key)) { throw new Error( `路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`, ); } literal.set(key, r.access); } /* * 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**, * 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。 * 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。 */ for (let i = 0; i < patterns.length; i += 1) { for (let j = i + 1; j < patterns.length; j += 1) { const a = patterns[i]; const b = patterns[j]; if (a === undefined || b === undefined) continue; if (a.method === b.method && canCollide(a.pattern, b.pattern)) { throw new Error( `路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` + '命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。', ); } } } return { // 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条) size: literal.size + patterns.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); }, }; }