From 106cca8102361666bd3a776c8cf52d41e8162394 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 11:11:27 -0400 Subject: [PATCH 1/7] fix: various custom operator conformance fixes * support v/V prefix in semver * support partial versions in semver * support numeric context values in semver * return null on errors * fix fractional single-entry flattening Signed-off-by: Todd Baert --- .../providers/flagd/e2e/RunInProcessTest.java | 2 +- .../providers/flagd/e2e/RunRpcTest.java | 11 +++- .../flagd/e2e/steps/ContextSteps.java | 23 ++++++- providers/flagd/test-harness | 2 +- tools/flagd-api-testkit/test-harness | 2 +- .../flagd/core/targeting/Fractional.java | 20 +++++- .../tools/flagd/core/targeting/SemVer.java | 66 +++++++++++++++++-- .../core/e2e/FlagdCoreEvaluatorTest.java | 2 +- .../flagd/core/targeting/FractionalTest.java | 37 +++++++++++ .../flagd/core/targeting/SemVerTest.java | 32 ++++++++- .../flagd/core/targeting/StringCompTest.java | 27 +++++++- 11 files changed, 204 insertions(+), 20 deletions(-) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java index 475d377f4..82a326244 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java @@ -28,7 +28,7 @@ @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags("in-process") -@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"}) +@ExcludeTags({"unixsocket", "fractional-v1", "deprecated"}) @Testcontainers public class RunInProcessTest { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java index 491e8dd7e..3c98db7f8 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java @@ -28,7 +28,16 @@ @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags({"rpc"}) -@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"}) +@ExcludeTags({ + "unixsocket", + "fractional-v1", + "deprecated", + "operator-errors", + "semver-edge-cases", + "evaluator-refs-whitespace", + "non-existent-evaluator-ref", + "fractional-single-entry" +}) @Testcontainers public class RunRpcTest { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java index 42e466a35..caa66ee5c 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java @@ -18,7 +18,28 @@ public ContextSteps(State state) { public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) throws ClassNotFoundException, InstantiationException { Map map = state.context.asMap(); - map.put(key, new Value(value)); + Value typedValue; + switch (type) { + case "Integer": + long longVal = Long.parseLong(value); + if (longVal >= Integer.MIN_VALUE && longVal <= Integer.MAX_VALUE) { + typedValue = new Value((int) longVal); + } else { + // value exceeds int range; store as string to preserve precision + typedValue = new Value(value); + } + break; + case "Float": + typedValue = new Value(Double.parseDouble(value)); + break; + case "Boolean": + typedValue = new Value(Boolean.parseBoolean(value)); + break; + default: + typedValue = new Value(value); + break; + } + map.put(key, typedValue); state.context = new MutableContext(state.context.getTargetingKey(), map); } diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index ff2fbe6c6..190307b3b 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit ff2fbe6c6584953cb2753ae9188d1cee14f7f57f +Subproject commit 190307b3b1982773976f05464942f69bb23528a4 diff --git a/tools/flagd-api-testkit/test-harness b/tools/flagd-api-testkit/test-harness index ff2fbe6c6..190307b3b 160000 --- a/tools/flagd-api-testkit/test-harness +++ b/tools/flagd-api-testkit/test-harness @@ -1 +1 @@ -Subproject commit ff2fbe6c6584953cb2753ae9188d1cee14f7f57f +Subproject commit 190307b3b1982773976f05464942f69bb23528a4 diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 156d00e8a..f63e5d533 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -36,13 +36,27 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json Object arg1 = arguments.get(0); final String bucketBy; - final Object[] distributions; + final List distributions; if (arg1 instanceof String) { // first arg is a String, use for bucketing bucketBy = (String) arg1; Object[] source = arguments.toArray(); - distributions = Arrays.copyOfRange(source, 1, source.length); + Object[] remaining = Arrays.copyOfRange(source, 1, source.length); + + // json-logic pre-evaluation flattens a single-entry fractional + // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap + if (remaining.length > 0 && !(remaining[0] instanceof List)) { + List wrapped = new ArrayList<>(); + wrapped.add(arg1); + for (Object r : remaining) { + wrapped.add(r); + } + distributions = new ArrayList<>(); + distributions.add(wrapped); + } else { + distributions = Arrays.asList(remaining); + } } else { // fallback to targeting key if present if (properties.getTargetingKey() == null) { @@ -51,7 +65,7 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json } bucketBy = properties.getFlagKey() + properties.getTargetingKey(); - distributions = arguments.toArray(); + distributions = arguments; } final List propertyList = new ArrayList<>(); diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java index 44573904b..049fa2983 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java @@ -49,17 +49,25 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return null; } - for (int i = 0; i < 3; i++) { - if (!(arguments.get(i) instanceof String)) { - log.debug("Invalid argument type. Require Strings"); - return null; - } + // arg 1 and arg 3 must be strings or numbers (coerced to string) + // arg 2 must be a string (operator) + final String arg1Str = coerceToString(arguments.get(0)); + final String arg3Str = coerceToString(arguments.get(2)); + + if (arg1Str == null || arg3Str == null) { + log.debug("Arguments 1 and 3 must be strings or numbers"); + return null; + } + + if (!(arguments.get(1) instanceof String)) { + log.debug("Argument 2 (operator) must be a string"); + return null; } // arg 1 should be a SemVer final Semver arg1Parsed; - if ((arg1Parsed = Semver.parse((String) arguments.get(0))) == null) { + if ((arg1Parsed = normalizeVersion(arg1Str)) == null) { log.debug("Argument one is not a valid SemVer"); return null; } @@ -75,7 +83,7 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json // arg 3 should be a SemVer final Semver arg3Parsed; - if ((arg3Parsed = Semver.parse((String) arguments.get(2))) == null) { + if ((arg3Parsed = normalizeVersion(arg3Str)) == null) { log.debug("Argument three is not a valid SemVer"); return null; } @@ -83,6 +91,50 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return compare(arg2Parsed, arg1Parsed, arg3Parsed, jsonPath); } + /** + * Coerce a value to a string representation suitable for semver parsing. + */ + private static String coerceToString(Object value) { + if (value instanceof String) { + return (String) value; + } + if (value instanceof Number) { + Number num = (Number) value; + double dub = num.doubleValue(); + if (dub == Math.floor(dub) && !Double.isInfinite(dub)) { + return String.valueOf(num.longValue()); + } + return String.valueOf(dub); + } + return null; + } + + /** + * Parse a semver string, handling v-prefix (case-insensitive) and partial versions. + */ + private static Semver normalizeVersion(String version) { + // strip v/V prefix + String stripped = version; + if (stripped.startsWith("v") || stripped.startsWith("V")) { + stripped = stripped.substring(1); + } + + // try strict parse first + Semver result = Semver.parse(stripped); + if (result != null) { + return result; + } + + // fall back to coerce for partial versions (fewer than 2 dots) + // do not coerce strings that have too many parts (e.g. "2.0.0.0") + long dotCount = stripped.chars().filter(c -> c == '.').count(); + if (dotCount < 2) { + return Semver.coerce(stripped); + } + + return null; + } + private static boolean compare(final String operator, final Semver arg1, final Semver arg2, final String jsonPath) throws JsonLogicEvaluationException { diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java index a7d2ecc12..941d978d5 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java @@ -11,7 +11,7 @@ * configuration. Registered as an {@link dev.openfeature.contrib.tools.flagd.api.testkit.EvaluatorFactory} * via {@code META-INF/services}. */ -@ExcludeTags({"fractional-v1"}) +@ExcludeTags({"fractional-v1", "evaluator-refs-whitespace", "non-existent-evaluator-ref"}) public class FlagdCoreEvaluatorTest extends AbstractEvaluatorTest { @Override diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 6bdd45b29..d2171ec42 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -4,6 +4,7 @@ import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAG_KEY; import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.TARGET_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; @@ -19,6 +20,7 @@ import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; @@ -80,4 +82,39 @@ static class TestData { @JsonProperty("rule") List rule; } + + @Test + void missingBucketKeyReturnsNull() throws JsonLogicEvaluationException { + // no targeting key in data; bucket key var resolves to null + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + // no TARGET_KEY set + + List rule = List.of( + // bucket key is a null var result (simulated by being a non-string, non-list) + List.of("one", 50), List.of("two", 50)); + + // targeting key is null, so fractional falls back to flagKey + targetingKey + // but targetingKey is null, so it should return null + assertNull(fractional.evaluate(rule, data, "path")); + } + + @Test + void zeroWeightsReturnsNull() throws JsonLogicEvaluationException { + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of(List.of("one", 0), List.of("two", 0)); + + assertNull(fractional.evaluate(rule, data, "path")); + } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java index 39692a53a..38fcd81d1 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.tools.flagd.core.targeting; import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -45,10 +46,22 @@ void testValidCases(List args) throws JsonLogicEvaluationException { static Stream invalidInputs() { return Stream.of( - Arguments.of(Arrays.asList("1.2.3", "=", 1.2)), - Arguments.of(Arrays.asList("1.2", "=", "1.2.3")), + // invalid operator Arguments.of(Arrays.asList("1.2.3", "*", "1.2.3")), - Arguments.of(Arrays.asList("1.2.3", "=", "1.2"))); + // wrong argument count (too few) + Arguments.of(Arrays.asList("1.0.0", "=")), + // wrong argument count (too many) + Arguments.of(Arrays.asList("1.0.0", "=", "1.0.0", "extra"))); + } + + static Stream coercedInputs() { + return Stream.of( + // numeric third arg coerced to semver + Arguments.of(Arrays.asList("1.2.3", "=", 1.2), false), + // partial version coerced + Arguments.of(Arrays.asList("1.2", "=", "1.2.3"), false), + Arguments.of(Arrays.asList("1.2.3", "=", "1.2"), false), + Arguments.of(Arrays.asList("1.2.0", "=", "1.2"), true)); } @ParameterizedTest @@ -60,4 +73,17 @@ void testInvalidCases(List args) throws JsonLogicEvaluationException { // then assertNull(semVer.evaluate(args, new Object(), "jsonPath")); } + + @ParameterizedTest + @MethodSource("coercedInputs") + void testCoercedCases(List args, boolean expected) throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(args, new Object(), "jsonPath"); + + // then + assertEquals(expected, result); + } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java index 8deed2acb..6b15ca2b6 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java @@ -70,10 +70,35 @@ public void invalidNumberOfArgs() throws JsonLogicEvaluationException { // given final StringComp operator = new StringComp(StringComp.Type.STARTS_WITH); - // when + // when - too many args Object result = operator.evaluate(Arrays.asList("123", "12", "1"), new Object(), "jsonPath"); // then assertThat(result).isNull(); } + + @Test + public void tooFewArgs() throws JsonLogicEvaluationException { + // given + final StringComp startsWith = new StringComp(StringComp.Type.STARTS_WITH); + final StringComp endsWith = new StringComp(StringComp.Type.ENDS_WITH); + + // when/then - single arg returns null + assertThat(startsWith.evaluate(Arrays.asList("abc"), new Object(), "jsonPath")) + .isNull(); + assertThat(endsWith.evaluate(Arrays.asList("xyz"), new Object(), "jsonPath")) + .isNull(); + } + + @Test + public void endsWithNonStringInput() throws JsonLogicEvaluationException { + // given + final StringComp operator = new StringComp(StringComp.Type.ENDS_WITH); + + // when - non-string first arg + Object result = operator.evaluate(Arrays.asList(123, "abc"), new Object(), "jsonPath"); + + // then + assertThat(result).isNull(); + } } From 05c7d3d4b4b8809327994b1da522fb5c640a57f4 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 13:10:22 -0400 Subject: [PATCH 2/7] fixup: gemini feedback Signed-off-by: Todd Baert --- .../tools/flagd/core/targeting/Fractional.java | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index f63e5d533..a17b9ae53 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -5,7 +5,6 @@ import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -25,6 +24,7 @@ public String key() { } @Override + @SuppressWarnings("unchecked") // json-logic-java's PreEvaluatedArgumentsExpression uses raw List public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException { if (arguments.size() < 1) { return null; @@ -41,21 +41,17 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json if (arg1 instanceof String) { // first arg is a String, use for bucketing bucketBy = (String) arg1; - Object[] source = arguments.toArray(); - Object[] remaining = Arrays.copyOfRange(source, 1, source.length); + List remaining = arguments.subList(1, arguments.size()); // json-logic pre-evaluation flattens a single-entry fractional // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap - if (remaining.length > 0 && !(remaining[0] instanceof List)) { - List wrapped = new ArrayList<>(); + if (!remaining.isEmpty() && !(remaining.get(0) instanceof List)) { + List wrapped = new ArrayList<>(remaining.size() + 1); wrapped.add(arg1); - for (Object r : remaining) { - wrapped.add(r); - } - distributions = new ArrayList<>(); - distributions.add(wrapped); + wrapped.addAll(remaining); + distributions = List.of(wrapped); } else { - distributions = Arrays.asList(remaining); + distributions = remaining; } } else { // fallback to targeting key if present From 23abf5aa7b1aaa89ec9267881093ca7a10ec0706 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 14:09:23 -0400 Subject: [PATCH 3/7] fixup: improve ref regex reliability by parseing first Signed-off-by: Todd Baert --- .../tools/flagd/core/model/FlagParser.java | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java index d15d93419..3c8da2225 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java @@ -16,7 +16,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -28,7 +27,6 @@ public class FlagParser { private static final String FLAG_KEY = "flags"; private static final String METADATA_KEY = "metadata"; private static final String EVALUATOR_KEY = "$evaluators"; - private static final String REPLACER_FORMAT = "\"\\$ref\":(\\s)*\"%s\""; private static final ObjectMapper MAPPER = new ObjectMapper(); private static JsonSchema SCHEMA_VALIDATOR; @@ -116,7 +114,6 @@ private static Map parseMetadata(TreeNode metadataNode) throws J private static String transposeEvaluators(final String configuration) throws IOException { try (JsonParser parser = MAPPER.createParser(configuration)) { - final Map patternMap = new HashMap<>(); final TreeNode treeNode = parser.readValueAsTree(); final TreeNode evaluators = treeNode.get(EVALUATOR_KEY); @@ -124,24 +121,16 @@ private static String transposeEvaluators(final String configuration) throws IOE return configuration; } - String replacedConfigurations = configuration; + // round-trip to normalize whitespace so we can use plain string matching + String replacedConfigurations = MAPPER.writeValueAsString(MAPPER.readTree(configuration)); final Iterator evalFields = evaluators.fieldNames(); while (evalFields.hasNext()) { final String evalName = evalFields.next(); - // first replace outmost brackets final String evaluator = evaluators.get(evalName).toString(); - final String replacer = evaluator.substring(1, evaluator.length() - 1); + final String refPattern = "{\"$ref\":\"" + evalName + "\"}"; - final String replacePattern = String.format(REPLACER_FORMAT, evalName); - - // then derive pattern - final Pattern regReplace = - patternMap.computeIfAbsent(replacePattern, s -> Pattern.compile(replacePattern)); - - // finally replace all references - replacedConfigurations = - regReplace.matcher(replacedConfigurations).replaceAll(replacer); + replacedConfigurations = replacedConfigurations.replace(refPattern, evaluator); } return replacedConfigurations; From 951574236b31234102bb523392d35a75ba740eee Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 30 Apr 2026 14:46:15 -0400 Subject: [PATCH 4/7] fixup: remove branch from gitmodules Signed-off-by: Todd Baert --- .gitmodules | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 2b4152ccf..106b0d69f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,7 +4,6 @@ [submodule "providers/flagd/test-harness"] path = providers/flagd/test-harness url = https://github.com/open-feature/test-harness.git - branch = v3.5.0 [submodule "providers/flagd/spec"] path = providers/flagd/spec url = https://github.com/open-feature/spec.git @@ -17,4 +16,3 @@ [submodule "tools/flagd-api-testkit/test-harness"] path = tools/flagd-api-testkit/test-harness url = https://github.com/open-feature/test-harness.git - branch = v3.5.0 From 16feaccd3c2131f0bd9d8aed634c1427e30dd0e6 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 30 Apr 2026 15:36:04 -0400 Subject: [PATCH 5/7] fixup: update testbed and remove RPC excludes Signed-off-by: Todd Baert --- .../contrib/providers/flagd/e2e/RunFileTest.java | 3 +-- .../contrib/providers/flagd/e2e/RunRpcTest.java | 7 +------ providers/flagd/test-harness | 2 +- tools/flagd-api-testkit/test-harness | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java index e5661e38c..dc4397659 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java @@ -36,8 +36,7 @@ "events", "contextEnrichment", "fractional-v1", - "deprecated", - "operator-errors" + "deprecated" }) @Testcontainers public class RunFileTest { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java index 3c98db7f8..52acc017b 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java @@ -31,12 +31,7 @@ @ExcludeTags({ "unixsocket", "fractional-v1", - "deprecated", - "operator-errors", - "semver-edge-cases", - "evaluator-refs-whitespace", - "non-existent-evaluator-ref", - "fractional-single-entry" + "deprecated" }) @Testcontainers public class RunRpcTest { diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index 190307b3b..b507289c4 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit 190307b3b1982773976f05464942f69bb23528a4 +Subproject commit b507289c45fca9c2d312c7231929e5b95eae62bb diff --git a/tools/flagd-api-testkit/test-harness b/tools/flagd-api-testkit/test-harness index 190307b3b..b507289c4 160000 --- a/tools/flagd-api-testkit/test-harness +++ b/tools/flagd-api-testkit/test-harness @@ -1 +1 @@ -Subproject commit 190307b3b1982773976f05464942f69bb23528a4 +Subproject commit b507289c45fca9c2d312c7231929e5b95eae62bb From a172c687a6116cef4f623d0c277e114bb95cb6cd Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 30 Apr 2026 15:54:50 -0400 Subject: [PATCH 6/7] fixup: spotless Signed-off-by: Todd Baert --- .../openfeature/contrib/providers/flagd/e2e/RunRpcTest.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java index 52acc017b..778ef918b 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java @@ -28,11 +28,7 @@ @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags({"rpc"}) -@ExcludeTags({ - "unixsocket", - "fractional-v1", - "deprecated" -}) +@ExcludeTags({"unixsocket", "fractional-v1", "deprecated"}) @Testcontainers public class RunRpcTest { From 378a06ca1a1170f0261c0c2b4c65cc76f393eccf Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 30 Apr 2026 16:18:27 -0400 Subject: [PATCH 7/7] fixup: unflatten both branches Signed-off-by: Todd Baert --- .../flagd/core/targeting/Fractional.java | 43 +++++++++++-------- .../flagd/core/targeting/FractionalTest.java | 35 ++++++++++++++- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index a17b9ae53..b0800ed75 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -32,34 +32,28 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json final Operator.FlagProperties properties = new Operator.FlagProperties(data); - // check optional string target in first arg - Object arg1 = arguments.get(0); - final String bucketBy; final List distributions; - if (arg1 instanceof String) { - // first arg is a String, use for bucketing - bucketBy = (String) arg1; - List remaining = arguments.subList(1, arguments.size()); - - // json-logic pre-evaluation flattens a single-entry fractional - // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap - if (!remaining.isEmpty() && !(remaining.get(0) instanceof List)) { - List wrapped = new ArrayList<>(remaining.size() + 1); - wrapped.add(arg1); - wrapped.addAll(remaining); - distributions = List.of(wrapped); - } else { - distributions = remaining; + // json-logic pre-evaluation flattens a single-entry fractional + // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap + if (isFlattened(arguments)) { + if (properties.getTargetingKey() == null) { + log.debug("Missing fallback targeting key"); + return null; } + bucketBy = properties.getFlagKey() + properties.getTargetingKey(); + distributions = List.of(arguments); + } else if (arguments.get(0) instanceof String) { + // first arg is a String, use for bucketing + bucketBy = (String) arguments.get(0); + distributions = arguments.subList(1, arguments.size()); } else { // fallback to targeting key if present if (properties.getTargetingKey() == null) { log.debug("Missing fallback targeting key"); return null; } - bucketBy = properties.getFlagKey() + properties.getTargetingKey(); distributions = arguments; } @@ -103,6 +97,19 @@ private static Object distributeValue( return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath); } + /** + * Checks if arguments have been flattened by json-logic pre-evaluation. + * A flattened list contains no List elements (e.g. ["single", 1] instead of [["single", 1]]). + */ + private static boolean isFlattened(List arguments) { + for (Object arg : arguments) { + if (arg instanceof List) { + return false; + } + } + return true; + } + static Object distributeValueFromHash( final int hash, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index d2171ec42..046d8914f 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -98,11 +98,44 @@ void missingBucketKeyReturnsNull() throws JsonLogicEvaluationException { // bucket key is a null var result (simulated by being a non-string, non-list) List.of("one", 50), List.of("two", 50)); - // targeting key is null, so fractional falls back to flagKey + targetingKey + // bucketing key is null, so fractional falls back to flagKey + targetingKey // but targetingKey is null, so it should return null assertNull(fractional.evaluate(rule, data, "path")); } + @Test + void singleEntryFractionalWithNonStringVariant() throws JsonLogicEvaluationException { + // simulates pre-evaluation flattening of [[100, 1]] -> [100, 1] + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of(100, 1); + + assertEquals(100, fractional.evaluate(rule, data, "path")); + } + + @Test + void singleEntryFractionalWithStringVariant() throws JsonLogicEvaluationException { + // simulates pre-evaluation flattening of [["single", 1]] -> ["single", 1] + // "single" looks like a bucketing key but is actually the variant + Fractional fractional = new Fractional(); + + Map data = new HashMap<>(); + data.put(TARGET_KEY, "user"); + Map flagdProperties = new HashMap<>(); + flagdProperties.put(FLAG_KEY, "flagA"); + data.put(FLAGD_PROPS_KEY, flagdProperties); + + List rule = List.of("single", 1); + + assertEquals("single", fractional.evaluate(rule, data, "path")); + } + @Test void zeroWeightsReturnsNull() throws JsonLogicEvaluationException { Fractional fractional = new Fractional();