diff --git a/eng/versioning/external_dependencies.txt b/eng/versioning/external_dependencies.txt index cbf42e119ae7..f37933dc068a 100644 --- a/eng/versioning/external_dependencies.txt +++ b/eng/versioning/external_dependencies.txt @@ -383,7 +383,6 @@ springboot3_org.springframework.boot:spring-boot-test;3.5.5 springboot3_org.springframework.boot:spring-boot-testcontainers;3.5.5 springboot3_org.springframework.cloud:spring-cloud-bus;4.3.0 springboot3_org.springframework.cloud:spring-cloud-context;4.3.0 -springboot3_org.springframework.cloud:spring-cloud-starter-bootstrap;4.3.0 springboot3_org.springframework.cloud:spring-cloud-starter-stream-kafka;4.3.0 springboot3_org.springframework.cloud:spring-cloud-stream-test-binder;4.3.0 springboot3_org.springframework.cloud:spring-cloud-stream;4.3.0 @@ -412,6 +411,8 @@ springboot3_org.springframework:spring-web;6.2.10 springboot3_org.springframework:spring-webmvc;6.2.10 springboot3_org.testcontainers:junit-jupiter;1.21.3 springboot3_org.testcontainers:azure;1.21.3 +springboot3_jakarta.annotation:jakarta.annotation-api;3.0.0 +springboot3_ch.qos.logback:logback-classic;1.5.18 # Used for Spring version updates springboot3_org.springframework.boot:spring-boot-dependencies;3.5.5 springboot3_org.springframework.cloud:spring-cloud-dependencies;2025.0.0 diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt index 299a9b0cd09e..b5e6930f0aba 100644 --- a/eng/versioning/version_client.txt +++ b/eng/versioning/version_client.txt @@ -211,11 +211,11 @@ com.microsoft.azure:azure-eventhubs;3.3.0;3.4.0-beta.1 com.microsoft.azure:azure-servicebus;3.6.7;3.7.0-beta.1 com.microsoft.azure:azure-media;1.0.0-beta.1;1.0.0-beta.1 com.azure.spring:azure-monitor-spring-native-test;1.0.0-beta.1;1.0.0-beta.1 -com.azure.spring:spring-cloud-azure-appconfiguration-config-web;5.23.0;5.24.0-beta.1 -com.azure.spring:spring-cloud-azure-appconfiguration-config;5.23.0;5.24.0-beta.1 -com.azure.spring:spring-cloud-azure-feature-management-web;5.23.0;5.24.0-beta.1 -com.azure.spring:spring-cloud-azure-feature-management;5.23.0;5.24.0-beta.1 -com.azure.spring:spring-cloud-azure-starter-appconfiguration-config;5.23.0;5.24.0-beta.1 +com.azure.spring:spring-cloud-azure-appconfiguration-config-web;6.0.0;6.0.0-beta.2 +com.azure.spring:spring-cloud-azure-appconfiguration-config;6.0.0;6.0.0-beta.2 +com.azure.spring:spring-cloud-azure-feature-management-web;6.0.0;6.0.0-beta.2 +com.azure.spring:spring-cloud-azure-feature-management;6.0.0;6.0.0-beta.2 +com.azure.spring:spring-cloud-azure-starter-appconfiguration-config;6.0.0;6.0.0-beta.2 com.azure.spring:spring-cloud-azure-dependencies;5.23.0;5.24.0-beta.1 com.azure.spring:spring-messaging-azure;5.23.0;5.24.0-beta.1 com.azure.spring:spring-messaging-azure-eventhubs;5.23.0;5.24.0-beta.1 diff --git a/sdk/spring/spring-cloud-azure-actuator-autoconfigure/pom.xml b/sdk/spring/spring-cloud-azure-actuator-autoconfigure/pom.xml index a0387c5b4390..dc8babfb9a80 100644 --- a/sdk/spring/spring-cloud-azure-actuator-autoconfigure/pom.xml +++ b/sdk/spring/spring-cloud-azure-actuator-autoconfigure/pom.xml @@ -62,7 +62,7 @@ com.azure.spring spring-cloud-azure-appconfiguration-config-web - 5.24.0-beta.1 + 6.0.0-beta.2 true diff --git a/sdk/spring/spring-cloud-azure-actuator/pom.xml b/sdk/spring/spring-cloud-azure-actuator/pom.xml index 2416914bfe38..1cddb7848f0b 100644 --- a/sdk/spring/spring-cloud-azure-actuator/pom.xml +++ b/sdk/spring/spring-cloud-azure-actuator/pom.xml @@ -109,7 +109,7 @@ com.azure.spring spring-cloud-azure-appconfiguration-config-web - 5.24.0-beta.1 + 6.0.0-beta.2 true diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/CHANGELOG.md index 0555f70690aa..3ccaad647e43 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 5.24.0-beta.1 (Unreleased) +## 6.0.0-beta.2 (Unreleased) ### Features Added diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/pom.xml b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/pom.xml index 33602fab1a1a..c5f165f719ed 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/pom.xml +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/pom.xml @@ -9,7 +9,7 @@ 4.0.0 com.azure.spring spring-cloud-azure-appconfiguration-config-web - 5.24.0-beta.1 + 6.0.0-beta.2 Spring Cloud Azure App Configuration Config Web Integration of Spring Cloud Config and Azure App Configuration Service @@ -21,7 +21,7 @@ com.azure.spring spring-cloud-azure-appconfiguration-config - 5.24.0-beta.1 + 6.0.0-beta.2 org.springframework.boot diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java index 1b00e0f55600..8771558968af 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java @@ -9,6 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import org.springframework.web.context.support.ServletRequestHandledEvent; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationRefresh; @@ -32,7 +33,7 @@ public AppConfigurationEventListener(AppConfigurationRefresh appConfigurationRef } @Override - public void onApplicationEvent(ServletRequestHandledEvent event) { + public void onApplicationEvent(@NonNull ServletRequestHandledEvent event) { try { if (!(event.getRequestUrl().equals(ACTUATOR + APPCONFIGURATION_REFRESH) || event.getRequestUrl().equals(ACTUATOR + APPCONFIGURATION_REFRESH_BUS))) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushbusrefresh/AppConfigurationBusRefreshEventListener.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushbusrefresh/AppConfigurationBusRefreshEventListener.java index bf846e6418df..296f554f51ec 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushbusrefresh/AppConfigurationBusRefreshEventListener.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushbusrefresh/AppConfigurationBusRefreshEventListener.java @@ -5,6 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationRefresh; @@ -31,7 +32,7 @@ public AppConfigurationBusRefreshEventListener(AppConfigurationRefresh appConfig * @param event Event Triggering refresh, contains valid config store endpoint. */ @Override - public void onApplicationEvent(AppConfigurationBusRefreshEvent event) { + public void onApplicationEvent(@NonNull AppConfigurationBusRefreshEvent event) { try { appConfigurationRefresh.expireRefreshInterval(event.getEndpoint(), event.getSyncToken()); } catch (Exception e) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEndpoint.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEndpoint.java index 139f2a18b433..1a97678e0cd9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEndpoint.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEndpoint.java @@ -10,11 +10,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.cloud.context.refresh.ContextRefresher; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -29,8 +30,7 @@ /** * Endpoint for requesting new configurations to be loaded. */ -@SuppressWarnings("removal") -@ControllerEndpoint(id = APPCONFIGURATION_REFRESH) +@Endpoint(id = APPCONFIGURATION_REFRESH) public class AppConfigurationRefreshEndpoint implements ApplicationEventPublisherAware { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshEndpoint.class); @@ -105,7 +105,7 @@ public String refresh(HttpServletRequest request, HttpServletResponse response, } @Override - public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + public void setApplicationEventPublisher(@NonNull ApplicationEventPublisher applicationEventPublisher) { this.publisher = applicationEventPublisher; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEventListener.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEventListener.java index 187946abbf02..cfaf05050bc9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEventListener.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pushrefresh/AppConfigurationRefreshEventListener.java @@ -5,6 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationRefresh; @@ -32,7 +33,7 @@ public AppConfigurationRefreshEventListener(AppConfigurationRefresh appConfigura * @param event Event Triggering refresh, contains valid config store endpoint. */ @Override - public void onApplicationEvent(AppConfigurationRefreshEvent event) { + public void onApplicationEvent(@NonNull AppConfigurationRefreshEvent event) { try { appConfigurationRefresh.expireRefreshInterval(event.getEndpoint(), event.getSyncToken()); } catch (Exception e) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/test/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/AppConfigurationWebAutoConfigurationTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/test/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/AppConfigurationWebAutoConfigurationTest.java deleted file mode 100644 index 2f861d46ad5d..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/test/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/AppConfigurationWebAutoConfigurationTest.java +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.web.implementation; - -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.TestConstants.CONN_STRING_PROP; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.TestConstants.STORE_ENDPOINT_PROP; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.TestConstants.TEST_CONN_STRING; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.TestConstants.TEST_STORE_NAME; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.TestUtils.propPair; -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.FilteredClassLoader; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; -import org.springframework.cloud.bus.BusProperties; -import org.springframework.cloud.bus.event.PathDestinationFactory; -import org.springframework.cloud.bus.event.RefreshRemoteApplicationEvent; -import org.springframework.cloud.endpoint.RefreshEndpoint; - -import com.azure.spring.cloud.appconfiguration.config.AppConfigurationAutoConfiguration; -import com.azure.spring.cloud.appconfiguration.config.implementation.config.AppConfigurationBootstrapConfiguration; -import com.azure.spring.cloud.autoconfigure.implementation.context.AzureGlobalPropertiesAutoConfiguration; - -public class AppConfigurationWebAutoConfigurationTest { - - private static final ApplicationContextRunner CONTEXT_RUNNER = new ApplicationContextRunner() - .withPropertyValues(propPair(CONN_STRING_PROP, TEST_CONN_STRING), - propPair(STORE_ENDPOINT_PROP, TEST_STORE_NAME)) - .withConfiguration(AutoConfigurations.of(AppConfigurationBootstrapConfiguration.class, - AppConfigurationAutoConfiguration.class, AppConfigurationWebAutoConfiguration.class, - RefreshAutoConfiguration.class, PathDestinationFactory.class, AzureGlobalPropertiesAutoConfiguration.class)) - .withUserConfiguration(BusProperties.class); - - @Test - public void refreshMissing() { - CONTEXT_RUNNER - .withClassLoader(new FilteredClassLoader(WebEndpointProperties.class)) - .run(context -> { - assertThat(context) - .doesNotHaveBean("appConfigurationRefreshBusEndpoint"); - assertThat(context) - .doesNotHaveBean("appConfigurationRefreshEndpoint"); - assertThat(context) - .hasBean("configListener"); - }); - } - - @Test - public void busRefreshMissing() { - CONTEXT_RUNNER - .withClassLoader(new FilteredClassLoader(RefreshRemoteApplicationEvent.class)) - .run(context -> { - assertThat(context) - .doesNotHaveBean("appConfigurationBusRefreshEndpoint"); - assertThat(context) - .hasBean("appConfigurationRefreshEndpoint"); - assertThat(context) - .hasBean("configListener"); - }); - } - - @Test - public void pullRefreshListenerMissing() { - CONTEXT_RUNNER.withClassLoader(new FilteredClassLoader(RefreshEndpoint.class)) - .run(context -> assertThat(context) - .doesNotHaveBean("configListener")); - } - - @Test - public void pushRefresh() { - CONTEXT_RUNNER - .run(context -> { - assertThat(context) - .hasBean("appConfigurationRefreshEndpoint"); - }); - } - - @Test - public void busRefresh() { - CONTEXT_RUNNER - .run(context -> assertThat(context) - .hasBean("appConfigurationBusRefreshEndpoint")); - } - - @Test - public void fullRefresh() { - CONTEXT_RUNNER - .run(context -> { - assertThat(context) - .hasBean("configListener"); - assertThat(context) - .hasBean("appConfigurationRefreshEndpoint"); - assertThat(context) - .hasBean("appConfigurationBusRefreshEndpoint"); - }); - } -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md index dc4f726273a0..be5ad0cf47eb 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 5.24.0-beta.1 (Unreleased) +## 6.0.0-beta.2 (Unreleased) ### Features Added @@ -10,6 +10,7 @@ ### Other Changes + ## 5.23.0 (2025-08-05) Please refer to [spring/CHANGELOG.md](https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/spring/CHANGELOG.md#5230-2025-08-05) for more details. diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/pom.xml b/sdk/spring/spring-cloud-azure-appconfiguration-config/pom.xml index 22511b233b44..3c0f03be9169 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/pom.xml +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/pom.xml @@ -9,7 +9,7 @@ 4.0.0 com.azure.spring spring-cloud-azure-appconfiguration-config - 5.24.0-beta.1 + 6.0.0-beta.2 Spring Cloud Azure App Configuration Config Integration of Spring Cloud Config and Azure App Configuration Service @@ -29,11 +29,6 @@ 3.5.5 true - - org.springframework.cloud - spring-cloud-starter-bootstrap - 4.3.0 - org.springframework.cloud spring-cloud-context @@ -45,6 +40,11 @@ 3.5.5 compile + + jakarta.annotation + jakarta.annotation-api + 3.0.0 + com.azure @@ -182,8 +182,8 @@ org.springframework.boot:spring-boot-actuator:[3.5.5] org.springframework.boot:spring-boot-autoconfigure:[3.5.5] org.springframework.cloud:spring-cloud-context:[4.3.0] + jakarta.annotation:jakarta.annotation-api:[3.0.0] org.springframework.boot:spring-boot-configuration-processor:[3.5.5] - org.springframework.cloud:spring-cloud-starter-bootstrap:[4.3.0] diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationAutoConfiguration.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java similarity index 52% rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationAutoConfiguration.java rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java index dde0e852d65a..9b0d4be6c0a0 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationAutoConfiguration.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java @@ -2,12 +2,14 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config; +import org.springframework.boot.BootstrapContext; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.endpoint.RefreshEndpoint; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationPullRefresh; @@ -15,41 +17,31 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProviderProperties; /** * Setup AppConfigurationRefresh when spring.cloud.azure.appconfiguration.enabled is enabled. */ -@Configuration @EnableAsync @ConditionalOnProperty(prefix = AppConfigurationProperties.CONFIG_PREFIX, name = "enabled", matchIfMissing = true) -public class AppConfigurationAutoConfiguration { +@EnableConfigurationProperties({ AppConfigurationProperties.class }) +@AutoConfiguration +@ConditionalOnClass(RefreshEndpoint.class) +public class AppConfigurationWatchAutoConfiguration { /** - * Creates an instance of {@link AppConfigurationAutoConfiguration} + * Creates an instance of {@link AppConfigurationWatchAutoConfiguration} */ - public AppConfigurationAutoConfiguration() { + public AppConfigurationWatchAutoConfiguration() { } - /** - * Auto Watch - */ - @Configuration - @ConditionalOnClass(RefreshEndpoint.class) - public static class AppConfigurationWatchAutoConfiguration { - - /** - * Creates an instance of {@link AppConfigurationWatchAutoConfiguration} - */ - public AppConfigurationWatchAutoConfiguration() { - } + @Bean + @ConditionalOnMissingBean + AppConfigurationRefresh appConfigurationRefresh(AppConfigurationProperties properties, BootstrapContext context) { + AppConfigurationReplicaClientFactory clientFactory = context + .get(AppConfigurationReplicaClientFactory.class); + ReplicaLookUp replicaLookUp = context.get(ReplicaLookUp.class); - @Bean - @ConditionalOnMissingBean - AppConfigurationRefresh appConfigurationRefresh(AppConfigurationProperties properties, - AppConfigurationProviderProperties appProperties, AppConfigurationReplicaClientFactory clientFactory, ReplicaLookUp replicaLookUp) { - return new AppConfigurationPullRefresh(clientFactory, properties.getRefreshInterval(), - appProperties.getDefaultMinBackoff(), replicaLookUp, new AppConfigurationRefreshUtil()); - } + return new AppConfigurationPullRefresh(clientFactory, properties.getRefreshInterval(), replicaLookUp, + new AppConfigurationRefreshUtil()); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java index 5d410ab33151..4ac64874a871 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java @@ -18,6 +18,7 @@ import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.util.StringUtils; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.SecretReferenceConfigurationSetting; @@ -60,7 +61,7 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP * @param keyPrefixTrimValues prefixs to trim from key values * @throws InvalidConfigurationPropertyValueException thrown if fails to parse Json content type */ - public void initProperties(List keyPrefixTrimValues, boolean isRefresh) throws InvalidConfigurationPropertyValueException { + public void initProperties(List keyPrefixTrimValues, Context context) throws InvalidConfigurationPropertyValueException { List labels = Arrays.asList(labelFilters); // Reverse labels so they have the right priority order. @@ -70,7 +71,7 @@ public void initProperties(List keyPrefixTrimValues, boolean isRefresh) SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter + "*").setLabelFilter(label); // * for wildcard match - processConfigurationSettings(replicaClient.listSettings(settingSelector, isRefresh), settingSelector.getKeyFilter(), + processConfigurationSettings(replicaClient.listSettings(settingSelector, context), settingSelector.getKeyFilter(), keyPrefixTrimValues); } } @@ -107,7 +108,7 @@ protected void processConfigurationSettings(List settings, * @return Key Vault Secret Value * @throws InvalidConfigurationPropertyValueException */ - protected void handleKeyVaultReference(String key, SecretReferenceConfigurationSetting secretReference) + private void handleKeyVaultReference(String key, SecretReferenceConfigurationSetting secretReference) throws InvalidConfigurationPropertyValueException { // Parsing Key Vault Reference for URI try { @@ -115,21 +116,22 @@ protected void handleKeyVaultReference(String key, SecretReferenceConfigurationS KeyVaultSecret secret = keyVaultClientFactory.getClient("https://" + uri.getHost()).getSecret(uri); properties.put(key, secret.getValue()); } catch (URISyntaxException e) { - LOGGER.error(String.format("Error Retrieving Key Vault Entry for key %s.", key)); + LOGGER.error("Error Retrieving Key Vault Entry for key {}.", key); throw new InvalidConfigurationPropertyValueException(key, "", "Invalid URI found in JSON property field 'uri' unable to parse."); } catch (RuntimeException e) { - LOGGER.error(String.format("Error Retrieving Key Vault Entry for key %s.", key)); + LOGGER.error("Error Retrieving Key Vault Entry for key {}.", key); throw e; } } void handleFeatureFlag(String key, FeatureFlagConfigurationSetting setting, List trimStrings) throws InvalidConfigurationPropertyValueException { - handleJson(setting, trimStrings); + // Feature Flags aren't loaded as configuration, but are loaded as feature flags when loading a snapshot. + return; } - void handleJson(ConfigurationSetting setting, List keyPrefixTrimValues) + private void handleJson(ConfigurationSetting setting, List keyPrefixTrimValues) throws InvalidConfigurationPropertyValueException { Map jsonSettings = JsonConfigurationParser.parseJsonSetting(setting); for (Entry jsonSetting : jsonSettings.entrySet()) { @@ -138,7 +140,7 @@ void handleJson(ConfigurationSetting setting, List keyPrefixTrimValues) } } - protected String trimKey(String key, List trimStrings) { + private String trimKey(String key, List trimStrings) { key = key.trim(); if (trimStrings != null) { for (String trim : trimStrings) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationConstants.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationConstants.java index 44a8db8df3cc..b480b72eb922 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationConstants.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationConstants.java @@ -12,16 +12,6 @@ public class AppConfigurationConstants { */ public static final String FEATURE_FLAG_CONTENT_TYPE = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8"; - /** - * App Configurations Key Vault Reference Content Type - */ - public static final String KEY_VAULT_CONTENT_TYPE = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8"; - - /** - * Feature Management Key Prefix - */ - public static final String FEATURE_MANAGEMENT_KEY = "feature-management."; - /** * Feature Flag Prefix */ @@ -46,17 +36,17 @@ public class AppConfigurationConstants { * Constant for tracing if Key Vault is configured for use. */ public static final String KEY_VAULT_CONFIGURED_TRACING = "UsesKeyVault"; - + /** - * Constant for tracing for Replica Count + * Constant for tracing if Push Refresh is enabled for the store. */ - public static final String REPLICA_COUNT = "ReplicaCount"; + public static final String PUSH_REFRESH = "PushRefresh"; /** * Http Header User Agent */ public static final String USER_AGENT_TYPE = "User-Agent"; - + /** * Http Header Correlation Context */ @@ -71,30 +61,10 @@ public class AppConfigurationConstants { public static final String TELEMETRY = "telemetry"; - public static final String USERS = "users"; - - public static final String USERS_CAPS = "Users"; - - public static final String AUDIENCE = "Audience"; - - public static final String GROUPS = "groups"; - - public static final String GROUPS_CAPS = "Groups"; - - public static final String TARGETING_FILTER = "targetingFilter"; - - public static final String DEFAULT_ROLLOUT_PERCENTAGE = "defaultRolloutPercentage"; - - public static final String DEFAULT_ROLLOUT_PERCENTAGE_CAPS = "DefaultRolloutPercentage"; - public static final String DEFAULT_REQUIREMENT_TYPE = "Any"; public static final String REQUIREMENT_TYPE_SERVICE = "requirement_type"; - public static final String REQUIREMENT_TYPE = "requirement-type"; - - public static final String FEATURE_FLAG_ID = "FeatureFlagId"; - public static final String FEATURE_FLAG_REFERENCE = "FeatureFlagReference"; public static final String E_TAG = "ETag"; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySource.java index 310081227eaa..97efc7c20260 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySource.java @@ -2,8 +2,13 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; +import java.util.List; + import org.springframework.core.env.EnumerablePropertySource; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + /** * Azure App Configuration PropertySource unique per Store Label(Profile) combo. * @@ -12,12 +17,13 @@ *

*/ class AppConfigurationFeatureManagementPropertySource extends EnumerablePropertySource { - private final FeatureFlagClient featureFlagLoader; - private static final String FEATURE_MANAGEMENT_KEY = "feature_management"; + private static final String FEATURE_MANAGEMENT_KEY = "feature-management"; - private static final String FEATURE_FLAG_KEY = FEATURE_MANAGEMENT_KEY + ".feature_flags"; + private static final ObjectMapper MAPPER = JsonMapper.builder().build(); + + private static final String FEATURE_FLAG_KEY = FEATURE_MANAGEMENT_KEY + ".feature-flags"; AppConfigurationFeatureManagementPropertySource(FeatureFlagClient featureFlagLoader) { super(FEATURE_MANAGEMENT_KEY, featureFlagLoader); @@ -26,14 +32,17 @@ class AppConfigurationFeatureManagementPropertySource extends EnumerableProperty @Override public String[] getPropertyNames() { - String[] names = { FEATURE_FLAG_KEY }; - return names; + if (featureFlagLoader != null && featureFlagLoader.getFeatureFlags() != null + && !featureFlagLoader.getFeatureFlags().isEmpty()) { + return new String[] { FEATURE_FLAG_KEY }; + } + return new String[0]; } @Override public Object getProperty(String name) { if (FEATURE_FLAG_KEY.equals(name)) { - return featureFlagLoader.getProperties(); + return MAPPER.convertValue(featureFlagLoader.getFeatureFlags(), List.class); } return null; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationKeyVaultClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationKeyVaultClientFactory.java index 2cc9fbffe363..e9ded98208b9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationKeyVaultClientFactory.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationKeyVaultClientFactory.java @@ -10,46 +10,83 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.stores.AppConfigurationSecretClientManager; import com.azure.spring.cloud.service.implementation.keyvault.secrets.SecretClientBuilderFactory; -public class AppConfigurationKeyVaultClientFactory { - +/** + * Factory for creating and managing AppConfigurationSecretClientManager instances. This class caches clients per Key + * Vault host. + */ +class AppConfigurationKeyVaultClientFactory { + /** + * Cache of secret client managers by Key Vault host. + */ private final Map keyVaultClients; + /** + * Optional customizer for Key Vault secret clients. + */ private final SecretClientCustomizer keyVaultClientProvider; + /** + * Optional provider for custom secret resolution. + */ private final KeyVaultSecretProvider keyVaultSecretProvider; + /** + * Factory for creating secret client builders. + */ private final SecretClientBuilderFactory secretClientFactory; - + + /** + * Flag indicating whether credentials are configured. + */ private final boolean credentialsConfigured; + /** + * Flag indicating whether the factory being used for telemetry. + */ private final boolean isConfigured; - - private final int timeout; - public AppConfigurationKeyVaultClientFactory(SecretClientCustomizer keyVaultClientProvider, + /** + * Creates a new AppConfigurationKeyVaultClientFactory. + * + * @param keyVaultClientProvider optional customizer for Key Vault secret clients + * @param keyVaultSecretProvider optional provider for custom secret resolution + * @param secretClientFactory factory for creating secret client builders + * @param credentialsConfigured whether credentials are configured + */ + AppConfigurationKeyVaultClientFactory(SecretClientCustomizer keyVaultClientProvider, KeyVaultSecretProvider keyVaultSecretProvider, SecretClientBuilderFactory secretClientFactory, - boolean credentialsConfigured, int timeout) { + boolean credentialsConfigured) { this.keyVaultClientProvider = keyVaultClientProvider; this.keyVaultSecretProvider = keyVaultSecretProvider; this.secretClientFactory = secretClientFactory; keyVaultClients = new HashMap<>(); this.credentialsConfigured = credentialsConfigured; isConfigured = keyVaultClientProvider != null || credentialsConfigured; - this.timeout = timeout; } - public AppConfigurationSecretClientManager getClient(String host) { + /** + * Gets or creates a secret client manager for the specified Key Vault host. + * + * @param host the Key Vault host endpoint + * @return the secret client manager for the host + */ + AppConfigurationSecretClientManager getClient(String host) { // Check if we already have a client for this key vault, if not we will make // one if (!keyVaultClients.containsKey(host)) { AppConfigurationSecretClientManager client = new AppConfigurationSecretClientManager(host, - keyVaultClientProvider, keyVaultSecretProvider, secretClientFactory, credentialsConfigured, timeout); + keyVaultClientProvider, keyVaultSecretProvider, secretClientFactory, credentialsConfigured); keyVaultClients.put(host, client); } return keyVaultClients.get(host); } - public boolean isConfigured() { + /** + * Returns if Key Vault is configured to be used. + * + * @return true if either a client provider is configured or credentials are configured + */ + boolean isConfigured() { return isConfigured; } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySource.java index c31069689625..120adc189672 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySource.java @@ -2,58 +2,89 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; import org.springframework.core.env.EnumerablePropertySource; -import com.azure.data.appconfiguration.ConfigurationClient; -import com.azure.data.appconfiguration.models.ConfigurationSetting; +import com.azure.core.util.Context; /** - * Azure App Configuration PropertySource unique per Store Label(Profile) combo. - * + * Abstract base class for Azure App Configuration PropertySource implementations. + * *

- * i.e. If connecting to 2 stores and have 2 labels set 4 AppConfigurationPropertySources need to be - * created. + * Each PropertySource is unique per Store-Label(Profile) combination. For example, if connecting to 2 stores with 2 + * labels each, 4 AppConfigurationPropertySources need to be created. *

*/ -abstract class AppConfigurationPropertySource extends EnumerablePropertySource { +abstract class AppConfigurationPropertySource extends EnumerablePropertySource { + /** + * Cache for storing configuration properties retrieved from Azure App Configuration. + */ protected final Map properties = new LinkedHashMap<>(); - protected final List featureConfigurationSettings = new ArrayList<>(); - + /** + * Client for communicating with Azure App Configuration service. + */ protected final AppConfigurationReplicaClient replicaClient; + /** + * Creates a new AppConfigurationPropertySource. + * + * @param name the name of this property source, should be unique to identify the store-label combination + * @param replicaClient the client for communicating with Azure App Configuration + */ AppConfigurationPropertySource(String name, AppConfigurationReplicaClient replicaClient) { // The context alone does not uniquely define a PropertySource, append storeName // and label to uniquely define a PropertySource - super(name); + super(name, replicaClient); this.replicaClient = replicaClient; } + /** + * Returns the names of all properties in this property source. + * + * @return array of property names + */ @Override public String[] getPropertyNames() { - Set keySet = properties.keySet(); - return keySet.toArray(new String[keySet.size()]); + return properties.keySet().toArray(String[]::new); } + /** + * Returns the value of the specified property. + * + * @param name the name of the property to retrieve + * @return the value of the property, or null if not found + */ @Override public Object getProperty(String name) { return properties.get(name); } + /** + * Creates a comma-separated string from the given label filters. + * + * @param labelFilters array of label filters, may be null + * @return comma-separated string of labels, or empty string if null/empty + */ protected static String getLabelName(String[] labelFilters) { - if (labelFilters == null) { + if (labelFilters == null || labelFilters.length == 0) { return ""; } return String.join(",", labelFilters); } - protected abstract void initProperties(List trim, boolean isRefresh) throws InvalidConfigurationPropertyValueException; + /** + * Initializes the properties for this property source by loading them from Azure App Configuration. + * + * @param trim list of key prefixes to trim from configuration keys + * @param context the context for loading properties, may contain additional metadata + * @throws InvalidConfigurationPropertyValueException if there are issues with the configuration properties + */ + protected abstract void initProperties(List trim, Context context) + throws InvalidConfigurationPropertyValueException; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocator.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocator.java deleted file mode 100644 index 04de646b0631..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocator.java +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation; - -import static org.springframework.cloud.bootstrap.config.PropertySourceBootstrapConfiguration.BOOTSTRAP_PROPERTY_SOURCE_NAME; - -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cloud.bootstrap.config.PropertySourceLocator; -import org.springframework.core.env.CompositePropertySource; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.Environment; -import org.springframework.core.env.PropertySource; -import org.springframework.util.StringUtils; - -import com.azure.data.appconfiguration.models.ConfigurationSetting; -import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProviderProperties; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagKeyValueSelector; - -/** - * Locates Azure App Configuration Property Sources. - */ -public final class AppConfigurationPropertySourceLocator implements PropertySourceLocator { - - private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationPropertySourceLocator.class); - - private static final String PROPERTY_SOURCE_NAME = "azure-config-store"; - - private static final String REFRESH_ARGS_PROPERTY_SOURCE = "refreshArgs"; - - private final List configStores; - - private final AppConfigurationProviderProperties appProperties; - - private final AppConfigurationReplicaClientFactory clientFactory; - - private final AppConfigurationKeyVaultClientFactory keyVaultClientFactory; - - private final FeatureFlagClient featureFlagClient; - - private final ReplicaLookUp replicaLookUp; - - private Duration refreshInterval; - - static final AtomicBoolean STARTUP = new AtomicBoolean(true); - - /** - * Loads all Azure App Configuration Property Sources configured. - * - * @param properties Configurations for stores to be loaded. - * @param appProperties Configurations for the library. - * @param clientFactory factory for creating clients for connecting to Azure App Configuration. - * @param keyVaultClientFactory factory for creating clients for connecting to Azure Key Vault - * @param featureFlagLoader service for loadingFeatureFlags. - */ - public AppConfigurationPropertySourceLocator(AppConfigurationProviderProperties appProperties, - AppConfigurationReplicaClientFactory clientFactory, AppConfigurationKeyVaultClientFactory keyVaultClientFactory, - Duration refreshInterval, List configStores, ReplicaLookUp replicaLookUp, - FeatureFlagClient featureFlagLoader) { - this.refreshInterval = refreshInterval; - this.appProperties = appProperties; - this.configStores = configStores; - this.clientFactory = clientFactory; - this.keyVaultClientFactory = keyVaultClientFactory; - this.replicaLookUp = replicaLookUp; - this.featureFlagClient = featureFlagLoader; - - BackoffTimeCalculator.setDefaults(appProperties.getDefaultMaxBackoff(), appProperties.getDefaultMinBackoff()); - } - - @Override - public PropertySource locate(Environment environment) { - if (!(environment instanceof ConfigurableEnvironment)) { - return null; - } - replicaLookUp.updateAutoFailoverEndpoints(); - - ConfigurableEnvironment env = (ConfigurableEnvironment) environment; - boolean currentlyLoaded = env.getPropertySources().stream().anyMatch(source -> { - String storeName = configStores.get(0).getEndpoint(); - if (configStores.get(0).getSelects().size() == 0) { - return false; - } - AppConfigurationKeyValueSelector selectedKey = configStores.get(0).getSelects().get(0); - return source.getName() - .startsWith(BOOTSTRAP_PROPERTY_SOURCE_NAME + "-" + selectedKey.getKeyFilter() + storeName + "/"); - }); - if (currentlyLoaded && !env.getPropertySources().contains(REFRESH_ARGS_PROPERTY_SOURCE)) { - return null; - } - - List profiles = Arrays.asList(env.getActiveProfiles()); - - CompositePropertySource composite = new CompositePropertySource(PROPERTY_SOURCE_NAME); - Collections.reverse(configStores); // Last store has the highest precedence - - StateHolder newState = new StateHolder(); - newState.setNextForcedRefresh(refreshInterval); - - // Feature Management needs to be set in the last config store. - for (ConfigStore configStore : configStores) { - boolean loadNewPropertySources = STARTUP.get() || StateHolder.getLoadState(configStore.getEndpoint()); - - if (configStore.isEnabled() && loadNewPropertySources) { - // There is only one Feature Set for all AppConfigurationPropertySources - - List clients = clientFactory - .getAvailableClients(configStore.getEndpoint(), true); - - boolean generatedPropertySources = false; - - List sourceList = new ArrayList<>(); - boolean reloadFailed = false; - - for (AppConfigurationReplicaClient client : clients) { - sourceList = new ArrayList<>(); - - if (!STARTUP.get() && reloadFailed && !AppConfigurationRefreshUtil - .checkStoreAfterRefreshFailed(client, clientFactory, configStore.getFeatureFlags())) { - // This store doesn't have any changes where to refresh store did. Skipping Checking next. - continue; - } - - // Reverse in order to add Profile specific properties earlier, and last profile comes first - try { - List sources = createSettings(client, configStore, profiles); - List featureFlags = createFeatureFlags(client, configStore, profiles); - sourceList.addAll(sources); - - LOGGER.debug("PropertySource context."); - setupMonitoring(configStore, client, sources, newState, featureFlags); - - generatedPropertySources = true; - } catch (AppConfigurationStatusException e) { - reloadFailed = true; - clientFactory.backoffClientClient(configStore.getEndpoint(), client.getEndpoint()); - } catch (Exception e) { - newState = failedToGeneratePropertySource(configStore, newState, e); - - // Not a retiable error - break; - } - if (generatedPropertySources) { - break; - } - } - - if (generatedPropertySources) { - // Updating list of propertySources - sourceList.forEach(composite::addPropertySource); - } else if (!STARTUP.get() || (configStore.isFailFast() && STARTUP.get())) { - String message = "Failed to generate property sources for " + configStore.getEndpoint(); - - // Refresh failed for a config store ending attempt - failedToGeneratePropertySource(configStore, newState, new RuntimeException(message)); - } - - if (featureFlagClient.getProperties().size() > 0) { - // This can be true if feature flags are enabled or if a Snapshot contained feature flags - AppConfigurationFeatureManagementPropertySource acfmps = new AppConfigurationFeatureManagementPropertySource( - featureFlagClient); - composite.addPropertySource(acfmps); - } - - } else if (!configStore.isEnabled() && loadNewPropertySources) { - LOGGER.info("Not loading configurations from {} as it is not enabled.", configStore.getEndpoint()); - } else { - LOGGER.warn("Not loading configurations from {} as it failed on startup.", configStore.getEndpoint()); - } - } - - StateHolder.updateState(newState); - STARTUP.set(false); - - return composite; - } - - private void setupMonitoring(ConfigStore configStore, AppConfigurationReplicaClient client, - List sources, StateHolder newState, List featureFlags) { - AppConfigurationStoreMonitoring monitoring = configStore.getMonitoring(); - - if (configStore.getFeatureFlags().getEnabled()) { - newState.setStateFeatureFlag(configStore.getEndpoint(), featureFlags, - monitoring.getFeatureFlagRefreshInterval()); - } - - if (monitoring.isEnabled()) { - // Setting new ETag values for Watch - List watchKeysSettings = monitoring.getTriggers().stream() - .map(trigger -> client.getWatchKey(trigger.getKey(), trigger.getLabel(), !STARTUP.get())).toList(); - - newState.setState(configStore.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); - } - newState.setLoadState(configStore.getEndpoint(), true, configStore.isFailFast()); - } - - private StateHolder failedToGeneratePropertySource(ConfigStore configStore, StateHolder newState, Exception e) { - String message = "Failed to generate property sources for " + configStore.getEndpoint(); - if (!STARTUP.get()) { - // Need to check for refresh first, or reset will never happen if fail fast is true. - LOGGER.error("Refreshing failed while reading configuration from Azure App Configuration store " - + configStore.getEndpoint() + "."); - - if (refreshInterval != null) { - // The next refresh will happen sooner if refresh interval is expired. - newState.updateNextRefreshTime(refreshInterval, appProperties.getDefaultMinBackoff()); - } - throw new RuntimeException(message, e); - } else if (configStore.isFailFast()) { - LOGGER.error("Fail fast is set and there was an error reading configuration from Azure App " - + "Configuration store " + configStore.getEndpoint() + "."); - delayException(); - throw new RuntimeException(message, e); - } else { - LOGGER.warn( - "Unable to load configuration from Azure AppConfiguration store " + configStore.getEndpoint() + ".", e); - newState.setLoadState(configStore.getEndpoint(), false, configStore.isFailFast()); - } - return newState; - } - - /** - * Creates a new set of AppConfigurationPropertySources, 1 per Label. - * - * @param client client for connecting to App Configuration - * @param store Config Store the PropertySource is being generated from - * @param profiles active profiles to be used as labels. it needs to be in the last one. - * @return a list of AppConfigurationPropertySources - * @throws Exception creating a property source failed - */ - private List createSettings(AppConfigurationReplicaClient client, ConfigStore store, - List profiles) throws Exception { - List sourceList = new ArrayList<>(); - List selects = store.getSelects(); - - for (AppConfigurationKeyValueSelector selectedKeys : selects) { - AppConfigurationPropertySource propertySource = null; - - if (StringUtils.hasText(selectedKeys.getSnapshotName())) { - propertySource = new AppConfigurationSnapshotPropertySource( - selectedKeys.getSnapshotName() + "/" + store.getEndpoint(), client, keyVaultClientFactory, - selectedKeys.getSnapshotName(), featureFlagClient); - } else { - propertySource = new AppConfigurationApplicationSettingPropertySource( - selectedKeys.getKeyFilter() + store.getEndpoint() + "/", client, keyVaultClientFactory, - selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles)); - } - propertySource.initProperties(store.getTrimKeyPrefix(), !STARTUP.get()); - sourceList.add(propertySource); - - } - - return sourceList; - } - - /** - * Creates a new set of AppConfigurationPropertySources, 1 per Label. - * - * @param client client for connecting to App Configuration - * @param store Config Store the PropertySource is being generated from - * @param profiles active profiles to be used as labels. it needs to be in the last one. - * @return a list of AppConfigurationPropertySources - * @throws Exception creating a property source failed - */ - private List createFeatureFlags(AppConfigurationReplicaClient client, ConfigStore store, - List profiles) throws Exception { - List featureFlagWatchKeys = new ArrayList<>(); - if (store.getFeatureFlags().getEnabled()) { - for (FeatureFlagKeyValueSelector selectedKeys : store.getFeatureFlags().getSelects()) { - List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, - selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), !STARTUP.get()); - storesFeatureFlags.forEach(featureFlags -> featureFlags.setConfigStore(store)); - featureFlagWatchKeys.addAll(storesFeatureFlags); - } - } - return featureFlagWatchKeys; - } - - private void delayException() { - Instant currentDate = Instant.now(); - Instant preKillTIme = appProperties.getStartDate().plusSeconds(appProperties.getPrekillTime()); - if (currentDate.isBefore(preKillTIme)) { - long diffInMillies = Math.abs(preKillTIme.toEpochMilli() - currentDate.toEpochMilli()); - try { - Thread.sleep(diffInMillies); - } catch (InterruptedException e) { - LOGGER.error("Failed to wait before fast fail."); - } - } - } -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java index 44c59c1583a2..cb5d5735d39b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java @@ -20,55 +20,81 @@ import reactor.core.publisher.Mono; /** - * Enables checking of Configuration updates. + * Component responsible for checking Azure App Configuration for updates and triggering refresh events. */ @Component public class AppConfigurationPullRefresh implements AppConfigurationRefresh { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationPullRefresh.class); + /** + * Flag to prevent concurrent refresh operations. + */ private final AtomicBoolean running = new AtomicBoolean(false); + /** + * Publisher for Spring refresh events. + */ private ApplicationEventPublisher publisher; + private final Long defaultMinBackoff = (long) 30; - private final Long defaultMinBackoff; + /** + * Default minimum backoff duration in seconds when refresh operations fail. + */ + private static final Long DEFAULT_MIN_BACKOFF_SECONDS = 30L; + /** + * Factory for creating App Configuration replica clients. + */ private final AppConfigurationReplicaClientFactory clientFactory; + /** + * Time interval between configuration refresh checks. + */ private final Duration refreshInterval; - + + /** + * Component for replica lookup and failover functionality. + */ private final ReplicaLookUp replicaLookUp; - + + /** + * Utility component for refresh operations. + */ private final AppConfigurationRefreshUtil refreshUtils; /** - * Component used for checking for and triggering configuration refreshes. + * Creates a new AppConfigurationPullRefresh component. * - * @param clientFactory Clients stores used to connect to App Configuration. * @param defaultMinBackoff default - * @param refreshInterval time between refresh intervals - * @param defaultMinBackoff minimum time between backoff retries minimum backoff time + * @param clientFactory factory for creating App Configuration clients to connect to stores + * @param refreshInterval time duration between refresh interval checks + * @param replicaLookUp component for handling replica lookup and failover + * @param refreshUtils utility component for refresh operations */ public AppConfigurationPullRefresh(AppConfigurationReplicaClientFactory clientFactory, Duration refreshInterval, - Long defaultMinBackoff, ReplicaLookUp replicaLookUp, AppConfigurationRefreshUtil refreshUtils) { - this.defaultMinBackoff = defaultMinBackoff; + ReplicaLookUp replicaLookUp, AppConfigurationRefreshUtil refreshUtils) { this.refreshInterval = refreshInterval; this.clientFactory = clientFactory; this.replicaLookUp = replicaLookUp; this.refreshUtils = refreshUtils; - } + /** + * Sets the Spring application event publisher for publishing refresh events. + * + * @param applicationEventPublisher the Spring event publisher to use for refresh events + */ @Override public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { this.publisher = applicationEventPublisher; } /** - * Checks configurations to see if configurations should be reloaded. If the refresh interval has passed and a - * trigger has been updated configuration are reloaded. - * - * @return Future with a boolean of if a RefreshEvent was published. If refreshConfigurations is currently being run - * elsewhere this method will return right away as false. + * Checks configurations to see if they should be reloaded. If the refresh interval has passed and a trigger has + * been updated, configurations are reloaded. + * + * @return a Mono containing a boolean indicating if a RefreshEvent was published. Returns {@code false} if + * refreshConfigurations is currently being executed elsewhere. */ public Mono refreshConfigurations() { return Mono.just(refreshStores()); @@ -76,9 +102,10 @@ public Mono refreshConfigurations() { /** * Soft expires refresh interval. Sets amount of time to next refresh to be a random value between 0 and 15 seconds, - * unless value is less than the amount of time to the next refresh check. - * @param endpoint Config Store endpoint to expire refresh interval on. - * @param syncToken syncToken to verify the latest changes are available on pull + * unless that value is less than the amount of time to the next refresh check. + * + * @param endpoint the Config Store endpoint to expire refresh interval on + * @param syncToken the syncToken to verify the latest changes are available on pull */ public void expireRefreshInterval(String endpoint, String syncToken) { LOGGER.debug("Expiring refresh interval for " + endpoint); @@ -92,22 +119,23 @@ public void expireRefreshInterval(String endpoint, String syncToken) { /** * Goes through each config store and checks if any of its keys need to be refreshed. If any store has a value that - * needs to be updated a refresh event is called after every store is checked. + * needs to be updated, a refresh event is called after every store is checked. * - * @return If a refresh event is called. + * @return true if a refresh event is published, false otherwise */ private boolean refreshStores() { if (running.compareAndSet(false, true)) { try { RefreshEventData eventData = refreshUtils.refreshStoresCheck(clientFactory, - refreshInterval, defaultMinBackoff, replicaLookUp); + refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS, replicaLookUp); if (eventData.getDoRefresh()) { publisher.publishEvent(new RefreshEvent(this, eventData, eventData.getMessage())); return true; } } catch (Exception e) { + LOGGER.warn("Error occurred during configuration refresh, will retry at next interval", e); // The next refresh will happen sooner if refresh interval is expired. - StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, defaultMinBackoff); + StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS); throw e; } finally { running.set(false); @@ -116,6 +144,11 @@ private boolean refreshStores() { return false; } + /** + * Gets the health status of all configured App Configuration stores. + * + * @return a map containing the health status of each store, keyed by store identifier + */ @Override public Map getAppConfigurationStoresHealth() { return clientFactory.getHealth(); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java index ca1cc33c3905..f347bbe28e96 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java @@ -2,6 +2,8 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.PUSH_REFRESH; + import java.time.Duration; import java.time.Instant; import java.util.List; @@ -9,24 +11,33 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; import com.azure.core.exception.HttpResponseException; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; +/** + * Utility class for handling Azure App Configuration refresh operations. + */ public class AppConfigurationRefreshUtil { - private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationPullRefresh.class); + private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshUtil.class); /** - * Goes through each config store and checks if any of its keys need to be refreshed. If any store has a value that - * needs to be updated a refresh event is called after every store is checked. + * Checks all configured stores to determine if any configurations need to be refreshed. * - * @return If a refresh event is called. + * @param clientFactory factory for accessing App Configuration clients + * @param refreshInterval the time interval between refresh checks + * @param defaultMinBackoff the minimum backoff time for failed operations + * @param replicaLookUp component for handling replica failover + * @return RefreshEventData containing information about whether a refresh should occur */ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientFactory, Duration refreshInterval, Long defaultMinBackoff, ReplicaLookUp replicaLookUp) { @@ -51,13 +62,23 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF AppConfigurationStoreMonitoring monitor = connection.getMonitoring(); + boolean pushRefresh = false; + PushNotification notification = monitor.getPushNotification(); + if ((notification.getPrimaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName())) + || (notification.getSecondaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName()))) { + pushRefresh = true; + } + Context context = new Context("refresh", true).addData(PUSH_REFRESH, pushRefresh); + List clients = clientFactory.getAvailableClients(originEndpoint); if (monitor.isEnabled() && StateHolder.getLoadState(originEndpoint)) { for (AppConfigurationReplicaClient client : clients) { try { refreshWithTime(client, StateHolder.getState(originEndpoint), monitor.getRefreshInterval(), - eventData, replicaLookUp); + eventData, replicaLookUp, context); if (eventData.getDoRefresh()) { clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); return eventData; @@ -66,9 +87,11 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF break; } catch (HttpResponseException e) { LOGGER.warn( - "Failed attempting to connect to " + client.getEndpoint() + " during refresh check."); + "Failed to connect to App Configuration store {} during configuration refresh check. " + + "Status: {}, Message: {}", + client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - clientFactory.backoffClientClient(originEndpoint, client.getEndpoint()); + clientFactory.backoffClient(originEndpoint, client.getEndpoint()); } } } else { @@ -81,7 +104,7 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF for (AppConfigurationReplicaClient client : clients) { try { refreshWithTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(originEndpoint), - monitor.getFeatureFlagRefreshInterval(), eventData, replicaLookUp); + monitor.getFeatureFlagRefreshInterval(), eventData, replicaLookUp, context); if (eventData.getDoRefresh()) { clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); return eventData; @@ -90,9 +113,11 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF break; } catch (HttpResponseException e) { LOGGER.warn( - "Failed attempting to connect to " + client.getEndpoint() + " during refresh check."); + "Failed to connect to App Configuration store {} during feature flag refresh check. " + + "Status: {}, Message: {}", + client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - clientFactory.backoffClientClient(originEndpoint, client.getEndpoint()); + clientFactory.backoffClient(originEndpoint, client.getEndpoint()); } } } else { @@ -108,41 +133,39 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF return eventData; } - static boolean checkStoreAfterRefreshFailed(AppConfigurationReplicaClient client, - AppConfigurationReplicaClientFactory clientFactory, FeatureFlagStore featureStore) { - return refreshStoreCheck(client, clientFactory.findOriginForEndpoint(client.getEndpoint())) - || refreshStoreFeatureFlagCheck(featureStore, client); - } - /** - * This is for a refresh fail only. + * Performs a refresh check for a specific store client without time constraints. This method is used for refresh + * failure scenarios only. * - * @param client Client checking for refresh - * @param originEndpoint config store origin endpoint - * @return A refresh should be triggered. + * @param client the client for checking refresh status + * @param originEndpoint the original config store endpoint + * @param context the operation context + * @return true if a refresh should be triggered, false otherwise */ - private static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint) { + static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint, Context context) { RefreshEventData eventData = new RefreshEventData(); if (StateHolder.getLoadState(originEndpoint)) { - refreshWithoutTime(client, StateHolder.getState(originEndpoint).getWatchKeys(), eventData); + refreshWithoutTime(client, StateHolder.getState(originEndpoint).getWatchKeys(), eventData, context); } return eventData.getDoRefresh(); } /** - * This is for a refresh fail only. + * Performs a feature flag refresh check for a specific store client. This method is used for refresh failure + * scenarios only. * - * @param featureStore Feature info for the store - * @param client Client checking for refresh - * @return true if a refresh should be triggered. + * @param featureStoreEnabled whether feature store is enabled + * @param client the client for checking refresh status + * @param context the operation context + * @return true if a refresh should be triggered, false otherwise */ - private static boolean refreshStoreFeatureFlagCheck(FeatureFlagStore featureStore, - AppConfigurationReplicaClient client) { + static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled, + AppConfigurationReplicaClient client, Context context) { RefreshEventData eventData = new RefreshEventData(); String endpoint = client.getEndpoint(); - if (featureStore.getEnabled() && StateHolder.getStateFeatureFlag(endpoint) != null) { - refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData); + if (featureStoreEnabled && StateHolder.getStateFeatureFlag(endpoint) != null) { + refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData, context); } else { LOGGER.debug("Skipping feature flag refresh check for " + endpoint); } @@ -150,33 +173,42 @@ private static boolean refreshStoreFeatureFlagCheck(FeatureFlagStore featureStor } /** - * Checks refresh trigger for etag changes. If they have changed a RefreshEventData is published. + * Checks configuration refresh triggers for etag changes with time-based validation. Only performs the refresh + * check if the refresh interval has elapsed. * - * @param state The refresh state of the endpoint being checked. - * @param refreshInterval Amount of time to wait until next check of this endpoint. - * @param eventData Info for this refresh event. + * @param client the App Configuration client to use for checking + * @param state the current refresh state of the endpoint being checked + * @param refreshInterval the time duration to wait until next check of this endpoint + * @param eventData the refresh event data to update if changes are detected + * @param replicaLookUp component for updating auto-failover endpoints + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check */ private static void refreshWithTime(AppConfigurationReplicaClient client, State state, Duration refreshInterval, - RefreshEventData eventData, ReplicaLookUp replicaLookUp) throws AppConfigurationStatusException { + RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context) + throws AppConfigurationStatusException { if (Instant.now().isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); - refreshWithoutTime(client, state.getWatchKeys(), eventData); + refreshWithoutTime(client, state.getWatchKeys(), eventData, context); StateHolder.getCurrentState().updateStateRefresh(state, refreshInterval); } } /** - * Checks refresh trigger for etag changes. If they have changed a RefreshEventData is published. + * Checks configuration refresh triggers for etag changes without time validation. This method immediately checks + * all watch keys for changes regardless of refresh intervals. * - * @param client Client checking for refresh - * @param watchKeys Watch keys for the store. - * @param eventData Refresh event info + * @param client the App Configuration client to use for checking + * @param watchKeys the list of configuration settings to watch for changes + * @param eventData the refresh event data to update if changes are detected + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check */ private static void refreshWithoutTime(AppConfigurationReplicaClient client, List watchKeys, - RefreshEventData eventData) throws AppConfigurationStatusException { + RefreshEventData eventData, Context context) throws AppConfigurationStatusException { for (ConfigurationSetting watchKey : watchKeys) { - ConfigurationSetting watchedKey = client.getWatchKey(watchKey.getKey(), watchKey.getLabel(), true); + ConfigurationSetting watchedKey = client.getWatchKey(watchKey.getKey(), watchKey.getLabel(), context); // If there is no result, etag will be considered empty. // A refresh will trigger once the selector returns a value. @@ -189,16 +221,27 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis } } + /** + * Checks feature flag refresh triggers with time-based validation. Only performs the refresh check if the refresh + * interval has elapsed. + * + * @param client the App Configuration client to use for checking + * @param state the current feature flag state of the endpoint being checked + * @param refreshInterval the time duration to wait until next check of this endpoint + * @param eventData the refresh event data to update if changes are detected + * @param replicaLookUp component for updating auto-failover endpoints + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check + */ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState state, - Duration refreshInterval, RefreshEventData eventData, ReplicaLookUp replicaLookUp) + Duration refreshInterval, RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context) throws AppConfigurationStatusException { Instant date = Instant.now(); if (date.isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); for (FeatureFlags featureFlags : state.getWatchKeys()) { - - if (client.checkWatchKeys(featureFlags.getSettingSelector(), true)) { + if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { String eventDataInfo = ".appconfig.featureflag/*"; // Only one refresh Event needs to be call to update all of the @@ -215,12 +258,21 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl } } + /** + * Checks feature flag refresh triggers without time validation. This method immediately checks all feature flag + * watch keys for changes regardless of refresh intervals. + * + * @param client the App Configuration client to use for checking + * @param watchKeys the feature flag state containing watch keys to check for changes + * @param eventData the refresh event data to update if changes are detected + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check + */ private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState watchKeys, - RefreshEventData eventData) throws AppConfigurationStatusException { + RefreshEventData eventData, Context context) throws AppConfigurationStatusException { for (FeatureFlags featureFlags : watchKeys.getWatchKeys()) { - - if (client.checkWatchKeys(featureFlags.getSettingSelector(), true)) { + if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { String eventDataInfo = ".appconfig.featureflag/*"; // Only one refresh Event needs to be call to update all of the @@ -233,13 +285,23 @@ private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient } } + /** + * Checks the etag values between watched and current configuration settings to determine if a refresh is needed. + * + * @param watchSetting the configuration setting being watched for changes + * @param currentTriggerConfiguration the current configuration setting from the store + * @param endpoint the endpoint of the configuration store + * @param eventData the refresh event data to update if a change is detected + */ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSetting currentTriggerConfiguration, String endpoint, RefreshEventData eventData) { if (currentTriggerConfiguration == null) { return; } - LOGGER.debug(watchSetting.getETag(), " - ", currentTriggerConfiguration.getETag()); + LOGGER.debug("Comparing eTags - watched: {} vs current: {}", + watchSetting.getETag(), currentTriggerConfiguration.getETag()); + if (watchSetting.getETag() != null && !watchSetting.getETag().equals(currentTriggerConfiguration.getETag())) { LOGGER.trace("Some keys in store [{}] matching the key [{}] and label [{}] is updated, " + "will send refresh event.", endpoint, watchSetting.getKey(), watchSetting.getLabel()); @@ -254,7 +316,7 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe } /** - * For each refresh, multiple etags can change, but even one etag is changed, refresh is required. + * Data structure containing information about a refresh event. */ static class RefreshEventData { @@ -264,25 +326,50 @@ static class RefreshEventData { private boolean doRefresh = false; + /** + * Creates a new RefreshEventData with empty message and refresh flag set to false. + */ RefreshEventData() { this.message = ""; } + /** + * Sets the refresh message using the standard message template. + * + * @param prefix the prefix to include in the message (typically a key name) + * @return this RefreshEventData instance for method chaining + */ RefreshEventData setMessage(String prefix) { setFullMessage(String.format(MSG_TEMPLATE, prefix)); return this; } - RefreshEventData setFullMessage(String message) { + /** + * Sets the full refresh message and marks that a refresh should occur. + * + * @param message the complete message describing the refresh event + * @return this RefreshEventData instance for method chaining + */ + private RefreshEventData setFullMessage(String message) { this.message = message; this.doRefresh = true; return this; } + /** + * Gets the refresh event message. + * + * @return the message describing what triggered the refresh + */ public String getMessage() { return this.message; } + /** + * Indicates whether a refresh should be performed. + * + * @return true if a refresh is needed, false otherwise + */ public boolean getDoRefresh() { return doRefresh; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java index 9c43522cafcb..0cde712d5867 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java @@ -22,36 +22,43 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; -import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.TracingInfo; import io.netty.handler.codec.http.HttpResponseStatus; /** * Client for connecting to App Configuration when multiple replicas are in use. + * + * The client automatically retries retryable HTTP errors (429, 408, 5xx) and implements backoff logic to prevent + * overwhelming failing replicas. */ class AppConfigurationReplicaClient { + private static final long INITIAL_BACKOFF_OFFSET_MS = 1L; + + /** HTTP status code for "Not Modified" responses */ + private static final int HTTP_NOT_MODIFIED = 304; private final String endpoint; + private final String originClient; + private final ConfigurationClient client; private Instant backoffEndTime; private int failedAttempts; - private final TracingInfo tracingInfo; - /** * Holds Configuration Client and info needed to manage backoff. * @param endpoint client endpoint + * @param originClient origin client identifier * @param client Configuration Client to App Configuration store */ - AppConfigurationReplicaClient(String endpoint, ConfigurationClient client, TracingInfo tracingInfo) { + AppConfigurationReplicaClient(String endpoint, String originClient, ConfigurationClient client) { this.endpoint = endpoint; + this.originClient = originClient; this.client = client; - this.backoffEndTime = Instant.now().minusMillis(1); + this.backoffEndTime = Instant.now().minusMillis(INITIAL_BACKOFF_OFFSET_MS); this.failedAttempts = 0; - this.tracingInfo = tracingInfo; } /** @@ -84,18 +91,26 @@ String getEndpoint() { return endpoint; } + /** + * @return originClient + */ + String getOriginClient() { + return originClient; + } + /** * Gets the Configuration Setting for the given config store that match the Setting Selector criteria. Follows * retry-after-ms header. * * @param key String value of the watch key * @param label String value of the watch key, use \0 for null. + * @param context Azure SDK context for request correlation * @return The first returned configuration. + * @throws HttpResponseException if the request fails */ - ConfigurationSetting getWatchKey(String key, String label, Boolean isRefresh) + ConfigurationSetting getWatchKey(String key, String label, Context context) throws HttpResponseException { try { - Context context = new Context("refresh", isRefresh); ConfigurationSetting selector = new ConfigurationSetting().setKey(key).setLabel(label); ConfigurationSetting watchKey = NormalizeNull .normalizeNullLabel( @@ -103,7 +118,7 @@ ConfigurationSetting getWatchKey(String key, String label, Boolean isRefresh) this.failedAttempts = 0; return watchKey; } catch (HttpResponseException e) { - throw hanndleHttpResponseException(e); + throw handleHttpResponseException(e); } catch (UncheckedIOException e) { throw new AppConfigurationStatusException(e.getMessage(), null, null); } @@ -113,13 +128,14 @@ ConfigurationSetting getWatchKey(String key, String label, Boolean isRefresh) * Gets a list of Configuration Settings from the given config store that match the Setting Selector criteria. * * @param settingSelector Information on which setting to pull. i.e. number of results, key value... + * @param context Azure SDK context for request correlation * @return List of Configuration Settings. + * @throws HttpResponseException if the request fails */ - List listSettings(SettingSelector settingSelector, Boolean isRefresh) + List listSettings(SettingSelector settingSelector, Context context) throws HttpResponseException { List configurationSettings = new ArrayList<>(); try { - Context context = new Context("refresh", isRefresh); PagedIterable settings = client.listConfigurationSettings(settingSelector, context); settings.forEach(setting -> { configurationSettings.add(NormalizeNull.normalizeNullLabel(setting)); @@ -128,17 +144,25 @@ List listSettings(SettingSelector settingSelector, Boolean this.failedAttempts = 0; return configurationSettings; } catch (HttpResponseException e) { - throw hanndleHttpResponseException(e); + throw handleHttpResponseException(e); } catch (UncheckedIOException e) { throw new AppConfigurationStatusException(e.getMessage(), null, null); } } - FeatureFlags listFeatureFlags(SettingSelector settingSelector, Boolean isRefresh) throws HttpResponseException { + /** + * Lists feature flags from the Azure App Configuration store. + * + * @param settingSelector selector criteria for feature flags + * @param context Azure SDK context for request correlation + * @return FeatureFlags containing the retrieved feature flags and match conditions + * @throws HttpResponseException if the request fails + */ + FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context) + throws HttpResponseException { List configurationSettings = new ArrayList<>(); List checks = new ArrayList<>(); try { - Context context = new Context("refresh", isRefresh); client.listConfigurationSettings(settingSelector, context).streamByPage().forEach(pagedResponse -> { checks.add( new MatchConditions().setIfNoneMatch(pagedResponse.getHeaders().getValue(HttpHeaderName.ETAG))); @@ -153,16 +177,27 @@ FeatureFlags listFeatureFlags(SettingSelector settingSelector, Boolean isRefresh settingSelector.setMatchConditions(checks); return new FeatureFlags(settingSelector, configurationSettings); } catch (HttpResponseException e) { - throw hanndleHttpResponseException(e); + throw handleHttpResponseException(e); } catch (UncheckedIOException e) { throw new AppConfigurationStatusException(e.getMessage(), null, null); } } - List listSettingSnapshot(String snapshotName) { + /** + * Lists configuration settings from a specific snapshot. + * + * @param snapshotName the name of the snapshot to retrieve settings from + * @param context Azure SDK context for request correlation + * @return list of configuration settings from the snapshot + * @throws IllegalArgumentException if the snapshot is not of type KEY + * @throws HttpResponseException if the request fails + */ + List listSettingSnapshot(String snapshotName, Context context) { List configurationSettings = new ArrayList<>(); try { - ConfigurationSnapshot snapshot = client.getSnapshot(snapshotName); + // Because Spring always refreshes all we still have to load snapshots on refresh to build the property + // sources. + ConfigurationSnapshot snapshot = client.getSnapshotWithResponse(snapshotName, null, context).getValue(); if (!SnapshotComposition.KEY.equals(snapshot.getSnapshotComposition())) { throw new IllegalArgumentException("Snapshot " + snapshotName + " needs to be of type Key."); } @@ -172,16 +207,16 @@ List listSettingSnapshot(String snapshotName) { settings.forEach(setting -> configurationSettings.add(NormalizeNull.normalizeNullLabel(setting))); return configurationSettings; } catch (HttpResponseException e) { - throw hanndleHttpResponseException(e); + throw handleHttpResponseException(e); } catch (UncheckedIOException e) { throw new AppConfigurationStatusException(e.getMessage(), null, null); } } - Boolean checkWatchKeys(SettingSelector settingSelector, Boolean isRefresh) { - Context context = new Context("refresh", isRefresh); - List> results = client.listConfigurationSettings(settingSelector, context) - .streamByPage().filter(pagedResponse -> pagedResponse.getStatusCode() != 304).toList(); + boolean checkWatchKeys(SettingSelector settingSelector, Context context) { + List> results = client + .listConfigurationSettings(settingSelector, context) + .streamByPage().filter(pagedResponse -> pagedResponse.getStatusCode() != HTTP_NOT_MODIFIED).toList(); return results.size() > 0; } @@ -195,7 +230,14 @@ void updateSyncToken(String syncToken) { } } - private HttpResponseException hanndleHttpResponseException(HttpResponseException e) { + /** + * Handles HTTP response exceptions by determining if they are retryable. + * + * @param e the HTTP response exception to handle + * @return an AppConfigurationStatusException for retryable errors, or the original exception for non-retryable + * errors + */ + private HttpResponseException handleHttpResponseException(HttpResponseException e) { if (e.getResponse() != null) { int statusCode = e.getResponse().getStatusCode(); @@ -208,8 +250,4 @@ private HttpResponseException hanndleHttpResponseException(HttpResponseException return e; } - TracingInfo getTracingInfo() { - return tracingInfo; - } - } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java index 4f3fd987cc2f..63b2fd4db0d7 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java @@ -11,24 +11,27 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; /** - * Manages all client connections for all configuration stores. + * Manages all client connections for all configuration stores with support for replica failover. */ public class AppConfigurationReplicaClientFactory { + /** Map of connection managers keyed by origin endpoint */ private static final Map CONNECTIONS = new HashMap<>(); + /** List of configured stores for endpoint resolution */ private final List configStores; /** - * Sets up Connections to all configuration stores. + * Sets up connections to all configuration stores with replica support. * - * @param clientBuilder builder for app configuration replica clients - * @param configStores configuration info for config stores + * @param clientBuilder builder for creating app configuration replica clients + * @param configStores configuration information for all config stores + * @param replicaLookUp service for discovering and managing replica endpoints */ - public AppConfigurationReplicaClientFactory(AppConfigurationReplicaClientsBuilder clientBuilder, + AppConfigurationReplicaClientFactory(AppConfigurationReplicaClientsBuilder clientBuilder, List configStores, ReplicaLookUp replicaLookUp) { this.configStores = configStores; - if (CONNECTIONS.size() == 0) { + if (CONNECTIONS.isEmpty()) { for (ConfigStore store : configStores) { ConnectionManager manager = new ConnectionManager(clientBuilder, store, replicaLookUp); CONNECTIONS.put(manager.getMainEndpoint(), manager); @@ -37,42 +40,49 @@ public AppConfigurationReplicaClientFactory(AppConfigurationReplicaClientsBuilde } /** - * @return the connections + * Gets all connection managers mapped by their origin endpoints. + * + * @return map of endpoint to connection manager */ public Map getConnections() { return CONNECTIONS; } /** - * Returns the current used endpoint for a given config store. - * @param originEndpoint identifier of the store. The identifier is the primary endpoint of the store. - * @return ConfigurationClient for accessing App Configuration + * Returns available replica clients for a given configuration store. + * + * @param originEndpoint identifier of the store (primary endpoint) + * @return list of available replica clients for the store */ List getAvailableClients(String originEndpoint) { - return CONNECTIONS.get(originEndpoint).getAvailableClients(); + return getAvailableClients(originEndpoint, false); } /** - * Returns the current used endpoint for a given config store. - * @param originEndpoint identifier of the store. The identifier is the primary endpoint of the store. - * @return ConfigurationClient for accessing App Configuration + * Returns available replica clients for a given configuration store with current client preference. + * + * @param originEndpoint identifier of the store (primary endpoint) + * @param useCurrent whether to prefer the currently active client + * @return list of available replica clients for the store */ List getAvailableClients(String originEndpoint, Boolean useCurrent) { return CONNECTIONS.get(originEndpoint).getAvailableClients(useCurrent); } /** - * Sets backoff time for the current client that is being used, and attempts to get a new one. - * @param originEndpoint identifier of the store. The identifier is the primary endpoint of the store. - * @param endpoint replica endpoint + * Sets backoff time for a specific replica client due to connection failure. + * + * @param originEndpoint identifier of the store (primary endpoint) + * @param endpoint the specific replica endpoint that failed */ - void backoffClientClient(String originEndpoint, String endpoint) { + void backoffClient(String originEndpoint, String endpoint) { CONNECTIONS.get(originEndpoint).backoffClient(endpoint); } /** - * Gets the health of the client connections to App Configuration - * @return map of endpoint origin it's health + * Gets the health status of all managed configuration store connections. + * + * @return map of origin endpoint to health status */ Map getHealth() { Map health = new HashMap<>(); @@ -83,48 +93,38 @@ Map getHealth() { } /** - * Returns the origin endpoint for a given endpoint. If not found will return the given endpoint; + * Finds the origin endpoint for a given replica endpoint. * - * @param endpoint App Configuration Endpoint - * @return String Endpoint + * @param endpoint the replica endpoint to find the origin for + * @return the origin endpoint, or the input endpoint if no mapping is found */ String findOriginForEndpoint(String endpoint) { for (ConfigStore store : configStores) { - for (String replica : store.getEndpoints()) { - if (replica.equals(endpoint)) { - return store.getEndpoint(); - } + List replicas = store.getEndpoints(); + if (replicas != null && replicas.contains(endpoint)) { + return store.getEndpoint(); } } return endpoint; } /** - * Checks if a given endpoint has any configured replicas. - * @param endpoint Endpoint to check for replicas - * @return true if at least one other unique endpoint connects to the same configuration store - */ - boolean hasReplicas(String endpoint) { - String originEndpoint = findOriginForEndpoint(endpoint); - for (ConfigStore store : configStores) { - if (store.getEndpoint().equals(originEndpoint)) { - if (store.getConnectionStrings().size() > 0 || store.getEndpoints().size() > 0) { - return true; - } - } - } - return false; - } - - /** - * Sets the replica as the currently used endpoint for connecting to the config store. - * @param originEndpoint Origin Configuration Store - * @param replicaEndpoint Replica that was last successfully connected to. + * Sets the current active replica for a configuration store. + * + * @param originEndpoint the origin configuration store endpoint + * @param replicaEndpoint the replica endpoint that was successfully connected to */ void setCurrentConfigStoreClient(String originEndpoint, String replicaEndpoint) { CONNECTIONS.get(originEndpoint).setCurrentClient(replicaEndpoint); } + /** + * Updates the sync token for a specific replica endpoint. + * + * @param originEndpoint the origin configuration store endpoint + * @param endpoint the specific replica endpoint + * @param syncToken the new sync token to store + */ void updateSyncToken(String originEndpoint, String endpoint, String syncToken) { CONNECTIONS.get(originEndpoint).updateSyncToken(endpoint, syncToken); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java index 3e225980e1fd..6b4d42827626 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java @@ -3,6 +3,8 @@ package com.azure.spring.cloud.appconfiguration.config.implementation; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.time.Duration; import java.util.ArrayList; @@ -14,8 +16,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.convert.DurationStyle; -import org.springframework.context.EnvironmentAware; -import org.springframework.core.env.Environment; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -25,7 +25,8 @@ import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; import com.azure.data.appconfiguration.ConfigurationClientBuilder; -import com.azure.identity.ManagedIdentityCredentialBuilder; +import com.azure.identity.DefaultAzureCredential; +import com.azure.identity.DefaultAzureCredentialBuilder; import com.azure.spring.cloud.appconfiguration.config.ConfigurationClientCustomizer; import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.BaseAppConfigurationPolicy; import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.TracingInfo; @@ -37,22 +38,22 @@ import com.azure.spring.cloud.core.service.AzureServiceType.AppConfiguration; import com.azure.spring.cloud.service.implementation.appconfiguration.ConfigurationClientBuilderFactory; -public class AppConfigurationReplicaClientsBuilder implements EnvironmentAware { +public class AppConfigurationReplicaClientsBuilder { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationReplicaClientsBuilder.class); /** * Invalid Connection String error message */ - public static final String NON_EMPTY_MSG = "%s property should not be null or empty in the connection string of Azure Config Service."; + private static final String NON_EMPTY_MSG = "%s property should not be null or empty in the connection string of Azure Config Service."; - public static final String RETRY_MODE_PROPERTY_NAME = "retry.mode"; + private static final String RETRY_MODE_PROPERTY_NAME = "retry.mode"; - public static final String MAX_RETRIES_PROPERTY_NAME = "retry.exponential.max-retries"; + private static final String MAX_RETRIES_PROPERTY_NAME = "retry.exponential.max-retries"; - public static final String BASE_DELAY_PROPERTY_NAME = "retry.exponential.base-delay"; + private static final String BASE_DELAY_PROPERTY_NAME = "retry.exponential.base-delay"; - public static final String MAX_DELAY_PROPERTY_NAME = "retry.exponential.max-delay"; + private static final String MAX_DELAY_PROPERTY_NAME = "retry.exponential.max-delay"; private static final Duration DEFAULT_MIN_RETRY_POLICY = Duration.ofMillis(800); @@ -66,30 +67,27 @@ public class AppConfigurationReplicaClientsBuilder implements EnvironmentAware { /** * Invalid Formatted Connection String Error message */ - public static final String ENDPOINT_ERR_MSG = String.format("Connection string does not follow format %s.", + private static final String ENDPOINT_ERR_MSG = String.format("Connection string does not follow format %s.", CONN_STRING_REGEXP); private static final Pattern CONN_STRING_PATTERN = Pattern.compile(CONN_STRING_REGEXP); - private ConfigurationClientCustomizer clientProvider; + private ConfigurationClientCustomizer clientCustomizer; private final ConfigurationClientBuilderFactory clientFactory; - private Environment env; - - private boolean isDev = false; - - private boolean isKeyVaultConfigured = false; + private boolean isKeyVaultConfigured; private final boolean credentialConfigured; - private final int defaultMaxRetries; + private final int defaultMaxRetries = 2; - public AppConfigurationReplicaClientsBuilder(int defaultMaxRetries, ConfigurationClientBuilderFactory clientFactory, - boolean credentialConfigured) { - this.defaultMaxRetries = defaultMaxRetries; - this.clientFactory = clientFactory; + AppConfigurationReplicaClientsBuilder(ConfigurationClientBuilderFactory clientFactory, + ConfigurationClientCustomizer clientCustomizer, boolean credentialConfigured, boolean isKeyVaultConfigured) { this.credentialConfigured = credentialConfigured; + this.clientFactory = clientFactory; + this.clientCustomizer = clientCustomizer; + this.isKeyVaultConfigured = isKeyVaultConfigured; } /** @@ -114,17 +112,6 @@ public static String getEndpointFromConnectionString(String connectionString) { return endpoint; } - /** - * @param clientProvider the clientProvider to set - */ - public void setClientProvider(ConfigurationClientCustomizer clientProvider) { - this.clientProvider = clientProvider; - } - - public void setIsKeyVaultConfigured(boolean isKeyVaultConfigured) { - this.isKeyVaultConfigured = isKeyVaultConfigured; - } - /** * Builds all the clients for a connection. * @@ -169,79 +156,64 @@ List buildClients(ConfigStore configStore) { LOGGER.debug("Connecting to " + endpoint + " using Connecting String."); ConfigurationClientBuilder builder = createBuilderInstance().connectionString(connectionString); - clients.add(modifyAndBuildClient(builder, endpoint, connectionStrings.size() - 1)); + clients.add(modifyAndBuildClient(builder, endpoint, configStore.getEndpoint(), connectionStrings.size() - 1)); } } else { + DefaultAzureCredential defautAzureCredential = new DefaultAzureCredentialBuilder().build(); for (String endpoint : endpoints) { ConfigurationClientBuilder builder = this.createBuilderInstance(); if (!credentialConfigured) { - // System Assigned Identity. Needs to be checked last as all of the above should - // have an Endpoint. - LOGGER.debug("Connecting to {} using Azure System Assigned Identity.", endpoint); - builder.credential(new ManagedIdentityCredentialBuilder().build()); + builder.credential(defautAzureCredential); } builder.endpoint(endpoint); - clients.add(modifyAndBuildClient(builder, endpoint, endpoints.size() - 1)); + clients.add(modifyAndBuildClient(builder, endpoint, configStore.getEndpoint(), endpoints.size() - 1)); } } return clients; } - public AppConfigurationReplicaClient buildClient(String failoverEndpoint, ConfigStore configStore) { + AppConfigurationReplicaClient buildClient(String failoverEndpoint, ConfigStore configStore) { if (StringUtils.hasText(configStore.getConnectionString())) { ConnectionString connectionString = new ConnectionString(configStore.getConnectionString()); connectionString.setUri(failoverEndpoint); ConfigurationClientBuilder builder = createBuilderInstance().connectionString(connectionString.toString()); - return modifyAndBuildClient(builder, failoverEndpoint, 0); + return modifyAndBuildClient(builder, failoverEndpoint, configStore.getEndpoint(), 0); } else if (configStore.getConnectionStrings().size() > 0) { ConnectionString connectionString = new ConnectionString(configStore.getConnectionStrings().get(0)); connectionString.setUri(failoverEndpoint); ConfigurationClientBuilder builder = createBuilderInstance().connectionString(connectionString.toString()); - return modifyAndBuildClient(builder, failoverEndpoint, 0); + return modifyAndBuildClient(builder, failoverEndpoint, configStore.getEndpoint(), 0); } else { ConfigurationClientBuilder builder = createBuilderInstance(); if (!credentialConfigured) { - // System Assigned Identity. Needs to be checked last as all of the above should - // have an Endpoint. - LOGGER.debug("Connecting to {} using Azure System Assigned Identity.", failoverEndpoint); - builder.credential(new ManagedIdentityCredentialBuilder().build()); + builder.credential(new DefaultAzureCredentialBuilder().build()); } builder.endpoint(failoverEndpoint); - return modifyAndBuildClient(builder, failoverEndpoint, 0); + return modifyAndBuildClient(builder, failoverEndpoint, configStore.getEndpoint(), 0); } } - private AppConfigurationReplicaClient modifyAndBuildClient(ConfigurationClientBuilder builder, String endpoint, + private AppConfigurationReplicaClient modifyAndBuildClient(ConfigurationClientBuilder builder, String endpoint, String originEndpoint, Integer replicaCount) { - TracingInfo tracingInfo = new TracingInfo(isDev, isKeyVaultConfigured, replicaCount, + TracingInfo tracingInfo = new TracingInfo(isKeyVaultConfigured, replicaCount, Configuration.getGlobalConfiguration()); builder.addPolicy(new BaseAppConfigurationPolicy(tracingInfo)); - if (clientProvider != null) { - clientProvider.customize(builder, endpoint); + if (clientCustomizer != null) { + clientCustomizer.customize(builder, endpoint); } - return new AppConfigurationReplicaClient(endpoint, builder.buildClient(), tracingInfo); + return new AppConfigurationReplicaClient(endpoint, originEndpoint, builder.buildClient()); } - @Override - public void setEnvironment(Environment environment) { - for (String profile : environment.getActiveProfiles()) { - if ("dev".equalsIgnoreCase(profile)) { - this.isDev = true; - break; - } - } - this.env = environment; - } - - protected ConfigurationClientBuilder createBuilderInstance() { + private ConfigurationClientBuilder createBuilderInstance() { RetryStrategy retryStatagy = null; - String mode = env.getProperty(AzureGlobalProperties.PREFIX + "." + RETRY_MODE_PROPERTY_NAME); - String modeService = env.getProperty(AzureAppConfigurationProperties.PREFIX + "." + RETRY_MODE_PROPERTY_NAME); + String mode = System.getProperty(AzureGlobalProperties.PREFIX + "." + RETRY_MODE_PROPERTY_NAME); + String modeService = System + .getProperty(AzureAppConfigurationProperties.PREFIX + "." + RETRY_MODE_PROPERTY_NAME); if ("exponential".equals(mode) || "exponential".equals(modeService) || (mode == null && modeService == null)) { Function checkPropertyInt = parameter -> (Integer.parseInt(parameter)); @@ -322,7 +294,6 @@ private static class ConnectionString { private final String secret; - @SuppressWarnings("deprecation") ConnectionString(String connectionString) { if (CoreUtils.isNullOrEmpty(connectionString)) { throw new IllegalArgumentException("'connectionString' cannot be null or empty."); @@ -338,9 +309,11 @@ private static class ConnectionString { String segment = arg.trim(); if (ENDPOINT.regionMatches(true, 0, segment, 0, ENDPOINT.length())) { try { - baseUri = new URL(segment.substring(ENDPOINT.length())); + baseUri = new URI(segment.substring(ENDPOINT.length())).toURL(); } catch (MalformedURLException ex) { throw new IllegalArgumentException(ex); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); } } else if (ID.regionMatches(true, 0, segment, 0, ID.length())) { id = segment.substring(ID.length()); @@ -358,12 +331,13 @@ private static class ConnectionString { } } - @SuppressWarnings("deprecation") protected ConnectionString setUri(String uri) { try { - this.baseUri = new URL(uri); + this.baseUri = new URI(uri).toURL(); } catch (MalformedURLException ex) { throw new IllegalArgumentException(ex); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); } return this; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java index e4569b384150..e585997e05a5 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java @@ -7,6 +7,7 @@ import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; @@ -42,10 +43,11 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli *

* * @param trim prefix to trim + * @param isRefresh true if a refresh triggered the loading of the Snapshot. * @throws InvalidConfigurationPropertyValueException thrown if fails to parse Json content type */ - public void initProperties(List trim) throws InvalidConfigurationPropertyValueException { - processConfigurationSettings(replicaClient.listSettingSnapshot(snapshotName), null, trim); + public void initProperties(List trim, Context context) throws InvalidConfigurationPropertyValueException { + processConfigurationSettings(replicaClient.listSettingSnapshot(snapshotName, context), null, trim); FeatureFlags featureFlags = new FeatureFlags(null, featureFlagsList); featureFlagClient.proccessFeatureFlags(featureFlags, replicaClient.getEndpoint()); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java new file mode 100644 index 000000000000..fb0a090f0910 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import org.springframework.boot.BootstrapRegistry.InstanceSupplier; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.util.StringUtils; + +import com.azure.data.appconfiguration.ConfigurationClientBuilder; +import com.azure.spring.cloud.appconfiguration.config.ConfigurationClientCustomizer; +import com.azure.spring.cloud.appconfiguration.config.KeyVaultSecretProvider; +import com.azure.spring.cloud.appconfiguration.config.SecretClientCustomizer; +import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; +import com.azure.spring.cloud.autoconfigure.implementation.appconfiguration.AzureAppConfigurationProperties; +import com.azure.spring.cloud.autoconfigure.implementation.context.properties.AzureGlobalProperties; +import com.azure.spring.cloud.autoconfigure.implementation.keyvault.secrets.properties.AzureKeyVaultSecretProperties; +import com.azure.spring.cloud.autoconfigure.implementation.properties.core.AbstractAzureHttpConfigurationProperties; +import com.azure.spring.cloud.autoconfigure.implementation.properties.core.authentication.TokenCredentialConfigurationProperties; +import com.azure.spring.cloud.autoconfigure.implementation.properties.utils.AzureGlobalPropertiesUtils; +import com.azure.spring.cloud.core.customizer.AzureServiceClientBuilderCustomizer; +import com.azure.spring.cloud.core.implementation.util.AzureSpringIdentifier; +import com.azure.spring.cloud.service.implementation.appconfiguration.ConfigurationClientBuilderFactory; +import com.azure.spring.cloud.service.implementation.keyvault.secrets.SecretClientBuilderFactory; + +class AzureAppConfigurationBootstrapRegistrar { + + static void register(ConfigDataLocationResolverContext context, Binder binder, + AppConfigurationProperties properties, ReplicaLookUp replicaLookup) { + + AzureGlobalProperties globalProperties = binder + .bind(AzureGlobalProperties.PREFIX, Bindable.of(AzureGlobalProperties.class)) + .orElseGet(AzureGlobalProperties::new); + AzureAppConfigurationProperties appConfigurationProperties = binder + .bind(AzureAppConfigurationProperties.PREFIX, Bindable.of(AzureAppConfigurationProperties.class)) + .orElseGet(AzureAppConfigurationProperties::new); + // the properties are used to custom the ConfigurationClientBuilder + AzureAppConfigurationProperties loadedProperties = AzureGlobalPropertiesUtils.loadProperties(globalProperties, + appConfigurationProperties); + + boolean isCredentialConfigured = isCredentialConfigured(loadedProperties); + + AppConfigurationKeyVaultClientFactory keyVaultClientFactory = appConfigurationKeyVaultClientFactory(context, + binder, isCredentialConfigured); + AppConfigurationReplicaClientsBuilder replicaClientsBuilder = replicaClientBuilder(context, + keyVaultClientFactory, loadedProperties, isCredentialConfigured); + + context.getBootstrapContext().registerIfAbsent(AppConfigurationKeyVaultClientFactory.class, + InstanceSupplier.from(() -> keyVaultClientFactory)); + context.getBootstrapContext().registerIfAbsent(AppConfigurationReplicaClientFactory.class, + InstanceSupplier.from(() -> buildClientFactory(replicaClientsBuilder, properties, replicaLookup))); + } + + private static AppConfigurationKeyVaultClientFactory appConfigurationKeyVaultClientFactory( + ConfigDataLocationResolverContext context, Binder binder, boolean isCredentialConfigured) + throws IllegalArgumentException { + + SecretClientCustomizer customizer = context.getBootstrapContext().getOrElse(SecretClientCustomizer.class, null); + KeyVaultSecretProvider secretProvider = context.getBootstrapContext().getOrElse(KeyVaultSecretProvider.class, + null); + + AzureKeyVaultSecretProperties secretClientProperties = binder + .bind(AzureKeyVaultSecretProperties.PREFIX, Bindable.of(AzureKeyVaultSecretProperties.class)) + .orElseGet(AzureKeyVaultSecretProperties::new); + SecretClientBuilderFactory secretClientBuilderFactory = new SecretClientBuilderFactory(secretClientProperties); + + context.getBootstrapContext().registerIfAbsent(SecretClientBuilderFactory.class, + InstanceSupplier.from(() -> secretClientBuilderFactory)); + + return new AppConfigurationKeyVaultClientFactory(customizer, secretProvider, secretClientBuilderFactory, + isCredentialConfigured); + } + + private static AppConfigurationReplicaClientFactory buildClientFactory( + AppConfigurationReplicaClientsBuilder clientBuilder, AppConfigurationProperties properties, + ReplicaLookUp replicaLookup) { + return new AppConfigurationReplicaClientFactory(clientBuilder, properties.getStores(), replicaLookup); + } + + @SuppressWarnings("unchecked") + private static AppConfigurationReplicaClientsBuilder replicaClientBuilder(ConfigDataLocationResolverContext context, + AppConfigurationKeyVaultClientFactory keyVaultClientFactory, AzureAppConfigurationProperties properties, + boolean isCredentialConfigured) { + + InstanceSupplier> customizer = context + .getBootstrapContext() + .getRegisteredInstanceSupplier( + (Class>) (Class) AzureServiceClientBuilderCustomizer.class); + ConfigurationClientBuilderFactory clientFactory = context.getBootstrapContext() + .getOrElseSupply(ConfigurationClientBuilderFactory.class, () -> { + ConfigurationClientBuilderFactory factory = new ConfigurationClientBuilderFactory(properties); + factory.setSpringIdentifier(AzureSpringIdentifier.AZURE_SPRING_APP_CONFIG); + if (customizer != null) { + factory.addBuilderCustomizer(customizer.get(context.getBootstrapContext())); + } + return factory; + }); + if (customizer != null) { + clientFactory.addBuilderCustomizer(customizer.get(context.getBootstrapContext())); + } + + InstanceSupplier configurationClientCustomizer = context + .getBootstrapContext() + .getRegisteredInstanceSupplier( + (Class) (Class) ConfigurationClientCustomizer.class); + + ConfigurationClientCustomizer clientCustomizer = null; + if (configurationClientCustomizer != null) { + clientCustomizer = configurationClientCustomizer.get(context.getBootstrapContext()); + } + + return new AppConfigurationReplicaClientsBuilder(clientFactory, clientCustomizer, isCredentialConfigured, + keyVaultClientFactory.isConfigured()); + } + + private static boolean isCredentialConfigured(AbstractAzureHttpConfigurationProperties properties) { + if (properties.getCredential() != null) { + TokenCredentialConfigurationProperties tokenProps = properties.getCredential(); + if (StringUtils.hasText(tokenProps.getClientCertificatePassword())) { + return true; + } else if (StringUtils.hasText(tokenProps.getClientCertificatePath())) { + return true; + } else if (StringUtils.hasText(tokenProps.getClientId())) { + return true; + } else if (StringUtils.hasText(tokenProps.getClientSecret())) { + return true; + } else if (StringUtils.hasText(tokenProps.getUsername())) { + return true; + } else if (StringUtils.hasText(tokenProps.getPassword())) { + return true; + } + } + + return false; + } + +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java new file mode 100644 index 000000000000..616056a0a6b1 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.PUSH_REFRESH; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry.InstanceSupplier; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoader; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.util.StringUtils; + +import com.azure.core.util.Context; +import com.azure.data.appconfiguration.models.ConfigurationSetting; +import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagKeyValueSelector; + +/** + * Azure App Configuration data loader implementation for Spring Boot's ConfigDataLoader. + * + * @since 6.0.0 + */ + +public class AzureAppConfigDataLoader implements ConfigDataLoader { + + /** + * Logger instance for this class. + */ + private static Log logger = new DeferredLog(); + + /** + * The Azure App Configuration data resource being processed. + */ + private AzureAppConfigDataResource resource; + + /** + * Factory for creating replica clients to connect to Azure App Configuration. + */ + private AppConfigurationReplicaClientFactory replicaClientFactory; + + /** + * Factory for creating Key Vault clients for secret resolution. + */ + private AppConfigurationKeyVaultClientFactory keyVaultClientFactory; + + /** + * State holder for managing configuration and feature flag states. + */ + private StateHolder storeState = new StateHolder(); + + /** + * Client for handling feature flag operations. + */ + private FeatureFlagClient featureFlagClient; + + /** + * Request context for tracking operations and telemetry. + */ + private Context requestContext; + + /** + * Application start time for calculating delays. + */ + private static final Instant START_DATE = Instant.now(); + + /** + * Pre-kill time in seconds for delaying exceptions during startup. + */ + private static final Integer PREKILL_TIME = 5; + + /** + * Constructs a new AzureAppConfigDataLoader with the specified logger factory. + * + * @param logFactory the deferred log factory for creating loggers + */ + public AzureAppConfigDataLoader(DeferredLogFactory logFactory) { + logger = logFactory.getLog(getClass()); + } + + /** + * Loads configuration data from Azure App Configuration service. + * + * @param context the config data loader context + * @param resource the Azure App Configuration data resource + * @return ConfigData containing loaded property sources + * @throws IOException if an I/O error occurs during loading + * @throws ConfigDataResourceNotFoundException if the configuration resource is not found + */ + @Override + public ConfigData load(ConfigDataLoaderContext context, AzureAppConfigDataResource resource) + throws IOException, ConfigDataResourceNotFoundException { + this.resource = resource; + storeState.setNextForcedRefresh(resource.getRefreshInterval()); + if (context.getBootstrapContext().isRegistered(FeatureFlagClient.class)) { + featureFlagClient = context.getBootstrapContext().get(FeatureFlagClient.class); + } else { + featureFlagClient = new FeatureFlagClient(); + context.getBootstrapContext().registerIfAbsent(FeatureFlagClient.class, + InstanceSupplier.from(() -> featureFlagClient)); + } + // Reset telemetry usage for refresh + featureFlagClient.resetTelemetry(); + List> sourceList = new ArrayList<>(); + if (resource.isConfigStoreEnabled()) { + replicaClientFactory = context.getBootstrapContext() + .get(AppConfigurationReplicaClientFactory.class); + keyVaultClientFactory = context.getBootstrapContext() + .get(AppConfigurationKeyVaultClientFactory.class); + + List clients = replicaClientFactory + .getAvailableClients(resource.getEndpoint(), true); + + boolean reloadFailed = false; + boolean pushRefresh = false; + Exception lastException = null; + PushNotification notification = resource.getMonitoring().getPushNotification(); + if ((notification.getPrimaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName())) + || (notification.getSecondaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName()))) { + pushRefresh = true; + } + // Feature Management needs to be set in the last config store. + requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh); + + Iterator clientIterator = clients.iterator(); + + while (clientIterator.hasNext()) { + AppConfigurationReplicaClient client = clientIterator.next(); + + if (reloadFailed + && !AppConfigurationRefreshUtil.refreshStoreCheck(client, + replicaClientFactory.findOriginForEndpoint(client.getEndpoint()), requestContext)) { + // This store doesn't have any changes where to refresh store did. Skipping Checking next. + continue; + } + + // Reverse in order to add Profile specific properties earlier, and last profile comes first + try { + sourceList.addAll(createSettings(client)); + List featureFlags = createFeatureFlags(client); + + logger.debug("PropertySource context."); + AppConfigurationStoreMonitoring monitoring = resource.getMonitoring(); + + storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags, + monitoring.getFeatureFlagRefreshInterval()); + + if (monitoring.isEnabled()) { + // Setting new ETag values for Watch + List watchKeysSettings = monitoring.getTriggers().stream() + .map(trigger -> client.getWatchKey(trigger.getKey(), trigger.getLabel(), + requestContext)) + .toList(); + + storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); + } + storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop + lastException = null; + // Break out of the loop since we have successfully loaded configuration + break; + } catch (AppConfigurationStatusException e) { + reloadFailed = true; + replicaClientFactory.backoffClient(resource.getEndpoint(), client.getEndpoint()); + lastException = e; + // Log the specific replica failure with context + logReplicaFailure(client, "status exception", clientIterator.hasNext(), e); + } catch (Exception e) { + // Store the exception to potentially use if all replicas fail + lastException = e; // Log the specific replica failure with context + logReplicaFailure(client, "exception", clientIterator.hasNext(), e); + } + } // Check if all replicas failed + if (lastException != null && !resource.isRefresh()) { + // During startup, if all replicas failed, fail the application + logger.error("Azure App Configuration failed to load configuration during startup for store: " + + resource.getEndpoint() + ". Application cannot start without required configuration."); + failedToGeneratePropertySource(lastException); + } else if (lastException != null && resource.isRefresh()) { + // During refresh, log warning but don't fail the application + logger.warn("Azure App Configuration failed during refresh for store: " + + resource.getEndpoint() + ". Continuing with existing configuration."); + } + } + + StateHolder.updateState(storeState); + if (featureFlagClient.getFeatureFlags().size() > 0) { + // Don't add feature flags if there are none, otherwise the local file can't load them. + sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient)); + } + return new ConfigData(sourceList); + } + + /** + * Handles failed property source generation when all replicas fail during application startup. + * + * @param e the exception that caused the failure + * @throws RuntimeException always thrown to indicate the startup failure + */ + private void failedToGeneratePropertySource(Exception e) { + logger.error("Configuration loading failed during application startup from Azure App Configuration store " + + resource.getEndpoint() + ". Application cannot start without required configuration."); + delayException(); + throw new RuntimeException("Failed to generate property sources for " + resource.getEndpoint(), e); + } + + /** + * Creates a new set of AppConfigurationPropertySources, 1 per Label. + * + * @param client client for connecting to App Configuration + * @return a list of AppConfigurationPropertySources + * @throws Exception creating a property source failed + */ + private List createSettings(AppConfigurationReplicaClient client) + throws Exception { + List sourceList = new ArrayList<>(); + List selects = resource.getSelects(); + List profiles = resource.getProfiles().getActive(); + + for (AppConfigurationKeyValueSelector selectedKeys : selects) { + AppConfigurationPropertySource propertySource = null; + + if (StringUtils.hasText(selectedKeys.getSnapshotName())) { + propertySource = new AppConfigurationSnapshotPropertySource( + selectedKeys.getSnapshotName() + "/" + resource.getEndpoint(), client, keyVaultClientFactory, + selectedKeys.getSnapshotName(), featureFlagClient); + } else { + propertySource = new AppConfigurationApplicationSettingPropertySource( + selectedKeys.getKeyFilter() + resource.getEndpoint() + "/", client, keyVaultClientFactory, + selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles)); + } + propertySource.initProperties(resource.getTrimKeyPrefix(), requestContext); + sourceList.add(propertySource); + } + return sourceList; + } + + /** + * Creates a list of feature flags from Azure App Configuration. + * + * @param client client for connecting to App Configuration + * @return a list of FeatureFlags + * @throws Exception creating feature flags failed + */ + private List createFeatureFlags(AppConfigurationReplicaClient client) + throws Exception { + List featureFlagWatchKeys = new ArrayList<>(); + List profiles = resource.getProfiles().getActive(); + + for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) { + List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, + selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext); + featureFlagWatchKeys.addAll(storesFeatureFlags); + } + + return featureFlagWatchKeys; + } + + /** + * Logs a replica failure with contextual information about the failure scenario and available replicas. + * + * @param client the replica client that failed + * @param exceptionType a brief description of the exception type (e.g., "status exception", "exception") + * @param hasMoreReplicas whether there are additional replicas available to try + * @param exception the exception that caused the failure + */ + private void logReplicaFailure(AppConfigurationReplicaClient client, String exceptionType, + boolean hasMoreReplicas, Exception exception) { + String scenario = resource.isRefresh() ? "refresh" : "startup"; + String nextAction = hasMoreReplicas ? "Trying next replica." : "No more replicas available."; + + logger.warn("Azure App Configuration replica " + client.getEndpoint() + + " failed during " + scenario + " with " + exceptionType + ". " + + nextAction + " Store: " + resource.getEndpoint(), exception); + } + + /** + * Introduces a delay before throwing exceptions during startup to prevent fast crash loops. + */ + private void delayException() { + Instant currentDate = Instant.now(); + Instant preKillTime = START_DATE.plusSeconds(PREKILL_TIME); + if (currentDate.isBefore(preKillTime)) { + long diffInMillies = Math.abs(preKillTime.toEpochMilli() - currentDate.toEpochMilli()); + try { + Thread.sleep(diffInMillies); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore interrupted status + } + } + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java new file mode 100644 index 000000000000..41acde2f8765 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.naming.NamingException; + +import org.apache.commons.logging.Log; +import org.springframework.boot.BootstrapRegistry.InstanceSupplier; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolver; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; +import org.springframework.boot.context.config.Profiles; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.util.StringUtils; + +import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; + +/** + * Resolves Azure App Configuration data locations for Spring Boot's ConfigData API. + * + * @since 6.0.0 + */ + +public class AzureAppConfigDataLocationResolver + implements ConfigDataLocationResolver { + + private static final Log LOGGER = new DeferredLog(); + + /** Prefix used to identify Azure App Configuration locations */ + public static final String PREFIX = "azureAppConfiguration"; + + /** Flag to track startup phase for proper resource initialization */ + private static final AtomicBoolean START_UP = new AtomicBoolean(true); + + /** + * Determines if the given location can be resolved by this resolver. + * + * @param context the resolver context containing binder and bootstrap information + * @param location the configuration data location to check + * @return true if this resolver can handle the location, false otherwise + */ + @Override + public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDataLocation location) { + if (!location.hasPrefix(PREFIX)) { + return false; + } + + // Check if the configuration properties for Azure App Configuration are present + return hasValidStoreConfiguration(context.getBinder()); + } + + /** + * Checks if the required configuration properties for Azure App Configuration are present. + * + * @param binder the binder to check for properties + * @return true if at least one of the required properties is present, false otherwise + */ + private boolean hasValidStoreConfiguration(Binder binder) { + // Check if any of the required properties for Azure App Configuration stores are present + String configPrefix = AppConfigurationProperties.CONFIG_PREFIX + ".stores[0]."; + + return hasNonEmptyProperty(binder, configPrefix + "endpoint") + || hasNonEmptyProperty(binder, configPrefix + "connection-string") + || hasNonEmptyProperty(binder, configPrefix + "endpoints") + || hasNonEmptyProperty(binder, configPrefix + "connection-strings"); + } + + private boolean hasNonEmptyProperty(Binder binder, String propertyPath) { + return StringUtils.hasText(binder.bind(propertyPath, String.class).orElse("")); + } + + /** + * Resolves configuration data resources for the given location. + * + * @param context the resolver context + * @param location the configuration data location + * @return empty list of resources + */ + @Override + public List resolve(ConfigDataLocationResolverContext context, + ConfigDataLocation location) + throws ConfigDataLocationNotFoundException, ConfigDataResourceNotFoundException { + return Collections.emptyList(); + } + + /** + * Resolves profile-specific configuration data resources. + * + * @param resolverContext the resolver context + * @param location the configuration data location + * @param profiles the active Spring profiles + * @return list of Azure App Configuration data resources + * @throws ConfigDataLocationNotFoundException if location cannot be found + */ + @Override + public List resolveProfileSpecific( + ConfigDataLocationResolverContext resolverContext, ConfigDataLocation location, Profiles profiles) + throws ConfigDataLocationNotFoundException { + + AppConfigurationProperties properties = loadProperties(resolverContext); + List locations = new ArrayList<>(); + + if (properties.getStores() == null || properties.getStores().isEmpty()) { + throw new ConfigDataLocationNotFoundException(location, + "No Azure App Configuration stores are configured. Please check your application properties.", + new IllegalStateException("No stores configured")); + } + + for (ConfigStore store : properties.getStores()) { + locations.add( + new AzureAppConfigDataResource(store, profiles, START_UP.get(), properties.getRefreshInterval())); + } + START_UP.set(false); + return locations; + } + + /** + * Loads and validates Azure App Configuration properties from the configuration context. + * + * @param context the configuration data location resolver context + * @return validated Azure App Configuration properties + */ + protected AppConfigurationProperties loadProperties(ConfigDataLocationResolverContext context) { + Binder binder = context.getBinder(); + BindHandler bindHandler = getBindHandler(context); + AppConfigurationProperties properties = binder.bind(AppConfigurationProperties.CONFIG_PREFIX, + Bindable.of(AppConfigurationProperties.class), bindHandler).get(); + + properties.validateAndInit(); + ReplicaLookUp replicaLookup = null; + try { + replicaLookup = new ReplicaLookUp(properties); + context.getBootstrapContext().registerIfAbsent(ReplicaLookUp.class, InstanceSupplier.of(replicaLookup)); + } catch (NamingException e) { + LOGGER.info("Failed to find DNS Entry for config store while looking for replicas."); + } + + AzureAppConfigurationBootstrapRegistrar.register(context, binder, properties, replicaLookup); + + return properties; + } + + private BindHandler getBindHandler(ConfigDataLocationResolverContext context) { + return context.getBootstrapContext().getOrElse(BindHandler.class, null); + } + +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java new file mode 100644 index 000000000000..0d828552c0e3 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import java.time.Duration; +import java.util.List; + +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.boot.context.config.Profiles; + +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagKeyValueSelector; + +/** + * Represents an Azure App Configuration data resource that extends Spring Boot's ConfigDataResource. + * + * @since 6.0.0 + */ +public class AzureAppConfigDataResource extends ConfigDataResource { + + /** Indicates whether the configuration store is enabled for loading configuration data. */ + private final boolean configStoreEnabled; + + /** The endpoint URL of the Azure App Configuration store. */ + private final String endpoint; + + /** List of key prefixes to trim from configuration keys when loading. */ + private final List trimKeyPrefix; + + /** Spring Boot profiles configuration for conditional property loading. */ + private final Profiles profiles; + + /** List of selectors for filtering key-value pairs from the configuration store. */ + private final List selects; + + /** List of selectors for filtering feature flag key-value pairs from the configuration store. */ + private final List featureFlagSelects; + + /** Monitoring configuration for the configuration store including refresh triggers. */ + private final AppConfigurationStoreMonitoring monitoring; + + /** Indicates whether this resource supports configuration refresh at runtime. */ + private final boolean isRefresh; + + /** The interval at which configuration should be refreshed from the store. */ + private final Duration refreshInterval; + + /** + * Constructs a new AzureAppConfigDataResource with the specified configuration store settings. + * + * @param configStore the configuration store settings containing endpoint, selectors, and other options + * @param profiles the Spring Boot profiles for conditional configuration loading + * @param isRefresh whether this resource supports runtime configuration refresh + * @param refreshInterval the interval at which configuration should be refreshed + */ + AzureAppConfigDataResource(ConfigStore configStore, Profiles profiles, boolean isRefresh, + Duration refreshInterval) { + this.configStoreEnabled = configStore.isEnabled(); + this.endpoint = configStore.getEndpoint(); + this.selects = configStore.getSelects(); + this.featureFlagSelects = configStore.getFeatureFlags().getSelects(); + this.trimKeyPrefix = configStore.getTrimKeyPrefix(); + this.monitoring = configStore.getMonitoring(); + this.profiles = profiles; + this.isRefresh = isRefresh; + this.refreshInterval = refreshInterval; + } + + /** + * Gets the list of key-value selectors used to filter configuration data from the store. + * + * @return the list of configuration key-value selectors, may be null or empty + */ + public List getSelects() { + return selects; + } + + /** + * Gets the list of feature flag selectors used to filter feature flag data from the store. + * + * @return the list of feature flag selectors, may be null or empty + */ + public List getFeatureFlagSelects() { + return featureFlagSelects; + } + + /** + * Checks whether the configuration store is enabled for loading configuration data. + * + * @return true if the configuration store is enabled, false otherwise + */ + public boolean isConfigStoreEnabled() { + return configStoreEnabled; + } + + /** + * Gets the endpoint URL of the Azure App Configuration store. + * + * @return the endpoint URL as a string, may be null if not configured + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Gets the monitoring configuration for this configuration store. + * + * @return the monitoring configuration, may be null if not configured + */ + public AppConfigurationStoreMonitoring getMonitoring() { + return monitoring; + } + + /** + * Gets the list of key prefixes to trim from configuration keys when loading. + * + * @return the list of key prefixes to trim, may be null or empty if no trimming is configured + */ + public List getTrimKeyPrefix() { + return trimKeyPrefix; + } + + /** + * Gets the Spring Boot profiles configuration for conditional property loading. + * + * @return the profiles configuration, never null + */ + public Profiles getProfiles() { + return profiles; + } + + /** + * Returns if true if this resource is being refreshed. False if the resource is being loaded at startup. + * + * @return true if this is a refresh operation, false if it is a startup load + */ + public boolean isRefresh() { + return isRefresh; + } + + /** + * Gets the interval at which configuration should be refreshed from the store. + * + * @return the refresh interval, may be null if not configured + */ + public Duration getRefreshInterval() { + return refreshInterval; + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculator.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculator.java index afc647f1b31e..39f378fe2eff 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculator.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculator.java @@ -5,19 +5,39 @@ import java.util.Random; /** - * Calculates the amount of time to the next refresh, if a refresh fails. + * Utility class for calculating exponential backoff times for Azure App Configuration retry operations. */ final class BackoffTimeCalculator { - private static final Long MAX_ATTEMPTS = (long) 63; + /** + * Maximum number of attempts to consider for exponential backoff calculation. This prevents integer overflow when + * calculating 2^attempts. Value of 63 ensures that 2^63 is the largest power of 2 that fits in a long. + */ + private static final long MAX_ATTEMPTS = 63; - private static final Long SECONDS_TO_NANO_SECONDS = (long) 1000000000; + /** + * Conversion factor from seconds to nanoseconds. Used to convert backoff times from seconds to nanoseconds for + * precise timing. + */ + private static final long SECONDS_TO_NANOSECONDS = 1_000_000_000L; + /** + * Generator for introducing jitter in backoff calculations. Jitter helps prevent multiple clients from retrying + * simultaneously (thundering herd). + */ private static final Random RANDOM = new Random(); - private static Long maxBackoff = (long) 600; + /** + * Maximum backoff time in seconds. Default: 600 seconds (10 minutes) - reasonable maximum to prevent excessively + * long waits. + */ + private static long maxBackoffSeconds = 600; - private static Long minBackoff = (long) 30; + /** + * Minimum backoff time in seconds. Default: 30 seconds - ensures proper rate limiting and prevents rapid retry + * loops. + */ + private static long minBackoffSeconds = 30; /** * @@ -25,23 +45,23 @@ final class BackoffTimeCalculator { * @param minBackoff minimum amount of time between requests */ static void setDefaults(Long maxBackoff, Long minBackoff) { - BackoffTimeCalculator.maxBackoff = maxBackoff; - BackoffTimeCalculator.minBackoff = minBackoff; + BackoffTimeCalculator.maxBackoffSeconds = maxBackoff != null ? maxBackoff : 600L; + BackoffTimeCalculator.minBackoffSeconds = minBackoff != null ? minBackoff : 30L; } /** - * Calculates the new Backoff time for requests. - * @param attempts Number of attempts so far - * @return Nano Seconds to the next request - * @throws IllegalArgumentException when back off time or attempt number is invalid + * Calculates the exponential backoff time with jitter for retry operations. + * + * @param attempts the number of retry attempts made so far; must be non-negative + * @return the calculated backoff time in nanoseconds; never negative */ - static Long calculateBackoff(Integer attempts) { + static long calculateBackoff(Integer attempts) { - if (minBackoff < 0) { + if (minBackoffSeconds < 0) { throw new IllegalArgumentException("Minimum Backoff time needs to be greater than or equal to 0."); } - if (maxBackoff < 0) { + if (maxBackoffSeconds < 0) { throw new IllegalArgumentException("Maximum Backoff time needs to be greater than or equal to 0."); } @@ -49,10 +69,11 @@ static Long calculateBackoff(Integer attempts) { throw new IllegalArgumentException("Number of previous attempts needs to be a positive number."); } - long minBackoffNano = minBackoff * SECONDS_TO_NANO_SECONDS; - long maxBackoffNano = maxBackoff * SECONDS_TO_NANO_SECONDS; + final long minBackoffNano = minBackoffSeconds * SECONDS_TO_NANOSECONDS; + final long maxBackoffNano = maxBackoffSeconds * SECONDS_TO_NANOSECONDS; - if (attempts <= 1 || maxBackoff <= minBackoff) { + // For first attempts or when min equals max, return minimum backoff + if (attempts <= 1 || maxBackoffNano <= minBackoffNano) { return minBackoffNano; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java index 16c6d2d05fdc..f160649abe06 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java @@ -7,7 +7,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,33 +18,43 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; /** - * Holds a set of connections to an app configuration store with zero to many geo-replications. + * Manages connection pools and client lifecycle for Azure App Configuration stores with support for geo-replication, + * auto-failover, and intelligent client routing. */ -public class ConnectionManager { +class ConnectionManager { private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionManager.class); + /** The primary endpoint URL for the App Configuration store. */ private final String originEndpoint; - // Used if multiple connection method is given. + /** List of configured replica clients for the primary App Configuration store. */ private List clients; - private Map autoFailoverClients; + /** Map of auto-discovered failover clients, keyed by endpoint URL. */ + private final Map autoFailoverClients; + /** Currently active replica endpoint being used for requests. */ private String currentReplica; + /** Current health status of the App Configuration store connection. */ private AppConfigurationStoreHealth health; + /** Builder for creating App Configuration replica clients. */ private final AppConfigurationReplicaClientsBuilder clientBuilder; + /** Configuration store settings and connection parameters. */ private final ConfigStore configStore; + /** Service for discovering auto-failover replica endpoints. */ private final ReplicaLookUp replicaLookUp; /** - * Creates a set of connections to an app configuration store. - * @param clientBuilder Builder for App Configuration Clients - * @param configStore Connection info for the store + * Creates a connection manager for the specified App Configuration store. + * + * @param clientBuilder the builder for creating App Configuration replica clients; must not be null + * @param configStore the configuration store settings and connection parameters; must not be null + * @param replicaLookUp the service for discovering auto-failover endpoints; must not be null */ ConnectionManager(AppConfigurationReplicaClientsBuilder clientBuilder, ConfigStore configStore, ReplicaLookUp replicaLookUp) { @@ -59,41 +68,53 @@ public class ConnectionManager { } /** - * Gets the current health information on the Connection to the Config Store - * @return AppConfigurationConfigStoreHealth + * Retrieves the current health status of the App Configuration store connection. + * + * @return the current health status; never null */ AppConfigurationStoreHealth getHealth() { return this.health; } + /** + * Sets the current active replica endpoint for client routing. + * + * @param replicaEndpoint the endpoint URL to set as current; may be null to reset to primary endpoint + */ void setCurrentClient(String replicaEndpoint) { this.currentReplica = replicaEndpoint; } /** - * @return the originEndpoint + * Retrieves the primary (origin) endpoint URL for the App Configuration store. + * + * @return the primary endpoint URL; never null */ String getMainEndpoint() { return originEndpoint; } /** - * Returns a client. - * @return ConfigurationClient + * Retrieves all available App Configuration clients that are ready for use. + * + * @return a list of available clients; may be empty if all clients are currently unavailable */ List getAvailableClients() { return getAvailableClients(false); } /** - * Returns a client. - * @return ConfigurationClient + * Retrieves available App Configuration clients with optional current replica preference. + * + * @param useCurrent if true, prioritizes returning clients starting from the current replica; if false, returns all + * available clients + * @return a list of available clients ordered by preference; may be empty if all clients are currently unavailable */ List getAvailableClients(Boolean useCurrent) { if (clients == null) { clients = clientBuilder.buildClients(configStore); - if (clients.size() == 0) { + if (clients.isEmpty()) { this.health = AppConfigurationStoreHealth.NOT_LOADED; } } @@ -143,16 +164,10 @@ List getAvailableClients(Boolean useCurrent) { return availableClients; } - List getAllEndpoints() { - List endpoints = clients.stream().map(AppConfigurationReplicaClient::getEndpoint) - .collect(Collectors.toList()); - endpoints.addAll(replicaLookUp.getAutoFailoverEndpoints(configStore.getEndpoint())); - return endpoints; - } - /** - * Call when the current client failed - * @param endpoint replica endpoint + * Applies exponential backoff to a failed client endpoint. + * + * @param endpoint the endpoint URL of the failed client; must not be null or empty */ void backoffClient(String endpoint) { for (AppConfigurationReplicaClient client : clients) { @@ -170,9 +185,10 @@ void backoffClient(String endpoint) { } /** - * Updates the sync token of the client. Only works if no replicas are being used. - * - * @param syncToken App Configuration sync token + * Updates the synchronization token for the specified client endpoint. + * + * @param endpoint the endpoint URL of the client to update; may be null (method will have no effect if null) + * @param syncToken the new synchronization token; may be null to clear the existing token */ void updateSyncToken(String endpoint, String syncToken) { clients.stream().filter(client -> client.getEndpoint().equals(endpoint)).findFirst() @@ -181,10 +197,20 @@ void updateSyncToken(String endpoint, String syncToken) { .ifPresent(client -> client.updateSyncToken(syncToken)); } + /** + * Retrieves the monitoring configuration for the App Configuration store. + * + * @return the monitoring configuration; may be null if not configured + */ AppConfigurationStoreMonitoring getMonitoring() { return configStore.getMonitoring(); } + /** + * Retrieves the feature flag store configuration. + * + * @return the feature flag store configuration; may be null if not configured + */ FeatureFlagStore getFeatureFlagStore() { return configStore.getFeatureFlags(); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java index 956454d616ca..9c4f5433e02c 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java @@ -6,7 +6,6 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_REQUIREMENT_TYPE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.E_TAG; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_CONTENT_TYPE; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_ID; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_REFERENCE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.REQUIREMENT_TYPE_SERVICE; @@ -14,41 +13,56 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.TELEMETRY; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; -import org.bouncycastle.jcajce.provider.digest.SHA256; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; +import com.azure.data.appconfiguration.models.FeatureFlagFilter; import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.FeatureTelemetry; +import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.FeatureFlagTracing; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; -import com.nimbusds.jose.util.Base64URL; /** * Loads sets of feature flags, and de-duplicates the results with previously loaded feature flags. Newer Feature Flags * take priority. */ @Component -public class FeatureFlagClient { +class FeatureFlagClient { - protected final Map properties = new LinkedHashMap<>(); + private static final Logger LOGGER = LoggerFactory + .getLogger(FeatureFlagClient.class); + + private final Map properties = new LinkedHashMap<>(); private static final ObjectMapper CASE_INSENSITIVE_MAPPER = JsonMapper.builder() .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true).build(); + + private FeatureFlagTracing tracing = new FeatureFlagTracing(); /** *

@@ -62,8 +76,8 @@ public class FeatureFlagClient { *

* */ - public List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, - String[] labelFilter, boolean isRefresh) { + List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, + String[] labelFilter, Context context) { List loadedFeatureFlags = new ArrayList<>(); String keyFilter = SELECT_ALL_FEATURE_FLAGS; @@ -77,14 +91,15 @@ public List loadFeatureFlags(AppConfigurationReplicaClient replica for (String label : labels) { SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label); + context.addData("FeatureFlagTracing", tracing); - FeatureFlags features = replicaClient.listFeatureFlags(settingSelector, isRefresh); - loadedFeatureFlags.addAll(proccessFeatureFlags(features, keyFilter)); + FeatureFlags features = replicaClient.listFeatureFlags(settingSelector, context); + loadedFeatureFlags.addAll(proccessFeatureFlags(features, replicaClient.getOriginClient())); } return loadedFeatureFlags; } - public List proccessFeatureFlags(FeatureFlags features, String endpoint) { + List proccessFeatureFlags(FeatureFlags features, String endpoint) { List loadedFeatureFlags = new ArrayList<>(); loadedFeatureFlags.add(features); @@ -93,6 +108,7 @@ public List proccessFeatureFlags(FeatureFlags features, String end if (setting instanceof FeatureFlagConfigurationSetting && FEATURE_FLAG_CONTENT_TYPE.equals(setting.getContentType())) { FeatureFlagConfigurationSetting featureFlag = (FeatureFlagConfigurationSetting) setting; + updateTelemetry(featureFlag); properties.put(featureFlag.getKey(), createFeature(featureFlag, endpoint)); } } @@ -107,7 +123,8 @@ public List proccessFeatureFlags(FeatureFlags features, String end */ protected static Feature createFeature(FeatureFlagConfigurationSetting item, String originEndpoint) { String requirementType = DEFAULT_REQUIREMENT_TYPE; - FeatureTelemetry featureTelemetry = new FeatureTelemetry(); + FeatureTelemetry featureTelemetry = null; + Feature feature = null; try { JsonNode node = CASE_INSENSITIVE_MAPPER.readTree(item.getValue()); JsonNode conditions = node.get(CONDITIONS); @@ -115,54 +132,154 @@ protected static Feature createFeature(FeatureFlagConfigurationSetting item, Str requirementType = conditions.get(REQUIREMENT_TYPE_SERVICE).asText(); } JsonNode telemetryNode = node.get(TELEMETRY); - if (telemetryNode != null) { + if (telemetryNode != null && !telemetryNode.isEmpty()) { ObjectMapper objectMapper = JsonMapper.builder() .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true).build(); featureTelemetry = objectMapper.convertValue(telemetryNode, FeatureTelemetry.class); } - } catch (JsonProcessingException e) { - } + feature = new Feature(item, requirementType, featureTelemetry); - Feature feature = new Feature(item, requirementType, featureTelemetry); - - if (feature.getTelemetry() != null) { - final FeatureTelemetry telemetry = feature.getTelemetry(); - if (telemetry.isEnabled()) { - final Map originMetadata = telemetry.getMetadata(); - originMetadata.put(FEATURE_FLAG_ID, calculateFeatureFlagId(item.getKey(), item.getLabel())); - originMetadata.put(E_TAG, item.getETag()); - if (originEndpoint != null && !originEndpoint.isEmpty()) { - final String labelPart = item.getLabel().isEmpty() ? "" - : String.format("?label=%s", item.getLabel()); - originMetadata.put(FEATURE_FLAG_REFERENCE, - String.format("%s/kv/%s%s", originEndpoint, item.getKey(), labelPart)); + if (feature.getTelemetry() != null) { + final FeatureTelemetry telemetry = feature.getTelemetry(); + if (telemetry.isEnabled()) { + final Map originMetadata = telemetry.getMetadata(); + originMetadata.put(E_TAG, item.getETag()); + if (originEndpoint != null && !originEndpoint.isEmpty()) { + final String labelPart = item.getLabel().isEmpty() ? "" + : String.format("?label=%s", item.getLabel()); + originMetadata.put(FEATURE_FLAG_REFERENCE, + String.format("%s/kv/%s%s", originEndpoint, item.getKey(), labelPart)); + } + originMetadata.put("AllocationId", generateAllocationId(node)); } } + } catch (JsonProcessingException e) { + LOGGER.error("Error parsing feature flag value for key: {}", item.getKey(), e); } return feature; } /** - * @param key the key of feature flag - * @param label the label of feature flag. If label is whitespace, treat as null - * @return base64_url(SHA256(utf8_bytes("${key}\n${label}"))).replace('+', '-').replace('/', '_').trimEnd('=') - * trimEnd() means trims everything after the first occurrence of the '=' + * @return the properties */ - private static String calculateFeatureFlagId(String key, String label) { - final String data = String.format("%s\n%s", key, label.isEmpty() ? null : label); - final SHA256.Digest digest = new SHA256.Digest(); - final String beforeTrim = Base64URL.encode(digest.digest(data.getBytes(StandardCharsets.UTF_8))) - .toString().replace('+', '-').replace('/', '_'); - final int index = beforeTrim.indexOf('='); - return beforeTrim.substring(0, index > -1 ? index : beforeTrim.length()); + public List getFeatureFlags() { + return properties.values().stream().toList(); } - + + public void resetTelemetry() { + tracing.resetFeatureFilterTelemetry(); + } + /** - * @return the properties + * Looks at each filter used in a Feature Flag to check what types it is using. + * + * @param featureFlag FeatureFlagConfigurationSetting + * @param tracing The TracingInfo for this store. */ - public Map getProperties() { - return properties; + private void updateTelemetry(FeatureFlagConfigurationSetting featureFlag) { + for (FeatureFlagFilter filter : featureFlag.getClientFilters()) { + tracing.updateFeatureFilterTelemetry(filter.getName()); + } } + /** + * Generates a unique allocation ID for the feature flag based on its configuration. + * + * @param featureFlagValue The feature flag value as a map. + * @return A unique allocation ID or null if the allocation is not valid. + */ + static String generateAllocationId(JsonNode featureFlagValue) { + StringBuilder allocationId = new StringBuilder(); + List allocatedVariants = new ArrayList<>(); + + // Retrieve allocation object + JsonNode allocation = featureFlagValue.get("allocation"); + if (allocation == null) { + return null; + } + + // Seed + allocationId.append("seed=").append(allocation.has("seed") ? allocation.get("seed").asText() : ""); + + // DefaultWhenEnabled + if (allocation.has("default_when_enabled")) { + allocatedVariants.add(allocation.get("default_when_enabled").asText()); + } + allocationId.append("\ndefault_when_enabled=").append(allocation.has("default_when_enabled") ? allocation.get("default_when_enabled").asText() : ""); + + // Percentile + allocationId.append("\npercentiles="); + JsonNode percentile = allocation.get("percentile"); + List percentileAllocations = new ArrayList<>(); + if (percentile != null && percentile.isArray()) { + percentile.forEach(p -> { + if (!Objects.equals(p.get("from").asText(), p.get("to").asText())) { + percentileAllocations.add(p); + } + }); + percentileAllocations.sort(Comparator.comparing(p -> p.get("from").asInt())); + } + + for (JsonNode percentileAllocation : percentileAllocations) { + if (percentileAllocation.has("variant")) { + allocatedVariants.add(percentileAllocation.get("variant").asText()); + } + } + + allocationId.append(percentileAllocations.stream() + .map(pa -> pa.get("from") + "," + + Base64.getEncoder().encodeToString(pa.get("variant").asText().getBytes(StandardCharsets.UTF_8)) + "," + + pa.get("to")) + .collect(Collectors.joining(";"))); + + if (allocatedVariants.isEmpty() && (allocation.get("seed") == null)) { + return null; + } + + // Variants + allocationId.append("\nvariants="); + List> variantsValue = new ArrayList<>(); + JsonNode variantsNode = featureFlagValue.get("variants"); + if (variantsNode != null && variantsNode.isArray()) { + variantsNode.forEach(variantNode -> { + if (variantNode.isObject()) { + Map variantMap = new LinkedHashMap<>(); + variantNode.properties().iterator().forEachRemaining(entry -> variantMap.put(entry.getKey(), entry.getValue())); + variantsValue.add(variantMap); + } + }); + } + if (variantsValue != null && !variantsValue.isEmpty()) { + List> sortedVariants = variantsValue.stream() + .filter(v -> allocatedVariants.contains(v.get("name"))) + .sorted(Comparator.comparing(v -> (String) v.get("name"))) + .collect(Collectors.toList()); + + for (Map variant : sortedVariants) { + allocationId.append(Base64.getEncoder().encodeToString(((String) variant.get("name")).getBytes(StandardCharsets.UTF_8))).append(","); + Object configValue = variant.get("configuration_value"); + if (configValue instanceof Map) { + @SuppressWarnings("unchecked") + Map configMap = (Map) configValue; + allocationId.append(new TreeMap<>(configMap).toString()); + } + allocationId.append(";"); + } + if (!sortedVariants.isEmpty()) { + allocationId.setLength(allocationId.length() - 1); // Remove trailing semicolon + } + } + + // Create a SHA-256 hash of the allocationId + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(allocationId.toString().getBytes(StandardCharsets.UTF_8)); + + // Encode the first 15 bytes in Base64 URL-safe format + return Base64.getUrlEncoder().withoutPadding().encodeToString(Arrays.copyOf(hashBytes, 15)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java index 78b6e5c7bfea..824f111ae238 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java @@ -13,11 +13,14 @@ import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.json.JsonReadFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; final class JsonConfigurationParser { - private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final ObjectMapper MAPPER = JsonMapper.builder().enable(JsonReadFeature.ALLOW_JAVA_COMMENTS).build(); static boolean isJsonContentType(String contentType) { String acceptedMainType = "application"; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/NormalizeNull.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/NormalizeNull.java index 8463930ba1c9..3ae7ac5f267b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/NormalizeNull.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/NormalizeNull.java @@ -9,7 +9,7 @@ * configurations with any label. * */ -public class NormalizeNull { +class NormalizeNull { private static final String EMPTY_LABEL = "\0"; @@ -19,7 +19,7 @@ public class NormalizeNull { * @param setting ConfigurationSetting * @return ConfigurationSetting with label corrected from null to \0 */ - public static ConfigurationSetting normalizeNullLabel(ConfigurationSetting setting) { + static ConfigurationSetting normalizeNullLabel(ConfigurationSetting setting) { return setting.getLabel() == null ? setting.setLabel(EMPTY_LABEL) : setting; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java index 9de8f3d08228..8d63ce85ac92 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java @@ -21,7 +21,7 @@ final class StateHolder { private static StateHolder currentState; private final Map state = new ConcurrentHashMap<>(); - + private final Map featureFlagState = new ConcurrentHashMap<>(); private final Map loadState = new ConcurrentHashMap<>(); @@ -53,7 +53,7 @@ static State getState(String originEndpoint) { private Map getFullState() { return state; } - + private Map getFullFeatureFlagState() { return featureFlagState; } @@ -86,23 +86,15 @@ void setState(String originEndpoint, List watchKeys, Durat */ void setStateFeatureFlag(String originEndpoint, List watchKeys, Duration duration) { - featureFlagState.put(originEndpoint, new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); - } - - /** - * @param state previous state to base off - * @param duration nextRefreshPeriod - */ - void setState(State state, Duration duration) { - this.state.put(state.getOriginEndpoint(), - new State(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); + featureFlagState.put(originEndpoint, + new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); } void updateStateRefresh(State state, Duration duration) { this.state.put(state.getOriginEndpoint(), new State(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); } - + void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) { this.featureFlagState.put(state.getOriginEndpoint(), new FeatureFlagState(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); @@ -125,21 +117,12 @@ static boolean getLoadState(String originEndpoint) { return currentState.getFullLoadState().getOrDefault(originEndpoint, false); } - Map getLoadState() { - return loadState; - } - /** * @param originEndpoint the configuration store connected to. * @param loaded true if the configuration store was loaded. - * @param failFast application started after it failed to load from a store. */ - void setLoadState(String originEndpoint, Boolean loaded, Boolean failFast) { - if (loaded || !failFast) { - loadState.put(originEndpoint, true); - } else { - loadState.put(originEndpoint, false); - } + void setLoadState(String originEndpoint, Boolean loaded) { + loadState.put(originEndpoint, loaded); } /** @@ -163,7 +146,7 @@ public void setNextForcedRefresh(Duration refreshPeriod) { * Sets a minimum value until the next refresh. If a refresh interval has passed or is smaller than the calculated * backoff time, the refresh interval is set to the backoff time. * @param refreshInterval period between refresh checks. - * @param defaultMinBackoff min backoff between checks + * @param defaultMinBackoff min backoff between checks */ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { if (refreshInterval != null) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/autofailover/SRVRecord.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/autofailover/SRVRecord.java index 9646b83527d9..7b7e9c1225f2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/autofailover/SRVRecord.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/autofailover/SRVRecord.java @@ -2,7 +2,7 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.autofailover; -public class SRVRecord { +class SRVRecord { private final int priority; @@ -14,7 +14,7 @@ public class SRVRecord { private static final String PROTOCAL = "https://"; - public SRVRecord(String[] record) { + SRVRecord(String[] record) { this.priority = Integer.valueOf(record[0]); this.weight = Integer.valueOf(record[1]); this.port = Integer.valueOf(record[2]); @@ -41,7 +41,7 @@ public String getEndpoint() { return PROTOCAL + target; } - public int compareTo(SRVRecord record) { + int compareTo(SRVRecord record) { if (priority > record.getPriority()) { return 1; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfiguration.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfiguration.java deleted file mode 100644 index 2573d3abf766..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfiguration.java +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.config; - -import javax.naming.NamingException; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.PropertySource; -import org.springframework.core.env.Environment; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.util.StringUtils; - -import com.azure.data.appconfiguration.ConfigurationClientBuilder; -import com.azure.spring.cloud.appconfiguration.config.ConfigurationClientCustomizer; -import com.azure.spring.cloud.appconfiguration.config.KeyVaultSecretProvider; -import com.azure.spring.cloud.appconfiguration.config.SecretClientCustomizer; -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationKeyVaultClientFactory; -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationPropertySourceLocator; -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory; -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientsBuilder; -import com.azure.spring.cloud.appconfiguration.config.implementation.FeatureFlagClient; -import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProviderProperties; -import com.azure.spring.cloud.autoconfigure.implementation.appconfiguration.AzureAppConfigurationProperties; -import com.azure.spring.cloud.autoconfigure.implementation.context.properties.AzureGlobalProperties; -import com.azure.spring.cloud.autoconfigure.implementation.keyvault.secrets.properties.AzureKeyVaultSecretProperties; -import com.azure.spring.cloud.autoconfigure.implementation.properties.core.AbstractAzureHttpConfigurationProperties; -import com.azure.spring.cloud.autoconfigure.implementation.properties.core.authentication.TokenCredentialConfigurationProperties; -import com.azure.spring.cloud.autoconfigure.implementation.properties.utils.AzureGlobalPropertiesUtils; -import com.azure.spring.cloud.core.customizer.AzureServiceClientBuilderCustomizer; -import com.azure.spring.cloud.core.implementation.util.AzurePropertiesUtils; -import com.azure.spring.cloud.core.implementation.util.AzureSpringIdentifier; -import com.azure.spring.cloud.service.implementation.appconfiguration.ConfigurationClientBuilderFactory; -import com.azure.spring.cloud.service.implementation.keyvault.secrets.SecretClientBuilderFactory; - -/** - * Setup ConnectionPool, AppConfigurationPropertySourceLocator, and ClientStore when - * spring.cloud.azure.appconfiguration.enabled is enabled. - */ -@Configuration -@PropertySource("classpath:appConfiguration.properties") -@EnableConfigurationProperties({ AppConfigurationProperties.class, AppConfigurationProviderProperties.class }) -@ConditionalOnClass(AppConfigurationPropertySourceLocator.class) -@ConditionalOnProperty(prefix = AppConfigurationProperties.CONFIG_PREFIX, name = "enabled", matchIfMissing = true) -@EnableAsync -public class AppConfigurationBootstrapConfiguration { - - @Autowired - private transient ApplicationContext context; - - @Bean - AppConfigurationPropertySourceLocator sourceLocator(AppConfigurationProperties properties, - AppConfigurationProviderProperties appProperties, AppConfigurationReplicaClientFactory clientFactory, - AppConfigurationKeyVaultClientFactory keyVaultClientFactory, ReplicaLookUp replicaLookUp, - FeatureFlagClient featureFlagLoader) - throws IllegalArgumentException { - - return new AppConfigurationPropertySourceLocator(appProperties, clientFactory, keyVaultClientFactory, - properties.getRefreshInterval(), properties.getStores(), replicaLookUp, featureFlagLoader); - } - - @Bean - AppConfigurationKeyVaultClientFactory appConfigurationKeyVaultClientFactory(Environment environment, - AppConfigurationProviderProperties appProperties) - throws IllegalArgumentException { - AzureGlobalProperties globalSource = Binder.get(environment).bindOrCreate(AzureGlobalProperties.PREFIX, - AzureGlobalProperties.class); - AzureGlobalProperties serviceSource = Binder.get(environment).bindOrCreate(AzureKeyVaultSecretProperties.PREFIX, - AzureGlobalProperties.class); - - AzureKeyVaultSecretProperties globalProperties = AzureGlobalPropertiesUtils.loadProperties( - globalSource, - new AzureKeyVaultSecretProperties()); - AzureKeyVaultSecretProperties clientProperties = AzureGlobalPropertiesUtils.loadProperties(serviceSource, - new AzureKeyVaultSecretProperties()); - - AzurePropertiesUtils.copyAzureCommonPropertiesIgnoreNull(globalProperties, clientProperties); - - SecretClientCustomizer keyVaultClientProvider = context.getBeanProvider(SecretClientCustomizer.class) - .getIfAvailable(); - KeyVaultSecretProvider keyVaultSecretProvider = context.getBeanProvider(KeyVaultSecretProvider.class) - .getIfAvailable(); - - SecretClientBuilderFactory secretClientBuilderFactory = new SecretClientBuilderFactory(clientProperties); - - boolean credentialConfigured = isCredentialConfigured(clientProperties); - - return new AppConfigurationKeyVaultClientFactory(keyVaultClientProvider, keyVaultSecretProvider, - secretClientBuilderFactory, credentialConfigured, appProperties.getMaxRetryTime()); - } - - /** - * Factory for working with App Configuration Clients - * - * @param clientBuilder Builder for configuration clients - * @param properties Client configurations for setting up connections to each config store. - * @return AppConfigurationReplicaClientFactory - */ - @Bean - @ConditionalOnMissingBean - AppConfigurationReplicaClientFactory buildClientFactory(AppConfigurationReplicaClientsBuilder clientBuilder, - AppConfigurationProperties properties, ReplicaLookUp replicaLookUp) { - return new AppConfigurationReplicaClientFactory(clientBuilder, properties.getStores(), replicaLookUp); - } - - /** - * Loader for all feature flags. Enables de-duplicating of feature flags when multiple feature flags with the same - * name are loaded. - * @return {@link FeatureFlagClient} - */ - @Bean - @ConditionalOnMissingBean - FeatureFlagClient featureFlagLoader() { - return new FeatureFlagClient(); - } - - /** - * Builder for clients connecting to App Configuration. - * - * @param clientProperties AzureAppConfigurationProperties Spring Cloud Azure global properties. - * @param appProperties Library configurations for setting up connections to each config store. - * @param keyVaultClientFactory used for tracing info for if key vault has been configured - * @param customizers Client Customizers for connecting to Azure App Configuration - * @return ClientStore - */ - @Bean - @ConditionalOnMissingBean - AppConfigurationReplicaClientsBuilder replicaClientBuilder(Environment environment, - AppConfigurationProviderProperties appProperties, AppConfigurationKeyVaultClientFactory keyVaultClientFactory, - ObjectProvider> customizers) { - AzureGlobalProperties globalSource = Binder.get(environment).bindOrCreate(AzureGlobalProperties.PREFIX, - AzureGlobalProperties.class); - AzureGlobalProperties serviceSource = Binder.get(environment).bindOrCreate( - AzureAppConfigurationProperties.PREFIX, - AzureGlobalProperties.class); - - AzureGlobalProperties globalProperties = AzureGlobalPropertiesUtils.loadProperties(globalSource, - new AzureGlobalProperties()); - AzureAppConfigurationProperties clientProperties = AzureGlobalPropertiesUtils.loadProperties(serviceSource, - new AzureAppConfigurationProperties()); - - AzurePropertiesUtils.copyAzureCommonPropertiesIgnoreNull(globalProperties, clientProperties); - - ConfigurationClientBuilderFactory clientFactory = new ConfigurationClientBuilderFactory(clientProperties); - - clientFactory.setSpringIdentifier(AzureSpringIdentifier.AZURE_SPRING_APP_CONFIG); - customizers.orderedStream().forEach(clientFactory::addBuilderCustomizer); - - boolean credentialConfigured = isCredentialConfigured(clientProperties); - - AppConfigurationReplicaClientsBuilder clientBuilder = new AppConfigurationReplicaClientsBuilder( - appProperties.getMaxRetries(), clientFactory, credentialConfigured); - - clientBuilder - .setClientProvider(context.getBeanProvider(ConfigurationClientCustomizer.class) - .getIfAvailable()); - - clientBuilder.setIsKeyVaultConfigured(keyVaultClientFactory.isConfigured()); - - return clientBuilder; - } - - @Bean - ReplicaLookUp replicaLookUp(AppConfigurationProperties properties) throws NamingException { - return new ReplicaLookUp(properties); - } - - private boolean isCredentialConfigured(AbstractAzureHttpConfigurationProperties properties) { - if (properties.getCredential() != null) { - TokenCredentialConfigurationProperties tokenProps = properties.getCredential(); - if (StringUtils.hasText(tokenProps.getClientCertificatePassword())) { - return true; - } else if (StringUtils.hasText(tokenProps.getClientCertificatePath())) { - return true; - } else if (StringUtils.hasText(tokenProps.getClientId())) { - return true; - } else if (StringUtils.hasText(tokenProps.getClientSecret())) { - return true; - } else if (StringUtils.hasText(tokenProps.getUsername())) { - return true; - } else if (StringUtils.hasText(tokenProps.getPassword())) { - return true; - } - } - - return false; - } - -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java index e63cfb80be7f..ddca37b32140 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java @@ -13,24 +13,16 @@ public class FeatureFlagState { private final String originEndpoint; - private Integer refreshAttempt; - - private final int refreshInterval; - public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) { this.watchKeys = watchKeys; - this.refreshInterval = refreshInterval; nextRefreshCheck = Instant.now().plusSeconds(refreshInterval); this.originEndpoint = originEndpoint; - this.refreshAttempt = 1; } public FeatureFlagState(FeatureFlagState oldState, Instant newRefresh) { this.watchKeys = oldState.getWatchKeys(); - this.refreshInterval = oldState.getRefreshInterval(); this.nextRefreshCheck = newRefresh; this.originEndpoint = oldState.getOriginEndpoint(); - this.refreshAttempt = oldState.getRefreshAttempt(); } /** @@ -54,25 +46,4 @@ public String getOriginEndpoint() { return originEndpoint; } - /** - * @return the refreshAttempt - */ - public Integer getRefreshAttempt() { - return refreshAttempt; - } - - /** - * Adds 1 to the number of refresh attempts - */ - public void incrementRefreshAttempt() { - this.refreshAttempt += 1; - } - - /** - * @return the refreshInterval - */ - public int getRefreshInterval() { - return refreshInterval; - } - } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java index dc725a309000..2a1380a3d907 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java @@ -6,7 +6,6 @@ import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; public class FeatureFlags { @@ -14,27 +13,11 @@ public class FeatureFlags { private List featureFlags; - private ConfigStore configStore; - public FeatureFlags(SettingSelector settingSelector, List featureFlags) { this.settingSelector = settingSelector; this.featureFlags = featureFlags; } - /** - * @return the configStore - */ - public ConfigStore getConfigStore() { - return configStore; - } - - /** - * @param configStore the configStore to set - */ - public void setConfigStore(ConfigStore configStore) { - this.configStore = configStore; - } - /** * @return the settingSelector */ diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Conditions.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Conditions.java index d882c0399021..e010edfe7e8f 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Conditions.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Conditions.java @@ -20,7 +20,7 @@ public class Conditions { @JsonProperty("requirement_type") private String requirementType = DEFAULT_REQUIREMENT_TYPE; - public Conditions(List featureFilters, String requirementType) { + Conditions(List featureFilters, String requirementType) { clientFilters = new ArrayList<>(); clientFilters.addAll(featureFilters); this.requirementType = requirementType; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureFilterEvaluationContext.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureFilterEvaluationContext.java deleted file mode 100644 index 760284fc57bd..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureFilterEvaluationContext.java +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity; - -import java.util.HashMap; -import java.util.Map; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -/** - * Context passed into Feature Filters used for evaluation. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class FeatureFilterEvaluationContext { - - /** - * Creates an instance of {@link FeatureFilterEvaluationContext} - */ - public FeatureFilterEvaluationContext() { - } - - private String name; - - private Map parameters; - - /** - * Return the name - * @return the name - */ - public String getName() { - return name; - } - - /** - * Set the name - * @param name the name to set - */ - public void setName(String name) { - this.name = name; - } - - /** - * Return the parameters - * @return the parameters - */ - public Map getParameters() { - Map params = new HashMap(); - if (parameters != null) { - params.putAll(parameters); - } - return params; - } - - /** - * Set the parameters - * @param parameters the parameters to set - */ - public void setParameters(Map parameters) { - this.parameters = parameters; - } - -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureSet.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureSet.java deleted file mode 100644 index 5259f28a3b11..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/FeatureSet.java +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity; - -import java.util.HashMap; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Set of Feature Flag Key pairs. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class FeatureSet { - - @JsonProperty("FeatureManagement") - private HashMap featureManagement; - - /** - * Returns Map of Feature Flags. - * - * @return the featureFlags - */ - public HashMap getFeatureManagement() { - return featureManagement; - } - - /** - * Adds a new Feature Flag. - * - * @param key Name of the Feature Flag. - * @param feature true/false, for on/off feature Flag. {@code Feature} if Feature Filter. - */ - public void addFeature(String key, Object feature) { - if (featureManagement == null) { - featureManagement = new HashMap<>(); - } - if (feature != null) { - featureManagement.put(key, feature); - } - } -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicy.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicy.java index 0f5db1c2ee38..9f9ae1d174d1 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicy.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicy.java @@ -2,6 +2,8 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.http.policy; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.PUSH_REFRESH; + import org.springframework.util.StringUtils; import com.azure.core.http.HttpHeaderName; @@ -27,10 +29,10 @@ public final class BaseAppConfigurationPolicy implements HttpPipelinePolicy { /** * Format of User Agent */ - public static final String USER_AGENT = String.format("%s/%s", StringUtils.replace(PACKAGE_NAME, " ", ""), + private static final String USER_AGENT = String.format("%s/%s", StringUtils.replace(PACKAGE_NAME, " ", ""), BaseAppConfigurationPolicy.class.getPackage().getImplementationVersion()); - final TracingInfo tracingInfo; + private final TracingInfo tracingInfo; /** * App Configuration Http Pipeline Policy @@ -43,11 +45,13 @@ public BaseAppConfigurationPolicy(TracingInfo tracingInfo) { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { Boolean watchRequests = (Boolean) context.getData("refresh").orElse(false); + Boolean pushRefresh = (Boolean) context.getData(PUSH_REFRESH).orElse(false); + FeatureFlagTracing ffTracing = (FeatureFlagTracing) context.getData("FeatureFlagTracing").orElse(null); HttpHeaders headers = context.getHttpRequest().getHeaders(); String sdkUserAgent = headers.get(HttpHeaderName.USER_AGENT).getValue(); headers.set(HttpHeaderName.USER_AGENT, USER_AGENT + " " + sdkUserAgent); headers.set(HttpHeaderName.fromString(AppConfigurationConstants.CORRELATION_CONTEXT), - tracingInfo.getValue(watchRequests)); + tracingInfo.getValue(watchRequests, pushRefresh, ffTracing)); return next.process(); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/FeatureFlagTracing.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/FeatureFlagTracing.java index 8a325a4614ce..fd35c83c1387 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/FeatureFlagTracing.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/FeatureFlagTracing.java @@ -34,7 +34,7 @@ public class FeatureFlagTracing { private Boolean usesTargetingFilter = false; - public boolean usesAnyFilter() { + boolean usesAnyFilter() { return usesCustomFilter || usesPercentageFilter || usesTimeWindowFilter || usesTargetingFilter; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfo.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfo.java index e9608eb72db2..b1b7f4479e7b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfo.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfo.java @@ -2,8 +2,8 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.http.policy; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEV_ENV_TRACING; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.KEY_VAULT_CONFIGURED_TRACING; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.PUSH_REFRESH; import org.springframework.util.StringUtils; @@ -14,27 +14,26 @@ public class TracingInfo { - private boolean isDev = false; - private boolean isKeyVaultConfigured = false; private int replicaCount; - private final FeatureFlagTracing featureFlagTracing; + private FeatureFlagTracing featureFlagTracing; private final Configuration configuration; - public TracingInfo(boolean isDev, boolean isKeyVaultConfigured, int replicaCount, Configuration configuration) { - this.isDev = isDev; + public TracingInfo(boolean isKeyVaultConfigured, int replicaCount, Configuration configuration) { this.isKeyVaultConfigured = isKeyVaultConfigured; this.replicaCount = replicaCount; this.featureFlagTracing = new FeatureFlagTracing(); this.configuration = configuration; } - public String getValue(boolean watchRequests) { - String track = configuration - .get(RequestTracingConstants.REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE.toString()); + String getValue(boolean watchRequests, boolean pushRefresh, FeatureFlagTracing featureFlagTracing) { + if (featureFlagTracing != null) { + this.featureFlagTracing = featureFlagTracing; + } + String track = configuration.get(RequestTracingConstants.REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE.toString()); if (track != null && Boolean.valueOf(track)) { return ""; } @@ -44,21 +43,21 @@ public String getValue(boolean watchRequests) { sb.append(RequestTracingConstants.REQUEST_TYPE_KEY).append("=" + requestTypeValue); - if (featureFlagTracing != null && featureFlagTracing.usesAnyFilter()) { - sb.append(",Filter=").append(featureFlagTracing.toString()); + if (this.featureFlagTracing.usesAnyFilter()) { + sb.append(",Filter=").append(this.featureFlagTracing.toString()); } String hostType = getHostType(); if (!hostType.isEmpty()) { sb.append(",").append(RequestTracingConstants.HOST_TYPE_KEY).append("=").append(hostType); } - - if (isDev) { - sb.append(",Env=").append(DEV_ENV_TRACING); - } if (isKeyVaultConfigured) { sb.append(",").append(KEY_VAULT_CONFIGURED_TRACING); } + + if (pushRefresh) { + sb.append(",").append(PUSH_REFRESH); + } if (replicaCount > 0) { sb.append(",").append(RequestTracingConstants.REPLICA_COUNT).append("=").append(replicaCount); @@ -101,11 +100,4 @@ private static StringBuilder getFeatureManagementUsage(StringBuilder sb) { return sb; } - /** - * @return the featureFlagTracing - */ - public FeatureFlagTracing getFeatureFlagTracing() { - return featureFlagTracing; - } - } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java index d6d4baed1efd..3b210621899d 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java @@ -30,7 +30,7 @@ public final class AppConfigurationKeyValueSelector { /** * Separator for multiple labels */ - public static final String LABEL_SEPARATOR = ","; + private static final String LABEL_SEPARATOR = ","; @NotNull /** @@ -129,7 +129,7 @@ public void setSnapshotName(String snapshotName) { * Validates key-filter and label-filter are valid. */ @PostConstruct - public void validateAndInit() { + void validateAndInit() { Assert.isTrue(!keyFilter.contains("*"), "KeyFilter must not contain asterisk(*)"); if (labelFilter != null) { Assert.isTrue(!labelFilter.contains("*"), "LabelFilter must not contain asterisk(*)"); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProviderProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProviderProperties.java deleted file mode 100644 index c0b05c03881e..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProviderProperties.java +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.properties; - -import java.time.Instant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.util.Assert; - -import jakarta.annotation.PostConstruct; - -/** - * Properties defining connection to Azure App Configuration. - */ -@ConfigurationProperties(prefix = AppConfigurationProviderProperties.CONFIG_PREFIX) -public class AppConfigurationProviderProperties { - - /** - * Prefix for the libraries internal configurations. - */ - public static final String CONFIG_PREFIX = "spring.cloud.appconfiguration"; - - private static final Instant START_DATE = Instant.now(); - - @Value("${version:1.0}") - private String version; - - @Value("${maxRetries:2}") - private int maxRetries; - - @Value("${maxRetryTime:60}") - private int maxRetryTime; - - @Value("${prekillTime:5}") - private int prekillTime; - - @Value("${defaultMinBackoff:30}") - private Long defaultMinBackoff; - - @Value("${defaultMaxBackoff:600}") - private Long defaultMaxBackoff; - - /** - * @return the apiVersion - */ - public String getVersion() { - return version; - } - - /** - * @param apiVersion the apiVersion to set - */ - public void setVersion(String apiVersion) { - this.version = apiVersion; - } - - /** - * @return the maxRetries - */ - public int getMaxRetries() { - return maxRetries; - } - - /** - * @param maxRetries the maxRetries to set - */ - public void setMaxRetries(int maxRetries) { - this.maxRetries = maxRetries; - } - - /** - * @return the maxRetryTime - */ - public int getMaxRetryTime() { - return maxRetryTime; - } - - /** - * @param maxRetryTime the maxRetryTime to set - */ - public void setMaxRetryTime(int maxRetryTime) { - this.maxRetryTime = maxRetryTime; - } - - /** - * @return the prekillTime - */ - public int getPrekillTime() { - return prekillTime; - } - - /** - * @param prekillTime the prekillTime to set - */ - public void setPrekillTime(int prekillTime) { - this.prekillTime = prekillTime; - } - - /** - * @return the startDate - */ - public Instant getStartDate() { - return START_DATE; - } - - /** - * @return the defaultMinBackoff - */ - public Long getDefaultMinBackoff() { - return defaultMinBackoff; - } - - /** - * @param defaultMinBackoff the defaultMinBackoff to set - */ - public void setDefaultMinBackoff(Long defaultMinBackoff) { - this.defaultMinBackoff = defaultMinBackoff; - } - - /** - * @return the defaultMaxBackoff - */ - public Long getDefaultMaxBackoff() { - return defaultMaxBackoff; - } - - /** - * @param defaultMaxBackoff the defaultMaxBackoff to set - */ - public void setDefaultMaxBackoff(Long defaultMaxBackoff) { - this.defaultMaxBackoff = defaultMaxBackoff; - } - - @PostConstruct - public void validateAndInit() { - Assert.hasLength(version, "A version of app configuration should be set."); - Assert.notNull(maxRetries, "A number of max retries has to be configured."); - Assert.notNull(maxRetryTime, "A max retry value needs to be configured"); - Assert.notNull(prekillTime, "A preKill time value needs to be configured."); - Assert.notNull(defaultMinBackoff, "A default minimum backoff time value needs to be set."); - Assert.notNull(defaultMaxBackoff, "A default max backoff time value needs to be set."); - } - -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java index 22db1862d354..f90a3a573f12 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java @@ -118,7 +118,7 @@ public void setPushNotification(PushNotification pushNotification) { * Validates refreshIntervals are at least 1 second, and if enabled triggers are valid. */ @PostConstruct - public void validateAndInit() { + void validateAndInit() { if (enabled) { Assert.notEmpty(triggers, "Triggers need to be set if refresh is enabled."); for (AppConfigurationStoreTrigger trigger : triggers) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java index ec69376e1bd7..97499d8b3f23 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java @@ -58,7 +58,7 @@ public void setLabel(String label) { * Validates key isn't null */ @PostConstruct - public void validateAndInit() { + void validateAndInit() { Assert.notNull(key, "All Triggers need a key value set."); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java index 7444d739770f..0f344b1637a2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java @@ -53,15 +53,6 @@ public final class ConfigStore { */ private List selects = new ArrayList<>(); - /** - * If true, the application will fail to start if the Config Store cannot be - * reached. If false, the application will start without the Config Store. - */ - private boolean failFast = true; - - /** - * Options for retrieving Feature Flags from the Azure Config Service. - */ private FeatureFlagStore featureFlags = new FeatureFlagStore(); /** @@ -147,20 +138,6 @@ public void setConnectionStrings(List connectionStrings) { this.connectionStrings = connectionStrings; } - /** - * @return the failFast - */ - public boolean isFailFast() { - return failFast; - } - - /** - * @param failFast the failFast to set - */ - public void setFailFast(boolean failFast) { - this.failFast = failFast; - } - /** * @return the enabled */ diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java index b3280178e200..4d1de6b90b11 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java @@ -105,7 +105,7 @@ public FeatureFlagKeyValueSelector setLabelFilter(String labelFilter) { * Validates key-filter and label-filter are valid. */ @PostConstruct - public void validateAndInit() { + void validateAndInit() { if (labelFilter != null) { Assert.isTrue(!labelFilter.contains("*"), "LabelFilter must not contain asterisk(*)"); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java index c7b5e89646f7..18ee7c2f28b3 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java @@ -48,7 +48,7 @@ public void setSelects(List selects) { } @PostConstruct - public void validateAndInit() { + void validateAndInit() { if (enabled && selects.size() == 0) { selects.add(new FeatureFlagKeyValueSelector()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/AppConfigurationSecretClientManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/AppConfigurationSecretClientManager.java index 2c086d3c5dab..9a8437d7bfd2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/AppConfigurationSecretClientManager.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/AppConfigurationSecretClientManager.java @@ -7,7 +7,7 @@ import org.springframework.util.StringUtils; -import com.azure.identity.ManagedIdentityCredentialBuilder; +import com.azure.identity.DefaultAzureCredentialBuilder; import com.azure.security.keyvault.secrets.SecretAsyncClient; import com.azure.security.keyvault.secrets.SecretClientBuilder; import com.azure.security.keyvault.secrets.models.KeyVaultSecret; @@ -32,7 +32,7 @@ public final class AppConfigurationSecretClientManager { private final boolean credentialConfigured; - private final int timeout; + private final int timeout = 30; /** * Creates a Client for connecting to Key Vault @@ -41,17 +41,15 @@ public final class AppConfigurationSecretClientManager { * @param keyVaultSecretProvider optional provider for providing Secrets instead of connecting to Key Vault * @param secretClientFactory Factory for building clients to Key Vault * @param credentialConfigured Is a credential configured with Global Configurations or Service Configurations - * @param timeout How long the connection to key vault is kept open without a response. */ public AppConfigurationSecretClientManager(String endpoint, SecretClientCustomizer keyVaultClientProvider, KeyVaultSecretProvider keyVaultSecretProvider, SecretClientBuilderFactory secretClientFactory, - boolean credentialConfigured, int timeout) { + boolean credentialConfigured) { this.endpoint = endpoint; this.keyVaultClientProvider = keyVaultClientProvider; this.keyVaultSecretProvider = keyVaultSecretProvider; this.secretClientFactory = secretClientFactory; this.credentialConfigured = credentialConfigured; - this.timeout = timeout; } AppConfigurationSecretClientManager build() { @@ -59,7 +57,7 @@ AppConfigurationSecretClientManager build() { if (!credentialConfigured) { // System Assigned Identity. - builder.credential(new ManagedIdentityCredentialBuilder().build()); + builder.credential(new DefaultAzureCredentialBuilder().build()); } builder.vaultUrl(endpoint); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring.factories b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring.factories index 9a76c2325a3c..fb0f411d86c2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring.factories +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring.factories @@ -1,3 +1,5 @@ -org.springframework.cloud.bootstrap.BootstrapConfiguration=\ -com.azure.spring.cloud.appconfiguration.config.implementation.config.AppConfigurationBootstrapConfiguration +# ConfigData Location Resolvers + org.springframework.boot.context.config.ConfigDataLocationResolver= com.azure.spring.cloud.appconfiguration.config.implementation.AzureAppConfigDataLocationResolver + # ConfigData Loaders + org.springframework.boot.context.config.ConfigDataLoader= com.azure.spring.cloud.appconfiguration.config.implementation.AzureAppConfigDataLoader diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 1b36b907ed1b..0472d30fc7d2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1 @@ -com.azure.spring.cloud.appconfiguration.config.AppConfigurationAutoConfiguration -com.azure.spring.cloud.appconfiguration.config.implementation.config.AppConfigurationBootstrapConfiguration \ No newline at end of file +com.azure.spring.cloud.appconfiguration.config.AppConfigurationWatchAutoConfiguration diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/appConfiguration.properties b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/appConfiguration.properties deleted file mode 100644 index 9b90ce63ee85..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/resources/appConfiguration.properties +++ /dev/null @@ -1,6 +0,0 @@ -spring.cloud.appconfiguration.version=1.0 -spring.cloud.appconfiguration.maxRetries=2 -spring.cloud.appconfiguration.maxRetryTime=60 -spring.cloud.appconfiguration.preKillTime=5 -spring.cloud.appconfiguration.defaultMinBackoff=30 -spring.cloud.appconfiguration.defaultmaxBackoff=600 diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceSnapshotTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceSnapshotTest.java index bd48c729a109..1bad01307099 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceSnapshotTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceSnapshotTest.java @@ -39,12 +39,14 @@ import org.mockito.MockitoSession; import org.mockito.quality.Strictness; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; + public class AppConfigurationApplicationSettingPropertySourceSnapshotTest { private static final String EMPTY_CONTENT_TYPE = ""; @@ -70,7 +72,7 @@ public class AppConfigurationApplicationSettingPropertySourceSnapshotTest { createItem("/bar/", "test_key_4", "test_value_4", "test_label_4", EMPTY_CONTENT_TYPE); private static final FeatureFlagConfigurationSetting FEATURE_ITEM = createItemFeatureFlag(".appconfig.featureflag/", - "Alpha", FEATURE_VALUE, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE); + "Alpha", FEATURE_VALUE, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, "fake-etag"); private static final ConfigurationSetting ITEM_NULL = createItem(KEY_FILTER, TEST_KEY_3, TEST_VALUE_3, TEST_LABEL_3, null); @@ -90,6 +92,9 @@ public class AppConfigurationApplicationSettingPropertySourceSnapshotTest { @Mock private List configurationListMock; + @Mock + private Context contextMock; + FeatureFlagClient featureFlagLoader = new FeatureFlagClient(); private MockitoSession session; @@ -128,10 +133,10 @@ public void cleanup() throws Exception { @Test public void testPropCanBeInitAndQueried() throws IOException { when(configurationListMock.iterator()).thenReturn(testItems.iterator()).thenReturn(testItems.iterator()); - when(clientMock.listSettingSnapshot(Mockito.any())).thenReturn(configurationListMock) + when(clientMock.listSettingSnapshot(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock) .thenReturn(configurationListMock); - propertySource.initProperties(TRIM); + propertySource.initProperties(TRIM, contextMock); String[] keyNames = propertySource.getPropertyNames(); String[] expectedKeyNames = testItems.stream().map(t -> { @@ -145,7 +150,7 @@ public void testPropCanBeInitAndQueried() throws IOException { assertThat(propertySource.getProperty(TEST_KEY_2)).isEqualTo(TEST_VALUE_2); assertThat(propertySource.getProperty(TEST_KEY_3)).isEqualTo(TEST_VALUE_3); assertThat(propertySource.getProperty(".bar.test_key_4")).isEqualTo("test_value_4"); - assertEquals(1, featureFlagLoader.getProperties().size()); + assertEquals(1, featureFlagLoader.getFeatureFlags().size()); } @Test @@ -155,10 +160,10 @@ public void testPropertyNameSlashConvertedToDots() throws IOException { List settings = new ArrayList<>(); settings.add(slashedProp); when(configurationListMock.iterator()).thenReturn(settings.iterator()).thenReturn(Collections.emptyIterator()); - when(clientMock.listSettingSnapshot(Mockito.any())).thenReturn(configurationListMock) + when(clientMock.listSettingSnapshot(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock) .thenReturn(configurationListMock); - propertySource.initProperties(TRIM); + propertySource.initProperties(TRIM, contextMock); String expectedKeyName = TEST_SLASH_KEY.replace('/', '.'); String[] actualKeyNames = propertySource.getPropertyNames(); @@ -174,9 +179,9 @@ public void initNullValidContentTypeTest() throws IOException { List items = new ArrayList<>(); items.add(ITEM_NULL); when(configurationListMock.iterator()).thenReturn(items.iterator()).thenReturn(Collections.emptyIterator()); - when(clientMock.listSettingSnapshot(Mockito.any())).thenReturn(configurationListMock); + when(clientMock.listSettingSnapshot(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock); - propertySource.initProperties(TRIM); + propertySource.initProperties(TRIM, contextMock); String[] keyNames = propertySource.getPropertyNames(); String[] expectedKeyNames = diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java index 1f7fe5ce9760..af99492615e5 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java @@ -16,6 +16,7 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_VALUE_2; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_VALUE_3; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.createItem; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.createItemFeatureFlag; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; @@ -34,12 +35,14 @@ import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; import org.mockito.quality.Strictness; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; +import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; public class AppConfigurationApplicationSettingPropertySourceTest { @@ -66,6 +69,8 @@ public class AppConfigurationApplicationSettingPropertySourceTest { private static final ConfigurationSetting ITEM_INVALID_JSON = createItem(KEY_FILTER, TEST_KEY_3, TEST_VALUE_3, TEST_LABEL_3, JSON_CONTENT_TYPE); + + private static final FeatureFlagConfigurationSetting FEATURE_FLAG = createItemFeatureFlag("Beta", "/0"); private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -82,6 +87,9 @@ public class AppConfigurationApplicationSettingPropertySourceTest { @Mock private List configurationListMock; + @Mock + private Context contextMock; + private MockitoSession session; @BeforeAll @@ -100,6 +108,7 @@ public void init() { testItems.add(ITEM_1); testItems.add(ITEM_2); testItems.add(ITEM_3); + testItems.add(FEATURE_FLAG); String[] labelFilter = { "\0" }; @@ -116,13 +125,13 @@ public void cleanup() throws Exception { @Test public void testPropCanBeInitAndQueried() throws IOException { when(configurationListMock.iterator()).thenReturn(testItems.iterator()); - when(clientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(configurationListMock) + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock) .thenReturn(configurationListMock); - propertySource.initProperties(null, false); + propertySource.initProperties(null, contextMock); String[] keyNames = propertySource.getPropertyNames(); - String[] expectedKeyNames = testItems.stream() + String[] expectedKeyNames = testItems.stream().filter(config -> !(config instanceof FeatureFlagConfigurationSetting)) .map(t -> t.getKey().substring(KEY_FILTER.length())).toArray(String[]::new); assertThat(keyNames).containsExactlyInAnyOrder(expectedKeyNames); @@ -140,10 +149,10 @@ public void testPropertyNameSlashConvertedToDots() throws IOException { settings.add(slashedProp); when(configurationListMock.iterator()).thenReturn(settings.iterator()) .thenReturn(Collections.emptyIterator()); - when(clientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(configurationListMock) + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock) .thenReturn(configurationListMock); - propertySource.initProperties(null, false); + propertySource.initProperties(null, contextMock); String expectedKeyName = TEST_SLASH_KEY.replace('/', '.'); String[] actualKeyNames = propertySource.getPropertyNames(); @@ -160,9 +169,9 @@ public void initNullValidContentTypeTest() throws IOException { items.add(ITEM_NULL); when(configurationListMock.iterator()).thenReturn(items.iterator()) .thenReturn(Collections.emptyIterator()); - when(clientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(configurationListMock); + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock); - propertySource.initProperties(null, false); + propertySource.initProperties(null, contextMock); String[] keyNames = propertySource.getPropertyNames(); String[] expectedKeyNames = items.stream() @@ -177,9 +186,9 @@ public void jsonContentTypeWithInvalidJsonValueTest() { items.add(ITEM_INVALID_JSON); when(configurationListMock.iterator()).thenReturn(items.iterator()) .thenReturn(Collections.emptyIterator()); - when(clientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(configurationListMock); + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(configurationListMock); - assertThatThrownBy(() -> propertySource.initProperties(null, false)) + assertThatThrownBy(() -> propertySource.initProperties(null, contextMock)) .isInstanceOf(InvalidConfigurationPropertyValueException.class) .hasMessageNotContaining(ITEM_INVALID_JSON.getValue()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySourceTest.java index 804ef5b7c830..27eea55bd83a 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySourceTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationFeatureManagementPropertySourceTest.java @@ -8,7 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; -import java.util.HashMap; +import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,9 +25,9 @@ public class AppConfigurationFeatureManagementPropertySourceTest { @Mock private FeatureFlagClient featureFlagLoaderMock; - - private static final String FEATURE_FLAG_KEY = "feature_management.feature_flags"; - + + private static final String FEATURE_FLAG_KEY = "feature-management.feature-flags"; + private MockitoSession session; @BeforeEach @@ -35,7 +35,7 @@ public void init() { session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking(); MockitoAnnotations.openMocks(this); } - + @AfterEach public void cleanup() throws Exception { MockitoAnnotations.openMocks(this).close(); @@ -46,21 +46,24 @@ public void cleanup() throws Exception { public void getPropertyNamesTest() { AppConfigurationFeatureManagementPropertySource featureManagementPropertySource = new AppConfigurationFeatureManagementPropertySource( featureFlagLoaderMock); - + String[] names = featureManagementPropertySource.getPropertyNames(); - assertTrue(names.length == 1); + assertTrue(names.length == 0); + + when(featureFlagLoaderMock.getFeatureFlags()).thenReturn(List.of(new Feature())); + names = featureManagementPropertySource.getPropertyNames(); assertEquals(FEATURE_FLAG_KEY, names[0]); } - + @Test public void getPropertyTest() { AppConfigurationFeatureManagementPropertySource featureManagementPropertySource = new AppConfigurationFeatureManagementPropertySource( featureFlagLoaderMock); - + assertNull(featureManagementPropertySource.getProperty("NotFeatureFlagProperty")); - when(featureFlagLoaderMock.getProperties()).thenReturn(new HashMap()); + when(featureFlagLoaderMock.getFeatureFlags()).thenReturn(List.of(new Feature())); assertNotNull(featureManagementPropertySource.getProperty(FEATURE_FLAG_KEY)); - + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java index 2833cc92970c..74d97b275702 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java @@ -2,7 +2,7 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.KEY_VAULT_CONTENT_TYPE; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.KEY_VAULT_CONTENT_TYPE; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_CONN_STRING; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_KEY_VAULT_1; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_LABEL_VAULT_1; @@ -30,6 +30,7 @@ import org.mockito.quality.Strictness; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.SecretReferenceConfigurationSetting; import com.azure.security.keyvault.secrets.SecretAsyncClient; @@ -70,6 +71,9 @@ public class AppConfigurationPropertySourceKeyVaultTest { @Mock private List keyVaultSecretListMock; + + @Mock + private Context contextMock; private MockitoSession session; @@ -98,7 +102,7 @@ public void testKeyVaultTest() { List settings = List.of(KEY_VAULT_ITEM); when(keyVaultSecretListMock.iterator()).thenReturn(settings.iterator()) .thenReturn(Collections.emptyIterator()); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(keyVaultSecretListMock) + when(replicaClientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(keyVaultSecretListMock) .thenReturn(keyVaultSecretListMock); KeyVaultSecret secret = new KeyVaultSecret("mySecret", "mySecretValue"); @@ -107,7 +111,7 @@ public void testKeyVaultTest() { when(clientManagerMock.getSecret(Mockito.any(URI.class))).thenReturn(secret); try { - propertySource.initProperties(null, false); + propertySource.initProperties(null, contextMock); } catch (InvalidConfigurationPropertyValueException e) { fail("Failed Reading in Feature Flags"); } @@ -125,11 +129,11 @@ public void invalidKeyVaultReferenceInvalidURITest() { List settings = List.of(KEY_VAULT_ITEM_INVALID_URI); when(keyVaultSecretListMock.iterator()).thenReturn(settings.iterator()) .thenReturn(Collections.emptyIterator()); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(keyVaultSecretListMock) + when(replicaClientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(keyVaultSecretListMock) .thenReturn(keyVaultSecretListMock); InvalidConfigurationPropertyValueException exception = assertThrows( - InvalidConfigurationPropertyValueException.class, () -> propertySource.initProperties(null, false)); + InvalidConfigurationPropertyValueException.class, () -> propertySource.initProperties(null, contextMock)); assertEquals("test_key_vault_1", exception.getName()); assertEquals("", exception.getValue()); assertEquals("Invalid URI found in JSON property field 'uri' unable to parse.", exception.getReason()); @@ -140,14 +144,13 @@ public void invalidKeyVaultReferenceParseErrorTest() { List settings = List.of(KEY_VAULT_ITEM); when(keyVaultSecretListMock.iterator()).thenReturn(settings.iterator()) .thenReturn(Collections.emptyIterator()); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(keyVaultSecretListMock) + when(replicaClientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(keyVaultSecretListMock) .thenReturn(keyVaultSecretListMock); when(keyVaultClientFactoryMock.getClient(Mockito.eq("https://test.key.vault.com"))) .thenReturn(clientManagerMock); when(clientManagerMock.getSecret(Mockito.any())).thenThrow(new RuntimeException("Parse Failed")); - RuntimeException exception = assertThrows(RuntimeException.class, - () -> propertySource.initProperties(null, false)); + RuntimeException exception = assertThrows(RuntimeException.class, () -> propertySource.initProperties(null, contextMock)); assertEquals("Parse Failed", exception.getMessage()); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocatorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocatorTest.java deleted file mode 100644 index a68ac60e28e6..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceLocatorTest.java +++ /dev/null @@ -1,568 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation; - -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_CONN_STRING; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_CONN_STRING_2; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_STORE_NAME; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_STORE_NAME_2; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -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 static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.mockito.MockitoSession; -import org.mockito.quality.Strictness; -import org.springframework.core.env.CompositePropertySource; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MutablePropertySources; -import org.springframework.core.env.PropertySource; - -import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; -import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProviderProperties; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; -import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; - -@TestMethodOrder(MethodOrderer.MethodName.class) -public class AppConfigurationPropertySourceLocatorTest { - - private static final String PROFILE_NAME_1 = "dev"; - - private static final String PROFILE_NAME_2 = "prod"; - - private static final String KEY_FILTER = "/foo/"; - - @Mock - private ConfigurableEnvironment emptyEnvironment; - - @Mock - private ConfigurableEnvironment devEnvironment; - - @Mock - private ConfigurableEnvironment multiEnvironment; - - @Mock - private AppConfigurationReplicaClientFactory clientFactoryMock; - - @Mock - private AppConfigurationKeyVaultClientFactory keyVaultClientFactory; - - @Mock - private AppConfigurationReplicaClient replicaClientMock; - - @Mock - private FeatureFlagStore featureFlagStoreMock; - - @Mock - private ConfigStore configStoreMockError; - - @Mock - private AppConfigurationProviderProperties appPropertiesMock; - - @Mock - private ReplicaLookUp replicaLookUpMock; - - @Mock - private FeatureFlagClient featureFlagClientMock; - - @Mock - private ConfigStore configStoreMock; - - private AppConfigurationPropertySourceLocator locator; - - private AppConfigurationProperties properties; - - private AppConfigurationProviderProperties appProperties; - - private List stores; - - private AppConfigurationStoreMonitoring monitoring; - - private MutablePropertySources sources = new MutablePropertySources(); - - private MockitoSession session; - - @BeforeEach - public void setup() { - session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking(); - MockitoAnnotations.openMocks(this); - - sources.addFirst(new PropertySource("refreshArgs") { - - @Override - public Object getProperty(String name) { - return null; - } - }); - - properties = new AppConfigurationProperties(); - properties.setEnabled(true); - properties.setRefreshInterval(null); - - TestUtils.addStore(properties, TEST_STORE_NAME, TEST_CONN_STRING, KEY_FILTER); - - monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(false); - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); - trigger.setKey("test"); - - monitoring.setTriggers(List.of(trigger)); - - appProperties = new AppConfigurationProviderProperties(); - appProperties.setVersion("1.0"); - appProperties.setMaxRetries(12); - appProperties.setMaxRetryTime(0); - appProperties.setDefaultMaxBackoff((long) 600); - appProperties.setDefaultMinBackoff((long) 30); - - properties.getStores().get(0).setFeatureFlags(featureFlagStoreMock); - properties.getStores().get(0).setMonitoring(monitoring); - stores = properties.getStores(); - } - - @AfterEach - public void cleanup() throws Exception { - MockitoAnnotations.openMocks(this).close(); - session.finishMocking(); - AppConfigurationPropertySourceLocator.STARTUP.set(true); - } - - @Test - public void compositeSourceIsCreated() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, - keyVaultClientFactory, null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void compositeSourceIsCreatedWithMonitoring() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - String watchKey = "wk1"; - AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(true); - - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); - trigger.setKey(watchKey); - trigger.setLabel(EMPTY_LABEL); - monitoring.setTriggers(List.of(trigger)); - - properties.getStores().get(0).setMonitoring(monitoring); - - when(replicaClientMock.getWatchKey(Mockito.eq(watchKey), Mockito.anyString(), Mockito.anyBoolean())) - .thenReturn(TestUtils.createItem("", watchKey, "0", EMPTY_LABEL, "")); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - verify(replicaClientMock, times(1)).getWatchKey(Mockito.eq(watchKey), Mockito.anyString(), - Mockito.anyBoolean()); - } - } - - @Test - public void compositeSourceIsCreatedWithMonitoringWatchKeyDoesNotExist() { - // The listed Watch Key doesn't have a value in app config. When one is added will cause a refresh. - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - String watchKey = "wk1"; - AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(true); - - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); - trigger.setKey(watchKey); - trigger.setLabel(EMPTY_LABEL); - monitoring.setTriggers(List.of(trigger)); - - properties.getStores().get(0).setMonitoring(monitoring); - - when(replicaClientMock.getWatchKey(Mockito.eq(watchKey), Mockito.anyString(), Mockito.anyBoolean())) - .thenReturn(null); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - verify(replicaClientMock, times(1)).getWatchKey(Mockito.eq(watchKey), Mockito.anyString(), - Mockito.anyBoolean()); - } - } - - @Test - public void devSourceIsCreated() { - when(devEnvironment.getActiveProfiles()).thenReturn(new String[] { PROFILE_NAME_1 }); - when(devEnvironment.getPropertySources()).thenReturn(sources); - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of()); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(devEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/dev" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void multiSourceIsCreated() { - when(multiEnvironment.getActiveProfiles()).thenReturn(new String[] { PROFILE_NAME_1, PROFILE_NAME_2 }); - when(multiEnvironment.getPropertySources()).thenReturn(sources); - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of()); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(multiEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/prod,dev" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void storeCreatedWithFeatureFlags() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of("fake_features", new Feature())); - - FeatureFlagStore featureFlagStore = new FeatureFlagStore(); - featureFlagStore.setEnabled(true); - featureFlagStore.validateAndInit(); - - FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); - featureFlag.setValue(""); - - properties.getStores().get(0).setFeatureFlags(featureFlagStore); - - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of(featureFlag)); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0", - "feature_management" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void storeCreatedWithFeatureFlagsWithMonitoring() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of("fake_features", new Feature())); - - AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(true); - FeatureFlagStore featureFlagStore = new FeatureFlagStore(); - featureFlagStore.setEnabled(true); - featureFlagStore.validateAndInit(); - - FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); - featureFlag.setValue(""); - - properties.getStores().get(0).setFeatureFlags(featureFlagStore); - properties.getStores().get(0).setMonitoring(monitoring); - - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of(featureFlag)); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0", - "feature_management" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void watchedKeyCheck() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - String[] expectedSourceNames = new String[] { - KEY_FILTER + "store1/\0" - }; - assertEquals(expectedSourceNames.length, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void defaultFailFastThrowException() { - when(emptyEnvironment.getActiveProfiles()).thenReturn(new String[] {}); - when(emptyEnvironment.getPropertySources()).thenReturn(sources); - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(configStoreMock.getEndpoint()).thenReturn(TEST_STORE_NAME); - when(configStoreMock.isEnabled()).thenReturn(true); - when(configStoreMock.getSelects()).thenReturn(List.of()); - when(configStoreMock.getFeatureFlags()).thenReturn(featureFlagStoreMock); - when(configStoreMock.isFailFast()).thenReturn(true); - - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); - AppConfigurationStoreMonitoring monitor = new AppConfigurationStoreMonitoring(); - monitor.setEnabled(true); - monitor.setTriggers(List.of(trigger)); - - when(configStoreMock.getMonitoring()).thenReturn(monitor); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, List.of(configStoreMock), replicaLookUpMock, featureFlagClientMock); - - when(replicaClientMock.getWatchKey(Mockito.any(), Mockito.anyString(), Mockito.anyBoolean())).thenThrow(new RuntimeException()); - RuntimeException e = assertThrows(RuntimeException.class, () -> locator.locate(emptyEnvironment)); - assertEquals("Failed to generate property sources for " + TEST_STORE_NAME, e.getMessage()); - verify(configStoreMock, times(1)).isFailFast(); - } - - @Test - public void refreshThrowException() throws IllegalArgumentException { - setupEmptyEnvironment(); - when(replicaClientMock.listSettings(any(), Mockito.anyBoolean())).thenThrow(new RuntimeException()); - - AppConfigurationPropertySourceLocator.STARTUP.set(false); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(Mockito.anyString())).thenReturn(true); - RuntimeException e = assertThrows(RuntimeException.class, () -> locator.locate(emptyEnvironment)); - assertEquals("Failed to generate property sources for store1", e.getMessage()); - } - } - - @Test - public void notFailFastShouldPass() { - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - when(emptyEnvironment.getActiveProfiles()).thenReturn(new String[] {}); - when(emptyEnvironment.getPropertySources()).thenReturn(sources); - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(configStoreMock.getEndpoint()).thenReturn(TEST_STORE_NAME); - when(configStoreMock.isEnabled()).thenReturn(true); - when(configStoreMock.getSelects()).thenReturn(List.of()); - when(configStoreMock.getFeatureFlags()).thenReturn(featureFlagStoreMock); - when(configStoreMock.isFailFast()).thenReturn(false); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, List.of(configStoreMock), replicaLookUpMock, featureFlagClientMock); - - properties.getStores().get(0).setFailFast(false); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - // Once a store fails it should stop attempting to load - verify(configStoreMock, times(3)).isFailFast(); - } - } - - @Test - public void multiplePropertySourcesExistForMultiStores() { - setupEmptyEnvironment(); - when(featureFlagClientMock.getProperties()).thenReturn(Map.of()); - TestUtils.addStore(properties, TEST_STORE_NAME_2, TEST_CONN_STRING_2, KEY_FILTER); - - locator = new AppConfigurationPropertySourceLocator(appProperties, - clientFactoryMock, keyVaultClientFactory, null, properties.getStores(), replicaLookUpMock, - featureFlagClientMock); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - String[] expectedSourceNames = new String[] { KEY_FILTER + TEST_STORE_NAME_2 + "/\0", - KEY_FILTER + TEST_STORE_NAME + "/\0" }; - assertEquals(2, sources.size()); - assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); - } - } - - @Test - public void awaitOnError() { - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of()); - when(appPropertiesMock.getPrekillTime()).thenReturn(5); - - ConfigurableEnvironment env = Mockito.mock(ConfigurableEnvironment.class); - MutablePropertySources sources = new MutablePropertySources(); - - sources.addFirst(new PropertySource("refreshArgs") { - - @Override - public Object getProperty(String name) { - return null; - } - }); - - when(env.getPropertySources()).thenReturn(sources); - - String[] array = {}; - when(env.getActiveProfiles()).thenReturn(array); - AppConfigurationKeyValueSelector selectedKeys = new AppConfigurationKeyValueSelector() - .setKeyFilter("/application/"); - List selects = new ArrayList<>(); - selects.add(selectedKeys); - when(configStoreMockError.getSelects()).thenReturn(selects); - when(configStoreMockError.isEnabled()).thenReturn(true); - when(configStoreMockError.isFailFast()).thenReturn(true); - when(configStoreMockError.getEndpoint()).thenReturn(""); - - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenThrow(new NullPointerException("")); - when(appPropertiesMock.getPrekillTime()).thenReturn(-60); - when(appPropertiesMock.getStartDate()).thenReturn(Instant.now()); - - locator = new AppConfigurationPropertySourceLocator(appPropertiesMock, clientFactoryMock, keyVaultClientFactory, - null, List.of(configStoreMockError), replicaLookUpMock, featureFlagClientMock); - - assertThrows(RuntimeException.class, () -> locator.locate(env)); - verify(appPropertiesMock, times(1)).getPrekillTime(); - } - - @Test - public void storeDisabled() { - when(emptyEnvironment.getActiveProfiles()).thenReturn(new String[] {}); - when(emptyEnvironment.getPropertySources()).thenReturn(sources); - properties.getStores().get(0).setEnabled(false); - properties.getStores().get(0).setMonitoring(monitoring); - - locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, - null, stores, replicaLookUpMock, featureFlagClientMock); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.updateState(Mockito.any())).thenReturn(null); - PropertySource source = locator.locate(emptyEnvironment); - assertTrue(source instanceof CompositePropertySource); - - Collection> sources = ((CompositePropertySource) source).getPropertySources(); - - assertEquals(0, sources.size()); - } - } - - private void setupEmptyEnvironment() { - when(emptyEnvironment.getActiveProfiles()).thenReturn(new String[] {}); - when(emptyEnvironment.getPropertySources()).thenReturn(sources); - when(clientFactoryMock.getAvailableClients(Mockito.anyString(), Mockito.eq(true))).thenReturn(Arrays.asList(replicaClientMock)); - when(replicaClientMock.listSettings(Mockito.any(), Mockito.anyBoolean())).thenReturn(List.of()); - } -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java index d6163c972b7b..72bb068ecb71 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java @@ -63,7 +63,7 @@ public void refreshNoChange() throws InterruptedException, ExecutionException { when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock); AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval, - (long) 0, replicaLookUpMock, refreshUtilMock); + replicaLookUpMock, refreshUtilMock); assertFalse(refresh.refreshConfigurations().block()); } @@ -75,7 +75,7 @@ public void refreshUpdate() throws InterruptedException, ExecutionException { when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock); AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval, - (long) 0, replicaLookUpMock, refreshUtilMock); + replicaLookUpMock, refreshUtilMock); refresh.setApplicationEventPublisher(publisher); assertTrue(refresh.refreshConfigurations().block()); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java index ba6b62292b8c..9e5ab6bcaf9e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -28,6 +29,7 @@ import org.mockito.MockitoSession; import org.mockito.quality.Strictness; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; @@ -36,6 +38,8 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.AccessToken; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagKeyValueSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; @@ -62,6 +66,9 @@ public class AppConfigurationRefreshUtilTest { @Mock private ReplicaLookUp replicaLookUpMock; + @Mock + private Context contextMock; + private ConfigStore configStore; private String endpoint; @@ -101,13 +108,12 @@ public void cleanup() throws Exception { public void refreshWithoutTimeWatchKeyConfigStoreNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false); - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } } @@ -115,20 +121,19 @@ public void refreshWithoutTimeWatchKeyConfigStoreNotLoaded(TestInfo testInfo) { public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNotReturned(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); List watchKeys = generateWatchKeys(); State newState = new State(watchKeys, Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store doesn't return a watch key change. - when(clientMock.getWatchKey(Mockito.eq(KEY_FILTER), Mockito.eq(EMPTY_LABEL), Mockito.anyBoolean())) + when(clientMock.getWatchKey(Mockito.eq(KEY_FILTER), Mockito.eq(EMPTY_LABEL), Mockito.any(Context.class))) .thenReturn(watchKeys.get(0)); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } } @@ -136,49 +141,47 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNotReturned(TestInfo te public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNoChange(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store does return a watch key change. - when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.anyBoolean())).thenReturn(false); + when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } } - @SuppressWarnings("try") @Test public void refreshWithoutTimeFeatureFlagDisabled(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); configStore.getFeatureFlags().setEnabled(false); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); + stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1)); } } - @SuppressWarnings("try") @Test public void refreshWithoutTimeFeatureFlagNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); configStore.getFeatureFlags().setEnabled(true); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); + stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1)); } } @@ -186,19 +189,19 @@ public void refreshWithoutTimeFeatureFlagNotLoaded(TestInfo testInfo) { public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store doesn't return a watch key change. - when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.anyBoolean())).thenReturn(false); + when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - assertFalse( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } } @@ -207,19 +210,19 @@ public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) { public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - when(clientFactoryMock.findOriginForEndpoint(Mockito.eq(endpoint))).thenReturn(endpoint); FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store does return a watch key change. - when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.anyBoolean())).thenReturn(true); + when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - assertTrue( - AppConfigurationRefreshUtil.checkStoreAfterRefreshFailed(clientMock, clientFactoryMock, featureStore)); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); + assertTrue(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } } @@ -242,7 +245,7 @@ public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } @@ -262,7 +265,7 @@ public void refreshStoresCheckSettingsTestNotLoaded(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } @@ -283,7 +286,7 @@ public void refreshStoresCheckSettingsTestNotRefreshTime(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } @@ -304,10 +307,14 @@ public void refreshStoresCheckSettingsTestFailedRequest(TestInfo testInfo) { Duration.ofMinutes(10), (long) 60, replicaLookUpMock); assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + captorParam.capture()); assertEquals(newState, StateHolder.getState(endpoint)); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertFalse((Boolean) testContext.getData("PushRefresh").get()); } } @@ -316,7 +323,77 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo) endpoint = testInfo.getDisplayName() + ".azconfig.io"; setupFeatureFlagLoad(); - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean())) + when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) + .thenReturn(generateWatchKeys().get(0)); + + State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(Mockito.any())).thenReturn(newState); + StateHolder updatedStateHolder = new StateHolder(); + stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertEquals(newState, StateHolder.getState(endpoint)); + assertFalse(eventData.getDoRefresh()); + verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); + verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); + } + } + + @Test + public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) { + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + setupFeatureFlagLoad(); + PushNotification pushNotificaiton = new PushNotification(); + AccessToken p1 = new AccessToken(); + p1.setName("fake name"); + p1.setSecret("value"); + pushNotificaiton.setPrimaryToken(p1); + monitoring.setPushNotification(pushNotificaiton); + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + + when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) + .thenReturn(generateWatchKeys().get(0)); + + State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(Mockito.any())).thenReturn(newState); + StateHolder updatedStateHolder = new StateHolder(); + stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertEquals(newState, StateHolder.getState(endpoint)); + assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); + verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); + verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + captorParam.capture()); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertTrue((Boolean) testContext.getData("PushRefresh").get()); + } + } + + @Test + public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) { + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + setupFeatureFlagLoad(); + PushNotification pushNotificaiton = new PushNotification(); + AccessToken p2 = new AccessToken(); + p2.setName("fake name"); + p2.setSecret("value"); + pushNotificaiton.setPrimaryToken(p2); + monitoring.setPushNotification(pushNotificaiton); + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + + when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) .thenReturn(generateWatchKeys().get(0)); State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); @@ -331,9 +408,13 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo) Duration.ofMinutes(10), (long) 60, replicaLookUpMock); assertEquals(newState, StateHolder.getState(endpoint)); assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + captorParam.capture()); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertTrue((Boolean) testContext.getData("PushRefresh").get()); } } @@ -349,7 +430,7 @@ public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) { ConfigurationSetting refreshKey = new ConfigurationSetting().setKey(KEY_FILTER).setLabel(EMPTY_LABEL) .setETag("new"); - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean())) + when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) .thenReturn(refreshKey); State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); @@ -365,7 +446,7 @@ public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) { assertTrue(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any()); } } @@ -389,7 +470,7 @@ public void refreshStoresCheckFeatureFlagTestNotLoaded(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } @@ -412,7 +493,7 @@ public void refreshStoresCheckFeatureFlagTestNotRefreshTime(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } @@ -424,7 +505,7 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) { configStore.setMonitoring(monitoring); setupFeatureFlagLoad(); - when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.anyBoolean())).thenReturn(false); + when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(false); FeatureFlagState newState = new FeatureFlagState( List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), @@ -441,7 +522,7 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) { assertFalse(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); verify(currentStateMock, times(1)).updateFeatureFlagStateRefresh(Mockito.any(), Mockito.any()); } @@ -451,7 +532,7 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) { public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; setupFeatureFlagLoad(); - when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.anyBoolean())).thenReturn(true); + when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(true); FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); @@ -469,7 +550,7 @@ public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) { assertTrue(eventData.getDoRefresh()); verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.anyBoolean()); + Mockito.any(Context.class)); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java index 3dda6cd72a43..10e10c33787b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java @@ -51,7 +51,7 @@ public class AppConfigurationReplicaClientBuilderTest { @Mock private ConfigurationClientBuilderFactory clientFactoryMock; - + @Mock private Environment envMock; @@ -68,7 +68,6 @@ public void setup() { configStore.validateAndInit(); clientBuilder = null; - when(envMock.getActiveProfiles()).thenReturn(new String[0]); } @AfterEach @@ -79,8 +78,7 @@ public void cleanup() throws Exception { @Test public void buildClientFromEndpointTest() { - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); when(clientFactoryMock.build()).thenReturn(builderMock); @@ -102,8 +100,7 @@ public void buildClientFromConnectionStringTest() { configStore.setConnectionString(TEST_CONN_STRING); configStore.validateAndInit(); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); when(clientFactoryMock.build()).thenReturn(builderMock); @@ -120,9 +117,7 @@ public void buildClientFromConnectionStringTest() { @Test public void modifyClientTest() { - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setClientProvider(modifierMock); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, modifierMock, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); @@ -153,8 +148,7 @@ public void buildClientsFromMultipleEndpointsTest() { configStore.validateAndInit(); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); @@ -180,8 +174,7 @@ public void buildClientsFromMultipleConnectionStringsTest() { configStore.validateAndInit(); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); @@ -205,8 +198,7 @@ public void endpointAndConnectionString() { configStore.setConnectionString(TEST_CONN_STRING); configStore.validateAndInit(); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); String message = assertThrows(IllegalArgumentException.class, () -> clientBuilder.buildClients(configStore).get(0)).getMessage(); @@ -216,8 +208,7 @@ public void endpointAndConnectionString() { @Test public void buildClientTest() { - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); when(clientFactoryMock.build()).thenReturn(builderMock); @@ -236,8 +227,7 @@ public void buildClientTest() { @Test public void buildClientConnectionStringTest() { configStore.setConnectionString(TEST_CONN_STRING); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); when(clientFactoryMock.build()).thenReturn(builderMock); @@ -255,8 +245,7 @@ public void buildClientConnectionStringTest() { @Test public void buildClientConnectionStringsTest() { configStore.setConnectionStrings(List.of(TEST_CONN_STRING, TEST_CONN_STRING_GEO)); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); when(builderMock.addPolicy(Mockito.any())).thenReturn(builderMock); @@ -274,21 +263,19 @@ public void buildClientConnectionStringsTest() { @Test public void buildClientConnectionStringInvalidTest() { configStore.setConnectionString(TEST_CONN_STRING); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> spy.buildClient("fake.test.config.io", configStore)); - assertEquals("java.net.MalformedURLException: no protocol: fake.test.config.io", exception.getMessage()); + assertEquals("URI is not absolute", exception.getMessage()); } @Test public void buildClientConnectionStringInvalid2Test() { configStore.setConnectionString("Not A Connection String"); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, @@ -300,8 +287,7 @@ public void buildClientConnectionStringInvalid2Test() { @Test public void buildClientConnectionStringInvalid3Test() { configStore.setConnectionString("Not;A;Connection String"); - clientBuilder = new AppConfigurationReplicaClientsBuilder(0, clientFactoryMock, false); - clientBuilder.setEnvironment(envMock); + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactoryTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactoryTest.java index 3be2260be0f4..186a6ee8db94 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactoryTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactoryTest.java @@ -3,8 +3,6 @@ package com.azure.spring.cloud.appconfiguration.config.implementation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.List; @@ -79,12 +77,4 @@ public void findOriginTest() { assertEquals(invalidReplica, clientFactory.findOriginForEndpoint(invalidReplica)); } - @Test - public void hasReplicasTest() { - assertTrue(clientFactory.hasReplicas(originEndpoint)); - assertTrue(clientFactory.hasReplicas(replica1)); - assertFalse(clientFactory.hasReplicas(invalidReplica)); - assertFalse(clientFactory.hasReplicas(noReplicaEndpoint)); - } - } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java index 99c71ea169a0..b6b48e7288e6 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java @@ -37,7 +37,7 @@ import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.PagedResponseBase; import com.azure.core.http.rest.Response; -import com.azure.core.util.Configuration; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.ConfigurationClient; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.ConfigurationSnapshot; @@ -45,7 +45,6 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; import com.azure.identity.CredentialUnavailableException; -import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.TracingInfo; import reactor.core.publisher.Mono; @@ -67,7 +66,13 @@ public class AppConfigurationReplicaClientTest { private Supplier>> supplierMock; @Mock - private Response mockResponse; + private Response configurationSettingResponse; + + @Mock + private Response snapshotResponseMock; + + @Mock + private Context contextMock; private final String endpoint = "clientTest.azconfig.io"; @@ -87,42 +92,38 @@ public void cleanup() throws Exception { @Test public void getWatchKeyTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); ConfigurationSetting watchKey = new ConfigurationSetting().setKey("watch").setLabel("\0"); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.isNull(), Mockito.anyBoolean(), - Mockito.any())).thenReturn(mockResponse); - when(mockResponse.getValue()).thenReturn(watchKey); + when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), + Mockito.any())).thenReturn(configurationSettingResponse); + when(configurationSettingResponse.getValue()).thenReturn(watchKey); - //assertEquals(watchKey, client.getWatchKey("watch", "\0", false)); + assertEquals(watchKey, client.getWatchKey("watch", "\0", contextMock)); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.isNull(), Mockito.anyBoolean(), - Mockito.any())).thenReturn(mockResponse); - when(mockResponse.getValue()).thenThrow(exceptionMock); + when(configurationSettingResponse.getValue()).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", false)); + assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", false)); + assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", false)); + assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", contextMock)); when(responseMock.getStatusCode()).thenReturn(499); - assertThrows(HttpResponseException.class, () -> client.getWatchKey("watch", "\0", false)); + assertThrows(HttpResponseException.class, () -> client.getWatchKey("watch", "\0", contextMock)); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.isNull(), Mockito.anyBoolean(), + when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any())).thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", false)); + assertThrows(AppConfigurationStatusException.class, () -> client.getWatchKey("watch", "\0", contextMock)); } @Test public void listSettingsTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); ConfigurationSetting configurationSetting = new ConfigurationSetting().setKey("test-key"); List configurations = List.of(configurationSetting); @@ -135,27 +136,26 @@ public void listSettingsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenReturn(new PagedIterable<>(pagedFlux)); - assertEquals(configurations, client.listSettings(new SettingSelector(), false)); + assertEquals(configurations, client.listSettings(new SettingSelector(), contextMock)); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(499); - assertThrows(HttpResponseException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(HttpResponseException.class, () -> client.listSettings(new SettingSelector(), contextMock)); } @Test public void listFeatureFlagsTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); List configurations = List.of(featureFlag); @@ -170,64 +170,58 @@ public void listFeatureFlagsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenReturn(new PagedIterable<>(pagedFlux)); - assertEquals(configurations, client.listFeatureFlags(new SettingSelector(), false).getFeatureFlags()); + assertEquals(configurations, client.listFeatureFlags(new SettingSelector(), contextMock).getFeatureFlags()); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); assertThrows(AppConfigurationStatusException.class, - () -> client.listFeatureFlags(new SettingSelector(), false)); + () -> client.listFeatureFlags(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(408); assertThrows(AppConfigurationStatusException.class, - () -> client.listFeatureFlags(new SettingSelector(), false)); + () -> client.listFeatureFlags(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(500); assertThrows(AppConfigurationStatusException.class, - () -> client.listFeatureFlags(new SettingSelector(), false)); + () -> client.listFeatureFlags(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(499); - assertThrows(HttpResponseException.class, () -> client.listFeatureFlags(new SettingSelector(), false)); + assertThrows(HttpResponseException.class, () -> client.listFeatureFlags(new SettingSelector(), contextMock)); } @Test public void listSettingsUnknownHostTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); } @Test public void listSettingsNoCredentialTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new CredentialUnavailableException("No Credential")); - assertThrows(CredentialUnavailableException.class, () -> client.listSettings(new SettingSelector(), false)); + assertThrows(CredentialUnavailableException.class, () -> client.listSettings(new SettingSelector(), contextMock)); } @Test public void getWatchNoCredentialTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.isNull(), Mockito.anyBoolean(), - Mockito.any())).thenReturn(mockResponse); - when(mockResponse.getValue()) + when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any())) .thenThrow(new CredentialUnavailableException("No Credential")); - assertThrows(CredentialUnavailableException.class, () -> client.getWatchKey("key", "label", false)); + assertThrows(CredentialUnavailableException.class, () -> client.getWatchKey("key", "label", contextMock)); } @Test public void backoffTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); // Setups in the past and with no errors. assertTrue(client.getBackoffEndTime().isBefore(Instant.now())); @@ -248,62 +242,64 @@ public void backoffTest() { when(clientMock.listConfigurationSettings(Mockito.any(SettingSelector.class), Mockito.any())) .thenReturn(settingsMock); - client.listSettings(new SettingSelector(), false); + client.listSettings(new SettingSelector(), contextMock); assertTrue(client.getBackoffEndTime().isBefore(Instant.now())); assertEquals(0, client.getFailedAttempts()); } @Test public void listSettingSnapshotTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); List configurations = new ArrayList<>(); ConfigurationSnapshot snapshot = new ConfigurationSnapshot(null); snapshot.setSnapshotComposition(SnapshotComposition.KEY); - when(clientMock.getSnapshot(Mockito.any())).thenReturn(snapshot); + when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(snapshotResponseMock); + when(snapshotResponseMock.getValue()).thenReturn(snapshot); when(clientMock.listConfigurationSettingsForSnapshot(Mockito.any())).thenReturn(settingsMock); - assertEquals(configurations, client.listSettingSnapshot("SnapshotName")); + assertEquals(configurations, client.listSettingSnapshot("SnapshotName", contextMock)); when(clientMock.listConfigurationSettingsForSnapshot(Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName")); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName")); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName")); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(499); - assertThrows(HttpResponseException.class, () -> client.listSettingSnapshot("SnapshotName")); + assertThrows(HttpResponseException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); - when(clientMock.getSnapshot(Mockito.any())).thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName")); + when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any())) + .thenThrow(new UncheckedIOException(new UnknownHostException())); + assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); } @Test public void listSettingSnapshotInvalidCompositionTypeTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); ConfigurationSnapshot snapshot = new ConfigurationSnapshot(null); snapshot.setSnapshotComposition(SnapshotComposition.KEY_LABEL); - when(clientMock.getSnapshot(Mockito.any())).thenReturn(snapshot); + when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(snapshotResponseMock); + when(snapshotResponseMock.getValue()).thenReturn(snapshot); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, - () -> client.listSettingSnapshot("SnapshotName")); + () -> client.listSettingSnapshot("SnapshotName", contextMock)); assertEquals("Snapshot SnapshotName needs to be of type Key.", e.getMessage()); } @Test public void updateSyncTokenTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); String fakeToken = "fake_sync_token"; client.updateSyncToken(fakeToken); @@ -316,8 +312,7 @@ public void updateSyncTokenTest() { @Test public void checkWatchKeysTest() { - AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, clientMock, - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); List configurations = List.of(featureFlag); @@ -333,7 +328,7 @@ public void checkWatchKeysTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenReturn(new PagedIterable<>(pagedFlux)); - assertTrue(client.checkWatchKeys(new SettingSelector(), false)); + assertTrue(client.checkWatchKeys(new SettingSelector(), contextMock)); pagedResponse.close(); } catch (IOException e) { e.printStackTrace(); @@ -347,7 +342,7 @@ public void checkWatchKeysTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenReturn(new PagedIterable<>(pagedFlux)); - assertFalse(client.checkWatchKeys(new SettingSelector(), false)); + assertFalse(client.checkWatchKeys(new SettingSelector(), contextMock)); pagedResponse.close(); } catch (IOException e) { e.printStackTrace(); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculatorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculatorTest.java index a91d013e63b1..eb633cba1cd3 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculatorTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/BackoffTimeCalculatorTest.java @@ -16,7 +16,6 @@ public class BackoffTimeCalculatorTest { public void testCalculate() { int testTime = 10; - BackoffTimeCalculator.setDefaults((long) 600, (long) 30); Long testDate = BackoffTimeCalculator.calculateBackoff(1); assertNotNull(testDate); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java index ff6a30fed45b..bdaa51cecaf7 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java @@ -4,7 +4,6 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -118,8 +117,6 @@ public void backoffTest() { expectedEndpoints.add(replicaEndpoint); assertEquals(2, connectionManager.getAvailableClients().size()); - assertEquals(2, connectionManager.getAllEndpoints().size()); - assertTrue(connectionManager.getAllEndpoints().containsAll(expectedEndpoints)); assertEquals(AppConfigurationStoreHealth.UP, connectionManager.getHealth()); connectionManager.backoffClient(originEndpoint); @@ -129,15 +126,11 @@ public void backoffTest() { when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(1000)); assertEquals(1, connectionManager.getAvailableClients().size()); - assertEquals(2, connectionManager.getAllEndpoints().size()); - assertTrue(connectionManager.getAllEndpoints().containsAll(expectedEndpoints)); assertEquals(AppConfigurationStoreHealth.UP, connectionManager.getHealth()); connectionManager.backoffClient(originEndpoint); assertEquals(1, connectionManager.getAvailableClients().size()); - assertEquals(2, connectionManager.getAllEndpoints().size()); - assertTrue(connectionManager.getAllEndpoints().containsAll(expectedEndpoints)); assertEquals(AppConfigurationStoreHealth.UP, connectionManager.getHealth()); connectionManager.backoffClient(replicaEndpoint); @@ -146,8 +139,6 @@ public void backoffTest() { when(replicaClient2.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(1000)); assertEquals(0, connectionManager.getAvailableClients().size()); - assertEquals(2, connectionManager.getAllEndpoints().size()); - assertTrue(connectionManager.getAllEndpoints().containsAll(expectedEndpoints)); assertEquals(AppConfigurationStoreHealth.DOWN, connectionManager.getHealth()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java index f473388bd8c3..d462ccb7d481 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java @@ -2,27 +2,26 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_ROLLOUT_PERCENTAGE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.E_TAG; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_CONTENT_TYPE; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_ID; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_REFERENCE; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.GROUPS; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.USERS; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.DEFAULT_ROLLOUT_PERCENTAGE; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FEATURE_LABEL; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FEATURE_VALUE_ALL; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FEATURE_VALUE_TELEMETRY; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.GROUPS; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_E_TAG; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FEATURE_VALUE_ALL; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.USERS; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.createItemFeatureFlag; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +32,7 @@ import org.mockito.MockitoSession; import org.mockito.quality.Strictness; +import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; @@ -43,6 +43,9 @@ public class FeatureFlagClientTest { @Mock private AppConfigurationReplicaClient clientMock; + + @Mock + private Context contextMock; private FeatureFlagClient featureFlagClient; @@ -76,14 +79,13 @@ public void cleanup() throws Exception { public void loadFeatureFlagsTestNoFeatureFlags() { List settings = List.of(new ConfigurationSetting().setKey("FakeKey")); FeatureFlags featureFlags = new FeatureFlags(null, settings); - when(clientMock.listFeatureFlags(Mockito.any(), Mockito.anyBoolean())).thenReturn(featureFlags); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - false); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); assertEquals("FakeKey", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(0, featureFlagClient.getProperties().size()); + assertEquals(0, featureFlagClient.getFeatureFlags().size()); } @Test @@ -91,15 +93,14 @@ public void loadFeatureFlagsTestFeatureFlags() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), new FeatureFlagConfigurationSetting("Beta", true)); FeatureFlags featureFlags = new FeatureFlags(null, settings); - when(clientMock.listFeatureFlags(Mockito.any(), Mockito.anyBoolean())).thenReturn(featureFlags); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - false); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); - assertEquals(2, featureFlagClient.getProperties().size()); + assertEquals(2, featureFlagClient.getFeatureFlags().size()); } @Test @@ -107,31 +108,30 @@ public void loadFeatureFlagsTestMultipleLoads() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), new FeatureFlagConfigurationSetting("Beta", true)); FeatureFlags featureFlags = new FeatureFlags(null, settings); - when(clientMock.listFeatureFlags(Mockito.any(), Mockito.anyBoolean())).thenReturn(featureFlags); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - false); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); - assertEquals(2, featureFlagClient.getProperties().size()); + assertEquals(2, featureFlagClient.getFeatureFlags().size()); List settings2 = List.of(new FeatureFlagConfigurationSetting("Alpha", true), new FeatureFlagConfigurationSetting("Gamma", false)); featureFlags = new FeatureFlags(null, settings2); - when(clientMock.listFeatureFlags(Mockito.any(), Mockito.anyBoolean())).thenReturn(featureFlags); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, false); + featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); assertEquals(".appconfig.featureflag/Gamma", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); - assertEquals(3, featureFlagClient.getProperties().size()); - Map features = featureFlagClient.getProperties(); - assertTrue(features.get(".appconfig.featureflag/Alpha").isEnabled()); - assertTrue(features.get(".appconfig.featureflag/Beta").isEnabled()); - assertFalse(features.get(".appconfig.featureflag/Gamma").isEnabled()); + assertEquals(3, featureFlagClient.getFeatureFlags().size()); + List features = featureFlagClient.getFeatureFlags(); + assertTrue(features.get(0).isEnabled()); + assertTrue(features.get(1).isEnabled()); + assertFalse(features.get(2).isEnabled()); } @Test @@ -166,14 +166,13 @@ public void loadFeatureFlagsTestTargetingFilter() { targetingFlag.addClientFilter(targetingFilter); List settings = List.of(targetingFlag); FeatureFlags featureFlags = new FeatureFlags(null, settings); - when(clientMock.listFeatureFlags(Mockito.any(), Mockito.anyBoolean())).thenReturn(featureFlags); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - false); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); assertEquals(".appconfig.featureflag/TargetingTest", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(1, featureFlagClient.getProperties().size()); + assertEquals(1, featureFlagClient.getFeatureFlags().size()); } @Test @@ -185,13 +184,49 @@ public void testAndRequirementType() { @Test public void testFeatureFlagTelemetry() { Feature feature = FeatureFlagClient.createFeature(TELEMETRY_FEATURE, TEST_ENDPOINT); - - String featureFlagId = "yON6V7DTGfVgOKfnPtue_2hS-CFVV5ecv-dcjqCFQt4"; String featureFlagReference = String.format("%s/kv/%s", TEST_ENDPOINT, ".appconfig.featureflag/Delta"); - assertEquals(featureFlagId, feature.getTelemetry().getMetadata().get(FEATURE_FLAG_ID)); assertEquals(featureFlagReference, feature.getTelemetry().getMetadata().get(FEATURE_FLAG_REFERENCE)); assertEquals(TEST_E_TAG, feature.getTelemetry().getMetadata().get(E_TAG)); } + @Test + public void testAllocationIdInTelemetry() { + Feature feature = FeatureFlagClient.createFeature(TELEMETRY_FEATURE, TEST_ENDPOINT); + + assertEquals("wz4oTwm3SjARe1SrmzT7", feature.getTelemetry().getMetadata().get("AllocationId")); + + feature = FeatureFlagClient.createFeature(ALL_FEATURE, TEST_ENDPOINT); + assertNull(feature.getTelemetry()); + } + + @Test + public void testAllocationIdWithDifferentSeed() { + FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + + Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); + assertEquals("RkxUK5CoaOaNWBjc55Mi", feature.getTelemetry().getMetadata().get("AllocationId")); + } + + @Test + public void testAllocationIdWithVariants() { + String flagValue = "{\"allocation\": { \"percentile\": [{\"variant\": \"Off\", \"from\": 0, \"to\": 50}, {\"variant\": \"On\", \"from\": 50, \"to\": 100}], \"default_when_enabled\": \"Off2\", \"default_when_disabled\": \"Off\" }, \"telemetry\": {\"enabled\": true}}"; + FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + + Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); + assertEquals("wGzzPy4qGy92SHnMtSvY", feature.getTelemetry().getMetadata().get("AllocationId")); + } + + @Test + public void testAllocationIdWithEmptyAllocation() { + FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + + Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); + assertNull(feature.getTelemetry().getMetadata().get("AllocationId")); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java index 184a33374bb7..683de01f730b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java @@ -180,9 +180,8 @@ private void updateNextRefreshBackoffCalcTest(TestInfo testInfo) { private void loadStateTest(TestInfo testInfo) { String endpoint = testInfo.getDisplayName() + "updateRefreshTimeBackoffCalc" + ".azconfig.io"; StateHolder testStateHolder = new StateHolder(); - testStateHolder.setLoadState(endpoint, true, false); + testStateHolder.setLoadState(endpoint, true); StateHolder.updateState(testStateHolder); - assertEquals(testStateHolder.getLoadState().get(endpoint), StateHolder.getLoadState(endpoint)); assertEquals(testStateHolder, StateHolder.getCurrentState()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestConstants.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestConstants.java index 63903d1ef5ec..66739dec6904 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestConstants.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestConstants.java @@ -6,6 +6,22 @@ * Test constants which can be shared across different test classes */ public final class TestConstants { + + /** + * Http Header Correlation Context + */ + public static final String CORRELATION_CONTEXT = "Correlation-Context"; + + /** + * App Configurations Key Vault Reference Content Type + */ + public static final String KEY_VAULT_CONTENT_TYPE = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8"; + + public static final String USERS = "users"; + + public static final String GROUPS = "groups"; + + public static final String DEFAULT_ROLLOUT_PERCENTAGE = "defaultRolloutPercentage"; // Store specific configuration public static final String TEST_STORE_NAME = "store1"; @@ -78,6 +94,7 @@ public final class TestConstants { public static final String FEATURE_VALUE_TELEMETRY = "{\"id\":\"Delta\",\"description\":\"\",\"enabled\":true," + "\"conditions\":{\"client_filters\":[{\"Name\":\"TestFilter\",\"Parameters\":{\"key\":\"value\"}}]}," + + "\"allocation\": { \"percentile\": [{\"variant\": \"Off\", \"from\": 0, \"to\": 50}, {\"variant\": \"On\", \"from\": 50, \"to\": 100}], \"default_when_enabled\": \"Off\", \"default_when_disabled\": \"Off\" }, " + "\"telemetry\":{\"enabled\":true,\"metadata\":{\"key\":\"value\"}}}"; public static final String FEATURE_VALUE_ALL = "{ \"id\": \"AndTest\", \"description\": \"\",\"enabled\": true,\"conditions\": {\"requirement_type\": \"All\",\"client_filters\": [{\"Name\": \"percentageFilter\",\"Parameters\": {\"Value\": 50}},{\"Name\": \"percentageFilter\",\"Parameters\": {\"Value\": 50}}]}}"; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestUtils.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestUtils.java index 78b9bd6bd6ba..af7314924a2f 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestUtils.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/TestUtils.java @@ -2,6 +2,8 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_CONTENT_TYPE; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -47,42 +49,48 @@ static ConfigurationSetting createItem(String keyFilter, String key, String valu return item; } - static FeatureFlagConfigurationSetting createItemFeatureFlag(String prefix, String key, String value, String label, - String contentType) { - return createItemFeatureFlag(prefix, key, value, label, contentType, null); + static FeatureFlagConfigurationSetting createItemFeatureFlag(String key, String label) { + return createItemFeatureFlag(".appconfig.featureflag/", key, null, label, FEATURE_FLAG_CONTENT_TYPE, null); } static FeatureFlagConfigurationSetting createItemFeatureFlag(String prefix, String key, String value, String label, String contentType, String eTag) { FeatureFlagConfigurationSetting item = new FeatureFlagConfigurationSetting(key, true); - item.setValue(value); + if (value != null) { + item.setValue(value); + } item.setClientFilters(new ArrayList<>()); - item.setKey(prefix + key); + if (prefix != null) { + item.setKey(prefix + key); + } item.setLabel(label); item.setContentType(contentType); item.setETag(eTag); try { - JsonNode node = MAPPER.readTree(value).get("conditions").get("client_filters"); - - for (int i = 0; i < node.size(); i++) { - JsonNode nodeFilter = node.get(i); - FeatureFlagFilter filter = new FeatureFlagFilter(nodeFilter.get("Name").asText()); - - JsonNode nodeParams = nodeFilter.get("Parameters"); - if (nodeParams != null) { - for (int j = 0; j < nodeParams.size(); j++) { - // JsonNode param = nodeParams. - Map result = MAPPER.convertValue(nodeParams, - new TypeReference>() { - }); - Set parameters = result.keySet(); - for (String paramKey : parameters) { - filter.addParameter(paramKey, result.get(paramKey)); + if (value != null) { + JsonNode node = MAPPER.readTree(value).get("conditions"); + if (node != null) { + JsonNode clientFiltersNode = node.get("client_filters"); + if (clientFiltersNode != null) { + for (int i = 0; i < clientFiltersNode.size(); i++) { + JsonNode nodeFilter = clientFiltersNode.get(i); + FeatureFlagFilter filter = new FeatureFlagFilter(nodeFilter.get("Name").asText()); + + JsonNode nodeParams = nodeFilter.get("Parameters"); + if (nodeParams != null) { + Map result = MAPPER.convertValue(nodeParams, + new TypeReference>() { + }); + Set parameters = result.keySet(); + for (String paramKey : parameters) { + filter.addParameter(paramKey, result.get(paramKey)); + } + } + item.addClientFilter(filter); } } } - item.addClientFilter(filter); } } catch (JsonProcessingException e) { LOGGER.log(LogLevel.VERBOSE, () -> "Failed to create FeatureFlagConfigurationSetting.", e); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfigurationTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfigurationTest.java deleted file mode 100644 index 93202173f069..000000000000 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/config/AppConfigurationBootstrapConfigurationTest.java +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.config; - -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.CONN_STRING_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FAIL_FAST_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.STORE_ENDPOINT_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_CONN_STRING; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_STORE_NAME; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.propPair; -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationPropertySourceLocator; -import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory; -import com.azure.spring.cloud.autoconfigure.implementation.context.AzureGlobalPropertiesAutoConfiguration; - -public class AppConfigurationBootstrapConfigurationTest { - - private static final ApplicationContextRunner CONTEXT_RUNNER = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AppConfigurationBootstrapConfiguration.class, - AzureGlobalPropertiesAutoConfiguration.class)) - .withPropertyValues(propPair("spring.cloud.azure.appconfiguration.enabled", "true")); - - @Test - public void iniConnectionStringSystemAssigned() { - CONTEXT_RUNNER - .withPropertyValues(propPair(STORE_ENDPOINT_PROP, TEST_STORE_NAME), propPair(FAIL_FAST_PROP, "false")) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationPropertySourceLocator.class)); - } - - @Test - public void iniConnectionStringUserAssigned() { - CONTEXT_RUNNER - .withPropertyValues(propPair(STORE_ENDPOINT_PROP, TEST_STORE_NAME), propPair(FAIL_FAST_PROP, "false"), - propPair("spring.cloud.azure.appconfiguration.managed-identity.client-id", "client-id")) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationPropertySourceLocator.class)); - } - - @Test - public void propertySourceLocatorBeanCreated() { - CONTEXT_RUNNER - .withPropertyValues(propPair(CONN_STRING_PROP, TEST_CONN_STRING), propPair(FAIL_FAST_PROP, "false")) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationPropertySourceLocator.class)); - } - - @Test - public void clientsBeanCreated() { - CONTEXT_RUNNER - .withPropertyValues(propPair(CONN_STRING_PROP, TEST_CONN_STRING)) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationReplicaClientFactory.class)); - } -} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicyTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicyTest.java index c08c29975ffa..e449fd6357fa 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicyTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/BaseAppConfigurationPolicyTest.java @@ -2,14 +2,13 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.http.policy; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.CORRELATION_CONTEXT; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEV_ENV_TRACING; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.KEY_VAULT_CONFIGURED_TRACING; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.USER_AGENT_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.util.Optional; @@ -24,11 +23,13 @@ import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; +import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipelineCallContext; import com.azure.core.http.HttpPipelineNextPolicy; import com.azure.core.http.HttpRequest; import com.azure.core.util.Configuration; +import com.azure.spring.cloud.appconfiguration.config.implementation.RequestTracingConstants; // This test class needs to be isolated and ran sequential as it uses BaseAppConfigurationPolicy.setWatchRequests // which mutates a global static and can result in race condition failures. @@ -55,27 +56,28 @@ public void cleanup() throws Exception { MockitoAnnotations.openMocks(this).close(); } - @SuppressWarnings("deprecation") @Test - public void startupThenWatchUpdateTest() throws MalformedURLException { - URL url = new URL("https://www.test.url/kv"); + public void startupThenWatchUpdateTest() throws MalformedURLException, URISyntaxException { + URL url = new URI("https://www.test.url/kv").toURL(); HttpRequest request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); + request.setHeader(HttpHeaderName.USER_AGENT, "PreExistingUserAgent"); BaseAppConfigurationPolicy policy = new BaseAppConfigurationPolicy( - new TracingInfo(false, false, 0, Configuration.getGlobalConfiguration())); + new TracingInfo(false, 0, Configuration.getGlobalConfiguration())); when(contextMock.getHttpRequest()).thenReturn(request); policy.process(contextMock, nextMock); - String userAgent = contextMock.getHttpRequest().getHeaders().get(USER_AGENT_TYPE).getValue(); + String userAgent = contextMock.getHttpRequest().getHeaders().get(HttpHeaderName.USER_AGENT).getValue(); assertEquals("null/null " + PRE_USER_AGENT, userAgent); assertEquals("RequestType=Startup", - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); + contextMock.getHttpRequest().getHeaders() + .get(HttpHeaderName.fromString(RequestTracingConstants.CORRELATION_CONTEXT_HEADER.toString())) + .getValue()); request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); + request.setHeader(HttpHeaderName.USER_AGENT, "PreExistingUserAgent"); when(contextMock.getHttpRequest()).thenReturn(request); when(contextMock.getData("refresh")).thenReturn(Optional.of(true)); @@ -85,10 +87,12 @@ public void startupThenWatchUpdateTest() throws MalformedURLException { assertEquals("null/null " + PRE_USER_AGENT, userAgent); assertEquals("RequestType=Watch", - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); + contextMock.getHttpRequest().getHeaders() + .get(HttpHeaderName.fromString(RequestTracingConstants.CORRELATION_CONTEXT_HEADER.toString())) + .getValue()); request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); + request.setHeader(HttpHeaderName.USER_AGENT, "PreExistingUserAgent"); when(contextMock.getHttpRequest()).thenReturn(request); @@ -96,55 +100,26 @@ public void startupThenWatchUpdateTest() throws MalformedURLException { assertEquals("null/null " + PRE_USER_AGENT, userAgent); assertEquals("RequestType=Watch", - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); + contextMock.getHttpRequest().getHeaders() + .get(HttpHeaderName.fromString(RequestTracingConstants.CORRELATION_CONTEXT_HEADER.toString())) + .getValue()); } - @SuppressWarnings("deprecation") @Test - public void devIsConfigured() throws MalformedURLException { + public void keyVaultIsConfigured() throws MalformedURLException, URISyntaxException { BaseAppConfigurationPolicy policy = new BaseAppConfigurationPolicy( - new TracingInfo(true, false, 0, Configuration.getGlobalConfiguration())); + new TracingInfo(true, 0, Configuration.getGlobalConfiguration())); - URL url = new URL("https://www.test.url/kv"); + URL url = new URI("https://www.test.url/kv").toURL(); HttpRequest request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); - when(contextMock.getHttpRequest()).thenReturn(request); - - policy.process(contextMock, nextMock); - assertEquals("RequestType=Startup,Env=" + DEV_ENV_TRACING, - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); - } - - @SuppressWarnings("deprecation") - @Test - public void keyVaultIsConfigured() throws MalformedURLException { - BaseAppConfigurationPolicy policy = new BaseAppConfigurationPolicy( - new TracingInfo(false, true, 0, Configuration.getGlobalConfiguration())); - - URL url = new URL("https://www.test.url/kv"); - HttpRequest request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); + request.setHeader(HttpHeaderName.USER_AGENT, "PreExistingUserAgent"); when(contextMock.getHttpRequest()).thenReturn(request); policy.process(contextMock, nextMock); assertEquals("RequestType=Startup," + KEY_VAULT_CONFIGURED_TRACING, - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); - } - - @SuppressWarnings("deprecation") - @Test - public void devAndKeyVaultAreConfigured() throws MalformedURLException { - BaseAppConfigurationPolicy policy = new BaseAppConfigurationPolicy( - new TracingInfo(true, true, 0, Configuration.getGlobalConfiguration())); - - URL url = new URL("https://www.test.url/kv"); - HttpRequest request = new HttpRequest(HttpMethod.GET, url); - request.setHeader(USER_AGENT_TYPE, "PreExistingUserAgent"); - when(contextMock.getHttpRequest()).thenReturn(request); - - policy.process(contextMock, nextMock); - assertEquals("RequestType=Startup,Env=" + DEV_ENV_TRACING + "," + KEY_VAULT_CONFIGURED_TRACING, - contextMock.getHttpRequest().getHeaders().get(CORRELATION_CONTEXT).getValue()); + contextMock.getHttpRequest().getHeaders() + .get(HttpHeaderName.fromString(RequestTracingConstants.CORRELATION_CONTEXT_HEADER.toString())) + .getValue()); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfoTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfoTest.java index 67c3aba02153..4ad16ca71d67 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfoTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/http/policy/TracingInfoTest.java @@ -23,41 +23,43 @@ public class TracingInfoTest { public void getValueTest() { Configuration configuration = getConfiguration("false"); - TracingInfo tracingInfo = new TracingInfo(false, false, 0, configuration); - assertEquals("RequestType=Startup", tracingInfo.getValue(false)); - assertEquals("RequestType=Watch", tracingInfo.getValue(true)); + TracingInfo tracingInfo = new TracingInfo(false, 0, configuration); + assertEquals("RequestType=Startup", tracingInfo.getValue(false, false, null)); + assertEquals("RequestType=Watch", tracingInfo.getValue(true, false, null)); - tracingInfo = new TracingInfo(true, false, 0, configuration); - assertEquals("RequestType=Startup,Env=Dev", tracingInfo.getValue(false)); + tracingInfo = new TracingInfo(true, 0, configuration); + assertEquals("RequestType=Startup,UsesKeyVault", tracingInfo.getValue(false, false, null)); - tracingInfo = new TracingInfo(false, true, 0, configuration); - assertEquals("RequestType=Startup,UsesKeyVault", tracingInfo.getValue(false)); + tracingInfo = new TracingInfo(false, 1, configuration); + assertEquals("RequestType=Startup,ReplicaCount=1", tracingInfo.getValue(false, false, null)); - tracingInfo = new TracingInfo(false, false, 1, configuration); - assertEquals("RequestType=Startup,ReplicaCount=1", tracingInfo.getValue(false)); + tracingInfo = new TracingInfo(true, 0, configuration); - tracingInfo = new TracingInfo(false, false, 0, configuration); + FeatureFlagTracing ffTracing = new FeatureFlagTracing(); + + ffTracing.updateFeatureFilterTelemetry("Random"); + assertEquals("RequestType=Startup,Filter=CSTM,UsesKeyVault", tracingInfo.getValue(false, false, ffTracing)); + + assertEquals("RequestType=Startup,Filter=CSTM,UsesKeyVault,PushRefresh", tracingInfo.getValue(false, true, null)); - tracingInfo.getFeatureFlagTracing().updateFeatureFilterTelemetry("Random"); - assertEquals("RequestType=Startup,Filter=CSTM", tracingInfo.getValue(false)); } @Test public void disableTracingTest() { - TracingInfo tracingInfo = new TracingInfo(false, false, 0, getConfiguration(null)); - assertNotEquals("", tracingInfo.getValue(false)); - - tracingInfo = new TracingInfo(false, false, 0, getConfiguration("")); - assertNotEquals("", tracingInfo.getValue(false)); - - tracingInfo = new TracingInfo(false, false, 0, getConfiguration("true")); - assertEquals("", tracingInfo.getValue(false)); - - tracingInfo = new TracingInfo(false, false, 0, getConfiguration("false")); - assertNotEquals("", tracingInfo.getValue(false)); - - tracingInfo = new TracingInfo(false, false, 0, getConfiguration("random string")); - assertNotEquals("", tracingInfo.getValue(false)); + TracingInfo tracingInfo = new TracingInfo(false, 0, getConfiguration(null)); + assertNotEquals("", tracingInfo.getValue(false, false, null)); + + tracingInfo = new TracingInfo(false, 0, getConfiguration("")); + assertNotEquals("", tracingInfo.getValue(false, false, null)); + + tracingInfo = new TracingInfo(false, 0, getConfiguration("true")); + assertEquals("", tracingInfo.getValue(false, false, null)); + + tracingInfo = new TracingInfo(false, 0, getConfiguration("false")); + assertNotEquals("", tracingInfo.getValue(false, false, null)); + + tracingInfo = new TracingInfo(false, 0, getConfiguration("random string")); + assertNotEquals("", tracingInfo.getValue(false, false, null)); } private static final ConfigurationSource EMPTY_SOURCE = new ConfigurationSource() { @@ -68,9 +70,10 @@ public Map getProperties(String source) { }; private Configuration getConfiguration(String value) { - return new ConfigurationBuilder(EMPTY_SOURCE, EMPTY_SOURCE, new TestConfigurationSource().put(RequestTracingConstants.REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE.toString(), value)).build(); + return new ConfigurationBuilder(EMPTY_SOURCE, EMPTY_SOURCE, new TestConfigurationSource() + .put(RequestTracingConstants.REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE.toString(), value)).build(); } - + private final class TestConfigurationSource implements ConfigurationSource { private final Map testData; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java index e8d839e22faa..6641e1ee7108 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java @@ -2,35 +2,18 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.properties; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientsBuilder.ENDPOINT_ERR_MSG; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.CONN_STRING_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.CONN_STRING_PROP_NEW; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.FAIL_FAST_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.KEY_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.LABEL_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.REFRESH_INTERVAL_PROP; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.STORE_ENDPOINT_PROP; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_CONN_STRING; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT_GEO; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.propPair; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.time.Duration; import java.util.ArrayList; import java.util.List; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.MockitoAnnotations; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import com.azure.spring.cloud.appconfiguration.config.implementation.config.AppConfigurationBootstrapConfiguration; -import com.azure.spring.cloud.autoconfigure.implementation.context.AzureGlobalPropertiesAutoConfiguration; public class AppConfigurationPropertiesTest { @@ -43,29 +26,21 @@ public class AppConfigurationPropertiesTest { private static final String VALID_KEY = "/application/"; private static final String ILLEGAL_LABELS = "*,my-label"; + + private AppConfigurationProperties properties; - @InjectMocks - private ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AppConfigurationBootstrapConfiguration.class, - AzureGlobalPropertiesAutoConfiguration.class)) - .withPropertyValues("spring.cloud.azure.appconfiguration.endpoint=https://test-appconfig.azconfig.io"); @BeforeEach public void setup() { - MockitoAnnotations.openMocks(this); - } - - @AfterEach - public void cleanup() throws Exception { - MockitoAnnotations.openMocks(this).close(); + properties = new AppConfigurationProperties(); + properties.setStores(List.of(new ConfigStore())); } @Test public void validInputShouldCreatePropertiesBean() { - this.contextRunner - .withPropertyValues(propPair(CONN_STRING_PROP, TEST_CONN_STRING)) - .withPropertyValues(propPair(FAIL_FAST_PROP, "false")) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationProperties.class)); + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(TEST_CONN_STRING); + store.validateAndInit(); } @Test @@ -84,55 +59,54 @@ public void secretMustExistInConnectionString() { } private void testConnStringFields(String connString) { - this.contextRunner - .withPropertyValues(propPair(CONN_STRING_PROP, connString)) - .run(context -> assertThat(context).getFailure().hasStackTraceContaining(ENDPOINT_ERR_MSG)); + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(connString); + IllegalStateException e = assertThrows(IllegalStateException.class, () -> properties.validateAndInit()); + assertEquals("Connection string does not follow format Endpoint=([^;]+);Id=([^;]+);Secret=([^;]+).", e.getMessage()); } @Test public void asteriskShouldNotBeIncludedInTheLabels() { - this.contextRunner - .withPropertyValues( - propPair(CONN_STRING_PROP, TEST_CONN_STRING), - propPair(KEY_PROP, VALID_KEY), - propPair(LABEL_PROP, ILLEGAL_LABELS)) - .run(context -> assertThat(context) - .getFailure() - .hasStackTraceContaining("LabelFilter must not contain asterisk(*)")); + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(TEST_CONN_STRING); + AppConfigurationKeyValueSelector select = new AppConfigurationKeyValueSelector(); + select.setKeyFilter(VALID_KEY); + select.setLabelFilter(ILLEGAL_LABELS); + store.setSelects(List.of(select)); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> properties.validateAndInit()); + assertEquals("LabelFilter must not contain asterisk(*)", e.getMessage()); } @Test public void storeNameCanBeInitIfConnectionStringConfigured() { - this.contextRunner - .withPropertyValues( - propPair(CONN_STRING_PROP, TEST_CONN_STRING), - propPair(STORE_ENDPOINT_PROP, "")) - .withPropertyValues(propPair(FAIL_FAST_PROP, "false")) - .run(context -> { - AppConfigurationProperties properties = context.getBean(AppConfigurationProperties.class); - assertThat(properties.getStores()).isNotNull(); - assertThat(properties.getStores().size()).isEqualTo(1); - assertThat(properties.getStores().get(0).getEndpoint()).isEqualTo("https://fake.test.config.io"); - }); + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(TEST_CONN_STRING); + store.setEndpoint(""); + store.validateAndInit(); + assertEquals(1, properties.getStores().size()); + assertEquals("https://fake.test.config.io", properties.getStores().get(0).getEndpoint()); } @Test public void duplicateConnectionStringIsNotAllowed() { - this.contextRunner - .withPropertyValues( - propPair(CONN_STRING_PROP, TEST_CONN_STRING), - propPair(CONN_STRING_PROP_NEW, TEST_CONN_STRING)) - .run(context -> assertThat(context) - .getFailure() - .hasStackTraceContaining("Duplicate store name exists")); + properties = new AppConfigurationProperties(); + properties.setStores(List.of(new ConfigStore(), new ConfigStore())); + + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(TEST_CONN_STRING); + ConfigStore newStore = properties.getStores().get(1); + newStore.setConnectionString(TEST_CONN_STRING); + + java.lang.IllegalArgumentException e = assertThrows(java.lang.IllegalArgumentException.class, () -> properties.validateAndInit()); + assertEquals("Duplicate store name exists.", e.getMessage()); } @Test public void minValidWatchTime() { - this.contextRunner - .withPropertyValues(propPair(CONN_STRING_PROP, TEST_CONN_STRING)) - .withPropertyValues(propPair(REFRESH_INTERVAL_PROP, "1s")) - .run(context -> assertThat(context).hasSingleBean(AppConfigurationProperties.class)); + ConfigStore store = properties.getStores().get(0); + store.setConnectionString(TEST_CONN_STRING); + properties.setRefreshInterval(Duration.ofSeconds(1)); + properties.validateAndInit(); } @Test diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/KeyVaultClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/KeyVaultClientTest.java index 98f2c3ec9bc8..d3ff2c487311 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/KeyVaultClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/stores/KeyVaultClientTest.java @@ -59,7 +59,7 @@ public void configProviderAuth() throws URISyntaxException { String keyVaultUri = "https://keyvault.vault.azure.net"; clientStore = new AppConfigurationSecretClientManager(keyVaultUri, null, null, secretClientBuilderFactoryMock, - false, 60); + false); AppConfigurationSecretClientManager test = Mockito.spy(clientStore); when(secretClientBuilderFactoryMock.build()).thenReturn(builderMock); @@ -82,7 +82,7 @@ public void systemAssignedCredentials() throws URISyntaxException { String keyVaultUri = "https://keyvault.vault.azure.net/secrets/mySecret"; clientStore = new AppConfigurationSecretClientManager(keyVaultUri, null, null, secretClientBuilderFactoryMock, - false, 60); + false); AppConfigurationSecretClientManager test = Mockito.spy(clientStore); when(secretClientBuilderFactoryMock.build()).thenReturn(builderMock); @@ -105,7 +105,7 @@ public void secretResolverTest() throws URISyntaxException { String keyVaultUri = "https://keyvault.vault.azure.net/secrets/mySecret"; clientStore = new AppConfigurationSecretClientManager(keyVaultUri, null, new TestSecretResolver(), - secretClientBuilderFactoryMock, false, 60); + secretClientBuilderFactoryMock, false); AppConfigurationSecretClientManager test = Mockito.spy(clientStore); when(secretClientBuilderFactoryMock.build()).thenReturn(builderMock); diff --git a/sdk/spring/spring-cloud-azure-feature-management-web/CHANGELOG.md b/sdk/spring/spring-cloud-azure-feature-management-web/CHANGELOG.md index 4c923e2e288a..866ee2d2e502 100644 --- a/sdk/spring/spring-cloud-azure-feature-management-web/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-feature-management-web/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 5.24.0-beta.1 (Unreleased) +## 6.0.0-beta.2 (Unreleased) ### Features Added diff --git a/sdk/spring/spring-cloud-azure-feature-management-web/pom.xml b/sdk/spring/spring-cloud-azure-feature-management-web/pom.xml index d02fa03083e7..7e9cc7d1dae1 100644 --- a/sdk/spring/spring-cloud-azure-feature-management-web/pom.xml +++ b/sdk/spring/spring-cloud-azure-feature-management-web/pom.xml @@ -8,7 +8,7 @@ 4.0.0 com.azure.spring spring-cloud-azure-feature-management-web - 5.24.0-beta.1 + 6.0.0-beta.2 Spring Cloud Azure Feature Management Web Adds Feature Management into Spring Web @@ -39,7 +39,7 @@ com.azure.spring spring-cloud-azure-feature-management - 5.24.0-beta.1 + 6.0.0-beta.2 jakarta.servlet @@ -127,7 +127,7 @@ - com.azure.spring:spring-cloud-azure-feature-management:[5.24.0-beta.1] + com.azure.spring:spring-cloud-azure-feature-management:[6.0.0-beta.2] jakarta.servlet:jakarta.servlet-api:[6.0.0] org.springframework:spring-web:[6.2.10] org.springframework:spring-webmvc:[6.2.10] diff --git a/sdk/spring/spring-cloud-azure-feature-management-web/src/main/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshot.java b/sdk/spring/spring-cloud-azure-feature-management-web/src/main/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshot.java index f908b76c4041..6d3da2a2e20f 100644 --- a/sdk/spring/spring-cloud-azure-feature-management-web/src/main/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshot.java +++ b/sdk/spring/spring-cloud-azure-feature-management-web/src/main/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshot.java @@ -2,9 +2,12 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.web; +import java.time.Duration; import java.util.HashMap; +import java.util.Map; import com.azure.spring.cloud.feature.management.FeatureManager; +import com.azure.spring.cloud.feature.management.models.Variant; import reactor.core.publisher.Mono; @@ -16,7 +19,11 @@ public class FeatureManagerSnapshot { private final FeatureManager featureManager; - private final HashMap requestMap; + private final Map requestMap; + + private final Map variantMap; + + private static final Duration DEFAULT_BLOCK_TIMEOUT = Duration.ofSeconds(100); /** * Used to evaluate whether a feature is enabled or disabled. When setup with the @RequestScope it will @@ -26,6 +33,7 @@ public class FeatureManagerSnapshot { FeatureManagerSnapshot(FeatureManager featureManager) { this.featureManager = featureManager; this.requestMap = new HashMap<>(); + this.variantMap = new HashMap<>(); } /** @@ -40,12 +48,29 @@ public class FeatureManagerSnapshot { * @return state of the feature */ public Mono isEnabledAsync(String feature) { + return isEnabledAsync(feature, null); + } + + /** + * Checks to see if the feature is enabled. If enabled it checks each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. + *

+ * If isEnabled has already been called on this feature in this request, it will return the same value as it did + * before. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + */ + public Mono isEnabledAsync(String feature, Object featureContext) { Boolean featureValue = requestMap.get(feature); if (featureValue != null) { return Mono.just(featureValue); } - return featureManager.isEnabledAsync(feature).doOnSuccess((enabled) -> requestMap.put(feature, enabled)); + return featureManager.isEnabledAsync(feature, featureContext) + .doOnSuccess((enabled) -> requestMap.put(feature, enabled)); } /** @@ -60,12 +85,82 @@ public Mono isEnabledAsync(String feature) { * @return state of the feature */ public Boolean isEnabled(String feature) { - Boolean featureValue = requestMap.get(feature); - if (featureValue != null) { - return featureValue; + return isEnabled(feature, null); + } + + /** + * Checks to see if the feature is enabled. If enabled it checks each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. + *

+ * If isEnabled has already been called on this feature in this request, it will return the same value as it did + * before. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + */ + public Boolean isEnabled(String feature, Object featureContext) { + return isEnabledAsync(feature, featureContext).block(DEFAULT_BLOCK_TIMEOUT); + } + + /** + * Returns the variant assigned to the current context. + *

+ * If getVariantAsync has already been called on this feature in this request, it will return the same value as it + * did before. + * + * @param feature Feature being checked. + * @return state of the feature + */ + public Mono getVariantAsync(String feature) { + return getVariantAsync(feature, null); + } + + /** + * Returns the variant assigned to the current context. + *

+ * If getVariantAsync has already been called on this feature in this request, it will return the same value as it + * did before. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + */ + public Mono getVariantAsync(String feature, Object featureContext) { + Variant featureVariant = variantMap.get(feature); + if (featureVariant != null) { + return Mono.just(featureVariant); } - return featureManager.isEnabledAsync(feature).doOnSuccess((enabled) -> requestMap.put(feature, enabled)) - .block(); + return featureManager.getVariantAsync(feature, featureContext) + .doOnSuccess((variant) -> variantMap.put(feature, variant)); + } + + /** + * Returns the variant assigned to the current context. + *

+ * If getVariant has already been called on this feature in this request, it will return the same value as it did + * before. + * + * @param feature Feature being checked. + * @return state of the feature + */ + public Variant getVariant(String feature) { + return getVariant(feature, null); + } + + /** + * Returns the variant assigned to the current context. + *

+ * If getVariant has already been called on this feature in this request, it will return the same value as it did + * before. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + */ + public Variant getVariant(String feature, Object featureContext) { + return getVariantAsync(feature, featureContext).block(DEFAULT_BLOCK_TIMEOUT); } } diff --git a/sdk/spring/spring-cloud-azure-feature-management-web/src/test/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshotTest.java b/sdk/spring/spring-cloud-azure-feature-management-web/src/test/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshotTest.java index 8e9dbd7a0af5..f8c207eb8784 100644 --- a/sdk/spring/spring-cloud-azure-feature-management-web/src/test/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshotTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management-web/src/test/java/com/azure/spring/cloud/feature/management/web/FeatureManagerSnapshotTest.java @@ -2,7 +2,9 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.web; - +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -19,6 +21,7 @@ import org.mockito.MockitoAnnotations; import com.azure.spring.cloud.feature.management.FeatureManager; +import com.azure.spring.cloud.feature.management.models.Variant; import jakarta.servlet.http.HttpServletRequest; import reactor.core.publisher.Mono; @@ -41,22 +44,132 @@ public void setup(TestInfo testInfo) { @Test public void setAttribute() throws InterruptedException, ExecutionException { - when(featureManager.isEnabledAsync(Mockito.matches("setAttribute"))).thenReturn(Mono.just(true)); + when(featureManager.isEnabledAsync(Mockito.matches("setAttribute"), Mockito.isNull())) + .thenReturn(Mono.just(true)); assertTrue(featureManagerSnapshot.isEnabledAsync("setAttribute").block()); - verify(featureManager, times(1)).isEnabledAsync("setAttribute"); + verify(featureManager, times(1)).isEnabledAsync("setAttribute", null); } @Test public void setSavedValue() throws InterruptedException, ExecutionException { - when(featureManager.isEnabledAsync(Mockito.matches("setAttribute"))).thenReturn(Mono.just(true)); + when(featureManager.isEnabledAsync(Mockito.matches("setAttribute"), Mockito.isNull())) + .thenReturn(Mono.just(true)); assertTrue(featureManagerSnapshot.isEnabledAsync("setAttribute").block()); - verify(featureManager, times(1)).isEnabledAsync("setAttribute"); + verify(featureManager, times(1)).isEnabledAsync("setAttribute", null); // The second time should return the same value, but not increase the non-snapshot count. assertTrue(featureManagerSnapshot.isEnabledAsync("setAttribute").block()); - verify(featureManager, times(1)).isEnabledAsync("setAttribute"); + verify(featureManager, times(1)).isEnabledAsync("setAttribute", null); + } + + @Test + public void featureDisabled() throws InterruptedException, ExecutionException { + when(featureManager.isEnabledAsync(Mockito.matches("featureDisabled"), Mockito.isNull())) + .thenReturn(Mono.just(false)); + + assertFalse(featureManagerSnapshot.isEnabledAsync("featureDisabled").block()); + verify(featureManager, times(1)).isEnabledAsync("featureDisabled", null); + } + + @Test + public void featureEnabledWithException() throws InterruptedException, ExecutionException { + when(featureManager.isEnabledAsync(Mockito.matches("featureEnabledWithException"), Mockito.isNull())) + .thenReturn(Mono.error(new RuntimeException("Test Exception"))); + + assertThrows(RuntimeException.class, + () -> featureManagerSnapshot.isEnabledAsync("featureEnabledWithException").block()); + verify(featureManager, times(1)).isEnabledAsync("featureEnabledWithException", null); + } + + @Test + public void featureEnabledMultipleTimes() throws InterruptedException, ExecutionException { + when(featureManager.isEnabledAsync(Mockito.matches("featureEnabledMultipleTimes"), Mockito.isNull())) + .thenReturn(Mono.just(true)); + + assertTrue(featureManagerSnapshot.isEnabledAsync("featureEnabledMultipleTimes").block()); + verify(featureManager, times(1)).isEnabledAsync("featureEnabledMultipleTimes", null); + + assertTrue(featureManagerSnapshot.isEnabledAsync("featureEnabledMultipleTimes").block()); + verify(featureManager, times(1)).isEnabledAsync("featureEnabledMultipleTimes", null); + + assertTrue(featureManagerSnapshot.isEnabledAsync("featureEnabledMultipleTimes", null).block()); + } + + @Test + public void featureDisabledMultipleTimes() throws InterruptedException, ExecutionException { + when(featureManager.isEnabledAsync(Mockito.matches("featureDisabledMultipleTimes"), Mockito.isNull())) + .thenReturn(Mono.just(false)); + when(featureManager.isEnabledAsync(Mockito.matches("featureDisabledMultipleTimes"), + Mockito.matches("my Context"))) + .thenReturn(Mono.just(false)); + + assertFalse(featureManagerSnapshot.isEnabled("featureDisabledMultipleTimes")); + verify(featureManager, times(1)).isEnabledAsync("featureDisabledMultipleTimes", null); + + assertFalse(featureManagerSnapshot.isEnabled("featureDisabledMultipleTimes")); + verify(featureManager, times(1)).isEnabledAsync("featureDisabledMultipleTimes", null); + + assertFalse(featureManagerSnapshot.isEnabled("featureDisabledMultipleTimes", "my Context")); + verify(featureManager, times(1)).isEnabledAsync("featureDisabledMultipleTimes", null); + } + + @Test + public void getVariantAsync() throws InterruptedException, ExecutionException { + Variant variant = new Variant("variant1", true); + when(featureManager.getVariantAsync(Mockito.matches("featureWithVariant"), Mockito.matches("my Context"))) + .thenReturn(Mono.just(variant)); + + assertEquals(variant, featureManagerSnapshot.getVariantAsync("featureWithVariant", "my Context").block()); + verify(featureManager, times(1)).getVariantAsync("featureWithVariant", "my Context"); + } + + @Test + public void getVariantAsyncWithException() throws InterruptedException, ExecutionException { + when(featureManager.getVariantAsync(Mockito.matches("featureWithVariantException"), Mockito.isNull())) + .thenReturn(Mono.error(new RuntimeException("Test Exception"))); + + assertThrows(RuntimeException.class, + () -> featureManagerSnapshot.getVariantAsync("featureWithVariantException").block()); + verify(featureManager, times(1)).getVariantAsync("featureWithVariantException", null); + } + + @Test + public void getVariant() { + Variant variant = new Variant("variant1", true); + when(featureManager.getVariantAsync(Mockito.matches("featureWithVariant"), Mockito.isNull())) + .thenReturn(Mono.just(variant)); + + assertEquals(variant, featureManagerSnapshot.getVariant("featureWithVariant")); + verify(featureManager, times(1)).getVariantAsync("featureWithVariant", null); + } + + @Test + public void getVariantWithException() { + when(featureManager.getVariant(Mockito.matches("featureWithVariantException"), Mockito.isNull())) + .thenThrow(new RuntimeException("Test Exception")); + + assertThrows(RuntimeException.class, () -> featureManagerSnapshot.getVariant("featureWithVariantException")); + verify(featureManager, times(1)).getVariantAsync("featureWithVariantException", null); + } + + @Test + public void getVariantMultipleTimes() { + Variant variant = new Variant("variant1", true); + when(featureManager.getVariantAsync(Mockito.matches("featureWithVariantMultipleTimes"), Mockito.isNull())) + .thenReturn(Mono.just(variant)); + when(featureManager.getVariantAsync(Mockito.matches("featureWithVariantMultipleTimes"), + Mockito.matches("my Context"))).thenReturn(Mono.just(variant)); + + assertEquals(variant, featureManagerSnapshot.getVariant("featureWithVariantMultipleTimes")); + verify(featureManager, times(1)).getVariantAsync("featureWithVariantMultipleTimes", null); + + assertEquals(variant, featureManagerSnapshot.getVariant("featureWithVariantMultipleTimes")); + verify(featureManager, times(1)).getVariantAsync("featureWithVariantMultipleTimes", null); + + assertEquals(variant, featureManagerSnapshot.getVariant("featureWithVariantMultipleTimes", "my Context")); + verify(featureManager, times(1)).getVariantAsync("featureWithVariantMultipleTimes", null); } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md b/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md index db40f53543be..590c45f2546a 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 5.24.0-beta.1 (Unreleased) +## 6.0.0-beta.2 (Unreleased) ### Features Added diff --git a/sdk/spring/spring-cloud-azure-feature-management/pom.xml b/sdk/spring/spring-cloud-azure-feature-management/pom.xml index 2f8deb5aecc0..cd821c129f53 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/pom.xml +++ b/sdk/spring/spring-cloud-azure-feature-management/pom.xml @@ -8,7 +8,7 @@ 4.0.0 com.azure.spring spring-cloud-azure-feature-management - 5.24.0-beta.1 + 6.0.0-beta.2 Spring Cloud Azure Feature Management Adds Feature Management into Spring @@ -90,6 +90,12 @@ 1.17.7 test + + ch.qos.logback + logback-classic + 1.5.18 + test + diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManagementConfiguration.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManagementConfiguration.java index 4b7ca99db962..9096856a8082 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManagementConfiguration.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManagementConfiguration.java @@ -2,20 +2,36 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.lang.NonNull; +import com.azure.spring.cloud.feature.management.filters.PercentageFilter; +import com.azure.spring.cloud.feature.management.filters.TargetingFilter; +import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; +import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; +import com.azure.spring.cloud.feature.management.telemetry.LoggerTelemetryPublisher; +import com.azure.spring.cloud.feature.management.telemetry.TelemetryPublisher; /** * Configuration for setting up FeatureManager */ @Configuration @EnableConfigurationProperties({ FeatureManagementConfigProperties.class, FeatureManagementProperties.class }) -class FeatureManagementConfiguration { +class FeatureManagementConfiguration implements ApplicationContextAware { + + private ApplicationContext appContext; /** * Creates Feature Manager @@ -26,8 +42,50 @@ class FeatureManagementConfiguration { * @return FeatureManager */ @Bean - FeatureManager featureManager(ApplicationContext context, - FeatureManagementProperties featureManagementConfigurations, FeatureManagementConfigProperties properties) { - return new FeatureManager(context, featureManagementConfigurations, properties); + FeatureManager featureManager(FeatureManagementProperties featureManagementConfigurations, + FeatureManagementConfigProperties properties, TelemetryPublisher telemetryPublisher, + ObjectProvider contextAccessorProvider, + ObjectProvider evaluationOptionsProvider) { + + TargetingContextAccessor contextAccessor = contextAccessorProvider.getIfAvailable(); + TargetingEvaluationOptions evaluationOptions = evaluationOptionsProvider + .getIfAvailable(() -> new TargetingEvaluationOptions()); + + return new FeatureManager(appContext, featureManagementConfigurations, properties, contextAccessor, + evaluationOptions, telemetryPublisher); + } + + @Override + public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException { + this.appContext = applicationContext; + } + + @Bean(name = "Microsoft.TimeWindow") + @ConditionalOnMissingBean(TimeWindowFilter.class) + public TimeWindowFilter timeWindowFilter() { + return new TimeWindowFilter(); + } + + @Bean(name = "Microsoft.Percentage") + @ConditionalOnMissingBean(PercentageFilter.class) + public PercentageFilter percentageFilter() { + return new PercentageFilter(); + } + + @Bean(name = "Microsoft.Targeting") + @Scope("request") + @ConditionalOnMissingBean(TargetingFilter.class) + @ConditionalOnBean(TargetingContextAccessor.class) + public TargetingFilter targetingFilter(TargetingContextAccessor context, + ObjectProvider evaluationOptionsProvider) { + TargetingEvaluationOptions evaluationOptions = evaluationOptionsProvider + .getIfAvailable(() -> new TargetingEvaluationOptions().setIgnoreCase(true)); + return new TargetingFilter(context, evaluationOptions); + } + + @Bean + @ConditionalOnMissingBean(TelemetryPublisher.class) + public TelemetryPublisher telemetryPublisher() { + return new LoggerTelemetryPublisher(); } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManager.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManager.java index 04be3548066f..7a9926df68f8 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManager.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/FeatureManager.java @@ -2,125 +2,425 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management; +import static com.azure.spring.cloud.feature.management.implementation.FeatureManagementConstants.ALL_REQUIREMENT_TYPE; + +import java.time.Duration; +import java.util.ArrayList; import java.util.HashSet; -import java.util.Map; -import java.util.Objects; +import java.util.List; import java.util.Set; -import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; -import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import com.azure.spring.cloud.feature.management.filters.ContextualFeatureFilter; +import com.azure.spring.cloud.feature.management.filters.ContextualFeatureFilterAsync; import com.azure.spring.cloud.feature.management.filters.FeatureFilter; +import com.azure.spring.cloud.feature.management.filters.FeatureFilterAsync; +import com.azure.spring.cloud.feature.management.implementation.FeatureFilterUtils; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; -import com.azure.spring.cloud.feature.management.implementation.models.Feature; +import com.azure.spring.cloud.feature.management.models.Allocation; +import com.azure.spring.cloud.feature.management.models.Conditions; +import com.azure.spring.cloud.feature.management.models.EvaluationEvent; +import com.azure.spring.cloud.feature.management.models.Feature; import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; +import com.azure.spring.cloud.feature.management.models.FeatureManagementException; import com.azure.spring.cloud.feature.management.models.FilterNotFoundException; +import com.azure.spring.cloud.feature.management.models.GroupAllocation; +import com.azure.spring.cloud.feature.management.models.PercentileAllocation; +import com.azure.spring.cloud.feature.management.models.UserAllocation; +import com.azure.spring.cloud.feature.management.models.Variant; +import com.azure.spring.cloud.feature.management.models.VariantAssignmentReason; +import com.azure.spring.cloud.feature.management.models.VariantReference; +import com.azure.spring.cloud.feature.management.targeting.TargetingContext; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; +import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; +import com.azure.spring.cloud.feature.management.targeting.TargetingFilterContext; +import com.azure.spring.cloud.feature.management.telemetry.TelemetryPublisher; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** - * Holds information on Feature Management properties and can check if a given feature is enabled. + * Used to evaluate the enabled state of a feature and/or get the assigned variant of a feature, if any. */ public class FeatureManager { - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureManager.class); + private static final double PERCENTILE_MAXIMUM = 100.0; + private transient ApplicationContext context; private final FeatureManagementProperties featureManagementConfigurations; private transient FeatureManagementConfigProperties properties; + private static final Duration DEFAULT_BLOCK_TIMEOUT = Duration.ofSeconds(100); + + private final TargetingContextAccessor contextAccessor; + + private final TargetingEvaluationOptions evaluationOptions; + + private final TelemetryPublisher telemetryPublisher; + /** - * Can be called to check if a feature is enabled or disabled. + * Used to evaluate the enabled state of a feature and/or get the assigned variant of a feature, if any. * * @param context ApplicationContext * @param featureManagementConfigurations Configuration Properties for Feature Flags * @param properties FeatureManagementConfigProperties + * @param contextAccessor TargetingContextAccessor + * @param evaluationOptions TargetingEvaluationOptions + * @param telemetryPublisher TelemetryPublisher */ FeatureManager(ApplicationContext context, FeatureManagementProperties featureManagementConfigurations, - FeatureManagementConfigProperties properties) { + FeatureManagementConfigProperties properties, TargetingContextAccessor contextAccessor, + TargetingEvaluationOptions evaluationOptions, TelemetryPublisher telemetryPublisher) { this.context = context; this.featureManagementConfigurations = featureManagementConfigurations; this.properties = properties; + this.contextAccessor = contextAccessor; + this.evaluationOptions = evaluationOptions; + this.telemetryPublisher = telemetryPublisher; } /** - * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter - * returns true it returns true. If no filter returns true, it returns false. If there are no - * filters, it returns true. If feature isn't found it returns false. + * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. * * @param feature Feature being checked. * @return state of the feature * @throws FilterNotFoundException file not found */ public Mono isEnabledAsync(String feature) { - return Mono.just(checkFeature(feature)); + return checkFeature(feature, null).map(event -> event.isEnabled()); } /** - * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter - * returns true it returns true. If no filter returns true, it returns false. If there are no - * filters, it returns true. If feature isn't found it returns false. + * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. * * @param feature Feature being checked. * @return state of the feature * @throws FilterNotFoundException file not found */ public Boolean isEnabled(String feature) throws FilterNotFoundException { - return checkFeature(feature); + return checkFeature(feature, null).map(event -> event.isEnabled()).block(DEFAULT_BLOCK_TIMEOUT); + } + + /** + * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + * @throws FilterNotFoundException file not found + */ + public Mono isEnabledAsync(String feature, Object featureContext) { + return checkFeature(feature, featureContext).map(event -> event.isEnabled()); + } + + /** + * Checks to see if the feature is enabled. If enabled it check each filter, once a single filter returns true it + * returns true. If no filter returns true, it returns false. If there are no filters, it returns true. If feature + * isn't found it returns false. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return state of the feature + * @throws FilterNotFoundException file not found + */ + public Boolean isEnabled(String feature, Object featureContext) throws FilterNotFoundException { + return checkFeature(feature, featureContext).map(event -> event.isEnabled()).block(DEFAULT_BLOCK_TIMEOUT); + } + + /** + * Returns the variant assigned to the current context. + * + * @param feature Feature being checked. + * @return Assigned Variant + */ + public Variant getVariant(String feature) { + return checkFeature(feature, null).block().getVariant(); + } + + /** + * Returns the variant assigned to the current context. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return Assigned Variant + */ + public Variant getVariant(String feature, Object featureContext) { + return checkFeature(feature, featureContext).block().getVariant(); } - private boolean checkFeature(String feature) throws FilterNotFoundException { - if (featureManagementConfigurations.getFeatureManagement() == null - || featureManagementConfigurations.getOnOff() == null) { - return false; + /** + * Returns the variant assigned to the current context. + * + * @param feature Feature being checked. + * @return Assigned Variant + */ + public Mono getVariantAsync(String feature) { + return checkFeature(feature, null).map(event -> event.getVariant()); + } + + /** + * Returns the variant assigned to the current context. + * + * @param feature Feature being checked. + * @param featureContext Local context + * @return Assigned Variant + */ + public Mono getVariantAsync(String feature, Object featureContext) { + return checkFeature(feature, featureContext).map(event -> event.getVariant()); + } + + private Mono checkFeature(String featureName, Object featureContext) + throws FilterNotFoundException { + List featureFlags = featureManagementConfigurations.getFeatureFlags(); + + if (featureFlags == null) { + return Mono.just(new EvaluationEvent(null)); } - Boolean boolFeature = featureManagementConfigurations.getOnOff().get(feature); + Feature featureFlag = featureFlags.stream() + .filter(feature -> feature.getId().equals(featureName)).findAny().orElse(null); - if (boolFeature != null) { - return boolFeature; + EvaluationEvent event = new EvaluationEvent(featureFlag); + + if (featureFlag == null) { + LOGGER.warn("Feature flag {} not found", featureName); + return Mono.just(event); } - Feature featureItem = featureManagementConfigurations.getFeatureManagement().get(feature); + if (!featureFlag.isEnabled()) { + this.assignDefaultDisabledReason(event); + event.setEnabled(false); + publishTelemetryIfEnabled(event); - if (featureItem == null || !featureItem.getEvaluate()) { - return false; + // If a feature flag is disabled and override can't enable it + return Mono.just(event); } - Stream filters = featureItem.getEnabledFor().values().stream() - .filter(Objects::nonNull).filter(featureFilter -> featureFilter.getName() != null); + Mono result = this.checkFeatureFilters(event, featureContext); + result = assignAllocation(result); + result = result.doOnSuccess(resultEvent -> publishTelemetryIfEnabled(resultEvent)); + return result; + } - // All Filters must be true - if (featureItem.getRequirementType().equals("All")) { - return filters.allMatch(featureFilter -> isFeatureOn(featureFilter, feature)); + /** + * Publishes telemetry if enabled for the feature. + * + * @param event Evaluation event + */ + private void publishTelemetryIfEnabled(EvaluationEvent event) { + if (telemetryPublisher != null && event.getFeature() != null + && event.getFeature().getTelemetry() != null + && event.getFeature().getTelemetry().isEnabled()) { + telemetryPublisher.publishTelemetry(event); } + } - // Any Filter must be true - return filters.anyMatch(featureFilter -> isFeatureOn(featureFilter, feature)); + private Mono assignAllocation(Mono monoEvent) { + return monoEvent.map(event -> { + Feature featureFlag = event.getFeature(); + + if (featureFlag.getVariants() == null || featureFlag.getAllocation() == null) { + return event; + } + + if (!event.isEnabled()) { + this.assignDefaultDisabledReason(event); + return event; + } + this.assignVariant(event); + return event; + }); } - private boolean isFeatureOn(FeatureFilterEvaluationContext filter, String feature) { - try { - FeatureFilter featureFilter = (FeatureFilter) context.getBean(filter.getName()); - filter.setFeatureName(feature); + private void assignDefaultDisabledReason(EvaluationEvent event) { + Feature featureFlag = event.getFeature(); + event.setReason(VariantAssignmentReason.DEFAULT_WHEN_DISABLED); + if (event.getFeature().getAllocation() == null) { + return; + } + this.assignVariantOverride(event.getFeature().getVariants(), + event.getFeature().getAllocation().getDefaultWhenDisabled(), false, event); - return featureFilter.evaluate(filter); - } catch (NoSuchBeanDefinitionException e) { - LOGGER.error("Was unable to find Filter {}. Does the class exist and set as an @Component?", - filter.getName()); - if (properties.isFailFast()) { - String message = "Fail fast is set and a Filter was unable to be found"; - ReflectionUtils.rethrowRuntimeException(new FilterNotFoundException(message, e, filter)); + if (featureFlag.getAllocation() != null) { + String variantName = featureFlag.getAllocation().getDefaultWhenDisabled(); + event.setVariant(this.variantNameToVariant(featureFlag, variantName)); + } + } + + private void assignDefaultEnabledVariant(EvaluationEvent event) { + event.setReason(VariantAssignmentReason.DEFAULT_WHEN_ENABLED); + if (event.getFeature().getAllocation() == null) { + return; + } + this.assignVariantOverride(event.getFeature().getVariants(), + event.getFeature().getAllocation().getDefaultWhenEnabled(), true, event); + Feature featureFlag = event.getFeature(); + + if (featureFlag.getAllocation() != null) { + event.setVariant( + this.variantNameToVariant(featureFlag, featureFlag.getAllocation().getDefaultWhenEnabled())); + return; + } + } + + private void assignVariant(EvaluationEvent event) { + Feature featureFlag = event.getFeature(); + if (featureFlag.getVariants().size() == 0 || featureFlag.getAllocation() == null) { + return; + } + + Allocation allocation = featureFlag.getAllocation(); + + TargetingContext targetingContext = buildContext(); + + List groups = targetingContext.getGroups(); + String variantName = null; + + if (StringUtils.hasText(targetingContext.getUserId())) { + // Loop through all user allocations + for (UserAllocation userAllocation : allocation.getUser()) { + if (!evaluationOptions.isIgnoreCase() + && userAllocation.getUsers().contains(targetingContext.getUserId())) { + event.setReason(VariantAssignmentReason.USER); + variantName = userAllocation.getVariant(); + break; + } else if (evaluationOptions.isIgnoreCase() + && userAllocation.getUsers().stream().anyMatch(targetingContext.getUserId()::equalsIgnoreCase)) { + event.setReason(VariantAssignmentReason.USER); + variantName = userAllocation.getVariant(); + break; + } } } - return false; + if (variantName == null) { + for (GroupAllocation groupAllocation : allocation.getGroup()) { + for (String allocationGroup : groupAllocation.getGroups()) { + if (!evaluationOptions.isIgnoreCase() && groups.contains(allocationGroup)) { + event.setReason(VariantAssignmentReason.GROUP); + variantName = groupAllocation.getVariant(); + break; + } else if (evaluationOptions.isIgnoreCase() + && groups.stream().anyMatch(allocationGroup::equalsIgnoreCase)) { + event.setReason(VariantAssignmentReason.GROUP); + variantName = groupAllocation.getVariant(); + break; + } + } + if (variantName != null) { + break; + } + } + } + + if (variantName == null) { + String seed = allocation.getSeed(); + if (!StringUtils.hasText(seed)) { + seed = "allocation\n" + featureFlag.getId(); + } + String contextId = targetingContext.getUserId() + "\n" + seed; + double box = FeatureFilterUtils.isTargetedPercentage(contextId); + for (PercentileAllocation percentileAllocation : allocation.getPercentile()) { + Double to = percentileAllocation.getTo(); + if ((box == PERCENTILE_MAXIMUM && to == PERCENTILE_MAXIMUM) + || (percentileAllocation.getFrom() <= box && box < to)) { + event.setReason(VariantAssignmentReason.PERCENTILE); + variantName = percentileAllocation.getVariant(); + break; + } + } + } + + if (variantName == null) { + this.assignDefaultEnabledVariant(event); + return; + } + + event.setVariant(variantNameToVariant(featureFlag, variantName)); + assignVariantOverride(featureFlag.getVariants(), variantName, true, event); + } + + private void assignVariantOverride(List variants, String defaultVariantName, boolean status, + EvaluationEvent event) { + if (variants.size() == 0 || !StringUtils.hasText(defaultVariantName)) { + return; + } + for (VariantReference variant : variants) { + if (variant.getName().equals(defaultVariantName)) { + if ("Enabled".equals(variant.getStatusOverride())) { + event.setEnabled(true); + return; + } + if ("Disabled".equals(variant.getStatusOverride())) { + event.setEnabled(false); + return; + } + } + } + event.setEnabled(status); + } + + private Mono checkFeatureFilters(EvaluationEvent event, Object featureContext) { + Feature featureFlag = event.getFeature(); + Conditions conditions = featureFlag.getConditions(); + List featureFilters = conditions.getClientFilters(); + + if (featureFilters.size() == 0) { + return Mono.just(event.setEnabled(true)); + } else { + event.setEnabled(conditions.getRequirementType().equals(ALL_REQUIREMENT_TYPE)); + } + + List> filterResults = new ArrayList>(); + for (FeatureFilterEvaluationContext featureFilter : featureFilters) { + String filterName = featureFilter.getName(); + featureFilter.setFeatureName(event.getFeature().getId()); + + filterResults.add(evaluateFilter(featureFilter, featureContext, filterName)); + } + + if (ALL_REQUIREMENT_TYPE.equals(featureFlag.getConditions().getRequirementType())) { + return Flux.merge(filterResults).reduce((a, b) -> { + return a && b; + }).single().map(result -> { + return event.setEnabled(result); + }); + } + // Any Filter must be true + return Flux.merge(filterResults).reduce((a, b) -> a || b).single().map(result -> event.setEnabled(result)); + } + + private Variant variantNameToVariant(Feature featureFlag, String variantName) { + for (VariantReference variant : featureFlag.getVariants()) { + if (variant.getName().equals(variantName)) { + return new Variant(variantName, variant.getConfigurationValue()); + } + } + return null; + } + + private TargetingFilterContext buildContext() { + TargetingFilterContext targetingContext = new TargetingFilterContext(); + if (contextAccessor != null) { + // If this is the only one provided just use it. + contextAccessor.configureTargetingContext(targetingContext); + return targetingContext; + } + throw new FeatureManagementException("No Targeting Filter Context found to assign variant."); } /** @@ -129,25 +429,42 @@ private boolean isFeatureOn(FeatureFilterEvaluationContext filter, String featur * @return a set of all feature names */ public Set getAllFeatureNames() { - Set allFeatures = new HashSet<>(); - - allFeatures.addAll(featureManagementConfigurations.getOnOff().keySet()); - allFeatures.addAll(featureManagementConfigurations.getFeatureManagement().keySet()); - return allFeatures; - } - - /** - * @return the featureManagement - */ - Map getFeatureManagement() { - return featureManagementConfigurations.getFeatureManagement(); + return new HashSet( + featureManagementConfigurations.getFeatureFlags().stream().map(feature -> feature.getId()).toList()); } /** - * @return the onOff + * Enhanced filter evaluation with better error handling and logging. + * + * @param featureFilter Feature filter evaluation context + * @param featureContext Feature context + * @param filterName Name of the filter for logging + * @return {@code Mono} result of filter evaluation */ - Map getOnOff() { - return featureManagementConfigurations.getOnOff(); + private Mono evaluateFilter(FeatureFilterEvaluationContext featureFilter, Object featureContext, + String filterName) { + try { + Object filter = context.getBean(filterName); + if (filter instanceof FeatureFilter) { + return Mono.just(((FeatureFilter) filter).evaluate(featureFilter)); + } else if (filter instanceof ContextualFeatureFilter) { + return Mono.just(((ContextualFeatureFilter) filter).evaluate(featureFilter, featureContext)); + } else if (filter instanceof FeatureFilterAsync) { + return ((FeatureFilterAsync) filter).evaluateAsync(featureFilter); + } else if (filter instanceof ContextualFeatureFilterAsync) { + return ((ContextualFeatureFilterAsync) filter).evaluateAsync(featureFilter, featureContext); + } else { + LOGGER.warn("Filter {} does not implement any known filter interface", filterName); + return Mono.just(false); + } + } catch (NoSuchBeanDefinitionException e) { + LOGGER.error("Was unable to find Filter {}. Does the class exist and set as an @Component?", filterName, e); + if (properties.isFailFast()) { + String message = "Fail fast is set and a Filter was unable to be found"; + return Mono.error(new FilterNotFoundException(message, e, featureFilter)); + } + return Mono.just(false); + } } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilter.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilter.java new file mode 100644 index 000000000000..81fd09d0fe11 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilter.java @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.filters; + +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; + +/** + * A Filter for Feature Management that is attached to Features. The filter needs to have @Component set to be found by + * feature management. As a Contextual feature filter any context that is passed in to the feature request will be + * passed along to the filter(s). + * @since 6.0.0 + */ +@FunctionalInterface +public interface ContextualFeatureFilter { + + /** + * Evaluates if the filter is on or off. Returning true results in Feature evaluation ending and returning true. + * Returning false results in the next Feature evaluation to continue. + * + * @param context The context for whether or not the filter is passed. + * @param appContext The internal app context + * @return True if the feature is enabled, false otherwise. + */ + boolean evaluate(FeatureFilterEvaluationContext context, Object appContext); + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilterAsync.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilterAsync.java new file mode 100644 index 000000000000..c22930312978 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/ContextualFeatureFilterAsync.java @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.filters; + +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; + +import reactor.core.publisher.Mono; + +/** + * A Filter for Feature Management that is attached to Features. The filter needs to have @Component set to be found by + * feature management. As a Contextual feature filter any context that is passed in to the feature request will be + * passed along to the filter(s). + * @since 6.0.0 + */ +@FunctionalInterface +public interface ContextualFeatureFilterAsync { + + /** + * Evaluates if the filter is on or off. Returning true results in Feature evaluation ending and returning true. + * Returning false results in the next Feature evaluation to continue. + * + * @param context The context for whether or not the filter is passed. + * @param appContext The internal app context + * @return true if the feature is enabled, false otherwise. + */ + Mono evaluateAsync(FeatureFilterEvaluationContext context, Object appContext); + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/FeatureFilterAsync.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/FeatureFilterAsync.java new file mode 100644 index 000000000000..e484a2795045 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/FeatureFilterAsync.java @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.filters; + +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; + +import reactor.core.publisher.Mono; + +/** + * A Filter for Feature Management that is attached to Features. The filter needs to have @Component set to be found by + * feature management. + */ +@FunctionalInterface +public interface FeatureFilterAsync { + + /** + * Evaluates if the filter is on or off. Returning true results in Feature evaluation ending and returning true. + * Returning false results in the next Feature evaluation to continue. + * + * @param context The context for whether or not the filter is passed. + * @return True if the feature is enabled, false otherwise. + */ + Mono evaluateAsync(FeatureFilterEvaluationContext context); + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/PercentageFilter.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/PercentageFilter.java index 256ce9b537fd..6418ddc14d03 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/PercentageFilter.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/PercentageFilter.java @@ -2,11 +2,12 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.filters; -import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.PERCENTAGE_FILTER_SETTING; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.PERCENTAGE_FILTER_SETTING; +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; /** * A feature filter that can be used to activate a feature based on a random percentage. diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java index bea9aa4879db..5d1285aaee06 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java @@ -2,26 +2,23 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.filters; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import com.azure.spring.cloud.feature.management.implementation.FeatureFilterUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.StringUtils; +import com.azure.spring.cloud.feature.management.implementation.FeatureFilterUtils; import com.azure.spring.cloud.feature.management.implementation.targeting.Audience; -import com.azure.spring.cloud.feature.management.implementation.targeting.Exclusion; import com.azure.spring.cloud.feature.management.implementation.targeting.GroupRollout; import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; import com.azure.spring.cloud.feature.management.models.TargetingException; +import com.azure.spring.cloud.feature.management.targeting.TargetingContext; import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; import com.azure.spring.cloud.feature.management.targeting.TargetingFilterContext; @@ -32,7 +29,7 @@ /** * `Microsoft.TargetingFilter` enables evaluating a user/group/overall rollout of a feature. */ -public class TargetingFilter implements FeatureFilter { +public class TargetingFilter implements FeatureFilter, ContextualFeatureFilter { private static final Logger LOGGER = LoggerFactory.getLogger(TargetingFilter.class); @@ -54,8 +51,6 @@ public class TargetingFilter implements FeatureFilter { /** * Audience that always returns false */ - protected static final String EXCLUSION = "exclusion"; - private static final String EXCLUSION_CAMEL = "Exclusion"; /** @@ -103,15 +98,26 @@ public TargetingFilter(TargetingContextAccessor contextAccessor, TargetingEvalua } @Override - @SuppressWarnings("unchecked") public boolean evaluate(FeatureFilterEvaluationContext context) { + return evaluate(context, null); + } + + @SuppressWarnings("unchecked") + @Override + public boolean evaluate(FeatureFilterEvaluationContext context, Object appContext) { + if (context == null) { throw new IllegalArgumentException("Targeting Context not configured."); } - TargetingFilterContext targetingContext = new TargetingFilterContext(); - - contextAccessor.configureTargetingContext(targetingContext); + TargetingContext targetingContext = new TargetingFilterContext(); + if (appContext != null && appContext instanceof TargetingContext) { + // Use this if, there is an appContext + the contextualAccessor, or there is no contextAccessor. + targetingContext = (TargetingContext) appContext; + } else if (contextAccessor != null) { + // If this is the only one provided just use it. + contextAccessor.configureTargetingContext(targetingContext); + } if (validateTargetingContext(targetingContext)) { LOGGER.warn("No targeting context available for targeting evaluation."); @@ -125,16 +131,15 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { parameters = (Map) audienceObject; } - // The correct value is Users/Groups but keeping users/groups to make sure no one is broken. - FeatureFilterUtils.updateValueFromMapToList(parameters, USERS); - FeatureFilterUtils.updateValueFromMapToList(parameters, USERS.toLowerCase()); - FeatureFilterUtils.updateValueFromMapToList(parameters, GROUPS); - FeatureFilterUtils.updateValueFromMapToList(parameters, GROUPS.toLowerCase()); + FeatureFilterUtils.updateValueFromMapToList(parameters, USERS, true); + FeatureFilterUtils.updateValueFromMapToList(parameters, GROUPS, true); Audience audience; String exclusionValue = FeatureFilterUtils.getKeyCase(parameters, EXCLUSION_CAMEL); - String exclusionUserValue = FeatureFilterUtils.getKeyCase((Map) parameters.get(exclusionValue), "Users"); - String exclusionGroupsValue = FeatureFilterUtils.getKeyCase((Map) parameters.get(exclusionValue), "Groups"); + String exclusionUserValue = FeatureFilterUtils.getKeyCase((Map) parameters.get(exclusionValue), + "Users"); + String exclusionGroupsValue = FeatureFilterUtils + .getKeyCase((Map) parameters.get(exclusionValue), "Groups"); if (((Map) parameters.getOrDefault(exclusionValue, new HashMap<>())) .get(exclusionUserValue) instanceof List) { @@ -146,19 +151,20 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { exclusionMap = new HashMap<>(); } - audience = OBJECT_MAPPER.convertValue(parameters, Audience.class); - - Exclusion exclusion = new Exclusion(); Object users = exclusionMap.get(exclusionUserValue); Object groups = exclusionMap.get(exclusionGroupsValue); + Map exclusion = new HashMap<>(); + if (users instanceof Map) { - exclusion.setUsers(new ArrayList<>(((Map) users).values())); + exclusion.put(USERS, new ArrayList<>(((Map) users).values())); } if (groups instanceof Map) { - exclusion.setGroups(new ArrayList<>(((Map) groups).values())); + exclusion.put(GROUPS, new ArrayList<>(((Map) groups).values())); } - audience.setExclusion(exclusion); + parameters.put(exclusionValue, exclusion); + + audience = OBJECT_MAPPER.convertValue(parameters, Audience.class); } validateSettings(audience); @@ -196,17 +202,21 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { return isTargeted(defaultContextId, audience.getDefaultRolloutPercentage()); } - private boolean targetUser(String userId, List users) { + private boolean targetUser(String userId, Collection users) { return userId != null && users != null && users.stream().anyMatch(user -> equals(userId, user)); } - private boolean targetGroup(Audience audience, TargetingFilterContext targetingContext, + private boolean targetGroup(Audience audience, TargetingContext targetingContext, FeatureFilterEvaluationContext context, String group) { Optional groupRollout = audience.getGroups().stream() .filter(g -> equals(g.getName(), group)).findFirst(); if (groupRollout.isPresent()) { - String audienceContextId = targetingContext.getUserId() + "\n" + context.getName() + "\n" + group; + String userId = ""; + if (targetingContext.getUserId() != null) { + userId = targetingContext.getUserId(); + } + String audienceContextId = userId + "\n" + context.getFeatureName() + "\n" + group; if (isTargeted(audienceContextId, groupRollout.get().getRolloutPercentage())) { return true; @@ -215,7 +225,7 @@ private boolean targetGroup(Audience audience, TargetingFilterContext targetingC return false; } - private boolean validateTargetingContext(TargetingFilterContext targetingContext) { + private boolean validateTargetingContext(TargetingContext targetingContext) { boolean hasUserDefined = StringUtils.hasText(targetingContext.getUserId()); boolean hasGroupsDefined = targetingContext.getGroups() != null; boolean hasAtLeastOneGroup = false; @@ -227,35 +237,8 @@ private boolean validateTargetingContext(TargetingFilterContext targetingContext return (!hasUserDefined && !(hasGroupsDefined && hasAtLeastOneGroup)); } - /** - * Computes the percentage that the contextId falls into. - * - * @param contextId Id of the context being targeted - * @return the bucket value of the context id - * @throws TargetingException Unable to create hash of target context - */ - protected double isTargetedPercentage(String contextId) { - byte[] hash = null; - - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - hash = digest.digest(contextId.getBytes(Charset.defaultCharset())); - } catch (NoSuchAlgorithmException e) { - throw new TargetingException("Unable to find SHA-256 for targeting.", e); - } - - if (hash == null) { - throw new TargetingException("Unable to create Targeting Hash for " + contextId); - } - - ByteBuffer wrapped = ByteBuffer.wrap(hash); - int contextMarker = Math.abs(wrapped.getInt()); - - return (contextMarker / (double) Integer.MAX_VALUE) * 100; - } - private boolean isTargeted(String contextId, double percentage) { - return isTargetedPercentage(contextId) < percentage; + return FeatureFilterUtils.isTargetedPercentage(contextId) < percentage; } /** @@ -282,8 +265,8 @@ void validateSettings(Audience audience) { throw new TargetingException(paramName + " : " + reason); } - List groups = audience.getGroups(); - if (groups != null) { + if (audience.getGroups() != null) { + List groups = new ArrayList(audience.getGroups()); for (int index = 0; index < groups.size(); index++) { GroupRollout groupRollout = groups.get(index); if (groupRollout.getRolloutPercentage() < 0 || groupRollout.getRolloutPercentage() > 100) { diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TimeWindowFilter.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TimeWindowFilter.java index b58efddce703..a25274677a09 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TimeWindowFilter.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TimeWindowFilter.java @@ -2,6 +2,16 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.filters; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_END; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_RECURRENCE; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_START; + +import java.time.ZonedDateTime; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.azure.spring.cloud.feature.management.implementation.FeatureFilterUtils; import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; @@ -10,15 +20,6 @@ import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.ZonedDateTime; -import java.util.Map; - -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_END; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_RECURRENCE; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_START; /** * A feature filter that can be used at activate a feature based on a time window. diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureFilterUtils.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureFilterUtils.java index e95dc6a53404..0d25d4508272 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureFilterUtils.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureFilterUtils.java @@ -3,24 +3,41 @@ package com.azure.spring.cloud.feature.management.implementation; -import org.springframework.util.StringUtils; - +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import org.springframework.util.StringUtils; + +import com.azure.spring.cloud.feature.management.models.TargetingException; + public class FeatureFilterUtils { + + public static void updateValueFromMapToList(Map parameters, String key) { + updateValueFromMapToList(parameters, key, false); + } + /** - * Looks at the given key in the parameters and coverts it to a list if it is currently a map. + * Looks at the given key in the parameters and converts it to a list if it is currently a map. Also handles empty + * strings and null values based on the fixNull parameter. * * @param parameters map of generic objects - * @param key key of object int the parameters map + * @param key key of object in the parameters map + * @param fixNull whether to convert null values to empty lists */ @SuppressWarnings("unchecked") - public static void updateValueFromMapToList(Map parameters, String key) { + public static void updateValueFromMapToList(Map parameters, String key, boolean fixNull) { Object objectMap = parameters.get(key); if (objectMap instanceof Map) { Collection toType = ((Map) objectMap).values(); parameters.put(key, toType); + } else if ((objectMap != null && objectMap.equals("")) || (objectMap == null && fixNull)) { + parameters.put(key, new ArrayList()); + } else if (objectMap != null) { + parameters.put(key, objectMap); } } @@ -30,4 +47,43 @@ public static String getKeyCase(Map parameters, String key) { } return StringUtils.uncapitalize(key); } + + /** + * Computes the percentage bucket (0-100) that the contextId falls into. Uses SHA-256 hash for consistent + * distribution across the percentage range. + * + * @param contextId Id of the context being targeted + * @return the bucket value of the context id + * @throws TargetingException Unable to create hash of target context + */ + public static double isTargetedPercentage(String contextId) { + byte[] hash = null; + if (contextId == null) { + contextId = "\n"; + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + hash = digest.digest(contextId.getBytes()); + + } catch (NoSuchAlgorithmException e) { + throw new TargetingException("Unable to find SHA-256 for targeting.", e); + } + + if (hash == null) { + throw new TargetingException("Unable to create Targeting Hash for " + contextId); + } + + BigInteger bi = bigEndianToLittleEndian(hash); + + return (bi.longValue() / (Math.pow(2, 32) - 1)) * 100; + } + + public static BigInteger bigEndianToLittleEndian(byte[] bigEndian) { + byte[] reversedBytes = new byte[4]; + for (int i = 0; i < 4; i++) { + reversedBytes[i] = bigEndian[3 - i]; + } + return new BigInteger(1, reversedBytes); + } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementConstants.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementConstants.java index 1ce1fc46aca3..605d45ea1dfe 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementConstants.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementConstants.java @@ -2,8 +2,10 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.implementation; -public class FeatureManagementConstants { +public final class FeatureManagementConstants { public static final String DEFAULT_REQUIREMENT_TYPE = "Any"; + public static final String ALL_REQUIREMENT_TYPE = "All"; + } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementProperties.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementProperties.java index 56c0de153ab2..f7e7cc3185ce 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementProperties.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementProperties.java @@ -3,201 +3,65 @@ package com.azure.spring.cloud.feature.management.implementation; import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; -import com.azure.spring.cloud.feature.management.implementation.models.Feature; -import com.azure.spring.cloud.feature.management.implementation.models.ServerSideFeature; +import com.azure.spring.cloud.feature.management.models.Feature; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; /** * Configuration Properties for Feature Management. Processes the configurations to be usable by Feature Management. */ @ConfigurationProperties(prefix = "feature-management") -public class FeatureManagementProperties extends HashMap { +public class FeatureManagementProperties { - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureManagementProperties.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final ObjectMapper MAPPER = new ObjectMapper() - .setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE); + @JsonProperty("feature-flags") + private List featureFlags; - private static final long serialVersionUID = -1642032123104805346L; + //private List convertedFeatureFlags; - private static final String FEATURE_FLAG_SNAKE_CASE = "feature_flags"; - - /** - * Map of all Feature Flags that use Feature Filters. - */ - private transient Map featureManagement; + public List getFeatureFlags() { + return featureFlags; + } /** - * Map of all Feature Flags that are just enabled/disabled. + * Sets the feature flags from the configuration. This method handles conversion from various input formats. + * + * @param featureFlags the feature flags to set */ - private Map onOff; - - public FeatureManagementProperties() { - featureManagement = new HashMap<>(); - onOff = new HashMap<>(); - } - - @Override - public void putAll(Map m) { - if (m == null) { + public void setFeatureFlags(List featureFlags) { + if (featureFlags == null || featureFlags.isEmpty()) { + this.featureFlags = List.of(); return; } - // Need to reset or switch between on/off to conditional doesn't work - featureManagement = new HashMap<>(); - onOff = new HashMap<>(); - - // try to parse the properties by server side schema as default - tryServerSideSchema(m); - - if (featureManagement.isEmpty() && onOff.isEmpty()) { - tryClientSideSchema(m); - } - } - - @SuppressWarnings({ "unchecked", "deprecation" }) - private void tryServerSideSchema(Map features) { - if (features.keySet().isEmpty()) { - return; - } - - // check if FeatureFlags section exist - String featureFlagsSectionKey = ""; - for (String key : features.keySet()) { - if (FEATURE_FLAG_SNAKE_CASE.equalsIgnoreCase(key)) { - featureFlagsSectionKey = key; - break; - } - } - if (featureFlagsSectionKey.isEmpty()) { - return; - } - - // get FeatureFlags section and parse - final Object featureFlagsObject = features.get(featureFlagsSectionKey); - if (Map.class.isAssignableFrom(featureFlagsObject.getClass())) { - final Map featureFlagsSection = (Map) featureFlagsObject; - for (String key : featureFlagsSection.keySet()) { - addServerSideFeature(featureFlagsSection, key); - } - } else { - if (List.class.isAssignableFrom(featureFlagsObject.getClass())) { - final List featureFlagsSection = (List) featureFlagsObject; - for (Object flag : featureFlagsSection) { - addServerSideFeature((Map) flag, null); - } - } - } - } - - private void tryClientSideSchema(Map features) { - for (String key : features.keySet()) { - addFeature(features, key, ""); - } - } - - @SuppressWarnings("unchecked") - private void addFeature(Map features, String key, String combined) { - Object featureValue = features.get(key); - if (!combined.isEmpty() && !combined.endsWith(".")) { - combined += "."; - } - if (featureValue instanceof Boolean) { - onOff.put(combined + key, (Boolean) featureValue); - } else { - Feature feature = null; - try { - feature = MAPPER.convertValue(featureValue, Feature.class); - } catch (IllegalArgumentException e) { - LOGGER.error("Found invalid feature {} with value {}.", combined + key, featureValue.toString()); - } - // When coming from a file "feature.flag" is not a possible flag name - if (feature != null && feature.getEnabledFor() == null && feature.getKey() == null) { - if (Map.class.isAssignableFrom(featureValue.getClass())) { - features = (Map) featureValue; - for (String fKey : features.keySet()) { - addFeature(features, fKey, combined + key); - } - } - } else { - if (feature != null) { - feature.setKey(key); - featureManagement.put(key, feature); + + // Check if the first element is a Feature instance to determine if we need conversion + if (featureFlags.get(0) instanceof Feature) { + List features = new ArrayList<>(); + for (Object flag : featureFlags) { + // This should always be true based on our check, but we verify each element to be safe + if (flag instanceof Feature) { + features.add((Feature) flag); } } - } - } - - @SuppressWarnings("unchecked") - private void addServerSideFeature(Map features, String key) { - Object featureValue = null; - if (key != null) { - featureValue = features.get(key); - } else { - featureValue = features; - } - ServerSideFeature serverSideFeature = null; - try { - LinkedHashMap ff = new LinkedHashMap<>(); - if (featureValue.getClass().isAssignableFrom(LinkedHashMap.class)) { - ff = (LinkedHashMap) featureValue; - } - LinkedHashMap conditions = new LinkedHashMap<>(); - if (ff.containsKey("conditions") - && ff.get("conditions").getClass().isAssignableFrom(LinkedHashMap.class)) { - conditions = (LinkedHashMap) ff.get("conditions"); + if (!features.isEmpty()) { + this.featureFlags = features; + return; } - - Object objectMap = conditions.get("client_filters"); - if (objectMap == null || "".equals(objectMap)) { - conditions.put("client_filters", new ArrayList<>()); - } - - FeatureFilterUtils.updateValueFromMapToList(conditions, "client_filters"); - - serverSideFeature = MAPPER.convertValue(featureValue, ServerSideFeature.class); - } catch (IllegalArgumentException e) { - LOGGER.error("Found invalid feature {} with value {}.", key, featureValue.toString()); } - if (serverSideFeature != null && serverSideFeature.getId() != null) { - if (serverSideFeature.getConditions() != null - && serverSideFeature.getConditions().getClientFilters() != null - && serverSideFeature.getConditions().getClientFilters().size() > 0) { - final Feature feature = new Feature(); - feature.setKey(serverSideFeature.getId()); - feature.setEvaluate(serverSideFeature.isEnabled()); - feature.setEnabledFor(serverSideFeature.getConditions().getClientFiltersAsMap()); - feature.setRequirementType(serverSideFeature.getConditions().getRequirementType()); - featureManagement.put(serverSideFeature.getId(), feature); - } else { - onOff.put(serverSideFeature.getId(), serverSideFeature.isEnabled()); - } + // Convert the feature flags to the correct type + List convertedFlags = new ArrayList<>(); + for (Object feature : featureFlags) { + Feature convertedFeature = OBJECT_MAPPER.convertValue(feature, Feature.class); + convertedFlags.add(convertedFeature); } - } - - /** - * @return the featureManagement - */ - public Map getFeatureManagement() { - return featureManagement; - } - - /** - * @return the onOff - */ - public Map getOnOff() { - return onOff; + this.featureFlags = convertedFlags; } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Conditions.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Conditions.java deleted file mode 100644 index b8632677d0d0..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Conditions.java +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.spring.cloud.feature.management.implementation.models; - -import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static com.azure.spring.cloud.feature.management.implementation.FeatureManagementConstants.DEFAULT_REQUIREMENT_TYPE; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class Conditions { - @JsonProperty("client_filters") - private List clientFilters; - - @JsonProperty("requirement_type") - private String requirementType = DEFAULT_REQUIREMENT_TYPE; - - /** - * @return the requirementType - */ - public String getRequirementType() { - return requirementType; - } - - /** - * @param requirementType the requirementType to set - */ - public void setRequirementType(String requirementType) { - this.requirementType = requirementType; - } - - /** - * @return the clientFilters - */ - public List getClientFilters() { - return clientFilters; - } - - /** - * @return the clientFilters in Map format using index of list as key - */ - public Map getClientFiltersAsMap() { - final Map clientFiltersMap = new HashMap<>(); - for (int i = 0; i < clientFilters.size(); i++) { - clientFiltersMap.put(i, clientFilters.get(i)); - } - return clientFiltersMap; - } - - /** - * @param clientFilters the clientFilters to set - */ - public void setClientFilters(List clientFilters) { - this.clientFilters = clientFilters; - } - -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Feature.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Feature.java deleted file mode 100644 index 85f0f12841ea..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/Feature.java +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.feature.management.implementation.models; - -import static com.azure.spring.cloud.feature.management.implementation.FeatureManagementConstants.DEFAULT_REQUIREMENT_TYPE; - -import java.util.Map; - -import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * App Configuration Feature defines the feature name and a Map of FeatureFilterEvaluationContexts. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public class Feature { - - @JsonProperty("key") - private String key; - - @JsonProperty("evaluate") - private Boolean evaluate = true; - - @JsonProperty("enabled-for") - private Map enabledFor; - - @JsonProperty("requirement-type") - private String requirementType = DEFAULT_REQUIREMENT_TYPE; - - /** - * @return the key - */ - public String getKey() { - return key; - } - - /** - * @param key the key to set - */ - public void setKey(String key) { - this.key = key; - } - - /** - * @return the evaluate - */ - public Boolean getEvaluate() { - return evaluate; - } - - /** - * @param evaluate the evaluate to set - */ - public void setEvaluate(Boolean evaluate) { - this.evaluate = evaluate; - } - - /** - * @return the enabledFor - */ - public Map getEnabledFor() { - return enabledFor; - } - - /** - * @param enabledFor the enabledFor to set - */ - public void setEnabledFor(Map enabledFor) { - this.enabledFor = enabledFor; - } - - /** - * @return the requirementType - */ - public String getRequirementType() { - return requirementType; - } - - /** - * @param requirementType the requirementType to set - */ - public void setRequirementType(String requirementType) { - this.requirementType = requirementType; - } - -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrencePattern.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrencePattern.java index 330a89d82763..6addcea494f6 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrencePattern.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrencePattern.java @@ -3,14 +3,14 @@ package com.azure.spring.cloud.feature.management.implementation.models; -import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - import java.time.DayOfWeek; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * The recurrence pattern specifying how often the time window repeats * */ diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrenceRange.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrenceRange.java index a9e833aed7bb..0a6bad1d3dc5 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrenceRange.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/RecurrenceRange.java @@ -3,13 +3,13 @@ package com.azure.spring.cloud.feature.management.implementation.models; +import java.time.ZonedDateTime; +import java.util.Arrays; + import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowUtils; import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.time.ZonedDateTime; -import java.util.Arrays; - /** * The recurrence range specifying how long the recurrence pattern repeats * */ diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/ServerSideFeature.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/ServerSideFeature.java deleted file mode 100644 index 3b3213ee804c..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/models/ServerSideFeature.java +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.spring.cloud.feature.management.implementation.models; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * App Configuration Feature defines the feature name and a Map of FeatureFilterEvaluationContexts. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -public class ServerSideFeature { - @JsonProperty("id") - private String id; - - @JsonProperty("description") - private String description; - - @JsonProperty("enabled") - private boolean enabled; - - @JsonProperty("conditions") - private Conditions conditions; - - /** - * @return the id - */ - public String getId() { - return id; - } - - /** - * @param id the id to set - */ - public void setId(String id) { - this.id = id; - } - - /** - * @return the enabled - */ - public boolean isEnabled() { - return enabled; - } - - /** - * @param enabled the enabled to set - */ - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - /** - * @return the description - * */ - public String getDescription() { - return description; - } - - /** - * @param description the description to set - * */ - public void setDescription(String description) { - this.description = description; - } - - /** - * @return the conditions - * */ - public Conditions getConditions() { - return conditions; - } - - /** - * @param conditions the conditions to set - * */ - public void setConditions(Conditions conditions) { - this.conditions = conditions; - } -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/TargetingFilterSettings.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/TargetingFilterSettings.java deleted file mode 100644 index 88ad5aae1304..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/TargetingFilterSettings.java +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.feature.management.implementation.targeting; - -/** - * The settings that are used to configure the TargetingFilter feature filter. - */ -public class TargetingFilterSettings { - - private Audience audience; - - /** - * @return the audience - */ - public Audience getAudience() { - return audience; - } - - /** - * @param audience the audience to set - */ - public void setAudience(Audience audience) { - this.audience = audience; - } - -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowFilterSettings.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowFilterSettings.java index 5496bac40603..54b6675be16c 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowFilterSettings.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowFilterSettings.java @@ -2,10 +2,10 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.implementation.timewindow; -import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; - import java.time.ZonedDateTime; +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; + public class TimeWindowFilterSettings { /** * An optional start time used to determine when a feature should be enabled. diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowUtils.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowUtils.java index 3ee3fd0883cd..11d1deee7bef 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowUtils.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/TimeWindowUtils.java @@ -3,9 +3,6 @@ package com.azure.spring.cloud.feature.management.implementation.timewindow; -import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; -import org.springframework.util.StringUtils; - import java.time.DayOfWeek; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -15,6 +12,10 @@ import java.util.Comparator; import java.util.List; +import org.springframework.util.StringUtils; + +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; + public class TimeWindowUtils { public static ZonedDateTime convertStringToDate(String timeStr) { if (!StringUtils.hasText(timeStr)) { diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java index 17621fc0842b..a49837a0fe46 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java @@ -3,6 +3,11 @@ package com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.List; + import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePatternType; import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; @@ -10,11 +15,6 @@ import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowUtils; -import java.time.DayOfWeek; -import java.time.Duration; -import java.time.ZonedDateTime; -import java.util.List; - public class RecurrenceEvaluator { /** * Checks if a provided timestamp is within any recurring time window specified diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java index a09f16c3c432..366e36394da5 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java @@ -3,12 +3,9 @@ package com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence; -import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePatternType; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRangeType; -import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; -import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowUtils; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_END; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_RECURRENCE; +import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_START; import java.time.DayOfWeek; import java.time.Duration; @@ -16,9 +13,12 @@ import java.time.temporal.ChronoUnit; import java.util.List; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_END; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_RECURRENCE; -import static com.azure.spring.cloud.feature.management.models.FilterParameters.TIME_WINDOW_FILTER_SETTING_START; +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePatternType; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRangeType; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowUtils; public class RecurrenceValidator { public static void validateSettings(TimeWindowFilterSettings settings) { diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Allocation.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Allocation.java new file mode 100644 index 000000000000..ca2add71313b --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Allocation.java @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Allocation of a feature flag to variants. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Allocation { + + /** + * Creates a new instance of the Allocation class. + */ + public Allocation() { + } + + @JsonProperty("default_when_enabled") + private String defaultWhenEnabled; + + @JsonProperty("default_when_disabled") + private String defaultWhenDisabled; + + private List user = new ArrayList<>(); + + private List group = new ArrayList<>(); + + private List percentile = new ArrayList<>(); + + private String seed; + + /** + * Gets the variant to use when the feature flag is enabled and no specific allocation matches. + * + * @return the default variant when the feature flag is enabled + */ + public String getDefaultWhenEnabled() { + return defaultWhenEnabled; + } + + /** + * Sets the variant to use when the feature flag is enabled and no specific allocation matches. + * + * @param defaultWhenEnabled the default variant when enabled + * @return the updated Allocation object + */ + public Allocation setDefaultWhenEnabled(String defaultWhenEnabled) { + this.defaultWhenEnabled = defaultWhenEnabled; + return this; + } + + /** + * Gets the variant to use when the feature flag is disabled and no specific allocation matches. + * + * @return the default variant when the feature flag is disabled + */ + public String getDefaultWhenDisabled() { + return defaultWhenDisabled; + } + + /** + * Sets the variant to use when the feature flag is disabled and no specific allocation matches. + * + * @param defaultWhenDisabled the default variant when disabled + * @return the updated Allocation object + */ + public Allocation setDefaultWhenDisabled(String defaultWhenDisabled) { + this.defaultWhenDisabled = defaultWhenDisabled; + return this; + } + + /** + * Gets the list of user-specific allocations for the feature flag. + * + * @return the list of user allocations + */ + public List getUser() { + return user; + } + + /** + * Sets the list of user-specific allocations for the feature flag. + * + * @param user the list of user allocations + * @return the updated Allocation object + */ + public Allocation setUser(List user) { + this.user = user; + return this; + } + + /** + * Gets the list of group-specific allocations for the feature flag. + * + * @return the list of group allocations + */ + public List getGroup() { + return group; + } + + /** + * Sets the list of group-specific allocations for the feature flag. + * + * @param group the list of group allocations + * @return the updated Allocation object + */ + public Allocation setGroups(List group) { + this.group = group; + return this; + } + + /** + * Gets the list of percentile-based allocations for the feature flag. + * + * @return the list of percentile allocations + */ + public List getPercentile() { + return percentile; + } + + /** + * Sets the list of percentile-based allocations for the feature flag. + * + * @param percentile the list of percentile allocations + * @return the updated Allocation object + */ + public Allocation setPercentile(List percentile) { + this.percentile = percentile; + return this; + } + + /** + * Gets the seed value used for randomization in allocation calculations. + * + * @return the seed value for allocation + */ + public String getSeed() { + return seed; + } + + /** + * Sets the seed value used for randomization in allocation calculations. + * + * @param seed the seed value for allocation + * @return the updated Allocation object + */ + public Allocation setSeed(String seed) { + this.seed = seed; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Conditions.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Conditions.java new file mode 100644 index 000000000000..8ec90d82c05b --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Conditions.java @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.models; + +import static com.azure.spring.cloud.feature.management.implementation.FeatureManagementConstants.DEFAULT_REQUIREMENT_TYPE; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** +* Conditions for evaluating a feature flag. This class defines how feature filters +* should be evaluated to determine if a feature flag is enabled for the current request. +* It specifies both the filters to check and how their results should be combined +* (e.g., if all filters must pass or if only one needs to pass). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Conditions { + + /** + * Creates a new instance of the Conditions class. + */ + public Conditions() { + } + + /** + * List of client-side feature filters to evaluate for determining if + * a feature flag is enabled. Each filter context contains parameters + * needed for filter evaluation. + */ + @JsonProperty("client_filters") + private List clientFilters = new ArrayList<>(); + + /** + * Requirement type that determines the logic for combining filter results. + * Default is typically "All" which means all filters must pass for the + * feature to be enabled (logical AND). + */ + @JsonProperty("requirement_type") + private String requirementType = DEFAULT_REQUIREMENT_TYPE; + + /** + * Gets the requirement type that determines how feature filters are evaluated. + * The requirement type specifies whether all filters must evaluate to true (AND logic) + * or if only one filter needs to evaluate to true (OR logic). + * + * @return the requirement type for filter evaluation + */ + public String getRequirementType() { + return requirementType; + } + + /** + * Sets the requirement type that determines how feature filters are evaluated. + * Valid values are typically "All" (AND logic) or "Any" (OR logic), + * where "All" requires all filters to evaluate to true, and "Any" requires + * only one filter to evaluate to true. + * + * @param requirementType the requirement type to set for filter evaluation + * @return the updated Conditions object + */ + public Conditions setRequirementType(String requirementType) { + this.requirementType = requirementType; + return this; + } + + /** + * Gets the list of client-side feature filters that should be evaluated + * to determine if a feature flag is enabled. + * Each filter contains its own parameters and evaluation context. + * + * @return the list of client-side feature filters + */ + public List getClientFilters() { + return clientFilters; + } + + /** + * Sets the list of client-side feature filters to be evaluated + * to determine if a feature flag is enabled. + * Each filter should contain its necessary parameters and context for evaluation. + * + * @param clientFilters the list of client-side feature filters to set + * @return the updated Conditions object + */ + public Conditions setClientFilters(List clientFilters) { + this.clientFilters = clientFilters; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/EvaluationEvent.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/EvaluationEvent.java new file mode 100644 index 000000000000..e6a6596ed2ba --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/EvaluationEvent.java @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +/** + * Event tracking the evaluation of a feature flag. This class captures information about + * a feature flag evaluation, including which user evaluated it, whether it was enabled, + * which variant was returned (if any), and the reason for the variant assignment. + */ +public class EvaluationEvent { + + /** + * The feature flag that was evaluated. This contains information such as the + * feature flag name, its conditions, and other associated metadata. + */ + private final Feature feature; + + /** + * The identifier for the user who evaluated the feature flag. + * This is used for user-targeting scenarios and analytics. + */ + private String user = ""; + + /** + * Indicates whether the feature flag was determined to be enabled + * for this particular evaluation. + */ + private boolean enabled = false; + + /** + * The variant that was assigned during this feature flag evaluation. + * This is used for feature flags that support multiple variants beyond + * simply enabled or disabled. + */ + private Variant variant; + + /** + * The reason why a particular variant was assigned during the evaluation. + * This helps track the decision-making process that led to the variant selection. + */ + private VariantAssignmentReason reason = VariantAssignmentReason.NONE; + + /** + * Creates an Evaluation Event for the given feature. + * This constructor initializes a new evaluation event with the specified feature flag, + * while setting default values for other properties. + * + * @param feature The feature flag that is being evaluated + */ + public EvaluationEvent(Feature feature) { + this.feature = feature; + } + + /** + * Gets the feature flag that was evaluated. + * + * @return the feature flag associated with this evaluation event + */ + public Feature getFeature() { + return feature; + } + + /** + * Gets the identifier of the user who evaluated the feature flag. + * This is useful for targeting specific users with features and for analytics tracking. + * + * @return the user identifier associated with this evaluation + */ + public String getUser() { + return user; + } + + /** + * Sets the identifier of the user who evaluated the feature flag. + * This allows tracking which user accessed a particular feature. + * + * @param user the user identifier to associate with this evaluation + * @return the updated EvaluationEvent instance for method chaining + */ + public EvaluationEvent setUser(String user) { + this.user = user; + return this; + } + + /** + * Determines whether the feature flag was enabled for this evaluation. + * This indicates the result of the evaluation process for the feature flag. + * + * @return true if the feature flag was enabled, false otherwise + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether the feature flag was enabled for this evaluation. + * This allows recording the result of the feature flag evaluation process. + * + * @param enabled true to mark the feature as enabled, false otherwise + * @return the updated EvaluationEvent instance for method chaining + */ + public EvaluationEvent setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Gets the variant that was assigned during this feature flag evaluation. + * This is relevant for feature flags that support multiple variants + * rather than just being enabled or disabled. + * + * @return the variant assigned for this evaluation, or null if no variant was assigned + */ + public Variant getVariant() { + return variant; + } + + /** + * Sets the variant that was assigned during this feature flag evaluation. + * This allows recording which specific variant of a feature flag was selected + * when multiple variants are supported. + * + * @param variant the variant to associate with this evaluation + * @return the updated EvaluationEvent instance for method chaining + */ + public EvaluationEvent setVariant(Variant variant) { + this.variant = variant; + return this; + } + + /** + * Gets the reason why a particular variant was assigned during this evaluation. + * This helps track the decision-making process behind the variant selection + * and can be useful for debugging and analytics. + * + * @return the reason for the variant assignment + */ + public VariantAssignmentReason getReason() { + return reason; + } + + /** + * Sets the reason why a particular variant was assigned during this evaluation. + * This documents the logic behind why a specific variant was chosen, which + * can be useful for troubleshooting and analytics purposes. + * + * @param reason the reason for the variant assignment + * @return the updated EvaluationEvent instance for method chaining + */ + public EvaluationEvent setReason(VariantAssignmentReason reason) { + this.reason = reason; + return this; + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Feature.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Feature.java new file mode 100644 index 000000000000..2beb3aef3be7 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Feature.java @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.models; + +import java.util.List; + +import org.springframework.lang.NonNull; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * This class represents a complete feature flag definition including its identity, + * description, enabled state, conditions for evaluation, variant allocation, + * and variant references for feature flags that support multiple variations. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Feature { + + /** + * Creates a new instance of the Feature class. + */ + public Feature() { + } + + /** + * The unique identifier for this feature flag. + * This represents the name of the feature as stored in Azure App Configuration. + */ + @JsonProperty("id") + private String id; + + /** + * A human-readable description of the feature flag and its purpose. + * This provides context about what the feature flag controls. + */ + @JsonProperty("description") + private String description; + + /** + * The enabled state of the feature flag. + * When true, the feature is enabled by default, though conditions may still apply. + * When false, the feature is disabled by default. + */ + @JsonProperty("enabled") + private boolean enabled; + + /** + * The set of conditions that determine when this feature flag should be enabled. + * These conditions contain feature filters and their evaluation logic. + */ + @JsonProperty("conditions") + @NonNull + private Conditions conditions = new Conditions(); + + /** + * The allocation strategy for this feature flag when using variants. + * Determines how users or requests are assigned to specific variants. + */ + @JsonProperty("allocation") + private Allocation allocation; + + /** + * The list of variant references that define the different variations + * of this feature flag when it supports multiple implementations. + */ + @JsonProperty("variants") + private List variants; + + /** + * The telemetry configuration for this feature flag. + * Controls whether events related to this feature are logged + * and what additional metadata is included. + */ + @JsonProperty("telemetry") + private Telemetry telemetry = new Telemetry(); + + /** + * Gets the unique identifier of this feature flag. + * + * @return the feature flag's identifier + */ + public String getId() { + return id; + } + + /** + * Sets the unique identifier of this feature flag. + * + * @param id the feature flag identifier to set + * @return the updated Feature instance for method chaining + */ + public Feature setId(String id) { + this.id = id; + return this; + } + + /** + * Determines whether this feature flag is enabled by default. + * Even when enabled, the flag may still be controlled by conditions and filters. + * + * @return true if the feature flag is enabled, false otherwise + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether this feature flag is enabled by default. + * + * @param enabled true to enable the feature flag, false to disable it + * @return the updated Feature instance for method chaining + */ + public Feature setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Gets the human-readable description of this feature flag. + * + * @return the description of the feature flag + */ + public String getDescription() { + return description; + } + + /** + * Sets the human-readable description of this feature flag. + * This provides context about what the feature flag controls. + * + * @param description the description to set for the feature flag + * @return the updated Feature instance for method chaining + */ + public Feature setDescription(String description) { + this.description = description; + return this; + } + + /** + * Gets the set of conditions that determine when this feature flag should be enabled. + * The conditions contain feature filters and their evaluation logic. + * + * @return the conditions for feature flag evaluation + */ + public Conditions getConditions() { + return conditions; + } + + /** + * Sets the conditions that determine when this feature flag should be enabled. + * These conditions define the feature filters and logic for evaluating + * whether the feature should be enabled for a specific request. + * + * @param conditions the conditions to set for feature flag evaluation + * @return the updated Feature instance for method chaining + */ + public Feature setConditions(Conditions conditions) { + this.conditions = conditions; + return this; + } + + /** + * Gets the allocation strategy for this feature flag when using variants. + * The allocation defines how users or requests are assigned to specific variants + * through mechanisms like user targeting, percentile rollout, or group assignment. + * + * @return the allocation strategy for variant assignment + */ + public Allocation getAllocation() { + return allocation; + } + + /** + * Sets the allocation strategy for this feature flag when using variants. + * The allocation controls how users or requests are assigned to specific + * variants through user targeting, percentile rollout, or group assignment. + * + * @param allocation the allocation strategy to set for variant assignment + * @return the updated Feature instance for method chaining + */ + public Feature setAllocation(Allocation allocation) { + this.allocation = allocation; + return this; + } + + /** + * Gets the list of variant references that define the different variations + * of this feature flag. These variants represent different implementations + * or configurations that can be assigned to users when the feature is enabled. + * + * @return the list of variant references for this feature flag + */ + public List getVariants() { + return variants; + } + + /** + * Sets the list of variant references that define the different variations + * of this feature flag. These variants represent different implementations + * or configurations that can be dynamically assigned when the feature is enabled. + * + * @param variants the list of variant references to set for this feature flag + * @return the updated Feature instance for method chaining + */ + public Feature setVariants(List variants) { + this.variants = variants; + return this; + } + + /** + * Gets the telemetry configuration for this feature flag. + * The telemetry configuration controls whether events related to this + * feature flag should be logged and what additional metadata should be included. + * + * @return the telemetry configuration for this feature flag + */ + public Telemetry getTelemetry() { + return telemetry; + } + + /** + * Sets the telemetry configuration for this feature flag. + * This controls whether events related to this feature flag + * should be logged and what additional metadata should be included. + * + * @param telemetry the telemetry configuration to set for this feature flag + * @return the updated Feature instance for method chaining + */ + public Feature setTelemetry(Telemetry telemetry) { + this.telemetry = telemetry; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java index 449c21901dd3..483d95a0cdcc 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java @@ -2,12 +2,12 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.models; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - import java.util.HashMap; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Context passed into Feature Filters used for evaluation. */ @@ -38,9 +38,11 @@ public String getName() { /** * Set the name * @param name the name to set + * @return FeatureFilterEvaluationContext */ - public void setName(String name) { + public FeatureFilterEvaluationContext setName(String name) { this.name = name; + return this; } /** @@ -58,9 +60,11 @@ public Map getParameters() { /** * Set the parameters * @param parameters the parameters to set + * @return FeatureFilterEvaluationContext */ - public void setParameters(Map parameters) { + public FeatureFilterEvaluationContext setParameters(Map parameters) { this.parameters = parameters; + return this; } /** @@ -74,9 +78,11 @@ public String getFeatureName() { /** * Set the featureName * @param featureName the featureName to set + * @return FeatureFilterEvaluationContext */ - public void setFeatureName(String featureName) { + public FeatureFilterEvaluationContext setFeatureName(String featureName) { this.featureName = featureName; + return this; } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureManagementException.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureManagementException.java index 8204b27cabfa..c4312595e9f0 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureManagementException.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureManagementException.java @@ -20,7 +20,7 @@ public final class FeatureManagementException extends RuntimeException { * * @param message the error message. */ - FeatureManagementException(String message) { + public FeatureManagementException(String message) { super(message); this.message = message; } @@ -31,7 +31,7 @@ public final class FeatureManagementException extends RuntimeException { * @param message the error message. * @param cause the original error thrown, typically of NoSuchBeanDefinitionException type. */ - FeatureManagementException(String message, Throwable cause) { + public FeatureManagementException(String message, Throwable cause) { super(message, cause); this.message = message; } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/GroupAllocation.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/GroupAllocation.java new file mode 100644 index 000000000000..221faf3ce65c --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/GroupAllocation.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import java.util.List; + +/** + * Group allocation of a variant for feature flag targeting. This class defines how specific + * user groups are assigned to a particular variant of a feature. It contains a reference + * to a variant name and a list of group identifiers that should receive this variant + * when the feature flag is evaluated. + */ +public class GroupAllocation { + + /** + * Creates a new instance of the GroupAllocation class. + */ + public GroupAllocation() { + } + + /** + * The name of the variant that will be assigned to the specified groups. + * This corresponds to a variant defined in the feature flag configuration. + */ + private String variant; + + /** + * The list of group identifiers that should receive this variant. + * When a user belongs to any of these groups, they will be assigned + * this variant during feature flag evaluation. + */ + private List groups; + + /** + * Gets the name of the variant that is assigned to the specified groups. + * + * @return the variant name for this group allocation + */ + public String getVariant() { + return variant; + } + + /** + * Sets the name of the variant that should be assigned to the specified groups. + * This should match a valid variant name defined in the feature flag configuration. + * + * @param variant the variant name to assign to the groups + */ + public void setVariant(String variant) { + this.variant = variant; + } + + /** + * Gets the list of group identifiers that should receive this variant. + * When a user belongs to any of these groups, they will be assigned + * this variant during feature flag evaluation. + * + * @return the list of group identifiers for this allocation + */ + public List getGroups() { + return groups; + } + + /** + * Sets the list of group identifiers that should receive this variant. + * When a user belongs to any of these groups, they will be assigned + * this variant during feature flag evaluation. + * + * @param groups the list of group identifiers to associate with this variant + */ + public void setGroups(List groups) { + this.groups = groups; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/PercentileAllocation.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/PercentileAllocation.java new file mode 100644 index 000000000000..610947aaa230 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/PercentileAllocation.java @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Percentile allocation of a variant for feature flag targeting. This class defines how users + * are assigned to a specific variant based on a percentage range. It allows for gradual rollout + * of features to a specific percentage of users by defining a variant name and a numeric range + * (from-to) that determines which portion of users receive this variant when the feature is + * evaluated. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PercentileAllocation { + + /** + * Creates a new instance of the PercentileAllocation class. + */ + public PercentileAllocation() { + } + + /** + * The name of the variant that will be assigned to users within the specified + * percentage range. This corresponds to a variant defined in the feature flag + * configuration. + */ + private String variant; + + /** + * The lower bound (inclusive) of the percentage range for this variant allocation. + * Users with a computed hash value >= this percentage will be assigned to this + * variant. The value should be between 0.0 and 100.0. + */ + private Double from; + + /** + * The upper bound of the percentage range for this variant allocation. + *

+ * This value is exclusive (users with computed hash values strictly less than this value will be assigned to this variant), + * except when set to 100, where it becomes inclusive. The value should be between 0.0 and 100.0 and greater than the + * 'from' value. + *

+ */ + private Double to; + + /** + * Gets the name of the variant that is assigned to users within the specified + * percentage range. + * + * @return the variant name for this percentile allocation + */ + public String getVariant() { + return variant; + } + + /** + * Sets the name of the variant that should be assigned to users within the + * specified percentage range. This should match a valid variant name defined + * in the feature flag configuration. + * + * @param variant the variant name to assign for this percentile range + * @return the updated PercentileAllocation instance for method chaining + */ + public PercentileAllocation setVariant(String variant) { + this.variant = variant; + return this; + } + + /** + * Gets the lower bound of the percentage range for this variant allocation. + * This represents the starting point of the percentile range where users will be + * assigned to this variant. The value is inclusive and typically between 0.0 and 100.0. + * + * @return the lower bound percentage value for this allocation + */ + public Double getFrom() { + return from; + } + + /** + * Sets the lower bound of the percentage range for this variant allocation. + * This value is inclusive (users with computed hash values greater than or equal to this + * value will be assigned to this variant). The value should be between 0.0 and 100.0 + * and less than the 'to' value. + * + * @param from the lower bound percentage value to set for this allocation + * @return the updated PercentileAllocation instance for method chaining + */ + public PercentileAllocation setFrom(Double from) { + this.from = from; + return this; + } + + + /** + * Gets the upper bound of the percentage range for this variant allocation. + *

+ * This value is exclusive (users with computed hash values strictly less than this value will be assigned to this variant), + * except when set to 100, where it becomes inclusive. The value should be between 0.0 and 100.0 and greater than the + * 'from' value. + *

+ * + * @return the upper bound percentage value for this allocation + */ + public Double getTo() { + return to; + } + + /** + * Sets the upper bound of the percentage range for this variant allocation. + *

+ * This value is exclusive (users with computed hash values strictly less than this value will be assigned to this variant), + * except when set to 100, where it becomes inclusive. The value should be between 0.0 and 100.0 and greater than the + * 'from' value. + *

+ * + * @param to the upper bound percentage value to set for this allocation + * @return the updated PercentileAllocation instance for method chaining + */ + public PercentileAllocation setTo(Double to) { + this.to = to; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Telemetry.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Telemetry.java new file mode 100644 index 000000000000..26c9ab88bb6f --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Telemetry.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Represents telemetry configuration for feature management. + * This class controls whether telemetry is enabled and provides additional + * metadata for customizing telemetry information. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Telemetry { + + private boolean enabled; + + private Map metadata; + + /** + * Creates a new instance of the Telemetry class. + * By default, telemetry is enabled and no metadata is set. + */ + public Telemetry() { + this.enabled = false; // Default to disabled + this.metadata = new HashMap<>(); + } + + /** + * Gets whether telemetry is enabled. + * + * @return {@code true} if telemetry is enabled, {@code false} otherwise + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether telemetry is enabled. + * + * @param enabled {@code true} to enable telemetry, {@code false} to disable it + * @return The updated Telemetry instance for method chaining + */ + public Telemetry setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Gets the metadata associated with telemetry. + * The metadata contains key-value pairs that provide additional context + * for telemetry events. + * + * @return A map of metadata key-value pairs + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets the metadata associated with telemetry. + * + * @param metadata A map of key-value pairs to provide additional context for telemetry events + * @return The updated Telemetry instance for method chaining + */ + public Telemetry setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/UserAllocation.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/UserAllocation.java new file mode 100644 index 000000000000..cbd87308cca8 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/UserAllocation.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import java.util.List; + +/** + * User allocation of a variant for feature flag targeting. This class defines how specific + * users are assigned to a particular variant of a feature. It contains a reference to + * a variant name and a list of user identifiers that should receive this variant + * when the feature flag is evaluated. This enables targeted feature rollout to specific users. + */ +public class UserAllocation { + + /** + * Creates a new instance of the UserAllocation class. + */ + public UserAllocation() { + } + + /** + * The name of the variant that will be assigned to the specified users. + * This corresponds to a variant defined in the feature flag configuration. + */ + private String variant; + + /** + * The list of user identifiers that should receive this variant. + * When a user matches any of these identifiers, they will be assigned + * this variant during feature flag evaluation. + */ + private List users; + + /** + * Gets the name of the variant that is assigned to the specified users. + * + * @return the variant name for this user allocation + */ + public String getVariant() { + return variant; + } + + /** + * Sets the name of the variant that should be assigned to the specified users. + * This should match a valid variant name defined in the feature flag configuration. + * + * @param variant the variant name to assign to the users + */ + public void setVariant(String variant) { + this.variant = variant; + } + + /** + * Gets the list of user identifiers that should receive this variant. + * These identifiers are typically usernames, email addresses, or user IDs + * that uniquely identify users in the system. + * + * @return the list of user identifiers for this allocation + */ + public List getUsers() { + return users; + } + + /** + * Sets the list of user identifiers that should receive this variant. + * When a user matches any of these identifiers, they will be assigned + * this variant during feature flag evaluation. + * + * @param users the list of user identifiers to associate with this variant + */ + public void setUsers(List users) { + this.users = users; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Variant.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Variant.java new file mode 100644 index 000000000000..ade2c831cfbb --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/Variant.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +/** + * Represents a feature flag variant in Azure Feature Management. + * This class is the return object of the getVariant method and contains both + * the name of the variant and its corresponding value instance. Variants allow + * for multiple implementations of a feature beyond simple on/off states. + */ +public class Variant { + + /** + * The name of the variant that identifies this specific variation of the feature. + * This is used to match against variant references in feature flag configurations. + */ + private String name; + + /** + * The actual implementation value of the variant. This can be any type of object + * that represents the variant's behavior or configuration. + */ + private Object value; + + /** + * Creates a new Variant with the specified name and value. + * + * @param name The name that identifies this variant in feature flag configurations + * @param value The implementation value or configuration for this variant + */ + public Variant(String name, Object value) { + this.name = name; + this.value = value; + } + + /** + * Gets the name of this variant. + * + * @return the name that identifies this variant + */ + public String getName() { + return name; + } + + /** + * Sets the name of this variant. + * + * @param name the name to identify this variant + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the implementation value of this variant. + * This can be any object that represents the variant's behavior or configuration. + * + * @return the implementation value for this variant + */ + public Object getValue() { + return value; + } + + /** + * Sets the implementation value of this variant. + * + * @param value the implementation value to set for this variant + */ + public void setValue(Object value) { + this.value = value; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantAssignmentReason.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantAssignmentReason.java new file mode 100644 index 000000000000..dc030a2c71e8 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantAssignmentReason.java @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.models; + +/** + * The reason why a given boolean/variant was returned when calling isEnabled/getVariant. This enum represents the + * different filtering mechanisms that determined the feature flag state. + */ +public enum VariantAssignmentReason { + + /** + * Indicates no specific reason was assigned for the feature flag evaluation. + */ + NONE("None"), + + /** + * Indicates the feature flag was evaluated based on the default value when the flag is disabled. + */ + DEFAULT_WHEN_DISABLED("DefaultWhenDisabled"), + + /** + * Indicates the feature flag was evaluated based on the default value when the flag is enabled. + */ + DEFAULT_WHEN_ENABLED("DefaultWhenEnabled"), + + /** + * Indicates the feature flag was evaluated based on user targeting criteria. + */ + USER("User"), + + /** + * Indicates the feature flag was evaluated based on group targeting criteria. + */ + GROUP("Group"), + + /** + * Indicates the feature flag was evaluated based on percentile targeting criteria. + */ + PERCENTILE("Percentile"); + + private final String type; + + /** + * Creates a new instance of the VariantAssignmentReason enum with the specified type. + * + * @param type The string representation of the variant assignment reason + */ + VariantAssignmentReason(final String type) { + this.type = type; + } + + /** + * Gets the string representation of this variant assignment reason. + * + * @return the string representation of this variant assignment reason + */ + public String getType() { + return type; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantReference.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantReference.java new file mode 100644 index 000000000000..845f41f16096 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/VariantReference.java @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Reference to a Variant containing the Variant name, configuration value, and possible status override. + * This class provides a way to reference variants in feature flag configurations and their associated values. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class VariantReference { + + /** + * The name of the variant reference. + */ + private String name; + + /** + * The configuration value associated with this variant reference. + * This can be any type of object depending on the feature configuration. + */ + private Object configurationValue; + + /** + * The status override that can be used to override the default status of a feature flag. + */ + private String statusOverride; + + /** + * Creates a new instance of the VariantReference class. + */ + public VariantReference() { + } + + /** + * Gets the name of this variant reference. + * + * @return the name of the variant reference + */ + public String getName() { + return name; + } + + /** + * Sets the name of this variant reference. + * + * @param name the name to set for this variant reference + * @return the updated VariantReference instance for method chaining + */ + public VariantReference setName(String name) { + this.name = name; + return this; + } + + /** + * Gets the configuration value associated with this variant reference. + * + * @return the configuration value of this variant reference + */ + public Object getConfigurationValue() { + return configurationValue; + } + + /** + * Sets the configuration value for this variant reference. + * + * @param configurationValue the configuration value to set for this variant reference + * @return the updated VariantReference instance for method chaining + */ + public VariantReference setConfigurationValue(Object configurationValue) { + this.configurationValue = configurationValue; + return this; + } + + /** + * Gets the status override associated with this variant reference. + * + * @return the status override of this variant reference + */ + public String getStatusOverride() { + return statusOverride; + } + + /** + * Sets the status override for this variant reference. + * + * @param statusOverride the status override to set for this variant reference + * @return the updated VariantReference instance for method chaining + */ + public VariantReference setStatusOverride(String statusOverride) { + this.statusOverride = statusOverride; + return this; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisher.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisher.java new file mode 100644 index 000000000000..39b8248d16f2 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisher.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.telemetry; + +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.DEFAULT_WHEN_ENABLED; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.ENABLED; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.EVALUATION_EVENT_VERSION; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.EVENT_NAME; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.FEATURE_NAME; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.REASON; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VARIANT; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VARIANT_ASSIGNMENT_PERCENTAGE; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VERSION; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import com.azure.spring.cloud.feature.management.models.EvaluationEvent; +import com.azure.spring.cloud.feature.management.models.Feature; +import com.azure.spring.cloud.feature.management.models.PercentileAllocation; +import com.azure.spring.cloud.feature.management.models.Variant; +import com.azure.spring.cloud.feature.management.models.VariantAssignmentReason; + +/** + * Telemetry publisher that logs feature evaluation events using SLF4J. This + * class implements the TelemetryPublisher interface and is responsible for + * publishing telemetry events related to feature evaluations. It uses SLF4J for + * logging the events, which allows for easy integration with various logging + * frameworks. It uses MDC (Mapped Diagnostic Context) to add contextual + * information to the logs, such as feature name, enabled status, variant name, + * and reason for the evaluation. + */ +public class LoggerTelemetryPublisher implements TelemetryPublisher { + + private static final Logger LOGGER = LoggerFactory.getLogger(LoggerTelemetryPublisher.class); + + /** + * Constructor for LoggerTelemetryPublisher. + */ + public LoggerTelemetryPublisher() { + + } + + /** + * Publishes telemetry events related to feature evaluations. It logs the + * evaluation event using SLF4J, adding contextual information to the logs using + * MDC. + * + * @param evaluationEvent The evaluation event to be published. + */ + public void publishTelemetry(EvaluationEvent evaluationEvent) { + if (evaluationEvent == null || evaluationEvent.getFeature() == null) { + return; + } + + Feature feature = evaluationEvent.getFeature(); + + Variant variant = evaluationEvent.getVariant(); + + Map eventProperties = new HashMap<>(Map.of( + FEATURE_NAME, feature.getId(), + ENABLED, String.valueOf(evaluationEvent.isEnabled()), + REASON, evaluationEvent.getReason().getType(), + VERSION, EVALUATION_EVENT_VERSION)); + + if (variant != null) { + eventProperties.put(VARIANT, variant.getName()); + } + + if (evaluationEvent.getReason() == VariantAssignmentReason.DEFAULT_WHEN_ENABLED) { + // Calculate the amount of unallocated variant percentage. This is therefore the amount allocated to the default when enabled variant. + eventProperties.put(VARIANT_ASSIGNMENT_PERCENTAGE, "100"); + if (feature.getAllocation() != null && feature.getAllocation().getPercentile() != null) { + double allocationPercentage = 0.0; + for (PercentileAllocation allocation : feature.getAllocation().getPercentile()) { + if (allocation.getTo() != null && allocation.getFrom() != null) { + allocationPercentage += allocation.getTo() - allocation.getFrom(); + } + } + + eventProperties.put(VARIANT_ASSIGNMENT_PERCENTAGE, String.valueOf(100 - allocationPercentage)); + } + } else if (evaluationEvent.getReason() == VariantAssignmentReason.PERCENTILE) { + if (feature.getAllocation() != null && feature.getAllocation().getPercentile() != null) { + eventProperties.put(VARIANT_ASSIGNMENT_PERCENTAGE, String.valueOf(feature.getAllocation().getPercentile().stream() + // Filter out null values and calculate the sum of the allocation percentages + // for the specific variant. + .filter(percentile -> percentile.getVariant() != null && variant != null + && percentile.getVariant().equals(variant.getName())) + .filter(allocation -> allocation.getTo() != null && allocation.getFrom() != null) + // Calculate the percentage of the variant allocation. + .mapToDouble(allocation -> allocation.getTo() - allocation.getFrom()) + .sum())); + } + } + + if (evaluationEvent.getFeature().getAllocation() != null + && evaluationEvent.getFeature().getAllocation().getDefaultWhenEnabled() != null) { + eventProperties.put(DEFAULT_WHEN_ENABLED, + evaluationEvent.getFeature().getAllocation().getDefaultWhenEnabled()); + } + + // Set the key-value pair in the MDC context for logging + for (Map.Entry entry : eventProperties.entrySet()) { + MDC.put(entry.getKey(), entry.getValue()); + } + MDC.put(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY, EVENT_NAME); + LOGGER.info(EVENT_NAME); + + // Remove the key-value pairs from the MDC context after logging. + MDC.remove(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY); + for (String key : eventProperties.keySet()) { + MDC.remove(key); + } + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryConstants.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryConstants.java new file mode 100644 index 000000000000..c1b909fb18a7 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryConstants.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.telemetry; + +/** + * This class contains constants used for telemetry events related to feature + * evaluations. These constants are used to define the structure and content of + * telemetry events, including event names, property names, and versioning + * information. + */ +public final class TelemetryConstants { + + static final String EVENT_NAME = "FeatureEvaluation"; + + static final String FEATURE_NAME = "FeatureName"; + + static final String ENABLED = "Enabled"; + + static final String VARIANT = "Variant"; + + static final String REASON = "VariantAssignmentReason"; + + static final String DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"; + + static final String VERSION = "Version"; + + static final String VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage"; + + static final String EVALUATION_EVENT_VERSION = "1.1.0"; + + static final String APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY = "microsoft.custom_event.name"; + + /** Private constructor to prevent instantiation. */ + private TelemetryConstants() { + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryPublisher.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryPublisher.java new file mode 100644 index 000000000000..bd324507aae4 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/TelemetryPublisher.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.telemetry; + +import com.azure.spring.cloud.feature.management.models.EvaluationEvent; + +/** + * TelemetryPublisher is an interface for publishing telemetry events. + * Implementations of this interface can be used to send telemetry data to various + * telemetry services or systems. + */ +public interface TelemetryPublisher { + + /** + * Publishes an evaluation event to the telemetry system. + * + * @param evaluationEvent The evaluation event to be published. + */ + void publishTelemetry(EvaluationEvent evaluationEvent); + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/package-info.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/package-info.java new file mode 100644 index 000000000000..2e07b8d70f7a --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/telemetry/package-info.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/** + * Package containing classes for telemetry related to feature flags. + */ + +package com.azure.spring.cloud.feature.management.telemetry; diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ClientSideFeatureManagementPropertiesTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ClientSideFeatureManagementPropertiesTest.java deleted file mode 100644 index fa0bd49a0457..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ClientSideFeatureManagementPropertiesTest.java +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.spring.cloud.feature.management; - -import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; -import com.azure.spring.cloud.feature.management.implementation.models.Feature; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@ExtendWith(SpringExtension.class) -@EnableConfigurationProperties(value = FeatureManagementProperties.class) -@SpringBootTest(classes = { SpringBootTest.class }) -@ActiveProfiles("client") -public class ClientSideFeatureManagementPropertiesTest { - @Autowired - private FeatureManagementProperties clientSideProperties; - - @Test - void onOffMapTest() { - assertTrue(clientSideProperties.getOnOff().get("gamma")); - } - - @Test - void featureManagementTest() { - final Feature alphaFeatureItem = clientSideProperties.getFeatureManagement().get("alpha"); - assertEquals(alphaFeatureItem.getKey(), "alpha"); - assertEquals(alphaFeatureItem.getEnabledFor().size(), 1); - assertEquals(alphaFeatureItem.getEnabledFor().get(0).getName(), "randomFilter"); - - final Feature betaFeatureItem = clientSideProperties.getFeatureManagement().get("beta"); - assertEquals(betaFeatureItem.getKey(), "beta"); - assertEquals(betaFeatureItem.getEnabledFor().size(), 1); - assertEquals(betaFeatureItem.getEnabledFor().get(0).getName(), "timeWindowFilter"); - } -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementConfigurationTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementConfigurationTest.java new file mode 100644 index 000000000000..388a03aca3ab --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementConfigurationTest.java @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import com.azure.spring.cloud.feature.management.filters.PercentageFilter; +import com.azure.spring.cloud.feature.management.filters.TargetingFilter; +import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; +import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; + +public class FeatureManagementConfigurationTest { + + private static final ApplicationContextRunner CONTEXT_RUNNER = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeatureManagementConfiguration.class)); + + private static final ApplicationContextRunner CONTEXT_RUNNER_OPTIONS = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(FeatureManagementTestConfigurations.class, FeatureManagementConfiguration.class)); + + @Test + public void featureManagementTest() { + CONTEXT_RUNNER.run(context -> { + assertThat(context).hasSingleBean(FeatureManager.class); + assertThat(context).doesNotHaveBean(TargetingEvaluationOptions.class); + assertThat(context).hasSingleBean(TimeWindowFilter.class); + assertThat(context).hasSingleBean(PercentageFilter.class); + assertThat(context).doesNotHaveBean(TargetingFilter.class); + }); + } + + @Test + public void featureManagementWithEvaluationOptionsTest() { + CONTEXT_RUNNER_OPTIONS.run(context -> { + assertThat(context).hasSingleBean(TargetingEvaluationOptions.class); + assertThat(context).hasSingleBean(FeatureManager.class); + assertThat(context).hasSingleBean(TimeWindowFilter.class); + assertThat(context).hasSingleBean(PercentageFilter.class); + assertThat(context).doesNotHaveBean(TargetingFilter.class); + }); + } + + @Test + public void featureManagementWithAccessorTest() { + CONTEXT_RUNNER.withUserConfiguration(TargetingContextAccessorTestConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(FeatureManager.class); + assertThat(context).hasSingleBean(TimeWindowFilter.class); + assertThat(context).hasSingleBean(PercentageFilter.class); + assertThat(context).hasSingleBean(TargetingContextAccessor.class); + assertThat(context).hasSingleBean(TargetingFilter.class); + }); + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementTestConfigurations.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementTestConfigurations.java new file mode 100644 index 000000000000..6fc172aab6c9 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagementTestConfigurations.java @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; +import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; + +@Configuration +@EnableConfigurationProperties({ FeatureManagementConfigProperties.class, FeatureManagementProperties.class }) +class FeatureManagementTestConfigurations { + + @Bean + public TargetingEvaluationOptions targetingEvaluationOptions() { + TargetingEvaluationOptions options = new TargetingEvaluationOptions(); + options.setIgnoreCase(true); + return options; + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerTest.java index 396be785dc14..74b806494a72 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerTest.java @@ -3,6 +3,7 @@ package com.azure.spring.cloud.feature.management; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,7 +15,6 @@ import java.util.List; import java.util.concurrent.ExecutionException; -import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,18 +25,23 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; +import com.azure.spring.cloud.feature.management.filters.ContextualFeatureFilter; import com.azure.spring.cloud.feature.management.filters.FeatureFilter; +import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; -import com.azure.spring.cloud.feature.management.implementation.TestConfiguration; -import com.azure.spring.cloud.feature.management.implementation.models.Feature; +import com.azure.spring.cloud.feature.management.models.Conditions; +import com.azure.spring.cloud.feature.management.models.EvaluationEvent; +import com.azure.spring.cloud.feature.management.models.Feature; import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; import com.azure.spring.cloud.feature.management.models.FilterNotFoundException; +import com.azure.spring.cloud.feature.management.models.Telemetry; +import com.azure.spring.cloud.feature.management.telemetry.TelemetryPublisher; /** * Unit tests for FeatureManager. */ -@SpringBootTest(classes = { TestConfiguration.class, SpringBootTest.class }) +@SpringBootTest(classes = { FeatureManagementTestConfigurations.class, SpringBootTest.class }) public class FeatureManagerTest { private FeatureManager featureManager; @@ -50,12 +55,18 @@ public class FeatureManagerTest { @Mock private FeatureManagementProperties featureManagementPropertiesMock; + @Mock + private TelemetryPublisher telemetryPublisher; + + @Mock + private FeatureFilter filterMock; + @BeforeEach public void setup() { MockitoAnnotations.openMocks(this); when(properties.isFailFast()).thenReturn(true); - featureManager = new FeatureManager(context, featureManagementPropertiesMock, properties); + featureManager = new FeatureManager(context, featureManagementPropertiesMock, properties, null, null, telemetryPublisher); } @AfterEach @@ -66,56 +77,73 @@ public void cleanup() throws Exception { @Test public void isEnabledFeatureNotFound() { assertFalse(featureManager.isEnabledAsync("Non Existed Feature").block()); - verify(featureManagementPropertiesMock, times(2)).getOnOff(); - verify(featureManagementPropertiesMock, times(2)).getFeatureManagement(); + verify(featureManagementPropertiesMock, times(1)).getFeatureFlags(); } @Test public void isEnabledFeatureOff() { - HashMap features = new HashMap<>(); - features.put("Off", false); - when(featureManagementPropertiesMock.getOnOff()).thenReturn(features); + List features = List.of(new Feature().setId("Off").setEnabled(false)); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); assertFalse(featureManager.isEnabledAsync("Off").block()); - verify(featureManagementPropertiesMock, times(2)).getOnOff(); - verify(featureManagementPropertiesMock, times(1)).getFeatureManagement(); + verify(featureManagementPropertiesMock, times(1)).getFeatureFlags(); } @Test public void isEnabledOnBoolean() throws InterruptedException, ExecutionException, FilterNotFoundException { - HashMap features = new HashMap<>(); - features.put("On", true); - when(featureManagementPropertiesMock.getOnOff()).thenReturn(features); + List features = List.of(new Feature().setId("On").setEnabled(true)); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + assertTrue(featureManager.isEnabled("On")); + assertTrue(featureManager.isEnabledAsync("On").block()); + verify(featureManagementPropertiesMock, times(2)).getFeatureFlags(); + } + + @Test + public void isEnabledOnContext() throws InterruptedException, ExecutionException, FilterNotFoundException { + List features = List.of(new Feature().setId("On").setEnabled(true).setConditions(new Conditions() + .setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOnContext"))))); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.matches("AlwaysOnContext"))).thenReturn(new AlwaysOnContextFilter()); + + assertFalse(featureManager.isEnabled("On", false)); + assertFalse(featureManager.isEnabledAsync("On", false).block()); + } + + @Test + public void validateFeatureName() { + FeatureFilterEvaluationContext filterContext = new FeatureFilterEvaluationContext().setName("AlwaysOnContext"); + List features = List.of(new Feature().setId("On").setEnabled(true).setConditions(new Conditions() + .setClientFilters(List.of(filterContext)))); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOnContext"))).thenReturn(filterMock); + + when(filterMock.evaluate(Mockito.eq(filterContext))).thenReturn(true); + + assertTrue(featureManager.isEnabled("On")); + assertEquals(filterContext.getFeatureName(), "On"); assertTrue(featureManager.isEnabledAsync("On").block()); - verify(featureManagementPropertiesMock, times(2)).getOnOff(); - verify(featureManagementPropertiesMock, times(1)).getFeatureManagement(); + assertEquals(filterContext.getFeatureName(), "On"); } @Test public void isEnabledFeatureHasNoFilters() { - HashMap features = new HashMap<>(); - Feature noFilters = new Feature(); - noFilters.setKey("NoFilters"); - noFilters.setEnabledFor(new HashMap()); - features.put("NoFilters", noFilters); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + List features = List.of(new Feature().setId("NoFilters").setEnabled(false) + .setConditions(new Conditions().setClientFilters(List.of()))); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); assertFalse(featureManager.isEnabledAsync("NoFilters").block()); } @Test - public void isEnabledON() throws InterruptedException, ExecutionException, FilterNotFoundException { - HashMap features = new HashMap<>(); - Feature onFeature = new Feature(); - onFeature.setKey("On"); - HashMap filters = new HashMap(); - FeatureFilterEvaluationContext alwaysOn = new FeatureFilterEvaluationContext(); - alwaysOn.setName("AlwaysOn"); - filters.put(0, alwaysOn); - onFeature.setEnabledFor(filters); - features.put("On", onFeature); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + public void isEnabledOn() throws InterruptedException, ExecutionException, FilterNotFoundException { + List features = List.of(new Feature().setId("On").setEnabled(true).setConditions( + new Conditions().setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOn"))))); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); @@ -124,16 +152,10 @@ public void isEnabledON() throws InterruptedException, ExecutionException, Filte @Test public void noFilter() throws FilterNotFoundException { - HashMap features = new HashMap<>(); - Feature onFeature = new Feature(); - onFeature.setKey("Off"); - HashMap filters = new HashMap(); - FeatureFilterEvaluationContext alwaysOn = new FeatureFilterEvaluationContext(); - alwaysOn.setName("AlwaysOff"); - filters.put(0, alwaysOn); - onFeature.setEnabledFor(filters); - features.put("Off", onFeature); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + List features = List.of(new Feature().setId("Off").setEnabled(true).setConditions( + new Conditions().setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOff"))))); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); when(context.getBean(Mockito.matches("AlwaysOff"))).thenThrow(new NoSuchBeanDefinitionException("")); @@ -144,18 +166,12 @@ public void noFilter() throws FilterNotFoundException { @Test public void allOn() { - HashMap features = new HashMap<>(); - Feature onFeature = new Feature(); - onFeature.setKey("On"); - HashMap filters = new HashMap(); - FeatureFilterEvaluationContext alwaysOn = new FeatureFilterEvaluationContext(); - alwaysOn.setName("AlwaysOn"); - filters.put(0, alwaysOn); - filters.put(1, alwaysOn); - onFeature.setEnabledFor(filters); - onFeature.setRequirementType("All"); - features.put("On", onFeature); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + List features = List.of(new Feature().setId("On").setEnabled(true) + .setConditions(new Conditions().setRequirementType("All") + .setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setName("AlwaysOn"))))); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) .thenReturn(new AlwaysOnFilter()); @@ -165,57 +181,43 @@ public void allOn() { @Test public void oneOffAny() { - HashMap features = new HashMap<>(); - Feature onFeature = new Feature(); - onFeature.setKey("On"); - HashMap filters = new HashMap(); - FeatureFilterEvaluationContext alwaysOn = new FeatureFilterEvaluationContext(); - alwaysOn.setName("AlwaysOn"); - filters.put(0, alwaysOn); - filters.put(1, alwaysOn); - onFeature.setEnabledFor(filters); - onFeature.setRequirementType("Any"); - features.put("On", onFeature); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + List features = List.of(new Feature().setId("On").setEnabled(true) + .setConditions(new Conditions().setRequirementType("Any") + .setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setName("AlwaysOff"))))); - when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) - .thenReturn(new AlwaysOffFilter()); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + when(context.getBean(Mockito.matches("AlwaysOff"))).thenReturn(new AlwaysOffFilter()); assertTrue(featureManager.isEnabledAsync("On").block()); } @Test public void oneOffAll() { - HashMap features = new HashMap<>(); - Feature onFeature = new Feature(); - onFeature.setKey("On"); - HashMap filters = new HashMap(); - FeatureFilterEvaluationContext alwaysOn = new FeatureFilterEvaluationContext(); - alwaysOn.setName("AlwaysOn"); - filters.put(0, alwaysOn); - filters.put(1, alwaysOn); - onFeature.setEnabledFor(filters); - onFeature.setRequirementType("All"); - features.put("On", onFeature); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + List features = List.of(new Feature().setId("On").setEnabled(true) + .setConditions(new Conditions().setRequirementType("All") + .setClientFilters(List.of(new FeatureFilterEvaluationContext().setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setName("AlwaysOffFilter"))))); - when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) - .thenReturn(new AlwaysOffFilter()); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + when(context.getBean(Mockito.matches("AlwaysOff"))).thenReturn(new AlwaysOffFilter()); assertFalse(featureManager.isEnabledAsync("On").block()); } @Test public void timeWindowFilter() { - final HashMap features = new HashMap<>(); - final HashMap filters = new HashMap(); - final HashMap parameters = new HashMap<>(); parameters.put("Start", "Sun, 14 Jan 2024 00:00:00 GMT"); parameters.put("End", "Mon, 15 Jan 2024 00:00:00 GMT"); final HashMap pattern = new HashMap<>(); pattern.put("Type", "Weekly"); - pattern.put("DaysOfWeek", List.of("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")); + pattern.put("DaysOfWeek", + List.of("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")); final HashMap range = new HashMap<>(); range.put("Type", "NoEnd"); final HashMap recurrence = new HashMap<>(); @@ -226,16 +228,47 @@ public void timeWindowFilter() { final FeatureFilterEvaluationContext weeklyAlwaysOn = new FeatureFilterEvaluationContext(); weeklyAlwaysOn.setName("TimeWindowFilter"); weeklyAlwaysOn.setParameters(parameters); - filters.put(0, weeklyAlwaysOn); - final Feature weeklyAlwaysOnFeature = new Feature(); - weeklyAlwaysOnFeature.setEnabledFor(filters); - features.put("Alpha", weeklyAlwaysOnFeature); + List features = List + .of(new Feature().setId("Alpha").setEnabled(true) + .setConditions(new Conditions().setClientFilters(List.of(weeklyAlwaysOn)))); - when(featureManagementPropertiesMock.getFeatureManagement()).thenReturn(features); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); when(context.getBean(Mockito.matches("TimeWindowFilter"))).thenReturn(new TimeWindowFilter()); + assertEquals(null, weeklyAlwaysOn.getFeatureName()); assertTrue(featureManager.isEnabled("Alpha")); + assertEquals("Alpha", weeklyAlwaysOn.getFeatureName()); + } + + @Test + public void telemetryPublisherCalledWhenFeatureEnabledWithTelemetry() { + List features = List.of(new Feature().setId("EnabledFeatureWithTelemetry").setEnabled(true) + .setTelemetry(new Telemetry().setEnabled(true))); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + assertTrue(featureManager.isEnabled("EnabledFeatureWithTelemetry")); + verify(telemetryPublisher, times(1)).publishTelemetry(Mockito.any(EvaluationEvent.class)); + } + + @Test + public void telemetryPublisherNotCalledWhenTelemetryDisabled() { + List features = List.of(new Feature().setId("FeatureWithTelemetryDisabled").setEnabled(true) + .setTelemetry(new Telemetry().setEnabled(false))); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + assertTrue(featureManager.isEnabled("FeatureWithTelemetryDisabled")); + verify(telemetryPublisher, times(0)).publishTelemetry(Mockito.any(EvaluationEvent.class)); + } + + @Test + public void telemetryPublisherCalledWhenFeatureDisabledWithTelemetry() { + List features = List.of(new Feature().setId("DisabledFeatureWithTelemetry").setEnabled(false) + .setTelemetry(new Telemetry().setEnabled(true))); + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + assertFalse(featureManager.isEnabled("DisabledFeatureWithTelemetry")); + verify(telemetryPublisher, times(1)).publishTelemetry(Mockito.any(EvaluationEvent.class)); } class AlwaysOnFilter implements FeatureFilter { @@ -247,6 +280,17 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { } + class AlwaysOnContextFilter implements ContextualFeatureFilter { + + @Override + public boolean evaluate(FeatureFilterEvaluationContext context, Object localContext) { + if (localContext == Boolean.FALSE) { + return false; + } + return true; + } + } + class AlwaysOffFilter implements FeatureFilter { @Override diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerVariantTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerVariantTest.java new file mode 100644 index 000000000000..93e2cdebe716 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/FeatureManagerVariantTest.java @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +import com.azure.spring.cloud.feature.management.filters.FeatureFilter; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; +import com.azure.spring.cloud.feature.management.models.Allocation; +import com.azure.spring.cloud.feature.management.models.Conditions; +import com.azure.spring.cloud.feature.management.models.Feature; +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; +import com.azure.spring.cloud.feature.management.models.Variant; +import com.azure.spring.cloud.feature.management.models.VariantReference; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; + +/** + * Unit tests for FeatureManager. + */ +@SpringBootTest(classes = { FeatureManagementTestConfigurations.class, SpringBootTest.class }) +public class FeatureManagerVariantTest { + + private FeatureManager featureManager; + + @Mock + private ApplicationContext context; + + @Mock + private FeatureManagementConfigProperties properties; + + @Mock + private FeatureManagementProperties featureManagementPropertiesMock; + + @Mock + private TargetingContextAccessor contextAccessorMock; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + when(properties.isFailFast()).thenReturn(true); + + featureManager = new FeatureManager(context, featureManagementPropertiesMock, properties, contextAccessorMock, + null, null); + } + + @AfterEach + public void cleanup() throws Exception { + MockitoAnnotations.openMocks(this).close(); + } + + @Test + public void isEnabledFeatureNotFound() { + assertNull(featureManager.getVariant("Unknown Banner")); + } + + @Test + public void noAssignedVariants() { + List features = List.of(new Feature().setId("No Variants")); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + assertNull(featureManager.getVariant("No Variants")); + } + + @Test + public void noAssigner() { + featureManager = new FeatureManager(context, featureManagementPropertiesMock, properties, null, null, null); + List features = List.of(new Feature().setId("No Assigner").setVariants(createVariants())); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + assertNull(featureManager.getVariant("No Assigner")); + } + + @Test + public void noAssignmentNoDefault() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()).setAllocation(new Allocation())); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + assertNull(featureManager.getVariant("No Assigner")); + } + + @Test + public void noAssignmentDefaultEnabled() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setAllocation(new Allocation().setDefaultWhenEnabled("small")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "small"); + assertEquals(result.getValue(), 1); + } + + @Test + public void noAssignmentDefaultDisabled() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setAllocation(new Allocation().setDefaultWhenDisabled("large"))); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "large"); + assertEquals(result.getValue(), 9); + } + + @Test + public void disabledNoDefaultDisabled() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setAllocation(new Allocation())); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + Variant result = featureManager.getVariant("No Assigner"); + assertNull(result); + } + + @Test + public void enabledFilterDefault() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn")))) + .setAllocation(new Allocation().setDefaultWhenEnabled("small")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.eq("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "small"); + assertEquals(result.getValue(), 1); + + result = featureManager.getVariantAsync("No Assigner").block(); + assertEquals(result.getName(), "small"); + assertEquals(result.getValue(), 1); + } + + @Test + public void enabledFilterDefaultAnyTrue() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn")))) + .setAllocation(new Allocation().setDefaultWhenEnabled("small")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.eq("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "small"); + assertEquals(result.getValue(), 1); + } + + @Test + public void enabledFilterDefaultAnyTrueJustOne() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOff")))) + .setAllocation(new Allocation().setDefaultWhenEnabled("small")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.eq("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "small"); + assertEquals(result.getValue(), 1); + } + + @Test + public void enabledFilterDefaultAllTrueJustOne() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOff"))) + .setRequirementType("All")) + .setAllocation(new Allocation().setDefaultWhenEnabled("small").setDefaultWhenDisabled("large")) + .setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.eq("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + when(context.getBean(Mockito.eq("AlwaysOff"))).thenReturn(new AlwaysOffFilter()); + Variant result = featureManager.getVariant("No Assigner"); + assertEquals(result.getName(), "large"); + assertEquals(result.getValue(), 9); + } + + @Test + public void enabledFilterDefaultAllTrueJustOneNoDefault() { + List features = List + .of(new Feature().setId("No Assigner").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"))) + .setRequirementType("All")) + .setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + when(context.getBean(Mockito.eq("AlwaysOn"))).thenReturn(new AlwaysOnFilter()); + Variant result = featureManager.getVariant("No Assigner"); + assertNull(result); + } + + @Test + public void allOnVariantOverride() { + List features = List + .of(new Feature().setId("On").setVariants(createVariants()) + .setConditions(new Conditions().setRequirementType("All").setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn")))) + .setAllocation(new Allocation().setDefaultWhenEnabled("large")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOnFilter()); + + assertFalse(featureManager.isEnabledAsync("On").block()); + } + + @Test + public void allOnVariantOverrideNoDefault() { + List features = List + .of(new Feature().setId("On").setVariants(createVariants()) + .setConditions(new Conditions().setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"))) + .setRequirementType("All")) + .setAllocation(new Allocation()).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOnFilter()); + + assertTrue(featureManager.isEnabledAsync("On").block()); + } + + @Test + public void allOnVariantOverrideInvalidVariant() { + List features = List.of(new Feature().setId("On").setVariants(createVariants()) + .setConditions(new Conditions() + .setClientFilters( + List.of(new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"), + new FeatureFilterEvaluationContext().setFeatureName("No Assigner").setName("AlwaysOn"))) + .setRequirementType("All")) + .setAllocation(new Allocation()).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOnFilter()); + + assertTrue(featureManager.isEnabledAsync("On").block()); + } + + @Test + public void noFiltersButVariants() { + List features = List + .of(new Feature().setId("On").setVariants(createVariants()) + .setConditions(new Conditions().setRequirementType("All")) + .setAllocation(new Allocation().setDefaultWhenEnabled("small")).setEnabled(true)); + + when(featureManagementPropertiesMock.getFeatureFlags()).thenReturn(features); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOnFilter()); + + assertTrue(featureManager.isEnabledAsync("On").block()); + } + + private List createVariants() { + List variants = new ArrayList<>(); + variants.add(new VariantReference().setName("small").setConfigurationValue(1).setStatusOverride("Enabled")); + variants.add(new VariantReference().setName("large").setConfigurationValue(9).setStatusOverride("Disabled")); + return variants; + } + + class AlwaysOnFilter implements FeatureFilter { + + @Override + public boolean evaluate(FeatureFilterEvaluationContext context) { + return true; + } + + } + + class AlwaysOffFilter implements FeatureFilter { + + @Override + public boolean evaluate(FeatureFilterEvaluationContext context) { + return false; + } + + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesListTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesListTest.java deleted file mode 100644 index e4e63a6ef239..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesListTest.java +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.spring.cloud.feature.management; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; - -import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; - -public class ServerSideFeatureManagementPropertiesListTest { - - private FeatureManagementProperties serverSideProperties; - - @Test - void featureManagementTest() { - serverSideProperties = new FeatureManagementProperties(); - Map> test = new LinkedHashMap<>(); - Map alpha = new LinkedHashMap<>(); - alpha.put("id", "Alpha"); - alpha.put("enabled", true); - - Map randomFilter = new LinkedHashMap<>(); - randomFilter.put("name", "Microsoft.Random"); - randomFilter.put("parameters", Map.of("Value", 50)); - - Map clientFilters = new LinkedHashMap<>(); - clientFilters.put("0", randomFilter); - Map conditions = new LinkedHashMap<>(); - conditions.put("client_filters", clientFilters); - Map beta = new LinkedHashMap<>(); - beta.put("id", "Beta"); - beta.put("enabled", true); - beta.put("conditions", conditions); - - test.put("feature_flags", List.of(alpha, beta)); - serverSideProperties.putAll(test); - assertEquals(1, serverSideProperties.getOnOff().size()); - assertEquals(1, serverSideProperties.getFeatureManagement().size()); - } - -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesTest.java deleted file mode 100644 index 67d1555e3b90..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ServerSideFeatureManagementPropertiesTest.java +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.spring.cloud.feature.management; - -import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; -import com.azure.spring.cloud.feature.management.implementation.models.Feature; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@ExtendWith(SpringExtension.class) -@EnableConfigurationProperties(value = FeatureManagementProperties.class) -@SpringBootTest(classes = { SpringBootTest.class }) -@ActiveProfiles("server") -public class ServerSideFeatureManagementPropertiesTest { - @Autowired - private FeatureManagementProperties serverSideProperties; - - @Test - void onOffMapTest() { - assertTrue(serverSideProperties.getOnOff().get("Gamma")); - } - - @Test - void featureManagementTest() { - final Feature alphaFeatureItem = serverSideProperties.getFeatureManagement().get("Alpha"); - assertEquals(alphaFeatureItem.getKey(), "Alpha"); - assertEquals(alphaFeatureItem.getEnabledFor().size(), 1); - assertEquals(alphaFeatureItem.getEnabledFor().get(0).getName(), "Microsoft.Random"); - - final Feature betaFeatureItem = serverSideProperties.getFeatureManagement().get("Beta"); - assertEquals(betaFeatureItem.getKey(), "Beta"); - assertEquals(betaFeatureItem.getEnabledFor().size(), 1); - assertEquals(betaFeatureItem.getEnabledFor().get(0).getName(), "Microsoft.TimeWindowFilter"); - - final Boolean deltaFeatureItem = serverSideProperties.getOnOff().get("Delta"); - assertTrue(deltaFeatureItem); - } - -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/TargetingContextAccessorTestConfiguration.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/TargetingContextAccessorTestConfiguration.java new file mode 100644 index 000000000000..1b61d3453805 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/TargetingContextAccessorTestConfiguration.java @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; +import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; +import com.azure.spring.cloud.feature.management.targeting.TargetingContext; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; + +@Configuration +@EnableConfigurationProperties({ FeatureManagementConfigProperties.class, FeatureManagementProperties.class }) +public class TargetingContextAccessorTestConfiguration { + + @Bean + public TargetingContextAccessor targetingAccessor() { + return new TestAccessor(); + } + + + + class TestAccessor implements TargetingContextAccessor { + + @Override + public void configureTargetingContext(TargetingContext context) { + } + + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java deleted file mode 100644 index 35d43967f85e..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/ValidationsTest.java +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.feature.management; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.when; - -import java.io.File; -import java.io.IOException; -import java.net.URL; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.stream.Stream; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import com.azure.spring.cloud.feature.management.filters.TargetingFilter; -import com.azure.spring.cloud.feature.management.filters.TargetingFilterTestContextAccessor; -import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; -import com.azure.spring.cloud.feature.management.implementation.FeatureManagementConfigProperties; -import com.azure.spring.cloud.feature.management.implementation.FeatureManagementProperties; -import com.azure.spring.cloud.feature.management.validationstests.models.ValidationTestCase; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.type.CollectionType; -import com.fasterxml.jackson.databind.type.TypeFactory; - -@ExtendWith(SpringExtension.class) -public class ValidationsTest { - @Mock - private ApplicationContext context; - - @Mock - private FeatureManagementConfigProperties configProperties; - - private static final Logger LOGGER = LoggerFactory.getLogger(ValidationsTest.class); - - private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() - .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true).build(); - - private static final String TEST_CASE_FOLDER_PATH = "validations-tests"; - - private final String inputsUser = "user"; - - private final String inputsGroups = "groups"; - - private static final String SAMPLE_FILE_NAME_FILTER = "sample"; - - private static final String TESTS_FILE_NAME_FILTER = "tests"; - - @BeforeEach - public void setup() { - MockitoAnnotations.openMocks(this); - when(configProperties.isFailFast()).thenReturn(true); - when(context.getBean(Mockito.contains("TimeWindow"))).thenReturn(new TimeWindowFilter()); - } - - @AfterEach - public void cleanup() throws Exception { - MockitoAnnotations.openMocks(this).close(); - } - - private boolean hasException(ValidationTestCase testCase) { - final String exceptionStr = testCase.getIsEnabled().getException(); - return exceptionStr != null && !exceptionStr.isEmpty(); - } - - private boolean hasInput(ValidationTestCase testCase) { - final LinkedHashMap inputsMap = testCase.getInputs(); - return inputsMap != null && !inputsMap.isEmpty(); - } - - private static File[] getFileList(String fileNameFilter) { - final URL folderUrl = Thread.currentThread().getContextClassLoader().getResource(TEST_CASE_FOLDER_PATH); - assert folderUrl != null; - - final File folderFile = new File(folderUrl.getFile()); - final File[] filteredFiles = folderFile - .listFiles(pathname -> pathname.getName().toLowerCase().contains(fileNameFilter)); - assert filteredFiles != null; - - Arrays.sort(filteredFiles, Comparator.comparing(File::getName)); - return filteredFiles; - } - - private List readTestcasesFromFile(File testFile) throws IOException { - final String jsonString = Files.readString(testFile.toPath()); - final CollectionType typeReference = TypeFactory.defaultInstance().constructCollectionType(List.class, - ValidationTestCase.class); - return OBJECT_MAPPER.readValue(jsonString, typeReference); - } - - @SuppressWarnings("unchecked") - private static LinkedHashMap readConfigurationFromFile(File sampleFile) throws IOException { - final String jsonString = Files.readString(sampleFile.toPath()); - final LinkedHashMap configurations = OBJECT_MAPPER.readValue(jsonString, new TypeReference<>() { - }); - final Object featureManagementSection = configurations.get("feature_management"); - if (featureManagementSection.getClass().isAssignableFrom(LinkedHashMap.class)) { - return (LinkedHashMap) featureManagementSection; - } - throw new IllegalArgumentException("feature_management part is not a map"); - } - - static Stream testProvider() throws IOException { - List arguments = new ArrayList<>(); - File[] files = getFileList(TESTS_FILE_NAME_FILTER); - - final File[] sampleFiles = getFileList(SAMPLE_FILE_NAME_FILTER); - List properties = new ArrayList<>(); - for (File sampleFile : sampleFiles) { - final FeatureManagementProperties managementProperties = new FeatureManagementProperties(); - managementProperties.putAll(readConfigurationFromFile(sampleFile)); - properties.add(managementProperties); - } - - for (int i = 0; i < files.length; i++) { - if (files[i].getName().contains(("TargetingFilter"))) { - continue; // TODO(mametcal). Not run the test case until we release the little endian fix - } - arguments.add(Arguments.of(files[i].getName(), files[i], properties.get(i))); - } - - return arguments.stream(); - } - - @ParameterizedTest(name = "{0}") - @MethodSource("testProvider") - void validationTest(String name, File testsFile, FeatureManagementProperties managementProperties) - throws IOException { - LOGGER.debug("Running test case from file: " + name); - final FeatureManager featureManager = new FeatureManager(context, managementProperties, configProperties); - List testCases = readTestcasesFromFile(testsFile); - for (ValidationTestCase testCase : testCases) { - LOGGER.debug("Test case : " + testCase.getDescription()); - if (hasException(testCase)) { // TODO(mametcal). Currently we didn't throw the exception when parameter is - // invalid - assertNull(managementProperties.getOnOff().get(testCase.getFeatureFlagName())); - continue; - } - if (hasInput(testCase)) { // Set inputs - final Object userObj = testCase.getInputs().get(inputsUser); - final Object groupsObj = testCase.getInputs().get(inputsGroups); - final String user = userObj != null ? userObj.toString() : null; - @SuppressWarnings("unchecked") - final List groups = groupsObj != null ? (List) groupsObj : null; - when(context.getBean(Mockito.contains("Targeting"))) - .thenReturn(new TargetingFilter(new TargetingFilterTestContextAccessor(user, groups))); - } - - final Boolean result = featureManager.isEnabled(testCase.getFeatureFlagName()); - assertEquals(result.toString(), testCase.getIsEnabled().getResult()); - } - } -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java index 28d17b4eb1f9..26115986d3fe 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java @@ -15,12 +15,13 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import com.azure.spring.cloud.feature.management.implementation.TestConfiguration; import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; import com.azure.spring.cloud.feature.management.models.TargetingException; +import com.azure.spring.cloud.feature.management.targeting.TargetingContext; +import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; -@SpringBootTest(classes = { TestConfiguration.class, SpringBootTest.class }) +@SpringBootTest(classes = { SpringBootTest.class }) public class TargetingFilterTest { private static final String USERS = "Users"; @@ -29,7 +30,7 @@ public class TargetingFilterTest { private static final String AUDIENCE = "Audience"; - private static final String DEFAULT_ROLLOUT_PERCENTAGE = "defaultRolloutPercentage"; + private static final String DEFAULT_ROLLOUT_PERCENTAGE = "DefaultRolloutPercentage"; private static final String OUT_OF_RANGE = "The value is out of the accepted range."; @@ -59,25 +60,27 @@ public void targetedUser() { assertTrue(filter.evaluate(context)); } - + @Test - public void targetedUserLower() { + public void targetedUserAudience() { FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); - Map parameters = new LinkedHashMap(); + Map parameters = new LinkedHashMap<>(); + Map audience = new LinkedHashMap<>(); - Map users = new LinkedHashMap(); + Map users = new LinkedHashMap<>(); users.put("0", "Doe"); - parameters.put(USERS.toLowerCase(), users); - parameters.put(GROUPS.toLowerCase(), new LinkedHashMap()); - parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); - parameters.put("Exclusion", emptyExclusion()); + audience.put(USERS, users); + audience.put(GROUPS, new LinkedHashMap()); + audience.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); + audience.put("Exclusion", emptyExclusion()); Map excludes = new LinkedHashMap<>(); Map excludedGroups = new LinkedHashMap<>(); excludes.put(GROUPS, excludedGroups); + parameters.put("Audience", audience); context.setParameters(parameters); context.setFeatureName("testFeature"); @@ -85,6 +88,9 @@ public void targetedUserLower() { TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("Doe", null)); assertTrue(filter.evaluate(context)); + + filter = new TargetingFilter(new TargetingFilterTestContextAccessor("Doe", null)); + assertTrue(filter.evaluate(context)); } @Test @@ -136,12 +142,13 @@ public void targetedGroup() { assertTrue(filter.evaluate(context)); } - + @Test - public void targetedGroupLower() { + public void targetedGroupAudience() { FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); - Map parameters = new LinkedHashMap(); + Map parameters = new LinkedHashMap<>(); + Map audience = new LinkedHashMap<>(); Map groups = new LinkedHashMap(); Map g1 = new LinkedHashMap(); @@ -149,10 +156,12 @@ public void targetedGroupLower() { g1.put("rolloutPercentage", "100"); groups.put("0", g1); - parameters.put(USERS.toLowerCase(), new LinkedHashMap()); - parameters.put(GROUPS.toLowerCase(), groups); - parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); - parameters.put("Exclusion", emptyExclusion()); + audience.put(USERS, new LinkedHashMap()); + audience.put(GROUPS, groups); + audience.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); + audience.put("Exclusion", emptyExclusion()); + + parameters.put("Audience", audience); context.setParameters(parameters); context.setFeatureName("testFeature"); @@ -218,7 +227,7 @@ public void targetedGroupFiftyPass() { TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("Jane", targetedGroups)); - assertTrue(filter.evaluate(context)); + assertFalse(filter.evaluate(context)); } @Test @@ -455,4 +464,23 @@ private Map emptyExclusion() { excludes.put(GROUPS, excludedGroups); return excludes; } + + class TargetingFilterTestContextAccessor implements TargetingContextAccessor { + + private String user; + + private ArrayList groups; + + TargetingFilterTestContextAccessor(String user, ArrayList groups) { + this.user = user; + this.groups = groups; + } + + @Override + public void configureTargetingContext(TargetingContext context) { + context.setUserId(user); + context.setGroups(groups); + } + + } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorTest.java index 12d9fd6c6308..94cfb06870a7 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorTest.java @@ -3,18 +3,19 @@ package com.azure.spring.cloud.feature.management.filters.recurrence; -import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; -import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceEvaluator; -import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.List; -import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceEvaluator; public class RecurrenceEvaluatorTest { diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorTest.java index a4122ca06a63..34301162e3fa 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorTest.java @@ -3,15 +3,8 @@ package com.azure.spring.cloud.feature.management.filters.recurrence; -import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; -import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; -import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; -import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; -import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; -import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; -import com.azure.spring.cloud.feature.management.models.FilterParameters; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -20,8 +13,16 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.filters.TimeWindowFilter; +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceConstants; +import com.azure.spring.cloud.feature.management.models.FeatureFilterEvaluationContext; +import com.azure.spring.cloud.feature.management.models.FilterParameters; public class RecurrenceValidatorTest { private final String recurrencePatter = "Recurrence.Pattern"; diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implemenation/TargetingFilterUtilsTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implemenation/TargetingFilterUtilsTest.java new file mode 100644 index 000000000000..759cf5873635 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implemenation/TargetingFilterUtilsTest.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.implemenation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.FeatureFilterUtils; + +public class TargetingFilterUtilsTest { + + @Test + public void isTargetedPercentageTest() { + assertEquals(FeatureFilterUtils.isTargetedPercentage(null), 9.875071074318855); + assertEquals(FeatureFilterUtils.isTargetedPercentage(""), 26.0813765987012); + assertEquals(FeatureFilterUtils.isTargetedPercentage("Alice"), 38.306839656621875); + assertEquals(FeatureFilterUtils.isTargetedPercentage("Quinn\nDeb"), 79.98622464481421); + assertEquals(FeatureFilterUtils.isTargetedPercentage("\nProd"), 73.47059517015484); + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementPropertiesTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementPropertiesTest.java new file mode 100644 index 000000000000..6a8b330688b7 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementPropertiesTest.java @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.implementation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.models.Feature; + +public class FeatureManagementPropertiesTest { + + + @Test + public void setFeatureFlagsTest() { + FeatureManagementProperties properties = new FeatureManagementProperties(); + List featureFlags = new ArrayList<>(); + properties.setFeatureFlags(featureFlags); + assertEquals(0, properties.getFeatureFlags().size()); + + Feature alphaFeatureFlag = new Feature(); + alphaFeatureFlag.setId("alpha"); + alphaFeatureFlag.setEnabled(true); + featureFlags.add(alphaFeatureFlag); + properties.setFeatureFlags(featureFlags); + assertEquals(1, properties.getFeatureFlags().size()); + assertEquals("alpha", properties.getFeatureFlags().get(0).getId()); + assertEquals(true, properties.getFeatureFlags().get(0).isEnabled()); + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/TestConfiguration.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/TestConfiguration.java deleted file mode 100644 index 9f306b0e695c..000000000000 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/implementation/TestConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -package com.azure.spring.cloud.feature.management.implementation; - - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -@ConfigurationProperties -public class TestConfiguration { - - @Bean - public FeatureManagementConfigProperties properties() { - return new FeatureManagementConfigProperties(); - } -} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisherTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisherTest.java new file mode 100644 index 000000000000..ff9d354323df --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/telemetry/LoggerTelemetryPublisherTest.java @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.telemetry; + +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.DEFAULT_WHEN_ENABLED; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.ENABLED; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.EVALUATION_EVENT_VERSION; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.EVENT_NAME; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.FEATURE_NAME; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.REASON; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VARIANT; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VARIANT_ASSIGNMENT_PERCENTAGE; +import static com.azure.spring.cloud.feature.management.telemetry.TelemetryConstants.VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.when; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestMethodOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.LoggerFactory; + +import com.azure.spring.cloud.feature.management.models.Allocation; +import com.azure.spring.cloud.feature.management.models.EvaluationEvent; +import com.azure.spring.cloud.feature.management.models.Feature; +import com.azure.spring.cloud.feature.management.models.PercentileAllocation; +import com.azure.spring.cloud.feature.management.models.Variant; +import com.azure.spring.cloud.feature.management.models.VariantAssignmentReason; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.read.ListAppender; + +@TestMethodOrder(MethodOrderer.MethodName.class) +public class LoggerTelemetryPublisherTest { + + private Logger publisherLogger; + + private ListAppender listAppender; + + @Mock + private Feature featureMock; + + @Mock + private Variant variantMock; + + private ILoggingEvent logEvent; + + @BeforeAll + public static void setUpLogging() { + // Force SLF4J to initialize + LoggerFactory.getLogger(LoggerTelemetryPublisherTest.class).info("Initializing SLF4J in test"); + } + + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + + org.slf4j.Logger slf4jLogger = LoggerFactory.getLogger(LoggerTelemetryPublisher.class); + + // Check if we can cast to Logback's Logger + if (slf4jLogger instanceof ch.qos.logback.classic.Logger) { + publisherLogger = (ch.qos.logback.classic.Logger) slf4jLogger; + + // Create a new ListAppender for each test + listAppender = new ListAppender<>(); + listAppender.start(); + + // Remove any existing appenders of this type first + for (Iterator> it = publisherLogger.iteratorForAppenders(); it.hasNext();) { + Appender appender = it.next(); + if (appender instanceof ListAppender) { + publisherLogger.detachAppender(appender); + } + } + + // Add the fresh appender + publisherLogger.addAppender(listAppender); + } else { + assumeTrue( + false, + "Tests require Logback implementation, but found: " + slf4jLogger.getClass().getName() + ); + } + } + + @Test + void featureFlagTest(TestInfo testInfo) throws Exception { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals("None", mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + } + + @Test + void featureVariantTest(TestInfo testInfo) throws Exception { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + evaluationEvent.setVariant(variantMock); + evaluationEvent.setReason(VariantAssignmentReason.DEFAULT_WHEN_ENABLED); + + when(variantMock.getName()).thenReturn("fake-variant"); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals(DEFAULT_WHEN_ENABLED, mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + assertEquals("fake-variant", mdcMap.get(VARIANT)); + assertEquals("100", mdcMap.get(VARIANT_ASSIGNMENT_PERCENTAGE)); + } + + @Test + void featureFlagDisabledTest(TestInfo testInfo) throws Exception { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + when(featureMock.isEnabled()).thenReturn(false); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals("None", mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + } + + @Test + void featureVariantWithPercentageTest(TestInfo testInfo) throws Exception { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + evaluationEvent.setVariant(variantMock); + evaluationEvent.setReason(VariantAssignmentReason.PERCENTILE); + + PercentileAllocation fakeVariant1 = new PercentileAllocation().setVariant("fake-variant").setFrom((double) 0) + .setTo((double) 75); + PercentileAllocation fakeVariant2 = new PercentileAllocation().setVariant("fake-variant2").setFrom((double) 76) + .setTo((double) 100); + + when(featureMock.getAllocation()).thenReturn(new Allocation().setPercentile(List.of(fakeVariant1, fakeVariant2)) + .setDefaultWhenEnabled("defaultVariant")); + when(variantMock.getName()).thenReturn("fake-variant"); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals("Percentile", mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + assertEquals("fake-variant", mdcMap.get(VARIANT)); + assertEquals("75.0", mdcMap.get(VARIANT_ASSIGNMENT_PERCENTAGE)); + assertEquals("defaultVariant", mdcMap.get(DEFAULT_WHEN_ENABLED)); + } + + @Test + void nullEvaluationEventTest(TestInfo testInfo) { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(null); + + // Ensure no logs are generated + assertEquals(0, listAppender.list.size()); + } + + @Test + void nullFeatureInEvaluationEventTest(TestInfo testInfo) { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(null); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + // Ensure no logs are generated + assertEquals(0, listAppender.list.size()); + } + + @Test + void nullVariantInEvaluationEventTest(TestInfo testInfo) { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + evaluationEvent.setVariant(null); + evaluationEvent.setReason(VariantAssignmentReason.DEFAULT_WHEN_ENABLED); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals(DEFAULT_WHEN_ENABLED, mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + assertEquals(null, mdcMap.get(VARIANT)); + assertEquals("100", mdcMap.get(VARIANT_ASSIGNMENT_PERCENTAGE)); + } + + @Test + void emptyPercentileAllocationTest(TestInfo testInfo) { + when(featureMock.getId()).thenReturn(testInfo.getDisplayName()); + EvaluationEvent evaluationEvent = new EvaluationEvent(featureMock); + evaluationEvent.setVariant(variantMock); + evaluationEvent.setReason(VariantAssignmentReason.PERCENTILE); + + when(featureMock.getAllocation()).thenReturn(new Allocation().setPercentile(List.of())); + when(variantMock.getName()).thenReturn("fake-variant"); + + LoggerTelemetryPublisher publisher = new LoggerTelemetryPublisher(); + publisher.publishTelemetry(evaluationEvent); + + logEvent = getEvent(listAppender.list, testInfo.getDisplayName()); + Map mdcMap = logEvent.getMDCPropertyMap(); + + assertEquals(EVENT_NAME, logEvent.getMessage()); + assertEquals(Level.INFO, logEvent.getLevel()); + assertEquals("Percentile", mdcMap.get(REASON)); + assertEquals(testInfo.getDisplayName(), mdcMap.get(FEATURE_NAME)); + assertEquals("false", mdcMap.get(ENABLED)); + assertEquals(EVALUATION_EVENT_VERSION, mdcMap.get(VERSION)); + assertEquals(EVENT_NAME, mdcMap.get(APPLICATION_INSIGHTS_CUSTOM_EVENT_KEY)); + assertEquals("fake-variant", mdcMap.get(VARIANT)); + assertEquals("0.0", mdcMap.get(VARIANT_ASSIGNMENT_PERCENTAGE)); + } + + ILoggingEvent getEvent(List events, String featureName) { + for (ILoggingEvent event : events) { + if (featureName.equals(event.getMDCPropertyMap().get(FEATURE_NAME))) { + return event; + } + } + assumeTrue( + false, + "Log event not found for feature: " + featureName + ); + return null; // This line will never be reached due to the assumption above + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/application.yaml b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/application.yaml index a6fda6647d79..3e5ffd598893 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/application.yaml +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/application.yaml @@ -1,10 +1,8 @@ feature-management: - featureSet: - features: - FeatureU: false - FeatureV: - enabledFor: - - name: Random - parameters: - chance: "50" - - name: ClientFilter + FeatureU: false + FeatureV: + enabledFor: + - name: Random + parameters: + chance: "50" + - name: ClientFilter \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/logback-test.xml b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/logback-test.xml new file mode 100644 index 000000000000..4490745c459d --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/sdk/spring/spring-cloud-azure-integration-test-appconfiguration-config/pom.xml b/sdk/spring/spring-cloud-azure-integration-test-appconfiguration-config/pom.xml index 0caeba4a3ee8..702c8c509813 100644 --- a/sdk/spring/spring-cloud-azure-integration-test-appconfiguration-config/pom.xml +++ b/sdk/spring/spring-cloud-azure-integration-test-appconfiguration-config/pom.xml @@ -21,7 +21,7 @@ com.azure.spring spring-cloud-azure-starter-appconfiguration-config - 5.24.0-beta.1 + 6.0.0-beta.2 org.springframework.boot diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/CHANGELOG.md index a75eb4b2797a..52c8460387a0 100644 --- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 5.24.0-beta.1 (Unreleased) +## 6.0.0-beta.2 (Unreleased) ### Features Added diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md index aa151dc77b02..d1d222dfb426 100644 --- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md +++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md @@ -21,7 +21,7 @@ There are two libraries that can be used spring-cloud-azure-appconfiguration-con com.azure.spring spring-cloud-azure-appconfiguration-config - 5.23.0 + 6.0.0 ``` [//]: # ({x-version-update-end}) @@ -33,7 +33,7 @@ or com.azure.spring spring-cloud-azure-appconfiguration-config-web - 5.23.0 + 6.0.0 ``` [//]: # ({x-version-update-end}) @@ -48,6 +48,10 @@ Please use this `sample` as a reference for how to use this starter. ### Supported properties +Name | Description | Required | Default +---|---|---|--- +spring.config.import | The Spring property that triggers the loading of Azure App Configuration properties. It need to contain the value `azureAppConfiguration` | yes | None + Name | Description | Required | Default ---|---|---|--- spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | true @@ -68,10 +72,17 @@ spring.cloud.azure.appconfiguration.stores[0].replicaDiscoveryEnabled | Enables Configuration Store Authentication +By default when connecting to Azure App Configuration, if no credential is provided, the `DefaultAzureCredential` will be used. + Name | Description | Required | Default ---|---|---|--- spring.cloud.azure.appconfiguration.stores[0].endpoint | When the endpoint of an App Configuration store is specified, a managed identity or a token credential provided using `AppConfigCredentialProvider` will be used to connect to the App Configuration service. An `IllegalArgumentException` will be thrown if the endpoint and connection-string are specified at the same time. | Conditional | null spring.cloud.azure.appconfiguration.stores[0].endpoints | When multiple replica endpoints of an App Configuration store are specified, a managed identity or a token credential provided using `AppConfigCredentialProvider` will be used to connect to the App Configuration service. Replica endpoints should be listed in priority order of connection. An `IllegalArgumentException` will be thrown if multiple authentication methods are provided. | Conditional | null + +Additionally, you can connect to Azure App Configuration using a connection string. But this method is not recommended. + +Name | Description | Required | Default +---|---|---|--- spring.cloud.azure.appconfiguration.stores[0].connection-string | When the connection-string of an App Configuration store is specified, HMAC authentication will be used to connect to the App Configuration service. An `IllegalArgumentException` will be thrown if the endpoint and connection-string are specified at the same time. | Conditional | null spring.cloud.azure.appconfiguration.stores[0].connection-strings | When the connection-strings of an App Configuration store is specified, HMAC authentication will be used to connect to the App Configuration service. Replica stores should be listed in priority order of connection. An `IllegalArgumentException` will be thrown if the endpoint and connection-string are specified at the same time. | Conditional | null @@ -84,6 +95,11 @@ spring.cloud.azure.appconfiguration.stores[0].monitoring.refresh-interval | Amou spring.cloud.azure.appconfiguration.stores[0].monitoring.feature-flag-refresh-interval | Amount of time, of type Duration, feature flags are stored before a check can occur. | No | 30s spring.cloud.azure.appconfiguration.stores[0].monitoring.triggers[0].key | A key that is watched for change via etag. If a change is detected on the key then a refresh of all configurations will be triggered. | Yes (If monitoring enabled) | null spring.cloud.azure.appconfiguration.stores[0].monitoring.triggers[0].label | The label of the key that is being watched for etag changes. | No | \0 + +These properties enable push-based notifications for configuration changes. But this method of refresh is no-longer recommended, but is currently still supported. + +Name | Description | Required | Default +---|---|---|--- spring.cloud.azure.appconfiguration.stores[0].monitoring.push-notification.primary-token.name | The name of a token used with Event Hub to trigger push based refresh. | No | null spring.cloud.azure.appconfiguration.stores[0].monitoring.push-notification.primary-token.secret | The secret value of a token used with Event Hub to trigger push based refresh. | No | null spring.cloud.azure.appconfiguration.stores[0].monitoring.push-notification.secondary-token.name | The name of a token used with Event Hub to trigger push based refresh. | No | null @@ -97,6 +113,17 @@ spring.cloud.azure.appconfiguration.stores[0].feature-flags.enabled | Whether fe spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].key-filter | The key pattern used to indicate which feature flags will be loaded. | No | \0 spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].label-filter | The label used to indicate which feature flags will be loaded. | No | \0 +### Basic usage + +Enabling the Azure App Configuration integration is as simple as adding the necessary dependencies, listed above, and the following configuration properties. + +```properties +spring.config.import=azureAppConfiguration +spring.cloud.azure.appconfiguration.stores[0].endpoint=[your-endpoint] +``` + +With these settings in place, your application will be able to connect to Azure App Configuration and retrieve configuration values. The default values loaded with these properties are all configuration that start with the value `/application/` and have no label. + ### Advanced usage #### Geo-Replication @@ -119,7 +146,7 @@ The failover won't happen for client errors like authentication failures. #### Load from multiple configuration stores -If the application needs to load configuration properties from multiple stores, following configuration sample describes how the bootstrap.properties(or .yaml) can be configured. +If the application needs to load configuration properties from multiple stores, following configuration sample describes how the application.properties(or .yaml) can be configured. ```properties spring.cloud.azure.appconfiguration.stores[0].connection-string=[first-store-connection-string] @@ -266,6 +293,8 @@ In the console library calling refreshConfiguration on `AppConfigurationRefresh` ##### Push Based Refresh +NOTE: This method of refresh is no longer recommended, but is still supported. + The Web Provider can be connect to your Azure App Configuration store via an Azure Event Grid Web Hook to trigger a refresh event. By adding the Spring Actuator as a dependency you can add App Configuration Refresh as an exposed endpoint. There are two options appconfiguration-refresh and appconfiguration-refresh-bus. These endpoints work just like there counterparts refresh and refresh-bus, but have the required web hook authorization to work with Azure Event Grid. When needing to refresh multiple application instances `azure-servicebus-jms-spring-boot-starter` needs to be setup to have the refresh triggered in all instances. ```properties @@ -320,11 +349,11 @@ Follow the below steps to enable accessing App Configuration with managed identi 1. Configure the [Azure RBAC][azure_rbac] of your App Configuration store to grant access to the Azure service where your application is running. Select the App Configuration Data Reader. The App Configuration Data Owner role is not required but can be used if needed. -1. Configure bootstrap.properties(or .yaml) in the Spring Boot application. +1. Configure application.properties(or .yaml) in the Spring Boot application. The configuration store endpoint must be configured when `connection-string` is empty. When using a User Assigned Id the value `spring.cloud.azure.appconfiguration.managed-identity.client-id=[client-id]` must be set. -##### bootstrap.application +##### application.application ```application spring.cloud.azure.appconfiguration.stores[0].endpoint=[config-store-endpoint] diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/pom.xml b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/pom.xml index 19e53f98a56a..76b4433df5b3 100644 --- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/pom.xml +++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/pom.xml @@ -6,7 +6,7 @@ com.azure.spring spring-cloud-azure-starter-appconfiguration-config - 5.24.0-beta.1 + 6.0.0-beta.2 Spring Cloud Azure Starter App Configuration Config Spring Cloud Azure Starter App Configuration Config https://microsoft.github.io/spring-cloud-azure @@ -88,12 +88,12 @@ com.azure.spring spring-cloud-azure-appconfiguration-config-web - 5.24.0-beta.1 + 6.0.0-beta.2 com.azure.spring spring-cloud-azure-feature-management-web - 5.24.0-beta.1 + 6.0.0-beta.2