diff --git a/e2e/tests/api-console.spec.ts b/e2e/tests/api-console.spec.ts index 05bd7a78..4bfbf8f0 100644 --- a/e2e/tests/api-console.spec.ts +++ b/e2e/tests/api-console.spec.ts @@ -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') @@ -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, }) => { diff --git a/src/routes/raw_api/index.module.css b/src/routes/raw_api/index.module.css index 94fd4bd3..d43617ed 100644 --- a/src/routes/raw_api/index.module.css +++ b/src/routes/raw_api/index.module.css @@ -245,6 +245,10 @@ padding: 12px 12px 0; } +.loadedBodyAlert { + margin-top: 10px; +} + .editor { flex: 1; min-height: 0; diff --git a/src/routes/raw_api/index.tsx b/src/routes/raw_api/index.tsx index 89475bc0..ea91892a 100644 --- a/src/routes/raw_api/index.tsx +++ b/src/routes/raw_api/index.tsx @@ -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'; @@ -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'; @@ -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); + 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 }; @@ -393,6 +427,8 @@ function RawApiPage() { const [presetsOpen, setPresetsOpen] = useState(false); const [savePresetOpen, setSavePresetOpen] = useState(false); const [presetName, setPresetName] = useState(''); + const [loadedBodyNotice, setLoadedBodyNotice] = + useState(null); const [requestHistory, setRequestHistory] = useState( readRequestHistory ); @@ -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 + ? { + rawBody: editableBody.rawBody, + removedKeys: editableBody.removedKeys, + } + : null + ); setResponse({ status: res.status, data: stringifyResponseData(res.data), @@ -469,6 +514,7 @@ function RawApiPage() { setPathSuffix(requestSnapshot.pathSuffix); setQueryString(requestSnapshot.queryString); setBody(requestSnapshot.body); + setLoadedBodyNotice(null); setResponse(null); setResponseError(null); setResponseView('Body'); @@ -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 ( @@ -765,6 +818,7 @@ function RawApiPage() { value={method} onChange={(value) => { setMethod(value); + setLoadedBodyNotice(null); setBody( stringifyRequiredRequestTemplate( resource, @@ -789,6 +843,7 @@ function RawApiPage() { value={resource} onChange={(v) => { setResource(v); + setLoadedBodyNotice(null); setBody(stringifyRequiredRequestTemplate(v, method, '')); setPathSuffix(''); setQueryString(''); @@ -808,7 +863,10 @@ function RawApiPage() { { + setPathSuffix(value); + setLoadedBodyNotice(null); + }} options={existingResources.map((r) => ({ value: r.path, label: ( @@ -925,12 +983,29 @@ function RawApiPage() { style={{ padding: '8px 12px', fontSize: 'var(--app-font-size-sm)' }} /> )} + {loadedBodyNotice && ( + + Use raw response + + } + className={classes.loadedBodyAlert} + /> + )}
setBody(nextValue ?? '')} + onChange={(nextValue) => { + setBody(nextValue ?? ''); + setLoadedBodyNotice(null); + }} variant="flush" />