Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7b0cfd7
add policy
zhiyuanliang-ms Sep 16, 2025
1ffceb0
add testcase
zhiyuanliang-ms Sep 17, 2025
bb14d10
remove debug code
zhiyuanliang-ms Sep 17, 2025
d81b70a
Merge branch 'main' of https://github.com/zhiyuanliang-ms/azure-sdk-f…
zhiyuanliang-ms Sep 18, 2025
042e19f
update recording
zhiyuanliang-ms Sep 18, 2025
a3c0992
fix format
zhiyuanliang-ms Sep 18, 2025
b0f8ce2
update
zhiyuanliang-ms Sep 18, 2025
aca2dd9
Merge branch 'main' of https://github.com/zhiyuanliang-ms/azure-sdk-f…
zhiyuanliang-ms Sep 22, 2025
387d730
add test for tag query
zhiyuanliang-ms Sep 25, 2025
1bd5a4e
update
zhiyuanliang-ms Sep 29, 2025
141e4f7
Merge branch 'main' of https://github.com/zhiyuanliang-ms/azure-sdk-f…
zhiyuanliang-ms Sep 29, 2025
857c849
update
zhiyuanliang-ms Sep 29, 2025
6376b8c
update
zhiyuanliang-ms Oct 10, 2025
a68e541
update
zhiyuanliang-ms Oct 15, 2025
3f06358
update
zhiyuanliang-ms Oct 23, 2025
ee92713
Merge branch 'main' of https://github.com/zhiyuanliang-ms/azure-sdk-f…
zhiyuanliang-ms Oct 23, 2025
b461666
update
zhiyuanliang-ms Oct 23, 2025
dcf63a6
update test
zhiyuanliang-ms Oct 23, 2025
ccbb155
update test
zhiyuanliang-ms Oct 23, 2025
5218baf
handle corner case that query doesn't have =
zhiyuanliang-ms Oct 23, 2025
4b538f5
handle corner case that query is empty
zhiyuanliang-ms Oct 23, 2025
43994c3
update testcase
zhiyuanliang-ms Oct 23, 2025
445920a
update testcase
zhiyuanliang-ms Oct 23, 2025
1fdc43a
update testcase
zhiyuanliang-ms Oct 23, 2025
e35413a
update
zhiyuanliang-ms Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions sdk/appconfiguration/app-configuration/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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<FeatureFlagValue>` and `ConfigurationSetting<SecretReferenceValue>`.
- Use `parseFeatureFlag` and `parseSecretReference` methods to parse the configuration settings into feature flag and secret reference respectively.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { urlQueryParamNormalizationPolicy } from "./internal/queryParamPolicy.js";
import type { TokenCredential } from "@azure/core-auth";
import { isTokenCredential } from "@azure/core-auth";
import type {
Expand Down Expand Up @@ -195,6 +196,7 @@ export class AppConfigurationClient {
internalClientPipelineOptions,
);
this.client.pipeline.addPolicy(authPolicy, { phase: "Sign" });
this.client.pipeline.addPolicy(urlQueryParamNormalizationPolicy());
this.client.pipeline.addPolicy(syncTokenPolicy(this._syncTokens), { afterPhase: "Retry" });
}

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { PipelinePolicy } 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
*/
export function urlQueryParamNormalizationPolicy(): PipelinePolicy {
return {
name: "urlQueryParamNormalizationPolicy",
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);
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);
}
Object.assign(request, { url: `${base}?${normalized.toString()}` });
return next(request);
},
};
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PipelineResponse> => {
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);
});
});
Original file line number Diff line number Diff line change
@@ -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 };
}
});
});
Loading