diff --git a/src/main/java/dev/openfeature/javasdk/Client.java b/src/main/java/dev/openfeature/javasdk/Client.java index cbe1bc91..159e2849 100644 --- a/src/main/java/dev/openfeature/javasdk/Client.java +++ b/src/main/java/dev/openfeature/javasdk/Client.java @@ -8,6 +8,18 @@ import java.util.List; public interface Client extends Features { Metadata getMetadata(); + /** + * Return an optional client-level evaluation context. + * @return {@link EvaluationContext} + */ + EvaluationContext getEvaluationContext(); + + /** + * Set the client-level evaluation context. + * @param ctx Client level context. + */ + void setEvaluationContext(EvaluationContext ctx); + /** * Adds hooks for evaluation. * diff --git a/src/main/java/dev/openfeature/javasdk/EvaluationContext.java b/src/main/java/dev/openfeature/javasdk/EvaluationContext.java index e90ddebe..3b926e9d 100644 --- a/src/main/java/dev/openfeature/javasdk/EvaluationContext.java +++ b/src/main/java/dev/openfeature/javasdk/EvaluationContext.java @@ -55,6 +55,9 @@ public class EvaluationContext { private T getAttributeByType(String key, FlagValueType type) { Pair val = attributes.get(key); + if (val == null) { + return null; + } if (val.getFirst() == type) { return (T) val.getSecond(); } @@ -106,6 +109,12 @@ public class EvaluationContext { */ public static EvaluationContext merge(EvaluationContext ctx1, EvaluationContext ctx2) { EvaluationContext ec = new EvaluationContext(); + if (ctx1 == null) { + return ctx2; + } else if (ctx2 == null) { + return ctx1; + } + ec.attributes.putAll(ctx1.attributes); ec.attributes.putAll(ctx2.attributes); diff --git a/src/main/java/dev/openfeature/javasdk/NoOpProvider.java b/src/main/java/dev/openfeature/javasdk/NoOpProvider.java index d8e5131d..233114d0 100644 --- a/src/main/java/dev/openfeature/javasdk/NoOpProvider.java +++ b/src/main/java/dev/openfeature/javasdk/NoOpProvider.java @@ -9,6 +9,11 @@ public class NoOpProvider implements FeatureProvider { public static final String PASSED_IN_DEFAULT = "Passed in default"; @Getter private final String name = "No-op Provider"; + private EvaluationContext ctx; + + public EvaluationContext getMergedContext() { + return ctx; + } @Override public Metadata getMetadata() { @@ -22,6 +27,7 @@ public class NoOpProvider implements FeatureProvider { @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + this.ctx = ctx; return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) @@ -31,6 +37,7 @@ public class NoOpProvider implements FeatureProvider { @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + this.ctx = ctx; return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) @@ -40,6 +47,7 @@ public class NoOpProvider implements FeatureProvider { @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + this.ctx = ctx; return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) @@ -48,6 +56,7 @@ public class NoOpProvider implements FeatureProvider { } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { + this.ctx = ctx; return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) @@ -56,6 +65,7 @@ public class NoOpProvider implements FeatureProvider { } @Override public ProviderEvaluation getObjectEvaluation(String key, T defaultValue, EvaluationContext invocationContext, FlagEvaluationOptions options) { + this.ctx = ctx; return ProviderEvaluation.builder() .value(defaultValue) .variant(PASSED_IN_DEFAULT) diff --git a/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java b/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java index 55e44aa2..6dd714f7 100644 --- a/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java +++ b/src/main/java/dev/openfeature/javasdk/OpenFeatureAPI.java @@ -16,6 +16,8 @@ import java.util.List; public class OpenFeatureAPI { @Getter @Setter private FeatureProvider provider; private static OpenFeatureAPI api; + + @Getter @Setter private EvaluationContext ctx; @Getter private List apiHooks; public static OpenFeatureAPI getInstance() { diff --git a/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java index 77a407a3..c04f905f 100644 --- a/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/javasdk/OpenFeatureClient.java @@ -5,6 +5,7 @@ import java.util.*; import dev.openfeature.javasdk.exceptions.GeneralError; import dev.openfeature.javasdk.internal.ObjectUtils; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -17,6 +18,9 @@ public class OpenFeatureClient implements Client { @Getter private final List clientHooks; private final HookSupport hookSupport; + @Getter @Setter private EvaluationContext evaluationContext; + + public OpenFeatureClient(OpenFeatureAPI openFeatureAPI, String name, String version) { this.openfeatureApi = openFeatureAPI; this.name = name; @@ -38,7 +42,6 @@ public class OpenFeatureClient implements Client { ctx = new EvaluationContext(); } - // merge of: API.context, client.context, invocation.context HookContext hookCtx = HookContext.from(key, type, this.getMetadata(), openfeatureApi.getProvider().getMetadata(), ctx, defaultValue); List mergedHooks = ObjectUtils.merge(provider.getProviderHooks(), flagOptions.getHooks(), clientHooks, openfeatureApi.getApiHooks()); @@ -46,9 +49,19 @@ public class OpenFeatureClient implements Client { FlagEvaluationDetails details = null; try { EvaluationContext ctxFromHook = hookSupport.beforeHooks(type, hookCtx, mergedHooks, hints); - EvaluationContext invocationContext = EvaluationContext.merge(ctxFromHook, ctx); - ProviderEvaluation providerEval = (ProviderEvaluation) createProviderEvaluation(type, key, defaultValue, options, provider, invocationContext); + EvaluationContext invocationCtx = EvaluationContext.merge(ctxFromHook, ctx); + + // merge of: API.context, client.context, invocation.context + EvaluationContext mergedCtx = EvaluationContext.merge( + EvaluationContext.merge( + openfeatureApi.getCtx(), + this.getEvaluationContext() + ), + invocationCtx + ); + + ProviderEvaluation providerEval = (ProviderEvaluation) createProviderEvaluation(type, key, defaultValue, options, provider, mergedCtx); details = FlagEvaluationDetails.from(providerEval, key); hookSupport.afterHooks(type, hookCtx, details, mergedHooks, hints); diff --git a/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java index 5811eb8f..67a8eaa5 100644 --- a/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/javasdk/FlagEvaluationSpecTest.java @@ -20,6 +20,11 @@ class FlagEvaluationSpecTest implements HookFixtures { return api.getClient(); } + @AfterEach void reset_ctx() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setCtx(null); + } + @Specification(number="1.1.1", text="The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.") @Test void global_singleton() { assertSame(OpenFeatureAPI.getInstance(), OpenFeatureAPI.getInstance()); @@ -209,6 +214,41 @@ class FlagEvaluationSpecTest implements HookFixtures { assertEquals(Reason.ERROR, result.getReason()); } + @Specification(number="3.2.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.") + @Specification(number="3.2.2", text="Evaluation context MUST be merged in the order: API (global) - client - invocation, with duplicate values being overwritten.") + @Test void multi_layer_context_merges_correctly() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + NoOpProvider provider = new NoOpProvider(); + api.setProvider(provider); + + EvaluationContext apiCtx = new EvaluationContext(); + apiCtx.addStringAttribute("common", "1"); + apiCtx.addStringAttribute("common2", "1"); + apiCtx.addStringAttribute("api", "2"); + api.setCtx(apiCtx); + + Client c = api.getClient(); + EvaluationContext clientCtx = new EvaluationContext(); + clientCtx.addStringAttribute("common", "3"); + clientCtx.addStringAttribute("common2", "3"); + clientCtx.addStringAttribute("client", "4"); + c.setEvaluationContext(clientCtx); + + EvaluationContext invocationCtx = new EvaluationContext(); + clientCtx.addStringAttribute("common", "5"); + clientCtx.addStringAttribute("invocation", "6"); + + assertFalse(c.getBooleanValue("key", false, invocationCtx)); + + EvaluationContext merged = provider.getMergedContext(); + assertEquals("6", merged.getStringAttribute("invocation")); + assertEquals("5", merged.getStringAttribute("common"), "invocation merge is incorrect"); + assertEquals("4", merged.getStringAttribute("client")); + assertEquals("3", merged.getStringAttribute("common2"), "api client merge is incorrect"); + assertEquals("2", merged.getStringAttribute("api")); + + } + @Specification(number="1.3.3", text="The client SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied default value should be returned.") @Specification(number="1.1.6", text="The client creation function MUST NOT throw, or otherwise abnormally terminate.") @Disabled("Not sure how to test?")