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
10 changes: 10 additions & 0 deletions .changeset/26722-api-target-interpolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/core': patch
---

fix(core): an `api` action's target is interpolated before a registered `api` handler sends it

Clause-②: no
Metadata-protocol: none

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.
19 changes: 16 additions & 3 deletions ui/packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1680,7 +1680,18 @@ export class ActionRunner {
// Check for a registered custom handler first
if (actionType && this.handlers.has(actionType)) {
const handler = this.handlers.get(actionType)!;
return handler(action, this.context);
// An `api` target is interpolated HERE, by the runner, before a host
// handler sees it — the same `interpolateTarget` the built-in
// `executeAPI` and `executeUrl` run. A registered `api` handler (the
// console's) replaces `executeAPI` wholesale, so without this the spec's
// documented `${ctx.recordId}` went out on the wire as the literal
// placeholder while a `url` target with the same token resolved. One
// substitution, one vocabulary, one encoding, whoever sends the request.
// A target with no token reaches the handler as the same object.
const target = actionType === 'api' && typeof action.target === 'string'
? this.interpolateTarget(action.target, action)
: action.target;
return handler(target === action.target ? action : { ...action, target }, this.context);
}

// Built-in action execution by type.
Expand Down Expand Up @@ -2802,7 +2813,8 @@ export class ActionRunner {
/**
* Substitute `${param.X}` and `${ctx.X}` tokens in an action target.
*
* Used by executeUrl and executeAPI so metadata can produce dynamic
* Used by executeUrl and executeAPI — and on an `api` target before a
* registered `api` handler receives it — so metadata can produce dynamic
* URLs without coupling to the wider ExpressionEvaluator surface
* (which would also evaluate operators, member access, etc. — overkill
* and unsafe for URL-position values).
Expand Down Expand Up @@ -2867,7 +2879,8 @@ export class ActionRunner {
//
// `result` (the handler's own return value) is passed by exactly one caller:
// the `ActionSchema.onSuccess` post-success hop, where a response exists.
// Every other caller — `executeUrl`, `executeAPI` — interpolates a target
// Every other caller — `executeUrl`, `executeAPI`, the registered `api`
// handler hand-off in `dispatchCollected` — interpolates a target
// BEFORE the request, so there is no result to read; admitting `${result.*}`
// there would widen the authorable surface with nothing behind it and
// silently blank the token instead of leaving the author's mistake visible.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* An `api` action's `target` is interpolated before a REGISTERED `api` handler
* receives it, through the same substitution a `url` target goes through.
*
* The console registers its own `api` handler, which replaces the built-in
* `executeAPI` wholesale. Only `executeAPI` called `interpolateTarget`, so on a
* record page `target: "/api/v1/data/gapdata_item/${ctx.recordId}"` reached the
* wire as `…/$%7Bctx.recordId%7D` for PATCH and POST alike, while a `url`
* action with the same token opened the resolved address.
*
* Pinned here: what the handler receives for PATCH and POST, that the encoding
* is the url target's (one opaque segment per value), that the built-in
* executor sends the same path, that a `url` action is unchanged, and that a
* non-`api` handler still receives its target as authored.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { ActionRunner, type ActionContext, type ActionDef, type ActionRunnerHandler } from '../ActionRunner';

const RECORD_PAGE: ActionContext = {
record: { id: 'gd_001', name: 'Gap item' },
objectName: 'gapdata_item',
};

const TARGET = '/api/v1/data/gapdata_item/${ctx.recordId}';
// A url target under `/api/` leaves through `window.location`, not the host's
// navigation handler, so the url control uses a route path with the same token.
const URL_TARGET = '/gapdata_item/${ctx.recordId}';

function recordingApiHandler() {
const seen: ActionDef[] = [];
const handler: ActionRunnerHandler = async (action) => {
seen.push(action);
return { success: true, silent: true };
};
return { seen, handler };
}

describe('api targets interpolate before a registered api handler (objectstack#26722)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it.each(['PATCH', 'POST'])('a %s api action hands the handler the resolved record path', async (method) => {
const runner = new ActionRunner(RECORD_PAGE);
const { seen, handler } = recordingApiHandler();
runner.registerHandler('api', handler);

const result = await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method });

expect(result.success).toBe(true);
expect(seen).toHaveLength(1);
expect(seen[0].target).toBe('/api/v1/data/gapdata_item/gd_001');
expect(seen[0].method).toBe(method);
});

it('an api target with no token reaches the handler as the same action object', async () => {
const runner = new ActionRunner(RECORD_PAGE);
const { seen, handler } = recordingApiHandler();
runner.registerHandler('api', handler);
const action: ActionDef = { type: 'api', name: 'plain', target: '/api/v1/data/gapdata_item', method: 'POST' };

await runner.execute(action);

expect(seen[0]).toBe(action);
});

it('encodes each value the way a url target does', async () => {
const context: ActionContext = { record: { id: 'a/b c' }, objectName: 'gapdata_item' };
const runner = new ActionRunner(context);
const { seen, handler } = recordingApiHandler();
runner.registerHandler('api', handler);
const navigate = vi.fn();
runner.setNavigationHandler(navigate);

await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method: 'PATCH' });
await runner.execute({ type: 'url', name: 'open', target: URL_TARGET });

expect(seen[0].target).toBe('/api/v1/data/gapdata_item/a%2Fb%20c');
expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/a%2Fb%20c');
});

it('the built-in executor sends the same resolved path', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) });
vi.stubGlobal('fetch', fetchMock);
const runner = new ActionRunner(RECORD_PAGE);

await runner.execute({ type: 'api', name: 'export_tab', target: TARGET, method: 'POST' });

expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/data/gapdata_item/gd_001',
expect.objectContaining({ method: 'POST' }),
);
});

it('a url action with the same token is unchanged', async () => {
const runner = new ActionRunner(RECORD_PAGE);
const navigate = vi.fn();
runner.setNavigationHandler(navigate);

const result = await runner.execute({ type: 'url', name: 'open', target: URL_TARGET });

expect(result.success).toBe(true);
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/gd_001');
});

it('a registered non-api handler still receives its target as authored', async () => {
const runner = new ActionRunner(RECORD_PAGE);
const seen: ActionDef[] = [];
runner.registerHandler('flow', async (action) => {
seen.push(action);
return { success: true, silent: true };
});

await runner.execute({ type: 'flow', name: 'run_flow', target: 'flow_${ctx.recordId}' });

expect(seen[0].target).toBe('flow_${ctx.recordId}');
});
});
Loading