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 a8f6074fc551..04f3ffea3af2 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 @@ -82,4 +82,11 @@ public class AppConfigurationConstants { 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"; + } 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 f60d47a12d9f..29c289cb961f 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 @@ -3,6 +3,7 @@ package com.azure.spring.cloud.appconfiguration.config.implementation; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.AUDIENCE; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_REQUIREMENT_TYPE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_ROLLOUT_PERCENTAGE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_ROLLOUT_PERCENTAGE_CAPS; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_CONTENT_TYPE; @@ -10,6 +11,7 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_MANAGEMENT_KEY; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.GROUPS; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.GROUPS_CAPS; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.REQUIREMENT_TYPE_SERVICE; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.SELECT_ALL_FEATURE_FLAGS; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.TARGETING_FILTER; import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.USERS; @@ -32,7 +34,9 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.TracingInfo; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +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; @@ -50,7 +54,6 @@ final class AppConfigurationFeatureManagementPropertySource extends AppConfigura .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true).build(); private final List featureConfigurationSettings; - AppConfigurationFeatureManagementPropertySource(String originEndpoint, AppConfigurationReplicaClient replicaClient, String keyFilter, String[] labelFilter) { super("FM_" + originEndpoint, replicaClient, keyFilter, labelFilter); @@ -127,7 +130,17 @@ List getFeatureFlagSettings() { @SuppressWarnings("unchecked") private Object createFeature(FeatureFlagConfigurationSetting item) { String key = getFeatureSimpleName(item); - Feature feature = new Feature(key, item); + String requirementType = DEFAULT_REQUIREMENT_TYPE; + try { + JsonNode node = CASE_INSENSITIVE_MAPPER.readTree(item.getValue()); + JsonNode conditions = node.get("conditions"); + if (conditions != null && conditions.get(REQUIREMENT_TYPE_SERVICE) != null) { + requirementType = conditions.get(REQUIREMENT_TYPE_SERVICE).asText(); + } + } catch (JsonProcessingException e) { + + } + Feature feature = new Feature(key, item, requirementType); Map featureEnabledFor = feature.getEnabledFor(); // Setting Enabled For to null, but enabled = true will result in the feature diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Feature.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Feature.java index f26a40541ac0..4435bb728367 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Feature.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/entity/Feature.java @@ -2,13 +2,15 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.DEFAULT_REQUIREMENT_TYPE; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.REQUIREMENT_TYPE; + import java.util.HashMap; import java.util.List; import java.util.Map; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; -import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -21,9 +23,12 @@ public final class Feature { @JsonProperty("key") private String key; - @JsonAlias("enabled-for") + @JsonProperty("enabled-for") private Map enabledFor; + @JsonProperty(REQUIREMENT_TYPE) + private String requirementType = DEFAULT_REQUIREMENT_TYPE; + /** * Feature Flag object. */ @@ -36,7 +41,7 @@ public Feature() { * @param key Name of the Feature Flag * @param featureItem Configurations of the Feature Flag. */ - public Feature(String key, FeatureFlagConfigurationSetting featureItem) { + public Feature(String key, FeatureFlagConfigurationSetting featureItem, String requirementType) { this.key = key; List filterMapper = featureItem.getClientFilters(); @@ -45,6 +50,7 @@ public Feature(String key, FeatureFlagConfigurationSetting featureItem) { for (int i = 0; i < filterMapper.size(); i++) { enabledFor.put(i, filterMapper.get(i)); } + this.requirementType = requirementType; } /** @@ -75,4 +81,18 @@ 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-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 index 150bfe5e019e..f36b392d699d 100644 --- 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 @@ -45,6 +45,8 @@ import com.azure.data.appconfiguration.ConfigurationAsyncClient; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; +import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; +import com.azure.spring.cloud.appconfiguration.config.implementation.http.policy.TracingInfo; 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; @@ -347,7 +349,50 @@ public void storeCreatedWithFeatureFlags() { featureFlagStore.setEnabled(true); featureFlagStore.validateAndInit(); + List featureList = new ArrayList<>(); + FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); + featureFlag.setValue("{}"); + featureList.add(featureFlag); + + when(configStoreMock.getFeatureFlags()).thenReturn(featureFlagStore); + when(replicaClientMock.listSettings(Mockito.any())).thenReturn(featureList); + + locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, + null, stores); + + 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(); + // Application name: foo and active profile: dev,prod, should construct below + // composite Property Source: + // [/foo_prod/, /foo_dev/, /foo/, /application_prod/, /application_dev/, + // /application/] + String[] expectedSourceNames = new String[] { + "FM_store1/", + KEY_FILTER + "store1/\0" + }; + assertEquals(expectedSourceNames.length, sources.size()); + assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); + } + } + + @Test + public void storeCreatedWithFeatureFlagsRequireAll() { + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(true); + featureFlagStore.validateAndInit(); + + List featureList = new ArrayList<>(); + FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", true); + featureFlag.setValue("{\"id\":null,\"description\":null,\"display_name\":null,\"enabled\":true,\"conditions\":{\"requirement_type\":\"All\", \"client_filters\":[{\"name\":\"AlwaysOn\",\"parameters\":{}}]}}"); + featureList.add(featureFlag); + when(configStoreMock.getFeatureFlags()).thenReturn(featureFlagStore); + when(replicaClientMock.listSettings(Mockito.any())).thenReturn(featureList); + when(replicaClientMock.getTracingInfo()).thenReturn(new TracingInfo(false, false, 0, null)); locator = new AppConfigurationPropertySourceLocator(appProperties, clientFactoryMock, keyVaultClientFactory, null, stores); @@ -367,7 +412,11 @@ public void storeCreatedWithFeatureFlags() { KEY_FILTER + "store1/\0" }; assertEquals(expectedSourceNames.length, sources.size()); + Object[] propertySources = sources.stream().map(c -> c.getProperty("feature-management.Alpha")).toArray(); + Feature alpha = (Feature) propertySources[0]; + assertEquals("All", alpha.getRequirementType()); assertArrayEquals((Object[]) expectedSourceNames, sources.stream().map(PropertySource::getName).toArray()); + } } @@ -381,6 +430,7 @@ public void storeCreatedWithFeatureFlagsWithMonitoring() { List featureList = new ArrayList<>(); FeatureFlagConfigurationSetting featureFlag = new FeatureFlagConfigurationSetting("Alpha", false); + featureFlag.setValue("{}"); featureList.add(featureFlag); when(configStoreMock.getFeatureFlags()).thenReturn(featureFlagStore); 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 72d6b751d616..1805b0367627 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 @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,9 +51,9 @@ public class FeatureManager { } /** - * 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 @@ -63,9 +64,9 @@ public Mono isEnabledAsync(String feature) { } /** - * 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 @@ -93,9 +94,16 @@ private boolean checkFeature(String feature) throws FilterNotFoundException { return false; } - return featureItem.getEnabledFor().values().stream().filter(Objects::nonNull) - .filter(featureFilter -> featureFilter.getName() != null) - .anyMatch(featureFilter -> isFeatureOn(featureFilter, feature)); + Stream filters = featureItem.getEnabledFor().values().stream() + .filter(Objects::nonNull).filter(featureFilter -> featureFilter.getName() != null); + + // All Filters must be true + if (featureItem.getRequirementType().equals("All")) { + return filters.allMatch(featureFilter -> isFeatureOn(featureFilter, feature)); + } + + // Any Filter must be true + return filters.anyMatch(featureFilter -> isFeatureOn(featureFilter, feature)); } private boolean isFeatureOn(FeatureFilterEvaluationContext filter, String feature) { 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 new file mode 100644 index 000000000000..d3aae1c796c7 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/FeatureManagementConstants.java @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.implementation; + +public class FeatureManagementConstants { + + public static final String DEFAULT_REQUIREMENT_TYPE = "Any"; + +} 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 index f105e57f7b9a..555505b56a68 100644 --- 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 @@ -2,10 +2,14 @@ // 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.HashMap; +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; -import java.util.HashMap; /** * App Configuration Feature defines the feature name and a Map of FeatureFilterEvaluationContexts. @@ -19,8 +23,11 @@ public class Feature { @JsonProperty("evaluate") private Boolean evaluate = true; + @JsonProperty("requirement-type") + private String requirementType = DEFAULT_REQUIREMENT_TYPE;; + @JsonProperty("enabled-for") - private HashMap enabledFor; + private Map enabledFor; /** * @return the key @@ -53,7 +60,7 @@ public void setEvaluate(Boolean evaluate) { /** * @return the enabledFor */ - public HashMap getEnabledFor() { + public Map getEnabledFor() { return enabledFor; } @@ -64,4 +71,18 @@ public void setEnabledFor(HashMap enabl 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/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 38c4fcbd89ef..9933722a018e 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 @@ -139,6 +139,69 @@ public void noFilter() throws FilterNotFoundException { assertThat(e).hasMessage("Fail fast is set and a Filter was unable to be found: AlwaysOff"); } + @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); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOnFilter()); + + assertTrue(featureManager.isEnabledAsync("On").block()); + } + + @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); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .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); + + when(context.getBean(Mockito.matches("AlwaysOn"))).thenReturn(new AlwaysOnFilter()) + .thenReturn(new AlwaysOffFilter()); + + assertFalse(featureManager.isEnabledAsync("On").block()); + } + class AlwaysOnFilter implements FeatureFilter { @Override @@ -148,4 +211,13 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { } + class AlwaysOffFilter implements FeatureFilter { + + @Override + public boolean evaluate(FeatureFilterEvaluationContext context) { + return false; + } + + } + }