Skip to content

Commit 485c8a3

Browse files
feat: json logic operators for flagd in-process provider (#434)
Signed-off-by: Kavindu Dodanduwa <[email protected]>
1 parent f53bf30 commit 485c8a3

File tree

14 files changed

+1179
-17
lines changed

14 files changed

+1179
-17
lines changed

Diff for: providers/flagd/pom.xml

+7
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@
114114
<artifactId>opentelemetry-api</artifactId>
115115
<version>1.30.1</version>
116116
</dependency>
117+
118+
<dependency>
119+
<groupId>org.semver4j</groupId>
120+
<artifactId>semver4j</artifactId>
121+
<version>5.1.0</version>
122+
</dependency>
123+
117124
</dependencies>
118125

119126
<build>

Diff for: providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java

+6-6
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage;
88
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState;
99
import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.grpc.GrpcStreamConnector;
10+
import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator;
11+
import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.TargetingRuleException;
1012
import dev.openfeature.sdk.ErrorCode;
1113
import dev.openfeature.sdk.EvaluationContext;
1214
import dev.openfeature.sdk.ProviderEvaluation;
1315
import dev.openfeature.sdk.ProviderState;
1416
import dev.openfeature.sdk.Reason;
1517
import dev.openfeature.sdk.Value;
16-
import io.github.jamsesso.jsonlogic.JsonLogic;
17-
import io.github.jamsesso.jsonlogic.JsonLogicException;
1818
import lombok.extern.java.Log;
1919

2020
import java.util.function.Consumer;
@@ -30,7 +30,7 @@
3030
public class InProcessResolver implements Resolver {
3131
private final Storage flagStore;
3232
private final Consumer<ProviderState> stateConsumer;
33-
private final JsonLogic jsonLogicHandler;
33+
private final Operator operator;
3434

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

4545
/**
@@ -160,15 +160,15 @@ private <T> ProviderEvaluation<T> resolve(Class<T> type, String key, T defaultVa
160160
reason = Reason.STATIC.toString();
161161
} else {
162162
try {
163-
final Object jsonResolved = jsonLogicHandler.apply(flag.getTargeting(), ctx.asObjectMap());
163+
final Object jsonResolved = operator.apply(key, flag.getTargeting(), ctx);
164164
if (jsonResolved == null) {
165165
resolvedVariant = flag.getDefaultVariant();
166166
reason = Reason.DEFAULT.toString();
167167
} else {
168168
resolvedVariant = jsonResolved;
169169
reason = Reason.TARGETING_MATCH.toString();
170170
}
171-
} catch (JsonLogicException e) {
171+
} catch (TargetingRuleException e) {
172172
log.log(Level.FINE, "Error evaluating targeting rule", e);
173173
return ProviderEvaluation.<T>builder()
174174
.value(defaultValue)

Diff for: providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java

+11-10
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@
1010
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
1111
import lombok.extern.java.Log;
1212

13+
import java.io.ByteArrayOutputStream;
1314
import java.io.IOException;
14-
import java.net.URL;
15-
import java.nio.charset.StandardCharsets;
16-
import java.nio.file.Files;
17-
import java.nio.file.Paths;
15+
import java.io.InputStream;
1816
import java.util.HashMap;
1917
import java.util.Iterator;
2018
import java.util.Map;
@@ -42,15 +40,18 @@ public class FlagParser {
4240
private static JsonSchema SCHEMA_VALIDATOR;
4341

4442
static {
45-
try {
46-
final URL url = FlagParser.class.getClassLoader().getResource(SCHEMA_RESOURCE);
47-
if (url == null) {
43+
try (InputStream schema = FlagParser.class.getClassLoader().getResourceAsStream(SCHEMA_RESOURCE)) {
44+
if (schema == null) {
4845
log.log(Level.WARNING, String.format("Resource %s not found", SCHEMA_RESOURCE));
4946
} else {
50-
byte[] bytes = Files.readAllBytes(Paths.get(url.getPath()));
51-
String schemaString = new String(bytes, StandardCharsets.UTF_8);
47+
final ByteArrayOutputStream result = new ByteArrayOutputStream();
48+
byte[] buffer = new byte[512];
49+
for (int size; 0 < (size = schema.read(buffer)); ) {
50+
result.write(buffer, 0, size);
51+
}
52+
5253
JsonSchemaFactory instance = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
53-
SCHEMA_VALIDATOR = instance.getSchema(schemaString);
54+
SCHEMA_VALIDATOR = instance.getSchema(result.toString("UTF-8"));
5455
}
5556
} catch (Throwable e) {
5657
// log only, do not throw
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
2+
3+
import io.github.jamsesso.jsonlogic.JsonLogicException;
4+
import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException;
5+
import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression;
6+
import lombok.Getter;
7+
import lombok.extern.java.Log;
8+
import org.apache.commons.codec.digest.MurmurHash3;
9+
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.ArrayList;
12+
import java.util.Arrays;
13+
import java.util.List;
14+
import java.util.logging.Level;
15+
16+
@Log
17+
class Fractional implements PreEvaluatedArgumentsExpression {
18+
19+
public String key() {
20+
return "fractional";
21+
}
22+
23+
public Object evaluate(List arguments, Object data) throws JsonLogicEvaluationException {
24+
if (arguments.size() < 2) {
25+
return null;
26+
}
27+
28+
final Operator.FlagProperties properties = new Operator.FlagProperties(data);
29+
30+
// check optional string target in first arg
31+
Object arg1 = arguments.get(0);
32+
33+
final String bucketBy;
34+
final Object[] distibutions;
35+
36+
if (arg1 instanceof String) {
37+
// first arg is a String, use for bucketing
38+
bucketBy = (String) arg1;
39+
40+
Object[] source = arguments.toArray();
41+
distibutions = Arrays.copyOfRange(source, 1, source.length);
42+
} else {
43+
// fallback to targeting key if present
44+
if (properties.getTargetingKey() == null) {
45+
log.log(Level.FINE, "Missing fallback targeting key");
46+
return null;
47+
}
48+
49+
bucketBy = properties.getTargetingKey();
50+
distibutions = arguments.toArray();
51+
}
52+
53+
final String hashKey = properties.getFlagKey() + bucketBy;
54+
final List<FractionProperty> propertyList = new ArrayList<>();
55+
56+
double distribution = 0;
57+
try {
58+
for (Object dist : distibutions) {
59+
FractionProperty fractionProperty = new FractionProperty(dist);
60+
propertyList.add(fractionProperty);
61+
distribution += fractionProperty.getPercentage();
62+
}
63+
} catch (JsonLogicException e) {
64+
log.log(Level.FINE, "Error parsing fractional targeting rule", e);
65+
return null;
66+
}
67+
68+
if (distribution != 100) {
69+
log.log(Level.FINE, "Fractional properties do not sum to 100");
70+
return null;
71+
}
72+
73+
// find distribution
74+
return distributeValue(hashKey, propertyList);
75+
}
76+
77+
private static String distributeValue(final String hashKey, final List<FractionProperty> propertyList)
78+
throws JsonLogicEvaluationException {
79+
byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8);
80+
int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0);
81+
int bucket = (int) ((Math.abs(mmrHash) * 1.0f / Integer.MAX_VALUE) * 100);
82+
83+
int bucketSum = 0;
84+
for (FractionProperty p : propertyList) {
85+
bucketSum += p.getPercentage();
86+
87+
if (bucket < bucketSum) {
88+
return p.getVariant();
89+
}
90+
}
91+
92+
// this shall not be reached
93+
throw new JsonLogicEvaluationException("Unable to find a correct bucket");
94+
}
95+
96+
@Getter
97+
private static class FractionProperty {
98+
private final String variant;
99+
private final int percentage;
100+
101+
FractionProperty(final Object from) throws JsonLogicException {
102+
if (!(from instanceof List<?>)) {
103+
throw new JsonLogicException("Property is not an array");
104+
}
105+
106+
final List<?> array = (List) from;
107+
108+
if (array.size() != 2) {
109+
throw new JsonLogicException("Fraction property does not have two elements");
110+
}
111+
112+
// first must be a string
113+
if (!(array.get(0) instanceof String)) {
114+
throw new JsonLogicException("First element of the fraction property is not a string variant");
115+
}
116+
117+
// second element must be a number
118+
if (!(array.get(1) instanceof Number)) {
119+
throw new JsonLogicException("Second element of the fraction property is not a number");
120+
}
121+
122+
variant = (String) array.get(0);
123+
percentage = ((Number) array.get(1)).intValue();
124+
}
125+
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.process.targeting;
2+
3+
import dev.openfeature.sdk.EvaluationContext;
4+
import io.github.jamsesso.jsonlogic.JsonLogic;
5+
import io.github.jamsesso.jsonlogic.JsonLogicException;
6+
import lombok.Getter;
7+
8+
import java.util.Map;
9+
10+
/**
11+
* Targeting operator wraps JsonLogic handlers and expose a simple API for external layers.
12+
* This helps to isolate external dependencies to this package.
13+
*/
14+
public class Operator {
15+
16+
static final String FLAG_KEY = "$flagKey";
17+
static final String TARGET_KEY = "targetingKey";
18+
19+
private final JsonLogic jsonLogicHandler;
20+
21+
/**
22+
* Construct a targeting operator.
23+
*/
24+
public Operator() {
25+
jsonLogicHandler = new JsonLogic();
26+
jsonLogicHandler.addOperation(new Fractional());
27+
jsonLogicHandler.addOperation(new SemVer());
28+
jsonLogicHandler.addOperation(new StringComp(StringComp.Type.STARTS_WITH));
29+
jsonLogicHandler.addOperation(new StringComp(StringComp.Type.ENDS_WITH));
30+
}
31+
32+
/**
33+
* Apply this operator on the provided rule.
34+
*/
35+
public Object apply(final String flagKey, final String targetingRule, final EvaluationContext ctx)
36+
throws TargetingRuleException {
37+
final Map<String, Object> valueMap = ctx.asObjectMap();
38+
valueMap.put(FLAG_KEY, flagKey);
39+
40+
try {
41+
return jsonLogicHandler.apply(targetingRule, valueMap);
42+
} catch (JsonLogicException e) {
43+
throw new TargetingRuleException("Error evaluating json logic", e);
44+
}
45+
}
46+
47+
@Getter
48+
static class FlagProperties {
49+
private final String flagKey;
50+
private final String targetingKey;
51+
52+
FlagProperties(Object from) {
53+
if (from instanceof Map) {
54+
Map<?, ?> dataMap = (Map<?, ?>) from;
55+
56+
Object flagKey = dataMap.get(FLAG_KEY);
57+
58+
if (flagKey instanceof String) {
59+
this.flagKey = (String) flagKey;
60+
} else {
61+
this.flagKey = null;
62+
}
63+
64+
Object targetKey = dataMap.get(TARGET_KEY);
65+
66+
if (targetKey instanceof String) {
67+
targetingKey = (String) targetKey;
68+
} else {
69+
targetingKey = null;
70+
}
71+
} else {
72+
flagKey = null;
73+
targetingKey = null;
74+
}
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)