Skip to content

Commit e1bfde3

Browse files
fix(core): interpolate api action targets before a registered api handler (#26826)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3cr6whx99bskUDH3xXu7A3)_ Fixes #26722 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录页上 `type: "api"` 的动作,目标地址里的 `${ctx.recordId}` 等占位符现在会先被替换成真实值再发请求,和 `url` 动作用的是同一套替换与编码。 - **为什么改**:控制台注册了自己的 api 处理器,绕过了 ActionRunner 里唯一做替换的 `executeAPI`,请求带着字面占位符发出(`…/$%7Bctx.recordId%7D`),记录级 PATCH/POST 动作在每条记录上都打到不存在的记录。 - **风险与回滚**:只影响「已注册 api 处理器 + 目标里带 `${` 占位符」的动作;不带占位符的动作原对象原样交给处理器。回滚即 revert 本 PR。 - **先看**:`ui/packages/core/src/actions/ActionRunner.ts` 中 `dispatchCollected` 的已注册处理器分支;`ActionRunner.apiHandlerTarget-26722.test.ts`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts`,预期 7 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes `ActionRunner.dispatchCollected` now runs `interpolateTarget` on an `api` action's string `target` before handing the action to a registered `api` handler. It is the same call the built-in `executeAPI` and `executeUrl` make, so an api target gets the same scopes (`ctx`, `param`) and the same per-value `encodeURIComponent` as a url target. There is no second interpolator and no change in `app-shell`. - The landing site differs from the card's first guess. `executeAPI` already interpolated, but the Console registers its own `api` handler (`useConsoleActionRuntime`'s `apiHandler`, wired through `ActionProvider`'s `handlers`), and that handler replaces `executeAPI` entirely. The handler only filled `{field}` row tokens, so `${ctx.recordId}` reached `authFetch` literally. - An api target with no token reaches the handler as the same action object. A registered handler for any other type (`flow`, `script`, `modal`) still gets its target exactly as authored. - Comments on `interpolateTarget` now name the new caller. ## Why The spec documents `${ctx.recordId}` for api targets, and a url action with the same token already resolved. Record-level API actions could not be built without code because they failed on every record. ## Tests New `ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts` (7 cases): - a PATCH and a POST api action on a record page hand the registered handler `/api/v1/data/gapdata_item/gd_001`; - an id with a slash and a space is encoded the same way for an api target and a url target (`a%2Fb%20c`); - with no handler registered, the built-in executor sends the same resolved path; - a url action with the same token is unchanged; - a registered `flow` handler still gets its target as authored; - an untokenized api action reaches the handler as the same object. ## Acceptance notes - The url path also runs the expression evaluator after substitution; the api path did not before and does not now. The order asked for the same variables and encoding, which this delivers, and nothing more. - `${ctx.recordId}` on a list row action resolves from the runner's context (the page record), not from the clicked row. That is how url targets behave today too, so it is unchanged here. Row actions keep using the `{field}` row tokens the Console's handler already fills. ## Verification <details><summary>Ablation (one-time, on head 333eef5, mutation restored)</summary> With `actionType === 'api'` replaced by `false` in the new branch (via `scripts/ablation-replace.mjs`, anchor 1 → 0, blob restored to HEAD's), the new file reads 3 failed / 4 passed: the PATCH, POST and encoding cases go red, and the built-in, url, flow and same-object controls stay green. </details> <details><summary>Suites and type-check (head 333eef5)</summary> - `pnpm --dir ui exec vitest run packages/core/ --maxWorkers=1` under the verify lock: Test Files 215 passed (215), Tests 4112 passed, 27 skipped. - `pnpm --filter @object-ui/core run type-check`: exit 0. - `pnpm --dir ui exec vitest run scripts/ --maxWorkers=2` under the verify lock: Test Files 178 passed, 2 skipped (180); Tests 5065 passed, 2 skipped. - `pnpm check:new-line-citations` (from `ui/`): 0 new citations. `eslint` on both changed files: 0 errors; the 15 warnings are existing `no-explicit-any` lines the diff does not touch. - Narrowing: `app-shell` and `components` suites were not run locally. A `git grep` over ui tests finds no test outside `packages/core` that registers an api handler and dispatches a target carrying a `${` token, and untokenized actions reach the handler as the same object. CI's Test UI runs those suites. </details> <details><summary>Derived gates (head 333eef5)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands. 33 ran locally, each exit 0, and with the scripts suite `--ran` reconciles all of them (`Run reconciliation — 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN`). The 34th, `pnpm --dir ui exec vitest run scripts/`, is in the suites block above. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTDpMW8KaXLg1DFU6f)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bff1546 commit e1bfde3

3 files changed

Lines changed: 148 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
fix(core): an `api` action's target is interpolated before a registered `api` handler sends it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An `api` action whose `target` carries `${ctx.recordId}` (or any other `${ctx.X}` / `${param.X}` token) went out on the wire with the literal placeholder (`/api/v1/data/gapdata_item/$%7Bctx.recordId%7D`) when a host had registered its own `api` handler, as the Console does, so a record-level PATCH or POST hit a record that does not exist. A `url` action with the same token already resolved it. `ActionRunner` now runs its one target substitution on an `api` target before handing the action to a registered `api` handler, with the same scopes and the same per-value `encodeURIComponent` a `url` target gets. The built-in executor already did this and is unchanged; a registered handler for any other type still receives its target as authored.

‎ui/packages/core/src/actions/ActionRunner.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,7 +1680,18 @@ export class ActionRunner {
16801680
// Check for a registered custom handler first
16811681
if (actionType && this.handlers.has(actionType)) {
16821682
const handler = this.handlers.get(actionType)!;
1683-
return handler(action, this.context);
1683+
// An `api` target is interpolated HERE, by the runner, before a host
1684+
// handler sees it — the same `interpolateTarget` the built-in
1685+
// `executeAPI` and `executeUrl` run. A registered `api` handler (the
1686+
// console's) replaces `executeAPI` wholesale, so without this the spec's
1687+
// documented `${ctx.recordId}` went out on the wire as the literal
1688+
// placeholder while a `url` target with the same token resolved. One
1689+
// substitution, one vocabulary, one encoding, whoever sends the request.
1690+
// A target with no token reaches the handler as the same object.
1691+
const target = actionType === 'api' && typeof action.target === 'string'
1692+
? this.interpolateTarget(action.target, action)
1693+
: action.target;
1694+
return handler(target === action.target ? action : { ...action, target }, this.context);
16841695
}
16851696

16861697
// Built-in action execution by type.
@@ -2802,7 +2813,8 @@ export class ActionRunner {
28022813
/**
28032814
* Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
28042815
*
2805-
* Used by executeUrl and executeAPI so metadata can produce dynamic
2816+
* Used by executeUrl and executeAPI — and on an `api` target before a
2817+
* registered `api` handler receives it — so metadata can produce dynamic
28062818
* URLs without coupling to the wider ExpressionEvaluator surface
28072819
* (which would also evaluate operators, member access, etc. — overkill
28082820
* and unsafe for URL-position values).
@@ -2867,7 +2879,8 @@ export class ActionRunner {
28672879
//
28682880
// `result` (the handler's own return value) is passed by exactly one caller:
28692881
// the `ActionSchema.onSuccess` post-success hop, where a response exists.
2870-
// Every other caller — `executeUrl`, `executeAPI` — interpolates a target
2882+
// Every other caller — `executeUrl`, `executeAPI`, the registered `api`
2883+
// handler hand-off in `dispatchCollected` — interpolates a target
28712884
// BEFORE the request, so there is no result to read; admitting `${result.*}`
28722885
// there would widen the authorable surface with nothing behind it and
28732886
// silently blank the token instead of leaving the author's mistake visible.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* An `api` action's `target` is interpolated before a REGISTERED `api` handler
5+
* receives it, through the same substitution a `url` target goes through.
6+
*
7+
* The console registers its own `api` handler, which replaces the built-in
8+
* `executeAPI` wholesale. Only `executeAPI` called `interpolateTarget`, so on a
9+
* record page `target: "/api/v1/data/gapdata_item/${ctx.recordId}"` reached the
10+
* wire as `…/$%7Bctx.recordId%7D` for PATCH and POST alike, while a `url`
11+
* action with the same token opened the resolved address.
12+
*
13+
* Pinned here: what the handler receives for PATCH and POST, that the encoding
14+
* is the url target's (one opaque segment per value), that the built-in
15+
* executor sends the same path, that a `url` action is unchanged, and that a
16+
* non-`api` handler still receives its target as authored.
17+
*/
18+
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { ActionRunner, type ActionContext, type ActionDef, type ActionRunnerHandler } from '../ActionRunner';
21+
22+
const RECORD_PAGE: ActionContext = {
23+
record: { id: 'gd_001', name: 'Gap item' },
24+
objectName: 'gapdata_item',
25+
};
26+
27+
const TARGET = '/api/v1/data/gapdata_item/${ctx.recordId}';
28+
// A url target under `/api/` leaves through `window.location`, not the host's
29+
// navigation handler, so the url control uses a route path with the same token.
30+
const URL_TARGET = '/gapdata_item/${ctx.recordId}';
31+
32+
function recordingApiHandler() {
33+
const seen: ActionDef[] = [];
34+
const handler: ActionRunnerHandler = async (action) => {
35+
seen.push(action);
36+
return { success: true, silent: true };
37+
};
38+
return { seen, handler };
39+
}
40+
41+
describe('api targets interpolate before a registered api handler (objectstack#26722)', () => {
42+
afterEach(() => {
43+
vi.unstubAllGlobals();
44+
});
45+
46+
it.each(['PATCH', 'POST'])('a %s api action hands the handler the resolved record path', async (method) => {
47+
const runner = new ActionRunner(RECORD_PAGE);
48+
const { seen, handler } = recordingApiHandler();
49+
runner.registerHandler('api', handler);
50+
51+
const result = await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method });
52+
53+
expect(result.success).toBe(true);
54+
expect(seen).toHaveLength(1);
55+
expect(seen[0].target).toBe('/api/v1/data/gapdata_item/gd_001');
56+
expect(seen[0].method).toBe(method);
57+
});
58+
59+
it('an api target with no token reaches the handler as the same action object', async () => {
60+
const runner = new ActionRunner(RECORD_PAGE);
61+
const { seen, handler } = recordingApiHandler();
62+
runner.registerHandler('api', handler);
63+
const action: ActionDef = { type: 'api', name: 'plain', target: '/api/v1/data/gapdata_item', method: 'POST' };
64+
65+
await runner.execute(action);
66+
67+
expect(seen[0]).toBe(action);
68+
});
69+
70+
it('encodes each value the way a url target does', async () => {
71+
const context: ActionContext = { record: { id: 'a/b c' }, objectName: 'gapdata_item' };
72+
const runner = new ActionRunner(context);
73+
const { seen, handler } = recordingApiHandler();
74+
runner.registerHandler('api', handler);
75+
const navigate = vi.fn();
76+
runner.setNavigationHandler(navigate);
77+
78+
await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method: 'PATCH' });
79+
await runner.execute({ type: 'url', name: 'open', target: URL_TARGET });
80+
81+
expect(seen[0].target).toBe('/api/v1/data/gapdata_item/a%2Fb%20c');
82+
expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/a%2Fb%20c');
83+
});
84+
85+
it('the built-in executor sends the same resolved path', async () => {
86+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) });
87+
vi.stubGlobal('fetch', fetchMock);
88+
const runner = new ActionRunner(RECORD_PAGE);
89+
90+
await runner.execute({ type: 'api', name: 'export_tab', target: TARGET, method: 'POST' });
91+
92+
expect(fetchMock).toHaveBeenCalledWith(
93+
'/api/v1/data/gapdata_item/gd_001',
94+
expect.objectContaining({ method: 'POST' }),
95+
);
96+
});
97+
98+
it('a url action with the same token is unchanged', async () => {
99+
const runner = new ActionRunner(RECORD_PAGE);
100+
const navigate = vi.fn();
101+
runner.setNavigationHandler(navigate);
102+
103+
const result = await runner.execute({ type: 'url', name: 'open', target: URL_TARGET });
104+
105+
expect(result.success).toBe(true);
106+
expect(navigate).toHaveBeenCalledTimes(1);
107+
expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/gd_001');
108+
});
109+
110+
it('a registered non-api handler still receives its target as authored', async () => {
111+
const runner = new ActionRunner(RECORD_PAGE);
112+
const seen: ActionDef[] = [];
113+
runner.registerHandler('flow', async (action) => {
114+
seen.push(action);
115+
return { success: true, silent: true };
116+
});
117+
118+
await runner.execute({ type: 'flow', name: 'run_flow', target: 'flow_${ctx.recordId}' });
119+
120+
expect(seen[0].target).toBe('flow_${ctx.recordId}');
121+
});
122+
});

0 commit comments

Comments
 (0)