Repository: Unleash/unleash-client-java
Pull request #298 (2025-03-21 15:36:34)

Conditionally enable features based on contexts
## About the changes
Conditionally enable feature toggles based on matching the context. 
See https://github.com/Unleash/unleash-client-java/issues/266

### Important files
* FakeUnleash
* FakeUnleashTest


## Discussion points
We assume that we will want to define *multiple* conditions concurrently, because that's how unleash works in reality. As a result, `FakeUnleash.conditionallyEnable()` can be called multiple times, and these conditions are ORed together. That way one test can say "enable for x=1" and another says "enable for y=2", and these can co-exist without affecting each other.

We assume that enabling or disabling a feature toggle without specifying a context wipes out any conditionally enabled configurations for the same feature toggles, and vice versa. See the difference between `features` and `conditionalFeatures`

We also assume that we need to be able to do this concurrently in terms of execution, hence the use of concurrent classes in FakeUnleash.



Patch:
diff --git a/pom.xml b/pom.xml
index 1bf62022..7558f942 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
     <modelVersion>4.0.0</modelVersion>
     <groupId>io.getunleash</groupId>
     <artifactId>unleash-client-java</artifactId>
-    <version>10.2.1-SNAPSHOT</version>
+    <version>10.2.2</version>
 
     <properties>
         <version.slf4j>2.0.13</version.slf4j>
@@ -58,7 +58,7 @@
         <dependency>
             <groupId>io.getunleash</groupId>
             <artifactId>yggdrasil-engine</artifactId>
-            <version>0.1.1</version>
+            <version>0.1.2</version>
         </dependency>
         <dependency>
             <groupId>com.google.code.gson</groupId>
