Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
30c39e1
SortsQueryParams
mrm9084 Sep 11, 2025
2a863f2
Update CHANGELOG.md
mrm9084 Sep 11, 2025
c6e085f
Update QueryParamPolicy.java
mrm9084 Sep 11, 2025
1c451cf
Fixing tags
mrm9084 Sep 12, 2025
cd062ee
Style fixes
mrm9084 Sep 12, 2025
edbecab
Update QueryParamPolicyTest.java
mrm9084 Sep 12, 2025
858ae3c
Update QueryParamPolicyTest.java
mrm9084 Sep 12, 2025
387731f
Update QueryParamPolicyTest.java
mrm9084 Sep 12, 2025
d73ca61
Update assets.json
mrm9084 Oct 9, 2025
0efdeb4
review items
mrm9084 Oct 9, 2025
e0d02bb
Merge branch 'main' into QueryParamaterPipeline
mrm9084 Oct 9, 2025
404b483
Update QueryParamPolicy.java
mrm9084 Oct 9, 2025
7193a4a
Merge branch 'QueryParamaterPipeline' of https://github.com/mrm9084/a…
mrm9084 Oct 9, 2025
3cbc14e
Update QueryParamPolicy.java
mrm9084 Oct 13, 2025
aa6a387
Update QueryParamPolicy.java
mrm9084 Oct 13, 2025
267caf3
Update QueryParamPolicy.java
mrm9084 Oct 13, 2025
e1907f1
Updating assets.json for azure-monitor-query
jairmyree Oct 22, 2025
dd05f82
updating test files to use TME subscription
jairmyree Oct 22, 2025
b1570ec
Updating assets.json for azure-monitor-query-metrics
jairmyree Oct 27, 2025
5d43d7b
Changing monitor app-config dependency to current to pull new changes
jairmyree Oct 27, 2025
3255ca4
Changing monitor app-config dependencies
jairmyree Oct 27, 2025
110b832
updating assets.json
jairmyree Oct 27, 2025
5595677
Merge branch 'main' into QueryParamaterPipeline
jairmyree Oct 27, 2025
18dd52e
Changing monitor dependency to unreleased app config
jairmyree Oct 30, 2025
f1e8dd6
Merge branch 'main' into QueryParamaterPipeline
jairmyree Oct 30, 2025
6a1e309
Updating assets.json files
jairmyree Oct 31, 2025
6e20621
updated pom and assets.json
jairmyree Oct 31, 2025
94139bc
Update QueryParamPolicyTest.java
mrm9084 Nov 3, 2025
e393d1f
Merge branch 'main' into QueryParamaterPipeline
mrm9084 Nov 3, 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Other Changes

- Added a pipeline policy to handle query parameters to make sure the keys are always in lower case and in alphabetical order.

## 1.8.3 (2025-08-21)

### Other Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import com.azure.data.appconfiguration.implementation.AzureAppConfigurationImpl;
import com.azure.data.appconfiguration.implementation.ConfigurationClientCredentials;
import com.azure.data.appconfiguration.implementation.ConfigurationCredentialsPolicy;
import com.azure.data.appconfiguration.implementation.QueryParamPolicy;
import com.azure.data.appconfiguration.implementation.SyncTokenPolicy;
import com.azure.data.appconfiguration.models.ConfigurationAudience;

