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
11 changes: 11 additions & 0 deletions .changeset/10591-tool-preview-api-console-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

The tool preview's "Open in API Console" link now opens the API console inside the current app, with the tool's execute request pre-filled (objectui#10591).

The link pointed at `/developer/api-console?path=…`, a URL with no app segment. The console declares no route for it, so its catch-all redirected the author to `/`, the post-login landing, instead of the API console. The API console also read no query string, so the `path` the link carried was dropped even at the right URL.

- `@object-ui/app-shell`: `ToolPreview` builds the link from the `developer:api-console` registry key inside the app in the URL, `/apps/APP/component/developer/api-console`. That is the path Studio's Developer navigation reaches the API console at. The query carries the tool's execute path (`/api/v1/ai/tools/NAME/execute`) and `method=POST`. The link is rendered through the router, so a console mounted under a base path (such as `/_console`) keeps it inside that mount, and it still opens in a new tab. When the preview is rendered with no router above it, or on a route that names no app, it draws no link.
- `@object-ui/console`: `ApiConsolePage` reads `path` and `method` from the query string once, when it mounts, and pre-fills the URL field and the method selector. It does not send the request. The preset works on both of the page's mounts: the `developer/api-console` route and the `developer:api-console` registry key. `path` is used only when it starts with `/api/`; an absolute URL such as `http://…`, a protocol-relative `//host` URL or any other value is ignored. `method` is read only together with an accepted `path`, in any letter case, and only when it is one of the verbs the method selector offers. Without an accepted `path` the page opens exactly as before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The API console's request preset, and the tool preview link that carries it
* (objectui#10591).
*
* ## The defect
*
* The tool preview's "Open in API Console" link was the bare
* `/developer/api-console?path=…`. The console's root route table declares no
* `/developer` route, so its catch-all sent the author home. And even at the
* right URL the preset was dropped: this page read no query string at all.
*
* ## What this file pins
*
* 1. The page reads `?path=` (and `?method=`) once, on mount, into the
* request editor, and sends nothing. With no query it opens exactly as
* before (the control), and a `path` that is not a same-origin API path
* is ignored.
* 2. Both mounts honour it: the standalone `developer/api-console` route from
* this host's REAL `systemRoutes`, and the `developer:api-console`
* registry key served by app-shell's REAL `DefaultAppContent`.
* 3. End to end: the href the REAL tool preview draws, followed into that
* route table, reaches this page pre-filled, not the root catch-all.
*
* ## Scope of the stubs
*
* The page, the tool preview, the registration module, the route table and
* the link builder are all the production ones. The shell around the route
* table is stubbed the way `__tests__/orphanedPageComponentRefs-10520.test.tsx`
* stubs it, for the reasons given there. The root route table in `App.tsx` is
* not exported, so its `path="*"` catch-all (a redirect home) is stood in for
* by a probe; a harness control below proves that probe is reachable.
* `fetch` is stubbed so the page's endpoint discovery has an answer, and so
* every request the page makes is recorded.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useNavigate } from 'react-router-dom';

vi.mock('../../../../../packages/app-shell/src/views/metadata-admin', () => ({
MetadataDirectoryPage: () => <div data-testid="metadata-directory-page" />,
StudioHomePage: () => <div data-testid="studio-home-page" />,
MetadataResourceListPage: () => <div data-testid="metadata-resource-list-page" />,
MetadataResourceEditPage: () => <div data-testid="metadata-resource-edit-page" />,
MetadataResourceHistoryPage: () => <div data-testid="metadata-resource-history-page" />,
MetadataDiagnosticsPage: () => <div data-testid="metadata-diagnostics-page" />,
MetadataResourceRouter: () => <div data-testid="metadata-resource-router" />,
registerMetadataResource: () => {},
}));
vi.mock('../../../../../packages/app-shell/src/console/marketplace/MarketplacePage', () => ({
MarketplacePage: () => <div data-testid="marketplace-page" />,
}));
vi.mock('../../../../../packages/app-shell/src/console/marketplace/MarketplaceInstalledPage', () => ({
MarketplaceInstalledPage: () => <div data-testid="marketplace-installed-page" />,
}));
vi.mock('../../../../../packages/app-shell/src/console/marketplace/MarketplacePackagePage', () => ({
MarketplacePackagePage: () => <div data-testid="marketplace-package-page" />,
}));
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));
vi.mock('../../../../../packages/app-shell/src/layout/ConsoleLayout', () => ({
ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => (
<div data-testid="console-layout" data-active-app={activeAppName}>
{children}
</div>
),
}));
vi.mock('../../../../../packages/app-shell/src/chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../../../../packages/app-shell/src/chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../../../../packages/app-shell/src/chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../../../../packages/app-shell/src/views/ObjectView', () => ({
ObjectView: () => <div data-testid="object-view" />,
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
}),
useObjectLabel: () => ({
objectLabel: ({ label }: { label?: string }) => label,
}),
}));
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: null,
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));
const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../../../../packages/app-shell/src/providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));
vi.mock('../../../../../packages/app-shell/src/providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => ({
apps: [
{ name: 'setup', label: 'Setup', isDefault: true, navigation: [] },
{ name: 'studio', label: 'Studio', navigation: [] },
],
objects: [],
loading: false,
ensureType: undefined,
error: null,
refresh: vi.fn(async () => {}),
}),
}));

