Skip to content

Commit a459edf

Browse files
[Azure App Configuration] - Normalize the query parameters in request url (#35962)
### Packages impacted by this PR @azure/app-configuration ### Issues associated with this PR ### Describe the problem that is addressed by this PR Adds a new pipeline to update the query params to make sure they are all lowercase and in alphabetical order. This is required for support of Azure Front Door as a CDN. ### What are the possible designs available to address the problem? If there are more than one possible design, why was the one in this PR chosen? ### Are there test cases added in this PR? _(If not, why?)_ Yes ### Provide a list of related PRs _(if any)_ ### Command used to generate this PR:**_(Applicable only to SDK release request PRs)_ ### Checklists - [x] Added impacted package name to the issue description - [ ] Does this PR needs any fixes in the SDK Generator?** _(If so, create an Issue in the [Autorest/typescript](https://github.com/Azure/autorest.typescript) repository and link it here)_ - [x] Added a changelog (if necessary)
1 parent 91d5181 commit a459edf

File tree

9 files changed

+345
-8
lines changed

9 files changed

+345
-8
lines changed

sdk/appconfiguration/app-configuration/CHANGELOG.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Added internal pipeline policy to normalize (case-insensitive alphabetical) ordering of query parameters for deterministic request URLs.
8+
79
- Support snapshot reference.
810
- New type for SnapshotReference - `ConfigurationSetting<SnapshotReferenceValue>`
911
- Upon using `getConfigurationSetting`(or add/update), use `parseSnapshotReference` methods to access the properties (to translate `ConfigurationSetting` into the type above).
@@ -18,8 +20,9 @@
1820
## 1.9.0 (2025-04-08)
1921

2022
### Features Added
21-
- Include all the changes from 1.9.0-beta.1 version
22-
23+
24+
- Include all the changes from 1.9.0-beta.1 version
25+
2326
### Other Changes
2427

2528
- 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)
@@ -149,7 +152,6 @@ See [`listConfigurationSettings.ts`](https://github.com/Azure/azure-sdk-for-js/t
149152
### Other Changes
150153

151154
- Updated our `@azure/core-tracing` dependency to the latest version (1.0.0).
152-
153155
- Notable changes include Removal of `@opentelemetry/api` as a transitive dependency and ensuring that the active context is properly propagated.
154156
- 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.
155157

@@ -186,7 +188,6 @@ See [`listConfigurationSettings.ts`](https://github.com/Azure/azure-sdk-for-js/t
186188
### Features Added
187189

188190
- Special configuration settings - feature flag and secret reference are now supported. 🎉
189-
190191
- For types, use `ConfigurationSetting<FeatureFlagValue>` and `ConfigurationSetting<SecretReferenceValue>`.
191192
- Use `parseFeatureFlag` and `parseSecretReference` methods to parse the configuration settings into feature flag and secret reference respectively.
192193

sdk/appconfiguration/app-configuration/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "js",
44
"TagPrefix": "js/appconfiguration/app-configuration",
5-
"Tag": "js/appconfiguration/app-configuration_257c4f0dd5"
5+
"Tag": "js/appconfiguration/app-configuration_e47e6a8bab"
66
}

sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import type { PagedAsyncIterableIterator, PagedResult } from "@azure/core-paging
5656
import { getPagedAsyncIterator } from "@azure/core-paging";
5757
import type { PipelinePolicy, RestError } from "@azure/core-rest-pipeline";
5858
import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline";
59-
import { SyncTokens, syncTokenPolicy } from "./internal/synctokenpolicy.js";
59+
import { SyncTokens, syncTokenPolicy } from "./internal/syncTokenPolicy.js";
60+
import { queryParamPolicy } from "./internal/queryParamPolicy.js";
6061
import type { TokenCredential } from "@azure/core-auth";
6162
import { isTokenCredential } from "@azure/core-auth";
6263
import type {
@@ -196,6 +197,7 @@ export class AppConfigurationClient {
196197
internalClientPipelineOptions,
197198
);
198199
this.client.pipeline.addPolicy(authPolicy, { phase: "Sign" });
200+
this.client.pipeline.addPolicy(queryParamPolicy());
199201
this.client.pipeline.addPolicy(syncTokenPolicy(this._syncTokens), { afterPhase: "Retry" });
200202
}
201203

sdk/appconfiguration/app-configuration/src/generated/src/models/parameters.ts

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type {
5+
PipelinePolicy,
6+
PipelineRequest,
7+
PipelineResponse,
8+
SendRequest,
9+
} from "@azure/core-rest-pipeline";
10+
11+
/**
12+
* Creates a PipelinePolicy that normalizes query parameters:
13+
* - Lowercase names
14+
* - Sort by lowercase name
15+
* - Preserve the relative order of duplicates
16+
*/
17+
export function queryParamPolicy(): PipelinePolicy {
18+
return {
19+
name: "queryParamPolicy",
20+
async sendRequest(request: PipelineRequest, next: SendRequest): Promise<PipelineResponse> {
21+
try {
22+
const originalUrl: string = request.url;
23+
const url = new URL(originalUrl);
24+
25+
if (url.search === "") {
26+
return next(request);
27+
}
28+
29+
const params: ParamEntry[] = [];
30+
for (const entry of url.search.substring(1).split("&")) {
31+
if (entry === "") {
32+
continue;
33+
}
34+
const equalIndex = entry.indexOf("=");
35+
const name = equalIndex === -1 ? entry : entry.substring(0, equalIndex);
36+
const value = equalIndex === -1 ? "" : entry.substring(equalIndex + 1);
37+
params.push({ lowercaseName: name.toLowerCase(), value });
38+
}
39+
40+
// Modern JavaScript Array.prototype.sort is stable
41+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sort_stability
42+
params.sort((a, b) => {
43+
if (a.lowercaseName < b.lowercaseName) {
44+
return -1;
45+
} else if (a.lowercaseName > b.lowercaseName) {
46+
return 1;
47+
}
48+
return 0;
49+
});
50+
51+
const newSearchParams: string = params
52+
.map(({ lowercaseName, value }) => `${lowercaseName}=${value}`)
53+
.join("&");
54+
55+
const newUrl = url.origin + url.pathname + "?" + newSearchParams + url.hash;
56+
if (newUrl !== originalUrl) {
57+
request.url = newUrl;
58+
}
59+
} catch {
60+
// If anything goes wrong, fall back to sending the original request.
61+
console.log("Failed to normalize query parameters.");
62+
}
63+
64+
return next(request);
65+
},
66+
};
67+
}
68+
69+
interface ParamEntry {
70+
lowercaseName: string;
71+
value: string;
72+
}

sdk/appconfiguration/app-configuration/test/internal/node/http.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
import { SyncTokens, parseSyncToken } from "../../../src/internal/synctokenpolicy.js";
4+
import { SyncTokens, parseSyncToken } from "../../../src/internal/syncTokenPolicy.js";
55
import {
66
assertThrowsRestError,
77
createAppConfigurationClientForTests,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it, expect } from "vitest";
5+
import { queryParamPolicy } from "../../src/internal/queryParamPolicy.js";
6+
import { createPipelineRequest, createHttpHeaders } from "@azure/core-rest-pipeline";
7+
import type { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline";
8+
9+
function mockNext(returnStatus: number = 200) {
10+
return async (request: PipelineRequest): Promise<PipelineResponse> => {
11+
return {
12+
request,
13+
headers: createHttpHeaders({ "url-lookup": request.url }),
14+
status: returnStatus,
15+
} as PipelineResponse;
16+
};
17+
}
18+
19+
describe("urlQueryParamsNormalizationPolicy", () => {
20+
it("normalizes query parameters", async () => {
21+
const policy = queryParamPolicy();
22+
const request = createPipelineRequest({
23+
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",
24+
});
25+
const response = await policy.sendRequest(request, mockNext());
26+
const finalUrl = response.headers.get("url-lookup")!;
27+
expect(
28+
finalUrl.endsWith(
29+
"?$select=key&after=abcdefg&api-version=2023-11-01&key=*&label=dev&tags=tag3%3Dvalue3&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1",
30+
),
31+
).toBe(true);
32+
});
33+
34+
it("keeps original encoded parameter value", async () => {
35+
const policy = queryParamPolicy();
36+
const request = createPipelineRequest({
37+
url: "https://example.azconfig.io/kv?key=%25%20%2B&label=%00",
38+
});
39+
const response = await policy.sendRequest(request, mockNext());
40+
const finalUrl = response.headers.get("url-lookup")!;
41+
expect(finalUrl.endsWith("?key=%25%20%2B&label=%00")).toBe(true);
42+
});
43+
44+
it("keeps original order of query parameters", async () => {
45+
const policy = queryParamPolicy();
46+
const request = createPipelineRequest({
47+
url: "https://example.azconfig.io/kv?tags=tag2&api-version=2023-11-01&tags=tag1",
48+
});
49+
const response = await policy.sendRequest(request, mockNext());
50+
const finalUrl = response.headers.get("url-lookup")!;
51+
expect(finalUrl.endsWith("?api-version=2023-11-01&tags=tag2&tags=tag1")).toBe(true);
52+
});
53+
54+
it("keeps empty parameter value", async () => {
55+
const policy = queryParamPolicy();
56+
const request = createPipelineRequest({
57+
url: "https://example.azconfig.io/kv?key=&api-version=2023-11-01&key1&=",
58+
});
59+
const response = await policy.sendRequest(request, mockNext());
60+
const finalUrl = response.headers.get("url-lookup")!;
61+
expect(finalUrl.endsWith("?=&api-version=2023-11-01&key=&key1=")).toBe(true);
62+
});
63+
64+
it("removes redundant &", async () => {
65+
const policy = queryParamPolicy();
66+
const request = createPipelineRequest({
67+
url: "https://example.azconfig.io/kv?b=2&&a=1",
68+
});
69+
const response = await policy.sendRequest(request, mockNext());
70+
const finalUrl = response.headers.get("url-lookup")!;
71+
expect(finalUrl.endsWith("?a=1&b=2")).toBe(true);
72+
});
73+
74+
it("skips when no query parameter is present", async () => {
75+
const policy = queryParamPolicy();
76+
const request = createPipelineRequest({
77+
url: "https://example.azconfig.io/kv?",
78+
});
79+
const response = await policy.sendRequest(request, mockNext());
80+
const finalUrl = response.headers.get("url-lookup")!;
81+
expect(finalUrl.endsWith("/kv?")).toBe(true);
82+
});
83+
});

0 commit comments

Comments
 (0)