Support for eval context & merging relevant thereof

This commit is contained in:
Justin Abrahms 2022-05-25 17:57:32 -07:00
parent 23e2cd0a6b
commit aaa924f94c
No known key found for this signature in database
GPG Key ID: 599E2E12011DC474
7 changed files with 114 additions and 190 deletions

View File

@ -1,33 +1,21 @@
package dev.openfeature.javasdk; package dev.openfeature.javasdk;
import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ToString @EqualsAndHashCode
public class EvaluationContext { public class EvaluationContext {
@Getter private final String targetingKey; @Setter @Getter private String targetingKey;
private final Map<String, Integer> integerAttributes; private final Map<String, Integer> integerAttributes;
private final Map<String, String> stringAttributes; private final Map<String, String> stringAttributes;
private enum KNOWN_KEYS {
EMAIL,
FIRST_NAME,
LAST_NAME,
NAME,
IP,
TZ,
LOCALE,
COUNTRY_CODE,
ENVIRONMENT,
APPLICATION,
VERSION,
TIMESTAMP,
}
EvaluationContext() { EvaluationContext() {
this.targetingKey = ""; this.targetingKey = "";
this.integerAttributes = new HashMap<>(); this.integerAttributes = new HashMap<>();
@ -62,6 +50,8 @@ public class EvaluationContext {
this.stringAttributes.put(key, value.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)); this.stringAttributes.put(key, value.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
} }
// TODO: addStructure or similar.
public ZonedDateTime getDatetimeAttribute(String key) { public ZonedDateTime getDatetimeAttribute(String key) {
String attr = this.stringAttributes.get(key); String attr = this.stringAttributes.get(key);
if (attr == null) { if (attr == null) {
@ -70,107 +60,34 @@ public class EvaluationContext {
return ZonedDateTime.parse(attr, DateTimeFormatter.ISO_ZONED_DATE_TIME); return ZonedDateTime.parse(attr, DateTimeFormatter.ISO_ZONED_DATE_TIME);
} }
public String getEmail() {
return this.stringAttributes.get(KNOWN_KEYS.EMAIL.toString());
}
public String getFirstName() {
return this.stringAttributes.get(KNOWN_KEYS.FIRST_NAME.toString());
}
public String getLastName() {
return this.stringAttributes.get(KNOWN_KEYS.LAST_NAME.toString());
}
public String getName() {
return this.stringAttributes.get(KNOWN_KEYS.NAME.toString());
}
public String getIp() {
return this.stringAttributes.get(KNOWN_KEYS.IP.toString());
}
public String getTz() {
return this.stringAttributes.get(KNOWN_KEYS.TZ.toString());
}
public String getLocale() {
return this.stringAttributes.get(KNOWN_KEYS.LOCALE.toString());
}
public String getCountryCode() {
return this.stringAttributes.get(KNOWN_KEYS.COUNTRY_CODE.toString());
}
public String getEnvironment() {
return this.stringAttributes.get(KNOWN_KEYS.ENVIRONMENT.toString());
}
public String getApplication() {
return this.stringAttributes.get(KNOWN_KEYS.APPLICATION.toString());
}
public String getVersion() {
return this.stringAttributes.get(KNOWN_KEYS.VERSION.toString());
}
public ZonedDateTime getTimestamp() {
return getDatetimeAttribute(KNOWN_KEYS.TIMESTAMP.toString());
}
public void setEmail(String email) {
this.stringAttributes.put(KNOWN_KEYS.EMAIL.toString(), email);
}
public void setFirstName(String firstname) {
this.stringAttributes.put(KNOWN_KEYS.FIRST_NAME.toString(), firstname);
}
public void setLastName(String lastname) {
this.stringAttributes.put(KNOWN_KEYS.LAST_NAME.toString(), lastname);
}
public void setName(String name) {
this.stringAttributes.put(KNOWN_KEYS.NAME.toString(), name);
}
public void setIp(String ip) {
this.stringAttributes.put(KNOWN_KEYS.IP.toString(), ip);
}
public void setTz(String tz) {
this.stringAttributes.put(KNOWN_KEYS.TZ.toString(), tz);
}
public void setLocale(String locale) {
this.stringAttributes.put(KNOWN_KEYS.LOCALE.toString(), locale);
}
public void setCountryCode(String countryCode) {
this.stringAttributes.put(KNOWN_KEYS.COUNTRY_CODE.toString(), countryCode);
}
public void setEnvironment(String environment) {
this.stringAttributes.put(KNOWN_KEYS.ENVIRONMENT.toString(), environment);
}
public void setApplication(String application) {
this.stringAttributes.put(KNOWN_KEYS.APPLICATION.toString(), application);
}
public void setVersion(String version) {
this.stringAttributes.put(KNOWN_KEYS.VERSION.toString(), version);
}
public void setTimestamp(ZonedDateTime timestamp) {
addDatetimeAttribute(KNOWN_KEYS.TIMESTAMP.toString(), timestamp);
}
/** /**
* Merges two EvaluationContext objects with the second overriding the first in case of conflict. * Merges two EvaluationContext objects with the second overriding the first in case of conflict.
*/ */
public static EvaluationContext merge(EvaluationContext ctx1, EvaluationContext ctx2) { public static EvaluationContext merge(EvaluationContext ctx1, EvaluationContext ctx2) {
// TODO(abrahms): Actually implement this when we know what the fields of EC are. EvaluationContext ec = new EvaluationContext();
return ctx1; for (Map.Entry<String, Integer> e : ctx1.integerAttributes.entrySet()) {
ec.addIntegerAttribute(e.getKey(), e.getValue());
}
for (Map.Entry<String, Integer> e : ctx2.integerAttributes.entrySet()) {
ec.addIntegerAttribute(e.getKey(), e.getValue());
}
for (Map.Entry<String, String> e : ctx1.stringAttributes.entrySet()) {
ec.addStringAttribute(e.getKey(), e.getValue());
}
for (Map.Entry<String, String> e : ctx2.stringAttributes.entrySet()) {
ec.addStringAttribute(e.getKey(), e.getValue());
}
if (ctx1.getTargetingKey() != null) {
ec.setTargetingKey(ctx1.getTargetingKey());
}
if (ctx2.getTargetingKey() != null) {
ec.setTargetingKey(ctx2.getTargetingKey());
}
return ec;
} }
} }

View File

@ -7,7 +7,7 @@ import java.util.Optional;
// TODO: interface? or abstract class? // TODO: interface? or abstract class?
public abstract class Hook<T> { public abstract class Hook<T> {
public Optional<EvaluationContext> before(HookContext<T> ctx, ImmutableMap<String, Object> hints) { public Optional<EvaluationContext> before(HookContext<T> ctx, ImmutableMap<String, Object> hints) {
return null; return Optional.empty();
} }
public void after(HookContext<T> ctx, FlagEvaluationDetails<T> details, ImmutableMap<String, Object> hints) {} public void after(HookContext<T> ctx, FlagEvaluationDetails<T> details, ImmutableMap<String, Object> hints) {}
public void error(HookContext<T> ctx, Exception error, ImmutableMap<String, Object> hints) {} public void error(HookContext<T> ctx, Exception error, ImmutableMap<String, Object> hints) {}

View File

@ -4,14 +4,13 @@ import lombok.Builder;
import lombok.NonNull; import lombok.NonNull;
import lombok.Value; import lombok.Value;
import lombok.With; import lombok.With;
import javax.annotation.Nullable;
@Value @Builder @With @Value @Builder @With
public class HookContext<T> { public class HookContext<T> {
@NonNull String flagKey; @NonNull String flagKey;
@NonNull FlagValueType type; @NonNull FlagValueType type;
@NonNull T defaultValue; @NonNull T defaultValue;
@Nullable EvaluationContext ctx; @NonNull EvaluationContext ctx;
Client client; Client client;
FeatureProvider provider; FeatureProvider provider;

View File

@ -36,11 +36,14 @@ public class OpenFeatureClient implements Client {
<T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key, T defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) { <T> FlagEvaluationDetails<T> evaluateFlag(FlagValueType type, String key, T defaultValue, EvaluationContext ctx, FlagEvaluationOptions options) {
FeatureProvider provider = this.openfeatureApi.getProvider(); FeatureProvider provider = this.openfeatureApi.getProvider();
ImmutableMap<String, Object> hints = options.getHookHints(); ImmutableMap<String, Object> hints = options.getHookHints();
if (ctx == null) {
ctx = new EvaluationContext();
}
// merge of: API.context, client.context, invocation.context // merge of: API.context, client.context, invocation.context
// TODO: Context transformation? // TODO: Context transformation?
HookContext hookCtx = HookContext.from(key, type, this, null, defaultValue); HookContext hookCtx = HookContext.from(key, type, this, ctx, defaultValue);
List<Hook> mergedHooks; List<Hook> mergedHooks;
if (options != null && options.getHooks() != null) { if (options != null && options.getHooks() != null) {
@ -112,7 +115,7 @@ public class OpenFeatureClient implements Client {
EvaluationContext ctx = hookCtx.getCtx(); EvaluationContext ctx = hookCtx.getCtx();
for (Hook hook : Lists.reverse(hooks)) { for (Hook hook : Lists.reverse(hooks)) {
Optional<EvaluationContext> newCtx = hook.before(hookCtx, hints); Optional<EvaluationContext> newCtx = hook.before(hookCtx, hints);
if (newCtx.isPresent()) { if (newCtx != null && newCtx.isPresent()) {
ctx = EvaluationContext.merge(ctx, newCtx.get()); ctx = EvaluationContext.merge(ctx, newCtx.get());
hookCtx = hookCtx.withCtx(ctx); hookCtx = hookCtx.withCtx(ctx);
} }
@ -137,7 +140,7 @@ public class OpenFeatureClient implements Client {
@Override @Override
public FlagEvaluationDetails<Boolean> getBooleanDetails(String key, Boolean defaultValue) { public FlagEvaluationDetails<Boolean> getBooleanDetails(String key, Boolean defaultValue) {
return getBooleanDetails(key, defaultValue, null); return getBooleanDetails(key, defaultValue, new EvaluationContext());
} }
@Override @Override
@ -197,7 +200,7 @@ public class OpenFeatureClient implements Client {
@Override @Override
public FlagEvaluationDetails<Integer> getIntegerDetails(String key, Integer defaultValue) { public FlagEvaluationDetails<Integer> getIntegerDetails(String key, Integer defaultValue) {
return getIntegerDetails(key, defaultValue, null); return getIntegerDetails(key, defaultValue, new EvaluationContext());
} }
@Override @Override

View File

@ -6,21 +6,20 @@ import org.junit.jupiter.api.Test;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
public class EvalContextTests { public class EvalContextTests {
@Specification(spec="flag evaluation", number="3.1", @Specification(spec="Evaluation Context", number="3.1",
text="The `evaluation context` structure MUST define a required `targeting key` " + text="The `evaluation context` structure **MUST** define an optional `targeting key` field of " +
"field of type string, identifying the subject of the flag evaluation.") "type string, identifying the subject of the flag evaluation.")
@Disabled("https://github.com/open-feature/spec/pull/60/files#r872827439")
@Test void requires_targeting_key() { @Test void requires_targeting_key() {
EvaluationContext ec = new EvaluationContext(); EvaluationContext ec = new EvaluationContext();
ec.setTargetingKey("targeting-key");
assertEquals("targeting-key", ec.getTargetingKey()); assertEquals("targeting-key", ec.getTargetingKey());
} }
@Specification(spec="flag evaluation", number="3.3", text="The evaluation context MUST support the inclusion " + @Specification(spec="Evaluation Context", number="3.2", text="The evaluation context MUST support the inclusion of " +
"of custom fields, having keys of type `string`, and values of " + "custom fields, having keys of type `string`, and " +
"type `boolean | string | number | datetime`.") "values of type `boolean | string | number | datetime | structure`.")
@Test void eval_context() { @Test void eval_context() {
EvaluationContext ec = new EvaluationContext(); EvaluationContext ec = new EvaluationContext();
@ -38,63 +37,9 @@ public class EvalContextTests {
assertEquals(dt, ec.getDatetimeAttribute("dt")); assertEquals(dt, ec.getDatetimeAttribute("dt"));
} }
@Specification(spec="Evaluation Context", number="3.2", text="The evaluation context MUST support the inclusion of " +
@Specification(spec="flag evaluation", number="3.2", text="The evaluation context MUST define the " + "custom fields, having keys of type `string`, and " +
"following optional fields: `email` (string), `first name` (string), `last name`(string), " + "values of type `boolean | string | number | datetime | structure`.")
"`name`(string), `ip`(string), `tz`(string), `locale`(string), `country code` (string), " + @Disabled("Structure support")
"`timestamp`(date), `environment`(string), `application`(string), and `version`(string).") @Test void eval_context__structure() {}
@Test void mandated_fields() {
EvaluationContext ec = new EvaluationContext();
assertNull(ec.getEmail());
ec.setEmail("Test");
assertEquals("Test", ec.getEmail());
assertNull(ec.getFirstName());
ec.setFirstName("Test");
assertEquals("Test", ec.getFirstName());
assertNull(ec.getLastName());
ec.setLastName("Test");
assertEquals("Test", ec.getLastName());
assertNull(ec.getName());
ec.setName("Test");
assertEquals("Test", ec.getName());
assertNull(ec.getIp());
ec.setIp("Test");
assertEquals("Test", ec.getIp());
assertNull(ec.getTz());
ec.setTz("Test");
assertEquals("Test", ec.getTz());
assertNull(ec.getLocale());
ec.setLocale("Test");
assertEquals("Test", ec.getLocale());
assertNull(ec.getCountryCode());
ec.setCountryCode("Test");
assertEquals("Test", ec.getCountryCode());
assertNull(ec.getTimestamp());
ZonedDateTime dt = ZonedDateTime.now();
ec.setTimestamp(dt);
assertEquals(dt, ec.getTimestamp());
assertNull(ec.getEnvironment());
ec.setEnvironment("Test");
assertEquals("Test", ec.getEnvironment());
assertNull(ec.getApplication());
ec.setApplication("Test");
assertEquals("Test", ec.getApplication());
assertNull(ec.getVersion());
ec.setVersion("Test");
assertEquals("Test", ec.getVersion());
}
} }

View File

@ -1,6 +1,5 @@
package dev.openfeature.javasdk; package dev.openfeature.javasdk;
import dev.openfeature.javasdk.*;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -132,6 +131,7 @@ public class FlagEvaluationSpecTests {
"unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.") "unhandled error, etc) the reason field in the evaluation details SHOULD indicate an error.")
@Disabled @Disabled
@Test void detail_flags() { @Test void detail_flags() {
// TODO: Add tests re: detail functions.
throw new NotImplementedException(); throw new NotImplementedException();
} }

View File

@ -5,6 +5,7 @@ import lombok.SneakyThrows;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder; import org.mockito.InOrder;
import java.util.ArrayList; import java.util.ArrayList;
@ -396,7 +397,66 @@ public class HookSpecTests {
} }
@Specification(spec="hooks", number="3.3", text="Any `EvaluationContext` returned from a `before` hook **MUST** be passed to subsequent `before` hooks (via `HookContext`).") @Specification(spec="hooks", number="3.3", text="Any `EvaluationContext` 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);
when(hook.before(any(), any())).thenReturn(Optional.of(ctx));
Hook hook2 = mock(Hook.class);
when(hook.before(any(), any())).thenReturn(Optional.empty());
InOrder order = inOrder(hook, hook2);
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(new NoOpProvider());
Client client = api.getClient();
client.getBooleanValue("key", false, new EvaluationContext(),
FlagEvaluationOptions.builder()
.hook(hook2)
.hook(hook)
.build());
order.verify(hook).before(any(), any());
ArgumentCaptor<HookContext> captor = ArgumentCaptor.forClass(HookContext.class);
order.verify(hook2).before(captor.capture(), any());
HookContext hc = captor.getValue();
assertEquals(hc.getCtx(), ctx);
}
@Specification(spec="hooks", number="3.4", text="When `before` hooks have finished executing, any resulting `EvaluationContext` **MUST** be merged with the invocation `EvaluationContext` wherein the invocation `EvaluationContext` win any conflicts.") @Specification(spec="hooks", number="3.4", text="When `before` hooks have finished executing, any resulting `EvaluationContext` **MUST** be merged with the invocation `EvaluationContext` wherein the invocation `EvaluationContext` win any conflicts.")
@Test void mergeHappensCorrectly() {
EvaluationContext hookCtx = new EvaluationContext();
hookCtx.addStringAttribute("test", "broken");
hookCtx.addStringAttribute("another", "exists");
EvaluationContext invocationCtx = new EvaluationContext();
invocationCtx.addStringAttribute("test", "works");
Hook hook = mock(Hook.class);
when(hook.before(any(), any())).thenReturn(Optional.of(hookCtx));
FeatureProvider provider = mock(FeatureProvider.class);
when(provider.getBooleanEvaluation(any(),any(),any(),any())).thenReturn(ProviderEvaluation.<Boolean>builder()
.value(true)
.build());
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProvider(provider);
Client client = api.getClient();
client.getBooleanValue("key", false, invocationCtx,
FlagEvaluationOptions.builder()
.hook(hook)
.build());
ArgumentCaptor<EvaluationContext> captor = ArgumentCaptor.forClass(EvaluationContext.class);
verify(provider).getBooleanEvaluation(any(), any(), captor.capture(), any());
EvaluationContext ec = captor.getValue();
assertEquals("works", ec.getStringAttribute("test"));
assertEquals("exists", ec.getStringAttribute("another"));
}
@Specification(spec="hooks", number="1.4", text="The evaluation context MUST be mutable only within the before hook.") @Specification(spec="hooks", number="1.4", text="The evaluation context MUST be mutable only within the before hook.")
@Specification(spec="hooks", number="3.1", text="Hooks MUST specify at least one stage.") @Specification(spec="hooks", number="3.1", text="Hooks MUST specify at least one stage.")
@Test @Disabled void todo() {} @Test @Disabled void todo() {}