import { DefaultAppContent, getMetadataPreview } from '@object-ui/app-shell';
import { systemRoutes } from '../../AppContent';
// Imported at module scope, not only reached through `lazy()`: the route table
// and the registry both lazy-import this module, and ESM caches it by resolved
// specifier, so their factories resolve at once (AGENTS.md §测试纪律).
import { ApiConsolePage } from './ApiConsolePage';
// Side effect under test: registers `developer:api-console`, the way `main.tsx` does.
import '../../registerDeveloperComponents';

const TOOL = 'delete_all_orders';
const EXECUTE_PATH = `/api/v1/ai/tools/${TOOL}/execute`;

const fetchSpy = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response('{}', { status: 404, headers: { 'Content-Type': 'application/json' } }),
);

beforeEach(() => {
fetchSpy.mockClear();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

/** Every URL the page has fetched so far. Endpoint discovery accounts for some. */
const fetchedUrls = () => fetchSpy.mock.calls.map(([input]) => String(input));

const urlField = () => screen.getByPlaceholderText('/api/v1/...') as HTMLInputElement;
const methodField = () => screen.getByRole('combobox') as HTMLSelectElement;

function renderPageAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="*" element={<ApiConsolePage />} />
</Routes>
</MemoryRouter>,
);
}

describe('ApiConsolePage request preset (objectui#10591)', () => {
it('opens with the tool execute path and method pre-filled, and sends nothing', async () => {
renderPageAt(`/?path=${encodeURIComponent(EXECUTE_PATH)}&method=POST`);

expect(urlField()).toHaveValue(EXECUTE_PATH);
expect(methodField()).toHaveValue('POST');
// POST shows the body editor, as picking the verb by hand would.
expect(screen.getByText('Request Body (JSON)')).toBeInTheDocument();
// Let endpoint discovery settle (its built-in Auth group is listed even
// with no discovery payload), then check nothing hit the preset.
expect(await screen.findByText('Auth')).toBeInTheDocument();
expect(fetchedUrls().some((u) => u.includes('/ai/tools/'))).toBe(false);
expect(screen.queryByText('Sending request...')).not.toBeInTheDocument();
});

it('control: with no query string it opens exactly as before', async () => {
renderPageAt('/');

expect(urlField()).toHaveValue('');
expect(methodField()).toHaveValue('GET');
expect(screen.queryByText('Request Body (JSON)')).not.toBeInTheDocument();
expect(await screen.findByText('Auth')).toBeInTheDocument();
});

it.each([
['an absolute URL to another origin', 'http://evil.example/api/v1/ai/tools/x/execute'],
['a protocol-relative URL', '//evil.example/api/v1/ai/tools/x/execute'],
['a path outside /api/', '/apps/studio'],
['an empty value', ''],
])('ignores %s, and the method riding with it', (_label, path) => {
renderPageAt(`/?path=${encodeURIComponent(path)}&method=DELETE`);

expect(urlField()).toHaveValue('');
expect(methodField()).toHaveValue('GET');
});

it('accepts the method in any case, and ignores a verb the selector does not offer', () => {
renderPageAt(`/?path=${encodeURIComponent(EXECUTE_PATH)}&method=patch`);
expect(methodField()).toHaveValue('PATCH');
cleanup();

renderPageAt(`/?path=${encodeURIComponent(EXECUTE_PATH)}&method=TRACE`);
expect(urlField()).toHaveValue(EXECUTE_PATH);
expect(methodField()).toHaveValue('GET');
});

it('reads the preset once: a later query change does not overwrite the editor', () => {
function Retarget() {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate('/?path=/api/v1/health&method=GET')}>
retarget
</button>
);
}
render(
<MemoryRouter initialEntries={[`/?path=${encodeURIComponent(EXECUTE_PATH)}&method=POST`]}>
<Routes>
<Route
path="*"
element={
<>
<Retarget />
<ApiConsolePage />
</>
}
/>
</Routes>
</MemoryRouter>,
);

fireEvent.click(screen.getByRole('button', { name: 'retarget' }));

expect(urlField()).toHaveValue(EXECUTE_PATH);
expect(methodField()).toHaveValue('POST');
});
});

