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>

Reply via email to