From b242156fc62304e797bfee5dd4ac702f4e69ea7d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 6 Aug 2026 09:50:51 -0400 Subject: [PATCH] feat: execute child provider hooks in multiprovider evaluations Signed-off-by: Jonathan Norris --- .../multiprovider/HookExecutionContext.java | 15 + .../sdk/multiprovider/MultiProvider.java | 132 ++++- .../MultiProviderHookExecutor.java | 309 ++++++++++ .../MultiProviderHookExecutorTest.java | 528 ++++++++++++++++++ .../multiprovider/MultiProviderHooksTest.java | 243 ++++++++ 5 files changed, 1222 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java create mode 100644 src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutorTest.java create mode 100644 src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java b/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java new file mode 100644 index 000000000..5b0d501d9 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/HookExecutionContext.java @@ -0,0 +1,15 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.ClientMetadata; +import java.util.Map; + +/** Captures hook lifecycle context (client metadata and hints) for per-provider hook execution. */ +final class HookExecutionContext { + final ClientMetadata clientMetadata; + final Map hints; + + HookExecutionContext(ClientMetadata clientMetadata, Map hints) { + this.clientMetadata = clientMetadata; + this.hints = hints; + } +} diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java index 00611ed78..c201dea1c 100644 --- a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java +++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java @@ -1,8 +1,13 @@ package dev.openfeature.sdk.multiprovider; +import dev.openfeature.sdk.ClientMetadata; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; @@ -15,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -28,6 +34,9 @@ *

This provider delegates flag evaluations to multiple underlying providers using a configurable * {@link Strategy}. It also exposes combined metadata containing the original metadata of each * underlying provider. + * + *

Hooks registered on the child providers are executed around each child evaluation, so a child + * provider's own hooks observe the evaluation it takes part in. */ @Slf4j public class MultiProvider extends EventProvider { @@ -40,6 +49,9 @@ public class MultiProvider extends EventProvider { private final Map providers; private final Strategy strategy; + private final ThreadLocal hookExecutionContextThreadLocal = new ThreadLocal<>(); + private final ClientMetadata hookClientMetadata = MultiProvider::getNAME; + private final MultiProviderHookExecutor hookExecutor = new MultiProviderHookExecutor(hookClientMetadata); private MultiProviderMetadata metadata; /** @@ -63,6 +75,49 @@ public MultiProvider(List providers, Strategy strategy) { this.strategy = Objects.requireNonNull(strategy, "strategy must not be null"); } + @SuppressWarnings({"rawtypes", "unchecked"}) + private final List providerHooks = List.of(new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + hookExecutionContextThreadLocal.set( + new HookExecutionContext(ctx.getClientMetadata(), snapshotHints(hints))); + return Optional.empty(); + } + + @Override + public void finallyAfter(HookContext ctx, FlagEvaluationDetails details, Map hints) { + hookExecutionContextThreadLocal.remove(); + } + }); + + /** + * Returns provider-level hooks for this MultiProvider. + * + *