Expand Down Expand Up @@ -263,6 +264,9 @@ private HttpPipeline createDefaultHttpPipeline(SyncTokenPolicy syncTokenPolicy,
policies.add(new AddHeadersFromContextPolicy());
policies.add(ADD_HEADERS_POLICY);

// Add query parameter reordering policy
policies.add(new QueryParamPolicy());

policies.addAll(perCallPolicies);
HttpPolicyProviders.addBeforeRetryPolicies(policies);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.data.appconfiguration.implementation;

import java.net.MalformedURLException;
import java.util.Map;
import java.util.TreeMap;

import com.azure.core.http.HttpPipelineCallContext;
import com.azure.core.http.HttpPipelineNextPolicy;
import com.azure.core.http.HttpRequest;
import com.azure.core.http.HttpResponse;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.util.UrlBuilder;
import com.azure.core.util.logging.ClientLogger;

import reactor.core.publisher.Mono;

public class QueryParamPolicy implements HttpPipelinePolicy {
private static final ClientLogger LOGGER = new ClientLogger(QueryParamPolicy.class);

@Override
public Mono<HttpResponse> process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
HttpRequest request = context.getHttpRequest();

try {
UrlBuilder urlBuilder = UrlBuilder.parse(request.getUrl());
Map<String, String> queryParams = urlBuilder.getQuery();

if (queryParams != null && !queryParams.isEmpty()) {
// Create a new TreeMap to automatically sort by keys alphabetically
Map<String, String> sortedParams = new TreeMap<>();

// Process each query parameter: convert key to lowercase and add to sorted map
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();

// Convert key to lowercase, but preserve special cases like $Select -> $select
String lowercaseKey = key.toLowerCase();
sortedParams.put(lowercaseKey, value);
}

// Clear existing query parameters and add sorted ones
urlBuilder.setQuery(null);
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
urlBuilder.addQueryParameter(entry.getKey(), entry.getValue());
}

// Update the request URL with reordered parameters
request.setUrl(urlBuilder.toUrl());
}
} catch (MalformedURLException e) {
// If URL parsing fails, continue without modification
LOGGER.warning(
"Failed to parse URL for query parameter normalization. "
+ "Request will proceed with original URL. URL: {}, Error: {}",
request.getUrl(), e.getMessage(), e);
}

return next.process();
}

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

package com.azure.data.appconfiguration.implementation;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.net.MalformedURLException;

import org.junit.jupiter.api.Test;

import com.azure.core.http.HttpMethod;
import com.azure.core.http.HttpPipeline;
import com.azure.core.http.HttpPipelineBuilder;
import com.azure.core.http.HttpRequest;
import com.azure.core.http.HttpResponse;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.test.SyncAsyncExtension;
import com.azure.core.test.annotation.SyncAsyncTest;
import com.azure.core.test.http.NoOpHttpClient;
import com.azure.core.util.Context;

import reactor.core.publisher.Mono;

