Skip to content

Commit c342923

Browse files
committed
feat(spec): executor contract surface — matchEndpoint? + setFallbackHandler? (#5080)
Part of #5040 (E1, contract-first). Pure declaration: two OPTIONAL contract members plus one exported type. No implementation, no wiring, zero behavior change — declared `apis:` are still hard-rejected at publish in v17 (#4936). IMetadataService.matchEndpoint?(query: { path, method }) Resolves a request's method+path to the declared `api` item that owns it, or undefined on a miss — the dispatcher step between "no built-in domain claimed this" and "answer a semantic 404". New exported type ApiEndpointMatch: - `endpoint` is the ApiEndpointSchema.parse-d shape, schema defaults MATERIALIZED, so a consumer can never read a missing `authRequired` as permissive. - `params` is always {} in 17.x. The frozen ApiEndpointSchema vocabulary (ADR-0121) defines no template syntax and this contract deliberately does not invent one — a syntax living only inside an implementation is the hidden dialect Prime Directive #12 forbids. The slot is declared now so path templates would be an additive vocabulary change rather than a breaking contract change. IHttpServer.setFallbackHandler?(handler: RouteHandler) The last-resort handler, invoked only after every explicitly registered route has missed. Structurally incapable of shadowing a registered route, hence zero registration-order dependency — unlike the wildcard-route alternative, whose ownership is decided by first-registration-wins across plugin start() order (the ADR-0076 D11 hazard). Second guarantee, also in the contract: the handler's `req.body` IS readable, in contrast with the use() middleware contract which explicitly does not populate it. That difference is why the middleware seam cannot carry dynamic endpoints. Both members are optional and feature-detected with typeof === 'function', matching watch? / subscribe? / getRawApp?. No migration for implementors. Contract tests mirror the existing contracts-test style: optional-member presence/absence probing, and type-level shape assertions via typed literals. Generated: api-surface.json gains exactly one line, ApiEndpointMatch (interface) — 0 breaking, 1 added. The two members are interface members, not exports, so the other seven artifacts are untouched.
1 parent 30ff55b commit c342923

6 files changed

Lines changed: 401 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): 执行器契约面 —— `IMetadataService.matchEndpoint?``IHttpServer.setFallbackHandler?` 可选成员(#5040 执行器 E1)
6+
7+
**纯声明,零行为变更。** 本改动只在 `packages/spec/src/contracts/` 增加两个**可选**契约成员与一个导出类型;仓内没有任何实现体、没有任何接线,现网行为逐字节不变。声明式 `ApiEndpoint` 在 v17 仍被 publish 硬拒(#4936 裁决),本单落的是它未来得以执行所需的契约前件(contract-first 首件)。
8+
9+
**1. `IMetadataService.matchEndpoint?(query: { path, method })`** — 把一次请求的 `method`+`path` 解析为拥有该路由的 `api` 元数据条目,或在无人声明时返回 `undefined`。这是 HTTP dispatcher 在「内建 domain 均未认领」与「答语义 404」之间的那一步。随之导出新类型 `ApiEndpointMatch`:
10+
11+
- `endpoint``ApiEndpointSchema.parse` **之后**的形状 —— 默认值已物化,而非存储里的原始 JSON。作者漏写 `authRequired` 时消费端拿到的是 `true`(schema 默认值),因此消费端永远读不到「缺省」这个中间态,也就不可能把一个缺失的安全默认误读成放行。
12+
- `params` 在 17.x **恒为 `{}`**`ApiEndpointSchema.path` 词表已冻结(ADR-0121),既未定义 `:param` 也未定义 `{param}`,本契约**刻意不发明**模板语法 —— 只存在于实现里的语法就是隐藏方言(Prime Directive #12)。槽位现在就声明出来,是为了将来真要加路径模板时,那是词表的加法,而不是本契约的破坏性变更。
13+
14+
**2. `IHttpServer.setFallbackHandler?(handler: RouteHandler)`** — 传输层兜底 seam:仅当**全部显式注册的路由均未命中**后才被调用。它在结构上不可能遮蔽任何已注册路由,因此零注册顺序依赖 —— 这正是它优于「通配路由」方案的原因,后者由插件 `start()` 顺序下的 first-registration-wins 决定归属,即 ADR-0076 D11「一条路由一个属主」要防的病灶。第二条保证同样载入契约:兜底 handler 收到的 `req.body` **可读**,与 `use()` 中间件契约明确「body 不填充」相反(在 `use()` 处解析 body 会在真正拥有它的路由 handler 之前吃掉请求流)—— 这条差异正是中间件 seam 无法承载动态端点、而必须新增本成员的原因:由 flow 或 `create` 操作支撑的声明式端点必须读 body。
15+
16+
**两者均为可选成员**,消费端按仓内既有惯例以 `typeof x === 'function'` 探测(同 `watch?` / `subscribe?` / `getRawApp?`)。不实现它的 `metadata` 槽位占用者、无法表达 not-found 钩子的适配器,都仍然满足契约,消费端退化到既有的未命中应答。因此对现有实现方**无迁移动作**
17+
18+
生成物影响:`api-surface.json` 新增一行 `ApiEndpointMatch (interface)`(0 breaking / 1 added)。两个新成员是 interface 成员而非导出,不动其余七件生成物。

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3564,6 +3564,7 @@
35643564
"AnalyticsQueryInput (type)",
35653565
"AnalyticsResult (interface)",
35663566
"AnalyticsStrategy (interface)",
3567+
"ApiEndpointMatch (interface)",
35673568
"ApprovalActionAttachment (interface)",
35683569
"ApprovalActionKind (type)",
35693570
"ApprovalActionRow (interface)",

packages/spec/src/contracts/http-server.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,139 @@ describe('HTTP Server Contract', () => {
177177
await expect(server.close!()).resolves.toBeUndefined();
178178
});
179179

180+
describe('optional setFallbackHandler (#5040 E1)', () => {
181+
/** A server with only the REQUIRED members. */
182+
const baseServer = (): IHttpServer => ({
183+
get: () => {},
184+
post: () => {},
185+
put: () => {},
186+
delete: () => {},
187+
patch: () => {},
188+
use: () => {},
189+
listen: async () => {},
190+
});
191+
192+
it('is optional — an adapter without it still satisfies the contract', () => {
193+
const server = baseServer();
194+
195+
expect(typeof server.setFallbackHandler).toBe('undefined');
196+
expect(typeof server.setFallbackHandler === 'function').toBe(false);
197+
});
198+
199+
it('is feature-detected with typeof === "function" when provided', () => {
200+
const server: IHttpServer = {
201+
...baseServer(),
202+
setFallbackHandler: (_handler) => {},
203+
};
204+
205+
expect(typeof server.setFallbackHandler).toBe('function');
206+
});
207+
208+
it('accepts a RouteHandler — the same handler shape routes take', () => {
209+
let installed: RouteHandler | undefined;
210+
211+
const server: IHttpServer = {
212+
...baseServer(),
213+
setFallbackHandler: (handler) => { installed = handler; },
214+
};
215+
216+
const fallback: RouteHandler = async (_req, res) => {
217+
res.status(404).json({ error: { code: 'ROUTE_NOT_FOUND' } });
218+
};
219+
server.setFallbackHandler!(fallback);
220+
221+
expect(installed).toBe(fallback);
222+
});
223+
224+
it('runs only after every registered route misses', async () => {
225+
const registered = new Set<string>();
226+
let fallback: RouteHandler | undefined;
227+
228+
const server: IHttpServer = {
229+
...baseServer(),
230+
get: (path) => { registered.add(`GET ${path}`); },
231+
setFallbackHandler: (handler) => { fallback = handler; },
232+
};
233+
234+
server.get('/api/v1/data/showcase_task', async (_req, res) => res.json([]));
235+
236+
const answered: string[] = [];
237+
server.setFallbackHandler!(async (req, res) => {
238+
answered.push(`${req.method} ${req.path}`);
239+
res.status(404).json({ error: { code: 'ROUTE_NOT_FOUND' } });
240+
});
241+
242+
const dispatch = async (method: string, path: string) => {
243+
const res: IHttpResponse = {
244+
json: () => {}, send: () => {},
245+
status: function () { return this; },
246+
header: function () { return this; },
247+
};
248+
if (registered.has(`${method} ${path}`)) return 'route';
249+
await fallback!(
250+
{ params: {}, query: {}, headers: {}, method, path, body: { note: 'readable' } },
251+
res,
252+
);
253+
return 'fallback';
254+
};
255+
256+
// A registered route is never shadowed by the fallback.
257+
expect(await dispatch('GET', '/api/v1/data/showcase_task')).toBe('route');
258+
expect(answered).toEqual([]);
259+
260+
// Only the unmatched request reaches it.
261+
expect(await dispatch('GET', '/api/v1/apps/showcase/tasks')).toBe('fallback');
262+
expect(answered).toEqual(['GET /api/v1/apps/showcase/tasks']);
263+
});
264+
265+
it('receives a request whose body is readable (unlike the use() middleware seam)', async () => {
266+
let seenBody: unknown;
267+
268+
const fallback: RouteHandler = async (req, res) => {
269+
seenBody = req.body;
270+
res.status(200).json({ ok: true });
271+
};
272+
273+
const res: IHttpResponse = {
274+
json: () => {}, send: () => {},
275+
status: function () { return this; },
276+
header: function () { return this; },
277+
};
278+
279+
await fallback(
280+
{
281+
params: {},
282+
query: {},
283+
headers: { 'content-type': 'application/json' },
284+
method: 'POST',
285+
path: '/api/v1/apps/showcase/inquiries/purge',
286+
body: { olderThanDays: 30 },
287+
},
288+
res,
289+
);
290+
291+
expect(seenBody).toEqual({ olderThanDays: 30 });
292+
});
293+
294+
it('installing twice replaces the handler — there is one fallback, not a chain', () => {
295+
let current: RouteHandler | undefined;
296+
297+
const server: IHttpServer = {
298+
...baseServer(),
299+
setFallbackHandler: (handler) => { current = handler; },
300+
};
301+
302+
const first: RouteHandler = () => {};
303+
const second: RouteHandler = () => {};
304+
305+
server.setFallbackHandler!(first);
306+
expect(current).toBe(first);
307+
308+
server.setFallbackHandler!(second);
309+
expect(current).toBe(second);
310+
});
311+
});
312+
180313
it('should listen on a port', async () => {
181314
let listenedPort: number | undefined;
182315

packages/spec/src/contracts/http-server.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,46 @@ export interface IHttpServer {
238238
* to expose its internals.
239239
*/
240240
getRawApp?(): any;
241+
242+
/**
243+
* Install the LAST-RESORT handler: the one invoked for a request that
244+
* matched none of the explicitly registered routes.
245+
*
246+
* ## Contract (#5040 §1-C)
247+
*
248+
* Two guarantees, and they are the whole reason this seam exists rather
249+
* than a wildcard route:
250+
*
251+
* 1. **It runs only after every explicitly registered route has missed.**
252+
* Not "usually last", not "last if you register it late" — a fallback
253+
* is structurally incapable of shadowing a registered route, so this
254+
* member carries ZERO registration-order dependency. That matters
255+
* because the alternative (mounting `${prefix}/*` wildcards) is
256+
* decided by first-registration-wins across plugin `start()` order,
257+
* the exact ADR-0076 D11 hazard "one route, one owner" exists to
258+
* prevent. Implementations map this onto their framework's own
259+
* not-found hook (Hono's `app.notFound`), never onto a route.
260+
* 2. **`req.body` IS readable here.** The handler receives a fully
261+
* populated {@link IHttpRequest}, body included — unlike the
262+
* {@link Middleware} seam installed by {@link use}, whose contract
263+
* explicitly does NOT populate `body` (parsing it there would consume
264+
* the request stream before the route handler that owns it). This is
265+
* the difference that makes `use()` unusable for the dynamic-endpoint
266+
* case and this member necessary: a declared endpoint backed by a flow
267+
* or a `create` operation must read the request body.
268+
*
269+
* Calling this more than once REPLACES the previous handler — there is one
270+
* fallback, not a chain; a host that needs to compose behaviours composes
271+
* them inside its own handler. A handler that writes no response leaves the
272+
* adapter's standard unmatched-request answer in place (the 404/405
273+
* semantics documented on this interface).
274+
*
275+
* Optional, and feature-detected by consumers with
276+
* `typeof server.setFallbackHandler === 'function'` — an adapter that
277+
* cannot express a not-found hook simply omits it, and the consumer
278+
* degrades to the adapter's own unmatched-request answer.
279+
*
280+
* @param handler - The handler to invoke for otherwise-unmatched requests
281+
*/
282+
setFallbackHandler?(handler: RouteHandler): void;
241283
}

packages/spec/src/contracts/metadata-service.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import type { IMetadataService, MetadataWatchCallback, MetadataWatchHandle, MetadataTypeInfo } from './metadata-service';
2+
import type { IMetadataService, MetadataWatchCallback, MetadataWatchHandle, MetadataTypeInfo, ApiEndpointMatch } from './metadata-service';
3+
import { ApiEndpointSchema, type ApiEndpoint } from '../api/endpoint.zod';
34

45
describe('Metadata Service Contract', () => {
56
it('should allow a minimal IMetadataService implementation with required methods', () => {
@@ -422,4 +423,128 @@ describe('Metadata Service Contract', () => {
422423
const published = await service.getPublished!('object', 'account');
423424
expect(published).toEqual({ name: 'account', label: 'Account' });
424425
});
426+
427+
// ==========================================
428+
// API Endpoint Resolution (#5040 E1)
429+
// ==========================================
430+
431+
describe('matchEndpoint (optional member)', () => {
432+
/** A minimal base implementation with only the REQUIRED members. */
433+
const baseService = (): IMetadataService => ({
434+
register: async () => {},
435+
get: async () => undefined,
436+
list: async () => [],
437+
unregister: async () => {},
438+
exists: async () => false,
439+
listNames: async () => [],
440+
getObject: async () => undefined,
441+
listObjects: async () => [],
442+
});
443+
444+
/** An author-written `api` item that OMITS the `authRequired` default. */
445+
const authoredEndpoint = {
446+
name: 'showcase_tasks',
447+
path: '/api/v1/apps/showcase/tasks',
448+
method: 'GET',
449+
type: 'object_operation',
450+
target: 'showcase_task',
451+
objectParams: { object: 'showcase_task', operation: 'find' },
452+
};
453+
454+
it('is optional — an implementation without it still satisfies the contract', () => {
455+
const service = baseService();
456+
457+
// The whole point of the optional-member convention: consumers probe.
458+
expect(typeof service.matchEndpoint).toBe('undefined');
459+
expect(typeof (service as IMetadataService).matchEndpoint === 'function').toBe(false);
460+
});
461+
462+
it('is probeable with typeof === "function" when provided', () => {
463+
const service: IMetadataService = {
464+
...baseService(),
465+
matchEndpoint: async () => undefined,
466+
};
467+
468+
expect(typeof service.matchEndpoint).toBe('function');
469+
});
470+
471+
it('resolves method+path to a match, and undefined on a miss', async () => {
472+
const parsed = ApiEndpointSchema.parse(authoredEndpoint);
473+
474+
const service: IMetadataService = {
475+
...baseService(),
476+
matchEndpoint: async ({ path, method }) =>
477+
method.toUpperCase() === parsed.method && path === parsed.path
478+
? { endpoint: parsed, params: {} }
479+
: undefined,
480+
};
481+
482+
const hit = await service.matchEndpoint!({
483+
path: '/api/v1/apps/showcase/tasks',
484+
method: 'get',
485+
});
486+
expect(hit).toBeDefined();
487+
expect(hit!.endpoint.name).toBe('showcase_tasks');
488+
489+
const miss = await service.matchEndpoint!({
490+
path: '/api/v1/apps/showcase/nope',
491+
method: 'GET',
492+
});
493+
expect(miss).toBeUndefined();
494+
});
495+
496+
it('returns the ApiEndpointSchema.parse-d shape — schema defaults materialized', async () => {
497+
// The author never wrote `authRequired`; the contract says a consumer
498+
// must never see "absent" for it.
499+
expect('authRequired' in authoredEndpoint).toBe(false);
500+
501+
const service: IMetadataService = {
502+
...baseService(),
503+
matchEndpoint: async () => ({
504+
endpoint: ApiEndpointSchema.parse(authoredEndpoint),
505+
params: {},
506+
}),
507+
};
508+
509+
const match = await service.matchEndpoint!({
510+
path: '/api/v1/apps/showcase/tasks',
511+
method: 'GET',
512+
});
513+
514+
expect(match!.endpoint.authRequired).toBe(true);
515+
expect(typeof match!.endpoint.authRequired).toBe('boolean');
516+
});
517+
518+
it('params is always {} in 17.x — the slot is reserved, no template syntax', async () => {
519+
const service: IMetadataService = {
520+
...baseService(),
521+
matchEndpoint: async () => ({
522+
endpoint: ApiEndpointSchema.parse(authoredEndpoint),
523+
params: {},
524+
}),
525+
};
526+
527+
const match = await service.matchEndpoint!({
528+
path: '/api/v1/apps/showcase/tasks',
529+
method: 'GET',
530+
});
531+
532+
expect(match!.params).toEqual({});
533+
});
534+
535+
it('ApiEndpointMatch types endpoint as ApiEndpoint and params as Record< string, string >', () => {
536+
// Type-level shape assertion: the literal only compiles against the
537+
// declared member types.
538+
const match: ApiEndpointMatch = {
539+
endpoint: ApiEndpointSchema.parse(authoredEndpoint),
540+
params: {},
541+
};
542+
543+
const endpoint: ApiEndpoint = match.endpoint;
544+
const params: Record<string, string> = match.params;
545+
546+
expect(endpoint.path).toBe('/api/v1/apps/showcase/tasks');
547+
expect(params).toEqual({});
548+
});
549+
});
425550
});

0 commit comments

Comments
 (0)