Merge pull request #24 from lopitz/fix-classcastexceptions

Fixes ClassCastExceptions when hooks are called (address #23)
This commit is contained in:
Justin Abrahms 2022-06-21 14:02:28 -05:00 committed by GitHub
commit d24380ede3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 550 additions and 216 deletions

View File

@ -38,4 +38,4 @@ jobs:
flags: unittests # optional
name: pr coverage # optional
fail_ci_if_error: true # optional (default = false)
verbose: true # optional (default = false)
verbose: true # optional (default = false)

View File

@ -42,6 +42,12 @@ dependencies {
api 'org.apache.commons:commons-math3:3.6.1'
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(8))
}
}
tasks.named('test') {
// Use JUnit Platform for unit tests.
useJUnitPlatform()

View File

@ -0,0 +1,9 @@
package dev.openfeature.javasdk;
public interface BooleanHook extends Hook<Boolean> {
@Override
default boolean supportsFlagValueType(FlagValueType flagValueType) {
return FlagValueType.BOOLEAN == flagValueType;
}
}

View File

@ -11,11 +11,11 @@ import javax.annotation.Nullable;
*/
@Data @Builder
public class FlagEvaluationDetails<T> implements BaseEvaluation<T> {
String flagKey;
T value;
@Nullable String variant;
Reason reason;
@Nullable String errorCode;
private String flagKey;
private T value;
@Nullable private String variant;
private Reason reason;
@Nullable private String errorCode;
public static <T> FlagEvaluationDetails<T> from(ProviderEvaluation<T> providerEval, String flagKey) {
return FlagEvaluationDetails.<T>builder()

View File

@ -1,15 +1,14 @@
package dev.openfeature.javasdk;
import com.google.common.collect.ImmutableMap;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.*;
import java.util.List;
import lombok.*;
@Data @Builder
@Value
@Builder
public class FlagEvaluationOptions {
@Singular private List<Hook> hooks;
@Singular
List<Hook> hooks;
@Builder.Default
private ImmutableMap<String, Object> hookHints = ImmutableMap.of();
Map<String, Object> hookHints = new HashMap<>();
}

View File

@ -1,45 +1,55 @@
package dev.openfeature.javasdk;
import com.google.common.collect.ImmutableMap;
import java.util.Optional;
import java.util.*;
/**
* An extension point which can run around flag resolution. They are intended to be used as a way to add custom logic
* to the lifecycle of flag evaluation.
*
* @param <T> The type of the flag being evaluated.
*/
public interface Hook<T> {
/**
* Runs before flag is resolved.
* @param ctx Information about the particular flag evaluation
*
* @param ctx Information about the particular flag evaluation
* @param hints An immutable mapping of data for users to communicate to the hooks.
* @return An optional {@link EvaluationContext}. If returned, it will be merged with the EvaluationContext instances from other hooks, the client and API.
*/
default Optional<EvaluationContext> before(HookContext<T> ctx, ImmutableMap<String, Object> hints) {
default Optional<EvaluationContext> before(HookContext<T> ctx, Map<String, Object> hints) {
return Optional.empty();
}
/**
* Runs after a flag is resolved.
* @param ctx Information about the particular flag evaluation
*
* @param ctx Information about the particular flag evaluation
* @param details Information about how the flag was resolved, including any resolved values.
* @param hints An immutable mapping of data for users to communicate to the hooks.
* @param hints An immutable mapping of data for users to communicate to the hooks.
*/
default void after(HookContext<T> ctx, FlagEvaluationDetails<T> details, ImmutableMap<String, Object> hints) {}
default void after(HookContext<T> ctx, FlagEvaluationDetails<T> details, Map<String, Object> hints) {
}
/**
* Run when evaluation encounters an error. This will always run. Errors thrown will be swallowed.
* @param ctx Information about the particular flag evaluation
*
* @param ctx Information about the particular flag evaluation
* @param error The exception that was thrown.
* @param hints An immutable mapping of data for users to communicate to the hooks.
*/
default void error(HookContext<T> ctx, Exception error, ImmutableMap<String, Object> hints) {}
default void error(HookContext<T> ctx, Exception error, Map<String, Object> hints) {
}
/**
* Run after flag evaluation, including any error processing. This will always run. Errors will be swallowed.
* @param ctx Information about the particular flag evaluation
*
* @param ctx Information about the particular flag evaluation
* @param hints An immutable mapping of data for users to communicate to the hooks.
*/
default void finallyAfter(HookContext<T> ctx, ImmutableMap<String, Object> hints) {}
default void finallyAfter(HookContext<T> ctx, Map<String, Object> hints) {
}
default boolean supportsFlagValueType(FlagValueType flagValueType) {
return true;
}
}

View File

@ -0,0 +1,81 @@
package dev.openfeature.javasdk;
import java.util.*;
import java.util.function.*;
import java.util.stream.Stream;
import com.google.common.collect.Lists;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@SuppressWarnings({"unchecked", "rawtypes"})
class HookSupport {
public void errorHooks(FlagValueType flagValueType, HookContext hookCtx, Exception e, List<Hook> hooks, Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "error", hook -> hook.error(hookCtx, e, hints));
}
public void afterAllHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks, Map<String, Object> hints) {
executeHooks(flagValueType, hooks, "finally", hook -> hook.finallyAfter(hookCtx, hints));
}
public void afterHooks(FlagValueType flagValueType, HookContext hookContext, FlagEvaluationDetails details, List<Hook> hooks, Map<String, Object> hints) {
executeHooksUnchecked(flagValueType, hooks, hook -> hook.after(hookContext, details, hints));
}
private <T> void executeHooks(
FlagValueType flagValueType, List<Hook> hooks,
String hookMethod,
Consumer<Hook<T>> hookCode
) {
hooks
.stream()
.filter(hook -> hook.supportsFlagValueType(flagValueType))
.forEach(hook -> executeChecked(hook, hookCode, hookMethod));
}
private <T> void executeHooksUnchecked(
FlagValueType flagValueType, List<Hook> hooks,
Consumer<Hook<T>> hookCode
) {
hooks
.stream()
.filter(hook -> hook.supportsFlagValueType(flagValueType))
.forEach(hookCode::accept);
}
private <T> void executeChecked(Hook<T> hook, Consumer<Hook<T>> hookCode, String hookMethod) {
try {
hookCode.accept(hook);
} catch (Exception exception) {
log.error("Exception when running {} hooks {}", hookMethod, hook.getClass(), exception);
}
}
public EvaluationContext beforeHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks, Map<String, Object> hints) {
Stream<EvaluationContext> result = callBeforeHooks(flagValueType, hookCtx, hooks, hints);
return EvaluationContext.merge(hookCtx.getCtx(), collectContexts(result));
}
private Stream<EvaluationContext> callBeforeHooks(FlagValueType flagValueType, HookContext hookCtx, List<Hook> hooks, Map<String, Object> hints) {
// These traverse backwards from normal.
return Lists
.reverse(hooks)
.stream()
.filter(hook -> hook.supportsFlagValueType(flagValueType))
.map(hook -> hook.before(hookCtx, hints))
.filter(Objects::nonNull)
.filter(Optional::isPresent)
.map(Optional::get)
.map(EvaluationContext.class::cast);
}
//for whatever reason, an error `error: incompatible types: invalid method reference` is thrown on compilation with javac
//when the reduce call is appended directly as stream call chain above. moving it to its own method works however...
private EvaluationContext collectContexts(Stream<EvaluationContext> result) {
return result
.reduce(new EvaluationContext(), EvaluationContext::merge, EvaluationContext::merge);
}
}

View File

@ -0,0 +1,9 @@
package dev.openfeature.javasdk;
public interface IntegerHook extends Hook<Integer> {
@Override
default boolean supportsFlagValueType(FlagValueType flagValueType) {
return FlagValueType.INTEGER == flagValueType;
}
}

View File

@ -1,31 +1,28 @@
package dev.openfeature.javasdk;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import dev.openfeature.javasdk.exceptions.*;
import java.util.*;
import dev.openfeature.javasdk.exceptions.GeneralError;
import dev.openfeature.javasdk.internal.ObjectUtils;
import lombok.Getter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
@Slf4j
@SuppressWarnings({"PMD.DataflowAnomalyAnalysis", "PMD.BeanMembersShouldSerialize", "unchecked", "rawtypes"})
public class OpenFeatureClient implements Client {
private transient final OpenFeatureAPI openfeatureApi;
private final OpenFeatureAPI openfeatureApi;
@Getter private final String name;
@Getter private final String version;
@Getter private final List<Hook> clientHooks;
private static final Logger log = LoggerFactory.getLogger(OpenFeatureClient.class);
private final HookSupport hookSupport;
public OpenFeatureClient(OpenFeatureAPI openFeatureAPI, String name, String version) {
this.openfeatureApi = openFeatureAPI;
this.name = name;
this.version = version;
this.clientHooks = new ArrayList<>();
this.hookSupport = new HookSupport();
}
@Override
@ -33,9 +30,10 @@ public class OpenFeatureClient implements Client {
this.clientHooks.addAll(Arrays.asList(hooks));
}
<T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key, T defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) {
FeatureProvider provider = this.openfeatureApi.getProvider();
ImmutableMap<String, Object> hints = options.getHookHints();
private <T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key, T defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) {
FlagEvaluationOptions flagOptions = ObjectUtils.defaultIfNull(options, () -> FlagEvaluationOptions.builder().build());
FeatureProvider provider = openfeatureApi.getProvider();
Map<String, Object> hints = Collections.unmodifiableMap(flagOptions.getHookHints());
if (ctx == null) {
ctx = new EvaluationContext();
}
@ -43,95 +41,57 @@ public class OpenFeatureClient implements Client {
// merge of: API.context, client.context, invocation.context
// TODO: Context transformation?
HookContext hookCtx = HookContext.from(key, type, this.getMetadata(), OpenFeatureAPI.getInstance().getProvider().getMetadata(), ctx, defaultValue);
HookContext<T> hookCtx = HookContext.from(key, type, this.getMetadata(), openfeatureApi.getProvider().getMetadata(), ctx, defaultValue);
List<Hook> mergedHooks;
if (options != null && options.getHooks() != null) {
mergedHooks = ImmutableList.<Hook>builder()
.addAll(options.getHooks())
.addAll(clientHooks)
.addAll(openfeatureApi.getApiHooks())
.build();
} else {
mergedHooks = clientHooks;
}
List<Hook> mergedHooks = ObjectUtils.merge(flagOptions.getHooks(), clientHooks, openfeatureApi.getApiHooks());
FlagEvaluationDetails<T> details = null;
try {
EvaluationContext ctxFromHook = this.beforeHooks(hookCtx, mergedHooks, hints);
EvaluationContext ctxFromHook = hookSupport.beforeHooks(type, hookCtx, mergedHooks, hints);
EvaluationContext invocationContext = EvaluationContext.merge(ctxFromHook, ctx);
ProviderEvaluation<T> providerEval;
if (type == FlagValueType.BOOLEAN) {
// TODO: Can we guarantee that defaultValue is a boolean? If not, how to we handle that?
providerEval = (ProviderEvaluation<T>) provider.getBooleanEvaluation(key, (Boolean) defaultValue, invocationContext, options);
} else if(type == FlagValueType.STRING) {
providerEval = (ProviderEvaluation<T>) provider.getStringEvaluation(key, (String) defaultValue, invocationContext, options);
} else if (type == FlagValueType.INTEGER) {
providerEval = (ProviderEvaluation<T>) provider.getIntegerEvaluation(key, (Integer) defaultValue, invocationContext, options);
} else if (type == FlagValueType.OBJECT) {
providerEval = (ProviderEvaluation<T>) provider.getObjectEvaluation(key, defaultValue, invocationContext, options);
} else {
throw new GeneralError("Unknown flag type");
}
ProviderEvaluation<T> providerEval = (ProviderEvaluation<T>) createProviderEvaluation(type, key, defaultValue, options, provider, invocationContext);
details = FlagEvaluationDetails.from(providerEval, key);
this.afterHooks(hookCtx, details, mergedHooks, hints);
hookSupport.afterHooks(type, hookCtx, details, mergedHooks, hints);
} catch (Exception e) {
log.error("Unable to correctly evaluate flag with key {} due to exception {}", key, e.getMessage());
if (details == null) {
details = FlagEvaluationDetails.<T>builder().value(defaultValue).reason(Reason.ERROR).build();
details = FlagEvaluationDetails.<T>builder().build();
}
details.value = defaultValue;
details.reason = Reason.ERROR;
details.errorCode = e.getMessage();
this.errorHooks(hookCtx, e, mergedHooks, hints);
details.setValue(defaultValue);
details.setReason(Reason.ERROR);
details.setErrorCode(e.getMessage());
hookSupport.errorHooks(type, hookCtx, e, mergedHooks, hints);
} finally {
this.afterAllHooks(hookCtx, mergedHooks, hints);
hookSupport.afterAllHooks(type, hookCtx, mergedHooks, hints);
}
return details;
}
private void errorHooks(HookContext hookCtx, Exception e, List<Hook> hooks, ImmutableMap<String, Object> hints) {
for (Hook hook : hooks) {
try {
hook.error(hookCtx, e, hints);
} catch (Exception inner_exception) {
log.error("Exception when running error hooks " + hook.getClass().toString(), inner_exception);
}
private <T> ProviderEvaluation<?> createProviderEvaluation(
FlagValueType type,
String key,
T defaultValue,
FlagEvaluationOptions options,
FeatureProvider provider,
EvaluationContext invocationContext
) {
switch (type) {
case BOOLEAN:
return provider.getBooleanEvaluation(key, (Boolean) defaultValue, invocationContext, options);
case STRING:
return provider.getStringEvaluation(key, (String) defaultValue, invocationContext, options);
case INTEGER:
return provider.getIntegerEvaluation(key, (Integer) defaultValue, invocationContext, options);
case OBJECT:
return provider.getObjectEvaluation(key, defaultValue, invocationContext, options);
default:
throw new GeneralError("Unknown flag type");
}
}
private void afterAllHooks(HookContext hookCtx, List<Hook> hooks, ImmutableMap<String, Object> hints) {
for (Hook hook : hooks) {
try {
hook.finallyAfter(hookCtx, hints);
} catch (Exception inner_exception) {
log.error("Exception when running finally hooks " + hook.getClass().toString(), inner_exception);
}
}
}
private <T> void afterHooks(HookContext hookContext, FlagEvaluationDetails<T> details, List<Hook> hooks, ImmutableMap<String, Object> hints) {
for (Hook hook : hooks) {
hook.after(hookContext, details, hints);
}
}
private EvaluationContext beforeHooks(HookContext hookCtx, List<Hook> hooks, ImmutableMap<String, Object> hints) {
// These traverse backwards from normal.
EvaluationContext ctx = hookCtx.getCtx();
for (Hook hook : Lists.reverse(hooks)) {
Optional<EvaluationContext> newCtx = hook.before(hookCtx, hints);
if (newCtx != null && newCtx.isPresent()) {
ctx = EvaluationContext.merge(ctx, newCtx.get());
hookCtx = hookCtx.withCtx(ctx);
}
}
return ctx;
}
@Override
public Boolean getBooleanValue(String key, Boolean defaultValue) {
return getBooleanDetails(key, defaultValue).getValue();
@ -179,7 +139,7 @@ public class OpenFeatureClient implements Client {
@Override
public FlagEvaluationDetails<String> getStringDetails(String key, String defaultValue) {
return getStringDetails(key, defaultValue, null);
return getStringDetails(key, defaultValue, null);
}
@Override
@ -254,11 +214,6 @@ public class OpenFeatureClient implements Client {
@Override
public Metadata getMetadata() {
return new Metadata() {
@Override
public String getName() {
return name;
}
};
return () -> name;
}
}

View File

@ -0,0 +1,9 @@
package dev.openfeature.javasdk;
public interface StringHook extends Hook<String> {
@Override
default boolean supportsFlagValueType(FlagValueType flagValueType) {
return FlagValueType.STRING == flagValueType;
}
}

View File

@ -0,0 +1,41 @@
package dev.openfeature.javasdk.internal;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ObjectUtils {
public static <T> List<T> defaultIfNull(List<T> source, Supplier<List<T>> defaultValue) {
if (source == null) {
return defaultValue.get();
}
return source;
}
public static <K, V> Map<K, V> defaultIfNull(Map<K, V> source, Supplier<Map<K, V>> defaultValue) {
if (source == null) {
return defaultValue.get();
}
return source;
}
public static <T> T defaultIfNull(T source, Supplier<T> defaultValue) {
if (source == null) {
return defaultValue.get();
}
return source;
}
@SafeVarargs
public static <T> List<T> merge(List<T>... sources) {
return Arrays
.stream(sources)
.flatMap(Collection::stream)
.collect(Collectors.toList());
}
}

View File

@ -1,17 +1,17 @@
package dev.openfeature.javasdk;
import dev.openfeature.javasdk.fixtures.HookFixtures;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.*;
@SuppressWarnings("unchecked")
class DeveloperExperienceTest {
class DeveloperExperienceTest implements HookFixtures {
transient String flagKey = "mykey";
@Test void simpleBooleanFlag() {
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
@ -22,7 +22,7 @@ class DeveloperExperienceTest {
}
@Test void clientHooks() {
Hook<Boolean> exampleHook = (Hook<Boolean>) Mockito.mock(Hook.class);
Hook<Boolean> exampleHook = mockBooleanHook();
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new NoOpProvider());
@ -34,8 +34,8 @@ class DeveloperExperienceTest {
}
@Test void evalHooks() {
Hook<Boolean> clientHook = (Hook<Boolean>) Mockito.mock(Hook.class);
Hook<Boolean> evalHook = (Hook<Boolean>) Mockito.mock(Hook.class);
Hook<Boolean> clientHook = mockBooleanHook();
Hook<Boolean> evalHook = mockBooleanHook();
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new NoOpProvider());

View File

@ -4,12 +4,7 @@ public class DoSomethingProvider implements FeatureProvider {
@Override
public Metadata getMetadata() {
return new Metadata() {
@Override
public String getName() {
return "test";
}
};
return () -> "test";
}
@Override

View File

@ -1,7 +1,7 @@
package dev.openfeature.javasdk;
import dev.openfeature.javasdk.fixtures.HookFixtures;
import org.junit.jupiter.api.*;
import org.slf4j.*;
import uk.org.lidalia.slf4jtest.*;
import java.util.List;
@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FlagEvaluationSpecTests {
class FlagEvaluationSpecTests implements HookFixtures {
private static final TestLogger TEST_LOGGER = TestLoggerFactory.getTestLogger(OpenFeatureClient.class);
@ -144,8 +144,8 @@ class FlagEvaluationSpecTests {
@Specification(number="1.5.1", text="The evaluation options structure's hooks field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")
@Test void hooks() {
Client c = _client();
Hook clientHook = mock(Hook.class);
Hook invocationHook = mock(Hook.class);
Hook<Boolean> clientHook = mockBooleanHook();
Hook<Boolean> invocationHook = mockBooleanHook();
c.addHooks(clientHook);
c.getBooleanValue("key", false, null, FlagEvaluationOptions.builder()
.hook(invocationHook)

View File

@ -1,22 +1,20 @@
package dev.openfeature.javasdk;
import java.util.*;
import com.google.common.collect.ImmutableMap;
import dev.openfeature.javasdk.fixtures.HookFixtures;
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.List;
import java.util.Optional;
import org.junit.jupiter.api.*;
import org.mockito.*;
import static org.assertj.core.api.Assertions.fail;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
public class HookSpecTests {
public class HookSpecTests implements HookFixtures {
@AfterEach
void emptyApiHooks() {
// it's a singleton. Don't pollute each test.
@ -145,7 +143,7 @@ public class HookSpecTests {
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new AlwaysBrokenProvider());
Client client = api.getClient();
Hook<Boolean> evalHook = (Hook<Boolean>) mock(Hook.class);
Hook<Boolean> evalHook = mockBooleanHook();
client.getBooleanValue("key", false, new EvaluationContext(),
FlagEvaluationOptions.builder().hook(evalHook).build());
@ -161,7 +159,7 @@ public class HookSpecTests {
@Test void error_hook_run_during_non_finally_stage() {
final boolean[] error_called = {false};
Hook h = mock(Hook.class);
Hook h = mockBooleanHook();
doThrow(RuntimeException.class).when(h).finallyAfter(any(), any());
verify(h, times(0)).error(any(), any(), any());
@ -174,96 +172,96 @@ public class HookSpecTests {
@Specification(number="4.3.6", text="The error hook MUST run when errors are encountered in the before stage, the after stage or during flag resolution. It accepts hook context (required), exception representing what went wrong (required), and hook hints (optional). It has no return value.")
@Specification(number="4.3.7", text="The finally hook MUST run after the before, after, and error stages. It accepts a hook context (required) and hook hints (optional). There is no return value.")
@Test void hook_eval_order() {
List<String> evalOrder = new ArrayList<String>();
List<String> evalOrder = new ArrayList<>();
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new NoOpProvider());
api.addHooks(new Hook<Boolean>() {
api.addHooks(new BooleanHook() {
@Override
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("api before");
return null;
}
@Override
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, ImmutableMap<String, Object> hints) {
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, Map<String, Object> hints) {
evalOrder.add("api after");
throw new RuntimeException(); // trigger error flows.
}
@Override
public void error(HookContext<Boolean> ctx, Exception error, ImmutableMap<String, Object> hints) {
public void error(HookContext<Boolean> ctx, Exception error, Map<String, Object> hints) {
evalOrder.add("api error");
}
@Override
public void finallyAfter(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
public void finallyAfter(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("api finally");
}
});
Client c = api.getClient();
c.addHooks(new Hook<Boolean>() {
c.addHooks(new BooleanHook() {
@Override
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("client before");
return null;
}
@Override
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, ImmutableMap<String, Object> hints) {
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, Map<String, Object> hints) {
evalOrder.add("client after");
}
@Override
public void error(HookContext<Boolean> ctx, Exception error, ImmutableMap<String, Object> hints) {
public void error(HookContext<Boolean> ctx, Exception error, Map<String, Object> hints) {
evalOrder.add("client error");
}
@Override
public void finallyAfter(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
public void finallyAfter(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("client finally");
}
});
c.getBooleanValue("key", false, null, FlagEvaluationOptions.builder()
.hook(new Hook<Boolean>() {
@Override
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
evalOrder.add("invocation before");
return null;
}
c.getBooleanValue("key", false, null, FlagEvaluationOptions
.builder()
.hook(new BooleanHook() {
@Override
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("invocation before");
return null;
}
@Override
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, ImmutableMap<String, Object> hints) {
evalOrder.add("invocation after");
}
@Override
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, Map<String, Object> hints) {
evalOrder.add("invocation after");
}
@Override
public void error(HookContext<Boolean> ctx, Exception error, ImmutableMap<String, Object> hints) {
evalOrder.add("invocation error");
}
@Override
public void error(HookContext<Boolean> ctx, Exception error, Map<String, Object> hints) {
evalOrder.add("invocation error");
}
@Override
public void finallyAfter(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
evalOrder.add("invocation finally");
}
})
.build());
@Override
public void finallyAfter(HookContext<Boolean> ctx, Map<String, Object> hints) {
evalOrder.add("invocation finally");
}
})
.build());
ArrayList<String> expectedOrder = new ArrayList<String>();
expectedOrder.addAll(Arrays.asList(
"api before", "client before", "invocation before",
"invocation after", "client after", "api after",
"invocation error", "client error", "api error",
"invocation finally", "client finally", "api finally"));
List<String> expectedOrder = Arrays.asList(
"api before", "client before", "invocation before",
"invocation after", "client after", "api after",
"invocation error", "client error", "api error",
"invocation finally", "client finally", "api finally");
assertEquals(expectedOrder, evalOrder);
}
@Specification(number="4.4.6", text="If an error occurs during the evaluation of before or after hooks, any remaining hooks in the before or after stages MUST NOT be invoked.")
@Test void error_stops_before() {
Hook<Boolean> h = mock(Hook.class);
Hook<Boolean> h = mockBooleanHook();
doThrow(RuntimeException.class).when(h).before(any(), any());
Hook<Boolean> h2 = mock(Hook.class);
Hook<Boolean> h2 = mockBooleanHook();
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new AlwaysBrokenProvider());
@ -279,9 +277,9 @@ public class HookSpecTests {
@Specification(number="4.4.6", text="If an error occurs during the evaluation of before or after hooks, any remaining hooks in the before or after stages MUST NOT be invoked.")
@Test void error_stops_after() {
Hook<Boolean> h = mock(Hook.class);
Hook<Boolean> h = mockBooleanHook();
doThrow(RuntimeException.class).when(h).after(any(), any(), any());
Hook<Boolean> h2 = mock(Hook.class);
Hook<Boolean> h2 = mockBooleanHook();
Client c = getClient(null);
@ -298,31 +296,32 @@ public class HookSpecTests {
@Specification(number="4.2.2.1", text="Condition: Hook hints MUST be immutable.")
@Specification(number="4.5.3", text="The hook MUST NOT alter the hook hints structure.")
@Test void hook_hints() {
String hintKey = "My hint key";
Client client = getClient(null);
Hook<Boolean> mutatingHook = new Hook<Boolean>() {
Hook<Boolean> mutatingHook = new BooleanHook() {
@Override
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
assertTrue(hints instanceof ImmutableMap);
return null;
public Optional<EvaluationContext> before(HookContext<Boolean> ctx, Map<String, Object> hints) {
assertThatCode(() -> hints.put(hintKey, "changed value")).isInstanceOf(UnsupportedOperationException.class);
return Optional.empty();
}
@Override
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, ImmutableMap<String, Object> hints) {
assertTrue(hints instanceof ImmutableMap);
public void after(HookContext<Boolean> ctx, FlagEvaluationDetails<Boolean> details, Map<String, Object> hints) {
assertThatCode(() -> hints.put(hintKey, "changed value")).isInstanceOf(UnsupportedOperationException.class);
}
@Override
public void error(HookContext<Boolean> ctx, Exception error, ImmutableMap<String, Object> hints) {
assertTrue(hints instanceof ImmutableMap);
public void error(HookContext<Boolean> ctx, Exception error, Map<String, Object> hints) {
assertThatCode(() -> hints.put(hintKey, "changed value")).isInstanceOf(UnsupportedOperationException.class);
}
@Override
public void finallyAfter(HookContext<Boolean> ctx, ImmutableMap<String, Object> hints) {
assertTrue(hints instanceof ImmutableMap);
public void finallyAfter(HookContext<Boolean> ctx, Map<String, Object> hints) {
assertThatCode(() -> hints.put(hintKey, "changed value")).isInstanceOf(UnsupportedOperationException.class);
}
};
ImmutableMap<String, Object> hh = ImmutableMap.of("My hint key", "My hint value");
Map<String, Object> hh = new HashMap<>(ImmutableMap.of(hintKey, "My hint value"));
client.getBooleanValue("key", false, new EvaluationContext(), FlagEvaluationOptions.builder()
.hook(mutatingHook)
@ -338,7 +337,7 @@ public class HookSpecTests {
}
@Test void flag_eval_hook_order() {
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
FeatureProvider provider = mock(FeatureProvider.class);
when(provider.getBooleanEvaluation(any(), any(), any(), any()))
.thenReturn(ProviderEvaluation.<Boolean>builder()
@ -360,7 +359,7 @@ public class HookSpecTests {
@Specification(number="4.4.5", text="If an error occurs in the before or after hooks, the error hooks MUST be invoked.")
@Test void error_hooks__before() {
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
doThrow(RuntimeException.class).when(hook).before(any(), any());
Client client = getClient(null);
client.getBooleanValue("key", false, new EvaluationContext(),
@ -371,7 +370,7 @@ public class HookSpecTests {
@Specification(number="4.4.5", text="If an error occurs in the before or after hooks, the error hooks MUST be invoked.")
@Test void error_hooks__after() {
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
doThrow(RuntimeException.class).when(hook).after(any(), any(), any());
Client client = getClient(null);
client.getBooleanValue("key", false, new EvaluationContext(),
@ -381,8 +380,8 @@ public class HookSpecTests {
}
@Test void multi_hooks_early_out__before() {
Hook hook = mock(Hook.class);
Hook hook2 = mock(Hook.class);
Hook<Boolean> hook = mockBooleanHook();
Hook<Boolean> hook2 = mockBooleanHook();
doThrow(RuntimeException.class).when(hook).before(any(), any());
Client client = getClient(null);
@ -400,13 +399,13 @@ public class HookSpecTests {
verify(hook2, times(1)).error(any(), any(), any());
}
@Specification(number="4.1.4", text="The evaluation context MUST be mutable only within the before hook.")
@Specification(number="4.3.3", text="Any evaluation context returned from a before hook MUST be passed to subsequent before hooks (via HookContext).")
@Specification(number = "4.1.4", text = "The evaluation context MUST be mutable only within the before hook.")
@Specification(number = "4.3.3", text = "Any evaluation context returned from a before hook MUST be passed to subsequent before hooks (via HookContext).")
@Test void beforeContextUpdated() {
EvaluationContext ctx = new EvaluationContext();
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
when(hook.before(any(), any())).thenReturn(Optional.of(ctx));
Hook hook2 = mock(Hook.class);
Hook hook2 = mockBooleanHook();
when(hook.before(any(), any())).thenReturn(Optional.empty());
InOrder order = inOrder(hook, hook2);
@ -436,7 +435,7 @@ public class HookSpecTests {
EvaluationContext invocationCtx = new EvaluationContext();
invocationCtx.addStringAttribute("test", "works");
Hook hook = mock(Hook.class);
Hook<Boolean> hook = mockBooleanHook();
when(hook.before(any(), any())).thenReturn(Optional.of(hookCtx));
FeatureProvider provider = mock(FeatureProvider.class);
@ -461,10 +460,10 @@ public class HookSpecTests {
@Specification(number="4.4.3", text="If a finally hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining finally hooks.")
@Test void first_finally_broken() {
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
doThrow(RuntimeException.class).when(hook).before(any(), any());
doThrow(RuntimeException.class).when(hook).finallyAfter(any(), any());
Hook hook2 = mock(Hook.class);
Hook hook2 = mockBooleanHook();
InOrder order = inOrder(hook, hook2);
Client client = getClient(null);
@ -481,11 +480,10 @@ public class HookSpecTests {
@Specification(number="4.4.4", text="If an error hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining error hooks.")
@Test void first_error_broken() {
Hook hook = mock(Hook.class);
Hook hook = mockBooleanHook();
doThrow(RuntimeException.class).when(hook).before(any(), any());
doThrow(RuntimeException.class).when(hook).error(any(), any(), any());
Hook hook2 = mock(Hook.class);
Hook hook2 = mockBooleanHook();
InOrder order = inOrder(hook, hook2);
Client client = getClient(null);
@ -518,12 +516,13 @@ public class HookSpecTests {
@SneakyThrows
@Test void doesnt_use_finally() {
try {
Hook.class.getMethod("finally", HookContext.class, ImmutableMap.class);
Hook.class.getMethod("finally", HookContext.class, Map.class);
fail("Not possible. Finally is a reserved word.");
} catch (NoSuchMethodException e) {
// expected
}
Hook.class.getMethod("finallyAfter", HookContext.class, ImmutableMap.class);
Hook.class.getMethod("finallyAfter", HookContext.class, Map.class);
}
}

View File

@ -0,0 +1,76 @@
package dev.openfeature.javasdk;
import java.util.*;
import dev.openfeature.javasdk.fixtures.HookFixtures;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class HookSupportTest implements HookFixtures {
@Test
@DisplayName("should merge EvaluationContexts on before hooks correctly")
void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() {
EvaluationContext baseContext = new EvaluationContext();
baseContext.addStringAttribute("baseKey", "baseValue");
HookContext<String> hookContext = new HookContext<>("flagKey", FlagValueType.STRING, "defaultValue", baseContext, () -> "client", () -> "provider");
Hook<String> hook1 = mockStringHook();
Hook<String> hook2 = mockStringHook();
when(hook1.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("bla", "blubber")));
when(hook2.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("foo", "bar")));
HookSupport hookSupport = new HookSupport();
EvaluationContext result = hookSupport.beforeHooks(FlagValueType.STRING, hookContext, Arrays.asList(hook1, hook2), Collections.emptyMap());
assertThat(result.getStringAttribute("bla")).isEqualTo("blubber");
assertThat(result.getStringAttribute("foo")).isEqualTo("bar");
assertThat(result.getStringAttribute("baseKey")).isEqualTo("baseValue");
}
@ParameterizedTest
@EnumSource(value = FlagValueType.class)
@DisplayName("should always call generic hook")
void shouldAlwaysCallGenericHook(FlagValueType flagValueType) {
Hook<?> genericHook = mockGenericHook();
HookSupport hookSupport = new HookSupport();
EvaluationContext baseContext = new EvaluationContext();
IllegalStateException expectedException = new IllegalStateException("All fine, just a test");
HookContext<Object> hookContext = new HookContext<>("flagKey", flagValueType, createDefaultValue(flagValueType), baseContext, () -> "client", () -> "provider");
hookSupport.beforeHooks(flagValueType, hookContext, Collections.singletonList(genericHook), Collections.emptyMap());
hookSupport.afterHooks(flagValueType, hookContext, FlagEvaluationDetails.builder().build(), Collections.singletonList(genericHook), Collections.emptyMap());
hookSupport.afterAllHooks(flagValueType, hookContext, Collections.singletonList(genericHook), Collections.emptyMap());
hookSupport.errorHooks(flagValueType, hookContext, expectedException, Collections.singletonList(genericHook), Collections.emptyMap());
verify(genericHook).before(any(), any());
verify(genericHook).after(any(), any(), any());
verify(genericHook).finallyAfter(any(), any());
verify(genericHook).error(any(), any(), any());
}
private Object createDefaultValue(FlagValueType flagValueType) {
switch (flagValueType) {
case INTEGER:
return 1;
case BOOLEAN:
return true;
case STRING:
return "defaultValue";
case OBJECT:
return "object";
default:
throw new IllegalArgumentException();
}
}
private EvaluationContext evaluationContextWithValue(String key, String value) {
EvaluationContext result = new EvaluationContext();
result.addStringAttribute(key, value);
return result;
}
}

View File

@ -0,0 +1,32 @@
package dev.openfeature.javasdk;
import java.util.*;
import dev.openfeature.javasdk.fixtures.HookFixtures;
import org.junit.jupiter.api.*;
import uk.org.lidalia.slf4jext.Level;
import uk.org.lidalia.slf4jtest.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class OpenFeatureClientTest implements HookFixtures {
private static final TestLogger TEST_LOGGER = TestLoggerFactory.getTestLogger(OpenFeatureClient.class);
@Test
@DisplayName("should not throw exception if hook has different type argument than hookContext")
void shouldNotThrowExceptionIfHookHasDifferentTypeArgumentThanHookContext() {
TEST_LOGGER.clear();
OpenFeatureAPI api = mock(OpenFeatureAPI.class);
when(api.getProvider()).thenReturn(new DoSomethingProvider());
when(api.getApiHooks()).thenReturn(Arrays.asList(mockBooleanHook(), mockStringHook()));
OpenFeatureClient client = new OpenFeatureClient(api, "name", "version");
FlagEvaluationDetails<Boolean> actual = client.getBooleanDetails("feature key", Boolean.FALSE);
assertThat(actual.getValue()).isTrue();
assertThat(TEST_LOGGER.getLoggingEvents()).filteredOn(event -> event.getLevel().equals(Level.ERROR)).isEmpty();
}
}

View File

@ -0,0 +1,25 @@
package dev.openfeature.javasdk.fixtures;
import dev.openfeature.javasdk.*;
import static org.mockito.Mockito.spy;
public interface HookFixtures {
default Hook<Boolean> mockBooleanHook() {
return spy(BooleanHook.class);
}
default Hook<String> mockStringHook() {
return spy(StringHook.class);
}
default Hook<Integer> mockIntegerHook() {
return spy(IntegerHook.class);
}
default Hook<?> mockGenericHook() {
return spy(Hook.class);
}
}

View File

@ -0,0 +1,88 @@
package dev.openfeature.javasdk.internal;
import java.util.*;
import com.google.common.collect.ImmutableMap;
import org.junit.jupiter.api.*;
import static dev.openfeature.javasdk.internal.ObjectUtils.defaultIfNull;
import static org.assertj.core.api.Assertions.assertThat;
class ObjectUtilsTest {
@Nested
class GenericObject {
@Test
@DisplayName("should return default value if null")
void shouldReturnDefaultValueIfNull() {
String defaultValue = "default";
String actual = defaultIfNull(null, () -> defaultValue);
assertThat(actual).isEqualTo(defaultValue);
}
@Test
@DisplayName("should return given value if not null")
void shouldReturnGivenValueIfNotNull() {
String defaultValue = "default";
String expectedValue = "expected";
String actual = defaultIfNull(expectedValue, () -> defaultValue);
assertThat(actual).isEqualTo(expectedValue);
}
}
@Nested
class ListSupport {
@Test
@DisplayName("should return default list if given one is null")
void shouldReturnDefaultListIfGivenOneIsNull() {
List<String> defaultValue = Collections.singletonList("default");
List<String> actual = defaultIfNull(null, () -> defaultValue);
assertThat(actual).isEqualTo(defaultValue);
}
@Test
@DisplayName("should return given list if not null")
void shouldReturnGivenListIfNotNull() {
List<String> defaultValue = Collections.singletonList("default");
List<String> expectedValue = Collections.singletonList("expected");
List<String> actual = defaultIfNull(expectedValue, () -> defaultValue);
assertThat(actual).isEqualTo(expectedValue);
}
}
@Nested
class MapSupport {
@Test
@DisplayName("should return default map if given one is null")
void shouldReturnDefaultMapIfGivenOneIsNull() {
Map<String, Object> defaultValue = ImmutableMap.of("key", "default");
Map<String, Object> actual = defaultIfNull(null, () -> defaultValue);
assertThat(actual).isEqualTo(defaultValue);
}
@Test
@DisplayName("should return given map if not null")
void shouldReturnGivenMapIfNotNull() {
Map<String, String> defaultValue = ImmutableMap.of("key", "default");
Map<String, String> expectedValue = ImmutableMap.of("key", "expected");
Map<String, String> actual = defaultIfNull(expectedValue, () -> defaultValue);
assertThat(actual).isEqualTo(expectedValue);
}
}
}