-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: A new inline evaluating provider based on jsonlogic.com (#241)
Signed-off-by: Justin Abrahms <[email protected]> Co-authored-by: Kavindu Dodanduwa <[email protected]>
- Loading branch information
1 parent
8511136
commit bba8ef3
Showing
14 changed files
with
400 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
# JSONLogic Evaluation Provider | ||
|
||
This provider does inline evaluation (e.g. no hot-path remote calls) based on [JSONLogic](https://jsonlogic.com/). This should allow you to | ||
achieve low latency flag evaluation. | ||
|
||
## Installation | ||
|
||
<!-- x-release-please-start-version --> | ||
```xml | ||
|
||
<dependency> | ||
<groupId>dev.openfeature.contrib.providers</groupId> | ||
<artifactId>jsonlogic-eval-provider</artifactId> | ||
<version>0.0.1</version> | ||
</dependency> | ||
``` | ||
<!-- x-release-please-end-version --> | ||
|
||
## Usage | ||
|
||
You will need to create a custom class which implements the `RuleFetcher` interface. This code should cache your | ||
rules locally. During the `initialization` method, it should also set up a mechanism to stay up to date with remote | ||
flag changes. You can see `FileBasedFetcher` as a simplified example. | ||
|
||
```java | ||
JsonlogicProvider jlp = new JsonlogicProvider(new RuleFetcher() { | ||
@Override | ||
public void initialize(EvaluationContext initialContext) { | ||
// setup initial fetch & stay-up-to-date logic | ||
} | ||
|
||
@Nullable | ||
@Override | ||
public String getRuleForKey(String key) { | ||
// return the jsonlogic rule in string format for a given flag key | ||
return null; | ||
} | ||
}) | ||
|
||
OpenFeature.setProvider(jlp); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
<modelVersion>4.0.0</modelVersion> | ||
<parent> | ||
<groupId>dev.openfeature.contrib</groupId> | ||
<artifactId>parent</artifactId> | ||
<version>0.1.0</version> | ||
<relativePath>../../pom.xml</relativePath> | ||
</parent> | ||
<!-- The group id MUST start with dev.openfeature, or publishing will fail. OpenFeature has verified ownership of this (reversed) domain. --> | ||
<groupId>dev.openfeature.contrib.providers</groupId> | ||
<artifactId>jsonlogic-eval-provider</artifactId> | ||
<version>0.0.1</version> <!--x-release-please-version --> | ||
|
||
<name>inline-evaluating-provider</name> | ||
<description>Allows for evaluating rules on the client without synchronous calls to a backend</description> | ||
<url>https://openfeature.dev</url> | ||
|
||
<developers> | ||
<developer> | ||
<id>justinabrahms</id> | ||
<name>Justin Abrahms</name> | ||
<organization>OpenFeature</organization> | ||
<url>https://openfeature.dev/</url> | ||
</developer> | ||
</developers> | ||
|
||
<dependencies> | ||
<dependency> | ||
<groupId>io.github.jamsesso</groupId> | ||
<artifactId>json-logic-java</artifactId> | ||
<version>1.0.7</version> | ||
</dependency> | ||
<dependency> | ||
<groupId>org.json</groupId> | ||
<artifactId>json</artifactId> | ||
<version>20230227</version> | ||
</dependency> | ||
<dependency> | ||
<groupId>com.github.spotbugs</groupId> | ||
<artifactId>spotbugs-annotations</artifactId> | ||
<version>4.7.3</version> | ||
<scope>compile</scope> | ||
</dependency> | ||
</dependencies> | ||
|
||
<build> | ||
<plugins> | ||
<!-- plugins your module needs (in addition to those inherited from parent) --> | ||
</plugins> | ||
</build> | ||
|
||
</project> |
48 changes: 48 additions & 0 deletions
48
...-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcher.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import dev.openfeature.sdk.EvaluationContext; | ||
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; | ||
import org.json.JSONException; | ||
import org.json.JSONObject; | ||
|
||
import java.io.IOException; | ||
import java.net.URI; | ||
import java.nio.file.Files; | ||
import java.nio.file.Paths; | ||
import java.util.logging.Logger; | ||
|
||
/** | ||
* A {@link RuleFetcher} which reads in the rules from a file. It assumes that the keys are the flag keys and the | ||
* values are the json logic rules. | ||
*/ | ||
@SuppressFBWarnings( | ||
value = "PATH_TRAVERSAL_IN", | ||
justification = "This is expected to read files based on user input" | ||
) | ||
public class FileBasedFetcher implements RuleFetcher { | ||
private static final Logger log = Logger.getLogger(String.valueOf(FileBasedFetcher.class)); | ||
private final JSONObject rules; | ||
|
||
/** | ||
* Create a file based fetcher give a file URI. | ||
* @param filename URI to a given file. | ||
* @throws IOException when we can't load the file correctly | ||
*/ | ||
public FileBasedFetcher(URI filename) throws IOException { | ||
String jsonData = String.join("", Files.readAllLines(Paths.get(filename))); | ||
rules = new JSONObject(jsonData); | ||
} | ||
|
||
@Override | ||
public String getRuleForKey(String key) { | ||
try { | ||
return rules.getJSONObject(key).toString(); | ||
} catch (JSONException e) { | ||
log.warning(String.format("Unable to deserialize rule for %s due to exception %s", key, e)); | ||
} | ||
return null; | ||
} | ||
|
||
@Override public void initialize(EvaluationContext initialContext) { | ||
} | ||
} |
87 changes: 87 additions & 0 deletions
87
...provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProvider.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import dev.openfeature.sdk.*; | ||
import dev.openfeature.sdk.exceptions.ParseError; | ||
import io.github.jamsesso.jsonlogic.JsonLogic; | ||
import io.github.jamsesso.jsonlogic.JsonLogicException; | ||
|
||
import java.util.function.Function; | ||
|
||
/** | ||
* A provider which evaluates JsonLogic rules provided by a {@link RuleFetcher}. | ||
*/ | ||
public class JsonlogicProvider implements FeatureProvider { | ||
private final JsonLogic logic; | ||
private final RuleFetcher fetcher; | ||
|
||
|
||
public void initialize(EvaluationContext initialContext) { | ||
fetcher.initialize(initialContext); | ||
} | ||
|
||
public JsonlogicProvider(RuleFetcher fetcher) { | ||
this.logic = new JsonLogic(); | ||
this.fetcher = fetcher; | ||
} | ||
|
||
public JsonlogicProvider(JsonLogic logic, RuleFetcher fetcher) { | ||
this.logic = logic; | ||
this.fetcher = fetcher; | ||
} | ||
|
||
@Override | ||
public Metadata getMetadata() { | ||
return () -> "JsonLogicProvider(" + this.fetcher.getClass().getName() + ")"; | ||
} | ||
|
||
@Override | ||
public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { | ||
return evalRuleForKey(key, defaultValue, ctx); | ||
} | ||
|
||
@Override | ||
public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { | ||
return evalRuleForKey(key, defaultValue, ctx); | ||
} | ||
|
||
@Override | ||
public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { | ||
// jsonlogic only returns doubles, not integers. | ||
return evalRuleForKey(key, defaultValue, ctx, (o) -> ((Double) o).intValue()); | ||
} | ||
|
||
@Override | ||
public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { | ||
return evalRuleForKey(key, defaultValue, ctx); | ||
} | ||
|
||
@Override | ||
public ProviderEvaluation<Value> getObjectEvaluation(String s, Value value, EvaluationContext evaluationContext) { | ||
// we can't use the common implementation because we need to convert to-and-from Value objects. | ||
throw new UnsupportedOperationException("Haven't gotten there yet."); | ||
} | ||
|
||
private <T> ProviderEvaluation<T> evalRuleForKey(String key, T defaultValue, EvaluationContext ctx) { | ||
return evalRuleForKey(key, defaultValue, ctx, (o) -> (T) o); | ||
} | ||
|
||
private <T> ProviderEvaluation<T> evalRuleForKey( | ||
String key, T defaultValue, EvaluationContext ctx, Function<Object, T> resultToType) { | ||
String rule = fetcher.getRuleForKey(key); | ||
if (rule == null) { | ||
return ProviderEvaluation.<T>builder() | ||
.value(defaultValue) | ||
.reason(Reason.ERROR.toString()) | ||
.errorMessage("Unable to find rules for the given key") | ||
.build(); | ||
} | ||
|
||
try { | ||
return ProviderEvaluation.<T>builder() | ||
.value(resultToType.apply(this.logic.apply(rule, ctx.asObjectMap()))) | ||
.build(); | ||
} catch (JsonLogicException e) { | ||
throw new ParseError(e); | ||
} | ||
} | ||
} |
26 changes: 26 additions & 0 deletions
26
...-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/RuleFetcher.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import dev.openfeature.sdk.EvaluationContext; | ||
|
||
import javax.annotation.Nullable; | ||
|
||
/** | ||
* A RuleFetcher exists to fetch rules from a likely remote location which will be used for local evaluation. | ||
*/ | ||
public interface RuleFetcher { | ||
|
||
/** | ||
* Called to set up the client initially. This is used to pre-fetch initial data as well as setup mechanisms | ||
* to stay up to date. | ||
* @param initialContext application context known thus far | ||
*/ | ||
void initialize(EvaluationContext initialContext); | ||
|
||
/** | ||
* Given a key name, return the JSONLogic rules for it. | ||
* @param key The key to fetch logic for | ||
* @return json logic rules or null | ||
*/ | ||
@Nullable | ||
String getRuleForKey(String key); | ||
} |
16 changes: 16 additions & 0 deletions
16
...vider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcherTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import org.junit.jupiter.api.Test; | ||
|
||
import java.net.URI; | ||
|
||
import static org.junit.jupiter.api.Assertions.assertNull; | ||
|
||
class FileBasedFetcherTest { | ||
|
||
@Test public void testNullValueForRule() throws Exception { | ||
URI uri = this.getClass().getResource("/test-rules.json").toURI(); | ||
FileBasedFetcher f = new FileBasedFetcher(uri); | ||
assertNull(f.getRuleForKey("malformed")); | ||
} | ||
} |
67 changes: 67 additions & 0 deletions
67
...ider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProviderTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import dev.openfeature.sdk.ImmutableContext; | ||
import dev.openfeature.sdk.ProviderEvaluation; | ||
import dev.openfeature.sdk.Value; | ||
import io.github.jamsesso.jsonlogic.JsonLogic; | ||
import org.junit.jupiter.api.Test; | ||
|
||
import java.net.URL; | ||
import java.util.Collections; | ||
import java.util.HashMap; | ||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals; | ||
import static org.junit.jupiter.api.Assertions.assertTrue; | ||
import static org.mockito.Mockito.*; | ||
|
||
class JsonlogicProviderTest { | ||
@Test | ||
public void demonstrateJsonLogic() throws Exception { | ||
// if specific id matches or category is in valid set, yes. Otherwise, no. | ||
String rule = Utils.readTestResource("/dessert-decider.json"); | ||
|
||
JsonLogic logic = new JsonLogic(); | ||
assertEquals(false, logic.apply(rule, new HashMap<String, String>())); | ||
assertEquals(true, logic.apply(rule, Collections.singletonMap("userId", 2))); | ||
assertEquals(false, logic.apply(rule, Collections.singletonMap("userId", 5))); | ||
assertEquals(true, logic.apply(rule, Collections.singletonMap("category", "pies"))); | ||
assertEquals(false, logic.apply(rule, Collections.singletonMap("category", "muffins"))); | ||
} | ||
|
||
@Test | ||
public void jsonlogicReturnTypes() throws Exception { | ||
// if specific id matches or category is in valid set, yes. Otherwise, no. | ||
|
||
String rule = Utils.readTestResource("/many-types.json"); | ||
JsonLogic logic = new JsonLogic(); | ||
assertEquals(2D, logic.apply(rule, Collections.emptyMap())); | ||
assertEquals(4.2D, logic.apply(rule, Collections.singletonMap("double", true))); | ||
assertEquals("yes", logic.apply(rule, Collections.singletonMap("string", true))); | ||
assertEquals(true, logic.apply(rule, Collections.singletonMap("bool", "true"))); | ||
} | ||
|
||
@Test public void providerTest() throws Exception { | ||
URL v = this.getClass().getResource("/test-rules.json"); | ||
JsonlogicProvider iep = new JsonlogicProvider(new FileBasedFetcher(v.toURI())); | ||
ImmutableContext evalCtx = new ImmutableContext(Collections.singletonMap("userId", new Value(2))); | ||
|
||
ProviderEvaluation<Boolean> result = iep.getBooleanEvaluation("should-have-dessert?", false, evalCtx); | ||
assertTrue(result.getValue(), result.getReason()); | ||
} | ||
|
||
@Test public void missingKey() throws Exception { | ||
URL v = this.getClass().getResource("/test-rules.json"); | ||
JsonlogicProvider iep = new JsonlogicProvider(new FileBasedFetcher(v.toURI())); | ||
|
||
ProviderEvaluation<Boolean> result = iep.getBooleanEvaluation("missingKey", false, null); | ||
assertEquals("Unable to find rules for the given key", result.getErrorMessage()); | ||
assertEquals("ERROR", result.getReason()); | ||
} | ||
|
||
@Test public void callsFetcherInitialize() { | ||
RuleFetcher mockFetcher = mock(RuleFetcher.class); | ||
JsonlogicProvider iep = new JsonlogicProvider(mockFetcher); | ||
iep.initialize(null); | ||
verify(mockFetcher).initialize(any()); | ||
} | ||
} |
17 changes: 17 additions & 0 deletions
17
...nlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/Utils.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package dev.openfeature.contrib.providers.jsonlogic; | ||
|
||
import java.io.IOException; | ||
import java.net.URISyntaxException; | ||
import java.net.URL; | ||
import java.nio.file.Files; | ||
import java.nio.file.Paths; | ||
|
||
public class Utils { | ||
public static String readTestResource(String name) throws IOException, URISyntaxException { | ||
URL url = Utils.class.getResource(name); | ||
if (url == null) { | ||
return null; | ||
} | ||
return String.join("", Files.readAllLines(Paths.get(url.toURI()))); | ||
} | ||
} |
8 changes: 8 additions & 0 deletions
8
providers/jsonlogic-eval-provider/src/test/resources/dessert-decider.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{"if": [ | ||
{"or": [ | ||
{"in": [{"var": "userId"}, [1,2,3,4]]}, | ||
{"in": [{"var": "category"}, ["pies", "cakes"]]} | ||
]}, | ||
true, | ||
false | ||
]} |
7 changes: 7 additions & 0 deletions
7
providers/jsonlogic-eval-provider/src/test/resources/many-types.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{"if": [{"var": "bool"}, true, | ||
{"if": [{"var": "string"}, "yes", | ||
{"if": [{"var": "double"}, 4.2, | ||
2 | ||
]} | ||
]} | ||
]} |
Oops, something went wrong.