Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/executor-contract-surface-e1.md
Original file line number Diff line number Diff line change
@@ -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 成员而非导出,不动其余七件生成物。
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3564,6 +3564,7 @@
"AnalyticsQueryInput (type)",
"AnalyticsResult (interface)",
"AnalyticsStrategy (interface)",
"ApiEndpointMatch (interface)",
"ApprovalActionAttachment (interface)",
"ApprovalActionKind (type)",
"ApprovalActionRow (interface)",
Expand Down
133 changes: 133 additions & 0 deletions packages/spec/src/contracts/http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;

Expand Down
42 changes: 42 additions & 0 deletions packages/spec/src/contracts/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
127 changes: 126 additions & 1 deletion packages/spec/src/contracts/metadata-service.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, string> = match.params;

expect(endpoint.path).toBe('/api/v1/apps/showcase/tasks');
expect(params).toEqual({});
});
});
});
Loading
Loading