/**
* Unit tests for QueryParamPolicy
*/
public class QueryParamPolicyTest {
private static final String BASE_URL = "http://localhost:8080";
private static final String ENDPOINT_PATH = "/kv/test";

/**
* Test that query parameters are sorted alphabetically
*/
@SyncAsyncTest
public void queryParametersAreSortedAlphabetically() {
final String originalUrl = BASE_URL + ENDPOINT_PATH + "?zebra=value1&alpha=value2&beta=value3";
final String expectedUrl = BASE_URL + ENDPOINT_PATH + "?alpha=value2&beta=value3&zebra=value1";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
assertEquals(expectedUrl, actualUrl, "Query parameters should be sorted alphabetically");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that query parameter keys are converted to lowercase
*/
@SyncAsyncTest
public void queryParameterKeysAreConvertedToLowercase() {
final String originalUrl = BASE_URL + ENDPOINT_PATH + "?SELECT=field1&FILTER=condition&orderBy=field2";
final String expectedUrl = BASE_URL + ENDPOINT_PATH + "?filter=condition&orderby=field2&select=field1";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
assertEquals(expectedUrl, actualUrl, "Query parameter keys should be lowercase and sorted");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that OData-style parameters like $select are handled correctly
*/
@SyncAsyncTest
public void oDataParametersAreHandledCorrectly() {
final String originalUrl
= BASE_URL + ENDPOINT_PATH + "?$Select=name,value&$Filter=startsWith(key,'test')&api-version=1.0";
final String expectedUrl
= BASE_URL + ENDPOINT_PATH + "?$filter=startsWith(key,'test')&$select=name,value&api-version=1.0";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
assertEquals(expectedUrl, actualUrl, "OData parameters should be lowercase and sorted");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that URLs without query parameters are not modified
*/
@SyncAsyncTest
public void urlsWithoutQueryParametersAreNotModified() {
final String originalUrl = BASE_URL + ENDPOINT_PATH;

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
assertEquals(originalUrl, actualUrl, "URLs without query parameters should not be modified");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that empty query parameters are handled correctly
*/
@SyncAsyncTest
public void emptyQueryParametersAreHandledCorrectly() {
final String originalUrl = BASE_URL + ENDPOINT_PATH + "?";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
// The URL should either be cleaned up or preserved as is
assertTrue(actualUrl.equals(BASE_URL + ENDPOINT_PATH) || actualUrl.equals(originalUrl),
"Empty query parameters should be handled gracefully");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that query parameter values are preserved exactly
*/
@SyncAsyncTest
public void queryParameterValuesArePreserved() {
final String originalUrl = BASE_URL + ENDPOINT_PATH + "?key1=Value%20With%20Spaces&key2=SimpleValue&key3=";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
// Check that all values are preserved
assertTrue(actualUrl.contains("Value%20With%20Spaces"), "Values with spaces should be preserved");
assertTrue(actualUrl.contains("SimpleValue"), "Simple values should be preserved");
assertTrue(actualUrl.contains("key3="), "Empty values should be preserved");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that duplicate query parameter keys are handled correctly
*/
@SyncAsyncTest
public void duplicateQueryParameterKeysAreHandled() {
final String originalUrl = BASE_URL + ENDPOINT_PATH + "?filter=condition1&select=field1&filter=condition2";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();
// The policy should handle duplicates gracefully (TreeMap behavior)
assertTrue(actualUrl.contains("filter="), "Filter parameter should be present");
assertTrue(actualUrl.contains("select="), "Select parameter should be present");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

/**
* Test that malformed URLs are handled gracefully
*/
@Test
public void malformedUrlsAreHandledGracefully() {
// This test uses a synchronous approach since we're testing error handling
final String malformedUrl = "not-a-valid-url://[invalid";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
// The URL should remain unchanged when malformed
final String actualUrl = context.getHttpRequest().getUrl().toString();
assertEquals(malformedUrl, actualUrl, "Malformed URLs should remain unchanged");
return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

// Test should not throw an exception
try {
HttpRequest request = new HttpRequest(HttpMethod.GET, malformedUrl);
pipeline.send(request, Context.NONE).block();
} catch (Exception e) {
// If an exception occurs, it should not be from the QueryParamPolicy
assertTrue(e.getCause() instanceof MalformedURLException || e.getMessage().contains("not-a-valid-url"),
"Exception should be related to the malformed URL, not the policy");
}
}

/**
* Test comprehensive scenario with mixed case, special characters, and sorting
*/
@SyncAsyncTest
public void comprehensiveQueryParameterNormalization() {
final String originalUrl = BASE_URL + ENDPOINT_PATH
+ "?$TOP=10&API-Version=2023-10-01&$select=key,value&label=prod&$filter=startsWith(key,'app')&maxItems=100";

QueryParamPolicy queryParamPolicy = new QueryParamPolicy();

HttpPipelinePolicy auditorPolicy = (context, next) -> {
final String actualUrl = context.getHttpRequest().getUrl().toString();

// Verify alphabetical ordering and lowercase conversion
String[] expectedOrder = { "$filter", "$select", "$top", "api-version", "label", "maxitems" };
String queryString = actualUrl.substring(actualUrl.indexOf('?') + 1);
String[] actualParams = queryString.split("&");

for (int i = 0; i < expectedOrder.length && i < actualParams.length; i++) {
String actualKey = actualParams[i].split("=")[0];
assertEquals(expectedOrder[i], actualKey,
"Parameter at position " + i + " should be " + expectedOrder[i]);
}

return next.process();
};

final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient())
.policies(queryParamPolicy, auditorPolicy)
.build();

SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl),
() -> sendRequest(pipeline, originalUrl));
}

private Mono<HttpResponse> sendRequest(HttpPipeline pipeline, String url) {
return pipeline.send(new HttpRequest(HttpMethod.GET, url), Context.NONE);
}

private HttpResponse sendRequestSync(HttpPipeline pipeline, String url) {
return pipeline.send(new HttpRequest(HttpMethod.GET, url), Context.NONE).block();
}
}
Loading