/**
* The console as it routes an in-app URL: app-shell's REAL `DefaultAppContent`
* with this host's REAL `systemRoutes`, under `/apps/:appName/*`, beside a
* stand-in for the root table's catch-all (the redirect home).
*/
function renderConsoleAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route
path="/apps/:appName/*"
element={<DefaultAppContent extraRoutes={systemRoutes} extraRoutesNoApp={systemRoutes} />}
/>
<Route path="*" element={<div data-testid="root-catch-all" />} />
</Routes>
</MemoryRouter>,
);
}

/** The href the REAL tool preview draws for `TOOL`, opened from Studio. */
function toolPreviewHref(): string {
const ToolPreview = getMetadataPreview('tool');
if (!ToolPreview) throw new Error('the tool preview is not registered');
render(
<MemoryRouter initialEntries={[`/apps/studio/metadata/tool/${TOOL}`]}>
<Routes>
<Route
path="/apps/:appName/*"
element={<ToolPreview type="tool" name={TOOL} draft={{ name: TOOL, label: 'Delete All Orders' }} />}
/>
</Routes>
</MemoryRouter>,
);
const href = screen.getByRole('link', { name: /open in api console/i }).getAttribute('href');
cleanup();
if (!href) throw new Error('the tool preview drew no href');
return href;
}

