This is an automated email from the ASF dual-hosted git repository.
LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 836f6b16b feat: add a per-route test-request drawer with a copyable
curl (#3456)
836f6b16b is described below
commit 836f6b16be7f3d43a3e50b6425394021d04ced68
Author: Yuhan <[email protected]>
AuthorDate: Mon Aug 3 13:48:35 2026 +0800
feat: add a per-route test-request drawer with a copyable curl (#3456)
---
.../regression/routes.test-request-drawer.spec.ts | 81 ++++++
src/components/page/RouteTestDrawer/index.tsx | 314 +++++++++++++++++++++
.../page/RouteTestDrawer/sendLive.test.ts | 54 ++++
src/components/page/RouteTestDrawer/sendLive.ts | 64 +++++
src/components/page/RouteTestDrawer/util.test.ts | 105 +++++++
src/components/page/RouteTestDrawer/util.ts | 77 +++++
src/locales/de/common.json | 21 ++
src/locales/en/common.json | 21 ++
src/locales/es/common.json | 21 ++
src/locales/tr/common.json | 21 ++
src/locales/zh/common.json | 21 ++
src/routes/routes/detail.$id.tsx | 54 ++--
12 files changed, 836 insertions(+), 18 deletions(-)
diff --git a/e2e/tests/regression/routes.test-request-drawer.spec.ts
b/e2e/tests/regression/routes.test-request-drawer.spec.ts
new file mode 100644
index 000000000..331dc1a8a
--- /dev/null
+++ b/e2e/tests/regression/routes.test-request-drawer.spec.ts
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.use({ permissions: ['clipboard-read', 'clipboard-write'] });
+
+const ROUTE_ID = 'reg-test-request-drawer';
+
+const clean = () => safeClean(() => deleteAllRoutes(e2eReq));
+
+test.beforeAll(async () => {
+ await clean();
+ await putRouteReq(e2eReq, {
+ id: ROUTE_ID,
+ name: ROUTE_ID,
+ uri: '/reg-test-hello',
+ hosts: ['ex.example.com'],
+ methods: ['GET'],
+ plugins: {
+ mocking: {
+ content_type: 'application/json',
+ response_status: 200,
+ response_example: '{"message":"it works!"}',
+ },
+ },
+ } as APISIXType['Route']);
+});
+
+test.afterAll(clean);
+
+test('route test-request drawer prefills curl, updates live, and falls back
when unreachable', async ({
+ page,
+}) => {
+ await uiGoto(page, `/routes/detail/${ROUTE_ID}`);
+
+ await page.getByRole('button', { name: 'Test', exact: true }).click();
+
+ // Set a gateway base so curl is concrete.
+ await page.getByLabel('Gateway URL').fill('http://127.0.0.1:9080');
+
+ // curl preview reflects the route's prefill (method, path, Host header).
+ const curl = page.getByText(/^curl -X GET/);
+ await expect(curl).toContainText("'http://127.0.0.1:9080/reg-test-hello'");
+ await expect(curl).toContainText('Host: ex.example.com');
+
+ // Editing the path updates the preview live.
+ await page.getByLabel('Path').fill('/changed');
+ await expect(curl).toContainText("'http://127.0.0.1:9080/changed'");
+
+ // Copy shows the copied state.
+ await page.getByRole('button', { name: 'Copy', exact: true }).click();
+ await expect(page.getByRole('button', { name: 'Copied', exact: true
})).toBeVisible();
+
+ // Send against an unreachable base → deterministic honest fallback.
+ await page.getByLabel('Gateway URL').fill('http://127.0.0.1:6553');
+ await page.getByRole('button', { name: 'Send request', exact: true
}).click();
+ await expect(
+ page.getByText(/couldn't read a response/i)
+ ).toBeVisible();
+});
diff --git a/src/components/page/RouteTestDrawer/index.tsx
b/src/components/page/RouteTestDrawer/index.tsx
new file mode 100644
index 000000000..edd4e235c
--- /dev/null
+++ b/src/components/page/RouteTestDrawer/index.tsx
@@ -0,0 +1,314 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ ActionIcon,
+ Alert,
+ Badge,
+ Button,
+ Code,
+ Collapse,
+ CopyButton,
+ Drawer,
+ Group,
+ ScrollArea,
+ Select,
+ Stack,
+ Text,
+ Textarea,
+ TextInput,
+} from '@mantine/core';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { getRouteQueryOptions } from '@/apis/hooks';
+import IconClose from '~icons/tabler/x';
+
+import { type LiveResult, sendLive } from './sendLive';
+import {
+ deriveFromRoute,
+ HTTP_METHODS,
+ METHODS_WITH_BODY,
+ type TestHeader,
+ type TestRequest,
+ toCurl,
+} from './util';
+
+const GATEWAY_URL_KEY = 'test-request:gatewayUrl';
+
+const statusColor = (status: number) => {
+ if (status < 300) return 'green';
+ if (status < 500) return 'yellow';
+ return 'red';
+};
+
+const prettyBody = (body: string) => {
+ try {
+ return JSON.stringify(JSON.parse(body), null, 2);
+ } catch {
+ return body;
+ }
+};
+
+type Props = { opened: boolean; onClose: () => void; id: string };
+
+export const RouteTestDrawer = (props: Props) => {
+ const { opened, onClose, id } = props;
+ const { t } = useTranslation();
+ const routeQuery = useQuery({ ...getRouteQueryOptions(id), enabled: opened
});
+ const route = routeQuery.data?.value;
+
+ const [gatewayUrl, setGatewayUrl] = useState(
+ () => localStorage.getItem(GATEWAY_URL_KEY) ?? ''
+ );
+ const [req, setReq] = useState<TestRequest | null>(null);
+ const [result, setResult] = useState<LiveResult | null>(null);
+ const [sending, setSending] = useState(false);
+ const [headersOpen, setHeadersOpen] = useState(false);
+ const sendTokenRef = useRef(0);
+
+ // Re-derive prefill whenever the drawer opens for a (freshly loaded) route.
+ useEffect(() => {
+ if (opened && route) {
+ sendTokenRef.current++;
+ setSending(false);
+ setReq(deriveFromRoute(route));
+ setResult(null);
+ }
+ }, [opened, route]);
+
+ useEffect(() => {
+ localStorage.setItem(GATEWAY_URL_KEY, gatewayUrl);
+ }, [gatewayUrl]);
+
+ const curl = useMemo(
+ () => (req ? toCurl(req, gatewayUrl) : ''),
+ [req, gatewayUrl]
+ );
+
+ const methodOptions = useMemo(() => {
+ const declared = route?.methods ?? [];
+ return (declared.length > 0 ? declared : HTTP_METHODS).map((m) => ({
+ value: m,
+ label: m,
+ }));
+ }, [route]);
+
+ const patch = (next: Partial<TestRequest>) =>
+ setReq((prev) => (prev ? { ...prev, ...next } : prev));
+
+ const setHeader = (i: number, next: Partial<TestHeader>) =>
+ setReq((prev) =>
+ prev
+ ? {
+ ...prev,
+ headers: prev.headers.map((h, idx) =>
+ idx === i ? { ...h, ...next } : h
+ ),
+ }
+ : prev
+ );
+
+ const canSend = gatewayUrl.trim() !== '' && !!req;
+ const showBody = !!req && METHODS_WITH_BODY.has(req.method);
+ const showWildcardHint = !!req && req.path.includes('*');
+
+ const onSend = async () => {
+ if (!req) return;
+ const token = ++sendTokenRef.current;
+ setSending(true);
+ setResult(null);
+ const r = await sendLive(req, gatewayUrl);
+ if (sendTokenRef.current !== token) return; // a newer send or a reset
superseded this one
+ setResult(r);
+ setSending(false);
+ };
+
+ return (
+ <Drawer
+ offset={0}
+ radius="md"
+ position="right"
+ size="lg"
+ opened={opened}
+ onClose={onClose}
+ closeButtonProps={{ 'aria-label': t('form.btn.cancel') }}
+ title={t('test.title')}
+ styles={{ body: { paddingTop: '12px' } }}
+ >
+ {req && (
+ <Stack gap="sm">
+ <TextInput
+ label={t('test.gatewayUrl')}
+ placeholder={t('test.gatewayUrlPlaceholder')}
+ value={gatewayUrl}
+ onChange={(e) => setGatewayUrl(e.currentTarget.value)}
+ />
+
+ <Group grow align="flex-start">
+ <Select
+ label={t('test.method')}
+ data={methodOptions}
+ value={req.method}
+ onChange={(v) => v && patch({ method: v })}
+ allowDeselect={false}
+ />
+ <TextInput
+ label={t('test.path')}
+ value={req.path}
+ onChange={(e) => patch({ path: e.currentTarget.value })}
+ {...(showWildcardHint && { description: t('test.wildcardHint')
})}
+ />
+ </Group>
+
+ <div>
+ <Group justify="space-between" mb={4}>
+ <Text size="sm" fw={500}>
+ {t('test.headers')}
+ </Text>
+ <Button
+ size="compact-xs"
+ variant="light"
+ onClick={() =>
+ patch({ headers: [...req.headers, { name: '', value: '' }] })
+ }
+ >
+ {t('test.addHeader')}
+ </Button>
+ </Group>
+ <Stack gap={6}>
+ {req.headers.map((h, i) => (
+ <Group key={i} gap={6} wrap="nowrap">
+ <TextInput
+ aria-label={`${t('test.headers')} ${i} name`}
+ placeholder="Header"
+ value={h.name}
+ onChange={(e) => setHeader(i, { name:
e.currentTarget.value })}
+ style={{ flex: 1 }}
+ />
+ <TextInput
+ aria-label={`${t('test.headers')} ${i} value`}
+ placeholder="Value"
+ value={h.value}
+ onChange={(e) => setHeader(i, { value:
e.currentTarget.value })}
+ style={{ flex: 1 }}
+ />
+ <ActionIcon
+ variant="subtle"
+ color="gray"
+ aria-label={t('test.removeHeader')}
+ onClick={() =>
+ patch({ headers: req.headers.filter((_, idx) => idx !==
i) })
+ }
+ >
+ <IconClose />
+ </ActionIcon>
+ </Group>
+ ))}
+ </Stack>
+ </div>
+
+ {showBody && (
+ <Textarea
+ label={t('test.body')}
+ autosize
+ minRows={3}
+ value={req.body}
+ onChange={(e) => patch({ body: e.currentTarget.value })}
+ />
+ )}
+
+ <div>
+ <Group justify="space-between" mb={4}>
+ <Text size="sm" fw={500}>
+ {t('test.curl')}
+ </Text>
+ <CopyButton value={curl}>
+ {({ copied, copy }) => (
+ <Button size="compact-xs" variant="light" onClick={copy}>
+ {copied ? t('test.copied') : t('test.copy')}
+ </Button>
+ )}
+ </CopyButton>
+ </Group>
+ <Code block style={{ whiteSpace: 'pre' }}>
+ {curl}
+ </Code>
+ </div>
+
+ <Group justify="flex-end">
+ <Button
+ onClick={onSend}
+ loading={sending}
+ disabled={!canSend}
+ {...(!canSend && { title: t('test.gatewayUrlRequired') })}
+ >
+ {t('test.send')}
+ </Button>
+ </Group>
+
+ {result && (
+ <div>
+ <Text size="sm" fw={500} mb={4}>
+ {t('test.response')}
+ </Text>
+ {result.ok ? (
+ <Stack gap={6}>
+ <Group gap="xs">
+ <Badge color={statusColor(result.status)}>
+ {result.status} {result.statusText}
+ </Badge>
+ <Text size="sm" c="dimmed">
+ {/* eslint-disable-next-line i18next/no-literal-string
-- "ms" is a universal unit symbol, not translatable copy */}
+ {result.durationMs} ms
+ </Text>
+ </Group>
+ {result.headers.length > 0 && (
+ <div>
+ <Button
+ size="compact-xs"
+ variant="subtle"
+ color="gray"
+ onClick={() => setHeadersOpen((o) => !o)}
+ >
+ {t('test.responseHeaders')}
+ </Button>
+ <Collapse in={headersOpen}>
+ <Code block style={{ whiteSpace: 'pre' }}>
+ {result.headers
+ .map(([k, v]) => `${k}: ${v}`)
+ .join('\n')}
+ </Code>
+ </Collapse>
+ </div>
+ )}
+ <ScrollArea.Autosize mah={300}>
+ <Code block style={{ whiteSpace: 'pre' }}>
+ {prettyBody(result.body)}
+ </Code>
+ </ScrollArea.Autosize>
+ </Stack>
+ ) : (
+ <Alert color="yellow">{t('test.blocked')}</Alert>
+ )}
+ </div>
+ )}
+ </Stack>
+ )}
+ </Drawer>
+ );
+};
diff --git a/src/components/page/RouteTestDrawer/sendLive.test.ts
b/src/components/page/RouteTestDrawer/sendLive.test.ts
new file mode 100644
index 000000000..125436185
--- /dev/null
+++ b/src/components/page/RouteTestDrawer/sendLive.test.ts
@@ -0,0 +1,54 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { sendLive } from './sendLive';
+import type { TestRequest } from './util';
+
+const req: TestRequest = {
+ method: 'GET',
+ path: '/hello',
+ headers: [{ name: 'X-A', value: '1' }],
+ body: '',
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('sendLive', () => {
+ it('returns a readable response on success', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response('{"ok":true}', {
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'application/json' },
+ })
+ )
+ );
+ const out = await sendLive(req, 'http://h:9080');
+ expect(out).toMatchObject({ ok: true, status: 200, body: '{"ok":true}' });
+ });
+
+ it('collapses any rejection to a blocked fallback', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to
fetch')));
+ const out = await sendLive(req, 'http://h:9080');
+ expect(out).toEqual({ ok: false, kind: 'blocked' });
+ });
+});
diff --git a/src/components/page/RouteTestDrawer/sendLive.ts
b/src/components/page/RouteTestDrawer/sendLive.ts
new file mode 100644
index 000000000..3b24f5b01
--- /dev/null
+++ b/src/components/page/RouteTestDrawer/sendLive.ts
@@ -0,0 +1,64 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { buildUrl, METHODS_WITH_BODY, type TestRequest } from './util';
+
+export type LiveResult =
+ | {
+ ok: true;
+ status: number;
+ statusText: string;
+ durationMs: number;
+ headers: [string, string][];
+ body: string;
+ }
+ | { ok: false; kind: 'blocked' };
+
+export const sendLive = async (
+ req: TestRequest,
+ gatewayUrl: string
+): Promise<LiveResult> => {
+ const url = buildUrl(gatewayUrl.trim(), req.path);
+ const headers: Record<string, string> = {};
+ for (const h of req.headers) {
+ if (h.name.trim()) headers[h.name] = h.value;
+ }
+ const hasBody = req.body.trim() !== '' && METHODS_WITH_BODY.has(req.method);
+ const start = performance.now();
+ try {
+ const resp = await fetch(url, {
+ method: req.method,
+ headers,
+ body: hasBody ? req.body : undefined,
+ });
+ const durationMs = Math.round(performance.now() - start);
+ const body = await resp.text();
+ const respHeaders: [string, string][] = [];
+ resp.headers.forEach((v, k) => respHeaders.push([k, v]));
+ return {
+ ok: true,
+ status: resp.status,
+ statusText: resp.statusText,
+ durationMs,
+ headers: respHeaders,
+ body,
+ };
+ } catch {
+ // CORS-blocked, unreachable, and network errors are indistinguishable
+ // here (opaque TypeError). Collapse to one honest fallback.
+ return { ok: false, kind: 'blocked' };
+ }
+};
diff --git a/src/components/page/RouteTestDrawer/util.test.ts
b/src/components/page/RouteTestDrawer/util.test.ts
new file mode 100644
index 000000000..bbf58578a
--- /dev/null
+++ b/src/components/page/RouteTestDrawer/util.test.ts
@@ -0,0 +1,105 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { buildUrl, deriveFromRoute, GATEWAY_URL_PLACEHOLDER, toCurl } from
'./util';
+
+describe('deriveFromRoute', () => {
+ it('uses the first declared method', () => {
+ const req = deriveFromRoute({ uri: '/a', methods: ['POST', 'GET'] });
+ expect(req.method).toBe('POST');
+ });
+
+ it('defaults to GET when no methods are declared (route matches all)', () =>
{
+ const req = deriveFromRoute({ uri: '/a' });
+ expect(req.method).toBe('GET');
+ });
+
+ it('prefers uri, falling back to uris[0], then "/"', () => {
+ expect(deriveFromRoute({ uri: '/x' }).path).toBe('/x');
+ expect(deriveFromRoute({ uris: ['/y', '/z'] }).path).toBe('/y');
+ expect(deriveFromRoute({}).path).toBe('/');
+ });
+
+ it('prefills a Host header from hosts[0] (or host) when present', () => {
+ expect(deriveFromRoute({ uri: '/a', hosts: ['ex.com'] }).headers).toEqual([
+ { name: 'Host', value: 'ex.com' },
+ ]);
+ expect(deriveFromRoute({ uri: '/a', host: 'h.com' }).headers).toEqual([
+ { name: 'Host', value: 'h.com' },
+ ]);
+ });
+
+ it('adds no headers when the route has no host match', () => {
+ expect(deriveFromRoute({ uri: '/a' }).headers).toEqual([]);
+ });
+
+ it('carries a wildcard uri through verbatim', () => {
+ expect(deriveFromRoute({ uri: '/api/*' }).path).toBe('/api/*');
+ });
+});
+
+describe('buildUrl', () => {
+ it('joins base and path, trimming a trailing slash and ensuring a leading
one', () => {
+ expect(buildUrl('http://h:9080/', 'hello')).toBe('http://h:9080/hello');
+ expect(buildUrl('http://h:9080', '/hello')).toBe('http://h:9080/hello');
+ });
+});
+
+describe('toCurl', () => {
+ const base = { method: 'GET', path: '/hello', headers: [], body: '' };
+
+ it('renders method, quoted url and header lines', () => {
+ const out = toCurl(
+ { ...base, headers: [{ name: 'Host', value: 'ex.com' }] },
+ 'http://127.0.0.1:9080'
+ );
+ expect(out).toBe(
+ "curl -X GET 'http://127.0.0.1:9080/hello' \\\n -H 'Host: ex.com'"
+ );
+ });
+
+ it('uses the placeholder base when gateway url is empty', () => {
+ const out = toCurl(base, ' ');
+ expect(out).toContain(`'${GATEWAY_URL_PLACEHOLDER}/hello'`);
+ });
+
+ it('escapes single quotes in values (shell single-quote rule)', () => {
+ const out = toCurl(
+ { ...base, headers: [{ name: 'X-Note', value: "a'b" }] },
+ 'http://h:9080'
+ );
+ expect(out).toContain('-H \'X-Note: a\'\\\'\'b\'');
+ });
+
+ it('appends --data-raw only for body-bearing methods with a non-empty body',
() => {
+ expect(toCurl({ ...base, method: 'POST', body: '{"a":1}' },
'http://h')).toContain(
+ '--data-raw \'{"a":1}\''
+ );
+ expect(toCurl({ ...base, method: 'GET', body: 'x' },
'http://h')).not.toContain(
+ '--data-raw'
+ );
+ });
+
+ it('skips header rows with a blank name', () => {
+ const out = toCurl(
+ { ...base, headers: [{ name: ' ', value: 'v' }] },
+ 'http://h'
+ );
+ expect(out).not.toContain('-H');
+ });
+});
diff --git a/src/components/page/RouteTestDrawer/util.ts
b/src/components/page/RouteTestDrawer/util.ts
new file mode 100644
index 000000000..6babca11e
--- /dev/null
+++ b/src/components/page/RouteTestDrawer/util.ts
@@ -0,0 +1,77 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import type { APISIXType } from '@/types/schema/apisix';
+
+export type TestHeader = { name: string; value: string };
+export type TestRequest = {
+ method: string;
+ path: string;
+ headers: TestHeader[];
+ body: string;
+};
+
+export const HTTP_METHODS = [
+ 'GET',
+ 'POST',
+ 'PUT',
+ 'DELETE',
+ 'PATCH',
+ 'HEAD',
+ 'OPTIONS',
+] as const;
+
+export const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+
+type RouteLike = Pick<
+ APISIXType['Route'],
+ 'uri' | 'uris' | 'host' | 'hosts' | 'methods'
+>;
+
+export const deriveFromRoute = (route: RouteLike): TestRequest => {
+ const methods = route.methods ?? [];
+ const method = methods.length > 0 ? methods[0] : 'GET';
+ const path = route.uri ?? route.uris?.[0] ?? '/';
+ const hosts = route.hosts ?? (route.host ? [route.host] : []);
+ const headers: TestHeader[] =
+ hosts.length > 0 ? [{ name: 'Host', value: hosts[0] }] : [];
+ return { method, path, headers, body: '' };
+};
+
+export const GATEWAY_URL_PLACEHOLDER = 'http://<gateway-host>:<port>';
+
+export const buildUrl = (gatewayUrl: string, path: string): string => {
+ const base = gatewayUrl.trim().replace(/\/+$/, '');
+ const p = path.startsWith('/') ? path : `/${path}`;
+ return `${base}${p}`;
+};
+
+const shellSingleQuote = (s: string): string =>
+ `'${s.replace(/'/g, '\'\\\'\'')}'`;
+
+export const toCurl = (req: TestRequest, gatewayUrl: string): string => {
+ const base = gatewayUrl.trim() || GATEWAY_URL_PLACEHOLDER;
+ const url = buildUrl(base, req.path);
+ const lines = [`curl -X ${req.method} ${shellSingleQuote(url)}`];
+ for (const h of req.headers) {
+ if (!h.name.trim()) continue;
+ lines.push(` -H ${shellSingleQuote(`${h.name}: ${h.value}`)}`);
+ }
+ if (req.body.trim() && METHODS_WITH_BODY.has(req.method)) {
+ lines.push(` --data-raw ${shellSingleQuote(req.body)}`);
+ }
+ return lines.join(' \\\n');
+};
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 87cac851f..a030962b4 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -384,6 +384,27 @@
"disabled": "Deaktiviert",
"enabled": "Aktiviert"
},
+ "test": {
+ "title": "Test request",
+ "entry": "Test",
+ "gatewayUrl": "Gateway URL",
+ "gatewayUrlPlaceholder": "http://127.0.0.1:9080",
+ "gatewayUrlRequired": "Set the gateway URL first",
+ "method": "Method",
+ "path": "Path",
+ "wildcardHint": "Replace the wildcard/regex with a concrete path",
+ "headers": "Headers",
+ "addHeader": "Add header",
+ "removeHeader": "Remove header",
+ "body": "Body",
+ "curl": "curl",
+ "copy": "Copy",
+ "copied": "Copied",
+ "send": "Send request",
+ "response": "Response",
+ "responseHeaders": "Headers",
+ "blocked": "The browser couldn't read a response (likely cross-origin or
unreachable). Copy the command above and run it in a terminal."
+ },
"upstreams": {
"singular": "Upstream"
},
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index ae22e2c6f..67c092583 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -384,6 +384,27 @@
"disabled": "Disabled",
"enabled": "Enabled"
},
+ "test": {
+ "title": "Test request",
+ "entry": "Test",
+ "gatewayUrl": "Gateway URL",
+ "gatewayUrlPlaceholder": "http://127.0.0.1:9080",
+ "gatewayUrlRequired": "Set the gateway URL first",
+ "method": "Method",
+ "path": "Path",
+ "wildcardHint": "Replace the wildcard/regex with a concrete path",
+ "headers": "Headers",
+ "addHeader": "Add header",
+ "removeHeader": "Remove header",
+ "body": "Body",
+ "curl": "curl",
+ "copy": "Copy",
+ "copied": "Copied",
+ "send": "Send request",
+ "response": "Response",
+ "responseHeaders": "Headers",
+ "blocked": "The browser couldn't read a response (likely cross-origin or
unreachable). Copy the command above and run it in a terminal."
+ },
"upstreams": {
"singular": "Upstream"
},
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index e62229622..f34caa921 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -384,6 +384,27 @@
"disabled": "Deshabilitado",
"enabled": "Habilitado"
},
+ "test": {
+ "title": "Test request",
+ "entry": "Test",
+ "gatewayUrl": "Gateway URL",
+ "gatewayUrlPlaceholder": "http://127.0.0.1:9080",
+ "gatewayUrlRequired": "Set the gateway URL first",
+ "method": "Method",
+ "path": "Path",
+ "wildcardHint": "Replace the wildcard/regex with a concrete path",
+ "headers": "Headers",
+ "addHeader": "Add header",
+ "removeHeader": "Remove header",
+ "body": "Body",
+ "curl": "curl",
+ "copy": "Copy",
+ "copied": "Copied",
+ "send": "Send request",
+ "response": "Response",
+ "responseHeaders": "Headers",
+ "blocked": "The browser couldn't read a response (likely cross-origin or
unreachable). Copy the command above and run it in a terminal."
+ },
"upstreams": {
"singular": "Upstream"
},
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 91909d3b7..816430647 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -384,6 +384,27 @@
"disabled": "Pasif",
"enabled": "Aktif"
},
+ "test": {
+ "title": "Test request",
+ "entry": "Test",
+ "gatewayUrl": "Gateway URL",
+ "gatewayUrlPlaceholder": "http://127.0.0.1:9080",
+ "gatewayUrlRequired": "Set the gateway URL first",
+ "method": "Method",
+ "path": "Path",
+ "wildcardHint": "Replace the wildcard/regex with a concrete path",
+ "headers": "Headers",
+ "addHeader": "Add header",
+ "removeHeader": "Remove header",
+ "body": "Body",
+ "curl": "curl",
+ "copy": "Copy",
+ "copied": "Copied",
+ "send": "Send request",
+ "response": "Response",
+ "responseHeaders": "Headers",
+ "blocked": "The browser couldn't read a response (likely cross-origin or
unreachable). Copy the command above and run it in a terminal."
+ },
"upstreams": {
"singular": "Upstream"
},
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 6a6ca3fc4..b803c02b5 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -384,6 +384,27 @@
"disabled": "已禁用",
"enabled": "已启用"
},
+ "test": {
+ "title": "测试请求",
+ "entry": "测试",
+ "gatewayUrl": "网关地址",
+ "gatewayUrlPlaceholder": "http://127.0.0.1:9080",
+ "gatewayUrlRequired": "请先填写网关地址",
+ "method": "方法",
+ "path": "路径",
+ "wildcardHint": "请将通配符/正则替换为具体路径",
+ "headers": "请求头",
+ "addHeader": "添加请求头",
+ "removeHeader": "删除请求头",
+ "body": "请求体",
+ "curl": "curl",
+ "copy": "复制",
+ "copied": "已复制",
+ "send": "发送请求",
+ "response": "响应",
+ "responseHeaders": "响应头",
+ "blocked": "浏览器无法读取响应(很可能是跨域或不可达)。请复制上面的命令到终端运行。"
+ },
"upstreams": {
"singular": "上游"
},
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index 4196e4e6e..501d982e7 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -50,6 +50,7 @@ import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
import PageHeader from '@/components/page/PageHeader';
+import { RouteTestDrawer } from '@/components/page/RouteTestDrawer';
import { API_ROUTES } from '@/config/constant';
import { req } from '@/config/req';
import { useEditCancelGuard } from '@/hooks/useEditCancelGuard';
@@ -143,36 +144,53 @@ export const RouteDetail = (props: RouteDetailProps) => {
const { id, onDeleteSuccess } = props;
const { t } = useTranslation();
const [readOnly, setReadOnly] = useBoolean(true);
+ const [testOpened, setTestOpened] = useBoolean(false);
return (
<>
<PageHeader
title={t('info.edit.title', { name: t('routes.singular') })}
+ extra={
+ <Group>
+ <Button
+ onClick={() => setTestOpened(true)}
+ size="compact-sm"
+ variant="light"
+ >
+ {t('test.entry')}
+ </Button>
+ {readOnly && (
+ <>
+ <Button
+ onClick={() => setReadOnly(false)}
+ size="compact-sm"
+ variant="gradient"
+ >
+ {t('form.btn.edit')}
+ </Button>
+ <DeleteResourceBtn
+ mode="detail"
+ name={t('routes.singular')}
+ target={id}
+ api={`${API_ROUTES}/${id}`}
+ onSuccess={onDeleteSuccess}
+ />
+ </>
+ )}
+ </Group>
+ }
{...(readOnly && {
title: t('info.detail.titleWithId', {
name: t('routes.singular'),
id,
}),
- extra: (
- <Group>
- <Button
- onClick={() => setReadOnly(false)}
- size="compact-sm"
- variant="gradient"
- >
- {t('form.btn.edit')}
- </Button>
- <DeleteResourceBtn
- mode="detail"
- name={t('routes.singular')}
- target={id}
- api={`${API_ROUTES}/${id}`}
- onSuccess={onDeleteSuccess}
- />
- </Group>
- ),
})}
/>
+ <RouteTestDrawer
+ opened={testOpened}
+ onClose={() => setTestOpened(false)}
+ id={id}
+ />
<Suspense
fallback={
<FormTOCBox>