Skip to content

Commit 41e605e

Browse files
os-zhuangclaude
andauthored
feat(runtime): apply the endpoint mapping keys in the execution chain (#5137) (#5167)
`inputMapping` / `outputMapping` were declared by `ApiEndpointSchema` and read by nothing: an author could write them, publish would accept them, and the endpoint ran as if they were absent — the "parsed, then nothing happens" middle state #5040 exists to end, and the ADR-0049 `declared != enforced` shape. New pure module `packages/runtime/src/api-mapping.ts` is their single reader. Semantics come from the frozen vocabulary's describe text and nothing else, taken in its minimal faithful reading: - `inputMapping` ("Map Request Body to Internal Params") projects the request BODY by dot path, applied after the policy pass and before delegation, so a mapping can never buy a caller past `authRequired` / `rateLimit` and `endpoint-executor.ts` stays a pure delegator. Query params are deliberately NOT merged in — the vocabulary names the body, and merging would invent an unstated precedence rule. - `outputMapping` ("Map Internal Result to Response Body") projects the SUCCESS payload only, preserving the envelope; an error answer is never remapped, so a declaration cannot disguise a failure as data. - A mapping is a projection, not a merge: undeclared fields do not ride along, which makes the outbound side an allow-list. - Absent source => unset target; absent (or empty) key => byte-for-byte passthrough, by reference. - A declaration this runtime cannot serve is refused with a structured 501 NOT_IMPLEMENTED naming the entry — `transform` (no transformation-function registry exists), an unusable path (empty, empty segment, prototype key), or colliding targets. `outputMapping` is judged BEFORE delegation so a broken projection cannot let a `create` insert its record and then fail to answer. `api-mapping.ts` joins the error-envelope conformance scan. Zero live behavior change: a non-empty `apis:` is still rejected at publish until the E7 flip. Part of #5040 (E5c). Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 641363a commit 41e605e

6 files changed

Lines changed: 971 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
**声明式端点的映射键:`inputMapping` / `outputMapping` 链内应用(#5040 E5c)**
6+
7+
两个键此前被 `ApiEndpointSchema` 声明、被 runtime 读取零次:作者写了、publish 放行、端点跑起来映射什么也不做 —— 正是 #5040 要消灭的「解析通过然后什么也不发生」中间态,也是 ADR-0049 `declared ≠ enforced` 的教科书形状(对 AI 写的元数据尤其糟:静默忽略的键不产生任何信号)。新纯模块 `api-mapping.ts` 是它们的唯一读者,语义****来自冻结词表的 describe 文本,取其最小忠实解读:
8+
9+
- **`inputMapping`(*Map Request Body to Internal Params*)**:`source` 按点路径读**请求体**,投影出目标入参;在策略链通过之后、委派之前应用,因此映射永远买不通 `authRequired` / `rateLimit`,而 `endpoint-executor` 保持纯委派、对映射无感知。词表只说 body,**query 不并入**(合并会凭空发明一条谁覆盖谁的优先级规则),query 照旧原样抵达管线。
10+
- **`outputMapping`(*Map Internal Result to Response Body*)**:只作用于**成功**答案的载荷(`{success, data, meta}``data`),包络逐字保留 —— 声明改不动 `success`,也就无法把失败装扮成数据。401 / 429 / 400 / 501 一律不重映射。
11+
- **映射是投影,不是合并**:结果只由声明的 `target` 组成,未声明的字段不随行。出站方向因此天然是一份 allow-list —— `apis` 是平台的对外面(ADR-0121 D3),默认泄漏内部字段不是可接受的缺省。
12+
- **`source` 解析不到 ⇒ `target` 不写**(映射是投影不是校验器);**无声明 ⇒ 逐字节直通、按引用原样传递**,未声明映射的端点与 E5b 的行为完全一致。
13+
- **无法服务的声明响亮拒绝**,不静默跳过、不半应用:`transform`(全仓无「transformation function name」注册表,发明它是沙箱裁决而非映射细节)、不可用路径(空串、空段 `a..b``__proto__` / `prototype` / `constructor`)、互撞的 `target`(同路径或一个写进另一个内部)—— 均为结构化 **501 NOT_IMPLEMENTED**(带处方,点名具体条目如 `inputMapping[1].transform`),与 `endpoint-executor``unsupported` 分支同类同形。`outputMapping` 的这道判定在**委派之前**做:投影坏掉的 `create` 不该先插入记录再拒绝作答。
14+
- 新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。
15+
16+
**现网行为零变更**:非空 `apis:` 在 publish / validate 仍被硬拒(E7 #5111 前不撤),整条端点链结构性不可达。上述「不支持子集」应由 E7 的 publish 门在作者写应用时就拒掉,本模块是运行期兜底,不是主关口。

packages/runtime/src/api-endpoint-step.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,211 @@ describe('execution runs on the far side of the policy chain', () => {
394394
expect(hint).toContain('no execution wiring');
395395
});
396396
});
397+
398+
/**
399+
* The mapping keys, joined to the chain (#5040 E5c / #5137).
400+
*
401+
* `api-mapping.test.ts` owns what a projection IS; what is asserted here is
402+
* where it applies — that a mapped body is what the executor delegates, that a
403+
* mapped result is what the caller receives, that an ERROR answer is never
404+
* remapped whatever produced it, and that a declaration this runtime cannot
405+
* serve is refused before the target runs rather than after.
406+
*/
407+
describe('the mapping keys apply on the two sides of the delegation', () => {
408+
const CREATE: ApiEndpoint = ApiEndpointSchema.parse({
409+
name: 'showcase_inquiries',
410+
path: '/api/v1/apps/showcase/inquiries',
411+
method: 'POST',
412+
type: 'object_operation',
413+
target: 'showcase_inquiry',
414+
objectParams: { object: 'showcase_inquiry', operation: 'create' },
415+
authRequired: false,
416+
});
417+
418+
const limiters = () => createEndpointRateLimiterRegistry({ resolveCache: async () => undefined });
419+
420+
function callDataSpy(result: unknown = { id: 'rec_1', name: 'Ada', internal_note: 'do not ship' }) {
421+
const calls: unknown[][] = [];
422+
return { calls, fn: async (...args: unknown[]) => { calls.push(args); return result; } };
423+
}
424+
425+
const mappedStep = (
426+
endpoint: ApiEndpoint,
427+
callData: unknown,
428+
body: unknown = { firstName: 'Ada', secret: 'internal' },
429+
policy: Partial<EndpointPolicyContext> = {},
430+
) => runAppEndpointStep({
431+
method: endpoint.method,
432+
path: endpoint.path,
433+
prefix: '/api/v1',
434+
metadataService: matcherFor([endpoint]).service as never,
435+
policy: { limiters: limiters(), ...policy },
436+
execution: {
437+
request: { method: endpoint.method, path: endpoint.path, query: { trace: '1' }, body },
438+
deps: { callData: callData as never },
439+
},
440+
});
441+
442+
it('delegates the MAPPED body — the executor never sees the raw one', async () => {
443+
const spy = callDataSpy();
444+
const mapped = ApiEndpointSchema.parse({
445+
...CREATE,
446+
inputMapping: [{ source: 'firstName', target: 'first_name' }],
447+
});
448+
449+
const answer = await mappedStep(mapped, spy.fn);
450+
451+
expect(answer?.status).toBe(201);
452+
// `data` is the projection: the renamed field is there and the
453+
// undeclared one is gone, delegated through the same `callData` shape
454+
// `/data` uses.
455+
expect(spy.calls).toEqual([['create', { object: 'showcase_inquiry', data: { first_name: 'Ada' } }, undefined, undefined, undefined]]);
456+
});
457+
458+
it('leaves the query string alone — inputMapping maps the BODY', async () => {
459+
// The vocabulary says "Map Request Body to Internal Params"; query
460+
// parameters keep reaching the pipeline exactly as they did before.
461+
const spy = callDataSpy({ records: [], total: 0 });
462+
const find = ApiEndpointSchema.parse({
463+
...CREATE,
464+
name: 'showcase_find',
465+
method: 'GET',
466+
objectParams: { object: 'showcase_inquiry', operation: 'find' },
467+
inputMapping: [{ source: 'firstName', target: 'first_name' }],
468+
});
469+
470+
await mappedStep(find, spy.fn);
471+
472+
expect((spy.calls[0]![1] as { query: unknown }).query).toEqual({ trace: '1' });
473+
});
474+
475+
it('delegates the caller\'s own body when no mapping is declared', async () => {
476+
const spy = callDataSpy();
477+
const body = { firstName: 'Ada', secret: 'internal' };
478+
479+
await mappedStep(CREATE, spy.fn, body);
480+
481+
// By reference: an endpoint that declares no mapping is served exactly
482+
// as E5b served it, with no projection in between.
483+
expect((spy.calls[0]![1] as { data: unknown }).data).toBe(body);
484+
});
485+
486+
it('answers with the MAPPED result on a success', async () => {
487+
const spy = callDataSpy();
488+
const mapped = ApiEndpointSchema.parse({
489+
...CREATE,
490+
outputMapping: [{ source: 'id', target: 'inquiry_id' }, { source: 'name', target: 'contact.name' }],
491+
});
492+
493+
const answer = await mappedStep(mapped, spy.fn);
494+
495+
expect(answer?.status).toBe(201);
496+
expect(answer?.body).toEqual({
497+
success: true,
498+
data: { inquiry_id: 'rec_1', contact: { name: 'Ada' } },
499+
meta: undefined,
500+
});
501+
// The allow-list property, end to end: an internal field the pipeline
502+
// returned and the declaration did not name never reaches the wire.
503+
expect(JSON.stringify(answer?.body)).not.toContain('internal_note');
504+
});
505+
506+
it('keeps the cacheTtl header on a mapped success', async () => {
507+
// `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the
508+
// point is that the two keys compose — the projection replaces the body
509+
// and the policy verdict's header still rides with it.
510+
const mapped = ApiEndpointSchema.parse({
511+
...CREATE,
512+
name: 'showcase_cached_map',
513+
method: 'GET',
514+
objectParams: { object: 'showcase_inquiry', operation: 'find' },
515+
cacheTtl: 30,
516+
outputMapping: [{ source: 'total', target: 'count' }],
517+
});
518+
519+
const answer = await mappedStep(mapped, callDataSpy({ records: [], total: 2 }).fn);
520+
521+
expect(answer?.body).toEqual({ success: true, data: { count: 2 }, meta: undefined });
522+
expect(answer?.headers).toEqual({ 'Cache-Control': 'private, max-age=30' });
523+
});
524+
525+
it('never remaps an ERROR answer — a mapping must not disguise a failure', async () => {
526+
const outputMapping = [{ source: 'id', target: 'inquiry_id' }];
527+
528+
// 401: denied by the policy chain, before execution.
529+
const authed = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_authed', authRequired: true, outputMapping });
530+
const denied = await mappedStep(authed, callDataSpy().fn);
531+
expect(denied?.status).toBe(401);
532+
expect((denied!.body as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED');
533+
534+
// 400: a delegated pipeline's own failure.
535+
const failing = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_failing', outputMapping });
536+
const bad = await mappedStep(failing, async () => { throw { statusCode: 400, message: 'name is required' }; });
537+
expect(bad?.status).toBe(400);
538+
expect((bad!.body as { error: { message: string } }).error.message).toBe('name is required');
539+
540+
// 501: a declaration this runtime does not execute.
541+
const proxied = ApiEndpointSchema.parse({
542+
...CREATE, name: 'showcase_proxy_map', type: 'proxy', target: 'https://example.invalid', outputMapping,
543+
});
544+
const unsupported = await mappedStep(proxied, callDataSpy().fn);
545+
expect(unsupported?.status).toBe(501);
546+
expect((unsupported!.body as { error: { code: string } }).error.code).toBe('NOT_IMPLEMENTED');
547+
548+
// 429: the endpoint budget, spent. Every one of these bodies is the
549+
// error envelope, untouched by the declared projection.
550+
const entries = new Map<string, unknown>();
551+
const store: CounterStore = {
552+
get: async <T,>(k: string) => entries.get(k) as T | undefined,
553+
set: async (k: string, v: unknown) => { entries.set(k, v); },
554+
};
555+
const limited = ApiEndpointSchema.parse({
556+
...CREATE, name: 'showcase_limited_map', outputMapping,
557+
rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 },
558+
});
559+
const policy = { limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }) };
560+
expect((await mappedStep(limited, callDataSpy().fn, undefined, policy))?.status).toBe(201);
561+
const over = await mappedStep(limited, callDataSpy().fn, undefined, policy);
562+
expect(over?.status).toBe(429);
563+
564+
for (const answer of [denied, bad, unsupported, over]) {
565+
expect(JSON.stringify(answer?.body)).not.toContain('inquiry_id');
566+
expect((answer!.body as { success: boolean }).success).toBe(false);
567+
}
568+
});
569+
570+
it('refuses a `transform` declaration at request time, without executing anything', async () => {
571+
const spy = callDataSpy();
572+
const withTransform = ApiEndpointSchema.parse({
573+
...CREATE,
574+
inputMapping: [{ source: 'price', target: 'amount', transform: 'convertToInt' }],
575+
});
576+
577+
const answer = await mappedStep(withTransform, spy.fn);
578+
579+
expect(answer?.status).toBe(501);
580+
const error = (answer!.body as { error: Record<string, unknown> }).error;
581+
expect(error.code).toBe('NOT_IMPLEMENTED');
582+
expect(String(error.message)).toContain('inputMapping[0].transform');
583+
expect(spy.calls, 'a refused declaration still reached the pipeline').toEqual([]);
584+
// No `Cache-Control` on a refusal, for the same reason as any error.
585+
expect(answer?.headers).toBeUndefined();
586+
});
587+
588+
it('refuses a broken outputMapping BEFORE the target runs, not after', async () => {
589+
// The ordering that matters: a `create` with an unservable projection
590+
// must not insert the record and then fail to answer with it.
591+
const spy = callDataSpy();
592+
const broken = ApiEndpointSchema.parse({
593+
...CREATE,
594+
outputMapping: [{ source: 'id', target: 'a' }, { source: 'name', target: 'a.b' }],
595+
});
596+
597+
const answer = await mappedStep(broken, spy.fn);
598+
599+
expect(answer?.status).toBe(501);
600+
expect(String((answer!.body as { error: { message: string } }).error.message))
601+
.toContain('outputMapping[1].target');
602+
expect(spy.calls, 'the record was created and then the answer was refused').toEqual([]);
603+
});
604+
});

