diff --git a/.changeset/executor-contract-surface-e1.md b/.changeset/executor-contract-surface-e1.md new file mode 100644 index 0000000000..f76bdb971c --- /dev/null +++ b/.changeset/executor-contract-surface-e1.md @@ -0,0 +1,18 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): 执行器契约面 —— `IMetadataService.matchEndpoint?` 与 `IHttpServer.setFallbackHandler?` 可选成员(#5040 执行器 E1) + +**纯声明,零行为变更。** 本改动只在 `packages/spec/src/contracts/` 增加两个**可选**契约成员与一个导出类型;仓内没有任何实现体、没有任何接线,现网行为逐字节不变。声明式 `ApiEndpoint` 在 v17 仍被 publish 硬拒(#4936 裁决),本单落的是它未来得以执行所需的契约前件(contract-first 首件)。 + +**1. `IMetadataService.matchEndpoint?(query: { path, method })`** — 把一次请求的 `method`+`path` 解析为拥有该路由的 `api` 元数据条目,或在无人声明时返回 `undefined`。这是 HTTP dispatcher 在「内建 domain 均未认领」与「答语义 404」之间的那一步。随之导出新类型 `ApiEndpointMatch`: + +- `endpoint` 是 `ApiEndpointSchema.parse` **之后**的形状 —— 默认值已物化,而非存储里的原始 JSON。作者漏写 `authRequired` 时消费端拿到的是 `true`(schema 默认值),因此消费端永远读不到「缺省」这个中间态,也就不可能把一个缺失的安全默认误读成放行。 +- `params` 在 17.x **恒为 `{}`**。`ApiEndpointSchema.path` 词表已冻结(ADR-0121),既未定义 `:param` 也未定义 `{param}`,本契约**刻意不发明**模板语法 —— 只存在于实现里的语法就是隐藏方言(Prime Directive #12)。槽位现在就声明出来,是为了将来真要加路径模板时,那是词表的加法,而不是本契约的破坏性变更。 + +**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。 + +**两者均为可选成员**,消费端按仓内既有惯例以 `typeof x === 'function'` 探测(同 `watch?` / `subscribe?` / `getRawApp?`)。不实现它的 `metadata` 槽位占用者、无法表达 not-found 钩子的适配器,都仍然满足契约,消费端退化到既有的未命中应答。因此对现有实现方**无迁移动作**。 + +生成物影响:`api-surface.json` 新增一行 `ApiEndpointMatch (interface)`(0 breaking / 1 added)。两个新成员是 interface 成员而非导出,不动其余七件生成物。 diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 9afa0e13d7..5622d95d24 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3564,6 +3564,7 @@ "AnalyticsQueryInput (type)", "AnalyticsResult (interface)", "AnalyticsStrategy (interface)", + "ApiEndpointMatch (interface)", "ApprovalActionAttachment (interface)", "ApprovalActionKind (type)", "ApprovalActionRow (interface)", diff --git a/packages/spec/src/contracts/http-server.test.ts b/packages/spec/src/contracts/http-server.test.ts index 0522535f9a..a5f7d87c59 100644 --- a/packages/spec/src/contracts/http-server.test.ts +++ b/packages/spec/src/contracts/http-server.test.ts @@ -177,6 +177,139 @@ describe('HTTP Server Contract', () => { await expect(server.close!()).resolves.toBeUndefined(); }); + describe('optional setFallbackHandler (#5040 E1)', () => { + /** A server with only the REQUIRED members. */ + const baseServer = (): IHttpServer => ({ + get: () => {}, + post: () => {}, + put: () => {}, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + }); + + it('is optional — an adapter without it still satisfies the contract', () => { + const server = baseServer(); + + expect(typeof server.setFallbackHandler).toBe('undefined'); + expect(typeof server.setFallbackHandler === 'function').toBe(false); + }); + + it('is feature-detected with typeof === "function" when provided', () => { + const server: IHttpServer = { + ...baseServer(), + setFallbackHandler: (_handler) => {}, + }; + + expect(typeof server.setFallbackHandler).toBe('function'); + }); + + it('accepts a RouteHandler — the same handler shape routes take', () => { + let installed: RouteHandler | undefined; + + const server: IHttpServer = { + ...baseServer(), + setFallbackHandler: (handler) => { installed = handler; }, + }; + + const fallback: RouteHandler = async (_req, res) => { + res.status(404).json({ error: { code: 'ROUTE_NOT_FOUND' } }); + }; + server.setFallbackHandler!(fallback); + + expect(installed).toBe(fallback); + }); + + it('runs only after every registered route misses', async () => { + const registered = new Set(); + let fallback: RouteHandler | undefined; + + const server: IHttpServer = { + ...baseServer(), + get: (path) => { registered.add(`GET ${path}`); }, + setFallbackHandler: (handler) => { fallback = handler; }, + }; + + server.get('/api/v1/data/showcase_task', async (_req, res) => res.json([])); + + const answered: string[] = []; + server.setFallbackHandler!(async (req, res) => { + answered.push(`${req.method} ${req.path}`); + res.status(404).json({ error: { code: 'ROUTE_NOT_FOUND' } }); + }); + + const dispatch = async (method: string, path: string) => { + const res: IHttpResponse = { + json: () => {}, send: () => {}, + status: function () { return this; }, + header: function () { return this; }, + }; + if (registered.has(`${method} ${path}`)) return 'route'; + await fallback!( + { params: {}, query: {}, headers: {}, method, path, body: { note: 'readable' } }, + res, + ); + return 'fallback'; + }; + + // A registered route is never shadowed by the fallback. + expect(await dispatch('GET', '/api/v1/data/showcase_task')).toBe('route'); + expect(answered).toEqual([]); + + // Only the unmatched request reaches it. + expect(await dispatch('GET', '/api/v1/apps/showcase/tasks')).toBe('fallback'); + expect(answered).toEqual(['GET /api/v1/apps/showcase/tasks']); + }); + + it('receives a request whose body is readable (unlike the use() middleware seam)', async () => { + let seenBody: unknown; + + const fallback: RouteHandler = async (req, res) => { + seenBody = req.body; + res.status(200).json({ ok: true }); + }; + + const res: IHttpResponse = { + json: () => {}, send: () => {}, + status: function () { return this; }, + header: function () { return this; }, + }; + + await fallback( + { + params: {}, + query: {}, + headers: { 'content-type': 'application/json' }, + method: 'POST', + path: '/api/v1/apps/showcase/inquiries/purge', + body: { olderThanDays: 30 }, + }, + res, + ); + + expect(seenBody).toEqual({ olderThanDays: 30 }); + }); + + it('installing twice replaces the handler — there is one fallback, not a chain', () => { + let current: RouteHandler | undefined; + + const server: IHttpServer = { + ...baseServer(), + setFallbackHandler: (handler) => { current = handler; }, + }; + + const first: RouteHandler = () => {}; + const second: RouteHandler = () => {}; + + server.setFallbackHandler!(first); + expect(current).toBe(first); + + server.setFallbackHandler!(second); + expect(current).toBe(second); + }); + }); + it('should listen on a port', async () => { let listenedPort: number | undefined; diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts index 9985ae221c..754f15dc6a 100644 --- a/packages/spec/src/contracts/http-server.ts +++ b/packages/spec/src/contracts/http-server.ts @@ -238,4 +238,46 @@ export interface IHttpServer { * to expose its internals. */ getRawApp?(): any; + + /** + * Install the LAST-RESORT handler: the one invoked for a request that + * matched none of the explicitly registered routes. + * + * ## Contract (#5040 §1-C) + * + * Two guarantees, and they are the whole reason this seam exists rather + * than a wildcard route: + * + * 1. **It runs only after every explicitly registered route has missed.** + * Not "usually last", not "last if you register it late" — a fallback + * is structurally incapable of shadowing a registered route, so this + * member carries ZERO registration-order dependency. That matters + * because the alternative (mounting `${prefix}/*` wildcards) is + * decided by first-registration-wins across plugin `start()` order, + * the exact ADR-0076 D11 hazard "one route, one owner" exists to + * prevent. Implementations map this onto their framework's own + * not-found hook (Hono's `app.notFound`), never onto a route. + * 2. **`req.body` IS readable here.** The handler receives a fully + * populated {@link IHttpRequest}, body included — unlike the + * {@link Middleware} seam installed by {@link use}, whose contract + * explicitly does NOT populate `body` (parsing it there would consume + * the request stream before the route handler that owns it). This is + * the difference that makes `use()` unusable for the dynamic-endpoint + * case and this member necessary: a declared endpoint backed by a flow + * or a `create` operation must read the request body. + * + * Calling this more than once REPLACES the previous handler — there is one + * fallback, not a chain; a host that needs to compose behaviours composes + * them inside its own handler. A handler that writes no response leaves the + * adapter's standard unmatched-request answer in place (the 404/405 + * semantics documented on this interface). + * + * Optional, and feature-detected by consumers with + * `typeof server.setFallbackHandler === 'function'` — an adapter that + * cannot express a not-found hook simply omits it, and the consumer + * degrades to the adapter's own unmatched-request answer. + * + * @param handler - The handler to invoke for otherwise-unmatched requests + */ + setFallbackHandler?(handler: RouteHandler): void; } diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts index e9c88ecdde..a17f6695e1 100644 --- a/packages/spec/src/contracts/metadata-service.test.ts +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import type { IMetadataService, MetadataWatchCallback, MetadataWatchHandle, MetadataTypeInfo } from './metadata-service'; +import type { IMetadataService, MetadataWatchCallback, MetadataWatchHandle, MetadataTypeInfo, ApiEndpointMatch } from './metadata-service'; +import { ApiEndpointSchema, type ApiEndpoint } from '../api/endpoint.zod'; describe('Metadata Service Contract', () => { it('should allow a minimal IMetadataService implementation with required methods', () => { @@ -422,4 +423,128 @@ describe('Metadata Service Contract', () => { const published = await service.getPublished!('object', 'account'); expect(published).toEqual({ name: 'account', label: 'Account' }); }); + + // ========================================== + // API Endpoint Resolution (#5040 E1) + // ========================================== + + describe('matchEndpoint (optional member)', () => { + /** A minimal base implementation with only the REQUIRED members. */ + const baseService = (): IMetadataService => ({ + register: async () => {}, + get: async () => undefined, + list: async () => [], + unregister: async () => {}, + exists: async () => false, + listNames: async () => [], + getObject: async () => undefined, + listObjects: async () => [], + }); + + /** An author-written `api` item that OMITS the `authRequired` default. */ + const authoredEndpoint = { + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + }; + + it('is optional — an implementation without it still satisfies the contract', () => { + const service = baseService(); + + // The whole point of the optional-member convention: consumers probe. + expect(typeof service.matchEndpoint).toBe('undefined'); + expect(typeof (service as IMetadataService).matchEndpoint === 'function').toBe(false); + }); + + it('is probeable with typeof === "function" when provided', () => { + const service: IMetadataService = { + ...baseService(), + matchEndpoint: async () => undefined, + }; + + expect(typeof service.matchEndpoint).toBe('function'); + }); + + it('resolves method+path to a match, and undefined on a miss', async () => { + const parsed = ApiEndpointSchema.parse(authoredEndpoint); + + const service: IMetadataService = { + ...baseService(), + matchEndpoint: async ({ path, method }) => + method.toUpperCase() === parsed.method && path === parsed.path + ? { endpoint: parsed, params: {} } + : undefined, + }; + + const hit = await service.matchEndpoint!({ + path: '/api/v1/apps/showcase/tasks', + method: 'get', + }); + expect(hit).toBeDefined(); + expect(hit!.endpoint.name).toBe('showcase_tasks'); + + const miss = await service.matchEndpoint!({ + path: '/api/v1/apps/showcase/nope', + method: 'GET', + }); + expect(miss).toBeUndefined(); + }); + + it('returns the ApiEndpointSchema.parse-d shape — schema defaults materialized', async () => { + // The author never wrote `authRequired`; the contract says a consumer + // must never see "absent" for it. + expect('authRequired' in authoredEndpoint).toBe(false); + + const service: IMetadataService = { + ...baseService(), + matchEndpoint: async () => ({ + endpoint: ApiEndpointSchema.parse(authoredEndpoint), + params: {}, + }), + }; + + const match = await service.matchEndpoint!({ + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + }); + + expect(match!.endpoint.authRequired).toBe(true); + expect(typeof match!.endpoint.authRequired).toBe('boolean'); + }); + + it('params is always {} in 17.x — the slot is reserved, no template syntax', async () => { + const service: IMetadataService = { + ...baseService(), + matchEndpoint: async () => ({ + endpoint: ApiEndpointSchema.parse(authoredEndpoint), + params: {}, + }), + }; + + const match = await service.matchEndpoint!({ + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + }); + + expect(match!.params).toEqual({}); + }); + + it('ApiEndpointMatch types endpoint as ApiEndpoint and params as Record< string, string >', () => { + // Type-level shape assertion: the literal only compiles against the + // declared member types. + const match: ApiEndpointMatch = { + endpoint: ApiEndpointSchema.parse(authoredEndpoint), + params: {}, + }; + + const endpoint: ApiEndpoint = match.endpoint; + const params: Record = match.params; + + expect(endpoint.path).toBe('/api/v1/apps/showcase/tasks'); + expect(params).toEqual({}); + }); + }); }); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 905a319ee5..ec79cf28ea 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -31,6 +31,7 @@ * │ Validation │ validate * │ Type Registry │ getRegisteredTypes / getTypeInfo * │ Dependencies │ getDependencies / getDependents + * │ Endpoint Resolution │ matchEndpoint * └──────────────────────┘ * ``` */ @@ -43,6 +44,7 @@ import type { MetadataQuery, MetadataQueryResult, MetadataValidationResult, Meta // `@objectstack/spec/kernel`; it had no consumers and was removed in #4411, so // this is now the only type by that name. import type { MetadataWatchEvent } from '../system/metadata-persistence.zod'; +import type { ApiEndpoint } from '../api/endpoint.zod'; import type { Action } from '../ui/action.zod'; import type { MetadataOverlay } from '../kernel/metadata-customization.zod'; import type { PackagePublishResult, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataDiffResult } from '../system/metadata-persistence.zod'; @@ -176,6 +178,42 @@ export interface MetadataWriteOptions { userId?: string; } +/** + * The result of {@link IMetadataService.matchEndpoint} — a declared + * `api` metadata item that owns the requested `method`+`path`, together with + * the path parameters extracted while matching it. + * + * [#5040 E1] Declared ahead of any implementation (contract-first). The + * consumer side is the dispatcher's endpoint step; the producer side is the + * `metadata` slot's own endpoint index. + */ +export interface ApiEndpointMatch { + /** + * The matched endpoint as `ApiEndpointSchema.parse` returns it — i.e. with + * every schema default MATERIALIZED, not the raw stored JSON. In particular + * `authRequired` is a boolean here even when the author omitted it (the + * schema defaults it to `true`), so a consumer never has to reason about + * "absent" and cannot accidentally read a missing security default as + * permissive. Implementations MUST parse before returning, and MUST skip + * (loudly) any stored item that fails to parse rather than returning a + * half-valid shape. + */ + endpoint: ApiEndpoint; + + /** + * Path parameters extracted from the request path. + * + * **Always `{}` in 17.x.** `ApiEndpointSchema.path` is a frozen vocabulary + * (ADR-0121) that defines NO template syntax — neither `:param` nor + * `{param}` — and this contract deliberately does not invent one: a syntax + * that exists only inside an implementation is a hidden dialect, which is + * exactly the failure mode Prime Directive #12 forbids. The member is + * declared now so that adding path templates later is an additive change to + * the vocabulary rather than a breaking change to this contract. + */ + params: Record; +} + export interface IMetadataService { // ========================================== // Core CRUD Operations @@ -541,6 +579,49 @@ export interface IMetadataService { */ getDependents?(type: string, name: string): Promise; + // ========================================== + // API Endpoint Resolution + // ========================================== + + /** + * Resolve a request's `method`+`path` to the declared `api` metadata item + * that owns it, or `undefined` when nothing declares it. + * + * This is the lookup the HTTP dispatcher performs between "no built-in + * domain claimed this path" and "answer a semantic 404" — the seam that + * makes a declared {@link ApiEndpoint} reachable at all. + * + * ## Contract (#5040 §2) + * + * - **Scope is the instance.** There is no environment parameter: callers + * already resolve the `metadata` service for the environment they are + * serving, exactly as every other metadata read in this contract does. + * Adding an env parameter here would create a second scoping mechanism. + * - **Matching dimensions.** `method` is compared case-insensitively (a + * request's verb normalized to upper case); `path` is compared as a + * WHOLE STRING, exactly, after trimming a trailing slash. 17.x performs + * no percent-decoding, no Unicode normalization and no case folding of + * the path — the raw string is the key. See + * {@link ApiEndpointMatch.params} for why no template syntax exists. + * - **The answer is parsed, never raw.** See + * {@link ApiEndpointMatch.endpoint}. + * - **Absence is a miss, not an error.** `undefined` means "no declaration + * owns this route"; an implementation that cannot read its store must + * throw rather than report a miss, because a miss becomes a 404 and an + * outage must not masquerade as one (same distinction as + * {@link loadDiagnosed}). + * + * [#5040 E1] Optional, and probed by consumers with + * `typeof svc.matchEndpoint === 'function'` — the same convention as + * {@link watch} / {@link subscribe}. An occupant of the `metadata` slot that + * carries no endpoint index simply omits it, and the dispatcher falls + * through to its existing not-found answer. + * + * @param query - The request coordinates to resolve (`path`, `method`) + * @returns The owning endpoint plus its path params, or `undefined` on a miss + */ + matchEndpoint?(query: { path: string; method: string }): Promise; + // ========================================== // Version History & Rollback // ==========================================