Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/main/java/dev/openfeature/sdk/HookContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* @param <T> the type for the flag being evaluated
*/
@Value
@Builder
@Builder(toBuilder = true)
@With
public class HookContext<T> {
@NonNull String flagKey;
Expand All @@ -25,6 +25,12 @@ public class HookContext<T> {
ClientMetadata clientMetadata;
Metadata providerMetadata;

/**
* Hook data provides a way for hooks to maintain state across their execution stages.
* Each hook instance gets its own isolated data store.
*/
HookData hookData;

/**
* Builds a {@link HookContext} instances from request data.
*
Expand All @@ -51,6 +57,7 @@ public static <T> HookContext<T> from(
.providerMetadata(providerMetadata)
.ctx(ctx)
.defaultValue(defaultValue)
.hookData(null) // Explicitly set to null for backward compatibility
.build();
}
}
75 changes: 75 additions & 0 deletions src/main/java/dev/openfeature/sdk/HookData.java
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 {

/**
* 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 uses a HashMap.
*/
static HookData create() {
return new DefaultHookData();
}

/**
* Default implementation of HookData.
*/
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);
}
}
}
101 changes: 78 additions & 23 deletions src/main/java/dev/openfeature/sdk/HookSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,92 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.BiConsumer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;

@Slf4j
@RequiredArgsConstructor
@SuppressWarnings({"unchecked", "rawtypes"})
class HookSupport {

public EvaluationContext beforeHooks(
FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks, Map<String, Object> hints) {
return callBeforeHooks(flagValueType, hookCtx, hooks, hints);
FlagValueType flagValueType,
HookContext hookCtx,
List<Pair<Hook, HookData>> hookDataPairs,
Map<String, Object> hints) {
return callBeforeHooks(flagValueType, hookCtx, hookDataPairs, hints);
}

public void afterHooks(
FlagValueType flagValueType,
HookContext hookContext,
FlagEvaluationDetails details,
List<Hook> hooks,
List<Pair<Hook, HookData>> hookDataPairs,
Map<String, Object> hints) {
executeHooksUnchecked(flagValueType, hooks, hook -> hook.after(hookContext, details, hints));
executeHooksUnchecked(
flagValueType, hookDataPairs, hookContext, (hook, ctx) -> hook.after(ctx, details, hints));
}

public void afterAllHooks(
FlagValueType flagValueType,
HookContext hookCtx,
FlagEvaluationDetails details,
List<Hook> hooks,
List<Pair<Hook, HookData>> hookDataPairs,
Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "finally", hook -> hook.finallyAfter(hookCtx, details, hints));
executeHooks(
flagValueType,
hookDataPairs,
hookCtx,
"finally",
(hook, ctx) -> hook.finallyAfter(ctx, details, hints));
}

public void errorHooks(
FlagValueType flagValueType,
HookContext hookCtx,
Exception e,
List<Hook> hooks,
List<Pair<Hook, HookData>> hookDataPairs,
Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "error", hook -> hook.error(hookCtx, e, hints));
executeHooks(flagValueType, hookDataPairs, hookCtx, "error", (hook, ctx) -> hook.error(ctx, e, hints));
}

public List<Pair<Hook, HookData>> getHookDataPairs(List<Hook> hooks) {
var pairs = new ArrayList<Pair<Hook, HookData>>();
for (Hook hook : hooks) {
pairs.add(Pair.of(hook, HookData.create()));
}
return pairs;
}

