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
48 changes: 48 additions & 0 deletions e2e/tests/api-console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ test.beforeEach(async ({ page }) => {
return;
}

if (
request.method() === 'GET' &&
url.pathname.endsWith('/apisix/admin/routes/editable-console-route')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
value: {
id: 'editable-console-route',
uri: '/editable-console',
name: 'Editable Console Route',
create_time: 1710000000,
update_time: 1710000100,
},
}),
});
return;
}

if (
request.method() === 'PUT' &&
url.pathname.endsWith('/apisix/admin/routes/raw-console-route')
Expand Down Expand Up @@ -209,6 +229,34 @@ test('restores failed requests for correction and rerun', async ({ page }) => {
await expect(page.getByRole('button', { name: 'History (2)' })).toBeVisible();
});

test('loads existing resources as editable request bodies', async ({ page }) => {
const pathInput = page.getByRole('combobox', { name: /Path suffix/ });
await pathInput.fill('editable-console-route');

const loadedResponse = page.waitForResponse((response) =>
response.url().includes('/apisix/admin/routes/editable-console-route')
);
await page.getByRole('button', { name: 'Load resource' }).click();
expect((await loadedResponse).status()).toBe(200);

await expect(
page.getByText('Loaded as editable request body', { exact: true })
).toBeVisible();
await expect(
page.getByText('Removed read-only fields: id, create_time, update_time.')
).toBeVisible();

const requestEditor = page.locator('.monaco-editor').first();
await expect(requestEditor).toContainText('"uri": "/editable-console"');
await expect(requestEditor).not.toContainText('"id":');
await expect(requestEditor).not.toContainText('"create_time":');
await expect(requestEditor).not.toContainText('"update_time":');

await page.getByRole('button', { name: 'Use raw response' }).click();
await expect(requestEditor).toContainText('"id": "editable-console-route"');
await expect(requestEditor).toContainText('"create_time": 1710000000');
});

test('executes confirmed PUT requests with JSON body and history', async ({
page,
}) => {
Expand Down
4 changes: 4 additions & 0 deletions src/routes/raw_api/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@
padding: 12px 12px 0;
}

.loadedBodyAlert {
margin-top: 10px;
}

.editor {
flex: 1;
min-height: 0;
Expand Down
81 changes: 78 additions & 3 deletions src/routes/raw_api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ import {
import { adminKeyAtom } from '@/stores/global';
import { APISIX } from '@/types/schema/apisix';
import { APISIXProtos } from '@/types/schema/apisix/protos';
import {
isRecord,
PATCH_READONLY_KEYS,
sortJsonKeys,
stripPatchReadonlyFields,
} from '@/utils/apisixEditable';
import { createRequiredJsonTemplate } from '@/utils/jsonRequiredTemplate';
import { getJsonSchemaFeedback } from '@/utils/jsonSchemaFeedback';
import { getResourceConditionalRequirements } from '@/utils/resourceJsonSchema';
Expand Down Expand Up @@ -148,6 +154,10 @@ type ConsoleRequestSnapshot = {
body: string;
endpoint: string;
};
type LoadedBodyNotice = {
rawBody: string;
removedKeys: string[];
};

const REQUEST_HISTORY_KEY = 'api-console:session-history';
const REQUEST_PRESETS_KEY = 'api-console:session-presets';
Expand Down Expand Up @@ -317,6 +327,30 @@ const stringifyResponseData = (data: unknown) => {
}
};

const getEditableLoadedBody = (value: unknown) => {
const sortedRaw = sortJsonKeys(value);
const rawBody = JSON.stringify(sortedRaw, null, 2);

if (!isRecord(value)) {
return {
body: rawBody,
rawBody,
removedKeys: [],
};
}

const editableValue = stripPatchReadonlyFields(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't strip consumer usernames from loaded bodies

When the selected resource is Consumers, value contains the required top-level username, but this generic sanitizer removes it via PATCH_READONLY_KEYS. APISIX.ConsumerPut still requires username and the normal consumer PUT wrapper preserves it in the payload (src/types/schema/apisix/consumers.ts:24-39, src/apis/consumers.ts:44-48), so loading a consumer and sending the generated PUT body will fail Admin API validation instead of providing an editable update body.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip SSL validity fields from loaded bodies

For SSL resources, Admin API responses can include generated validity_start/validity_end fields (the SSL list reads validity_end), and the normal SSL write path explicitly deletes both before PUT (src/apis/ssls.ts:27-30). Because the new loader only removes PATCH_READONLY_KEYS, loading an SSL leaves those generated fields in the editable request body, so sending the unchanged loaded body through API Console includes fields that the regular API wrapper already treats as read-only.

Useful? React with 👍 / 👎.

const removedKeys = PATCH_READONLY_KEYS.filter((key) =>
Object.prototype.hasOwnProperty.call(value, key)
);

return {
body: JSON.stringify(sortJsonKeys(editableValue), null, 2),
rawBody,
removedKeys,
};
};

const getErrorResponse = (error: unknown, elapsed: number): ConsoleResponse & { error: string } => {
const response = (error as {
response?: { status?: number; data?: unknown; headers?: unknown };
Expand Down Expand Up @@ -393,6 +427,8 @@ function RawApiPage() {
const [presetsOpen, setPresetsOpen] = useState(false);
const [savePresetOpen, setSavePresetOpen] = useState(false);
const [presetName, setPresetName] = useState('');
const [loadedBodyNotice, setLoadedBodyNotice] =
useState<LoadedBodyNotice | null>(null);
const [requestHistory, setRequestHistory] = useState<RequestHistoryEntry[]>(
readRequestHistory
);
Expand Down Expand Up @@ -426,7 +462,16 @@ function RawApiPage() {
headers: { [SKIP_INTERCEPTOR_HEADER]: CONSOLE_INTERCEPTOR_SKIPS },
});
const value = res.data?.value ?? res.data;
setBody(JSON.stringify(value, null, 2));
const editableBody = getEditableLoadedBody(value);
setBody(editableBody.body);
setLoadedBodyNotice(
editableBody.removedKeys.length > 0
? {
Comment on lines +465 to +469
rawBody: editableBody.rawBody,
removedKeys: editableBody.removedKeys,
}
: null
);
setResponse({
status: res.status,
data: stringifyResponseData(res.data),
Expand Down Expand Up @@ -469,6 +514,7 @@ function RawApiPage() {
setPathSuffix(requestSnapshot.pathSuffix);
setQueryString(requestSnapshot.queryString);
setBody(requestSnapshot.body);
setLoadedBodyNotice(null);
setResponse(null);
setResponseError(null);
setResponseView('Body');
Expand Down Expand Up @@ -713,6 +759,13 @@ function RawApiPage() {
} catch { message.error('Failed to copy'); }
}, [method, requestUrl, body, needsBody, adminKey]);

const restoreLoadedRawBody = useCallback(() => {
if (!loadedBodyNotice) return;
setBody(loadedBodyNotice.rawBody);
setLoadedBodyNotice(null);
message.success('Restored raw response body');
}, [loadedBodyNotice]);

const statusColor = response ? (response.status < 300 ? 'success' : response.status < 400 ? 'warning' : 'error') : undefined;

return (
Expand Down Expand Up @@ -765,6 +818,7 @@ function RawApiPage() {
value={method}
onChange={(value) => {
setMethod(value);
setLoadedBodyNotice(null);
setBody(
stringifyRequiredRequestTemplate(
resource,
Expand All @@ -789,6 +843,7 @@ function RawApiPage() {
value={resource}
onChange={(v) => {
setResource(v);
setLoadedBodyNotice(null);
setBody(stringifyRequiredRequestTemplate(v, method, ''));
setPathSuffix('');
setQueryString('');
Expand All @@ -808,7 +863,10 @@ function RawApiPage() {
<span aria-hidden="true">/</span>
<AutoComplete
value={pathSuffix}
onChange={setPathSuffix}
onChange={(value) => {
setPathSuffix(value);
setLoadedBodyNotice(null);
}}
options={existingResources.map((r) => ({
value: r.path,
label: (
Expand Down Expand Up @@ -925,12 +983,29 @@ function RawApiPage() {
style={{ padding: '8px 12px', fontSize: 'var(--app-font-size-sm)' }}
/>
)}
{loadedBodyNotice && (
<Alert
type="info"
showIcon
message="Loaded as editable request body"
description={`Removed read-only fields: ${loadedBodyNotice.removedKeys.join(', ')}.`}
action={
<Button size="small" onClick={restoreLoadedRawBody}>
Use raw response
</Button>
}
className={classes.loadedBodyAlert}
/>
)}
</div>
<div className={classes.editor}>
<JsonCodeEditor
height="100%"
value={body}
onChange={(nextValue) => setBody(nextValue ?? '')}
onChange={(nextValue) => {
setBody(nextValue ?? '');
setLoadedBodyNotice(null);
}}
Comment on lines +1005 to +1008
variant="flush"
/>
</div>
Expand Down