Skip to content

feat: json logic operators for flagd in-process provider #434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 14, 2023
3 changes: 3 additions & 0 deletions checkstyle-suppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@
<suppressions>
<!-- don't check style of generated sources (GRPC bindings) -->
<suppress files="[\\/]generated-sources[\\/]" checks="[a-zA-Z0-9]*" />

<!-- ignore Murmur3 implementation -->
<suppress files="[Murmur3]" checks="[a-zA-Z0-9]*" />
</suppressions>
7 changes: 7 additions & 0 deletions providers/flagd/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@
<artifactId>opentelemetry-api</artifactId>
<version>1.30.1</version>
</dependency>

<dependency>
<groupId>org.semver4j</groupId>
<artifactId>semver4j</artifactId>
<version>5.1.0</version>
</dependency>

</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,7 +30,7 @@
public class InProcessResolver implements Resolver {
private final Storage flagStore;
private final Consumer<ProviderState> stateConsumer;
private final JsonLogic jsonLogicHandler;
private final Operator operator;

/**
* Initialize an in-process resolver.
Expand All @@ -39,7 +39,7 @@ public InProcessResolver(FlagdOptions options, Consumer<ProviderState> stateCons
// currently we support gRPC connector
this.flagStore = new FlagStore(new GrpcStreamConnector(options));
this.stateConsumer = stateConsumer;
jsonLogicHandler = new JsonLogic();
this.operator = new Operator();
}

/**
Expand Down Expand Up @@ -160,15 +160,15 @@ private <T> ProviderEvaluation<T> resolve(Class<T> 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();
} else {
resolvedVariant = jsonResolved;
reason = Reason.TARGETING_MATCH.toString();
}
} catch (JsonLogicException e) {
} catch (TargetingRuleException e) {
log.log(Level.FINE, "Error evaluating targeting rule", e);
return ProviderEvaluation.<T>builder()
.value(defaultValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;

import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.lib.Murmur3;
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 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<FractionProperty> 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<FractionProperty> propertyList)
throws JsonLogicEvaluationException {
byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8);
int mmrHash = Murmur3.hash32(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();
}

}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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;
}
}
}
}
Loading