From 2eef8b4544eab1e2936a11254685d84b39c1fac2 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 25 Aug 2026 16:42:11 +0800 Subject: [PATCH] =?UTF-8?q?app:=E8=B7=AF=E7=94=B1=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=20=E2=80=94=E2=80=94=20=E5=A5=91=E7=BA=A6=E8=87=AA?= =?UTF-8?q?=E5=B8=A6=20access,=E4=B8=80=E5=A4=84=20lookup=20=E7=BB=99?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E4=BB=B6(ADR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每条 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) Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw --- .../src/app/__tests__/route-registry.test.ts | 63 ++++++++++++++++ apps/api/src/app/route-registry.ts | 74 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 apps/api/src/app/__tests__/route-registry.test.ts create mode 100644 apps/api/src/app/route-registry.ts diff --git a/apps/api/src/app/__tests__/route-registry.test.ts b/apps/api/src/app/__tests__/route-registry.test.ts new file mode 100644 index 0000000..edfb790 --- /dev/null +++ b/apps/api/src/app/__tests__/route-registry.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/app/route-registry.ts b/apps/api/src/app/route-registry.ts new file mode 100644 index 0000000..9f333f2 --- /dev/null +++ b/apps/api/src/app/route-registry.ts @@ -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(); + 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); + }, + }; +}