Skip to content

Commit cced215

Browse files
authored
fix: various custom operator conformance fixes (#1778)
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
1 parent a6712ed commit cced215

14 files changed

Lines changed: 244 additions & 47 deletions

File tree

.gitmodules

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
[submodule "providers/flagd/test-harness"]
55
path = providers/flagd/test-harness
66
url = https://github.com/open-feature/test-harness.git
7-
branch = v3.5.0
87
[submodule "providers/flagd/spec"]
98
path = providers/flagd/spec
109
url = https://github.com/open-feature/spec.git
@@ -17,4 +16,3 @@
1716
[submodule "tools/flagd-api-testkit/test-harness"]
1817
path = tools/flagd-api-testkit/test-harness
1918
url = https://github.com/open-feature/test-harness.git
20-
branch = v3.5.0

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@
3636
"events",
3737
"contextEnrichment",
3838
"fractional-v1",
39-
"deprecated",
40-
"operator-errors"
39+
"deprecated"
4140
})
4241
@Testcontainers
4342
public class RunFileTest {

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps")
2929
@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory")
3030
@IncludeTags("in-process")
31-
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"})
31+
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated"})
3232
@Testcontainers
3333
public class RunInProcessTest {
3434

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps")
2929
@ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory")
3030
@IncludeTags({"rpc"})
31-
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated", "operator-errors"})
31+
@ExcludeTags({"unixsocket", "fractional-v1", "deprecated"})
3232
@Testcontainers
3333
public class RunRpcTest {
3434

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,28 @@ public ContextSteps(State state) {
1818
public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value)
1919
throws ClassNotFoundException, InstantiationException {
2020
Map<String, Value> map = state.context.asMap();
21-
map.put(key, new Value(value));
21+
Value typedValue;
22+
switch (type) {
23+
case "Integer":
24+
long longVal = Long.parseLong(value);
25+
if (longVal >= Integer.MIN_VALUE && longVal <= Integer.MAX_VALUE) {
26+
typedValue = new Value((int) longVal);
27+
} else {
28+
// value exceeds int range; store as string to preserve precision
29+
typedValue = new Value(value);
30+
}
31+
break;
32+
case "Float":
33+
typedValue = new Value(Double.parseDouble(value));
34+
break;
35+
case "Boolean":
36+
typedValue = new Value(Boolean.parseBoolean(value));
37+
break;
38+
default:
39+
typedValue = new Value(value);
40+
break;
41+
}
42+
map.put(key, typedValue);
2243
state.context = new MutableContext(state.context.getTargetingKey(), map);
2344
}
2445

providers/flagd/test-harness

tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import java.util.List;
1717
import java.util.Map;
1818
import java.util.Set;
19-
import java.util.regex.Pattern;
2019
import java.util.stream.Collectors;
2120
import lombok.extern.slf4j.Slf4j;
2221

@@ -28,7 +27,6 @@ public class FlagParser {
2827
private static final String FLAG_KEY = "flags";
2928
private static final String METADATA_KEY = "metadata";
3029
private static final String EVALUATOR_KEY = "$evaluators";
31-
private static final String REPLACER_FORMAT = "\"\\$ref\":(\\s)*\"%s\"";
3230
private static final ObjectMapper MAPPER = new ObjectMapper();
3331
private static JsonSchema SCHEMA_VALIDATOR;
3432

@@ -116,32 +114,23 @@ private static Map<String, Object> parseMetadata(TreeNode metadataNode) throws J
116114

117115
private static String transposeEvaluators(final String configuration) throws IOException {
118116
try (JsonParser parser = MAPPER.createParser(configuration)) {
119-
final Map<String, Pattern> patternMap = new HashMap<>();
120117
final TreeNode treeNode = parser.readValueAsTree();
121118
final TreeNode evaluators = treeNode.get(EVALUATOR_KEY);
122119

123120
if (evaluators == null || evaluators.size() == 0) {
124121
return configuration;
125122
}
126123

127-
String replacedConfigurations = configuration;
124+
// round-trip to normalize whitespace so we can use plain string matching
125+
String replacedConfigurations = MAPPER.writeValueAsString(MAPPER.readTree(configuration));
128126
final Iterator<String> evalFields = evaluators.fieldNames();
129127

130128
while (evalFields.hasNext()) {
131129
final String evalName = evalFields.next();
132-
// first replace outmost brackets
133130
final String evaluator = evaluators.get(evalName).toString();
134-
final String replacer = evaluator.substring(1, evaluator.length() - 1);
131+
final String refPattern = "{\"$ref\":\"" + evalName + "\"}";
135132

136-
final String replacePattern = String.format(REPLACER_FORMAT, evalName);
137-
138-
// then derive pattern
139-
final Pattern regReplace =
140-
patternMap.computeIfAbsent(replacePattern, s -> Pattern.compile(replacePattern));
141-
142-
// finally replace all references
143-
replacedConfigurations =
144-
regReplace.matcher(replacedConfigurations).replaceAll(replacer);
133+
replacedConfigurations = replacedConfigurations.replace(refPattern, evaluator);
145134
}
146135

147136
return replacedConfigurations;

tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression;
66
import java.nio.charset.StandardCharsets;
77
import java.util.ArrayList;
8-
import java.util.Arrays;
98
import java.util.List;
109
import lombok.Getter;
1110
import lombok.extern.slf4j.Slf4j;
@@ -25,33 +24,38 @@ public String key() {
2524
}
2625

2726
@Override
27+
@SuppressWarnings("unchecked") // json-logic-java's PreEvaluatedArgumentsExpression uses raw List
2828
public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException {
2929
if (arguments.size() < 1) {
3030
return null;
3131
}
3232

3333
final Operator.FlagProperties properties = new Operator.FlagProperties(data);
3434

35-
// check optional string target in first arg
36-
Object arg1 = arguments.get(0);
37-
3835
final String bucketBy;
39-
final Object[] distributions;
36+
final List<Object> distributions;
4037

41-
if (arg1 instanceof String) {
38+
// json-logic pre-evaluation flattens a single-entry fractional
39+
// e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap
40+
if (isFlattened(arguments)) {
41+
if (properties.getTargetingKey() == null) {
42+
log.debug("Missing fallback targeting key");
43+
return null;
44+
}
45+
bucketBy = properties.getFlagKey() + properties.getTargetingKey();
46+
distributions = List.of(arguments);
47+
} else if (arguments.get(0) instanceof String) {
4248
// first arg is a String, use for bucketing
43-
bucketBy = (String) arg1;
44-
Object[] source = arguments.toArray();
45-
distributions = Arrays.copyOfRange(source, 1, source.length);
49+
bucketBy = (String) arguments.get(0);
50+
distributions = arguments.subList(1, arguments.size());
4651
} else {
4752
// fallback to targeting key if present
4853
if (properties.getTargetingKey() == null) {
4954
log.debug("Missing fallback targeting key");
5055
return null;
5156
}
52-
5357
bucketBy = properties.getFlagKey() + properties.getTargetingKey();
54-
distributions = arguments.toArray();
58+
distributions = arguments;
5559
}
5660

5761
final List<FractionProperty> propertyList = new ArrayList<>();
@@ -93,6 +97,19 @@ private static Object distributeValue(
9397
return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath);
9498
}
9599

100+
/**
101+
* Checks if arguments have been flattened by json-logic pre-evaluation.
102+
* A flattened list contains no List elements (e.g. ["single", 1] instead of [["single", 1]]).
103+
*/
104+
private static boolean isFlattened(List<?> arguments) {
105+
for (Object arg : arguments) {
106+
if (arg instanceof List) {
107+
return false;
108+
}
109+
}
110+
return true;
111+
}
112+
96113
static Object distributeValueFromHash(
97114
final int hash, final List<FractionProperty> propertyList, final int totalWeight, final String jsonPath)
98115
throws JsonLogicEvaluationException {

tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,25 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json
4949
return null;
5050
}
5151

52-
for (int i = 0; i < 3; i++) {
53-
if (!(arguments.get(i) instanceof String)) {
54-
log.debug("Invalid argument type. Require Strings");
55-
return null;
56-
}
52+
// arg 1 and arg 3 must be strings or numbers (coerced to string)
53+
// arg 2 must be a string (operator)
54+
final String arg1Str = coerceToString(arguments.get(0));
55+
final String arg3Str = coerceToString(arguments.get(2));
56+
57+
if (arg1Str == null || arg3Str == null) {
58+
log.debug("Arguments 1 and 3 must be strings or numbers");
59+
return null;
60+
}
61+
62+
if (!(arguments.get(1) instanceof String)) {
63+
log.debug("Argument 2 (operator) must be a string");
64+
return null;
5765
}
5866

5967
// arg 1 should be a SemVer
6068
final Semver arg1Parsed;
6169

62-
if ((arg1Parsed = Semver.parse((String) arguments.get(0))) == null) {
70+
if ((arg1Parsed = normalizeVersion(arg1Str)) == null) {
6371
log.debug("Argument one is not a valid SemVer");
6472
return null;
6573
}
@@ -75,14 +83,58 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json
7583
// arg 3 should be a SemVer
7684
final Semver arg3Parsed;
7785

78-
if ((arg3Parsed = Semver.parse((String) arguments.get(2))) == null) {
86+
if ((arg3Parsed = normalizeVersion(arg3Str)) == null) {
7987
log.debug("Argument three is not a valid SemVer");
8088
return null;
8189
}
8290

8391
return compare(arg2Parsed, arg1Parsed, arg3Parsed, jsonPath);
8492
}
8593

94+
/**
95+
* Coerce a value to a string representation suitable for semver parsing.
96+
*/
97+
private static String coerceToString(Object value) {
98+
if (value instanceof String) {
99+
return (String) value;
100+
}
101+
if (value instanceof Number) {
102+
Number num = (Number) value;
103+
double dub = num.doubleValue();
104+
if (dub == Math.floor(dub) && !Double.isInfinite(dub)) {
105+
return String.valueOf(num.longValue());
106+
}
107+
return String.valueOf(dub);
108+
}
109+
return null;
110+
}
111+
112+
/**
113+
* Parse a semver string, handling v-prefix (case-insensitive) and partial versions.
114+
*/
115+
private static Semver normalizeVersion(String version) {
116+
// strip v/V prefix
117+
String stripped = version;
118+
if (stripped.startsWith("v") || stripped.startsWith("V")) {
119+
stripped = stripped.substring(1);
120+
}
121+
122+
// try strict parse first
123+
Semver result = Semver.parse(stripped);
124+
if (result != null) {
125+
return result;
126+
}
127+
128+
// fall back to coerce for partial versions (fewer than 2 dots)
129+
// do not coerce strings that have too many parts (e.g. "2.0.0.0")
130+
long dotCount = stripped.chars().filter(c -> c == '.').count();
131+
if (dotCount < 2) {
132+
return Semver.coerce(stripped);
133+
}
134+
135+
return null;
136+
}
137+
86138
private static boolean compare(final String operator, final Semver arg1, final Semver arg2, final String jsonPath)
87139
throws JsonLogicEvaluationException {
88140

0 commit comments

Comments
 (0)