diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml
index 57a6758ac..cc83f91d1 100644
--- a/providers/flagd/pom.xml
+++ b/providers/flagd/pom.xml
@@ -114,6 +114,13 @@
opentelemetry-api
1.30.1
+
+
+ org.semver4j
+ semver4j
+ 5.1.0
+
+
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java
index fb4c42cc4..339adbd2a 100644
--- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java
@@ -7,14 +7,14 @@
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage;
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState;
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.grpc.GrpcStreamConnector;
+import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator;
+import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.TargetingRuleException;
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.ProviderState;
import dev.openfeature.sdk.Reason;
import dev.openfeature.sdk.Value;
-import io.github.jamsesso.jsonlogic.JsonLogic;
-import io.github.jamsesso.jsonlogic.JsonLogicException;
import lombok.extern.java.Log;
import java.util.function.Consumer;
@@ -30,7 +30,7 @@
public class InProcessResolver implements Resolver {
private final Storage flagStore;
private final Consumer stateConsumer;
- private final JsonLogic jsonLogicHandler;
+ private final Operator operator;
/**
* Initialize an in-process resolver.
@@ -39,7 +39,7 @@ public InProcessResolver(FlagdOptions options, Consumer stateCons
// currently we support gRPC connector
this.flagStore = new FlagStore(new GrpcStreamConnector(options));
this.stateConsumer = stateConsumer;
- jsonLogicHandler = new JsonLogic();
+ this.operator = new Operator();
}
/**
@@ -160,7 +160,7 @@ private ProviderEvaluation resolve(Class type, String key, T defaultVa
reason = Reason.STATIC.toString();
} else {
try {
- final Object jsonResolved = jsonLogicHandler.apply(flag.getTargeting(), ctx.asObjectMap());
+ final Object jsonResolved = operator.apply(key, flag.getTargeting(), ctx);
if (jsonResolved == null) {
resolvedVariant = flag.getDefaultVariant();
reason = Reason.DEFAULT.toString();
@@ -168,7 +168,7 @@ private ProviderEvaluation resolve(Class type, String key, T defaultVa
resolvedVariant = jsonResolved;
reason = Reason.TARGETING_MATCH.toString();
}
- } catch (JsonLogicException e) {
+ } catch (TargetingRuleException e) {
log.log(Level.FINE, "Error evaluating targeting rule", e);
return ProviderEvaluation.builder()
.value(defaultValue)
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java
index a0afdc760..7a916b813 100644
--- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java
@@ -10,11 +10,9 @@
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.extern.java.Log;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
+import java.io.InputStream;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
@@ -42,15 +40,18 @@ public class FlagParser {
private static JsonSchema SCHEMA_VALIDATOR;
static {
- try {
- final URL url = FlagParser.class.getClassLoader().getResource(SCHEMA_RESOURCE);
- if (url == null) {
+ try (InputStream schema = FlagParser.class.getClassLoader().getResourceAsStream(SCHEMA_RESOURCE)) {
+ if (schema == null) {
log.log(Level.WARNING, String.format("Resource %s not found", SCHEMA_RESOURCE));
} else {
- byte[] bytes = Files.readAllBytes(Paths.get(url.getPath()));
- String schemaString = new String(bytes, StandardCharsets.UTF_8);
+ final ByteArrayOutputStream result = new ByteArrayOutputStream();
+ byte[] buffer = new byte[512];
+ for (int size; 0 < (size = schema.read(buffer)); ) {
+ result.write(buffer, 0, size);
+ }
+
JsonSchemaFactory instance = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
- SCHEMA_VALIDATOR = instance.getSchema(schemaString);
+ SCHEMA_VALIDATOR = instance.getSchema(result.toString("UTF-8"));
}
} catch (Throwable e) {
// log only, do not throw
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java
new file mode 100644
index 000000000..354201cf4
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java
@@ -0,0 +1,127 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+import io.github.jamsesso.jsonlogic.JsonLogicException;
+import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException;
+import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression;
+import lombok.Getter;
+import lombok.extern.java.Log;
+import org.apache.commons.codec.digest.MurmurHash3;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.logging.Level;
+
+@Log
+class Fractional implements PreEvaluatedArgumentsExpression {
+
+ public String key() {
+ return "fractional";
+ }
+
+ public Object evaluate(List arguments, Object data) throws JsonLogicEvaluationException {
+ if (arguments.size() < 2) {
+ return null;
+ }
+
+ final Operator.FlagProperties properties = new Operator.FlagProperties(data);
+
+ // check optional string target in first arg
+ Object arg1 = arguments.get(0);
+
+ final String bucketBy;
+ final Object[] distibutions;
+
+ if (arg1 instanceof String) {
+ // first arg is a String, use for bucketing
+ bucketBy = (String) arg1;
+
+ Object[] source = arguments.toArray();
+ distibutions = Arrays.copyOfRange(source, 1, source.length);
+ } else {
+ // fallback to targeting key if present
+ if (properties.getTargetingKey() == null) {
+ log.log(Level.FINE, "Missing fallback targeting key");
+ return null;
+ }
+
+ bucketBy = properties.getTargetingKey();
+ distibutions = arguments.toArray();
+ }
+
+ final String hashKey = properties.getFlagKey() + bucketBy;
+ final List propertyList = new ArrayList<>();
+
+ double distribution = 0;
+ try {
+ for (Object dist : distibutions) {
+ FractionProperty fractionProperty = new FractionProperty(dist);
+ propertyList.add(fractionProperty);
+ distribution += fractionProperty.getPercentage();
+ }
+ } catch (JsonLogicException e) {
+ log.log(Level.FINE, "Error parsing fractional targeting rule", e);
+ return null;
+ }
+
+ if (distribution != 100) {
+ log.log(Level.FINE, "Fractional properties do not sum to 100");
+ return null;
+ }
+
+ // find distribution
+ return distributeValue(hashKey, propertyList);
+ }
+
+ private static String distributeValue(final String hashKey, final List propertyList)
+ throws JsonLogicEvaluationException {
+ byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8);
+ int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0);
+ int bucket = (int) ((Math.abs(mmrHash) * 1.0f / Integer.MAX_VALUE) * 100);
+
+ int bucketSum = 0;
+ for (FractionProperty p : propertyList) {
+ bucketSum += p.getPercentage();
+
+ if (bucket < bucketSum) {
+ return p.getVariant();
+ }
+ }
+
+ // this shall not be reached
+ throw new JsonLogicEvaluationException("Unable to find a correct bucket");
+ }
+
+ @Getter
+ private static class FractionProperty {
+ private final String variant;
+ private final int percentage;
+
+ FractionProperty(final Object from) throws JsonLogicException {
+ if (!(from instanceof List>)) {
+ throw new JsonLogicException("Property is not an array");
+ }
+
+ final List> array = (List) from;
+
+ if (array.size() != 2) {
+ throw new JsonLogicException("Fraction property does not have two elements");
+ }
+
+ // first must be a string
+ if (!(array.get(0) instanceof String)) {
+ throw new JsonLogicException("First element of the fraction property is not a string variant");
+ }
+
+ // second element must be a number
+ if (!(array.get(1) instanceof Number)) {
+ throw new JsonLogicException("Second element of the fraction property is not a number");
+ }
+
+ variant = (String) array.get(0);
+ percentage = ((Number) array.get(1)).intValue();
+ }
+
+ }
+}
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java
new file mode 100644
index 000000000..13352c698
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java
@@ -0,0 +1,77 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+import dev.openfeature.sdk.EvaluationContext;
+import io.github.jamsesso.jsonlogic.JsonLogic;
+import io.github.jamsesso.jsonlogic.JsonLogicException;
+import lombok.Getter;
+
+import java.util.Map;
+
+/**
+ * Targeting operator wraps JsonLogic handlers and expose a simple API for external layers.
+ * This helps to isolate external dependencies to this package.
+ */
+public class Operator {
+
+ static final String FLAG_KEY = "$flagKey";
+ static final String TARGET_KEY = "targetingKey";
+
+ private final JsonLogic jsonLogicHandler;
+
+ /**
+ * Construct a targeting operator.
+ */
+ public Operator() {
+ jsonLogicHandler = new JsonLogic();
+ jsonLogicHandler.addOperation(new Fractional());
+ jsonLogicHandler.addOperation(new SemVer());
+ jsonLogicHandler.addOperation(new StringComp(StringComp.Type.STARTS_WITH));
+ jsonLogicHandler.addOperation(new StringComp(StringComp.Type.ENDS_WITH));
+ }
+
+ /**
+ * Apply this operator on the provided rule.
+ */
+ public Object apply(final String flagKey, final String targetingRule, final EvaluationContext ctx)
+ throws TargetingRuleException {
+ final Map valueMap = ctx.asObjectMap();
+ valueMap.put(FLAG_KEY, flagKey);
+
+ try {
+ return jsonLogicHandler.apply(targetingRule, valueMap);
+ } catch (JsonLogicException e) {
+ throw new TargetingRuleException("Error evaluating json logic", e);
+ }
+ }
+
+ @Getter
+ static class FlagProperties {
+ private final String flagKey;
+ private final String targetingKey;
+
+ FlagProperties(Object from) {
+ if (from instanceof Map) {
+ Map, ?> dataMap = (Map, ?>) from;
+
+ Object flagKey = dataMap.get(FLAG_KEY);
+
+ if (flagKey instanceof String) {
+ this.flagKey = (String) flagKey;
+ } else {
+ this.flagKey = null;
+ }
+
+ Object targetKey = dataMap.get(TARGET_KEY);
+
+ if (targetKey instanceof String) {
+ targetingKey = (String) targetKey;
+ } else {
+ targetingKey = null;
+ }
+ } else {
+ flagKey = null;
+ targetingKey = null;
+ }
+ }
+ }
+}
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java
new file mode 100644
index 000000000..4df8efe60
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java
@@ -0,0 +1,111 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException;
+import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression;
+import lombok.extern.java.Log;
+import org.semver4j.Semver;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Level;
+
+@Log
+class SemVer implements PreEvaluatedArgumentsExpression {
+
+ private static final String EQ = "=";
+ private static final String NEQ = "!=";
+ private static final String LT = "<";
+ private static final String LTE = "<=";
+ private static final String GT = ">";
+ private static final String GTE = ">=";
+ private static final String MAJOR = "^";
+ private static final String MINOR = "~";
+
+ private static final Set OPS = new HashSet<>();
+
+ static {
+ OPS.add(EQ);
+ OPS.add(NEQ);
+ OPS.add(LT);
+ OPS.add(LTE);
+ OPS.add(GT);
+ OPS.add(GTE);
+ OPS.add(MAJOR);
+ OPS.add(MINOR);
+ }
+
+ public String key() {
+ return "sem_ver";
+ }
+
+ public Object evaluate(List arguments, Object data) throws JsonLogicEvaluationException {
+
+ if (arguments.size() != 3) {
+ log.log(Level.FINE, "Incorrect number of arguments for sem_ver operator");
+ return null;
+ }
+
+ for (int i = 0; i < 3; i++) {
+ if (!(arguments.get(i) instanceof String)) {
+ log.log(Level.FINE, "Invalid argument type. Require Strings");
+ return null;
+ }
+ }
+
+ // arg 1 should be a SemVer
+ final Semver arg1Parsed;
+
+ if ((arg1Parsed = Semver.parse((String) arguments.get(0))) == null) {
+ log.log(Level.FINE, "Argument one is not a valid SemVer");
+ return null;
+ }
+
+ // arg 2 should be the supported operator
+ final String arg2Parsed = (String) arguments.get(1);
+
+ if (!OPS.contains(arg2Parsed)) {
+ log.log(Level.FINE, String.format("Not valid operator in argument 2. Received: %s", arg2Parsed));
+ return null;
+ }
+
+ // arg 3 should be a SemVer
+ final Semver arg3Parsed;
+
+ if ((arg3Parsed = Semver.parse((String) arguments.get(2))) == null) {
+ log.log(Level.FINE, "Argument three is not a valid SemVer");
+ return null;
+ }
+
+ return compare(arg2Parsed, arg1Parsed, arg3Parsed);
+ }
+
+ private static boolean compare(final String operator, final Semver arg1, final Semver arg2)
+ throws JsonLogicEvaluationException {
+
+ int comp = arg1.compareTo(arg2);
+
+ switch (operator) {
+ case EQ:
+ return comp == 0;
+ case NEQ:
+ return comp != 0;
+ case LT:
+ return comp < 0;
+ case LTE:
+ return comp <= 0;
+ case GT:
+ return comp > 0;
+ case GTE:
+ return comp >= 0;
+ case MAJOR:
+ return arg1.getMajor() == arg2.getMajor();
+ case MINOR:
+ return arg1.getMinor() == arg2.getMinor() && arg1.getMajor() == arg2.getMajor();
+ default:
+ throw new JsonLogicEvaluationException(
+ String.format("Unsupported operator received. Operator: %s", operator));
+ }
+ }
+
+}
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java
new file mode 100644
index 000000000..b55d2a4f8
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java
@@ -0,0 +1,68 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException;
+import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression;
+import lombok.extern.java.Log;
+
+import java.util.List;
+import java.util.logging.Level;
+
+@Log
+class StringComp implements PreEvaluatedArgumentsExpression {
+ private final Type type;
+
+ StringComp(Type type) {
+ this.type = type;
+ }
+
+ public String key() {
+ return type.key;
+ }
+
+ public Object evaluate(List arguments, Object data) throws JsonLogicEvaluationException {
+ if (arguments.size() != 2) {
+ log.log(Level.FINE, "Incorrect number of arguments for String comparison operator");
+ return null;
+ }
+
+ Object jsonLogicNode = arguments.get(0);
+
+ if (!(jsonLogicNode instanceof String)) {
+ log.log(Level.FINE, "Incorrect argument type for first argument");
+ return null;
+ }
+
+ final String arg1 = (String) jsonLogicNode;
+
+ jsonLogicNode = arguments.get(1);
+
+ if (!(jsonLogicNode instanceof String)) {
+ log.log(Level.FINE, "Incorrect argument type for second argument");
+ return null;
+ }
+
+ final String arg2 = (String) jsonLogicNode;
+
+ switch (this.type) {
+ case STARTS_WITH:
+ return arg1.startsWith(arg2);
+ case ENDS_WITH:
+ return arg1.endsWith(arg2);
+ default:
+ log.log(Level.FINE, String.format("Unknown string comparison evaluator type %s", this.type));
+ return null;
+ }
+ }
+
+
+ enum Type {
+ STARTS_WITH("starts_with"),
+ ENDS_WITH("ends_with");
+
+ private final String key;
+
+ Type(String key) {
+ this.key = key;
+ }
+ }
+}
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java
new file mode 100644
index 000000000..55bfccc9d
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java
@@ -0,0 +1,14 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+/**
+ * Exception used by targeting rule package.
+ **/
+public class TargetingRuleException extends Exception {
+
+ /**
+ * Construct exception.
+ **/
+ public TargetingRuleException(final String message, final Throwable t) {
+ super(message, t);
+ }
+}
diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json
new file mode 100644
index 000000000..079bb2c71
--- /dev/null
+++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json
@@ -0,0 +1,14 @@
+{
+ "targeting":
+ [
+ "bucketKet",
+ [
+ "red",
+ 50
+ ],
+ [
+ "blue",
+ 50
+ ]
+ ]
+}
\ No newline at end of file
diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java
index 78d9bf02d..3229e35f1 100644
--- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java
+++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java
@@ -46,7 +46,8 @@ public void validJsonConfigurationWithTargetingRulesParsing() throws IOException
assertEquals("loop", variants.get("loop"));
assertEquals("binet", variants.get("binet"));
- assertEquals("{\"if\":[{\"in\":[\"@faas.com\",{\"var\":[\"email\"]}]},\"binet\",null]}", stringFlag.getTargeting());
+ assertEquals("{\"if\":[{\"in\":[\"@faas.com\",{\"var\":[\"email\"]}]},\"binet\",null]}",
+ stringFlag.getTargeting());
}
diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java
new file mode 100644
index 000000000..b3220e1e0
--- /dev/null
+++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java
@@ -0,0 +1,263 @@
+package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
+
+import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator.FLAG_KEY;
+import static dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator.TARGET_KEY;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class FractionalTest {
+
+ @Test
+ void selfContainedFractionalA() throws JsonLogicEvaluationException {
+ // given
+ Fractional fractional = new Fractional();
+
+ /* Rule
+ * [
+ * "bucketKeyA", // this is resolved value of an expression
+ * [
+ * "red",
+ * 50
+ * ],
+ * [
+ * "blue",
+ * 50
+ * ]
+ * ]
+ * */
+
+ final List