diff --git a/src/main/java/io/getunleash/FakeUnleash.java b/src/main/java/io/getunleash/FakeUnleash.java
index dcf69e2a..2b57a085 100644
--- a/src/main/java/io/getunleash/FakeUnleash.java
+++ b/src/main/java/io/getunleash/FakeUnleash.java
@@ -3,53 +3,68 @@ package io.getunleash;
 import io.getunleash.lang.Nullable;
 import io.getunleash.variant.Variant;
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
 import java.util.function.BiPredicate;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 public class FakeUnleash implements Unleash {
     private boolean enableAll = false;
     private boolean disableAll = false;
-    private Map<String, Boolean> excludedFeatures = new HashMap<>();
-    private Map<String, Boolean> features = new HashMap<>();
-    private Map<String, Variant> variants = new HashMap<>();
+    /**
+     * @implNote This uses {@link Queue} instead of {@link List}, as there are concurrent queues,
+     *     but no concurrent lists, in the jdk. This will never be drained. Only iterated over.
+     */
+    private final Map<String, Queue<Predicate<UnleashContext>>> conditionalFeatures =
+            new ConcurrentHashMap<>();
 
-    @Override
-    public boolean isEnabled(String toggleName, boolean defaultSetting) {
-        if (enableAll) {
-            return excludedFeatures.getOrDefault(toggleName, true);
-        } else if (disableAll) {
-            return excludedFeatures.getOrDefault(toggleName, false);
-        } else {
-            return features.containsKey(toggleName) ? features.get(toggleName) : defaultSetting;
-        }
-    }
+    private final Map<String, Boolean> excludedFeatures = new ConcurrentHashMap<>();
+    private final Map<String, Boolean> features = new ConcurrentHashMap<>();
+    private final Map<String, Variant> variants = new ConcurrentHashMap<>();
 
     @Override
     public boolean isEnabled(
             String toggleName,
             UnleashContext context,
             BiPredicate<String, UnleashContext> fallbackAction) {
-        return isEnabled(toggleName, fallbackAction);
-    }
-
-    @Override
-    public boolean isEnabled(
-            String toggleName, BiPredicate<String, UnleashContext> fallbackAction) {
-        if ((!enableAll && !disableAll || excludedFeatures.containsKey(toggleName))
-                && !features.containsKey(toggleName)) {
-            return fallbackAction.test(toggleName, UnleashContext.builder().build());
+        if (enableAll) {
+            return excludedFeatures.getOrDefault(toggleName, true);
+        } else if (disableAll) {
+            return excludedFeatures.getOrDefault(toggleName, false);
+        } else {
+            Boolean unconditionallyEnabled = features.get(toggleName);
+            if (unconditionallyEnabled != null) {
+                return unconditionallyEnabled;
+            }
+            Queue<Predicate<UnleashContext>> conditionalFeaturePredicates =
+                    conditionalFeatures.get(toggleName);
+            if (conditionalFeaturePredicates == null) {
+                return fallbackAction.test(toggleName, context);
+            } else {
+                return conditionalFeaturePredicates.stream()
+                        .anyMatch(
+                                conditionalFeaturePredicate ->
+                                        conditionalFeaturePredicate.test(context));
+            }
         }
-        return isEnabled(toggleName);
     }
 
     @Override
     public Variant getVariant(String toggleName, UnleashContext context) {
-        return getVariant(toggleName, Variant.DISABLED_VARIANT);
+        return getVariant(toggleName, context, Variant.DISABLED_VARIANT);
     }
 
     @Override
     public Variant getVariant(String toggleName, UnleashContext context, Variant defaultValue) {
-        return getVariant(toggleName, defaultValue);
+        if (isEnabled(toggleName, context)) {
+            Variant variant = variants.get(toggleName);
+            if (variant != null) {
+                return variant;
+            }
+        }
+        return defaultValue;
     }
 
     @Override
@@ -59,11 +74,7 @@ public class FakeUnleash implements Unleash {
 
     @Override
     public Variant getVariant(String toggleName, Variant defaultValue) {
-        if (isEnabled(toggleName) && variants.containsKey(toggleName)) {
-            return variants.get(toggleName);
-        } else {
-            return defaultValue;
-        }
+        return getVariant(toggleName, UnleashContext.builder().build(), defaultValue);
     }
 
     @Override
@@ -74,8 +85,9 @@ public class FakeUnleash implements Unleash {
     public void enableAll() {
         disableAll = false;
         enableAll = true;
-        excludedFeatures.clear();
         features.clear();
+        excludedFeatures.clear();
+        conditionalFeatures.clear();
     }
 
     public void enableAllExcept(String... excludedFeatures) {
@@ -88,8 +100,9 @@ public class FakeUnleash implements Unleash {
     public void disableAll() {
         disableAll = true;
         enableAll = false;
-        excludedFeatures.clear();
         features.clear();
+        excludedFeatures.clear();
+        conditionalFeatures.clear();
     }
 
     public void disableAllExcept(String... excludedFeatures) {
@@ -104,48 +117,75 @@ public class FakeUnleash implements Unleash {
         enableAll = false;
         excludedFeatures.clear();
         features.clear();
+        conditionalFeatures.clear();
         variants.clear();
     }
 
     public void enable(String... features) {
         for (String name : features) {
+            this.conditionalFeatures.remove(name);
             this.features.put(name, true);
         }
     }
 
     public void disable(String... features) {
         for (String name : features) {
+            this.conditionalFeatures.remove(name);
             this.features.put(name, false);
         }
     }
 
     public void reset(String... features) {
         for (String name : features) {
+            this.conditionalFeatures.remove(name);
             this.features.remove(name);
         }
     }
 
-    public void setVariant(String t1, Variant a) {
-        variants.put(t1, a);
+    /**
+     * Enables or disables feature toggles depending on the evaluation of the {@code
+     * contextMatcher}. This can be called multiple times. If <b>any</b> of the context matchers
+     * match, the feature is enabled. This lets you conditionally configure multiple different tests
+     * to do different things, while running concurrently.
+     *
+     * <p>This will be overwritten if {@link #enable(String...)} or {@link #disable(String...)} are
+     * called and vice versa.
+     *
+     * @param contextMatcher the context matcher to evaluate
+     * @param features the features for which the context matcher will be invoked
+     */
+    public void conditionallyEnable(Predicate<UnleashContext> contextMatcher, String... features) {
+        for (String name : features) {
+            // calling conditionallyEnable() should override having called enable() or disable()
+            this.features.remove(name);
+            this.conditionalFeatures
+                    .computeIfAbsent(name, ignored -> new LinkedBlockingQueue<>())
+                    .add(contextMatcher);
+        }
+    }
+
+    public void setVariant(String toggleName, Variant variant) {
+        variants.put(toggleName, variant);
     }
 
     public class FakeMore implements MoreOperations {
 
         @Override
         public List<String> getFeatureToggleNames() {
-            return new ArrayList<>(features.keySet());
+            return Stream.concat(features.keySet().stream(), conditionalFeatures.keySet().stream())
+                    .distinct()
+                    .collect(Collectors.toList());
         }
 
         @Override
         public Optional<FeatureDefinition> getFeatureToggleDefinition(String toggleName) {
-            return Optional.ofNullable(features.get(toggleName))
-                    .map(
-                            value ->
-                                    new FeatureDefinition(
-                                            toggleName,
-                                            Optional.of("experiment"),
-                                            "default",
-                                            true));
+            if (conditionalFeatures.containsKey(toggleName) || features.containsKey(toggleName)) {
+                return Optional.of(
+                        new FeatureDefinition(
+                                toggleName, Optional.of("experiment"), "default", true));
+            } else {
+                return Optional.empty();
+            }
         }
 
         @Override


Test patch:
diff --git a/src/test/java/io/getunleash/FakeUnleashTest.java b/src/test/java/io/getunleash/FakeUnleashTest.java
index 4d766c34..e3575ede 100644
--- a/src/test/java/io/getunleash/FakeUnleashTest.java
+++ b/src/test/java/io/getunleash/FakeUnleashTest.java
@@ -10,6 +10,158 @@ import org.junit.jupiter.api.Test;
 
 public class FakeUnleashTest {
 
+    @Test
+    void conditionally_enabling_a_feature_should_replace_always_enabled() throws Exception {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.enable("t1");
+        fakeUnleash.conditionallyEnable(
+                context -> "expected_test_value".equals(context.getProperties().get("test")), "t1");
+
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1",
+                                UnleashContext.builder()
+                                        .addProperty("test", "expected_test_value")
+                                        .build()))
+                .isTrue();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1",
+                                UnleashContext.builder()
+                                        .addProperty("test", "unexpected_test_value")
+                                        .build()))
+                .isFalse();
+    }
+
+    @Test
+    void unconditionally_enabling_a_feature_should_replace_conditionally_enabled()
+            throws Exception {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.conditionallyEnable(
+                context -> "expected_test_value".equals(context.getProperties().get("test")), "t1");
+        fakeUnleash.enable("t1");
+
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1",
+                                UnleashContext.builder()
+                                        .addProperty("test", "expected_test_value")
+                                        .build()))
+                .isTrue();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1",
+                                UnleashContext.builder()
+                                        .addProperty("test", "unexpected_test_value")
+                                        .build()))
+                .isTrue();
+    }
+
+    @Test
+    void should_conditionally_enable_feature_only_for_matching_context() throws Exception {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.conditionallyEnable(
+                context -> "expected_test_value".equals(context.getProperties().get("test")),
+                "t1",
+                "t2");
+
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1",
+                                UnleashContext.builder()
+                                        .addProperty("test", "expected_test_value")
+                                        .addProperty("other", "other")
+                                        .build()))
+                .isTrue();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t2",
+                                UnleashContext.builder()
+                                        .addProperty("test", "unexpected_test_value")
+                                        .addProperty("other", "other")
+                                        .build()))
+                .isFalse();
+        assertThat(fakeUnleash.isEnabled("t1")).isFalse();
+        assertThat(fakeUnleash.isEnabled("unknown")).isFalse();
+    }
+
+    @Test
+    void should_evaluate_multiple_conditional_contexts() throws Exception {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.conditionallyEnable(
+                context -> "v1".equals(context.getProperties().get("test")), "t1", "t2");
+        fakeUnleash.conditionallyEnable(
+                context -> "v2".equals(context.getProperties().get("test")), "t1", "t2");
+
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1", UnleashContext.builder().addProperty("test", "v1").build()))
+                .isTrue();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1", UnleashContext.builder().addProperty("test", "v2").build()))
+                .isTrue();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1", UnleashContext.builder().addProperty("test", "v3").build()))
+                .isFalse();
+        assertThat(fakeUnleash.isEnabled("t1")).isFalse();
+        assertThat(fakeUnleash.isEnabled("unknown")).isFalse();
+
+        // disabling the whole feature toggle erases any conditional enablement
+        fakeUnleash.disable("t1");
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1", UnleashContext.builder().addProperty("test", "v1").build()))
+                .isFalse();
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "t1", UnleashContext.builder().addProperty("test", "v2").build()))
+                .isFalse();
+    }
+
+    @Test
+    void should_return_variant_only_if_conditionally_enabled() {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.conditionallyEnable(
+                context -> "v1".equals(context.getProperties().get("test")), "t1");
+        Variant variant = new Variant("a", "some payload", true);
+        fakeUnleash.setVariant("t1", variant);
+
+        assertThat(
+                        fakeUnleash.getVariant(
+                                "t1", UnleashContext.builder().addProperty("test", "v1").build()))
+                .isEqualTo(variant);
+        assertThat(
+                        fakeUnleash.getVariant(
+                                "t1", UnleashContext.builder().addProperty("test", "v2").build()))
+                .isEqualTo(Variant.DISABLED_VARIANT);
+    }
+
+    @Test
+    void should_reset_conditional_features_when_resetting_entire_feature() {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+        fakeUnleash.conditionallyEnable(ctx -> true, "t1");
+
+        assertThat(fakeUnleash.isEnabled("t1", UnleashContext.builder().build())).isTrue();
+
+        fakeUnleash.reset("t1");
+
+        assertThat(fakeUnleash.isEnabled("t1", UnleashContext.builder().build())).isFalse();
+    }
+
+    @Test
+    void should_use_fallback_if_no_matchers_defined() {
+        FakeUnleash fakeUnleash = new FakeUnleash();
+
+        assertThat(
+                        fakeUnleash.isEnabled(
+                                "unknown-toggle",
+                                UnleashContext.builder().addProperty("test", "v1").build(),
+                                (name, context) -> true))
+                .isTrue();
+    }
+
     @Test
     public void should_enable_all_toggles() throws Exception {
         FakeUnleash fakeUnleash = new FakeUnleash();