diff --git a/pom.xml b/pom.xml index f635ee0ac..73b897f6c 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ providers/flagd providers/flagsmith providers/go-feature-flag + providers/jsonlogic-eval-provider providers/env-var diff --git a/providers/jsonlogic-eval-provider/README.md b/providers/jsonlogic-eval-provider/README.md new file mode 100644 index 000000000..25b146c8b --- /dev/null +++ b/providers/jsonlogic-eval-provider/README.md @@ -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 + + +```xml + + + dev.openfeature.contrib.providers + jsonlogic-eval-provider + 0.0.1 + +``` + + +## 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); +``` \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/pom.xml b/providers/jsonlogic-eval-provider/pom.xml new file mode 100644 index 000000000..ad5fce5ec --- /dev/null +++ b/providers/jsonlogic-eval-provider/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.1.0 + ../../pom.xml + + + dev.openfeature.contrib.providers + jsonlogic-eval-provider + 0.0.1 + + inline-evaluating-provider + Allows for evaluating rules on the client without synchronous calls to a backend + https://openfeature.dev + + + + justinabrahms + Justin Abrahms + OpenFeature + https://openfeature.dev/ + + + + + + io.github.jamsesso + json-logic-java + 1.0.7 + + + org.json + json + 20230227 + + + com.github.spotbugs + spotbugs-annotations + 4.7.3 + compile + + + + + + + + + + \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcher.java b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcher.java new file mode 100644 index 000000000..7def40764 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcher.java @@ -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) { + } +} diff --git a/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProvider.java b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProvider.java new file mode 100644 index 000000000..8617ac691 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProvider.java @@ -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 getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return evalRuleForKey(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return evalRuleForKey(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation 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 getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return evalRuleForKey(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation 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 ProviderEvaluation evalRuleForKey(String key, T defaultValue, EvaluationContext ctx) { + return evalRuleForKey(key, defaultValue, ctx, (o) -> (T) o); + } + + private ProviderEvaluation evalRuleForKey( + String key, T defaultValue, EvaluationContext ctx, Function resultToType) { + String rule = fetcher.getRuleForKey(key); + if (rule == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorMessage("Unable to find rules for the given key") + .build(); + } + + try { + return ProviderEvaluation.builder() + .value(resultToType.apply(this.logic.apply(rule, ctx.asObjectMap()))) + .build(); + } catch (JsonLogicException e) { + throw new ParseError(e); + } + } +} diff --git a/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/RuleFetcher.java b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/RuleFetcher.java new file mode 100644 index 000000000..b802e4046 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/main/java/dev/openfeature/contrib/providers/jsonlogic/RuleFetcher.java @@ -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); +} diff --git a/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcherTest.java b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcherTest.java new file mode 100644 index 000000000..46a0204d3 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/FileBasedFetcherTest.java @@ -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")); + } +} \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProviderTest.java b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProviderTest.java new file mode 100644 index 000000000..5a0f51e00 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/JsonlogicProviderTest.java @@ -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())); + 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 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 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()); + } +} \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/Utils.java b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/Utils.java new file mode 100644 index 000000000..bd3b2423d --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/java/dev/openfeature/contrib/providers/jsonlogic/Utils.java @@ -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()))); + } +} diff --git a/providers/jsonlogic-eval-provider/src/test/resources/dessert-decider.json b/providers/jsonlogic-eval-provider/src/test/resources/dessert-decider.json new file mode 100644 index 000000000..6cf5675a8 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/resources/dessert-decider.json @@ -0,0 +1,8 @@ +{"if": [ + {"or": [ + {"in": [{"var": "userId"}, [1,2,3,4]]}, + {"in": [{"var": "category"}, ["pies", "cakes"]]} + ]}, + true, + false +]} \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/src/test/resources/many-types.json b/providers/jsonlogic-eval-provider/src/test/resources/many-types.json new file mode 100644 index 000000000..6d01b0999 --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/resources/many-types.json @@ -0,0 +1,7 @@ +{"if": [{"var": "bool"}, true, + {"if": [{"var": "string"}, "yes", + {"if": [{"var": "double"}, 4.2, + 2 + ]} + ]} +]} \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/src/test/resources/test-rules.json b/providers/jsonlogic-eval-provider/src/test/resources/test-rules.json new file mode 100644 index 000000000..de9cbd34e --- /dev/null +++ b/providers/jsonlogic-eval-provider/src/test/resources/test-rules.json @@ -0,0 +1,17 @@ +{ + "should-have-dessert?": {"if": [ + {"or": [ + {"in": [{"var": "userId"}, [1,2,3,4]]}, + {"in": [{"var": "category"}, ["pies", "cakes"]]} + ]}, + true, + false + ]}, + "many-types": {"if": [ + {"var": "bool"}, true, + {"var": "string"}, "yes", + {"var": "double"}, 4.2, + 2 + ]}, + "malformed": null +} \ No newline at end of file diff --git a/providers/jsonlogic-eval-provider/version.txt b/providers/jsonlogic-eval-provider/version.txt new file mode 100644 index 000000000..8a9ecc2ea --- /dev/null +++ b/providers/jsonlogic-eval-provider/version.txt @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/release-please-config.json b/release-please-config.json index c2f27753d..f4c12abfe 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -34,6 +34,17 @@ "pom.xml" ] }, + "providers/jsonlogic-eval-provider": { + "package-name": "dev.openfeature.contrib.providers.jsonlogic", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "providers/env-var": { "package-name": "dev.openfeature.contrib.providers.env-var", "release-type": "simple",