This is an automated email from the ASF dual-hosted git repository.
guoqqqi 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 ba6e7c0a5 fix: unify stream-route submit pipelines, drop phantom
plugin_config_id (#3437)
ba6e7c0a5 is described below
commit ba6e7c0a5395f795eef81789c06ade0ff7f029f9
Author: Yuhan <[email protected]>
AuthorDate: Mon Jul 27 11:09:11 2026 +0800
fix: unify stream-route submit pipelines, drop phantom plugin_config_id
(#3437)
---
...ream-routes.no-phantom-plugin-config-id.spec.ts | 107 +++++++++++++++++++++
src/components/form-slice/FormPartRoute/index.tsx | 23 +++--
.../form-slice/FormPartStreamRoute/index.tsx | 4 +-
.../form-slice/FormPartStreamRoute/util.test.ts | 93 ++++++++++++++++++
.../form-slice/FormPartStreamRoute/util.ts | 42 ++++----
src/routes/stream_routes/detail.$id.tsx | 4 +-
src/types/schema/apisix/stream_routes.ts | 5 +-
7 files changed, 248 insertions(+), 30 deletions(-)
diff --git
a/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts
b/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts
new file mode 100644
index 000000000..8df71a30c
--- /dev/null
+++ b/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts
@@ -0,0 +1,107 @@
+/**
+ * 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.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// the stream-route form reused the HTTP-route plugins section, which
+// renders a "Plugin Config ID" input — a field the stream_routes
+// resource does not have (the Admin API rejects it with 400, and the
+// dashboard's zod resolver strips it before the request): anything the
+// user typed there was silently discarded on a "successful" save.
+
+import { routesPom } from '@e2e/pom/routes';
+import { streamRoutesPom } from '@e2e/pom/stream_routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllStreamRoutes } from '@/apis/stream_routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+ await deleteAllStreamRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllStreamRoutes(e2eReq);
+});
+
+test('stream route forms do not offer the unsupported Plugin Config ID', async
({
+ page,
+}) => {
+ await streamRoutesPom.toAdd(page);
+ await streamRoutesPom.isAddPage(page);
+ // the plugins section itself must stay
+ await expect(
+ page.getByRole('button', { name: 'Select Plugins' })
+ ).toBeVisible();
+ await expect(page.getByLabel('Plugin Config ID')).toHaveCount(0);
+
+ const name = randomId('reg-no-pcid');
+ const res = await e2eReq.put<{ value: APISIXType['StreamRoute'] }>(
+ `/stream_routes/${name}`,
+ {
+ server_port: 9100,
+ upstream: { type: 'roundrobin', nodes: { 'no-pcid.local:80': 1 } },
+ }
+ );
+ await uiGoto(page, '/stream_routes/detail/$id', { id: res.data.value.id });
+ await streamRoutesPom.isDetailPage(page);
+ await expect(page.getByLabel('Plugin Config ID')).toHaveCount(0);
+});
+
+// #3437 review: the Admin API accepts a stream-route name, so it must
+// survive a dashboard edit-save instead of being deleted by the producer.
+test('a stream route keeps its name across a no-op edit-save', async ({
+ page,
+}) => {
+ const id = randomId('reg-sr-name');
+ const name = `sr name ${id}`;
+ const res = await e2eReq.put<{ value: APISIXType['StreamRoute'] }>(
+ `/stream_routes/${id}`,
+ {
+ name,
+ server_port: 9100,
+ upstream: { type: 'roundrobin', nodes: { 'sr-name.local:80': 1 } },
+ }
+ );
+
+ await uiGoto(page, '/stream_routes/detail/$id', { id: res.data.value.id });
+ await streamRoutesPom.isDetailPage(page);
+ // the name must render (form now shows it) and survive edit-save
+ await expect(page.locator('input[name="name"]')).toHaveValue(name);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expect(
+ page.getByRole('alert').filter({ hasText: /success/i })
+ ).toBeVisible();
+
+ const after = await e2eReq.get<{ value: APISIXType['StreamRoute'] }>(
+ `/stream_routes/${res.data.value.id}`
+ );
+ expect((after.data.value as { name?: string }).name).toBe(name);
+});
+
+test('the HTTP route form still offers Plugin Config ID', async ({ page }) => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+ await routesPom.getAddRouteBtn(page).click();
+ await routesPom.isAddPage(page);
+ await expect(page.getByLabel('Plugin Config ID')).toBeVisible();
+});
diff --git a/src/components/form-slice/FormPartRoute/index.tsx
b/src/components/form-slice/FormPartRoute/index.tsx
index bb483eba0..9d3638954 100644
--- a/src/components/form-slice/FormPartRoute/index.tsx
+++ b/src/components/form-slice/FormPartRoute/index.tsx
@@ -125,17 +125,26 @@ export const FormSectionUpstream = () => {
);
};
-export const FormSectionPlugins = () => {
+export type FormSectionPluginsProps = {
+ /** stream_routes has no plugin_config_id — the Admin API rejects it */
+ showConfigId?: boolean;
+};
+export const FormSectionPlugins = (props: FormSectionPluginsProps) => {
+ const { showConfigId = true } = props;
const { t } = useTranslation();
const { control } = useFormContext<RoutePostType>();
return (
<FormSection legend={t('form.plugins.label')}>
- <FormItemTextInput
- control={control}
- name="plugin_config_id"
- label={t('form.plugins.configId')}
- />
- <Divider my="xs" label={t('or')} />
+ {showConfigId && (
+ <>
+ <FormItemTextInput
+ control={control}
+ name="plugin_config_id"
+ label={t('form.plugins.configId')}
+ />
+ <Divider my="xs" label={t('or')} />
+ </>
+ )}
<FormItemPlugins name="plugins" />
</FormSection>
);
diff --git a/src/components/form-slice/FormPartStreamRoute/index.tsx
b/src/components/form-slice/FormPartStreamRoute/index.tsx
index 5c0bdf5ae..4614776be 100644
--- a/src/components/form-slice/FormPartStreamRoute/index.tsx
+++ b/src/components/form-slice/FormPartStreamRoute/index.tsx
@@ -97,11 +97,11 @@ const FormSectionStreamRouteProtocol = () => {
export const FormPartStreamRoute = () => {
return (
<>
- <FormPartBasic showName={false} />
+ <FormPartBasic />
<FormSectionStreamRouteBasic />
<FormSectionService />
<FormSectionUpstream />
- <FormSectionPlugins />
+ <FormSectionPlugins showConfigId={false} />
<FormSectionStreamRouteProtocol />
</>
);
diff --git a/src/components/form-slice/FormPartStreamRoute/util.test.ts
b/src/components/form-slice/FormPartStreamRoute/util.test.ts
new file mode 100644
index 000000000..b47e27bb9
--- /dev/null
+++ b/src/components/form-slice/FormPartStreamRoute/util.test.ts
@@ -0,0 +1,93 @@
+/**
+ * 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 type { StreamRoutePostType } from './schema';
+import { produceStreamRoute } from './util';
+
+// Regression for a data-integrity item of #3417: stream routes were the
+// only resource whose create and edit paths ran DIFFERENT cleaning
+// pipelines — create used a bare pipe without pipeProduce (no __-key
+// removal, no empty-value cleaning, no empty-plugin restore; only the
+// zod resolver's key-stripping stood between UI flags and the Admin API,
+// which rejects unknown root keys with 400), while edit borrowed the
+// HTTP-route producer. produceStreamRoute now wraps pipeProduce and is
+// used by both paths.
+
+const base = {
+ server_port: 9100,
+ upstream: { type: 'roundrobin', nodes: { 'a.local:80': 1 } },
+} as unknown as StreamRoutePostType;
+
+describe('produceStreamRoute', () => {
+ it('strips __-prefixed UI flags', () => {
+ const val = {
+ ...base,
+ __checksEnabled: true,
+ upstream: {
+ ...base.upstream,
+ __checksPassiveEnabled: false,
+ },
+ } as unknown as StreamRoutePostType;
+ const out = produceStreamRoute(val) as Record<string, unknown>;
+ expect('__checksEnabled' in out).toBe(false);
+ expect(
+ '__checksPassiveEnabled' in (out.upstream as Record<string, unknown>)
+ ).toBe(false);
+ });
+
+ it('cleans empty-string fields', () => {
+ const val = { ...base, desc: '' } as unknown as StreamRoutePostType;
+ const out = produceStreamRoute(val) as Record<string, unknown>;
+ expect('desc' in out).toBe(false);
+ });
+
+ it('preserves plugins with empty config', () => {
+ const val = {
+ ...base,
+ plugins: { 'key-auth': {} },
+ } as unknown as StreamRoutePostType;
+ const out = produceStreamRoute(val) as Record<string, unknown>;
+ expect(out.plugins).toEqual({ 'key-auth': {} });
+ });
+
+ // #3437 review (LiteSun): the Admin API accepts a stream-route `name`
+ // (201) but rejects `status` (400). The producer must preserve name so
+ // an API-created named stream route does not lose its name on an
+ // edit-save, while still stripping the unsupported status.
+ it('keeps name, strips status, and drops empty protocol', () => {
+ const val = {
+ ...base,
+ name: 'n1',
+ status: 1,
+ protocol: { conf: {} },
+ } as unknown as StreamRoutePostType;
+ const out = produceStreamRoute(val) as Record<string, unknown>;
+ expect(out.name).toBe('n1');
+ expect('status' in out).toBe(false);
+ expect('protocol' in out).toBe(false);
+ });
+
+ it('still drops the inline upstream when a reference id is present', () => {
+ const val = {
+ ...base,
+ upstream_id: 'u1',
+ } as unknown as StreamRoutePostType;
+ const out = produceStreamRoute(val) as Record<string, unknown>;
+ expect('upstream' in out).toBe(false);
+ });
+});
diff --git a/src/components/form-slice/FormPartStreamRoute/util.ts
b/src/components/form-slice/FormPartStreamRoute/util.ts
index 1b26d84dd..a2f8237b5 100644
--- a/src/components/form-slice/FormPartStreamRoute/util.ts
+++ b/src/components/form-slice/FormPartStreamRoute/util.ts
@@ -15,28 +15,34 @@
* limitations under the License.
*/
import { produce } from 'immer';
-import { pipe } from 'rambdax';
import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
+import { pipeProduce } from '@/utils/producer';
import type { StreamRoutePostType } from './schema';
-export const produceStreamRoute = (val: StreamRoutePostType) =>
- pipe(
- produceRmEmptyUpstreamFields,
- (produceRmUpstreamWhenHas('service_id', 'upstream_id') as unknown as (
- d: StreamRoutePostType
- ) => StreamRoutePostType),
- produce((draft: StreamRoutePostType) => {
- // Stream Routes do not support name and status
- const d = draft as StreamRoutePostType & { name?: string; status?:
number };
- delete d.name;
- delete d.status;
+/**
+ * Shared by BOTH the create and edit paths — stream routes used to be the
+ * only resource whose two paths ran different cleaning pipelines (create
+ * skipped pipeProduce entirely; edit borrowed the HTTP-route producer).
+ * pipeProduce supplies the __-flag removal, empty-value cleaning and
+ * empty-plugin restore every other resource gets (#3417).
+ */
+export const produceStreamRoute = pipeProduce(
+ produceRmUpstreamWhenHas('service_id', 'upstream_id'),
+ produceRmEmptyUpstreamFields,
+ produce((draft: StreamRoutePostType) => {
+ // The Admin API accepts a stream-route `name` (kept — the form renders
+ // it, and an API-created name must survive an edit-save, #3437 review)
+ // but rejects `status` (stripped; the form never renders it, but a
+ // reused generic component could introduce it).
+ const d = draft as StreamRoutePostType & { status?: number };
+ delete d.status;
- // Cleanup protocol if name is missing
- if (draft.protocol && !draft.protocol.name) {
- delete draft.protocol;
- }
- })
- )(val);
+ // Cleanup protocol if name is missing
+ if (draft.protocol && !draft.protocol.name) {
+ delete draft.protocol;
+ }
+ })
+);
diff --git a/src/routes/stream_routes/detail.$id.tsx
b/src/routes/stream_routes/detail.$id.tsx
index 6682a7a65..3b66a7618 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -31,8 +31,8 @@ import { useBoolean } from 'react-use';
import { getStreamRouteQueryOptions } from '@/apis/hooks';
import { putStreamRouteReq } from '@/apis/stream_routes';
import { FormSubmitBtn } from '@/components/form/Btn';
-import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
import { FormPartStreamRoute } from
'@/components/form-slice/FormPartStreamRoute';
+import { produceStreamRoute } from
'@/components/form-slice/FormPartStreamRoute/util';
import { produceToNestedUpstreamForm } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
@@ -79,7 +79,7 @@ const StreamRouteDetailForm = (props: Props) => {
const putStreamRoute = useMutation({
mutationFn: (d: APISIXType['StreamRoute']) =>
- putStreamRouteReq(req, produceRoute(d)),
+ putStreamRouteReq(req, produceStreamRoute(d)),
async onSuccess() {
notifications.show({
message: t('info.edit.success', { name: t('streamRoutes.singular') }),
diff --git a/src/types/schema/apisix/stream_routes.ts
b/src/types/schema/apisix/stream_routes.ts
index 77757abe0..c1f75d776 100644
--- a/src/types/schema/apisix/stream_routes.ts
+++ b/src/types/schema/apisix/stream_routes.ts
@@ -45,7 +45,10 @@ const StreamRoute = z
protocol: StreamRouteProtocol.partial().optional(),
})
.partial()
- .merge(APISIXCommon.Basic.omit({ name: true, status: true }))
+ // the Admin API accepts name/desc/labels on a stream route but rejects
+ // status — omit only status so the edit form (which resolves against
+ // this schema) does not strip a stored name (#3437 review)
+ .merge(APISIXCommon.Basic.omit({ status: true }))
.merge(APISIXCommon.Info);
export const APISIXStreamRoutes = {