Includes a {@code before} hook that captures the {@link ClientMetadata} + * and hook hints from the SDK's hook lifecycle. This context is then available + * during per-child-provider hook execution, matching the JS SDK's WeakMap-based + * approach for passing hook context into the provider evaluation. + * + * @return the list of provider hooks + */ + @Override + public List getProviderHooks() { + return providerHooks; + } + + /** + * Defensively copies the hook hints. {@code FlagEvaluationOptions.hookHints} is backed by a + * mutable map, and the captured hints may be read from other threads when a strategy evaluates + * providers in parallel. A plain copy (rather than {@code Map.copyOf}) is used so that hints + * containing null values are still supported. + */ + private static Map snapshotHints(Map hints) { + if (hints == null || hints.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new HashMap<>(hints)); + } + protected static Map buildProviders(List providers) { Map providersMap = new LinkedHashMap<>(providers.size()); for (FeatureProvider provider : providers) { @@ -147,29 +202,96 @@ public Metadata getMetadata() { @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + HookExecutionContext hookExecutionContext = currentHookExecutionContext(); return strategy.evaluate( - providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx)); + providers, + key, + defaultValue, + ctx, + provider -> hookExecutor.evaluate( + provider, + key, + defaultValue, + ctx, + hookExecutionContext, + FlagValueType.BOOLEAN, + (p, evaluationContext) -> p.getBooleanEvaluation(key, defaultValue, evaluationContext))); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx)); + HookExecutionContext hookExecutionContext = currentHookExecutionContext(); + return strategy.evaluate( + providers, + key, + defaultValue, + ctx, + provider -> hookExecutor.evaluate( + provider, + key, + defaultValue, + ctx, + hookExecutionContext, + FlagValueType.STRING, + (p, evaluationContext) -> p.getStringEvaluation(key, defaultValue, evaluationContext))); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + HookExecutionContext hookExecutionContext = currentHookExecutionContext(); return strategy.evaluate( - providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx)); + providers, + key, + defaultValue, + ctx, + provider -> hookExecutor.evaluate( + provider, + key, + defaultValue, + ctx, + hookExecutionContext, + FlagValueType.INTEGER, + (p, evaluationContext) -> p.getIntegerEvaluation(key, defaultValue, evaluationContext))); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx)); + HookExecutionContext hookExecutionContext = currentHookExecutionContext(); + return strategy.evaluate( + providers, + key, + defaultValue, + ctx, + provider -> hookExecutor.evaluate( + provider, + key, + defaultValue, + ctx, + hookExecutionContext, + FlagValueType.DOUBLE, + (p, evaluationContext) -> p.getDoubleEvaluation(key, defaultValue, evaluationContext))); } @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx)); + HookExecutionContext hookExecutionContext = currentHookExecutionContext(); + return strategy.evaluate( + providers, + key, + defaultValue, + ctx, + provider -> hookExecutor.evaluate( + provider, + key, + defaultValue, + ctx, + hookExecutionContext, + FlagValueType.OBJECT, + (p, evaluationContext) -> p.getObjectEvaluation(key, defaultValue, evaluationContext))); + } + + private HookExecutionContext currentHookExecutionContext() { + return hookExecutionContextThreadLocal.get(); } @Override diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java new file mode 100644 index 000000000..191c9d460 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutor.java @@ -0,0 +1,309 @@ +package dev.openfeature.sdk.multiprovider; + +import dev.openfeature.sdk.ClientMetadata; +import dev.openfeature.sdk.DefaultHookData; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.HookData; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ExceptionUtils; +import dev.openfeature.sdk.exceptions.OpenFeatureError; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import lombok.extern.slf4j.Slf4j; + +/** + * Runs per-provider hook lifecycles during flag evaluation. + * + *

Mirrors the role of {@code HookExecutor} in the JS SDK: executes the before/after/error/finally + * stages for each child provider's own hooks, using context captured by {@link MultiProvider}'s + * provider-level hook. + */ +@Slf4j +class MultiProviderHookExecutor { + + private final ClientMetadata fallbackClientMetadata; + + MultiProviderHookExecutor(ClientMetadata fallbackClientMetadata) { + this.fallbackClientMetadata = fallbackClientMetadata; + } + + ProviderEvaluation evaluate( + FeatureProvider provider, + String key, + T defaultValue, + EvaluationContext ctx, + HookExecutionContext hookExecutionContext, + FlagValueType valueType, + BiFunction> providerFunction) { + List> hooks = supportedHooks(provider.getProviderHooks(), valueType); + if (hooks.isEmpty()) { + return providerFunction.apply(provider, ctx); + } + return new Lifecycle(provider, key, defaultValue, valueType, hookExecutionContext, hooks) + .run(ctx, providerFunction); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private List> supportedHooks(List rawHooks, FlagValueType valueType) { + if (rawHooks == null || rawHooks.isEmpty()) { + return Collections.emptyList(); + } + List> hooks = new ArrayList<>(rawHooks.size()); + for (Hook hook : rawHooks) { + if (hook.supportsFlagValueType(valueType)) { + hooks.add(new HookExecution<>(hook, new DefaultHookData())); + } + } + return hooks; + } + + /** + * Runs the hook lifecycle for a single provider evaluation. + * + *

Holds the invariant evaluation parameters so that each stage can be executed by its own + * method. Instances are single-use and confined to the calling thread. + */ + private final class Lifecycle { + + private final FeatureProvider provider; + private final String key; + private final T defaultValue; + private final FlagValueType valueType; + private final HookExecutionContext hookExecutionContext; + private final Map hookHints; + private final List> hooks; + private final List> reversedHooks; + private EvaluationContext evaluatedContext; + private FlagEvaluationDetails details; + + private Lifecycle( + FeatureProvider provider, + String key, + T defaultValue, + FlagValueType valueType, + HookExecutionContext hookExecutionContext, + List> hooks) { + this.provider = provider; + this.key = key; + this.defaultValue = defaultValue; + this.valueType = valueType; + this.hookExecutionContext = hookExecutionContext; + this.hookHints = resolveHookHints(hookExecutionContext); + this.hooks = hooks; + // Per spec, before hooks run in registration order; after/error/finally run in reverse. + this.reversedHooks = new ArrayList<>(hooks); + Collections.reverse(this.reversedHooks); + } + + private ProviderEvaluation run( + EvaluationContext ctx, + BiFunction> providerFunction) { + evaluatedContext = copyEvaluationContext(ctx); + try { + runBefore(); + ProviderEvaluation providerEvaluation = + providerFunction.apply(provider, toProviderContext(ctx, evaluatedContext)); + details = FlagEvaluationDetails.from(providerEvaluation, key); + if (providerEvaluation.getErrorCode() == null) { + runAfter(); + } else { + enrichDetailsWithErrorDefaults(defaultValue, details); + runError(toEvaluationException(providerEvaluation)); + } + return providerEvaluation; + } catch (Exception e) { + details = buildErrorDetails(key, defaultValue, details, e); + runError(e); + throw e; + } finally { + runFinallyAfter(); + } + } + + private void runBefore() { + for (HookExecution execution : hooks) { + HookContext hookContext = hookContext(execution); + var contextUpdate = execution.hook.before(hookContext, hookHints); + // Hooks are invoked through a raw type; those predating Optional may return null. + if (contextUpdate != null // NOSONAR + && contextUpdate.isPresent() + && contextUpdate.get() != hookContext.getCtx() + && !contextUpdate.get().isEmpty()) { + evaluatedContext = evaluatedContext.merge(contextUpdate.get()); + } + } + } + + private void runAfter() { + for (HookExecution execution : reversedHooks) { + execution.hook.after(hookContext(execution), details, hookHints); + } + } + + private void runError(Exception error) { + for (HookExecution execution : reversedHooks) { + try { + execution.hook.error(hookContext(execution), error, hookHints); + } catch (Exception e) { + log.error("error executing provider hook error stage", e); + } + } + } + + private void runFinallyAfter() { + FlagEvaluationDetails finalDetails = details == null + ? FlagEvaluationDetails.builder() + .flagKey(key) + .value(defaultValue) + .build() + : details; + for (HookExecution execution : reversedHooks) { + try { + execution.hook.finallyAfter(hookContext(execution), finalDetails, hookHints); + } catch (Exception e) { + log.error("error executing provider hook finally stage", e); + } + } + } + + private HookContext hookContext(HookExecution execution) { + return createHookContext( + key, valueType, defaultValue, evaluatedContext, provider, hookExecutionContext, execution.hookData); + } + } + + private EvaluationContext copyEvaluationContext(EvaluationContext context) { + if (context == null) { + return ImmutableContext.EMPTY; + } + String targetingKey = context.getTargetingKey(); + if (targetingKey == null) { + return new ImmutableContext(context.asMap()); + } + return new ImmutableContext(targetingKey, context.asMap()); + } + + private EvaluationContext toProviderContext(EvaluationContext originalContext, EvaluationContext evaluatedContext) { + if (originalContext == null && (evaluatedContext == null || evaluatedContext.isEmpty())) { + return null; + } + return evaluatedContext; + } + + private Exception toEvaluationException(ProviderEvaluation providerEvaluation) { + if (providerEvaluation == null || providerEvaluation.getErrorCode() == null) { + return new RuntimeException("Provider evaluation returned an error"); + } + return ExceptionUtils.instantiateErrorByErrorCode( + providerEvaluation.getErrorCode(), providerEvaluation.getErrorMessage()); + } + + @SuppressWarnings("deprecation") + private HookContext createHookContext( + String key, + FlagValueType valueType, + T defaultValue, + EvaluationContext evaluationContext, + FeatureProvider provider, + HookExecutionContext hookExecutionContext, + HookData hookData) { + return HookContext.builder() + .flagKey(key) + .type(valueType) + .defaultValue(normalizeDefaultValue(valueType, defaultValue)) + .ctx(evaluationContext) + .clientMetadata(resolveClientMetadata(hookExecutionContext)) + .providerMetadata(provider.getMetadata()) + .hookData(hookData) + .build(); + } + + /** + * Returns a non-null default value for use in hook contexts when the caller passes {@code null}. + * The returned object is always assignment-compatible with the expected type for {@code valueType}. + */ + @SuppressWarnings("unchecked") + private T normalizeDefaultValue(FlagValueType valueType, T defaultValue) { + if (defaultValue != null) { + return defaultValue; + } + Object fallback; + switch (valueType) { + case BOOLEAN: + fallback = Boolean.FALSE; + break; + case STRING: + fallback = ""; + break; + case INTEGER: + fallback = Integer.valueOf(0); + break; + case DOUBLE: + fallback = Double.valueOf(0d); + break; + case OBJECT: + fallback = new Value(); + break; + default: + return defaultValue; + } + // Safe: the SDK guarantees T matches the valueType enum. + return (T) fallback; + } + + private ClientMetadata resolveClientMetadata(HookExecutionContext hookExecutionContext) { + if (hookExecutionContext == null || hookExecutionContext.clientMetadata == null) { + return fallbackClientMetadata; + } + return hookExecutionContext.clientMetadata; + } + + private Map resolveHookHints(HookExecutionContext hookExecutionContext) { + if (hookExecutionContext == null || hookExecutionContext.hints == null) { + return Collections.emptyMap(); + } + return hookExecutionContext.hints; + } + + private FlagEvaluationDetails buildErrorDetails( + String key, T defaultValue, FlagEvaluationDetails details, Exception error) { + FlagEvaluationDetails errorDetails = details == null + ? FlagEvaluationDetails.builder().flagKey(key).build() + : details; + if (error instanceof OpenFeatureError) { + errorDetails.setErrorCode(((OpenFeatureError) error).getErrorCode()); + } else { + errorDetails.setErrorCode(ErrorCode.GENERAL); + } + errorDetails.setErrorMessage(error.getMessage()); + enrichDetailsWithErrorDefaults(defaultValue, errorDetails); + return errorDetails; + } + + private void enrichDetailsWithErrorDefaults(T defaultValue, FlagEvaluationDetails details) { + details.setValue(defaultValue); + details.setReason(Reason.ERROR.toString()); + } + + private static final class HookExecution { + private final Hook hook; + private final HookData hookData; + + private HookExecution(Hook hook, HookData hookData) { + this.hook = hook; + this.hookData = hookData; + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutorTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutorTest.java new file mode 100644 index 000000000..1faff56c9 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHookExecutorTest.java @@ -0,0 +1,528 @@ +package dev.openfeature.sdk.multiprovider; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FlagNotFoundError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class MultiProviderHookExecutorTest { + + private final MultiProviderHookExecutor executor = new MultiProviderHookExecutor(() -> "test"); + + @Test + void shortCircuitsDirectlyWhenProviderHasNoHooks() { + AtomicBoolean called = new AtomicBoolean(false); + ProviderEvaluation result = executor.evaluate( + stubProvider("p", Collections.emptyList()), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> { + called.set(true); + return ProviderEvaluation.builder().value("direct").build(); + }); + + assertTrue(called.get()); + assertEquals("direct", result.getValue()); + } + + @Test + void runsBeforeInRegistrationOrderAndRemainingStagesInReverse() { + List calls = new ArrayList<>(); + Hook first = orderRecordingHook(calls, "first"); + Hook second = orderRecordingHook(calls, "second"); + + executor.evaluate( + stubProvider("p", List.of(first, second)), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("ok").build()); + + assertEquals( + List.of( + "before:first", + "before:second", + "after:second", + "after:first", + "finally:second", + "finally:first"), + calls); + } + + @Test + void shortCircuitsWhenNoHooksSupportTheFlagType() { + AtomicBoolean called = new AtomicBoolean(false); + Hook boolOnlyHook = new Hook() { + @Override + public boolean supportsFlagValueType(FlagValueType type) { + return type == FlagValueType.BOOLEAN; + } + }; + ProviderEvaluation result = executor.evaluate( + stubProvider("p", List.of(boolOnlyHook)), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> { + called.set(true); + return ProviderEvaluation.builder().value("direct").build(); + }); + + assertTrue(called.get()); + assertEquals("direct", result.getValue()); + } + + @Test + @SuppressWarnings("rawtypes") + void toleratesNullReturnedFromBeforeHook() { + Hook nullBeforeHook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + return null; + } + }; + ProviderEvaluation result = executor.evaluate( + stubProvider("p", List.of(nullBeforeHook)), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("ok").build()); + + assertEquals("ok", result.getValue()); + } + + @Test + void swallowsExceptionThrownFromErrorHook() { + AtomicBoolean errorHookCalled = new AtomicBoolean(false); + Hook throwingErrorHook = new Hook() { + @Override + public void error(HookContext ctx, Exception error, Map hints) { + errorHookCalled.set(true); + throw new RuntimeException("error hook exploded"); + } + }; + RuntimeException providerEx = new RuntimeException("provider failed"); + + RuntimeException thrown = assertThrows( + RuntimeException.class, + () -> executor.evaluate( + stubProvider("p", List.of(throwingErrorHook)), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> { + throw providerEx; + })); + + assertTrue(errorHookCalled.get(), "error() hook should have been called"); + assertEquals(providerEx, thrown, "original provider exception must propagate"); + } + + @Test + void swallowsExceptionThrownFromFinallyAfterHook() { + Hook throwingFinallyHook = new Hook() { + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + throw new RuntimeException("finallyAfter exploded"); + } + }; + + assertDoesNotThrow(() -> executor.evaluate( + stubProvider("p", List.of(throwingFinallyHook)), + "flag", + "default", + null, + null, + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("ok").build())); + } + + @Test + void finallyAfterReceivesSyntheticDetailsWhenBeforeThrows() { + AtomicReference> captured = new AtomicReference<>(); + Hook hook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + throw new RuntimeException("before failed"); + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + captured.set(details); + } + }; + + assertThrows( + RuntimeException.class, + () -> executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "fallback", + null, + null, + FlagValueType.STRING, + (p, ctx) -> + ProviderEvaluation.builder().value("ok").build())); + + assertNotNull(captured.get(), "finallyAfter must be called even when before() throws"); + assertEquals("flag", captured.get().getFlagKey()); + assertEquals("fallback", captured.get().getValue()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void normalizesNullDefaultValueForEachFlagType() { + AtomicReference capturedDefault = new AtomicReference<>(); + Hook capturingHook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + capturedDefault.set(ctx.getDefaultValue()); + return Optional.empty(); + } + }; + FeatureProvider provider = stubProvider("p", List.of(capturingHook)); + + executor.evaluate( + provider, + "f", + (Boolean) null, + null, + null, + FlagValueType.BOOLEAN, + (p, ctx) -> ProviderEvaluation.builder().value(false).build()); + assertEquals(Boolean.FALSE, capturedDefault.get()); + + executor.evaluate( + provider, + "f", + (String) null, + null, + null, + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("").build()); + assertEquals("", capturedDefault.get()); + + executor.evaluate( + provider, + "f", + (Integer) null, + null, + null, + FlagValueType.INTEGER, + (p, ctx) -> ProviderEvaluation.builder().value(0).build()); + assertEquals(0, capturedDefault.get()); + + executor.evaluate( + provider, + "f", + (Double) null, + null, + null, + FlagValueType.DOUBLE, + (p, ctx) -> ProviderEvaluation.builder().value(0d).build()); + assertEquals(0d, capturedDefault.get()); + + executor.evaluate( + provider, + "f", + (Value) null, + null, + null, + FlagValueType.OBJECT, + (p, ctx) -> + ProviderEvaluation.builder().value(new Value()).build()); + assertNotNull(capturedDefault.get()); + } + + @Test + void runsErrorStageWhenProviderReturnsAnErrorCodedEvaluation() { + AtomicReference capturedError = new AtomicReference<>(); + AtomicReference> capturedDetails = new AtomicReference<>(); + AtomicBoolean afterCalled = new AtomicBoolean(false); + Hook hook = new Hook() { + @Override + public void after( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + afterCalled.set(true); + } + + @Override + public void error(HookContext ctx, Exception error, Map hints) { + capturedError.set(error); + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + capturedDetails.set(details); + } + }; + + ProviderEvaluation result = executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "fallback", + null, + null, + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder() + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("nope") + .build()); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertFalse(afterCalled.get(), "after() must not run for an error-coded evaluation"); + assertInstanceOf(FlagNotFoundError.class, capturedError.get()); + assertEquals("fallback", capturedDetails.get().getValue()); + assertEquals(Reason.ERROR.toString(), capturedDetails.get().getReason()); + } + + @Test + void usesErrorCodeFromOpenFeatureErrorThrownByProvider() { + AtomicReference> capturedDetails = new AtomicReference<>(); + Hook hook = new Hook() { + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + capturedDetails.set(details); + } + }; + + assertThrows( + TypeMismatchError.class, + () -> executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "fallback", + null, + null, + FlagValueType.STRING, + (p, ctx) -> { + throw new TypeMismatchError("wrong type"); + })); + + assertEquals(ErrorCode.TYPE_MISMATCH, capturedDetails.get().getErrorCode()); + assertEquals("wrong type", capturedDetails.get().getErrorMessage()); + assertEquals("fallback", capturedDetails.get().getValue()); + } + + @Test + void passesCapturedClientMetadataAndHintsToHooks() { + AtomicReference capturedClientName = new AtomicReference<>(); + AtomicReference> capturedHints = new AtomicReference<>(); + Hook hook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + capturedClientName.set(ctx.getClientMetadata().getName()); + capturedHints.set(hints); + return Optional.empty(); + } + }; + + executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "default", + null, + new HookExecutionContext(() -> "my-client", Map.of("hint", "value")), + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("ok").build()); + + assertEquals("my-client", capturedClientName.get()); + assertEquals("value", capturedHints.get().get("hint")); + } + + @Test + void fallsBackToExecutorClientMetadataWhenContextHasNone() { + AtomicReference capturedClientName = new AtomicReference<>(); + Hook hook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + capturedClientName.set(ctx.getClientMetadata().getName()); + return Optional.empty(); + } + }; + + executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "default", + null, + new HookExecutionContext(null, null), + FlagValueType.STRING, + (p, ctx) -> ProviderEvaluation.builder().value("ok").build()); + + assertEquals("test", capturedClientName.get()); + } + + @Test + void mergesContextReturnedFromBeforeHookAndPreservesTargetingKey() { + AtomicReference capturedProviderContext = new AtomicReference<>(); + Hook hook = new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + return Optional.of(new ImmutableContext(Map.of("added", new Value("yes")))); + } + }; + + executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "default", + new ImmutableContext("user-1", Map.of("original", new Value("kept"))), + null, + FlagValueType.STRING, + (p, ctx) -> { + capturedProviderContext.set(ctx); + return ProviderEvaluation.builder().value("ok").build(); + }); + + EvaluationContext providerContext = capturedProviderContext.get(); + assertNotNull(providerContext); + assertEquals("user-1", providerContext.getTargetingKey()); + assertEquals("kept", providerContext.getValue("original").asString()); + assertEquals("yes", providerContext.getValue("added").asString()); + } + + @Test + void copiesContextWithoutTargetingKey() { + AtomicReference capturedProviderContext = new AtomicReference<>(); + Hook hook = new Hook() {}; + + executor.evaluate( + stubProvider("p", List.of(hook)), + "flag", + "default", + new ImmutableContext(Map.of("original", new Value("kept"))), + null, + FlagValueType.STRING, + (p, ctx) -> { + capturedProviderContext.set(ctx); + return ProviderEvaluation.builder().value("ok").build(); + }); + + assertNull(capturedProviderContext.get().getTargetingKey()); + assertEquals("kept", capturedProviderContext.get().getValue("original").asString()); + } + + @Test + void shortCircuitsWhenProviderReturnsNullHookList() { + AtomicBoolean called = new AtomicBoolean(false); + ProviderEvaluation result = executor.evaluate( + stubProvider("p", null), "flag", "default", null, null, FlagValueType.STRING, (p, ctx) -> { + called.set(true); + return ProviderEvaluation.builder().value("direct").build(); + }); + + assertTrue(called.get()); + assertEquals("direct", result.getValue()); + } + + private static Hook orderRecordingHook(List calls, String name) { + return new Hook() { + @Override + public Optional before(HookContext ctx, Map hints) { + calls.add("before:" + name); + return Optional.empty(); + } + + @Override + public void after( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + calls.add("after:" + name); + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + calls.add("finally:" + name); + } + }; + } + + @SuppressWarnings("rawtypes") + private static FeatureProvider stubProvider(String name, List hooks) { + return new EventProvider() { + @Override + public Metadata getMetadata() { + return () -> name; + } + + @Override + public List getProviderHooks() { + return hooks; + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + String key, Value defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + } +} diff --git a/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java new file mode 100644 index 000000000..5d5ac6dac --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/multiprovider/MultiProviderHooksTest.java @@ -0,0 +1,243 @@ +package dev.openfeature.sdk.multiprovider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagEvaluationOptions; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class MultiProviderHooksTest { + + @Test + void shouldExecuteProviderHooksAndKeepPerProviderContextIsolation() throws Exception { + RecordingHook firstHook = new RecordingHook("provider1"); + RecordingHook secondHook = new RecordingHook("provider2"); + + HookedStringProvider provider1 = new HookedStringProvider( + "provider1", + List.of(firstHook), + ProviderEvaluation.builder() + .errorCode(dev.openfeature.sdk.ErrorCode.GENERAL) + .errorMessage("failed") + .build()); + HookedStringProvider provider2 = new HookedStringProvider( + "provider2", + List.of(secondHook), + ProviderEvaluation.builder().value("ok").build()); + + MultiProvider multiProvider = new MultiProvider(List.of(provider1, provider2), new FirstSuccessfulStrategy()); + multiProvider.initialize(null); + + ProviderEvaluation evaluation = multiProvider.getStringEvaluation("flag", "default", null); + + assertEquals("ok", evaluation.getValue()); + + assertEquals(1, firstHook.beforeCount.get()); + assertEquals(0, firstHook.afterCount.get()); + assertEquals(1, firstHook.errorCount.get()); + assertEquals(1, firstHook.finallyCount.get()); + + assertEquals(1, secondHook.beforeCount.get()); + assertEquals(1, secondHook.afterCount.get()); + assertEquals(0, secondHook.errorCount.get()); + assertEquals(1, secondHook.finallyCount.get()); + + assertEquals( + "provider1", + provider1.lastEvaluationContext.getValue("hookOwner").asString()); + assertNull(provider1.lastEvaluationContext.getValue("provider2Marker")); + assertNotNull(firstHook.lastFinallyDetails); + assertEquals(ErrorCode.GENERAL, firstHook.lastFinallyDetails.getErrorCode()); + assertEquals(Reason.ERROR.toString(), firstHook.lastFinallyDetails.getReason()); + assertEquals("default", firstHook.lastFinallyDetails.getValue()); + assertEquals("failed", firstHook.lastFinallyDetails.getErrorMessage()); + + assertEquals( + "provider2", + provider2.lastEvaluationContext.getValue("hookOwner").asString()); + assertNull(provider2.lastEvaluationContext.getValue("provider1Marker")); + } + + @Test + void shouldPassHookHintsAndClientMetadataAndEnrichThrownProviderErrors() throws Exception { + RecordingHook firstHook = new RecordingHook("provider1"); + RecordingHook secondHook = new RecordingHook("provider2"); + + HookedStringProvider provider1 = + new HookedStringProvider("provider1", List.of(firstHook), new RuntimeException("boom")); + HookedStringProvider provider2 = new HookedStringProvider( + "provider2", + List.of(secondHook), + ProviderEvaluation.builder().value("ok").build()); + + MultiProvider multiProvider = new MultiProvider(List.of(provider1, provider2), new FirstSuccessfulStrategy()); + + OpenFeatureAPI api = OpenFeatureAPI.createIsolated(); + try { + api.setProviderAndWait("multiProviderHooks", multiProvider); + Client client = api.getClient("multiProviderHooks"); + + var evaluation = client.getStringDetails( + "flag", + "default", + new ImmutableContext(), + FlagEvaluationOptions.builder() + .hookHints(Map.of("hintKey", "hintValue")) + .build()); + + assertEquals("ok", evaluation.getValue()); + + assertEquals("hintValue", firstHook.lastHints.get("hintKey")); + assertEquals("hintValue", secondHook.lastHints.get("hintKey")); + assertEquals("multiProviderHooks", firstHook.lastClientDomain); + assertEquals("multiProviderHooks", secondHook.lastClientDomain); + + assertNotNull(firstHook.lastFinallyDetails); + assertEquals(ErrorCode.GENERAL, firstHook.lastFinallyDetails.getErrorCode()); + assertEquals(Reason.ERROR.toString(), firstHook.lastFinallyDetails.getReason()); + assertEquals("default", firstHook.lastFinallyDetails.getValue()); + assertEquals("boom", firstHook.lastFinallyDetails.getErrorMessage()); + } finally { + api.shutdown(); + } + } + + static class RecordingHook implements Hook { + private final String providerName; + private final AtomicInteger beforeCount = new AtomicInteger(); + private final AtomicInteger afterCount = new AtomicInteger(); + private final AtomicInteger errorCount = new AtomicInteger(); + private final AtomicInteger finallyCount = new AtomicInteger(); + private Map lastHints = Map.of(); + private String lastClientDomain; + private FlagEvaluationDetails lastFinallyDetails; + + RecordingHook(String providerName) { + this.providerName = providerName; + } + + @Override + public Optional before(HookContext ctx, Map hints) { + beforeCount.incrementAndGet(); + ctx.getHookData().set("provider", providerName); + lastHints = hints; + lastClientDomain = ctx.getClientMetadata().getDomain(); + return Optional.of( + new MutableContext().add("hookOwner", providerName).add(providerName + "Marker", providerName)); + } + + @Override + public void after( + HookContext ctx, + dev.openfeature.sdk.FlagEvaluationDetails details, + Map hints) { + afterCount.incrementAndGet(); + assertEquals(providerName, ctx.getHookData().get("provider")); + lastHints = hints; + lastClientDomain = ctx.getClientMetadata().getDomain(); + } + + @Override + public void error(HookContext ctx, Exception error, Map hints) { + errorCount.incrementAndGet(); + assertEquals(providerName, ctx.getHookData().get("provider")); + lastHints = hints; + lastClientDomain = ctx.getClientMetadata().getDomain(); + } + + @Override + public void finallyAfter( + HookContext ctx, + dev.openfeature.sdk.FlagEvaluationDetails details, + Map hints) { + finallyCount.incrementAndGet(); + assertEquals(providerName, ctx.getHookData().get("provider")); + lastHints = hints; + lastClientDomain = ctx.getClientMetadata().getDomain(); + lastFinallyDetails = details; + } + } + + static class HookedStringProvider extends EventProvider { + private final String name; + private final List> hooks; + private final ProviderEvaluation evaluation; + private final RuntimeException evaluationException; + private EvaluationContext lastEvaluationContext; + + HookedStringProvider(String name, List> hooks, ProviderEvaluation evaluation) { + this.name = name; + this.hooks = hooks; + this.evaluation = evaluation; + this.evaluationException = null; + } + + HookedStringProvider(String name, List> hooks, RuntimeException evaluationException) { + this.name = name; + this.hooks = hooks; + this.evaluation = null; + this.evaluationException = evaluationException; + } + + @Override + public Metadata getMetadata() { + return () -> name; + } + + @Override + @SuppressWarnings("rawtypes") + public List getProviderHooks() { + return List.copyOf(hooks); + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + lastEvaluationContext = ctx == null ? new MutableContext() : ctx; + if (evaluationException != null) { + throw evaluationException; + } + return evaluation; + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + } +}