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 62593c4a3 fix: stop deep-cleaning inside plugin configs, keep nested
discovery_args (#3438)
62593c4a3 is described below
commit 62593c4a3c819b2f92fc6e287f5e09af6e90735a
Author: Yuhan <[email protected]>
AuthorDate: Thu Jul 23 16:20:54 2026 +0800
fix: stop deep-cleaning inside plugin configs, keep nested discovery_args
(#3438)
---
.../form.plugin-config-nested-empties.spec.ts | 113 +++++++++++++++++++++
src/routes/upstreams/detail.$id.tsx | 2 +-
src/utils/producer.test.ts | 95 +++++++++++++++++
src/utils/producer.ts | 83 +++++++++------
4 files changed, 259 insertions(+), 34 deletions(-)
diff --git a/e2e/tests/regression/form.plugin-config-nested-empties.spec.ts
b/e2e/tests/regression/form.plugin-config-nested-empties.spec.ts
new file mode 100644
index 000000000..4623102c8
--- /dev/null
+++ b/e2e/tests/regression/form.plugin-config-nested-empties.spec.ts
@@ -0,0 +1,113 @@
+/**
+ * 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 submit pipeline deep-cleans every empty value ({} / [] / "" /
+// null), and the restore stage only brought back WHOLE plugin entries —
+// meaningful empty members inside a surviving plugin config were
+// silently removed on any edit-save, and `discovery_args: {}` on an
+// INLINE upstream was dropped (the #3376 fix only covered the upstreams
+// page's root-level field). The Admin API accepts and stores all these
+// shapes (verified live); plugin configs now pass through verbatim.
+
+import { routesPom } from '@e2e/pom/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 { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('no-op edit-save keeps empty members inside a plugin config', async ({
+ page,
+}) => {
+ const pluginConfig = {
+ empty_obj: {},
+ empty_arr: [],
+ empty_str: '',
+ kept: 'x',
+ };
+ const name = randomId('reg-nested-empty');
+ const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+ `/routes/${name}`,
+ {
+ name,
+ uri: `/reg-nested-empty/${name}`,
+ plugins: { 'key-auth': pluginConfig },
+ upstream: { type: 'roundrobin', nodes: { 'nested-empty.local:80': 1 } },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/routes/detail/$id', { id });
+ await routesPom.isDetailPage(page);
+ 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['Route'] }>(
+ `/routes/${id}`
+ );
+ expect(after.data.value.plugins?.['key-auth']).toEqual(pluginConfig);
+});
+
+test('no-op edit-save keeps discovery_args on an inline upstream', async ({
+ page,
+}) => {
+ const name = randomId('reg-inline-disc');
+ const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+ `/routes/${name}`,
+ {
+ name,
+ uri: `/reg-inline-disc/${name}`,
+ upstream: {
+ type: 'roundrobin',
+ discovery_type: 'dns',
+ service_name: 'svc.local',
+ discovery_args: {},
+ },
+ }
+ );
+ const id = res.data.value.id;
+
+ await uiGoto(page, '/routes/detail/$id', { id });
+ await routesPom.isDetailPage(page);
+ 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['Route'] }>(
+ `/routes/${id}`
+ );
+ const upstream = after.data.value.upstream as Record<string, unknown>;
+ expect(upstream.discovery_type).toBe('dns');
+ expect(upstream.discovery_args).toEqual({});
+});
diff --git a/src/routes/upstreams/detail.$id.tsx
b/src/routes/upstreams/detail.$id.tsx
index cdd8a3d90..e05c66bbe 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -91,7 +91,7 @@ const UpstreamDetailForm = (
const putUpstream = useMutation({
mutationFn: (d: APISIXType['Upstream']) => {
// Merge original discovery_args into form data before processing,
- // so pipeProduce's produceRestoreEmptyPlugins can restore
discovery_args: {}
+ // so pipeProduce's produceCleanPreservingUserValues can restore
discovery_args: {}
// even if the field was not touched (and thus absent from d).
const merged = {
...d,
diff --git a/src/utils/producer.test.ts b/src/utils/producer.test.ts
index 224530992..3d8617f8a 100644
--- a/src/utils/producer.test.ts
+++ b/src/utils/producer.test.ts
@@ -76,4 +76,99 @@ describe('pipeProduce', () => {
// owned by the existing null-cleaner / empty-plugin-restore stages
expect(produced?.plugins['key-auth']).toBeTruthy();
});
+
+ // Regression for #3417 D2: plugin configs are user-authored JSON — the
+ // deep-clean silently removed meaningful empty members ({} / [] / "" /
+ // null) from configs that partially survived cleaning (the old restore
+ // only brought back WHOLE plugin entries). The gateway is the only
+ // judge of such members: loose-schema plugins accept and store them
+ // (verified live, 201), strict ones reject with a descriptive 400.
+ it('passes plugin configs through verbatim, empties included', () => {
+ const plugins = {
+ 'key-auth': {
+ empty_obj: {},
+ empty_arr: [],
+ empty_str: '',
+ note: null,
+ kept: 'x',
+ },
+ };
+ const val = {
+ uri: '/r2',
+ plugins,
+ // array-form nodes: this shape (the add-page default) once tripped
+ // an immer set-trap crash when the restore stage assigned the
+ // original base reference back into the draft — keep it here
+ upstream: {
+ type: 'roundrobin',
+ nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+ },
+ };
+ const produced = pipeProduce()(val);
+ expect(produced.plugins).toEqual(plugins);
+ });
+
+ // #3438 review (LiteSun): the __-key cleaner runs over the whole draft
+ // before the plugins subtree is snapshotted, so a plugin config field
+ // that legitimately starts with __ was deleted — the "verbatim" claim
+ // did not hold. Plugin JSON is gateway-owned; nothing in it is a UI flag.
+ it('passes __-prefixed plugin config fields through verbatim', () => {
+ const plugins = { 'my-plugin': { __mode: 'strict', on: true } };
+ const val = {
+ uri: '/r-uu',
+ plugins,
+ upstream: {
+ type: 'roundrobin',
+ nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+ },
+ };
+ const produced = pipeProduce()(val);
+ expect(produced.plugins).toEqual(plugins);
+ });
+
+ it('restores discovery_args on an inline upstream', () => {
+ const val = {
+ uri: '/r3',
+ upstream: {
+ type: 'roundrobin',
+ discovery_type: 'dns',
+ service_name: 'svc.local',
+ discovery_args: {},
+ },
+ };
+ const produced = pipeProduce()(val);
+ expect(produced.upstream.discovery_args).toEqual({});
+ });
+
+ // the routes detail page composes produceRoute — itself a pipeProduce —
+ // as a stage of another pipeProduce, so inner stages receive the outer
+ // DRAFT instead of a plain value; a restore stage that closed over the
+ // pipeline input crashed exactly here (immer set trap / structuredClone
+ // on a live proxy). Keep this composition pinned.
+ it('survives nested pipeProduce composition with plugins verbatim', () => {
+ const plugins = { 'basic-auth': { hide_credentials: true, e: {} } };
+ const val = {
+ uri: '/r4',
+ plugins,
+ upstream: {
+ type: 'roundrobin',
+ nodes: [{ host: 'a.local', port: 80, weight: 1 }],
+ },
+ };
+ const inner = pipeProduce();
+ const produced = pipeProduce((v: object) => inner(v))(val);
+ expect(produced.plugins).toEqual(plugins);
+ });
+
+ it('still restores discovery_args at the root (upstreams page)', () => {
+ const val = {
+ name: 'u1',
+ type: 'roundrobin',
+ discovery_type: 'dns',
+ service_name: 'svc.local',
+ discovery_args: {},
+ };
+ const produced = pipeProduce()(val);
+ expect(produced.discovery_args).toEqual({});
+ });
});
diff --git a/src/utils/producer.ts b/src/utils/producer.ts
index ced4ff78d..f9785b4e2 100644
--- a/src/utils/producer.ts
+++ b/src/utils/producer.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { clean, type ICleanerOptions } from 'fast-clean';
-import { produce } from 'immer';
+import { current, isDraft, produce } from 'immer';
import { pipe } from 'rambdax';
import { produceTime } from './form-producer';
@@ -35,35 +35,56 @@ export const produceDeepCleanEmptyKeys = (opts:
ICleanerOptions = {}) =>
deepCleanEmptyKeys(draft, opts);
});
+/** plain deep snapshot of a value that may be an immer draft */
+const snapshot = <T>(v: T): T => (isDraft(v) ? (current(v as object) as T) :
v);
+
+const isEmptyObject = (v: unknown): v is object =>
+ !!v && typeof v === 'object' && Object.keys(v).length === 0;
+
/**
- * Preserves plugin entries with empty config ({}) after deep cleaning.
- * APISIX plugins like key-auth have no required fields and are valid with {}.
- * deepCleanEmptyKeys would strip them, so we restore them from the original.
+ * Deep-clean the draft while preserving values the cleaner must not own
+ * (#3417):
+ *
+ * - plugin configs are user-authored JSON, detached before the clean and
+ * reattached VERBATIM: the gateway is the only judge of empty members
+ * ({} / [] / "" / null) — loose-schema plugins accept and store them,
+ * strict ones reject with a descriptive 400 (both verified against a
+ * live Admin API). The old whole-entry-only restore silently removed
+ * empties inside partially-surviving configs (#3269/#3277 siblings).
+ * - discovery_args: {} is meaningful and accepted by the gateway;
+ * preserved at the root (upstreams page, #3376) and on an inline
+ * upstream (routes/services — the #3376 fix missed the nested case).
+ *
+ * Everything happens inside ONE draft with plain snapshots — a restore
+ * stage that closed over the pipeline's original value crashed under
+ * nested pipeProduce composition (the routes detail page composes
+ * produceRoute, itself a pipeProduce, as a stage of another pipeProduce,
+ * so inner stages receive the outer draft, not a plain value).
+ *
+ * The `__`-prefixed UI-flag removal (form-only fields like __checksEnabled)
+ * also runs here, AFTER the plugins subtree is detached — otherwise a
+ * plugin config field that legitimately begins with `__` would be
+ * stripped, breaking the verbatim contract (#3438 review).
*/
-export const produceRestoreEmptyPlugins = (original: object) =>
+export const produceCleanPreservingUserValues = (opts: ICleanerOptions = {}) =>
produce((draft: Record<string, unknown>) => {
- const orig = original as Record<string, unknown>;
- if (orig.plugins && typeof orig.plugins === 'object') {
- const origPlugins = orig.plugins as Record<string, unknown>;
- const draftPlugins = (draft.plugins ?? {}) as Record<string, unknown>;
- Object.keys(origPlugins).forEach((name) => {
- if (!(name in draftPlugins)) {
- draftPlugins[name] = origPlugins[name];
- }
- });
- if (Object.keys(draftPlugins).length > 0) {
- draft.plugins = draftPlugins;
- }
+ const plugins = snapshot(draft.plugins);
+ const rootDiscoveryArgs = snapshot(draft.discovery_args);
+ const upstreamDiscoveryArgs = snapshot(
+ (draft.upstream as Record<string, unknown> | undefined)?.discovery_args
+ );
+ delete draft.plugins;
+ rmDoubleUnderscoreKeys(draft);
+ deepCleanEmptyKeys(draft, opts);
+ if (plugins && typeof plugins === 'object') {
+ draft.plugins = plugins;
}
- // Restore discovery_args: {} if it was present in the original.
- // APISIX accepts empty discovery_args and deepCleanEmptyKeys would strip
it.
- if (
- 'discovery_args' in orig &&
- orig.discovery_args !== null &&
- typeof orig.discovery_args === 'object' &&
- Object.keys(orig.discovery_args as object).length === 0
- ) {
- (draft as Record<string, unknown>).discovery_args = {};
+ if (isEmptyObject(rootDiscoveryArgs)) {
+ draft.discovery_args = {};
+ }
+ const upstream = draft.upstream as Record<string, unknown> | undefined;
+ if (upstream && isEmptyObject(upstreamDiscoveryArgs)) {
+ upstream.discovery_args = {};
}
});
@@ -84,10 +105,6 @@ export const rmDoubleUnderscoreKeys = (obj: object) => {
return obj;
};
-export const produceRmDoubleUnderscoreKeys = produce((draft) => {
- rmDoubleUnderscoreKeys(draft);
-});
-
/**
* FIXME: type error
*/
@@ -100,10 +117,10 @@ export const pipeProduce = (...funcs: ((a: any) =>
unknown)[]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
...fs,
- produceRmDoubleUnderscoreKeys,
produceTime,
- produceDeepCleanEmptyKeys(),
- produceRestoreEmptyPlugins(val as object)
+ // __-flag removal happens inside this stage, after the plugins
+ // subtree is detached (see #3438 review)
+ produceCleanPreservingUserValues()
)(draft) as never;
}) as T;
};