-
Notifications
You must be signed in to change notification settings - Fork 49
feat: Added hook data #1506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Added hook data #1506
Changes from 9 commits
13dbeee
549e97c
c06c679
e00885f
e0ad9d9
f4c97ee
ce1a60b
bc883c5
1339f2f
c66e2c8
d9ebe63
f3ea94d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package dev.openfeature.sdk; | ||
|
||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
/** | ||
* Hook data provides a way for hooks to maintain state across their execution stages. | ||
* Each hook instance gets its own isolated data store that persists only for the duration | ||
* of a single flag evaluation. | ||
*/ | ||
public interface HookData { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We may be able to use some of the functionality in Structure or it's implementations to do this. We don't need all of those methods, but some wouldn't hurt, I think, and it would reduce duplication and promote consistency. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @toddbaert I took a closer look and found that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, but can use lombok to delegate only some methods. @aepfli what do you think? You have been working on improving consistency of our POJOs... what is you opinion on this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am actually questioning some of my rewokr based on this findings. -> yes we do have the need for some POJO's to contain only string/numbers/booleans/etc - but i am not sure we need this kind of structure for all of them. especially like the hookcontext is not used for futher evaluation etc. it is used within the same hook, and can have any kind of data. i think that a pure object map would be sufficient in this case. and we should allow all kind of objects (structure limits this, but structure has a dedicated usage for certain operations) |
||
|
||
/** | ||
* Sets a value for the given key. | ||
* | ||
* @param key the key to store the value under | ||
* @param value the value to store | ||
*/ | ||
void set(String key, Object value); | ||
|
||
/** | ||
* Gets the value for the given key. | ||
* | ||
* @param key the key to retrieve the value for | ||
* @return the value, or null if not found | ||
*/ | ||
Object get(String key); | ||
|
||
/** | ||
* Gets the value for the given key, cast to the specified type. | ||
* | ||
* @param <T> the type to cast to | ||
* @param key the key to retrieve the value for | ||
* @param type the class to cast to | ||
* @return the value cast to the specified type, or null if not found | ||
* @throws ClassCastException if the value cannot be cast to the specified type | ||
*/ | ||
<T> T get(String key, Class<T> type); | ||
|
||
/** | ||
* Default implementation is using a synchronized map. | ||
aepfli marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
static HookData create() { | ||
return new DefaultHookData(); | ||
} | ||
|
||
/** | ||
* Default thread-safe implementation of HookData. | ||
mdxabu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
public class DefaultHookData implements HookData { | ||
private final Map<String, Object> data = new HashMap<>(); | ||
|
||
@Override | ||
public void set(String key, Object value) { | ||
data.put(key, value); | ||
} | ||
|
||
@Override | ||
public Object get(String key) { | ||
return data.get(key); | ||
} | ||
|
||
@Override | ||
public <T> T get(String key, Class<T> type) { | ||
Object value = data.get(key); | ||
if (value == null) { | ||
return null; | ||
} | ||
if (!type.isInstance(value)) { | ||
throw new ClassCastException("Value for key '" + key + "' is not of type " + type.getName()); | ||
} | ||
return type.cast(value); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
|
||
import java.util.ArrayList; | ||
import java.util.Collections; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Optional; | ||
|
@@ -87,10 +88,21 @@ private EvaluationContext callBeforeHooks( | |
List<Hook> reversedHooks = new ArrayList<>(hooks); | ||
Collections.reverse(reversedHooks); | ||
EvaluationContext context = hookCtx.getCtx(); | ||
|
||
// Create hook data for each hook instance | ||
Map<Hook, HookData> hookDataMap = new HashMap<>(); | ||
for (Hook hook : reversedHooks) { | ||
if (hook.supportsFlagValueType(flagValueType)) { | ||
hookDataMap.put(hook, HookData.create()); | ||
} | ||
} | ||
Comment on lines
+92
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this map and loop? We don't return the map, so it will be GC'ed after the method returns, and we could create the |
||
|
||
for (Hook hook : reversedHooks) { | ||
if (hook.supportsFlagValueType(flagValueType)) { | ||
Optional<EvaluationContext> optional = | ||
Optional.ofNullable(hook.before(hookCtx, hints)).orElse(Optional.empty()); | ||
// Create a new context with this hook's data | ||
HookContext contextWithHookData = hookCtx.withHookData(hookDataMap.get(hook)); | ||
Optional<EvaluationContext> optional = Optional.ofNullable(hook.before(contextWithHookData, hints)) | ||
.orElse(Optional.empty()); | ||
if (optional.isPresent()) { | ||
context = context.merge(optional.get()); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
package dev.openfeature.sdk; | ||
|
||
import static org.junit.jupiter.api.Assertions.*; | ||
|
||
import java.util.concurrent.CountDownLatch; | ||
import java.util.concurrent.ExecutorService; | ||
import java.util.concurrent.Executors; | ||
import java.util.concurrent.TimeUnit; | ||
import org.junit.jupiter.api.Test; | ||
|
||
class HookDataTest { | ||
|
||
@Test | ||
void shouldStoreAndRetrieveValues() { | ||
HookData hookData = HookData.create(); | ||
|
||
hookData.set("key1", "value1"); | ||
hookData.set("key2", 42); | ||
hookData.set("key3", true); | ||
|
||
assertEquals("value1", hookData.get("key1")); | ||
assertEquals(42, hookData.get("key2")); | ||
assertEquals(true, hookData.get("key3")); | ||
} | ||
|
||
@Test | ||
void shouldReturnNullForMissingKeys() { | ||
HookData hookData = HookData.create(); | ||
|
||
assertNull(hookData.get("nonexistent")); | ||
} | ||
|
||
@Test | ||
void shouldSupportTypeSafeRetrieval() { | ||
HookData hookData = HookData.create(); | ||
|
||
hookData.set("string", "hello"); | ||
hookData.set("integer", 123); | ||
hookData.set("boolean", false); | ||
|
||
assertEquals("hello", hookData.get("string", String.class)); | ||
assertEquals(Integer.valueOf(123), hookData.get("integer", Integer.class)); | ||
assertEquals(Boolean.FALSE, hookData.get("boolean", Boolean.class)); | ||
} | ||
|
||
@Test | ||
void shouldReturnNullForMissingKeysWithType() { | ||
HookData hookData = HookData.create(); | ||
|
||
assertNull(hookData.get("missing", String.class)); | ||
} | ||
|
||
@Test | ||
void shouldThrowClassCastExceptionForWrongType() { | ||
HookData hookData = HookData.create(); | ||
|
||
hookData.set("string", "not a number"); | ||
|
||
assertThrows(ClassCastException.class, () -> { | ||
hookData.get("string", Integer.class); | ||
}); | ||
} | ||
|
||
@Test | ||
void shouldOverwriteExistingValues() { | ||
HookData hookData = HookData.create(); | ||
|
||
hookData.set("key", "original"); | ||
assertEquals("original", hookData.get("key")); | ||
|
||
hookData.set("key", "updated"); | ||
assertEquals("updated", hookData.get("key")); | ||
} | ||
|
||
@Test | ||
void shouldBeThreadSafe() throws InterruptedException { | ||
HookData hookData = HookData.create(); | ||
int threadCount = 10; | ||
int operationsPerThread = 100; | ||
CountDownLatch latch = new CountDownLatch(threadCount); | ||
ExecutorService executor = Executors.newFixedThreadPool(threadCount); | ||
|
||
for (int i = 0; i < threadCount; i++) { | ||
final int threadId = i; | ||
executor.submit(() -> { | ||
try { | ||
for (int j = 0; j < operationsPerThread; j++) { | ||
String key = "thread-" + threadId + "-key-" + j; | ||
String value = "thread-" + threadId + "-value-" + j; | ||
hookData.set(key, value); | ||
assertEquals(value, hookData.get(key)); | ||
} | ||
} finally { | ||
latch.countDown(); | ||
} | ||
}); | ||
} | ||
|
||
assertTrue(latch.await(10, TimeUnit.SECONDS)); | ||
executor.shutdown(); | ||
} | ||
Comment on lines
+75
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd expect this test to fail. HookData is not thread safe anymore. We can remove this test. (In the future, such tests should be done with VmLens, but that is not merged yet) |
||
|
||
@Test | ||
void shouldSupportNullValues() { | ||
HookData hookData = HookData.create(); | ||
|
||
hookData.set("nullKey", null); | ||
assertNull(hookData.get("nullKey")); | ||
assertNull(hookData.get("nullKey", String.class)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,8 +23,14 @@ void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() { | |
Map<String, Value> attributes = new HashMap<>(); | ||
attributes.put("baseKey", new Value("baseValue")); | ||
EvaluationContext baseContext = new ImmutableContext(attributes); | ||
HookContext<String> hookContext = new HookContext<>( | ||
"flagKey", FlagValueType.STRING, "defaultValue", baseContext, () -> "client", () -> "provider"); | ||
HookContext<String> hookContext = HookContext.<String>builder() | ||
.flagKey("flagKey") | ||
.type(FlagValueType.STRING) | ||
.defaultValue("defaultValue") | ||
.ctx(baseContext) | ||
.clientMetadata(() -> "client") | ||
.providerMetadata(() -> "provider") | ||
.build(); | ||
Hook<String> hook1 = mockStringHook(); | ||
Hook<String> hook2 = mockStringHook(); | ||
when(hook1.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("bla", "blubber"))); | ||
|
@@ -47,13 +53,14 @@ void shouldAlwaysCallGenericHook(FlagValueType flagValueType) { | |
HookSupport hookSupport = new HookSupport(); | ||
EvaluationContext baseContext = new ImmutableContext(); | ||
IllegalStateException expectedException = new IllegalStateException("All fine, just a test"); | ||
HookContext<Object> hookContext = new HookContext<>( | ||
"flagKey", | ||
flagValueType, | ||
createDefaultValue(flagValueType), | ||
baseContext, | ||
() -> "client", | ||
() -> "provider"); | ||
HookContext<Object> hookContext = HookContext.<Object>builder() | ||
.flagKey("flagKey") | ||
.type(flagValueType) | ||
.defaultValue(createDefaultValue(flagValueType)) | ||
.ctx(baseContext) | ||
.clientMetadata(() -> "client") | ||
.providerMetadata(() -> "provider") | ||
.build(); | ||
|
||
hookSupport.beforeHooks( | ||
flagValueType, hookContext, Collections.singletonList(genericHook), Collections.emptyMap()); | ||
|
@@ -105,4 +112,33 @@ private EvaluationContext evaluationContextWithValue(String key, String value) { | |
EvaluationContext baseContext = new ImmutableContext(attributes); | ||
return baseContext; | ||
} | ||
|
||
private static class TestHook implements Hook<String> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems to be unused |
||
boolean beforeCalled = false; | ||
boolean afterCalled = false; | ||
boolean errorCalled = false; | ||
boolean finallyCalled = false; | ||
|
||
@Override | ||
public Optional<EvaluationContext> before(HookContext<String> ctx, Map<String, Object> hints) { | ||
beforeCalled = true; | ||
return Optional.empty(); | ||
} | ||
|
||
@Override | ||
public void after(HookContext<String> ctx, FlagEvaluationDetails<String> details, Map<String, Object> hints) { | ||
afterCalled = true; | ||
} | ||
|
||
@Override | ||
public void error(HookContext<String> ctx, Exception error, Map<String, Object> hints) { | ||
errorCalled = true; | ||
} | ||
|
||
@Override | ||
public void finallyAfter( | ||
HookContext<String> ctx, FlagEvaluationDetails<String> details, Map<String, Object> hints) { | ||
finallyCalled = true; | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.