Skip to content

Commit

Permalink
feat: A new inline evaluating provider based on jsonlogic.com (#241)
Browse files Browse the repository at this point in the history
Signed-off-by: Justin Abrahms <[email protected]>
Co-authored-by: Kavindu Dodanduwa <[email protected]>
  • Loading branch information
justinabrahms and Kavindu-Dodan authored Apr 13, 2023
1 parent 8511136 commit bba8ef3
Show file tree
Hide file tree
Showing 14 changed files with 400 additions and 0 deletions.
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<module>providers/flagd</module>
<module>providers/flagsmith</module>
<module>providers/go-feature-flag</module>
<module>providers/jsonlogic-eval-provider</module>
<module>providers/env-var</module>
</modules>

Expand Down
41 changes: 41 additions & 0 deletions providers/jsonlogic-eval-provider/README.md
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);
```
53 changes: 53 additions & 0 deletions providers/jsonlogic-eval-provider/pom.xml
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>
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) {
}
}
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);
}
}
}
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);
}
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"));
}
}
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());
}
}
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())));
}
}
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
]}
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
]}
]}
]}
Loading

0 comments on commit bba8ef3

Please sign in to comment.