From 7b0cfd7325416a989894fed1288c131b4f67e3bc Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 16 Sep 2025 23:21:18 +0800 Subject: [PATCH 01/21] add policy --- .../app-configuration/CHANGELOG.md | 11 ++--- .../src/appConfigurationClient.ts | 4 +- .../src/generated/src/models/parameters.ts | 2 +- .../src/internal/queryParamPolicy.ts | 42 +++++++++++++++++++ ...{synctokenpolicy.ts => syncTokenPolicy.ts} | 0 .../test/internal/node/http.spec.ts | 2 +- 6 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts rename sdk/appconfiguration/app-configuration/src/internal/{synctokenpolicy.ts => syncTokenPolicy.ts} (100%) diff --git a/sdk/appconfiguration/app-configuration/CHANGELOG.md b/sdk/appconfiguration/app-configuration/CHANGELOG.md index 3c57d1b1f0d5..3f671bf1ca91 100644 --- a/sdk/appconfiguration/app-configuration/CHANGELOG.md +++ b/sdk/appconfiguration/app-configuration/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added internal pipeline policy to normalize (case-insensitive alphabetical) ordering of query parameters for deterministic request URLs. + ### Breaking Changes ### Bugs Fixed @@ -13,11 +15,12 @@ ## 1.9.0 (2025-04-08) ### Features Added - - Include all the changes from 1.9.0-beta.1 version - + +- Include all the changes from 1.9.0-beta.1 version + ### Other Changes -- Update README with a link to [*`@azure/app-configuration-provider`*](https://www.npmjs.com/package/@azure/app-configuration-provider). [#33152](https://github.com/Azure/azure-sdk-for-js/pull/33152) +- Update README with a link to [_`@azure/app-configuration-provider`_](https://www.npmjs.com/package/@azure/app-configuration-provider). [#33152](https://github.com/Azure/azure-sdk-for-js/pull/33152) ## 1.9.0-beta.1 (2025-03-11) @@ -144,7 +147,6 @@ See [`listConfigurationSettings.ts`](https://github.com/Azure/azure-sdk-for-js/t ### Other Changes - Updated our `@azure/core-tracing` dependency to the latest version (1.0.0). - - Notable changes include Removal of `@opentelemetry/api` as a transitive dependency and ensuring that the active context is properly propagated. - Customers who would like to continue using OpenTelemetry driven tracing should visit our [OpenTelemetry Instrumentation](https://www.npmjs.com/package/@azure/opentelemetry-instrumentation-azure-sdk) package for instructions. @@ -181,7 +183,6 @@ See [`listConfigurationSettings.ts`](https://github.com/Azure/azure-sdk-for-js/t ### Features Added - Special configuration settings - feature flag and secret reference are now supported. 🎉 - - For types, use `ConfigurationSetting` and `ConfigurationSetting`. - Use `parseFeatureFlag` and `parseSecretReference` methods to parse the configuration settings into feature flag and secret reference respectively. diff --git a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts index f87f8537eccc..62848b9407af 100644 --- a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts +++ b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts @@ -56,7 +56,8 @@ import type { PagedAsyncIterableIterator, PagedResult } from "@azure/core-paging import { getPagedAsyncIterator } from "@azure/core-paging"; import type { PipelinePolicy, RestError } from "@azure/core-rest-pipeline"; import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; -import { SyncTokens, syncTokenPolicy } from "./internal/synctokenpolicy.js"; +import { SyncTokens, syncTokenPolicy } from "./internal/syncTokenPolicy.js"; +import { urlQueryParamsNormalizationPolicy } from "./internal/queryParamPolicy.js"; import type { TokenCredential } from "@azure/core-auth"; import { isTokenCredential } from "@azure/core-auth"; import type { @@ -195,6 +196,7 @@ export class AppConfigurationClient { internalClientPipelineOptions, ); this.client.pipeline.addPolicy(authPolicy, { phase: "Sign" }); + this.client.pipeline.addPolicy(urlQueryParamsNormalizationPolicy(), { afterPhase: "Sign" }); this.client.pipeline.addPolicy(syncTokenPolicy(this._syncTokens), { afterPhase: "Retry" }); } diff --git a/sdk/appconfiguration/app-configuration/src/generated/src/models/parameters.ts b/sdk/appconfiguration/app-configuration/src/generated/src/models/parameters.ts index 095ab9349e2b..db83490d75a3 100644 --- a/sdk/appconfiguration/app-configuration/src/generated/src/models/parameters.ts +++ b/sdk/appconfiguration/app-configuration/src/generated/src/models/parameters.ts @@ -76,7 +76,7 @@ export const apiVersion: OperationQueryParameter = { export const after: OperationQueryParameter = { parameterPath: ["options", "after"], mapper: { - serializedName: "After", + serializedName: "after", type: { name: "String", }, diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts new file mode 100644 index 000000000000..0fe09a35a58b --- /dev/null +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PipelinePolicy } from "@azure/core-rest-pipeline"; + +/** + * A policy factory for normalizing query parameters in outgoing requests. + * @internal + */ +export function urlQueryParamsNormalizationPolicy(): PipelinePolicy { + return { + name: "UrlQueryParamsNormalizationPolicy", + async sendRequest(request, next) { + const qIndex = request.url.indexOf("?"); + if (qIndex === -1) { + return next(request); + } + + const base = request.url.substring(0, qIndex); + const queryString = request.url.substring(qIndex + 1); + + if (!queryString.includes("&")) { + return next(request); + } + + const params = new URLSearchParams(queryString); + const collected: Array<{ name: string; value: string; lower: string }> = []; + for (const [name, value] of params.entries()) { + collected.push({ name, value, lower: name.toLowerCase() }); + } + if (collected.length > 1) { + collected.sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0)); + const sorted = new URLSearchParams(); + for (const p of collected) { + sorted.append(p.name, p.value); + } + Object.assign(request, { url: `${base}?${sorted.toString()}` }); + } + return next(request); + }, + }; +} diff --git a/sdk/appconfiguration/app-configuration/src/internal/synctokenpolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/syncTokenPolicy.ts similarity index 100% rename from sdk/appconfiguration/app-configuration/src/internal/synctokenpolicy.ts rename to sdk/appconfiguration/app-configuration/src/internal/syncTokenPolicy.ts diff --git a/sdk/appconfiguration/app-configuration/test/internal/node/http.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/node/http.spec.ts index b96bd4fbde1f..1c2b6e74a4ca 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/node/http.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/node/http.spec.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SyncTokens, parseSyncToken } from "../../../src/internal/synctokenpolicy.js"; +import { SyncTokens, parseSyncToken } from "../../../src/internal/syncTokenPolicy.js"; import { assertThrowsRestError, createAppConfigurationClientForTests, From 1ffceb05234a5f9923eac048f411b0b64f20b8dc Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 17 Sep 2025 15:20:47 +0800 Subject: [PATCH 02/21] add testcase --- .../src/appConfigurationClient.ts | 4 +- .../src/internal/queryParamPolicy.ts | 28 +-- .../test/internal/queryParamPolicy.spec.ts | 32 ++++ .../test/public/queryParam.spec.ts | 159 ++++++++++++++++++ 4 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts create mode 100644 sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts diff --git a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts index 62848b9407af..6f512edcd7a3 100644 --- a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts +++ b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts @@ -57,7 +57,7 @@ import { getPagedAsyncIterator } from "@azure/core-paging"; import type { PipelinePolicy, RestError } from "@azure/core-rest-pipeline"; import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; import { SyncTokens, syncTokenPolicy } from "./internal/syncTokenPolicy.js"; -import { urlQueryParamsNormalizationPolicy } from "./internal/queryParamPolicy.js"; +import { urlQueryParamNormalizationPolicy } from "./internal/queryParamPolicy.js"; import type { TokenCredential } from "@azure/core-auth"; import { isTokenCredential } from "@azure/core-auth"; import type { @@ -196,7 +196,7 @@ export class AppConfigurationClient { internalClientPipelineOptions, ); this.client.pipeline.addPolicy(authPolicy, { phase: "Sign" }); - this.client.pipeline.addPolicy(urlQueryParamsNormalizationPolicy(), { afterPhase: "Sign" }); + this.client.pipeline.addPolicy(urlQueryParamNormalizationPolicy()); this.client.pipeline.addPolicy(syncTokenPolicy(this._syncTokens), { afterPhase: "Retry" }); } diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 0fe09a35a58b..f88bb953432e 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -4,12 +4,19 @@ import type { PipelinePolicy } from "@azure/core-rest-pipeline"; /** - * A policy factory for normalizing query parameters in outgoing requests. + * A policy that normalizes query parameters for stable, canonical request URLs. + * Behavior: + * 1. All query parameter names are converted to lowercase (canonical form). + * 2. Parameters are sorted lexicographically by their lowercase names. + * 3. Relative order of duplicate names (ignoring case) is preserved (stable sort guarantee). + * + * This improves determinism for recordings and avoids casing-related cache misses. + * NOTE: Only enable if the target service treats parameter names case-insensitively. * @internal */ -export function urlQueryParamsNormalizationPolicy(): PipelinePolicy { +export function urlQueryParamNormalizationPolicy(): PipelinePolicy { return { - name: "UrlQueryParamsNormalizationPolicy", + name: "urlQueryParamNormalizationPolicy", async sendRequest(request, next) { const qIndex = request.url.indexOf("?"); if (qIndex === -1) { @@ -18,11 +25,6 @@ export function urlQueryParamsNormalizationPolicy(): PipelinePolicy { const base = request.url.substring(0, qIndex); const queryString = request.url.substring(qIndex + 1); - - if (!queryString.includes("&")) { - return next(request); - } - const params = new URLSearchParams(queryString); const collected: Array<{ name: string; value: string; lower: string }> = []; for (const [name, value] of params.entries()) { @@ -30,12 +32,12 @@ export function urlQueryParamsNormalizationPolicy(): PipelinePolicy { } if (collected.length > 1) { collected.sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0)); - const sorted = new URLSearchParams(); - for (const p of collected) { - sorted.append(p.name, p.value); - } - Object.assign(request, { url: `${base}?${sorted.toString()}` }); } + const normalized = new URLSearchParams(); + for (const p of collected) { + normalized.append(p.lower, p.value); + } + Object.assign(request, { url: `${base}?${normalized.toString()}` }); return next(request); }, }; diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts new file mode 100644 index 000000000000..109d9444358f --- /dev/null +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { urlQueryParamNormalizationPolicy } from "../../src/internal/queryParamPolicy.js"; +import { createPipelineRequest, createHttpHeaders } from "@azure/core-rest-pipeline"; +import type { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; + +function mockNext(returnStatus: number = 200) { + return async (request: PipelineRequest): Promise => { + return { + request, + headers: createHttpHeaders({ "url-lookup": request.url }), + status: returnStatus, + } as PipelineResponse; + }; +} + +describe("urlQueryParamsNormalizationPolicy", () => { + it("normalizes query parameters", async () => { + const policy = urlQueryParamNormalizationPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?api-version=2023-11-01&After=abcdefg&key=*&label=dev&$Select=key", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + console.log(finalUrl); + expect( + finalUrl.endsWith("?%24select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev"), + ).toBe(true); + }); +}); diff --git a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts new file mode 100644 index 000000000000..36f420d7f2c3 --- /dev/null +++ b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + AppConfigurationClient +} from "../../src/index.js"; +import type { Recorder } from "@azure-tools/test-recorder"; +import { isLiveMode} from "@azure-tools/test-recorder"; +import type { PipelinePolicy } from "@azure/core-rest-pipeline"; +import { + createAppConfigurationClientForTests, + startRecorder, +} from "./utils/testHelpers.js"; +import { describe, it, assert, beforeEach, afterEach } from "vitest"; + +describe("request url query parameters", () => { + let recorder: Recorder; + + beforeEach(async (ctx) => { + recorder = await startRecorder(ctx); + }); + + afterEach(async () => { + await recorder.stop(); + }); + + describe("normalize query parameters", () => { + it("sort query params in alphabetical order", async () => { + const key = recorder.variable( + "sortQueryParams", + `sortQueryParams${Math.floor(Math.random() * 1000)}`, + ); + + const { getCapturedUrl, client } = createClientWithUrlCapturePolicy(); + + await client.addConfigurationSetting({ key, label: "dev", value: "some value" }); + + const configurationSetting = await client.getConfigurationSetting( + { key, label: "dev" }, + { fields: ["key"] }, + ); + + assert.ok( + getCapturedUrl(), + "Expected to have captured a request URL for getConfigurationSetting", + ); + // Regex enforces exact ordering of query params: $select (or %24select), api-version, label + let queryOrderRegex = /\?(?:\$|%24)select=key&api-version=[^&]+&label=dev$/; + assert.match( + getCapturedUrl()!, + queryOrderRegex, + `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, + ); + + assert.equal(configurationSetting.key, key); + + const listResult = client.listConfigurationSettings({ keyFilter: "*", labelFilter: "dev" }); + + for await (const _ of listResult.byPage()) { + // do nothing, just drain the iterator + } + + // Regex enforces exact ordering of query params: api-version, key, label + queryOrderRegex = /\?api-version=[^&]+&key=\*&label=dev$/; + console.log("Captured URL for listConfigurationSettings:", getCapturedUrl()); + assert.match( + getCapturedUrl()!, + queryOrderRegex, + `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, + ); + + await client.deleteConfigurationSetting({ key, label: "dev" }); + }); + + // This occasionally hits 429 error (throttling) since we are making 100s of requests in the test to create, get and delete keys. + // To avoid hitting the service with too many requests, skipping the test in live. + // More details at https://github.com/Azure/azure-sdk-for-js/issues/16743 + // + // Remove the following line if you want to hit the live service. + it("sort query params in alphabetical order - continuation token", /* { skip: isLiveMode() }, */ async () => { + const key = recorder.variable( + "sortQueryParamsMultiplePages", + `sortQueryParamsMultiplePages${Math.floor(Math.random() * 1000)}`, + ); + + const { getCapturedUrl, client } = createClientWithUrlCapturePolicy(); + + // this number is arbitrarily chosen to match the size of a page + 1 + const expectedNumberOfLabels = 101; + + let addSettingPromises = []; + + for (let i = 0; i < expectedNumberOfLabels; i++) { + addSettingPromises.push( + client.addConfigurationSetting({ + key, + value: `the value for ${i}`, + label: i.toString(), + }), + ); + + if (i !== 0 && i % 2 === 0) { + await Promise.all(addSettingPromises); + addSettingPromises = []; + } + } + + const listResult = client.listConfigurationSettings({ + keyFilter: key, + }); + + for await (const _ of listResult.byPage()) { + // do nothing, just drain the iterator + } + + // Regex enforces exact ordering of query params for continuation page: after, api-version, key + // Note that only the request for the second page has the 'after' query param + const queryOrderRegex = new RegExp( + `\\?after=[^&]+&api-version=[^&]+&key=[^&]+$`, + ); + + assert.match( + getCapturedUrl()!, + queryOrderRegex, + `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, + ); + + for (let i = 0; i < expectedNumberOfLabels; i++) { + await client.deleteConfigurationSetting({ key, label: i.toString() }); + } + }); + + function createClientWithUrlCapturePolicy(): { + getCapturedUrl: () => string | undefined; + client: AppConfigurationClient; + } { + let capturedUrl: string | undefined; + const urlCapturePolicy: PipelinePolicy = { + name: "UrlCapturePolicy", + async sendRequest(request, next) { + capturedUrl = request.url; + return next(request); + }, + }; + + const client = createAppConfigurationClientForTests( + recorder.configureClientOptions({ + additionalPolicies: [ + { + policy: urlCapturePolicy, + position: "perRetry", + }, + ], + }), + ); + return { getCapturedUrl: () => capturedUrl, client }; + } + }); +}); From bb14d1045777f06ec369cb472a172bac475290a1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 17 Sep 2025 15:24:37 +0800 Subject: [PATCH 03/21] remove debug code --- .../app-configuration/test/internal/queryParamPolicy.spec.ts | 1 - .../app-configuration/test/public/queryParam.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 109d9444358f..225f2e49f42e 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -24,7 +24,6 @@ describe("urlQueryParamsNormalizationPolicy", () => { }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; - console.log(finalUrl); expect( finalUrl.endsWith("?%24select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev"), ).toBe(true); diff --git a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts index 36f420d7f2c3..883a1f347f5f 100644 --- a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts @@ -62,7 +62,6 @@ describe("request url query parameters", () => { // Regex enforces exact ordering of query params: api-version, key, label queryOrderRegex = /\?api-version=[^&]+&key=\*&label=dev$/; - console.log("Captured URL for listConfigurationSettings:", getCapturedUrl()); assert.match( getCapturedUrl()!, queryOrderRegex, From 042e19f6f85dde4ab8af4cf8ccab3eade155637b Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 18 Sep 2025 11:21:49 +0800 Subject: [PATCH 04/21] update recording --- sdk/appconfiguration/app-configuration/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/appconfiguration/app-configuration/assets.json b/sdk/appconfiguration/app-configuration/assets.json index f1c3e2cc87aa..190fbaa69a6e 100644 --- a/sdk/appconfiguration/app-configuration/assets.json +++ b/sdk/appconfiguration/app-configuration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "js", "TagPrefix": "js/appconfiguration/app-configuration", - "Tag": "js/appconfiguration/app-configuration_bdaf29d71a" + "Tag": "js/appconfiguration/app-configuration_8e762deb20" } From a3c09926855b3b8b71c3380feefea69f8870a930 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 18 Sep 2025 11:35:36 +0800 Subject: [PATCH 05/21] fix format --- .../test/public/queryParam.spec.ts | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts index 883a1f347f5f..cee3d3224f85 100644 --- a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts @@ -1,16 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { - AppConfigurationClient -} from "../../src/index.js"; +import type { AppConfigurationClient } from "../../src/index.js"; import type { Recorder } from "@azure-tools/test-recorder"; -import { isLiveMode} from "@azure-tools/test-recorder"; +import { isLiveMode } from "@azure-tools/test-recorder"; import type { PipelinePolicy } from "@azure/core-rest-pipeline"; -import { - createAppConfigurationClientForTests, - startRecorder, -} from "./utils/testHelpers.js"; +import { createAppConfigurationClientForTests, startRecorder } from "./utils/testHelpers.js"; import { describe, it, assert, beforeEach, afterEach } from "vitest"; describe("request url query parameters", () => { @@ -76,58 +71,60 @@ describe("request url query parameters", () => { // More details at https://github.com/Azure/azure-sdk-for-js/issues/16743 // // Remove the following line if you want to hit the live service. - it("sort query params in alphabetical order - continuation token", /* { skip: isLiveMode() }, */ async () => { - const key = recorder.variable( - "sortQueryParamsMultiplePages", - `sortQueryParamsMultiplePages${Math.floor(Math.random() * 1000)}`, - ); + it( + "sort query params in alphabetical order - continuation token", + { skip: isLiveMode() }, + async () => { + const key = recorder.variable( + "sortQueryParamsMultiplePages", + `sortQueryParamsMultiplePages${Math.floor(Math.random() * 1000)}`, + ); - const { getCapturedUrl, client } = createClientWithUrlCapturePolicy(); + const { getCapturedUrl, client } = createClientWithUrlCapturePolicy(); - // this number is arbitrarily chosen to match the size of a page + 1 - const expectedNumberOfLabels = 101; + // this number is arbitrarily chosen to match the size of a page + 1 + const expectedNumberOfLabels = 101; - let addSettingPromises = []; + let addSettingPromises = []; - for (let i = 0; i < expectedNumberOfLabels; i++) { - addSettingPromises.push( - client.addConfigurationSetting({ - key, - value: `the value for ${i}`, - label: i.toString(), - }), - ); + for (let i = 0; i < expectedNumberOfLabels; i++) { + addSettingPromises.push( + client.addConfigurationSetting({ + key, + value: `the value for ${i}`, + label: i.toString(), + }), + ); - if (i !== 0 && i % 2 === 0) { - await Promise.all(addSettingPromises); - addSettingPromises = []; + if (i !== 0 && i % 2 === 0) { + await Promise.all(addSettingPromises); + addSettingPromises = []; + } } - } - const listResult = client.listConfigurationSettings({ - keyFilter: key, - }); + const listResult = client.listConfigurationSettings({ + keyFilter: key, + }); - for await (const _ of listResult.byPage()) { - // do nothing, just drain the iterator - } + for await (const _ of listResult.byPage()) { + // do nothing, just drain the iterator + } // Regex enforces exact ordering of query params for continuation page: after, api-version, key - // Note that only the request for the second page has the 'after' query param - const queryOrderRegex = new RegExp( - `\\?after=[^&]+&api-version=[^&]+&key=[^&]+$`, - ); + // Note that only the request for the second page has the 'after' query param + const queryOrderRegex = new RegExp(`\\?after=[^&]+&api-version=[^&]+&key=[^&]+$`); - assert.match( - getCapturedUrl()!, - queryOrderRegex, - `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, - ); + assert.match( + getCapturedUrl()!, + queryOrderRegex, + `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, + ); - for (let i = 0; i < expectedNumberOfLabels; i++) { - await client.deleteConfigurationSetting({ key, label: i.toString() }); - } - }); + for (let i = 0; i < expectedNumberOfLabels; i++) { + await client.deleteConfigurationSetting({ key, label: i.toString() }); + } + }, + ); function createClientWithUrlCapturePolicy(): { getCapturedUrl: () => string | undefined; From b0f8ce2844b96005e16f850d916b46e31370ecf4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 18 Sep 2025 14:24:43 +0800 Subject: [PATCH 06/21] update --- .../app-configuration/src/internal/queryParamPolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index f88bb953432e..62f39ed419a6 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -37,7 +37,7 @@ export function urlQueryParamNormalizationPolicy(): PipelinePolicy { for (const p of collected) { normalized.append(p.lower, p.value); } - Object.assign(request, { url: `${base}?${normalized.toString()}` }); + request.url = `${base}?${normalized.toString()}`; return next(request); }, }; From 387d730bb4bb62875352435c8031370d1226caab Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 25 Sep 2025 17:32:12 +0800 Subject: [PATCH 07/21] add test for tag query --- .../app-configuration/assets.json | 2 +- .../src/appConfigurationClient.ts | 4 ++-- .../src/internal/queryParamPolicy.ts | 2 +- .../test/internal/queryParamPolicy.spec.ts | 4 ++-- .../test/public/queryParam.spec.ts | 21 ++++++++++++++++++- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/assets.json b/sdk/appconfiguration/app-configuration/assets.json index 190fbaa69a6e..2f592c8dc5c8 100644 --- a/sdk/appconfiguration/app-configuration/assets.json +++ b/sdk/appconfiguration/app-configuration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "js", "TagPrefix": "js/appconfiguration/app-configuration", - "Tag": "js/appconfiguration/app-configuration_8e762deb20" + "Tag": "js/appconfiguration/app-configuration_8382357163" } diff --git a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts index 6f512edcd7a3..be1937b3bf1f 100644 --- a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts +++ b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts @@ -57,7 +57,7 @@ import { getPagedAsyncIterator } from "@azure/core-paging"; import type { PipelinePolicy, RestError } from "@azure/core-rest-pipeline"; import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; import { SyncTokens, syncTokenPolicy } from "./internal/syncTokenPolicy.js"; -import { urlQueryParamNormalizationPolicy } from "./internal/queryParamPolicy.js"; +import { queryParamPolicy } from "./internal/queryParamPolicy.js"; import type { TokenCredential } from "@azure/core-auth"; import { isTokenCredential } from "@azure/core-auth"; import type { @@ -196,7 +196,7 @@ export class AppConfigurationClient { internalClientPipelineOptions, ); this.client.pipeline.addPolicy(authPolicy, { phase: "Sign" }); - this.client.pipeline.addPolicy(urlQueryParamNormalizationPolicy()); + this.client.pipeline.addPolicy(queryParamPolicy()); this.client.pipeline.addPolicy(syncTokenPolicy(this._syncTokens), { afterPhase: "Retry" }); } diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 62f39ed419a6..de9a3384192d 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -14,7 +14,7 @@ import type { PipelinePolicy } from "@azure/core-rest-pipeline"; * NOTE: Only enable if the target service treats parameter names case-insensitively. * @internal */ -export function urlQueryParamNormalizationPolicy(): PipelinePolicy { +export function queryParamPolicy(): PipelinePolicy { return { name: "urlQueryParamNormalizationPolicy", async sendRequest(request, next) { diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 225f2e49f42e..3c31776eec62 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from "vitest"; -import { urlQueryParamNormalizationPolicy } from "../../src/internal/queryParamPolicy.js"; +import { queryParamPolicy } from "../../src/internal/queryParamPolicy.js"; import { createPipelineRequest, createHttpHeaders } from "@azure/core-rest-pipeline"; import type { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; @@ -18,7 +18,7 @@ function mockNext(returnStatus: number = 200) { describe("urlQueryParamsNormalizationPolicy", () => { it("normalizes query parameters", async () => { - const policy = urlQueryParamNormalizationPolicy(); + const policy = queryParamPolicy(); const request = createPipelineRequest({ url: "https://example.azconfig.io/kv?api-version=2023-11-01&After=abcdefg&key=*&label=dev&$Select=key", }); diff --git a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts index cee3d3224f85..4f51f4552124 100644 --- a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts @@ -28,7 +28,12 @@ describe("request url query parameters", () => { const { getCapturedUrl, client } = createClientWithUrlCapturePolicy(); - await client.addConfigurationSetting({ key, label: "dev", value: "some value" }); + await client.addConfigurationSetting({ + key, + label: "dev", + value: "some value", + tags: { tag1: "value1" } + }); const configurationSetting = await client.getConfigurationSetting( { key, label: "dev" }, @@ -63,6 +68,20 @@ describe("request url query parameters", () => { `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, ); + const listWithTagsResult = client.listConfigurationSettings({ keyFilter: "*", labelFilter: "dev", tagsFilter: ["tag1=value1", "tag1=value1"]}); + + for await (const _ of listWithTagsResult.byPage()) { + // do nothing, just drain the iterator + } + + // Regex enforces exact ordering of query params: api-version, key, label, tags + queryOrderRegex = /\?api-version=[^&]+&key=\*&label=dev&tags=tag1%3Dvalue1&tags=tag1%3Dvalue1$/; + assert.match( + getCapturedUrl()!, + queryOrderRegex, + `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, + ); + await client.deleteConfigurationSetting({ key, label: "dev" }); }); From 1bd5a4e7c8645e0dcb73f1a0b0716690b24c962c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 29 Sep 2025 17:27:38 +0800 Subject: [PATCH 08/21] update --- .../src/internal/queryParamPolicy.ts | 101 ++++++++++++------ .../test/internal/queryParamPolicy.spec.ts | 4 +- .../test/public/queryParam.spec.ts | 11 +- 3 files changed, 80 insertions(+), 36 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index de9a3384192d..525f11e7fffa 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -1,44 +1,83 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import type { PipelinePolicy } from "@azure/core-rest-pipeline"; +import type { + PipelinePolicy, + PipelineRequest, + PipelineResponse, + SendRequest +} from "@azure/core-rest-pipeline"; /** - * A policy that normalizes query parameters for stable, canonical request URLs. - * Behavior: - * 1. All query parameter names are converted to lowercase (canonical form). - * 2. Parameters are sorted lexicographically by their lowercase names. - * 3. Relative order of duplicate names (ignoring case) is preserved (stable sort guarantee). - * - * This improves determinism for recordings and avoids casing-related cache misses. - * NOTE: Only enable if the target service treats parameter names case-insensitively. - * @internal + * Creates a PipelinePolicy that normalizes query parameters: + * - Lowercase names + * - Sort by lowercase name + * - Preserve the relative order of duplicates + * - Do not percent-encode values; keep original text */ export function queryParamPolicy(): PipelinePolicy { return { - name: "urlQueryParamNormalizationPolicy", - async sendRequest(request, next) { - const qIndex = request.url.indexOf("?"); - if (qIndex === -1) { - return next(request); - } + name: "normalizeQueryPolicy", + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + try { + const originalUrl = request.url; - const base = request.url.substring(0, qIndex); - const queryString = request.url.substring(qIndex + 1); - const params = new URLSearchParams(queryString); - const collected: Array<{ name: string; value: string; lower: string }> = []; - for (const [name, value] of params.entries()) { - collected.push({ name, value, lower: name.toLowerCase() }); - } - if (collected.length > 1) { - collected.sort((a, b) => (a.lower < b.lower ? -1 : a.lower > b.lower ? 1 : 0)); - } - const normalized = new URLSearchParams(); - for (const p of collected) { - normalized.append(p.lower, p.value); + // Separate out any hash fragment so we can keep it verbatim. + const hashIndex = originalUrl.indexOf("#"); + const beforeHash = hashIndex >= 0 ? originalUrl.slice(0, hashIndex) : originalUrl; + const hashFrag = hashIndex >= 0 ? originalUrl.slice(hashIndex) : ""; + + const qIndex = beforeHash.indexOf("?"); + if (qIndex < 0) { + return next(request); + } + + // We don't use URLSearchParams because it will do percent-encoding. + // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#percent_encoding + const base = beforeHash.slice(0, qIndex); + const queryString = beforeHash.slice(qIndex + 1); + const segments = queryString.split("&").filter((s) => s.length > 0); + + type Entry = { + rawName: string; + lowerName: string; + value: string | undefined; + index: number; + }; + + const entries: Entry[] = segments.map((seg, i) => { + const eq = seg.indexOf("="); + const rawName = eq === -1 ? seg : seg.slice(0, eq); + const value = eq === -1 ? undefined : seg.slice(eq + 1); + return { + rawName, + lowerName: rawName.toLowerCase(), + value, + index: i + }; + }); + + entries.sort((a, b) => { + if (a.lowerName < b.lowerName) return -1; + if (a.lowerName > b.lowerName) return 1; + return a.index - b.index; // stability for duplicates + }); + + const normalizedQuery = entries + .map((e) => (e.value !== undefined ? `${e.lowerName}=${e.value}` : e.lowerName)) + .join("&"); + + const newUrl = `${base}${normalizedQuery ? "?" + normalizedQuery : ""}${hashFrag}`; + + // Only update if changed (optional, but nice to avoid surprising downstream logic). + if (newUrl !== originalUrl) { + request.url = newUrl; + } + } catch { + // If anything goes wrong, fall back to sending the original request. } - request.url = `${base}?${normalized.toString()}`; + return next(request); - }, + } }; } diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 3c31776eec62..f7f32584681e 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -20,12 +20,12 @@ describe("urlQueryParamsNormalizationPolicy", () => { it("normalizes query parameters", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?api-version=2023-11-01&After=abcdefg&key=*&label=dev&$Select=key", + url: "https://example.azconfig.io/kv?api-version=2023-11-01&After=abcdefg&tags=tag3%3Dvalue3&key=*&label=dev&$Select=key&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; expect( - finalUrl.endsWith("?%24select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev"), + finalUrl.endsWith("?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1"), ).toBe(true); }); }); diff --git a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts index 4f51f4552124..6b991c4e145a 100644 --- a/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/public/queryParam.spec.ts @@ -32,7 +32,7 @@ describe("request url query parameters", () => { key, label: "dev", value: "some value", - tags: { tag1: "value1" } + tags: { tag1: "value1" }, }); const configurationSetting = await client.getConfigurationSetting( @@ -68,14 +68,19 @@ describe("request url query parameters", () => { `Query parameters not in expected order or values. URL: ${getCapturedUrl()}`, ); - const listWithTagsResult = client.listConfigurationSettings({ keyFilter: "*", labelFilter: "dev", tagsFilter: ["tag1=value1", "tag1=value1"]}); + const listWithTagsResult = client.listConfigurationSettings({ + keyFilter: "*", + labelFilter: "dev", + tagsFilter: ["tag2=value2", "tag1=value1", "tag3=value3"], + }); for await (const _ of listWithTagsResult.byPage()) { // do nothing, just drain the iterator } // Regex enforces exact ordering of query params: api-version, key, label, tags - queryOrderRegex = /\?api-version=[^&]+&key=\*&label=dev&tags=tag1%3Dvalue1&tags=tag1%3Dvalue1$/; + queryOrderRegex = + /\?api-version=[^&]+&key=\*&label=dev&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1&tags=tag3%3Dvalue3$/; assert.match( getCapturedUrl()!, queryOrderRegex, From 857c8493a210cebac7d59aa1b0add5accfb180ee Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 29 Sep 2025 17:45:34 +0800 Subject: [PATCH 09/21] update --- sdk/appconfiguration/app-configuration/assets.json | 2 +- .../app-configuration/src/internal/queryParamPolicy.ts | 6 +++--- .../test/internal/queryParamPolicy.spec.ts | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/assets.json b/sdk/appconfiguration/app-configuration/assets.json index 2f592c8dc5c8..085e3044ecf7 100644 --- a/sdk/appconfiguration/app-configuration/assets.json +++ b/sdk/appconfiguration/app-configuration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "js", "TagPrefix": "js/appconfiguration/app-configuration", - "Tag": "js/appconfiguration/app-configuration_8382357163" + "Tag": "js/appconfiguration/app-configuration_55c63bcf25" } diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 525f11e7fffa..1b1d49b4a653 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -5,7 +5,7 @@ import type { PipelinePolicy, PipelineRequest, PipelineResponse, - SendRequest + SendRequest, } from "@azure/core-rest-pipeline"; /** @@ -53,7 +53,7 @@ export function queryParamPolicy(): PipelinePolicy { rawName, lowerName: rawName.toLowerCase(), value, - index: i + index: i, }; }); @@ -78,6 +78,6 @@ export function queryParamPolicy(): PipelinePolicy { } return next(request); - } + }, }; } diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index f7f32584681e..c301c4b818ac 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -25,7 +25,9 @@ describe("urlQueryParamsNormalizationPolicy", () => { const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; expect( - finalUrl.endsWith("?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1"), + finalUrl.endsWith( + "?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", + ), ).toBe(true); }); }); From 6376b8cd0f82cb70b5f6ed5307a5f0208f6bd201 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 10 Oct 2025 14:20:56 +0800 Subject: [PATCH 10/21] update --- .../src/internal/queryParamPolicy.ts | 83 +++++++++---------- .../test/internal/queryParamPolicy.spec.ts | 40 +++++++++ 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 1b1d49b4a653..d5f6c2451c59 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -17,62 +17,52 @@ import type { */ export function queryParamPolicy(): PipelinePolicy { return { - name: "normalizeQueryPolicy", + name: "queryParamPolicy", async sendRequest(request: PipelineRequest, next: SendRequest): Promise { try { const originalUrl = request.url; - // Separate out any hash fragment so we can keep it verbatim. - const hashIndex = originalUrl.indexOf("#"); - const beforeHash = hashIndex >= 0 ? originalUrl.slice(0, hashIndex) : originalUrl; - const hashFrag = hashIndex >= 0 ? originalUrl.slice(hashIndex) : ""; + // Use URL API to decompose parts + const url = new URL(originalUrl); - const qIndex = beforeHash.indexOf("?"); - if (qIndex < 0) { + if (url.search === "") { return next(request); } - // We don't use URLSearchParams because it will do percent-encoding. - // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#percent_encoding - const base = beforeHash.slice(0, qIndex); - const queryString = beforeHash.slice(qIndex + 1); - const segments = queryString.split("&").filter((s) => s.length > 0); + const search: string = url.search.slice(1); // Remove leading '?' + // We don't use URLSearchParams because it doesn't distinguish ?param= and ?param. + const rawParams = search.split("&").filter((value) => value !== ""); - type Entry = { - rawName: string; - lowerName: string; - value: string | undefined; - index: number; - }; - - const entries: Entry[] = segments.map((seg, i) => { - const eq = seg.indexOf("="); - const rawName = eq === -1 ? seg : seg.slice(0, eq); - const value = eq === -1 ? undefined : seg.slice(eq + 1); - return { - rawName, - lowerName: rawName.toLowerCase(), - value, - index: i, - }; - }); + const params: ParamEntry[] = []; + for (const p of rawParams) { + const eq = p.indexOf("="); + if (eq >= 0) { + params.push({ + lowercaseName: p.substring(0, eq).toLowerCase(), + value: p.substring(eq), // Keep the '=' + }); + } else { + params.push({ + lowercaseName: p.toLowerCase(), + value: "", + }); + } + } - entries.sort((a, b) => { - if (a.lowerName < b.lowerName) return -1; - if (a.lowerName > b.lowerName) return 1; - return a.index - b.index; // stability for duplicates + // Modern JavaScript Array.prototype.sort is stable + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sort_stability + params.sort((a, b) => { + if (a.lowercaseName < b.lowercaseName) { + return -1; + } else if (a.lowercaseName > b.lowercaseName) { + return 1; + } + return 0; }); - const normalizedQuery = entries - .map((e) => (e.value !== undefined ? `${e.lowerName}=${e.value}` : e.lowerName)) - .join("&"); - - const newUrl = `${base}${normalizedQuery ? "?" + normalizedQuery : ""}${hashFrag}`; - - // Only update if changed (optional, but nice to avoid surprising downstream logic). - if (newUrl !== originalUrl) { - request.url = newUrl; - } + const newSearch = params.map((p) => p.lowercaseName + p.value).join("&"); + const newUrl = url.origin + url.pathname + "?" + newSearch + url.hash; + request.url = newUrl; } catch { // If anything goes wrong, fall back to sending the original request. } @@ -81,3 +71,8 @@ export function queryParamPolicy(): PipelinePolicy { }, }; } + +interface ParamEntry { + lowercaseName: string; + value: string; +} diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index c301c4b818ac..2e81f77fa393 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -30,4 +30,44 @@ describe("urlQueryParamsNormalizationPolicy", () => { ), ).toBe(true); }); + + it("keeps original order of query parameters", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?tags=tag2&api-version=2023-11-01&tags=tag1", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true); + }); + + it("does not percent-encode values", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?tags=tag=value&==", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?==&tags=tag=value")).toBe(true); + }); + + it("keeps key with no value", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?tags&api-version=2023-11-01", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?api-version=2023-11-01&tags")).toBe(true); + }); + + it("removes redundant &", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?&&&", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?")).toBe(true); + }); }); From a68e541de14f1e9c7483c0fcacc9e80dc1fd1727 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 15 Oct 2025 14:57:39 +0800 Subject: [PATCH 11/21] update --- .../src/internal/queryParamPolicy.ts | 37 +++++++------------ .../test/internal/queryParamPolicy.spec.ts | 15 ++------ 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index d5f6c2451c59..3148805258de 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -13,40 +13,22 @@ import type { * - Lowercase names * - Sort by lowercase name * - Preserve the relative order of duplicates - * - Do not percent-encode values; keep original text */ export function queryParamPolicy(): PipelinePolicy { return { name: "queryParamPolicy", async sendRequest(request: PipelineRequest, next: SendRequest): Promise { try { - const originalUrl = request.url; - - // Use URL API to decompose parts + const originalUrl: string = request.url; const url = new URL(originalUrl); if (url.search === "") { return next(request); } - const search: string = url.search.slice(1); // Remove leading '?' - // We don't use URLSearchParams because it doesn't distinguish ?param= and ?param. - const rawParams = search.split("&").filter((value) => value !== ""); - const params: ParamEntry[] = []; - for (const p of rawParams) { - const eq = p.indexOf("="); - if (eq >= 0) { - params.push({ - lowercaseName: p.substring(0, eq).toLowerCase(), - value: p.substring(eq), // Keep the '=' - }); - } else { - params.push({ - lowercaseName: p.toLowerCase(), - value: "", - }); - } + for (const [name, value] of url.searchParams.entries()) { + params.push({ lowercaseName: name.toLowerCase(), value }); } // Modern JavaScript Array.prototype.sort is stable @@ -60,11 +42,18 @@ export function queryParamPolicy(): PipelinePolicy { return 0; }); - const newSearch = params.map((p) => p.lowercaseName + p.value).join("&"); - const newUrl = url.origin + url.pathname + "?" + newSearch + url.hash; - request.url = newUrl; + const newSearchParams = new URLSearchParams(); + for (const p of params) { + newSearchParams.append(p.lowercaseName, p.value); + } + + const newUrl = url.origin + url.pathname + "?" + newSearchParams.toString() + url.hash; + if (newUrl !== originalUrl) { + request.url = newUrl; + } } catch { // If anything goes wrong, fall back to sending the original request. + console.log("Failed to normalize query parameters."); } return next(request); diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 2e81f77fa393..45f0e4aa1a0f 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -24,9 +24,10 @@ describe("urlQueryParamsNormalizationPolicy", () => { }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; + console.log(finalUrl); expect( finalUrl.endsWith( - "?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", + "?%24select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", ), ).toBe(true); }); @@ -41,16 +42,6 @@ describe("urlQueryParamsNormalizationPolicy", () => { expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true); }); - it("does not percent-encode values", async () => { - const policy = queryParamPolicy(); - const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?tags=tag=value&==", - }); - const response = await policy.sendRequest(request, mockNext()); - const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?==&tags=tag=value")).toBe(true); - }); - it("keeps key with no value", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ @@ -58,7 +49,7 @@ describe("urlQueryParamsNormalizationPolicy", () => { }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?api-version=2023-11-01&tags")).toBe(true); + expect(finalUrl.endsWith("?api-version=2023-11-01&tags=")).toBe(true); }); it("removes redundant &", async () => { From 3f0635812ebc8cd20389873167fc898b0f64def4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 11:37:21 +0800 Subject: [PATCH 12/21] update --- .../src/internal/queryParamPolicy.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 3148805258de..a72b471c906a 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -27,7 +27,8 @@ export function queryParamPolicy(): PipelinePolicy { } const params: ParamEntry[] = []; - for (const [name, value] of url.searchParams.entries()) { + for (const entry of url.search.substring(1).split("&")) { + const [name, value] = entry.split("=", 1); params.push({ lowercaseName: name.toLowerCase(), value }); } @@ -42,12 +43,11 @@ export function queryParamPolicy(): PipelinePolicy { return 0; }); - const newSearchParams = new URLSearchParams(); - for (const p of params) { - newSearchParams.append(p.lowercaseName, p.value); - } + const newSearchParams = params + .map(({ lowercaseName, value }) => `${lowercaseName}=${value}`) + .join("&"); - const newUrl = url.origin + url.pathname + "?" + newSearchParams.toString() + url.hash; + const newUrl = url.origin + url.pathname + "?" + newSearchParams + url.hash; if (newUrl !== originalUrl) { request.url = newUrl; } From b461666d4e7fc0d7521c90af9f32483d83bd6572 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 13:17:05 +0800 Subject: [PATCH 13/21] update --- .../src/internal/queryParamPolicy.ts | 2 +- .../test/internal/queryParamPolicy.spec.ts | 22 +------------------ 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index a72b471c906a..c463af05930b 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -28,7 +28,7 @@ export function queryParamPolicy(): PipelinePolicy { const params: ParamEntry[] = []; for (const entry of url.search.substring(1).split("&")) { - const [name, value] = entry.split("=", 1); + const [name, value] = entry.split("=", 2); params.push({ lowercaseName: name.toLowerCase(), value }); } diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 57606d8109ef..4bdb79d2871d 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -26,7 +26,7 @@ describe("urlQueryParamsNormalizationPolicy", () => { const finalUrl = response.headers.get("url-lookup")!; expect( finalUrl.endsWith( - "?%24select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", + "?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1", ), ).toBe(true); }); @@ -40,24 +40,4 @@ describe("urlQueryParamsNormalizationPolicy", () => { const finalUrl = response.headers.get("url-lookup")!; expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true); }); - - it("keeps key with no value", async () => { - const policy = queryParamPolicy(); - const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?tags&api-version=2023-11-01", - }); - const response = await policy.sendRequest(request, mockNext()); - const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?api-version=2023-11-01&tags=")).toBe(true); - }); - - it("removes redundant &", async () => { - const policy = queryParamPolicy(); - const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?&&&", - }); - const response = await policy.sendRequest(request, mockNext()); - const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?")).toBe(true); - }); }); From dcf63a68ca1fc10c25b92c831227bbd6b25cc58d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 13:36:29 +0800 Subject: [PATCH 14/21] update test --- sdk/appconfiguration/app-configuration/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/appconfiguration/app-configuration/assets.json b/sdk/appconfiguration/app-configuration/assets.json index 085e3044ecf7..3771e6ef9f2e 100644 --- a/sdk/appconfiguration/app-configuration/assets.json +++ b/sdk/appconfiguration/app-configuration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "js", "TagPrefix": "js/appconfiguration/app-configuration", - "Tag": "js/appconfiguration/app-configuration_55c63bcf25" + "Tag": "js/appconfiguration/app-configuration_e47e6a8bab" } From ccbb1556864824d1502c85dbeceb1c95990c3669 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 14:33:59 +0800 Subject: [PATCH 15/21] update test --- .../test/internal/queryParamPolicy.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 4bdb79d2871d..e1428a944d01 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -40,4 +40,14 @@ describe("urlQueryParamsNormalizationPolicy", () => { const finalUrl = response.headers.get("url-lookup")!; expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true); }); + + it("keeps empty value", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?api-version=2023-11-01&key=")).toBe(true); + }); }); From 5218bafc0046d54f2deb47ce78b0f4304f1d6fdb Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 14:40:12 +0800 Subject: [PATCH 16/21] handle corner case that query doesn't have = --- .../app-configuration/src/internal/queryParamPolicy.ts | 2 +- .../app-configuration/test/internal/queryParamPolicy.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index c463af05930b..3b184356193b 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -29,7 +29,7 @@ export function queryParamPolicy(): PipelinePolicy { const params: ParamEntry[] = []; for (const entry of url.search.substring(1).split("&")) { const [name, value] = entry.split("=", 2); - params.push({ lowercaseName: name.toLowerCase(), value }); + params.push({ lowercaseName: name.toLowerCase(), value: value ?? "" }); } // Modern JavaScript Array.prototype.sort is stable diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index e1428a944d01..70d6545b89a9 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -44,10 +44,10 @@ describe("urlQueryParamsNormalizationPolicy", () => { it("keeps empty value", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01", + url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01&key1", }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?api-version=2023-11-01&key=")).toBe(true); + expect(finalUrl.endsWith("?api-version=2023-11-01&key=&key1=")).toBe(true); }); }); From 4b538f55b1001e3758a8669b325c7506fc5979bc Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 15:04:25 +0800 Subject: [PATCH 17/21] handle corner case that query is empty --- .../src/internal/queryParamPolicy.ts | 3 +++ .../test/internal/queryParamPolicy.spec.ts | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index 3b184356193b..acad358a3c8a 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -28,6 +28,9 @@ export function queryParamPolicy(): PipelinePolicy { const params: ParamEntry[] = []; for (const entry of url.search.substring(1).split("&")) { + if (entry === "") { + continue; + } const [name, value] = entry.split("=", 2); params.push({ lowercaseName: name.toLowerCase(), value: value ?? "" }); } diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 70d6545b89a9..e84b11c4e639 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -41,7 +41,7 @@ describe("urlQueryParamsNormalizationPolicy", () => { expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true); }); - it("keeps empty value", async () => { + it("keeps empty parameter value", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01&key1", @@ -50,4 +50,24 @@ describe("urlQueryParamsNormalizationPolicy", () => { const finalUrl = response.headers.get("url-lookup")!; expect(finalUrl.endsWith("?api-version=2023-11-01&key=&key1=")).toBe(true); }); + + it("removes redundant &", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?b=2&&a=1", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?a=1&b=2")).toBe(true); + }); + + it("skips when no query parameters are present", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("/kv?")).toBe(true); + }); }); From 43994c3e14a01b980006b64a5d7332af4e06bf02 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 15:43:39 +0800 Subject: [PATCH 18/21] update testcase --- .../app-configuration/test/internal/queryParamPolicy.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index e84b11c4e639..990b74d70751 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -44,11 +44,11 @@ describe("urlQueryParamsNormalizationPolicy", () => { it("keeps empty parameter value", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01&key1", + url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01&key1&=", }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?api-version=2023-11-01&key=&key1=")).toBe(true); + expect(finalUrl.endsWith("?=&api-version=2023-11-01&key=&key1=")).toBe(true); }); it("removes redundant &", async () => { From 445920a0827a239e3fb1be1c06ff78c830dae49d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 16:26:23 +0800 Subject: [PATCH 19/21] update testcase --- sdk/appconfiguration/app-configuration/CHANGELOG.md | 2 +- .../test/internal/queryParamPolicy.spec.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/appconfiguration/app-configuration/CHANGELOG.md b/sdk/appconfiguration/app-configuration/CHANGELOG.md index 41bdede9d539..2a66e984e626 100644 --- a/sdk/appconfiguration/app-configuration/CHANGELOG.md +++ b/sdk/appconfiguration/app-configuration/CHANGELOG.md @@ -25,7 +25,7 @@ ### Other Changes -- Update README with a link to [_`@azure/app-configuration-provider`_](https://www.npmjs.com/package/@azure/app-configuration-provider). [#33152](https://github.com/Azure/azure-sdk-for-js/pull/33152) +- Update README with a link to [*`@azure/app-configuration-provider`*](https://www.npmjs.com/package/@azure/app-configuration-provider). [#33152](https://github.com/Azure/azure-sdk-for-js/pull/33152) ## 1.9.0-beta.1 (2025-03-11) diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 990b74d70751..dc8ac3b0ebe3 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -31,6 +31,16 @@ describe("urlQueryParamsNormalizationPolicy", () => { ).toBe(true); }); + it("keeps original encoded parameter value", async () => { + const policy = queryParamPolicy(); + const request = createPipelineRequest({ + url: "https://example.azconfig.io/kv?key=%25%20%2B", + }); + const response = await policy.sendRequest(request, mockNext()); + const finalUrl = response.headers.get("url-lookup")!; + expect(finalUrl.endsWith("?key=%25%20%2B")).toBe(true); + }); + it("keeps original order of query parameters", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ From 1fdc43a8117dae4977ba91777695dc508e2b1568 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 23 Oct 2025 17:05:00 +0800 Subject: [PATCH 20/21] update testcase --- .../app-configuration/test/internal/queryParamPolicy.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index dc8ac3b0ebe3..5b086359bb1e 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -34,11 +34,11 @@ describe("urlQueryParamsNormalizationPolicy", () => { it("keeps original encoded parameter value", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ - url: "https://example.azconfig.io/kv?key=%25%20%2B", + url: "https://example.azconfig.io/kv?key=%25%20%2B&label=%00", }); const response = await policy.sendRequest(request, mockNext()); const finalUrl = response.headers.get("url-lookup")!; - expect(finalUrl.endsWith("?key=%25%20%2B")).toBe(true); + expect(finalUrl.endsWith("?key=%25%20%2B&label=%00")).toBe(true); }); it("keeps original order of query parameters", async () => { From e35413aed829f49078d466591638501d99c1b57b Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 24 Oct 2025 15:59:18 +0800 Subject: [PATCH 21/21] update --- .../app-configuration/src/internal/queryParamPolicy.ts | 8 +++++--- .../test/internal/queryParamPolicy.spec.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts index acad358a3c8a..81026a0465c3 100644 --- a/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts +++ b/sdk/appconfiguration/app-configuration/src/internal/queryParamPolicy.ts @@ -31,8 +31,10 @@ export function queryParamPolicy(): PipelinePolicy { if (entry === "") { continue; } - const [name, value] = entry.split("=", 2); - params.push({ lowercaseName: name.toLowerCase(), value: value ?? "" }); + const equalIndex = entry.indexOf("="); + const name = equalIndex === -1 ? entry : entry.substring(0, equalIndex); + const value = equalIndex === -1 ? "" : entry.substring(equalIndex + 1); + params.push({ lowercaseName: name.toLowerCase(), value }); } // Modern JavaScript Array.prototype.sort is stable @@ -46,7 +48,7 @@ export function queryParamPolicy(): PipelinePolicy { return 0; }); - const newSearchParams = params + const newSearchParams: string = params .map(({ lowercaseName, value }) => `${lowercaseName}=${value}`) .join("&"); diff --git a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts index 5b086359bb1e..f3ae620d8e81 100644 --- a/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/internal/queryParamPolicy.spec.ts @@ -71,7 +71,7 @@ describe("urlQueryParamsNormalizationPolicy", () => { expect(finalUrl.endsWith("?a=1&b=2")).toBe(true); }); - it("skips when no query parameters are present", async () => { + it("skips when no query parameter is present", async () => { const policy = queryParamPolicy(); const request = createPipelineRequest({ url: "https://example.azconfig.io/kv?",