private <T> void executeHooks(
FlagValueType flagValueType, List<Hook> hooks, String hookMethod, Consumer<Hook<T>> hookCode) {
if (hooks != null) {
for (Hook hook : hooks) {
FlagValueType flagValueType,
List<Pair<Hook, HookData>> hookDataPairs,
HookContext hookContext,
String hookMethod,
BiConsumer<Hook<T>, HookContext> hookCode) {
if (hookDataPairs != null) {
for (Pair<Hook, HookData> hookDataPair : hookDataPairs) {
Hook hook = hookDataPair.getLeft();
HookData hookData = hookDataPair.getRight();
if (hook.supportsFlagValueType(flagValueType)) {
executeChecked(hook, hookCode, hookMethod);
executeChecked(hook, hookData, hookContext, hookCode, hookMethod);
}
}
}
}

// before, error, and finally hooks shouldn't throw
private <T> void executeChecked(Hook<T> hook, Consumer<Hook<T>> hookCode, String hookMethod) {
private <T> void executeChecked(
Hook<T> hook,
HookData hookData,
HookContext hookContext,
BiConsumer<Hook<T>, HookContext> hookCode,
String hookMethod) {
try {
hookCode.accept(hook);
var hookCtxWithData = hookContext.withHookData(hookData);
hookCode.accept(hook, hookCtxWithData);
} catch (Exception exception) {
log.error(
"Unhandled exception when running {} hook {} (only 'after' hooks should throw)",
Expand All @@ -71,26 +101,51 @@ private <T> void executeChecked(Hook<T> hook, Consumer<Hook<T>> hookCode, String
}

// after hooks can throw in order to do validation
private <T> void executeHooksUnchecked(FlagValueType flagValueType, List<Hook> hooks, Consumer<Hook<T>> hookCode) {
if (hooks != null) {
for (Hook hook : hooks) {
private <T> void executeHooksUnchecked(
FlagValueType flagValueType,
List<Pair<Hook, HookData>> hookDataPairs,
HookContext hookContext,
BiConsumer<Hook<T>, HookContext> hookCode) {
if (hookDataPairs != null) {
for (Pair<Hook, HookData> hookDataPair : hookDataPairs) {
Hook hook = hookDataPair.getLeft();
HookData hookData = hookDataPair.getRight();
if (hook.supportsFlagValueType(flagValueType)) {
hookCode.accept(hook);
var hookCtxWithData = hookContext.withHookData(hookData);
hookCode.accept(hook, hookCtxWithData);
}
}
}
}

private EvaluationContext callBeforeHooks(
FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks, Map<String, Object> hints) {
FlagValueType flagValueType,
HookContext hookCtx,
List<Pair<Hook, HookData>> hookDataPairs,
Map<String, Object> hints) {
// These traverse backwards from normal.
List<Hook> reversedHooks = new ArrayList<>(hooks);
List<Pair<Hook, HookData>> reversedHooks = new ArrayList<>(hookDataPairs);
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)) {
Optional<EvaluationContext> optional =
Optional.ofNullable(hook.before(hookCtx, hints)).orElse(Optional.empty());
hookDataMap.put(hook, HookData.create());
}
}
*/

for (Pair<Hook, HookData> hookDataPair : reversedHooks) {
Hook hook = hookDataPair.getLeft();
HookData hookData = hookDataPair.getRight();

if (hook.supportsFlagValueType(flagValueType)) {
// Create a new context with this hook's data
HookContext contextWithHookData = hookCtx.withHookData(hookData);
Optional<EvaluationContext> optional = Optional.ofNullable(hook.before(contextWithHookData, hints))
.orElse(Optional.empty());
if (optional.isPresent()) {
context = context.merge(optional.get());
}
Expand Down
35 changes: 14 additions & 21 deletions src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.function.Consumer;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;

/**
* OpenFeature Client implementation.
Expand Down Expand Up @@ -164,29 +165,21 @@ private <T> FlagEvaluationDetails<T> evaluateFlag(
var hints = Collections.unmodifiableMap(flagOptions.getHookHints());

FlagEvaluationDetails<T> details = null;
List<Hook> mergedHooks = null;
List<Hook> mergedHooks;
List<Pair<Hook, HookData>> hookDataPairs = null;
HookContext<T> afterHookContext = null;

try {
var stateManager = openfeatureApi.getFeatureProviderStateManager(this.domain);
final var stateManager = openfeatureApi.getFeatureProviderStateManager(this.domain);
// provider must be accessed once to maintain a consistent reference
var provider = stateManager.getProvider();
var state = stateManager.getState();

final var provider = stateManager.getProvider();
final var state = stateManager.getState();
afterHookContext = HookContext.from(
key, type, this.getMetadata(), provider.getMetadata(), mergeEvaluationContext(ctx), defaultValue);
mergedHooks = ObjectUtils.merge(
provider.getProviderHooks(), flagOptions.getHooks(), clientHooks, openfeatureApi.getMutableHooks());

var mergedCtx = hookSupport.beforeHooks(
type,
HookContext.from(
key,
type,
this.getMetadata(),
provider.getMetadata(),
mergeEvaluationContext(ctx),
defaultValue),
mergedHooks,
hints);
hookDataPairs = hookSupport.getHookDataPairs(mergedHooks);
var mergedCtx = hookSupport.beforeHooks(type, afterHookContext, hookDataPairs, hints);

afterHookContext =
HookContext.from(key, type, this.getMetadata(), provider.getMetadata(), mergedCtx, defaultValue);
Expand All @@ -207,9 +200,9 @@ private <T> FlagEvaluationDetails<T> evaluateFlag(
var error =
ExceptionUtils.instantiateErrorByErrorCode(details.getErrorCode(), details.getErrorMessage());
enrichDetailsWithErrorDefaults(defaultValue, details);
hookSupport.errorHooks(type, afterHookContext, error, mergedHooks, hints);
hookSupport.errorHooks(type, afterHookContext, error, hookDataPairs, hints);
} else {
hookSupport.afterHooks(type, afterHookContext, details, mergedHooks, hints);
hookSupport.afterHooks(type, afterHookContext, details, hookDataPairs, hints);
}
} catch (Exception e) {
if (details == null) {
Expand All @@ -222,9 +215,9 @@ private <T> FlagEvaluationDetails<T> evaluateFlag(
}
details.setErrorMessage(e.getMessage());
enrichDetailsWithErrorDefaults(defaultValue, details);
hookSupport.errorHooks(type, afterHookContext, e, mergedHooks, hints);
hookSupport.errorHooks(type, afterHookContext, e, hookDataPairs, hints);
} finally {
hookSupport.afterAllHooks(type, afterHookContext, details, mergedHooks, hints);
hookSupport.afterAllHooks(type, afterHookContext, details, hookDataPairs, hints);
}

return details;
Expand Down
Loading
Loading