describe('the request preset through the console route table (objectui#10591)', () => {
it("the tool preview's link reaches the API console pre-filled, not the root catch-all", async () => {
renderConsoleAt(toolPreviewHref());

expect(await screen.findByPlaceholderText('/api/v1/...')).toHaveValue(EXECUTE_PATH);
expect(methodField()).toHaveValue('POST');
expect(screen.queryByTestId('root-catch-all')).not.toBeInTheDocument();
expect(screen.queryByText('Component not registered')).not.toBeInTheDocument();
expect(fetchedUrls().some((u) => u.includes('/ai/tools/'))).toBe(false);
});

it.each([
['the developer:api-console registry key', 'component/developer/api-console'],
['the standalone developer/api-console route', 'developer/api-console'],
])('%s honours the preset', async (_label, route) => {
renderConsoleAt(`/apps/studio/${route}?path=${encodeURIComponent(EXECUTE_PATH)}&method=POST`);

expect(await screen.findByPlaceholderText('/api/v1/...')).toHaveValue(EXECUTE_PATH);
expect(methodField()).toHaveValue('POST');
});

it('harness control: an app-less URL reaches the root catch-all, not the console', async () => {
renderConsoleAt(`/developer/api-console?path=${encodeURIComponent(EXECUTE_PATH)}`);

expect(await screen.findByTestId('root-catch-all')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('/api/v1/...')).not.toBeInTheDocument();
});
});
42 changes: 39 additions & 3 deletions apps/console/src/pages/developer/ApiConsolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,30 @@
* toggle (Project API / System API), and no `Server`/`FolderOpen` icons. The
* discovery hook is the console-local copy under `./hooks/useApiDiscovery`.
* UI primitives come from `@object-ui/components`.
*
* ## Request preset (objectui#10591)
*
* The page opens with a request pre-filled when its URL carries one:
* `?path=/api/v1/…` fills the URL field and `?method=POST` (any verb the
* method selector offers, in any case) picks the method. The tool preview's
* "Open in API Console" link is the producer; it builds exactly this pair.
*
* - Read ONCE, on mount, from the router's query string — so it works on both
* mounts, the standalone `developer/api-console` route and the
* `developer:api-console` registry key. A later change to the query does not
* overwrite what the user has typed since.
* - Pre-fill only: nothing is sent until the user presses Send.
* - `path` is honoured only when it is a same-origin API path, i.e. it starts
* with `/api/`. Anything else (an absolute `http://…` URL, a
* protocol-relative `//host`, a bare word, an empty value) is ignored, so a
* crafted link cannot aim the request editor at another origin.
* - `method` rides with `path`: it is read only when `path` is honoured, and a
* verb the selector does not offer is ignored.
* - With no honoured `path` the page opens exactly as it does without a query.
*/

import { useState, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAdapter } from '@object-ui/app-shell';
import {
Badge,
Expand Down Expand Up @@ -65,16 +86,31 @@ interface RequestHistoryEntry {

let nextParamId = 1;

/** The verbs the method selector offers, in its order — the selector renders this list. */
const REQUEST_METHODS: readonly HttpMethod[] = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'];

/** The request preset a link can carry — see "Request preset" in the header. */
function readRequestPreset(search: URLSearchParams): { path: string; method: HttpMethod | '' } | null {
const path = search.get('path');
if (!path || !path.startsWith('/api/')) return null;
const method = search.get('method')?.toUpperCase();
return { path, method: REQUEST_METHODS.find((m) => m === method) ?? '' };
}

export function ApiConsolePage() {
const adapter = useAdapter();
const client: any = adapter?.getClient?.();
const { groups, loading: discovering, refresh } = useApiDiscovery();
const [searchParams] = useSearchParams();
// Lazy initialiser: runs on the first render only, which is the "once, on
// mount" the preset promises.
const [preset] = useState(() => readRequestPreset(searchParams));

const [searchQuery, setSearchQuery] = useState('');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDef | null>(null);
const [methodOverride, setMethodOverride] = useState<HttpMethod | ''>('');
const [urlOverride, setUrlOverride] = useState('');
const [methodOverride, setMethodOverride] = useState<HttpMethod | ''>(preset?.method ?? '');
const [urlOverride, setUrlOverride] = useState(preset?.path ?? '');
const [requestBody, setRequestBody] = useState('');
const [queryParams, setQueryParams] = useState<QueryParam[]>([]);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -376,7 +412,7 @@ export function ApiConsolePage() {
onChange={e => setMethodOverride(e.target.value as HttpMethod)}
className={`rounded-md border bg-background px-2 py-1.5 font-mono text-xs font-semibold focus:outline-none focus:ring-1 focus:ring-ring ${METHOD_COLORS[effectiveMethod] || ''}`}
>
{(['GET', 'POST', 'PATCH', 'PUT', 'DELETE'] as HttpMethod[]).map(m => (
{REQUEST_METHODS.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
Expand Down
Loading
Loading