Skip to content

Commit ecc61ab

Browse files
os-zhuangclaude
andauthored
feat(metadata): endpoint matcher — matchEndpoint lazy index (#5089) (#5110)
Implement `IMetadataService.matchEndpoint?` on `MetadataManager`, the repo's occupant of the `metadata` slot, backed by a new pure matcher module. E2 of the #5040 endpoint-executor program; the contract text landed in #5080/#5097 and is implemented here literally. - METHOD -> exact-path -> parsed-endpoint index, built lazily on the first call. `method` upper-cased; `path` compared as a whole string after trimming exactly one trailing slash (both sides, never a lone `/`). No percent-decoding, no Unicode normalization, no case folding in 17.x. `params` is always `{}` — the frozen ADR-0121 vocabulary defines no template syntax and this does not invent one. - Every stored item goes through `ApiEndpointSchema.safeParse`, so the answer carries materialized defaults (an omitted `authRequired` comes back `true`). An item that fails to parse is skipped and named at `error` level; it never disturbs the good items around it. - Duplicate METHOD+path claims resolve deterministically: the lexicographically-first `name` keeps the route and the discarded claimant is named at `error` level with the rule (#5040 design §1.3). - `undefined` is a miss; a store that cannot be read THROWS, so an outage never masquerades as a 404 (ADR-0110 D3's distinction, applied to the plural read via a private `listForIndex`). A failed build is not cached. - Invalidation reuses the existing mechanisms only: `invalidateListCache('api')` covers every local write including the `{ notify: false }` artifact-ingest / HMR path, and a `subscribe('api')` watcher covers cluster peer replay. Zero HTTP behavior change: nothing calls `matchEndpoint` yet (the dispatcher seam is #5090) and publish still rejects a non-empty `apis:` (#4936), so the whole path is structurally unreachable. `packages/spec` untouched — the vocabulary stays frozen. Out-of-scope findings filed: #5108, #5109. Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41c3b48 commit ecc61ab

5 files changed

Lines changed: 946 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/metadata": minor
3+
---
4+
5+
feat(metadata): 端点匹配器 —— `MetadataManager.matchEndpoint` 惰性索引实现 (#5089)
6+
7+
`IMetadataService.matchEndpoint?` 的契约在 #5080/#5097 落地(声明先行),本变更补上
8+
`metadata` 槽位占位者 `MetadataManager` 的实现:把已声明的 `api` 元数据条目编成
9+
**METHOD → 精确路径 → 端点** 的惰性索引,供 HTTP 分发器在「没有内建域认领这条路径」
10+
与「回答语义 404」之间做一次查表。这是 #5040 端点执行器程序的 E2 单。
11+
12+
**结构性不可达,零行为变更。** 17.x 里没有任何东西会调用 `matchEndpoint`:挂载 seam
13+
#5090 的面,而 publish/validate 对非空 `apis:` 仍然硬拒(#4936)。新代码在真实组合
14+
里不暴露任何 HTTP 行为;测试直接驱动服务,这正是 #5040 设计选定的验收姿态。
15+
16+
实现要点(逐字实现契约文本,`packages/spec/src/contracts/metadata-service.ts`):
17+
18+
- **匹配维度**:`method` 大写规整后比较(请求动词大小写不敏感);`path` 去掉**一个**
19+
尾斜杠后**整串精确**比较,两侧同规则。17.x 不做百分号解码、不做 Unicode 规整、
20+
不做大小写折叠 —— 原串即键。词表(ADR-0121)未定义任何路径模板语法,因此
21+
`params` **恒为 `{}`**;此处不发明只存在于实现里的方言。
22+
- **答案是 parse 后的形状**:每条经 `ApiEndpointSchema.safeParse`,默认值已物化 ——
23+
作者省略 `authRequired` 时消费方拿到的是 `true`,不可能把「缺省」误读为放行。
24+
- **坏条目响亮缺席**:解析失败的存量条目被跳过并以 `error` 级点名(说明该路由将回 404
25+
及如何修),绝不返回半合法形状,也绝不牵连同批的好条目。
26+
- **重复声明确定性收敛**:两条条目声明同一 METHOD+path 时,`name` 字典序在前者保留
27+
路由,被弃者连同规则一并 `error` 级点名 —— 不是静默 last-write-wins,每个节点、每次
28+
启动的解析结果一致。
29+
- **断存储抛错,不伪装 404**:`undefined` 只表示「无声明拥有这条路由」;读不到存储时
30+
抛出(与 `loadDiagnosed` 的 miss/outage 之分同源,ADR-0110 D3),因为 miss 会变成
31+
404,而故障不得伪装成 404。构建失败不缓存,下次调用重试。
32+
- **失效**:挂在仓内既有机制上,不新造事件系统 —— `invalidateListCache('api')` 覆盖
33+
全部本地写入(含 artifact 装载 / HMR 的 `{ notify: false }` 写入,这些按构造不经过
34+
watcher),`subscribe('api', …)` 覆盖集群对端回放(它只经 `notifyWatchersLocal`)。
35+
失效后下次调用整体重建。
36+
37+
`ApiEndpointSchema``packages/spec` 未做任何改动(词表冻结)。
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5089 (#5040 E2) — `matchEndpoint`: the declared-endpoint matcher.
5+
*
6+
* The binding specification is the contract text on
7+
* `IMetadataService.matchEndpoint` / `ApiEndpointMatch`
8+
* (`packages/spec/src/contracts/metadata-service.ts`, landed by #5080/#5097).
9+
* Every `it()` below pins one clause of it, so a future edit that softens the
10+
* contract fails here and not in production:
11+
*
12+
* • method compared case-insensitively;
13+
* • path compared as a WHOLE STRING after trimming a trailing slash, with no
14+
* percent-decoding / Unicode normalization / case folding in 17.x;
15+
* • the answer is `ApiEndpointSchema.parse`-d — defaults MATERIALIZED, so an
16+
* omitted `authRequired` comes back `true`;
17+
* • a stored item that fails to parse is skipped LOUDLY and never breaks the
18+
* good items around it;
19+
* • `undefined` is a miss; a store that cannot be read THROWS, because a
20+
* miss becomes a 404 and an outage must not masquerade as one;
21+
* • `params` is always `{}` — 17.x defines no path-template syntax;
22+
* • a duplicate METHOD+path claim resolves deterministically and loudly.
23+
*/
24+
25+
import { describe, it, expect, vi, beforeEach } from 'vitest';
26+
import type { Logger } from '@objectstack/spec/contracts';
27+
import {
28+
EndpointMatcher,
29+
buildEndpointIndex,
30+
endpointIndexKey,
31+
normalizeEndpointMethod,
32+
normalizeEndpointPath,
33+
} from './endpoint-matcher.js';
34+
35+
function makeLogger(): Logger & { error: ReturnType<typeof vi.fn> } {
36+
return {
37+
debug: vi.fn(),
38+
info: vi.fn(),
39+
warn: vi.fn(),
40+
error: vi.fn(),
41+
} as unknown as Logger & { error: ReturnType<typeof vi.fn> };
42+
}
43+
44+
/** A minimal, valid `ApiEndpointSchema` input. `authRequired` deliberately omitted. */
45+
function endpoint(over: Record<string, unknown> = {}): Record<string, unknown> {
46+
return {
47+
name: 'list_tasks',
48+
path: '/api/v1/apps/showcase/tasks',
49+
method: 'GET',
50+
type: 'object_operation',
51+
target: 'showcase_task',
52+
...over,
53+
};
54+
}
55+
56+
describe('normalization helpers', () => {
57+
it('upper-cases the method', () => {
58+
expect(normalizeEndpointMethod('get')).toBe('GET');
59+
expect(normalizeEndpointMethod('PoSt')).toBe('POST');
60+
});
61+
62+
it('trims exactly ONE trailing slash', () => {
63+
expect(normalizeEndpointPath('/a/b/')).toBe('/a/b');
64+
expect(normalizeEndpointPath('/a/b')).toBe('/a/b');
65+
// one, not all — `/x//` and `/x/` are different paths to every router here
66+
expect(normalizeEndpointPath('/a/b//')).toBe('/a/b/');
67+
});
68+
69+
it('never trims a lone "/" (so a query for "" cannot collide with it)', () => {
70+
expect(normalizeEndpointPath('/')).toBe('/');
71+
expect(normalizeEndpointPath('')).toBe('');
72+
});
73+
74+
it('does NOT percent-decode, case-fold or Unicode-normalize (17.x)', () => {
75+
expect(normalizeEndpointPath('/a%2Fb')).toBe('/a%2Fb');
76+
expect(normalizeEndpointPath('/Tasks')).toBe('/Tasks');
77+
// NFD "é" stays NFD — no NFC folding
78+
expect(normalizeEndpointPath('/café')).toBe('/café');
79+
});
80+
81+
it('keys as "METHOD path"', () => {
82+
expect(endpointIndexKey('get', '/x/')).toBe('GET /x');
83+
});
84+
});
85+
86+
describe('buildEndpointIndex', () => {
87+
it('builds a METHOD → exact-path → parsed-endpoint index', () => {
88+
const logger = makeLogger();
89+
const index = buildEndpointIndex(
90+
[endpoint(), endpoint({ name: 'create_task', method: 'POST', path: '/api/v1/apps/showcase/tasks' })],
91+
logger,
92+
);
93+
94+
expect([...index.keys()].sort()).toEqual([
95+
'GET /api/v1/apps/showcase/tasks',
96+
'POST /api/v1/apps/showcase/tasks',
97+
]);
98+
expect(logger.error).not.toHaveBeenCalled();
99+
});
100+
101+
it('trims a stored declaration\'s trailing slash when indexing it', () => {
102+
const index = buildEndpointIndex([endpoint({ path: '/api/v1/apps/showcase/tasks/' })], makeLogger());
103+
expect(index.has('GET /api/v1/apps/showcase/tasks')).toBe(true);
104+
});
105+
106+
it('materializes schema defaults — an omitted authRequired is `true`', () => {
107+
const index = buildEndpointIndex([endpoint()], makeLogger());
108+
expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(true);
109+
});
110+
111+
it('preserves an explicit authRequired: false', () => {
112+
const index = buildEndpointIndex([endpoint({ authRequired: false })], makeLogger());
113+
expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false);
114+
});
115+
116+
it('strips storage annotations (_lock / packageId) rather than choking on them', () => {
117+
const index = buildEndpointIndex(
118+
[{ ...endpoint(), _lock: { managed: true }, _packageId: 'pkg_showcase' }],
119+
makeLogger(),
120+
);
121+
const hit = index.get('GET /api/v1/apps/showcase/tasks')!;
122+
expect(hit).toBeDefined();
123+
expect(hit as Record<string, unknown>).not.toHaveProperty('_lock');
124+
});
125+
});
126+
127+
describe('parse failure — loud skip, no collateral damage', () => {
128+
it('skips an unparseable stored item, logs it at error level, and keeps the good ones', () => {
129+
const logger = makeLogger();
130+
const index = buildEndpointIndex(
131+
[
132+
{ name: 'broken_ep', path: '/api/v1/apps/showcase/broken' }, // no method / type / target
133+
endpoint(),
134+
],
135+
logger,
136+
);
137+
138+
expect(index.has('GET /api/v1/apps/showcase/tasks')).toBe(true);
139+
expect(index.size).toBe(1);
140+
expect(logger.error).toHaveBeenCalledTimes(1);
141+
const [message] = logger.error.mock.calls[0];
142+
expect(message).toContain('broken_ep');
143+
expect(message).toContain('ApiEndpointSchema');
144+
});
145+
146+
it('names an item that has no usable name as <unnamed> instead of throwing', () => {
147+
const logger = makeLogger();
148+
const index = buildEndpointIndex([null, 42, { path: '/x' }], logger);
149+
expect(index.size).toBe(0);
150+
expect(logger.error).toHaveBeenCalledTimes(3);
151+
expect(logger.error.mock.calls[0][0]).toContain('<unnamed>');
152+
});
153+
});
154+
155+
describe('duplicate METHOD+path claims — deterministic and loud', () => {
156+
it('keeps the lexicographically-first `name` and names the ignored claimant', () => {
157+
const logger = makeLogger();
158+
const index = buildEndpointIndex(
159+
[
160+
endpoint({ name: 'zeta_tasks', target: 'z' }),
161+
endpoint({ name: 'alpha_tasks', target: 'a' }),
162+
],
163+
logger,
164+
);
165+
166+
expect(index.get('GET /api/v1/apps/showcase/tasks')!.name).toBe('alpha_tasks');
167+
expect(logger.error).toHaveBeenCalledTimes(1);
168+
const [message, , meta] = logger.error.mock.calls[0];
169+
expect(message).toContain('duplicate endpoint claim');
170+
expect(meta).toMatchObject({ winner: 'alpha_tasks', ignored: 'zeta_tasks' });
171+
});
172+
173+
it('resolves identically regardless of the order items arrive in', () => {
174+
const forward = buildEndpointIndex(
175+
[endpoint({ name: 'alpha_tasks' }), endpoint({ name: 'zeta_tasks' })],
176+
makeLogger(),
177+
);
178+
const reverse = buildEndpointIndex(
179+
[endpoint({ name: 'zeta_tasks' }), endpoint({ name: 'alpha_tasks' })],
180+
makeLogger(),
181+
);
182+
expect(forward.get('GET /api/v1/apps/showcase/tasks')!.name)
183+
.toBe(reverse.get('GET /api/v1/apps/showcase/tasks')!.name);
184+
});
185+
186+
it('does NOT treat different methods on the same path as a duplicate', () => {
187+
const logger = makeLogger();
188+
const index = buildEndpointIndex(
189+
[endpoint({ name: 'a_get' }), endpoint({ name: 'b_post', method: 'POST' })],
190+
logger,
191+
);
192+
expect(index.size).toBe(2);
193+
expect(logger.error).not.toHaveBeenCalled();
194+
});
195+
});
196+
197+
describe('EndpointMatcher.match', () => {
198+
let logger: ReturnType<typeof makeLogger>;
199+
let items: unknown[];
200+
let reads: number;
201+
let matcher: EndpointMatcher;
202+
203+
beforeEach(() => {
204+
logger = makeLogger();
205+
items = [endpoint()];
206+
reads = 0;
207+
matcher = new EndpointMatcher({
208+
listApiItems: async () => {
209+
reads++;
210+
return items;
211+
},
212+
logger,
213+
});
214+
});
215+
216+
it('hits an exactly-declared route', async () => {
217+
const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
218+
expect(match?.endpoint.name).toBe('list_tasks');
219+
});
220+
221+
it('returns `params: {}` — 17.x has no path-template syntax', async () => {
222+
const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
223+
expect(match?.params).toEqual({});
224+
});
225+
226+
it('compares the method case-insensitively', async () => {
227+
for (const verb of ['get', 'Get', 'gEt', 'GET']) {
228+
const match = await matcher.match({ method: verb, path: '/api/v1/apps/showcase/tasks' });
229+
expect(match?.endpoint.name).toBe('list_tasks');
230+
}
231+
});
232+
233+
it('trims a trailing slash on the QUERY side too', async () => {
234+
const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks/' });
235+
expect(match?.endpoint.name).toBe('list_tasks');
236+
});
237+
238+
it('trims on BOTH sides consistently (stored with slash, queried without)', async () => {
239+
items = [endpoint({ path: '/api/v1/apps/showcase/tasks/' })];
240+
matcher.invalidate();
241+
const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
242+
expect(match?.endpoint.name).toBe('list_tasks');
243+
});
244+
245+
it('misses on an undeclared path — undefined, not an error', async () => {
246+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/nope' }))
247+
.resolves.toBeUndefined();
248+
});
249+
250+
it('misses on a declared path with an undeclared method', async () => {
251+
await expect(matcher.match({ method: 'DELETE', path: '/api/v1/apps/showcase/tasks' }))
252+
.resolves.toBeUndefined();
253+
});
254+
255+
it('misses on a case-differing path — 17.x does NOT case-fold the path', async () => {
256+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/Tasks' }))
257+
.resolves.toBeUndefined();
258+
});
259+
260+
it('misses on a percent-encoded spelling — 17.x does NOT decode the path', async () => {
261+
items = [endpoint({ path: '/api/v1/apps/showcase/my tasks' })];
262+
matcher.invalidate();
263+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/my%20tasks' }))
264+
.resolves.toBeUndefined();
265+
});
266+
267+
it('a prefix of a declared path is not a match — the whole string is the key', async () => {
268+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase' }))
269+
.resolves.toBeUndefined();
270+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks/42' }))
271+
.resolves.toBeUndefined();
272+
});
273+
274+
it('builds the index lazily — once, then reuses it', async () => {
275+
expect(reads).toBe(0);
276+
await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
277+
await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
278+
await matcher.match({ method: 'GET', path: '/nope' });
279+
expect(reads).toBe(1);
280+
});
281+
282+
it('shares one store read across concurrent first calls', async () => {
283+
await Promise.all([
284+
matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }),
285+
matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }),
286+
matcher.match({ method: 'GET', path: '/nope' }),
287+
]);
288+
expect(reads).toBe(1);
289+
});
290+
291+
it('rebuilds after invalidate(), picking up the new declaration', async () => {
292+
expect(await matcher.match({ method: 'POST', path: '/api/v1/apps/showcase/tasks' })).toBeUndefined();
293+
items = [...items, endpoint({ name: 'create_task', method: 'POST' })];
294+
matcher.invalidate();
295+
const match = await matcher.match({ method: 'POST', path: '/api/v1/apps/showcase/tasks' });
296+
expect(match?.endpoint.name).toBe('create_task');
297+
expect(reads).toBe(2);
298+
});
299+
});
300+
301+
describe('a store that cannot be read THROWS — an outage is not a 404', () => {
302+
it('propagates the read failure instead of reporting a miss', async () => {
303+
const matcher = new EndpointMatcher({
304+
listApiItems: async () => {
305+
throw new Error('sys_metadata unreachable');
306+
},
307+
logger: makeLogger(),
308+
});
309+
310+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }))
311+
.rejects.toThrow('sys_metadata unreachable');
312+
});
313+
314+
it('does not cache the failure — a recovered store serves on the next call', async () => {
315+
let healthy = false;
316+
const matcher = new EndpointMatcher({
317+
listApiItems: async () => {
318+
if (!healthy) throw new Error('sys_metadata unreachable');
319+
return [endpoint()];
320+
},
321+
logger: makeLogger(),
322+
});
323+
324+
await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' })).rejects.toThrow();
325+
healthy = true;
326+
const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' });
327+
expect(match?.endpoint.name).toBe('list_tasks');
328+
});
329+
});

0 commit comments

Comments
 (0)