packages/runtime/src/api-endpoint-step.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,40 @@
4242
* header describes a body the caller should be willing to reuse, and telling a
4343
* client to cache a 401 / 429 / 500 for a minute is worse than saying nothing.
4444
*
45-
* What it does NOT do, so nobody reads more into it than is here:
46-
* `inputMapping` / `outputMapping` (declared, still unread — #5040's E7 gate
47-
* must not flip before they are, or the two keys sit in the "declared, legal,
48-
* ignored" state this program exists to end).
45+
* ## The mapping keys, and why they apply exactly here (#5040 E5c)
46+
*
47+
* `inputMapping` / `outputMapping` (`api-mapping.ts`) are applied by this
48+
* module, on the two sides of the delegation:
49+
*
50+
* - **`inputMapping` after the policy pass, before delegation.** It projects
51+
* the request the executor sees, so a mapping can never buy a caller past
52+
* `authRequired` or the rate limiter — and `endpoint-executor.ts` stays a
53+
* pure delegator that does not know mappings exist.
54+
* - **`outputMapping` on the SUCCESS body only.** An error answer is never
55+
* remapped: a projection that could reshape a 401 / 429 / 500 into data
56+
* would be able to disguise a failure as a result, and no declaration should
57+
* have that power. This is the same asymmetry `Cache-Control` has above, for
58+
* the same reason.
59+
*
60+
* A declaration this runtime cannot serve (`transform`, an unusable path,
61+
* colliding targets) is refused BEFORE the target runs — including
62+
* `outputMapping`, which is validated pre-delegation so a broken projection
63+
* cannot let a `create` insert a record and then fail to answer. With neither
64+
* key declared, the request and the answer pass through byte for byte, by
65+
* reference: an endpoint that declares no mapping is served exactly as E5b
66+
* served it.
4967
*/
5068

5169
import { DispatcherErrorCode } from '@objectstack/spec/api';
5270
import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts';
5371
import type { ExecutionContext } from '@objectstack/spec/kernel';
5472
import { apiErrorResponse } from './error-envelope.js';
5573
import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js';
74+
import {
75+
applyInputMapping,
76+
applyOutputMapping,
77+
mappingDeclarationRejection,
78+
} from './api-mapping.js';
5679
import {
5780
buildEndpointExecutionContext,
5881
executeEndpointTarget,
@@ -241,9 +264,26 @@ export async function runAppEndpointStep(
241264
}
242265

243266
const { request, deps, executionContext, environmentId, dataDriver } = input.execution;
267+
268+
// ── inputMapping: project the request the executor will see ──────────
269+
// Nothing has been delegated yet, so a declaration this runtime cannot
270+
// serve is refused before it can have an effect. With no declaration the
271+
// caller's own request object rides on unchanged, by reference.
272+
const mappedBody = applyInputMapping(match.endpoint, request.body);
273+
if (!mappedBody.ok) return mappedBody.rejection;
274+
const mappedRequest = mappedBody.value === request.body
275+
? request
276+
: { ...request, body: mappedBody.value };
277+
278+
// `outputMapping` is judged HERE, not after the result arrives: a broken
279+
// projection must not be able to let a `create` insert its record and then
280+
// refuse to answer with it.
281+
const outputRejection = mappingDeclarationRejection(match.endpoint, 'outputMapping');
282+
if (outputRejection) return outputRejection;
283+
244284
const answer = await executeEndpointTarget(
245285
buildEndpointExecutionContext({
246-
request,
286+
request: mappedRequest,
247287
match,
248288
...(executionContext !== undefined ? { executionContext } : {}),
249289
...(environmentId !== undefined ? { environmentId } : {}),
@@ -256,14 +296,26 @@ export async function runAppEndpointStep(
256296
// `executeEndpointTarget` never throws — a delegated failure is already an
257297
// error answer here — so the status is the whole test, and an endpoint whose
258298
// execution failed cannot hand the client a cache directive for the failure.
299+
// `outputMapping` rides on exactly the same test, and for a stronger reason:
300+
// a projection applied to an error body could disguise the failure as data.
259301
const isSuccess = answer.status < 400;
302+
let body = answer.body;
303+
if (isSuccess) {
304+
const mapped = applyOutputMapping(match.endpoint, answer.body);
305+
// Unreachable: the identical verdict was taken before delegation, above.
306+
// Restated rather than asserted away, so a future reordering of these
307+
// two lines cannot turn a refusal into a silently unmapped answer.
308+
if (!mapped.ok) return mapped.rejection;
309+
body = mapped.value;
310+
}
311+
260312
const headers = {
261313
...(answer.headers ?? {}),
262314
...(isSuccess ? verdict.responseHeaders : {}),
263315
};
264316
return {
265317
status: answer.status,
266-
body: answer.body,
318+
body,
267319
...(Object.keys(headers).length > 0 ? { headers } : {}),
268320
};
269321
}

0 commit comments

Comments
 (0)