Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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