Skip to content

Commit 7f8ae07

Browse files
authoredMar 26, 2025··
Conditionally enable features based on contexts (#298)
* Conditionally enable features * Avoid nulls where possible. Homogenize isEnabled() to bottom out in the same method. * Use BiPredicate, as that's already a pattern, and we can pass it along, instead of converting * Got rid of the custom FakeContextMatcher in favor of the simpler Predicate. This means less documentation, but more familiarity. Doubly so, since the API already uses BiPredicate * Removed unnecessary overrides, as they are present as default methods in the interface * Documentation * Conditional and unconditional feature configurations are mutually exclusive * Conditional and unconditional feature configurations are mutually exclusive - explicitly remove conditional features * Support variants. Added more tests * Clear more things when calling enableAll() and disableAll() * Better variable names * Actually use a concurrent queue. Added a test for fallbacks
1 parent a354d95 commit 7f8ae07

File tree

2 files changed

+235
-43
lines changed

2 files changed

+235
-43
lines changed
 

‎src/main/java/io/getunleash/FakeUnleash.java

+83-43
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,68 @@
33
import io.getunleash.lang.Nullable;
44
import io.getunleash.variant.Variant;
55
import java.util.*;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
import java.util.concurrent.LinkedBlockingQueue;
68
import java.util.function.BiPredicate;
9+
import java.util.function.Predicate;
710
import java.util.stream.Collectors;
11+
import java.util.stream.Stream;
812

913
public class FakeUnleash implements Unleash {
1014
private boolean enableAll = false;
1115
private boolean disableAll = false;
12-
private Map<String, Boolean> excludedFeatures = new HashMap<>();
13-
private Map<String, Boolean> features = new HashMap<>();
14-
private Map<String, Variant> variants = new HashMap<>();
16+
/**
17+
* @implNote This uses {@link Queue} instead of {@link List}, as there are concurrent queues,
18+
* but no concurrent lists, in the jdk. This will never be drained. Only iterated over.
19+
*/
20+
private final Map<String, Queue<Predicate<UnleashContext>>> conditionalFeatures =
21+
new ConcurrentHashMap<>();
1522

16-
@Override
17-
public boolean isEnabled(String toggleName, boolean defaultSetting) {
18-
if (enableAll) {
19-
return excludedFeatures.getOrDefault(toggleName, true);
20-
} else if (disableAll) {
21-
return excludedFeatures.getOrDefault(toggleName, false);
22-
} else {
23-
return features.containsKey(toggleName) ? features.get(toggleName) : defaultSetting;
24-
}
25-
}
23+
private final Map<String, Boolean> excludedFeatures = new ConcurrentHashMap<>();
24+
private final Map<String, Boolean> features = new ConcurrentHashMap<>();
25+
private final Map<String, Variant> variants = new ConcurrentHashMap<>();
2626

2727
@Override
2828
public boolean isEnabled(
2929
String toggleName,
3030
UnleashContext context,
3131
BiPredicate<String, UnleashContext> fallbackAction) {
32-
return isEnabled(toggleName, fallbackAction);
33-
}
34-
35-
@Override
36-
public boolean isEnabled(
37-
String toggleName, BiPredicate<String, UnleashContext> fallbackAction) {
38-
if ((!enableAll && !disableAll || excludedFeatures.containsKey(toggleName))
39-
&& !features.containsKey(toggleName)) {
40-
return fallbackAction.test(toggleName, UnleashContext.builder().build());
32+
if (enableAll) {
33+
return excludedFeatures.getOrDefault(toggleName, true);
34+
} else if (disableAll) {
35+
return excludedFeatures.getOrDefault(toggleName, false);
36+
} else {
37+
Boolean unconditionallyEnabled = features.get(toggleName);
38+
if (unconditionallyEnabled != null) {
39+
return unconditionallyEnabled;
40+
}
41+
Queue<Predicate<UnleashContext>> conditionalFeaturePredicates =
42+
conditionalFeatures.get(toggleName);
43+
if (conditionalFeaturePredicates == null) {
44+
return fallbackAction.test(toggleName, context);
45+
} else {
46+
return conditionalFeaturePredicates.stream()
47+
.anyMatch(
48+
conditionalFeaturePredicate ->
49+
conditionalFeaturePredicate.test(context));
50+
}
4151
}
42-
return isEnabled(toggleName);
4352
}
4453

4554
@Override
4655
public Variant getVariant(String toggleName, UnleashContext context) {
47-
return getVariant(toggleName, Variant.DISABLED_VARIANT);
56+
return getVariant(toggleName, context, Variant.DISABLED_VARIANT);
4857
}
4958

5059
@Override
5160
public Variant getVariant(String toggleName, UnleashContext context, Variant defaultValue) {
52-
return getVariant(toggleName, defaultValue);
61+
if (isEnabled(toggleName, context)) {
62+
Variant variant = variants.get(toggleName);
63+
if (variant != null) {
64+
return variant;
65+
}
66+
}
67+
return defaultValue;
5368
}
5469

5570
@Override
@@ -59,11 +74,7 @@ public Variant getVariant(String toggleName) {
5974

6075
@Override
6176
public Variant getVariant(String toggleName, Variant defaultValue) {
62-
if (isEnabled(toggleName) && variants.containsKey(toggleName)) {
63-
return variants.get(toggleName);
64-
} else {
65-
return defaultValue;
66-
}
77+
return getVariant(toggleName, UnleashContext.builder().build(), defaultValue);
6778
}
6879

6980
@Override
@@ -74,8 +85,9 @@ public MoreOperations more() {
7485
public void enableAll() {
7586
disableAll = false;
7687
enableAll = true;
77-
excludedFeatures.clear();
7888
features.clear();
89+
excludedFeatures.clear();
90+
conditionalFeatures.clear();
7991
}
8092

8193
public void enableAllExcept(String... excludedFeatures) {
@@ -88,8 +100,9 @@ public void enableAllExcept(String... excludedFeatures) {
88100
public void disableAll() {
89101
disableAll = true;
90102
enableAll = false;
91-
excludedFeatures.clear();
92103
features.clear();
104+
excludedFeatures.clear();
105+
conditionalFeatures.clear();
93106
}
94107

95108
public void disableAllExcept(String... excludedFeatures) {
@@ -104,48 +117,75 @@ public void resetAll() {
104117
enableAll = false;
105118
excludedFeatures.clear();
106119
features.clear();
120+
conditionalFeatures.clear();
107121
variants.clear();
108122
}
109123

110124
public void enable(String... features) {
111125
for (String name : features) {
126+
this.conditionalFeatures.remove(name);
112127
this.features.put(name, true);
113128
}
114129
}
115130

116131
public void disable(String... features) {
117132
for (String name : features) {
133+
this.conditionalFeatures.remove(name);
118134
this.features.put(name, false);
119135
}
120136
}
121137

122138
public void reset(String... features) {
123139
for (String name : features) {
140+
this.conditionalFeatures.remove(name);
124141
this.features.remove(name);
125142
}
126143
}
127144

128-
public void setVariant(String t1, Variant a) {
129-
variants.put(t1, a);
145+
/**
146+
* Enables or disables feature toggles depending on the evaluation of the {@code
147+
* contextMatcher}. This can be called multiple times. If <b>any</b> of the context matchers
148+
* match, the feature is enabled. This lets you conditionally configure multiple different tests
149+
* to do different things, while running concurrently.
150+
*
151+
* <p>This will be overwritten if {@link #enable(String...)} or {@link #disable(String...)} are
152+
* called and vice versa.
153+
*
154+
* @param contextMatcher the context matcher to evaluate
155+
* @param features the features for which the context matcher will be invoked
156+
*/
157+
public void conditionallyEnable(Predicate<UnleashContext> contextMatcher, String... features) {
158+
for (String name : features) {
159+
// calling conditionallyEnable() should override having called enable() or disable()
160+
this.features.remove(name);
161+
this.conditionalFeatures
162+
.computeIfAbsent(name, ignored -> new LinkedBlockingQueue<>())
163+
.add(contextMatcher);
164+
}
165+
}
166+
167+
public void setVariant(String toggleName, Variant variant) {
168+
variants.put(toggleName, variant);
130169
}
131170

132171
public class FakeMore implements MoreOperations {
133172

134173
@Override
135174
public List<String> getFeatureToggleNames() {
136-
return new ArrayList<>(features.keySet());
175+
return Stream.concat(features.keySet().stream(), conditionalFeatures.keySet().stream())
176+
.distinct()
177+
.collect(Collectors.toList());
137178
}
138179

139180
@Override
140181
public Optional<FeatureDefinition> getFeatureToggleDefinition(String toggleName) {
141-
return Optional.ofNullable(features.get(toggleName))
142-
.map(
143-
value ->
144-
new FeatureDefinition(
145-
toggleName,
146-
Optional.of("experiment"),
147-
"default",
148-
true));
182+
if (conditionalFeatures.containsKey(toggleName) || features.containsKey(toggleName)) {
183+
return Optional.of(
184+
new FeatureDefinition(
185+
toggleName, Optional.of("experiment"), "default", true));
186+
} else {
187+
return Optional.empty();
188+
}
149189
}
150190

151191
@Override

‎src/test/java/io/getunleash/FakeUnleashTest.java

+152
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,158 @@
1010

1111
public class FakeUnleashTest {
1212

13+
@Test
14+
void conditionally_enabling_a_feature_should_replace_always_enabled() throws Exception {
15+
FakeUnleash fakeUnleash = new FakeUnleash();
16+
fakeUnleash.enable("t1");
17+
fakeUnleash.conditionallyEnable(
18+
context -> "expected_test_value".equals(context.getProperties().get("test")), "t1");
19+
20+
assertThat(
21+
fakeUnleash.isEnabled(
22+
"t1",
23+
UnleashContext.builder()
24+
.addProperty("test", "expected_test_value")
25+
.build()))
26+
.isTrue();
27+
assertThat(
28+
fakeUnleash.isEnabled(
29+
"t1",
30+
UnleashContext.builder()
31+
.addProperty("test", "unexpected_test_value")
32+
.build()))
33+
.isFalse();
34+
}
35+
36+
@Test
37+
void unconditionally_enabling_a_feature_should_replace_conditionally_enabled()
38+
throws Exception {
39+
FakeUnleash fakeUnleash = new FakeUnleash();
40+
fakeUnleash.conditionallyEnable(
41+
context -> "expected_test_value".equals(context.getProperties().get("test")), "t1");
42+
fakeUnleash.enable("t1");
43+
44+
assertThat(
45+
fakeUnleash.isEnabled(
46+
"t1",
47+
UnleashContext.builder()
48+
.addProperty("test", "expected_test_value")
49+
.build()))
50+
.isTrue();
51+
assertThat(
52+
fakeUnleash.isEnabled(
53+
"t1",
54+
UnleashContext.builder()
55+
.addProperty("test", "unexpected_test_value")
56+
.build()))
57+
.isTrue();
58+
}
59+
60+
@Test
61+
void should_conditionally_enable_feature_only_for_matching_context() throws Exception {
62+
FakeUnleash fakeUnleash = new FakeUnleash();
63+
fakeUnleash.conditionallyEnable(
64+
context -> "expected_test_value".equals(context.getProperties().get("test")),
65+
"t1",
66+
"t2");
67+
68+
assertThat(
69+
fakeUnleash.isEnabled(
70+
"t1",
71+
UnleashContext.builder()
72+
.addProperty("test", "expected_test_value")
73+
.addProperty("other", "other")
74+
.build()))
75+
.isTrue();
76+
assertThat(
77+
fakeUnleash.isEnabled(
78+
"t2",
79+
UnleashContext.builder()
80+
.addProperty("test", "unexpected_test_value")
81+
.addProperty("other", "other")
82+
.build()))
83+
.isFalse();
84+
assertThat(fakeUnleash.isEnabled("t1")).isFalse();
85+
assertThat(fakeUnleash.isEnabled("unknown")).isFalse();
86+
}
87+
88+
@Test
89+
void should_evaluate_multiple_conditional_contexts() throws Exception {
90+
FakeUnleash fakeUnleash = new FakeUnleash();
91+
fakeUnleash.conditionallyEnable(
92+
context -> "v1".equals(context.getProperties().get("test")), "t1", "t2");
93+
fakeUnleash.conditionallyEnable(
94+
context -> "v2".equals(context.getProperties().get("test")), "t1", "t2");
95+
96+
assertThat(
97+
fakeUnleash.isEnabled(
98+
"t1", UnleashContext.builder().addProperty("test", "v1").build()))
99+
.isTrue();
100+
assertThat(
101+
fakeUnleash.isEnabled(
102+
"t1", UnleashContext.builder().addProperty("test", "v2").build()))
103+
.isTrue();
104+
assertThat(
105+
fakeUnleash.isEnabled(
106+
"t1", UnleashContext.builder().addProperty("test", "v3").build()))
107+
.isFalse();
108+
assertThat(fakeUnleash.isEnabled("t1")).isFalse();
109+
assertThat(fakeUnleash.isEnabled("unknown")).isFalse();
110+
111+
// disabling the whole feature toggle erases any conditional enablement
112+
fakeUnleash.disable("t1");
113+
assertThat(
114+
fakeUnleash.isEnabled(
115+
"t1", UnleashContext.builder().addProperty("test", "v1").build()))
116+
.isFalse();
117+
assertThat(
118+
fakeUnleash.isEnabled(
119+
"t1", UnleashContext.builder().addProperty("test", "v2").build()))
120+
.isFalse();
121+
}
122+
123+
@Test
124+
void should_return_variant_only_if_conditionally_enabled() {
125+
FakeUnleash fakeUnleash = new FakeUnleash();
126+
fakeUnleash.conditionallyEnable(
127+
context -> "v1".equals(context.getProperties().get("test")), "t1");
128+
Variant variant = new Variant("a", "some payload", true);
129+
fakeUnleash.setVariant("t1", variant);
130+
131+
assertThat(
132+
fakeUnleash.getVariant(
133+
"t1", UnleashContext.builder().addProperty("test", "v1").build()))
134+
.isEqualTo(variant);
135+
assertThat(
136+
fakeUnleash.getVariant(
137+
"t1", UnleashContext.builder().addProperty("test", "v2").build()))
138+
.isEqualTo(Variant.DISABLED_VARIANT);
139+
}
140+
141+
@Test
142+
void should_reset_conditional_features_when_resetting_entire_feature() {
143+
FakeUnleash fakeUnleash = new FakeUnleash();
144+
fakeUnleash.conditionallyEnable(ctx -> true, "t1");
145+
146+
assertThat(fakeUnleash.isEnabled("t1", UnleashContext.builder().build())).isTrue();
147+
148+
fakeUnleash.reset("t1");
149+
150+
assertThat(fakeUnleash.isEnabled("t1", UnleashContext.builder().build())).isFalse();
151+
}
152+
153+
@Test
154+
void should_use_fallback_if_no_matchers_defined() {
155+
FakeUnleash fakeUnleash = new FakeUnleash();
156+
157+
assertThat(
158+
fakeUnleash.isEnabled(
159+
"unknown-toggle",
160+
UnleashContext.builder().addProperty("test", "v1").build(),
161+
(name, context) -> true))
162+
.isTrue();
163+
}
164+
13165
@Test
14166
public void should_enable_all_toggles() throws Exception {
15167
FakeUnleash fakeUnleash = new FakeUnleash();

0 commit comments

Comments
 (0)
Please sign in to comment.