Skip to content

Commit 13dbeee

Browse files
committed
Added hook data
Signed-off-by: mdxabu <[email protected]>
1 parent 957c0d1 commit 13dbeee

File tree

6 files changed

+301
-12
lines changed

6 files changed

+301
-12
lines changed

src/main/java/dev/openfeature/sdk/HookContext.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* @param <T> the type for the flag being evaluated
1212
*/
1313
@Value
14-
@Builder
14+
@Builder(toBuilder = true)
1515
@With
1616
public class HookContext<T> {
1717
@NonNull String flagKey;
@@ -25,6 +25,12 @@ public class HookContext<T> {
2525
ClientMetadata clientMetadata;
2626
Metadata providerMetadata;
2727

28+
/**
29+
* Hook data provides a way for hooks to maintain state across their execution stages.
30+
* Each hook instance gets its own isolated data store.
31+
*/
32+
HookData hookData;
33+
2834
/**
2935
* Builds a {@link HookContext} instances from request data.
3036
*
@@ -51,6 +57,7 @@ public static <T> HookContext<T> from(
5157
.providerMetadata(providerMetadata)
5258
.ctx(ctx)
5359
.defaultValue(defaultValue)
60+
.hookData(null) // Explicitly set to null for backward compatibility
5461
.build();
5562
}
5663
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package dev.openfeature.sdk;
2+
3+
import java.util.concurrent.ConcurrentHashMap;
4+
import java.util.concurrent.ConcurrentMap;
5+
6+
/**
7+
* Hook data provides a way for hooks to maintain state across their execution stages.
8+
* Each hook instance gets its own isolated data store that persists only for the duration
9+
* of a single flag evaluation.
10+
*/
11+
public interface HookData {
12+
13+
/**
14+
* Sets a value for the given key.
15+
*
16+
* @param key the key to store the value under
17+
* @param value the value to store
18+
*/
19+
void set(String key, Object value);
20+
21+
/**
22+
* Gets the value for the given key.
23+
*
24+
* @param key the key to retrieve the value for
25+
* @return the value, or null if not found
26+
*/
27+
Object get(String key);
28+
29+
/**
30+
* Gets the value for the given key, cast to the specified type.
31+
*
32+
* @param <T> the type to cast to
33+
* @param key the key to retrieve the value for
34+
* @param type the class to cast to
35+
* @return the value cast to the specified type, or null if not found
36+
* @throws ClassCastException if the value cannot be cast to the specified type
37+
*/
38+
<T> T get(String key, Class<T> type);
39+
40+
/**
41+
* Default implementation using ConcurrentHashMap for thread safety.
42+
*/
43+
static HookData create() {
44+
return new DefaultHookData();
45+
}
46+
47+
/**
48+
* Default thread-safe implementation of HookData.
49+
*/
50+
class DefaultHookData implements HookData {
51+
private final ConcurrentMap<String, Object> data = new ConcurrentHashMap<>();
52+
53+
@Override
54+
public void set(String key, Object value) {
55+
data.put(key, value);
56+
}
57+
58+
@Override
59+
public Object get(String key) {
60+
return data.get(key);
61+
}
62+
63+
@Override
64+
public <T> T get(String key, Class<T> type) {
65+
Object value = data.get(key);
66+
if (value == null) {
67+
return null;
68+
}
69+
if (!type.isInstance(value)) {
70+
throw new ClassCastException("Value for key '" + key + "' is not of type " + type.getName());
71+
}
72+
return type.cast(value);
73+
}
74+
}
75+
}

src/main/java/dev/openfeature/sdk/HookSupport.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.ArrayList;
44
import java.util.Collections;
5+
import java.util.HashMap;
56
import java.util.List;
67
import java.util.Map;
78
import java.util.Optional;
@@ -87,10 +88,21 @@ private EvaluationContext callBeforeHooks(
8788
List<Hook> reversedHooks = new ArrayList<>(hooks);
8889
Collections.reverse(reversedHooks);
8990
EvaluationContext context = hookCtx.getCtx();
91+
92+
// Create hook data for each hook instance
93+
Map<Hook, HookData> hookDataMap = new HashMap<>();
94+
for (Hook hook : reversedHooks) {
95+
if (hook.supportsFlagValueType(flagValueType)) {
96+
hookDataMap.put(hook, HookData.create());
97+
}
98+
}
99+
90100
for (Hook hook : reversedHooks) {
91101
if (hook.supportsFlagValueType(flagValueType)) {
102+
// Create a new context with this hook's data
103+
HookContext contextWithHookData = hookCtx.withHookData(hookDataMap.get(hook));
92104
Optional<EvaluationContext> optional =
93-
Optional.ofNullable(hook.before(hookCtx, hints)).orElse(Optional.empty());
105+
Optional.ofNullable(hook.before(contextWithHookData, hints)).orElse(Optional.empty());
94106
if (optional.isPresent()) {
95107
context = context.merge(optional.get());
96108
}

src/test/java/dev/openfeature/sdk/HookContextTest.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package dev.openfeature.sdk;
22

3-
import static org.junit.jupiter.api.Assertions.assertTrue;
3+
import static org.junit.jupiter.api.Assertions.*;
44
import static org.mockito.Mockito.mock;
55

66
import org.junit.jupiter.api.Test;
@@ -29,4 +29,52 @@ void metadata_field_is_type_metadata() {
2929
"The before stage MUST run before flag resolution occurs. It accepts a hook context (required) and hook hints (optional) as parameters. It has no return value.")
3030
@Test
3131
void not_applicable_for_dynamic_context() {}
32+
33+
@Test
34+
void shouldCreateHookContextWithHookData() {
35+
HookData hookData = HookData.create();
36+
hookData.set("test", "value");
37+
38+
HookContext<String> context = HookContext.<String>builder()
39+
.flagKey("test-flag")
40+
.type(FlagValueType.STRING)
41+
.defaultValue("default")
42+
.ctx(new ImmutableContext())
43+
.hookData(hookData)
44+
.build();
45+
46+
assertNotNull(context.getHookData());
47+
assertEquals("value", context.getHookData().get("test"));
48+
}
49+
50+
@Test
51+
void shouldCreateHookContextWithoutHookData() {
52+
HookContext<String> context = HookContext.<String>builder()
53+
.flagKey("test-flag")
54+
.type(FlagValueType.STRING)
55+
.defaultValue("default")
56+
.ctx(new ImmutableContext())
57+
.build();
58+
59+
assertNull(context.getHookData());
60+
}
61+
62+
@Test
63+
void shouldCreateHookContextWithHookDataUsingWith() {
64+
HookContext<String> originalContext = HookContext.<String>builder()
65+
.flagKey("test-flag")
66+
.type(FlagValueType.STRING)
67+
.defaultValue("default")
68+
.ctx(new ImmutableContext())
69+
.build();
70+
71+
HookData hookData = HookData.create();
72+
hookData.set("timing", System.currentTimeMillis());
73+
74+
HookContext<String> contextWithHookData = originalContext.withHookData(hookData);
75+
76+
assertNull(originalContext.getHookData());
77+
assertNotNull(contextWithHookData.getHookData());
78+
assertNotNull(contextWithHookData.getHookData().get("timing"));
79+
}
3280
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package dev.openfeature.sdk;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.util.concurrent.CountDownLatch;
6+
import java.util.concurrent.ExecutorService;
7+
import java.util.concurrent.Executors;
8+
import java.util.concurrent.TimeUnit;
9+
import org.junit.jupiter.api.Test;
10+
11+
class HookDataTest {
12+
13+
@Test
14+
void shouldStoreAndRetrieveValues() {
15+
HookData hookData = HookData.create();
16+
17+
hookData.set("key1", "value1");
18+
hookData.set("key2", 42);
19+
hookData.set("key3", true);
20+
21+
assertEquals("value1", hookData.get("key1"));
22+
assertEquals(42, hookData.get("key2"));
23+
assertEquals(true, hookData.get("key3"));
24+
}
25+
26+
@Test
27+
void shouldReturnNullForMissingKeys() {
28+
HookData hookData = HookData.create();
29+
30+
assertNull(hookData.get("nonexistent"));
31+
}
32+
33+
@Test
34+
void shouldSupportTypeSafeRetrieval() {
35+
HookData hookData = HookData.create();
36+
37+
hookData.set("string", "hello");
38+
hookData.set("integer", 123);
39+
hookData.set("boolean", false);
40+
41+
assertEquals("hello", hookData.get("string", String.class));
42+
assertEquals(Integer.valueOf(123), hookData.get("integer", Integer.class));
43+
assertEquals(Boolean.FALSE, hookData.get("boolean", Boolean.class));
44+
}
45+
46+
@Test
47+
void shouldReturnNullForMissingKeysWithType() {
48+
HookData hookData = HookData.create();
49+
50+
assertNull(hookData.get("missing", String.class));
51+
}
52+
53+
@Test
54+
void shouldThrowClassCastExceptionForWrongType() {
55+
HookData hookData = HookData.create();
56+
57+
hookData.set("string", "not a number");
58+
59+
assertThrows(ClassCastException.class, () -> {
60+
hookData.get("string", Integer.class);
61+
});
62+
}
63+
64+
@Test
65+
void shouldOverwriteExistingValues() {
66+
HookData hookData = HookData.create();
67+
68+
hookData.set("key", "original");
69+
assertEquals("original", hookData.get("key"));
70+
71+
hookData.set("key", "updated");
72+
assertEquals("updated", hookData.get("key"));
73+
}
74+
75+
@Test
76+
void shouldBeThreadSafe() throws InterruptedException {
77+
HookData hookData = HookData.create();
78+
int threadCount = 10;
79+
int operationsPerThread = 100;
80+
CountDownLatch latch = new CountDownLatch(threadCount);
81+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
82+
83+
for (int i = 0; i < threadCount; i++) {
84+
final int threadId = i;
85+
executor.submit(() -> {
86+
try {
87+
for (int j = 0; j < operationsPerThread; j++) {
88+
String key = "thread-" + threadId + "-key-" + j;
89+
String value = "thread-" + threadId + "-value-" + j;
90+
hookData.set(key, value);
91+
assertEquals(value, hookData.get(key));
92+
}
93+
} finally {
94+
latch.countDown();
95+
}
96+
});
97+
}
98+
99+
assertTrue(latch.await(10, TimeUnit.SECONDS));
100+
executor.shutdown();
101+
}
102+
103+
@Test
104+
void shouldSupportNullValues() {
105+
HookData hookData = HookData.create();
106+
107+
hookData.set("nullKey", null);
108+
assertNull(hookData.get("nullKey"));
109+
assertNull(hookData.get("nullKey", String.class));
110+
}
111+
}
112+

src/test/java/dev/openfeature/sdk/HookSupportTest.java

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() {
2323
Map<String, Value> attributes = new HashMap<>();
2424
attributes.put("baseKey", new Value("baseValue"));
2525
EvaluationContext baseContext = new ImmutableContext(attributes);
26-
HookContext<String> hookContext = new HookContext<>(
27-
"flagKey", FlagValueType.STRING, "defaultValue", baseContext, () -> "client", () -> "provider");
26+
HookContext<String> hookContext = HookContext.<String>builder()
27+
.flagKey("flagKey")
28+
.type(FlagValueType.STRING)
29+
.defaultValue("defaultValue")
30+
.ctx(baseContext)
31+
.clientMetadata(() -> "client")
32+
.providerMetadata(() -> "provider")
33+
.build();
2834
Hook<String> hook1 = mockStringHook();
2935
Hook<String> hook2 = mockStringHook();
3036
when(hook1.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("bla", "blubber")));
@@ -47,13 +53,14 @@ void shouldAlwaysCallGenericHook(FlagValueType flagValueType) {
4753
HookSupport hookSupport = new HookSupport();
4854
EvaluationContext baseContext = new ImmutableContext();
4955
IllegalStateException expectedException = new IllegalStateException("All fine, just a test");
50-
HookContext<Object> hookContext = new HookContext<>(
51-
"flagKey",
52-
flagValueType,
53-
createDefaultValue(flagValueType),
54-
baseContext,
55-
() -> "client",
56-
() -> "provider");
56+
HookContext<Object> hookContext = HookContext.<Object>builder()
57+
.flagKey("flagKey")
58+
.type(flagValueType)
59+
.defaultValue(createDefaultValue(flagValueType))
60+
.ctx(baseContext)
61+
.clientMetadata(() -> "client")
62+
.providerMetadata(() -> "provider")
63+
.build();
5764

5865
hookSupport.beforeHooks(
5966
flagValueType, hookContext, Collections.singletonList(genericHook), Collections.emptyMap());
@@ -105,4 +112,32 @@ private EvaluationContext evaluationContextWithValue(String key, String value) {
105112
EvaluationContext baseContext = new ImmutableContext(attributes);
106113
return baseContext;
107114
}
115+
116+
private static class TestHook implements Hook<String> {
117+
boolean beforeCalled = false;
118+
boolean afterCalled = false;
119+
boolean errorCalled = false;
120+
boolean finallyCalled = false;
121+
122+
@Override
123+
public Optional<EvaluationContext> before(HookContext<String> ctx, Map<String, Object> hints) {
124+
beforeCalled = true;
125+
return Optional.empty();
126+
}
127+
128+
@Override
129+
public void after(HookContext<String> ctx, FlagEvaluationDetails<String> details, Map<String, Object> hints) {
130+
afterCalled = true;
131+
}
132+
133+
@Override
134+
public void error(HookContext<String> ctx, Exception error, Map<String, Object> hints) {
135+
errorCalled = true;
136+
}
137+
138+
@Override
139+
public void finallyAfter(HookContext<String> ctx, FlagEvaluationDetails<String> details, Map<String, Object> hints) {
140+
finallyCalled = true;
141+
}
142+
}
108143
}

0 commit comments

Comments
 (0)