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
16 changes: 16 additions & 0 deletions .changeset/endpoint-mapping-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/runtime': minor
---

**声明式端点的映射键:`inputMapping` / `outputMapping` 链内应用(#5040 E5c)**

两个键此前被 `ApiEndpointSchema` 声明、被 runtime 读取零次:作者写了、publish 放行、端点跑起来映射什么也不做 —— 正是 #5040 要消灭的「解析通过然后什么也不发生」中间态,也是 ADR-0049 `declared ≠ enforced` 的教科书形状(对 AI 写的元数据尤其糟:静默忽略的键不产生任何信号)。新纯模块 `api-mapping.ts` 是它们的唯一读者,语义**只**来自冻结词表的 describe 文本,取其最小忠实解读:

- **`inputMapping`(*Map Request Body to Internal Params*)**:`source` 按点路径读**请求体**,投影出目标入参;在策略链通过之后、委派之前应用,因此映射永远买不通 `authRequired` / `rateLimit`,而 `endpoint-executor` 保持纯委派、对映射无感知。词表只说 body,**query 不并入**(合并会凭空发明一条谁覆盖谁的优先级规则),query 照旧原样抵达管线。
- **`outputMapping`(*Map Internal Result to Response Body*)**:只作用于**成功**答案的载荷(`{success, data, meta}` 的 `data`),包络逐字保留 —— 声明改不动 `success`,也就无法把失败装扮成数据。401 / 429 / 400 / 501 一律不重映射。
- **映射是投影,不是合并**:结果只由声明的 `target` 组成,未声明的字段不随行。出站方向因此天然是一份 allow-list —— `apis` 是平台的对外面(ADR-0121 D3),默认泄漏内部字段不是可接受的缺省。
- **`source` 解析不到 ⇒ `target` 不写**(映射是投影不是校验器);**无声明 ⇒ 逐字节直通、按引用原样传递**,未声明映射的端点与 E5b 的行为完全一致。
- **无法服务的声明响亮拒绝**,不静默跳过、不半应用:`transform`(全仓无「transformation function name」注册表,发明它是沙箱裁决而非映射细节)、不可用路径(空串、空段 `a..b`、`__proto__` / `prototype` / `constructor`)、互撞的 `target`(同路径或一个写进另一个内部)—— 均为结构化 **501 NOT_IMPLEMENTED**(带处方,点名具体条目如 `inputMapping[1].transform`),与 `endpoint-executor` 的 `unsupported` 分支同类同形。`outputMapping` 的这道判定在**委派之前**做:投影坏掉的 `create` 不该先插入记录再拒绝作答。
- 新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。

**现网行为零变更**:非空 `apis:` 在 publish / validate 仍被硬拒(E7 #5111 前不撤),整条端点链结构性不可达。上述「不支持子集」应由 E7 的 publish 门在作者写应用时就拒掉,本模块是运行期兜底,不是主关口。
208 changes: 208 additions & 0 deletions packages/runtime/src/api-endpoint-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,211 @@ describe('execution runs on the far side of the policy chain', () => {
expect(hint).toContain('no execution wiring');
});
});

