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 rule = new ArrayList<>(); + rule.add("bucketKeyA"); + + final List bucket1 = new ArrayList<>(); + bucket1.add("red"); + bucket1.add(50); + + final List bucket2 = new ArrayList<>(); + bucket2.add("green"); + bucket2.add(50); + + rule.add(bucket1); + rule.add(bucket2); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "flagA"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertEquals("red", evaluate); + } + + @Test + void selfContainedFractionalB() throws JsonLogicEvaluationException { + // given + Fractional fractional = new Fractional(); + + /* Rule + * [ + * "bucketKeyB", // this is resolved value of an expression + * [ + * "red", + * 50 + * ], + * [ + * "blue", + * 50 + * ] + * ] + * */ + + final List rule = new ArrayList<>(); + rule.add("bucketKeyB"); + + final List bucket1 = new ArrayList<>(); + bucket1.add("red"); + bucket1.add(50); + + final List bucket2 = new ArrayList<>(); + bucket2.add("green"); + bucket2.add(50); + + rule.add(bucket1); + rule.add(bucket2); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "flagA"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertEquals("green", evaluate); + } + + @Test + void targetingBackedFractional() throws JsonLogicEvaluationException { + // given + Fractional fractional = new Fractional(); + + /* Rule + * [ + * [ + * "blue", + * 50 + * ], + * [ + * "green", + * 50 + * ] + * ] + * */ + + final List rule = new ArrayList<>(); + + final List bucket1 = new ArrayList<>(); + bucket1.add("blue"); + bucket1.add(50); + + final List bucket2 = new ArrayList<>(); + bucket2.add("green"); + bucket2.add(50); + + rule.add(bucket1); + rule.add(bucket2); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "headerColor"); + data.put(TARGET_KEY, "foo@foo.com"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertEquals("blue", evaluate); + } + + + @Test + void invalidRuleSumNot100() throws JsonLogicEvaluationException { + // given + Fractional fractional = new Fractional(); + + /* Rule + * [ + * [ + * "blue", + * 50 + * ], + * [ + * "green", + * 70 + * ] + * ] + * */ + + final List rule = new ArrayList<>(); + + final List bucket1 = new ArrayList<>(); + bucket1.add("blue"); + bucket1.add(50); + + final List bucket2 = new ArrayList<>(); + bucket2.add("green"); + bucket2.add(70); + + rule.add(bucket1); + rule.add(bucket2); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "headerColor"); + data.put(TARGET_KEY, "foo@foo.com"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertNull(evaluate); + } + + @Test + void notEnoughBuckets() throws JsonLogicEvaluationException { + // given + Fractional fractional = new Fractional(); + + /* Rule + * [ + * [ + * "blue", + * 100 + * ] + * ] + * */ + + final List rule = new ArrayList<>(); + + final List bucket1 = new ArrayList<>(); + bucket1.add("blue"); + bucket1.add(50); + + rule.add(bucket1); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "headerColor"); + data.put(TARGET_KEY, "foo@foo.com"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertNull(evaluate); + } + + + @Test + void invalidRule() throws JsonLogicEvaluationException { + // given + Fractional fractional = new Fractional(); + + /* Rule + * [ + * [ + * "blue", + * 50 + * ], + * [ + * "green" + * ] + * ] + * */ + + final List rule = new ArrayList<>(); + + final List bucket1 = new ArrayList<>(); + bucket1.add("blue"); + bucket1.add(50); + + final List bucket2 = new ArrayList<>(); + bucket2.add("green"); + + rule.add(bucket1); + rule.add(bucket2); + + Map data = new HashMap<>(); + data.put(FLAG_KEY, "headerColor"); + data.put(TARGET_KEY, "foo@foo.com"); + + // when + Object evaluate = fractional.evaluate(rule, data); + + // then + assertNull(evaluate); + } + +} \ No newline at end of file diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java new file mode 100644 index 000000000..a6a1fe2e3 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java @@ -0,0 +1,211 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; + +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Value; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OperatorTest { + private static Operator OPERATOR; + + @BeforeAll + static void setUp() { + OPERATOR = new Operator(); + } + + @Test + void fractionalTestA() throws TargetingRuleException { + // given + + // fractional rule with email as expression key + final String targetingRule = "" + + "{\n" + + " \"fractional\": [\n" + + " {\"var\": \"email\"},\n" + + " [\n" + + " \"red\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"blue\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"green\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"yellow\",\n" + + " 25\n" + + " ]\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("email", new Value("rachel@faas.com")); + + + // when + Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals("yellow", evalVariant); + } + + @Test + void fractionalTestB() throws TargetingRuleException { + // given + + // fractional rule with email as expression key + final String targetingRule = "" + + "{\n" + + " \"fractional\": [\n" + + " {\"var\": \"email\"},\n" + + " [\n" + + " \"red\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"blue\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"green\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"yellow\",\n" + + " 25\n" + + " ]\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("email", new Value("monica@faas.com")); + + + // when + Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals("blue", evalVariant); + } + + @Test + void fractionalTestC() throws TargetingRuleException { + // given + + // fractional rule with email as expression key + final String targetingRule = "" + + "{\n" + + " \"fractional\": [\n" + + " {\"var\": \"email\"},\n" + + " [\n" + + " \"red\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"blue\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"green\",\n" + + " 25\n" + + " ],\n" + + " [\n" + + " \"yellow\",\n" + + " 25\n" + + " ]\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("email", new Value("joey@faas.com")); + + + // when + Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals("red", evalVariant); + } + + @Test + void stringCompStartsWith() throws TargetingRuleException { + // given + + // starts with rule with email as expression key + final String targetingRule = "" + + "{\n" + + " \"starts_with\": [\n" + + " {\"var\": \"email\"},\n" + + " \"admin\"\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("email", new Value("admin@faas.com")); + + + // when + Object evalVariant = OPERATOR.apply("adminRule", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals(true, evalVariant); + } + + @Test + void stringCompEndsWith() throws TargetingRuleException { + // given + + // ends with rule with email as expression key + final String targetingRule = "" + + "{\n" + + " \"ends_with\": [\n" + + " {\"var\": \"email\"},\n" + + " \"@faas.com\"\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("email", new Value("admin@faas.com")); + + + // when + Object evalVariant = OPERATOR.apply("isFaas", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals(true, evalVariant); + } + + @Test + void semVerA() throws TargetingRuleException { + // given + + // sem_ver rule with version as expression key + final String targetingRule = "{\n" + + " \"if\": [\n" + + " {\n" + + " \"sem_ver\": [{\"var\": \"version\"}, \">=\", \"1.0.0\"]\n" + + " },\n" + + " \"red\", null\n" + + " ]\n" + + "}"; + + Map ctxData = new HashMap<>(); + ctxData.put("version", new Value("1.1.0")); + + + // when + Object evalVariant = OPERATOR.apply("versionFlag", targetingRule, new ImmutableContext(ctxData)); + + // then + assertEquals("red", evalVariant); + } + +} \ No newline at end of file diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java new file mode 100644 index 000000000..661f3a4bb --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java @@ -0,0 +1,186 @@ +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.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class SemVerTest { + + @Test + public void eqOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", "=", "1.2.3"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void neqOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", "!=", "1.2.4"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void ltOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", "<", "1.2.4"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void lteOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", "<=", "1.2.3"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void gtOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", ">", "0.2.3"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void gteOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", ">=", "v1.2.3"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void majorCompOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v1.2.3", "^", "v1.0.0"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void minorCompOperator() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("v5.0.3", "~", "v5.0.8"), new Object()); + + // then + if (!(result instanceof Boolean)) { + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void invalidType() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("1.2.3", "=", 1.2), new Object()); + + assertThat(result).isNull(); + } + + + @Test + public void invalidArg1() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("1.2", "=", "1.2.3"), new Object()); + + assertThat(result).isNull(); + } + + @Test + public void invalidArg2() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("1.2.3", "*", "1.2.3"), new Object()); + + assertThat(result).isNull(); + } + + @Test + public void invalidArg3() throws JsonLogicEvaluationException { + // given + final SemVer semVer = new SemVer(); + + // when + Object result = semVer.evaluate(Arrays.asList("1.2.3", "=", "1.2"), new Object()); + + assertThat(result).isNull(); + } + +} \ No newline at end of file diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java new file mode 100644 index 000000000..21c638ca0 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java @@ -0,0 +1,82 @@ +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.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +class StringCompTest { + + + @Test + public void startsWithEvaluation() throws JsonLogicEvaluationException { + // given + final StringComp startsWith = new StringComp(StringComp.Type.STARTS_WITH); + + // when + Object result = startsWith.evaluate(Arrays.asList("abc@123.com", "abc"), new Object()); + + // then + if (!(result instanceof Boolean)){ + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void endsWithEvaluation() throws JsonLogicEvaluationException { + // given + final StringComp endsWith = new StringComp(StringComp.Type.ENDS_WITH); + + // when + Object result = endsWith.evaluate( Arrays.asList("abc@123.com", "123.com"), new Object()); + + // then + if (!(result instanceof Boolean)){ + fail("Result is not of type Boolean"); + } + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void invalidTypeCheckArg1() throws JsonLogicEvaluationException { + // given + final StringComp operator = new StringComp(StringComp.Type.STARTS_WITH); + + // when + Object result = operator.evaluate(Arrays.asList(1230, "12"), new Object()); + + // then + assertThat(result).isNull(); + } + + @Test + public void invalidTypeCheckArg2() throws JsonLogicEvaluationException { + // given + final StringComp operator = new StringComp(StringComp.Type.STARTS_WITH); + + // when + Object result = operator.evaluate(Arrays.asList("abc@123.com", 123), new Object()); + + // then + assertThat(result).isNull(); + } + + @Test + public void invalidNumberOfArgs() throws JsonLogicEvaluationException { + // given + final StringComp operator = new StringComp(StringComp.Type.STARTS_WITH); + + // when + Object result = operator.evaluate(Arrays.asList("123", "12", "1"), new Object()); + + // then + assertThat(result).isNull(); + } + +} \ No newline at end of file