From 17eaa6a141c055b7e5cea1f6e9f4876e4530a6b2 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 11:11:27 -0400 Subject: [PATCH 1/3] 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 d7c20b455988b6701577c8f4f5d321d379d7c25c Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 13:10:22 -0400 Subject: [PATCH 2/3] 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 0c7570a2879001b37b9546f40e0fe893d8685ce0 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 22 Apr 2026 14:09:23 -0400 Subject: [PATCH 3/3] 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;