/**
* The mapping keys, joined to the chain (#5040 E5c / #5137).
*
* `api-mapping.test.ts` owns what a projection IS; what is asserted here is
* where it applies — that a mapped body is what the executor delegates, that a
* mapped result is what the caller receives, that an ERROR answer is never
* remapped whatever produced it, and that a declaration this runtime cannot
* serve is refused before the target runs rather than after.
*/
describe('the mapping keys apply on the two sides of the delegation', () => {
const CREATE: ApiEndpoint = ApiEndpointSchema.parse({
name: 'showcase_inquiries',
path: '/api/v1/apps/showcase/inquiries',
method: 'POST',
type: 'object_operation',
target: 'showcase_inquiry',
objectParams: { object: 'showcase_inquiry', operation: 'create' },
authRequired: false,
});

const limiters = () => createEndpointRateLimiterRegistry({ resolveCache: async () => undefined });

function callDataSpy(result: unknown = { id: 'rec_1', name: 'Ada', internal_note: 'do not ship' }) {
const calls: unknown[][] = [];
return { calls, fn: async (...args: unknown[]) => { calls.push(args); return result; } };
}

const mappedStep = (
endpoint: ApiEndpoint,
callData: unknown,
body: unknown = { firstName: 'Ada', secret: 'internal' },
policy: Partial<EndpointPolicyContext> = {},
) => runAppEndpointStep({
method: endpoint.method,
path: endpoint.path,
prefix: '/api/v1',
metadataService: matcherFor([endpoint]).service as never,
policy: { limiters: limiters(), ...policy },
execution: {
request: { method: endpoint.method, path: endpoint.path, query: { trace: '1' }, body },
deps: { callData: callData as never },
},
});

it('delegates the MAPPED body — the executor never sees the raw one', async () => {
const spy = callDataSpy();
const mapped = ApiEndpointSchema.parse({
...CREATE,
inputMapping: [{ source: 'firstName', target: 'first_name' }],
});

const answer = await mappedStep(mapped, spy.fn);

expect(answer?.status).toBe(201);
// `data` is the projection: the renamed field is there and the
// undeclared one is gone, delegated through the same `callData` shape
// `/data` uses.
expect(spy.calls).toEqual([['create', { object: 'showcase_inquiry', data: { first_name: 'Ada' } }, undefined, undefined, undefined]]);
});

it('leaves the query string alone — inputMapping maps the BODY', async () => {
// The vocabulary says "Map Request Body to Internal Params"; query
// parameters keep reaching the pipeline exactly as they did before.
const spy = callDataSpy({ records: [], total: 0 });
const find = ApiEndpointSchema.parse({
...CREATE,
name: 'showcase_find',
method: 'GET',
objectParams: { object: 'showcase_inquiry', operation: 'find' },
inputMapping: [{ source: 'firstName', target: 'first_name' }],
});

await mappedStep(find, spy.fn);

expect((spy.calls[0]![1] as { query: unknown }).query).toEqual({ trace: '1' });
});

it('delegates the caller\'s own body when no mapping is declared', async () => {
const spy = callDataSpy();
const body = { firstName: 'Ada', secret: 'internal' };

await mappedStep(CREATE, spy.fn, body);

// By reference: an endpoint that declares no mapping is served exactly
// as E5b served it, with no projection in between.
expect((spy.calls[0]![1] as { data: unknown }).data).toBe(body);
});

it('answers with the MAPPED result on a success', async () => {
const spy = callDataSpy();
const mapped = ApiEndpointSchema.parse({
...CREATE,
outputMapping: [{ source: 'id', target: 'inquiry_id' }, { source: 'name', target: 'contact.name' }],
});

const answer = await mappedStep(mapped, spy.fn);

expect(answer?.status).toBe(201);
expect(answer?.body).toEqual({
success: true,
data: { inquiry_id: 'rec_1', contact: { name: 'Ada' } },
meta: undefined,
});
// The allow-list property, end to end: an internal field the pipeline
// returned and the declaration did not name never reaches the wire.
expect(JSON.stringify(answer?.body)).not.toContain('internal_note');
});

it('keeps the cacheTtl header on a mapped success', async () => {
// `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the
// point is that the two keys compose — the projection replaces the body
// and the policy verdict's header still rides with it.
const mapped = ApiEndpointSchema.parse({
...CREATE,
name: 'showcase_cached_map',
method: 'GET',
objectParams: { object: 'showcase_inquiry', operation: 'find' },
cacheTtl: 30,
outputMapping: [{ source: 'total', target: 'count' }],
});

const answer = await mappedStep(mapped, callDataSpy({ records: [], total: 2 }).fn);

expect(answer?.body).toEqual({ success: true, data: { count: 2 }, meta: undefined });
expect(answer?.headers).toEqual({ 'Cache-Control': 'private, max-age=30' });
});

it('never remaps an ERROR answer — a mapping must not disguise a failure', async () => {
const outputMapping = [{ source: 'id', target: 'inquiry_id' }];

// 401: denied by the policy chain, before execution.
const authed = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_authed', authRequired: true, outputMapping });
const denied = await mappedStep(authed, callDataSpy().fn);
expect(denied?.status).toBe(401);
expect((denied!.body as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED');

// 400: a delegated pipeline's own failure.
const failing = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_failing', outputMapping });
const bad = await mappedStep(failing, async () => { throw { statusCode: 400, message: 'name is required' }; });
expect(bad?.status).toBe(400);
expect((bad!.body as { error: { message: string } }).error.message).toBe('name is required');

// 501: a declaration this runtime does not execute.
const proxied = ApiEndpointSchema.parse({
...CREATE, name: 'showcase_proxy_map', type: 'proxy', target: 'https://example.invalid', outputMapping,
});
const unsupported = await mappedStep(proxied, callDataSpy().fn);
expect(unsupported?.status).toBe(501);
expect((unsupported!.body as { error: { code: string } }).error.code).toBe('NOT_IMPLEMENTED');

// 429: the endpoint budget, spent. Every one of these bodies is the
// error envelope, untouched by the declared projection.
const entries = new Map<string, unknown>();
const store: CounterStore = {
get: async <T,>(k: string) => entries.get(k) as T | undefined,
set: async (k: string, v: unknown) => { entries.set(k, v); },
};
const limited = ApiEndpointSchema.parse({
...CREATE, name: 'showcase_limited_map', outputMapping,
rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 },
});
const policy = { limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }) };
expect((await mappedStep(limited, callDataSpy().fn, undefined, policy))?.status).toBe(201);
const over = await mappedStep(limited, callDataSpy().fn, undefined, policy);
expect(over?.status).toBe(429);

for (const answer of [denied, bad, unsupported, over]) {
expect(JSON.stringify(answer?.body)).not.toContain('inquiry_id');
expect((answer!.body as { success: boolean }).success).toBe(false);
}
});

it('refuses a `transform` declaration at request time, without executing anything', async () => {
const spy = callDataSpy();
const withTransform = ApiEndpointSchema.parse({
...CREATE,
inputMapping: [{ source: 'price', target: 'amount', transform: 'convertToInt' }],
});

const answer = await mappedStep(withTransform, spy.fn);

expect(answer?.status).toBe(501);
const error = (answer!.body as { error: Record<string, unknown> }).error;
expect(error.code).toBe('NOT_IMPLEMENTED');
expect(String(error.message)).toContain('inputMapping[0].transform');
expect(spy.calls, 'a refused declaration still reached the pipeline').toEqual([]);
// No `Cache-Control` on a refusal, for the same reason as any error.
expect(answer?.headers).toBeUndefined();
});

it('refuses a broken outputMapping BEFORE the target runs, not after', async () => {
// The ordering that matters: a `create` with an unservable projection
// must not insert the record and then fail to answer with it.
const spy = callDataSpy();
const broken = ApiEndpointSchema.parse({
...CREATE,
outputMapping: [{ source: 'id', target: 'a' }, { source: 'name', target: 'a.b' }],
});

const answer = await mappedStep(broken, spy.fn);

expect(answer?.status).toBe(501);
expect(String((answer!.body as { error: { message: string } }).error.message))
.toContain('outputMapping[1].target');
expect(spy.calls, 'the record was created and then the answer was refused').toEqual([]);
});
});
64 changes: 58 additions & 6 deletions packages/runtime/src/api-endpoint-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,40 @@
* header describes a body the caller should be willing to reuse, and telling a
* client to cache a 401 / 429 / 500 for a minute is worse than saying nothing.
*
* What it does NOT do, so nobody reads more into it than is here:
* `inputMapping` / `outputMapping` (declared, still unread — #5040's E7 gate
* must not flip before they are, or the two keys sit in the "declared, legal,
* ignored" state this program exists to end).
* ## The mapping keys, and why they apply exactly here (#5040 E5c)
*
* `inputMapping` / `outputMapping` (`api-mapping.ts`) are applied by this
* module, on the two sides of the delegation:
*
* - **`inputMapping` after the policy pass, before delegation.** It projects
* the request the executor sees, so a mapping can never buy a caller past
* `authRequired` or the rate limiter — and `endpoint-executor.ts` stays a
* pure delegator that does not know mappings exist.
* - **`outputMapping` on the SUCCESS body only.** An error answer is never
* remapped: a projection that could reshape a 401 / 429 / 500 into data
* would be able to disguise a failure as a result, and no declaration should
* have that power. This is the same asymmetry `Cache-Control` has above, for
* the same reason.
*
* A declaration this runtime cannot serve (`transform`, an unusable path,
* colliding targets) is refused BEFORE the target runs — including
* `outputMapping`, which is validated pre-delegation so a broken projection
* cannot let a `create` insert a record and then fail to answer. With neither
* key declared, the request and the answer pass through byte for byte, by
* reference: an endpoint that declares no mapping is served exactly as E5b
* served it.
*/

import { DispatcherErrorCode } from '@objectstack/spec/api';
import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { apiErrorResponse } from './error-envelope.js';
import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js';
import {
applyInputMapping,
applyOutputMapping,
mappingDeclarationRejection,
} from './api-mapping.js';
import {
buildEndpointExecutionContext,
executeEndpointTarget,
Expand Down Expand Up @@ -241,9 +264,26 @@ export async function runAppEndpointStep(
}

const { request, deps, executionContext, environmentId, dataDriver } = input.execution;

// ── inputMapping: project the request the executor will see ──────────
// Nothing has been delegated yet, so a declaration this runtime cannot
// serve is refused before it can have an effect. With no declaration the
// caller's own request object rides on unchanged, by reference.
const mappedBody = applyInputMapping(match.endpoint, request.body);
if (!mappedBody.ok) return mappedBody.rejection;
const mappedRequest = mappedBody.value === request.body
? request
: { ...request, body: mappedBody.value };

// `outputMapping` is judged HERE, not after the result arrives: a broken
// projection must not be able to let a `create` insert its record and then
// refuse to answer with it.
const outputRejection = mappingDeclarationRejection(match.endpoint, 'outputMapping');
if (outputRejection) return outputRejection;

const answer = await executeEndpointTarget(
buildEndpointExecutionContext({
request,
request: mappedRequest,
match,
...(executionContext !== undefined ? { executionContext } : {}),
...(environmentId !== undefined ? { environmentId } : {}),
Expand All @@ -256,14 +296,26 @@ export async function runAppEndpointStep(
// `executeEndpointTarget` never throws — a delegated failure is already an
// error answer here — so the status is the whole test, and an endpoint whose
// execution failed cannot hand the client a cache directive for the failure.
// `outputMapping` rides on exactly the same test, and for a stronger reason:
// a projection applied to an error body could disguise the failure as data.
const isSuccess = answer.status < 400;
let body = answer.body;
if (isSuccess) {
const mapped = applyOutputMapping(match.endpoint, answer.body);
// Unreachable: the identical verdict was taken before delegation, above.
// Restated rather than asserted away, so a future reordering of these
// two lines cannot turn a refusal into a silently unmapped answer.
if (!mapped.ok) return mapped.rejection;
body = mapped.value;
}

const headers = {
...(answer.headers ?? {}),
...(isSuccess ? verdict.responseHeaders : {}),
};
return {
status: answer.status,
body: answer.body,
body,
...(Object.keys(headers).length > 0 ? { headers } : {}),
};
}
Expand Down
Loading
Loading