evaluationContextThreadLocal = new ThreadLocal<>();
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public EvaluationContext getTransactionContext() {
+ return this.evaluationContextThreadLocal.get();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setTransactionContext(EvaluationContext evaluationContext) {
+ this.evaluationContextThreadLocal.set(evaluationContext);
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/TransactionContextPropagator.java b/src/main/java/dev/openfeature/sdk/TransactionContextPropagator.java
new file mode 100644
index 00000000..05f7d3eb
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/TransactionContextPropagator.java
@@ -0,0 +1,27 @@
+package dev.openfeature.sdk;
+
+/**
+ * {@link TransactionContextPropagator} is responsible for persisting a transactional context
+ * for the duration of a single transaction.
+ * Examples of potential transaction specific context include: a user id, user agent, IP.
+ * Transaction context is merged with evaluation context prior to flag evaluation.
+ *
+ * The precedence of merging context can be seen in
+ * the specification.
+ *
+ */
+public interface TransactionContextPropagator {
+
+ /**
+ * Returns the currently defined transaction context using the registered transaction
+ * context propagator.
+ *
+ * @return {@link EvaluationContext} The current transaction context
+ */
+ EvaluationContext getTransactionContext();
+
+ /**
+ * Sets the transaction context.
+ */
+ void setTransactionContext(EvaluationContext evaluationContext);
+}
diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java
index e2e00881..60b6ee13 100644
--- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java
+++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java
@@ -302,41 +302,107 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
@Specification(number="3.2.1.1", text="The API, Client and invocation MUST have a method for supplying evaluation context.")
@Specification(number="3.2.2.1", text="The API MUST have a method for setting the global evaluation context.")
- @Specification(number="3.2.3", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")
+ @Specification(number="3.2.3", text="Evaluation context MUST be merged in the order: API (global; lowest precedence) -> transaction -> client -> invocation -> before hooks (highest precedence), with duplicate values being overwritten.")
@Test void multi_layer_context_merges_correctly() {
DoSomethingProvider provider = new DoSomethingProvider();
FeatureProviderTestUtils.setFeatureProvider(provider);
+ TransactionContextPropagator transactionContextPropagator = new ThreadLocalTransactionContextPropagator();
+ api.setTransactionContextPropagator(transactionContextPropagator);
- Map attributes = new HashMap<>();
- attributes.put("common", new Value("1"));
- attributes.put("common2", new Value("1"));
- attributes.put("api", new Value("2"));
- EvaluationContext apiCtx = new ImmutableContext(attributes);
+ Map apiAttributes = new HashMap<>();
+ apiAttributes.put("common1", new Value("1"));
+ apiAttributes.put("common2", new Value("1"));
+ apiAttributes.put("common3", new Value("1"));
+ apiAttributes.put("api", new Value("1"));
+ EvaluationContext apiCtx = new ImmutableContext(apiAttributes);
api.setEvaluationContext(apiCtx);
+ Map transactionAttributes = new HashMap<>();
+ // overwrite value from api context
+ transactionAttributes.put("common1", new Value("2"));
+ transactionAttributes.put("common4", new Value("2"));
+ transactionAttributes.put("common5", new Value("2"));
+ transactionAttributes.put("transaction", new Value("2"));
+ EvaluationContext transactionCtx = new ImmutableContext(transactionAttributes);
+
+ api.setTransactionContext(transactionCtx);
+
Client c = api.getClient();
- Map attributes1 = new HashMap<>();
- attributes.put("common", new Value("3"));
- attributes.put("common2", new Value("3"));
- attributes.put("client", new Value("4"));
- attributes.put("common", new Value("5"));
- attributes.put("invocation", new Value("6"));
- EvaluationContext clientCtx = new ImmutableContext(attributes);
+ Map clientAttributes = new HashMap<>();
+ // overwrite value from api context
+ clientAttributes.put("common2", new Value("3"));
+ // overwrite value from transaction context
+ clientAttributes.put("common4", new Value("3"));
+ clientAttributes.put("common6", new Value("3"));
+ clientAttributes.put("client", new Value("3"));
+ EvaluationContext clientCtx = new ImmutableContext(clientAttributes);
c.setEvaluationContext(clientCtx);
- EvaluationContext invocationCtx = new ImmutableContext();
+ Map invocationAttributes = new HashMap<>();
+ // overwrite value from api context
+ invocationAttributes.put("common3", new Value("4"));
+ // overwrite value from transaction context
+ invocationAttributes.put("common5", new Value("4"));
+ // overwrite value from api client context
+ invocationAttributes.put("common6", new Value("4"));
+ invocationAttributes.put("invocation", new Value("4"));
+ EvaluationContext invocationCtx = new ImmutableContext(invocationAttributes);
// dosomethingprovider inverts this value.
assertTrue(c.getBooleanValue("key", false, invocationCtx));
EvaluationContext merged = provider.getMergedContext();
- assertEquals("6", merged.getValue("invocation").asString());
- assertEquals("5", merged.getValue("common").asString(), "invocation merge is incorrect");
- assertEquals("4", merged.getValue("client").asString());
+ assertEquals("1", merged.getValue("api").asString());
+ assertEquals("2", merged.getValue("transaction").asString());
+ assertEquals("3", merged.getValue("client").asString());
+ assertEquals("4", merged.getValue("invocation").asString());
+ assertEquals("2", merged.getValue("common1").asString(), "transaction merge is incorrect");
assertEquals("3", merged.getValue("common2").asString(), "api client merge is incorrect");
- assertEquals("2", merged.getValue("api").asString());
+ assertEquals("4", merged.getValue("common3").asString(), "invocation merge is incorrect");
+ assertEquals("3", merged.getValue("common4").asString(), "api client merge is incorrect");
+ assertEquals("4", merged.getValue("common5").asString(), "invocation merge is incorrect");
+ assertEquals("4", merged.getValue("common6").asString(), "invocation merge is incorrect");
+
+ }
+
+ @Specification(number="3.3.1.1", text="The API SHOULD have a method for setting a transaction context propagator.")
+ @Test void setting_transaction_context_propagator() {
+ DoSomethingProvider provider = new DoSomethingProvider();
+ FeatureProviderTestUtils.setFeatureProvider(provider);
+
+ TransactionContextPropagator transactionContextPropagator = new ThreadLocalTransactionContextPropagator();
+ api.setTransactionContextPropagator(transactionContextPropagator);
+ assertEquals(transactionContextPropagator, api.getTransactionContextPropagator());
+ }
+
+ @Specification(number="3.3.1.2.1", text="The API MUST have a method for setting the evaluation context of the transaction context propagator for the current transaction.")
+ @Test void setting_transaction_context() {
+ DoSomethingProvider provider = new DoSomethingProvider();
+ FeatureProviderTestUtils.setFeatureProvider(provider);
+
+ TransactionContextPropagator transactionContextPropagator = new ThreadLocalTransactionContextPropagator();
+ api.setTransactionContextPropagator(transactionContextPropagator);
+
+ Map attributes = new HashMap<>();
+ attributes.put("common", new Value("1"));
+ EvaluationContext transactionContext = new ImmutableContext(attributes);
+
+ api.setTransactionContext(transactionContext);
+ assertEquals(transactionContext, transactionContextPropagator.getTransactionContext());
+ }
+
+ @Specification(number="3.3.1.2.2", text="A transaction context propagator MUST have a method for setting the evaluation context of the current transaction.")
+ @Specification(number="3.3.1.2.3", text="A transaction context propagator MUST have a method for getting the evaluation context of the current transaction.")
+ @Test void transaction_context_propagator_setting_context() {
+ TransactionContextPropagator transactionContextPropagator = new ThreadLocalTransactionContextPropagator();
+
+ Map attributes = new HashMap<>();
+ attributes.put("common", new Value("1"));
+ EvaluationContext transactionContext = new ImmutableContext(attributes);
+ transactionContextPropagator.setTransactionContext(transactionContext);
+ assertEquals(transactionContext, transactionContextPropagator.getTransactionContext());
}
@Specification(number="1.3.4", 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.")
@@ -355,6 +421,7 @@ public void initialize(EvaluationContext evaluationContext) throws Exception {
@Specification(number="1.3.2.1", text="The client MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), and evaluation options (optional), which returns the flag value.")
@Specification(number="3.2.2.2", text="The Client and invocation MUST NOT have a method for supplying evaluation context.")
@Specification(number="3.2.4.1", text="When the global evaluation context is set, the on context changed handler MUST run.")
+ @Specification(number="3.3.2.1", text="The API MUST NOT have a method for setting a transaction context propagator.")
@Test void not_applicable_for_dynamic_context() {}
}
diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java
index def331db..d0e173b2 100644
--- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java
+++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java
@@ -1,5 +1,21 @@
package dev.openfeature.sdk;
+import dev.openfeature.sdk.fixtures.HookFixtures;
+import dev.openfeature.sdk.testutils.FeatureProviderTestUtils;
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -13,23 +29,6 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-
-import dev.openfeature.sdk.testutils.FeatureProviderTestUtils;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InOrder;
-
-import dev.openfeature.sdk.fixtures.HookFixtures;
-import lombok.SneakyThrows;
-
class HookSpecTest implements HookFixtures {
@AfterEach
void emptyApiHooks() {
@@ -500,7 +499,7 @@ public void finallyAfter(HookContext ctx, Map hints) {
.hook(hook)
.build());
- ArgumentCaptor captor = ArgumentCaptor.forClass(MutableContext.class);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ImmutableContext.class);
verify(provider).getBooleanEvaluation(any(), any(), captor.capture());
EvaluationContext ec = captor.getValue();
assertEquals("works", ec.getValue("test").asString());
diff --git a/src/test/java/dev/openfeature/sdk/LockingTest.java b/src/test/java/dev/openfeature/sdk/LockingTest.java
index f58795ad..ddfa9c07 100644
--- a/src/test/java/dev/openfeature/sdk/LockingTest.java
+++ b/src/test/java/dev/openfeature/sdk/LockingTest.java
@@ -185,6 +185,20 @@ void getContextShouldReadLockAndUnlock() {
verify(apiLock.readLock()).unlock();
}
+ @Test
+ void setTransactionalContextPropagatorShouldWriteLockAndUnlock() {
+ api.setTransactionContextPropagator(new NoOpTransactionContextPropagator());
+ verify(apiLock.writeLock()).lock();
+ verify(apiLock.writeLock()).unlock();
+ }
+
+ @Test
+ void getTransactionalContextPropagatorShouldReadLockAndUnlock() {
+ api.getTransactionContextPropagator();
+ verify(apiLock.readLock()).lock();
+ verify(apiLock.readLock()).unlock();
+ }
+
@Test
void clearHooksShouldWriteLockAndUnlock() {
diff --git a/src/test/java/dev/openfeature/sdk/MutableContextTest.java b/src/test/java/dev/openfeature/sdk/MutableContextTest.java
index 1d462b12..df21e6ec 100644
--- a/src/test/java/dev/openfeature/sdk/MutableContextTest.java
+++ b/src/test/java/dev/openfeature/sdk/MutableContextTest.java
@@ -116,4 +116,19 @@ void mergeShouldRetainItsSubkeysWhenOverridingContextHasNoTargetingKey() {
Structure value = key1.asStructure();
assertArrayEquals(new Object[]{"key1_1"}, value.keySet().toArray());
}
+
+ @DisplayName("Ensure mutations are chainable")
+ @Test
+ void shouldAllowChainingOfMutations() {
+ MutableContext context = new MutableContext();
+ context.add("key1", "val1")
+ .add("key2", 2)
+ .setTargetingKey("TARGETING_KEY")
+ .add("key3", 3.0);
+
+ assertEquals("TARGETING_KEY", context.getTargetingKey());
+ assertEquals("val1", context.getValue("key1").asString());
+ assertEquals(2, context.getValue("key2").asInteger());
+ assertEquals(3.0, context.getValue("key3").asDouble());
+ }
}
diff --git a/src/test/java/dev/openfeature/sdk/NoOpTransactionContextPropagatorTest.java b/src/test/java/dev/openfeature/sdk/NoOpTransactionContextPropagatorTest.java
new file mode 100644
index 00000000..06b7e93c
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/NoOpTransactionContextPropagatorTest.java
@@ -0,0 +1,29 @@
+package dev.openfeature.sdk;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NoOpTransactionContextPropagatorTest {
+
+ NoOpTransactionContextPropagator contextPropagator = new NoOpTransactionContextPropagator();
+
+ @Test
+ public void emptyTransactionContext() {
+ EvaluationContext result = contextPropagator.getTransactionContext();
+ assertTrue(result.asMap().isEmpty());
+ }
+
+ @Test
+ public void setTransactionContext() {
+ Map transactionAttrs = new HashMap<>();
+ transactionAttrs.put("userId", new Value("userId"));
+ EvaluationContext transactionCtx = new ImmutableContext(transactionAttrs);
+ contextPropagator.setTransactionContext(transactionCtx);
+ EvaluationContext result = contextPropagator.getTransactionContext();
+ assertTrue(result.asMap().isEmpty());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
index e19a10ae..eceace2b 100644
--- a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
+++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java
@@ -5,6 +5,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Collections;
+import java.util.HashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -73,4 +74,17 @@ void settingDefaultProviderToNullErrors() {
void settingNamedClientProviderToNullErrors() {
assertThatCode(() -> api.setProvider(CLIENT_NAME, null)).isInstanceOf(IllegalArgumentException.class);
}
+
+ @Test
+ void settingTransactionalContextPropagatorToNullErrors() {
+ assertThatCode(() -> api.setTransactionContextPropagator(null)).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void setEvaluationContextShouldAllowChaining() {
+ OpenFeatureClient client = new OpenFeatureClient(api, "name", "version");
+ EvaluationContext ctx = new ImmutableContext("targeting key", new HashMap<>());
+ OpenFeatureClient result = client.setEvaluationContext(ctx);
+ assertEquals(client, result);
+ }
}
diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
index 9036576d..d6340a84 100644
--- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
+++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java
@@ -3,12 +3,18 @@
import java.util.*;
import dev.openfeature.sdk.fixtures.HookFixtures;
+
import org.junit.jupiter.api.*;
import org.mockito.Mockito;
import org.simplify4u.slf4jmock.LoggerMock;
import org.slf4j.Logger;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class OpenFeatureClientTest implements HookFixtures {
@@ -67,4 +73,28 @@ void mergeContextTest() {
assertThat(result.getValue()).isTrue();
}
+
+ @Test
+ @DisplayName("addHooks should allow chaining by returning the same client instance")
+ void addHooksShouldAllowChaining() {
+ OpenFeatureAPI api = mock(OpenFeatureAPI.class);
+ OpenFeatureClient client = new OpenFeatureClient(api, "name", "version");
+ Hook> hook1 = Mockito.mock(Hook.class);
+ Hook> hook2 = Mockito.mock(Hook.class);
+
+ OpenFeatureClient result = client.addHooks(hook1, hook2);
+ assertEquals(client, result);
+ }
+
+ @Test
+ @DisplayName("setEvaluationContext should allow chaining by returning the same client instance")
+ void setEvaluationContextShouldAllowChaining() {
+ OpenFeatureAPI api = mock(OpenFeatureAPI.class);
+ OpenFeatureClient client = new OpenFeatureClient(api, "name", "version");
+ EvaluationContext ctx = new ImmutableContext("targeting key", new HashMap<>());
+
+ OpenFeatureClient result = client.setEvaluationContext(ctx);
+ assertEquals(client, result);
+ }
+
}
diff --git a/src/test/java/dev/openfeature/sdk/ThreadLocalTransactionContextPropagatorTest.java b/src/test/java/dev/openfeature/sdk/ThreadLocalTransactionContextPropagatorTest.java
new file mode 100644
index 00000000..531205c1
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/ThreadLocalTransactionContextPropagatorTest.java
@@ -0,0 +1,57 @@
+package dev.openfeature.sdk;
+
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.FutureTask;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ThreadLocalTransactionContextPropagatorTest {
+
+ ThreadLocalTransactionContextPropagator contextPropagator = new ThreadLocalTransactionContextPropagator();
+
+ @Test
+ public void setTransactionContextOneThread() {
+ EvaluationContext firstContext = new ImmutableContext();
+ contextPropagator.setTransactionContext(firstContext);
+ assertSame(firstContext, contextPropagator.getTransactionContext());
+ EvaluationContext secondContext = new ImmutableContext();
+ contextPropagator.setTransactionContext(secondContext);
+ assertNotSame(firstContext, contextPropagator.getTransactionContext());
+ assertSame(secondContext, contextPropagator.getTransactionContext());
+ }
+
+ @Test
+ public void emptyTransactionContext() {
+ EvaluationContext result = contextPropagator.getTransactionContext();
+ assertNull(result);
+ }
+
+ @SneakyThrows
+ @Test
+ public void setTransactionContextTwoThreads() {
+ EvaluationContext firstContext = new ImmutableContext();
+ EvaluationContext secondContext = new ImmutableContext();
+
+ Callable callable = () -> {
+ assertNull(contextPropagator.getTransactionContext());
+ contextPropagator.setTransactionContext(secondContext);
+ EvaluationContext transactionContext = contextPropagator.getTransactionContext();
+ assertSame(secondContext, transactionContext);
+ return transactionContext;
+ };
+ contextPropagator.setTransactionContext(firstContext);
+ EvaluationContext firstThreadContext = contextPropagator.getTransactionContext();
+ assertSame(firstContext, firstThreadContext);
+
+ FutureTask futureTask = new FutureTask<>(callable);
+ Thread thread = new Thread(futureTask);
+ thread.start();
+ EvaluationContext secondThreadContext = futureTask.get();
+
+ assertSame(secondContext, secondThreadContext);
+ assertSame(firstContext, contextPropagator.getTransactionContext());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java b/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java
index 1d1de1ef..9886c383 100644
--- a/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java
+++ b/src/test/java/dev/openfeature/sdk/fixtures/HookFixtures.java
@@ -1,6 +1,10 @@
package dev.openfeature.sdk.fixtures;
-import dev.openfeature.sdk.*;
+import dev.openfeature.sdk.BooleanHook;
+import dev.openfeature.sdk.DoubleHook;
+import dev.openfeature.sdk.Hook;
+import dev.openfeature.sdk.IntegerHook;
+import dev.openfeature.sdk.StringHook;
import static org.mockito.Mockito.spy;
diff --git a/version.txt b/version.txt
index de28578a..27f9cd32 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-1.7.6
+1.8.0