diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt index 4e8e4c8b4044..a633b0712a47 100644 --- a/eng/versioning/version_client.txt +++ b/eng/versioning/version_client.txt @@ -544,6 +544,7 @@ io.clientcore:optional-dependency-tests;1.0.0-beta.1;1.0.0-beta.1 # In the pom, the version update tag after the version should name the unreleased package and the dependency version: # +unreleased_com.azure:azure-data-appconfiguration;1.9.0-beta.1 unreleased_com.azure.v2:azure-core;2.0.0-beta.1 unreleased_com.azure.v2:azure-identity;2.0.0-beta.1 unreleased_com.azure.v2:azure-data-appconfiguration;2.0.0-beta.1 diff --git a/sdk/appconfiguration/azure-data-appconfiguration/CHANGELOG.md b/sdk/appconfiguration/azure-data-appconfiguration/CHANGELOG.md index d1bdb90bb55c..67c451261084 100644 --- a/sdk/appconfiguration/azure-data-appconfiguration/CHANGELOG.md +++ b/sdk/appconfiguration/azure-data-appconfiguration/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.9.0-beta.1 (Unreleased) ### Features Added +- Added a pipeline policy to handle query parameters to make sure the keys are always in lower case and in alphabetical order. ### Breaking Changes diff --git a/sdk/appconfiguration/azure-data-appconfiguration/assets.json b/sdk/appconfiguration/azure-data-appconfiguration/assets.json index b8dd5af55e36..af4c3760cd34 100644 --- a/sdk/appconfiguration/azure-data-appconfiguration/assets.json +++ b/sdk/appconfiguration/azure-data-appconfiguration/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/appconfiguration/azure-data-appconfiguration", - "Tag": "java/appconfiguration/azure-data-appconfiguration_5e00bac278" + "Tag": "java/appconfiguration/azure-data-appconfiguration_90b5086be3" } diff --git a/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/ConfigurationClientBuilder.java b/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/ConfigurationClientBuilder.java index 79c0bb5c80f2..7e209d811dd7 100644 --- a/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/ConfigurationClientBuilder.java +++ b/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/ConfigurationClientBuilder.java @@ -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; @@ -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); diff --git a/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/implementation/QueryParamPolicy.java b/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/implementation/QueryParamPolicy.java new file mode 100644 index 000000000000..d295613ce429 --- /dev/null +++ b/sdk/appconfiguration/azure-data-appconfiguration/src/main/java/com/azure/data/appconfiguration/implementation/QueryParamPolicy.java @@ -0,0 +1,58 @@ +// 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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.policy.HttpPipelineSyncPolicy; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.UrlBuilder; +import com.azure.core.util.logging.ClientLogger; + +public final class QueryParamPolicy extends HttpPipelineSyncPolicy { + private static final ClientLogger LOGGER = new ClientLogger(QueryParamPolicy.class); + + @Override + protected void beforeSendingRequest(HttpPipelineCallContext context) { + HttpRequest httpRequest = context.getHttpRequest(); + + try { + UrlBuilder builder = UrlBuilder.parse(httpRequest.getUrl()); + String queryString = builder.getQueryString(); + builder.clearQuery(); + TreeMap> orderedQuery = new TreeMap<>(String::compareTo); + CoreUtils.parseQueryParameters(queryString).forEachRemaining(kvp -> { + String lowercaseKey = kvp.getKey().toLowerCase(); + orderedQuery.compute(lowercaseKey, (ignored, values) -> { + if (values == null) { + values = new ArrayList<>(); + } + values.add(kvp.getValue()); + return values; + }); + }); + for (Map.Entry> ordered : orderedQuery.entrySet()) { + // Sort values for each parameter key to ensure consistent ordering + ordered.getValue().sort(String::compareTo); + for (String val : ordered.getValue()) { + builder.addQueryParameter(ordered.getKey(), val); + } + } + httpRequest.setUrl(builder.toUrl().toString()); + } catch (IllegalArgumentException | MalformedURLException e) { + // If the constructed URL is invalid when setting it, continue without modification + LOGGER.warning( + "Failed to set normalized URL due to invalid format. " + + "Request will proceed with original URL. URL: {}, Error: {}", + httpRequest.getUrl(), e.getMessage(), e); + } + } + +} diff --git a/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/QueryParamPolicyTest.java b/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/QueryParamPolicyTest.java new file mode 100644 index 000000000000..9e09d2344bb6 --- /dev/null +++ b/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/QueryParamPolicyTest.java @@ -0,0 +1,561 @@ +// 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; + +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((BASE_URL + ENDPOINT_PATH).equals(actualUrl) || 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 multiple query parameters with the same key are preserved as separate parameters + */ + @SyncAsyncTest + public void multipleParametersWithSameKeyArePreserved() { + 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(); + + // Count how many filter parameters exist + int filterCount = (actualUrl.length() - actualUrl.replace("filter=", "").length()) / "filter=".length(); + + // The policy should preserve both filter parameters as separate entries + assertEquals(2, filterCount, "Both filter parameters should be preserved separately"); + assertTrue(actualUrl.contains("filter=condition1"), "First filter parameter should be preserved"); + assertTrue(actualUrl.contains("filter=condition2"), "Second filter parameter should be preserved"); + assertTrue(actualUrl.contains("select=field1"), "Select parameter 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 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"; + final String expectedUrl = BASE_URL + ENDPOINT_PATH + + "?$filter=startsWith(key,'app')&$select=key,value&$top=10&api-version=2023-10-01&label=prod&maxitems=100"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Complex query parameters should be normalized, sorted, and lowercased"); + 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 validates the correct behavior for multiple tags parameters + * + * This test verifies that the policy correctly preserves multiple tags parameters + * as separate parameters instead of merging them into comma-separated values. + * + * This test validates that the QueryParamPolicy implementation properly handles + * the Azure App Configuration API requirement for separate tags parameters. + */ + @SyncAsyncTest + public void multipleTagsParameters() { + final String originalUrl = BASE_URL + ENDPOINT_PATH + + "?api-version=2023-11-01&key=*&label=dev&tags=environment%3Ddev&tags=team%3Dfrontend"; + + // The URL should preserve multiple tags parameters after processing (with alphabetical sorting) + final String expectedUrl = BASE_URL + ENDPOINT_PATH + + "?api-version=2023-11-01&key=*&label=dev&tags=environment%3Ddev&tags=team%3Dfrontend"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + + // Verify that multiple tags parameters are preserved as separate parameters + int tagsCount = (actualUrl.length() - actualUrl.replace("tags=", "").length()) / "tags=".length(); + assertEquals(2, tagsCount, "Multiple tags parameters should be preserved separately. " + + "Expected 2 separate tags parameters, but got " + tagsCount); + + // The URL should preserve multiple tags parameters in their original form + assertEquals(expectedUrl, actualUrl, "Multiple tags parameters should be preserved with proper ordering"); + + return next.process(); + }; + + final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()) + .policies(queryParamPolicy, auditorPolicy) + .build(); + + SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl), + () -> sendRequest(pipeline, originalUrl)); + } + + /** + * Test single tags parameter is handled correctly + */ + @SyncAsyncTest + public void singleTagsParameterPreserved() { + final String originalUrl = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&key=*&tags=environment%3Dprod"; + final String expectedUrl = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&key=*&tags=environment%3Dprod"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, "Single tags parameter should be preserved with proper ordering"); + 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 multiple tags parameters are preserved correctly with proper alphabetical ordering + * + * Verifies that the policy preserves multiple tags parameters as separate parameters + * while maintaining alphabetical ordering of all query parameters. + */ + @SyncAsyncTest + public void multipleTagsParametersWithOrderingVerification() { + final String originalUrl = BASE_URL + ENDPOINT_PATH + + "?api-version=2023-11-01&key=*&label=dev&tags=version%3D1.2.0&tags=region%3Deast"; + final String expectedUrl = BASE_URL + ENDPOINT_PATH + + "?api-version=2023-11-01&key=*&label=dev&tags=region%3Deast&tags=version%3D1.2.0"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Multiple tags parameters should be preserved with proper alphabetical ordering"); + return next.process(); + }; + + final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()) + .policies(queryParamPolicy, auditorPolicy) + .build(); + + SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl), + () -> sendRequest(pipeline, originalUrl)); + } + + /** + * Test tags parameters with complex values and URL encoding + */ + @SyncAsyncTest + public void tagsParametersWithComplexValues() { + final String originalUrl + = BASE_URL + ENDPOINT_PATH + "?tags=environment%3Dproduction&tags=team%3Dbackend&api-version=2023-11-01"; + final String expectedUrl + = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&tags=environment%3Dproduction&tags=team%3Dbackend"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, "Tags parameters with complex values should be sorted and 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 tags parameters mixed with other App Configuration parameters + */ + @SyncAsyncTest + public void tagsParametersMixedWithOtherParameters() { + final String originalUrl = BASE_URL + ENDPOINT_PATH + + "?$select=key,value&tags=feature%3Dauth&label=*&api-version=2023-11-01&$filter=startsWith(key,'app')&tags=env%3Dtest"; + final String expectedUrl = BASE_URL + ENDPOINT_PATH + + "?$filter=startsWith(key,'app')&$select=key,value&api-version=2023-11-01&label=*&tags=env%3Dtest&tags=feature%3Dauth"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Tags parameters mixed with other parameters should be sorted correctly"); + return next.process(); + }; + + final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()) + .policies(queryParamPolicy, auditorPolicy) + .build(); + + SyncAsyncExtension.execute(() -> sendRequestSync(pipeline, originalUrl), + () -> sendRequest(pipeline, originalUrl)); + } + + /** + * Test tags parameters with special characters and case sensitivity + */ + @SyncAsyncTest + public void tagsParametersWithSpecialCharacters() { + final String originalUrl + = BASE_URL + ENDPOINT_PATH + "?TAGS=Priority%3DHigh&api-version=2023-11-01&Tags=Status%3DActive"; + final String expectedUrl + = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&tags=Priority%3DHigh&tags=Status%3DActive"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Tags parameters with special characters should be normalized 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 key and label filters with ampersand (&) character + */ + @SyncAsyncTest + public void keyAndLabelFiltersWithAmpersandCharacter() { + final String originalUrl + = BASE_URL + ENDPOINT_PATH + "?key=app%26config&label=prod%26test&api-version=2023-11-01"; + final String expectedUrl + = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&key=app%26config&label=prod%26test"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Key and label filters with ampersand character should be preserved 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 key and label filters with space character + */ + @SyncAsyncTest + public void keyAndLabelFiltersWithSpaceCharacter() { + final String originalUrl + = BASE_URL + ENDPOINT_PATH + "?key=app%20config&label=dev%20environment&api-version=2023-11-01"; + final String expectedUrl + = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&key=app%20config&label=dev%20environment"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Key and label filters with space character should be preserved 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 key and label filters with hash (#) character + */ + @SyncAsyncTest + public void keyAndLabelFiltersWithHashCharacter() { + final String originalUrl + = BASE_URL + ENDPOINT_PATH + "?key=app%23config&label=version%23v1&api-version=2023-11-01"; + final String expectedUrl + = BASE_URL + ENDPOINT_PATH + "?api-version=2023-11-01&key=app%23config&label=version%23v1"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Key and label filters with hash character should be preserved 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 key and label filters with multiple special characters combined + */ + @SyncAsyncTest + public void keyAndLabelFiltersWithMixedSpecialCharacters() { + final String originalUrl = BASE_URL + ENDPOINT_PATH + + "?key=app%26config%20test%23v1&label=prod%20%26%20test%23env&api-version=2023-11-01"; + final String expectedUrl = BASE_URL + ENDPOINT_PATH + + "?api-version=2023-11-01&key=app%26config%20test%23v1&label=prod%20%26%20test%23env"; + + QueryParamPolicy queryParamPolicy = new QueryParamPolicy(); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String actualUrl = context.getHttpRequest().getUrl().toString(); + assertEquals(expectedUrl, actualUrl, + "Key and label filters with mixed special characters should be preserved 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)); + } + + private Mono 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(); + } + +} diff --git a/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/SyncTokenPolicyTest.java b/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/SyncTokenPolicyTest.java index 16f18cb627de..7b63ef7523b5 100644 --- a/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/SyncTokenPolicyTest.java +++ b/sdk/appconfiguration/azure-data-appconfiguration/src/test/java/com/azure/data/appconfiguration/implementation/SyncTokenPolicyTest.java @@ -3,6 +3,15 @@ package com.azure.data.appconfiguration.implementation; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.jupiter.api.Test; + import com.azure.core.http.HttpHeaders; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipeline; @@ -15,15 +24,8 @@ import com.azure.core.test.http.MockHttpResponse; import com.azure.core.test.http.NoOpHttpClient; import com.azure.core.util.Context; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; -import java.net.MalformedURLException; -import java.net.URL; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import reactor.core.publisher.Mono; /** * Unit tests for Sync Token @@ -203,6 +205,40 @@ public void externalSyncTokensFollowRulesWhenAddedTest() throws MalformedURLExce SyncAsyncExtension.execute(() -> pipeline.sendSync(request, Context.NONE), () -> pipeline.send(request)); } + @SyncAsyncTest + public void syncTokenPolicyWithAfterParameterTest() throws MalformedURLException { + final SyncTokenPolicy syncTokenPolicy = new SyncTokenPolicy(); + final String afterValue = "abcdefg"; + final String urlWithAfter = "https://example.azconfig.io/kv?api-version=2023-11-01&After=" + afterValue + + "&tags=tag3%3Dvalue3&key=*&label=dev&$Select=key&tags=tag2%3Dvalue2&tags=tag1%3Dvalue1"; + + syncTokenPolicy.updateSyncToken(SYNC_TOKEN_VALUE + ";sn=1"); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + final String headerValue = context.getHttpRequest().getHeaders().getValue(SYNC_TOKEN); + final String requestUrl = context.getHttpRequest().getUrl().toString(); + + // Verify sync token is present in header + assertEquals(SYNC_TOKEN_VALUE, headerValue); + // Verify the URL contains the "After" parameter with correct value + assertTrue(requestUrl.contains("After=" + afterValue)); + // Verify the URL contains other expected parameters + assertTrue(requestUrl.contains("api-version=2023-11-01")); + assertTrue(requestUrl.contains("key=*")); + assertTrue(requestUrl.contains("label=dev")); + assertTrue(requestUrl.contains("$Select=key")); + + return next.process(); + }; + + final HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()) + .policies(syncTokenPolicy, auditorPolicy) + .build(); + + HttpRequest request = new HttpRequest(HttpMethod.GET, urlWithAfter); + SyncAsyncExtension.execute(() -> pipeline.sendSync(request, Context.NONE), () -> pipeline.send(request)); + } + private void syncTokenEquals(SyncToken syncToken, String id, String value, long sn) { assertEquals(id, syncToken.getId()); assertEquals(value, syncToken.getValue()); diff --git a/sdk/monitor/azure-monitor-query-metrics/assets.json b/sdk/monitor/azure-monitor-query-metrics/assets.json index 707a85305859..5bda6dfb7a63 100644 --- a/sdk/monitor/azure-monitor-query-metrics/assets.json +++ b/sdk/monitor/azure-monitor-query-metrics/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/monitor/azure-monitor-query-metrics", - "Tag": "java/monitor/azure-monitor-query-metrics_965743715b" + "Tag": "java/monitor/azure-monitor-query-metrics_126778300d" } \ No newline at end of file diff --git a/sdk/monitor/azure-monitor-query-metrics/pom.xml b/sdk/monitor/azure-monitor-query-metrics/pom.xml index a03c386bb5c4..712aa1766f13 100644 --- a/sdk/monitor/azure-monitor-query-metrics/pom.xml +++ b/sdk/monitor/azure-monitor-query-metrics/pom.xml @@ -70,7 +70,7 @@ Code generated by Microsoft (R) TypeSpec Code Generator. com.azure azure-data-appconfiguration - 1.8.5 + 1.9.0-beta.1 test diff --git a/sdk/monitor/azure-monitor-query/assets.json b/sdk/monitor/azure-monitor-query/assets.json index 20d798b600b9..524e8e6a96ca 100644 --- a/sdk/monitor/azure-monitor-query/assets.json +++ b/sdk/monitor/azure-monitor-query/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/monitor/azure-monitor-query", - "Tag": "java/monitor/azure-monitor-query_dba737553b" + "Tag": "java/monitor/azure-monitor-query_eedf39b35d" } diff --git a/sdk/monitor/azure-monitor-query/pom.xml b/sdk/monitor/azure-monitor-query/pom.xml index 885e79b35c8f..07d5196535b6 100644 --- a/sdk/monitor/azure-monitor-query/pom.xml +++ b/sdk/monitor/azure-monitor-query/pom.xml @@ -77,7 +77,7 @@ com.azure azure-data-appconfiguration - 1.8.5 + 1.9.0-beta.1 test diff --git a/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MetricsClientTestBase.java b/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MetricsClientTestBase.java index a701354c6a44..b4c543b75979 100644 --- a/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MetricsClientTestBase.java +++ b/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MetricsClientTestBase.java @@ -15,7 +15,7 @@ public class MetricsClientTestBase extends TestProxyTestBase { static final String FAKE_RESOURCE_ID - = "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"; + = "/subscriptions/4d042dc6-fe17-4698-a23f-ec6a8d1e98f4/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm"; protected String metricEndpoint; protected MetricsClientBuilder clientBuilder; protected ConfigurationClient configClient; diff --git a/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MonitorQueryTestUtils.java b/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MonitorQueryTestUtils.java index 08d69e21ee42..fc83c957b993 100644 --- a/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MonitorQueryTestUtils.java +++ b/sdk/monitor/azure-monitor-query/src/test/java/com/azure/monitor/query/MonitorQueryTestUtils.java @@ -57,7 +57,7 @@ public static String getAdditionalLogWorkspaceId(boolean isPlaybackMode) { public static String getLogResourceId(boolean isPlaybackMode) { if (isPlaybackMode) { - return "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/azmonitorlogsws"; + return "/subscriptions/4d042dc6-fe17-4698-a23f-ec6a8d1e98f4/resourceGroups/rg/providers/Microsoft.OperationalInsights/workspaces/azmonitorlogsws"; } else { return LOG_RESOURCE_ID.substring(LOG_RESOURCE_ID.indexOf("/subscriptions")); } @@ -65,7 +65,7 @@ public static String getLogResourceId(boolean isPlaybackMode) { public static String getMetricResourceUri(boolean isPlaybackMode) { if (isPlaybackMode) { - return "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/rg/providers/Microsoft.Eventhub/Namespaces/eventhub"; + return "/subscriptions/4d042dc6-fe17-4698-a23f-ec6a8d1e98f4/resourceGroups/rg/providers/Microsoft.Eventhub/Namespaces/eventhub"; } else { return METRIC_RESOURCE_URI.substring(METRIC_RESOURCE_URI.indexOf("/subscriptions")); }