From 9f11af31b5139f2d52c4f9b320fa988d286c8cc6 Mon Sep 17 00:00:00 2001 From: Kaushal Kapasi Date: Mon, 28 Jul 2025 11:00:31 -0400 Subject: [PATCH] feat: adds support for sending DevCycle Eval Reasons to the OpenFeature interface --- .../server/openfeature/DevCycleProvider.java | 73 ++++++++++++++-- .../openfeature/DevCycleProviderTest.java | 87 +++++++++++++++++-- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java index bae42ea0..07cb9d2c 100644 --- a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java +++ b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java @@ -1,19 +1,31 @@ package com.devcycle.sdk.server.openfeature; +import java.math.BigDecimal; +import java.util.Map; +import java.util.Optional; + import com.devcycle.sdk.server.common.api.IDevCycleClient; import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; +import com.devcycle.sdk.server.common.model.EvalReason; import com.devcycle.sdk.server.common.model.Variable; -import dev.openfeature.sdk.*; -import dev.openfeature.sdk.exceptions.ProviderNotReadyError; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableMetadata.ImmutableMetadataBuilder; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.TrackingEventDetails; +import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import dev.openfeature.sdk.exceptions.TypeMismatchError; -import java.math.BigDecimal; -import java.util.Map; -import java.util.Optional; - public class DevCycleProvider implements FeatureProvider { private static final String PROVIDER_NAME = "DevCycle"; @@ -97,19 +109,36 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx); Variable variable = devcycleClient.variable(user, key, defaultValue.asStructure().asObjectMap()); + if (variable == null || variable.getIsDefaulted()) { + ImmutableMetadata flagMetadata = null; + if (variable != null && variable.getEval() != null) { + EvalReason eval = variable.getEval(); + flagMetadata = getFlagMetadata(eval); + } return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DEFAULT.toString()) + .flagMetadata(flagMetadata) .build(); } else { if (variable.getValue() instanceof Map) { // JSON objects are managed as Map implementations and must be converted to an OpenFeature structure Value objectValue = new Value(Structure.mapToStructure((Map) variable.getValue())); + + ImmutableMetadata flagMetadata = null; + String evalReason = Reason.TARGETING_MATCH.toString(); + if (variable.getEval() != null) { + EvalReason eval = variable.getEval(); + evalReason = eval.getReason(); + flagMetadata = getFlagMetadata(eval); + } + return ProviderEvaluation.builder() .value(objectValue) - .reason(Reason.TARGETING_MATCH.toString()) + .reason(evalReason) + .flagMetadata(flagMetadata) .build(); } else { throw new TypeMismatchError("DevCycle variable for key " + key + " is not a JSON object"); @@ -136,9 +165,15 @@ ProviderEvaluation resolvePrimitiveVariable(String key, T defaultValue, E Variable variable = devcycleClient.variable(user, key, defaultValue); if (variable == null || variable.getIsDefaulted()) { + ImmutableMetadata flagMetadata = null; + if (variable != null && variable.getEval() != null) { + EvalReason eval = variable.getEval(); + flagMetadata = getFlagMetadata(eval); + } return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DEFAULT.toString()) + .flagMetadata(flagMetadata) .build(); } else { T value = variable.getValue(); @@ -149,9 +184,18 @@ ProviderEvaluation resolvePrimitiveVariable(String key, T defaultValue, E value = (T) Integer.valueOf(numVal.intValue()); } + ImmutableMetadata flagMetadata = null; + String evalReason = Reason.TARGETING_MATCH.toString(); + if (variable.getEval() != null) { + EvalReason eval = variable.getEval(); + evalReason = eval.getReason(); + flagMetadata = getFlagMetadata(eval); + } + return ProviderEvaluation.builder() .value(value) - .reason(Reason.TARGETING_MATCH.toString()) + .reason(evalReason) + .flagMetadata(flagMetadata) .build(); } } catch (IllegalArgumentException e) { @@ -206,4 +250,17 @@ private Map getMetadataWithoutValue(TrackingEventDetails details metaData.remove("value"); return metaData; } + + private ImmutableMetadata getFlagMetadata(EvalReason evalReason) { + ImmutableMetadataBuilder flagMetadataBuilder = ImmutableMetadata.builder(); + + if (evalReason.getDetails() != null) { + flagMetadataBuilder.addString("evalReasonDetails", evalReason.getDetails()); + } + + if (evalReason.getTargetId() != null) { + flagMetadataBuilder.addString("evalReasonTargetId", evalReason.getTargetId()); + } + return flagMetadataBuilder.build(); + } } diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java index ddaa88d4..eccb1a5a 100644 --- a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java +++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java @@ -1,19 +1,33 @@ package com.devcycle.sdk.server.openfeature; -import com.devcycle.sdk.server.common.api.IDevCycleClient; -import com.devcycle.sdk.server.common.model.Variable; -import dev.openfeature.sdk.*; -import dev.openfeature.sdk.exceptions.ProviderNotReadyError; -import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.junit.MockitoJUnitRunner; -import java.util.*; +import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.model.EvalReason; +import com.devcycle.sdk.server.common.model.Variable; -import static org.mockito.Mockito.*; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import dev.openfeature.sdk.exceptions.TargetingKeyMissingError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; @RunWith(MockitoJUnitRunner.class) public class DevCycleProviderTest { @@ -90,7 +104,7 @@ public void testResolveVariableDefaulted() { IDevCycleClient dvcClient = mock(IDevCycleClient.class); when(dvcClient.isInitialized()).thenReturn(true); - when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value("unused value 1").defaultValue("default value").isDefaulted(true).type(Variable.TypeEnum.STRING).build()); + when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value("unused value 1").defaultValue("default value").isDefaulted(true).type(Variable.TypeEnum.STRING).eval(EvalReason.defaultReason(EvalReason.DefaultReasonDetailsEnum.USER_NOT_TARGETED)).build()); DevCycleProvider provider = new DevCycleProvider(dvcClient); @@ -99,6 +113,8 @@ public void testResolveVariableDefaulted() { Assert.assertEquals(result.getValue(), "default value"); Assert.assertEquals(result.getReason(), Reason.DEFAULT.toString()); Assert.assertNull(result.getErrorCode()); + Assert.assertNotNull(result.getFlagMetadata()); + Assert.assertEquals(result.getFlagMetadata().getString("evalReasonDetails"), EvalReason.DefaultReasonDetailsEnum.USER_NOT_TARGETED.getValue()); } @Test @@ -117,6 +133,25 @@ public void testResolveBooleanVariable() { Assert.assertNull(result.getErrorCode()); } + @Test + public void testResolveBooleanVariableWithDevCycleEvalReason() { + IDevCycleClient dvcClient = mock(IDevCycleClient.class); + when(dvcClient.isInitialized()).thenReturn(true); + + when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(true).defaultValue(false).type(Variable.TypeEnum.BOOLEAN).eval(new EvalReason("SPLIT", "User ID", "bool_target_id")).build()); + + DevCycleProvider provider = new DevCycleProvider(dvcClient); + + ProviderEvaluation result = provider.resolvePrimitiveVariable("some-flag", false, new ImmutableContext("user-1234")); + Assert.assertNotNull(result); + Assert.assertEquals(result.getValue(), true); + Assert.assertEquals(result.getReason(), "SPLIT"); + Assert.assertNull(result.getErrorCode()); + Assert.assertNotNull(result.getFlagMetadata()); + Assert.assertEquals(result.getFlagMetadata().getString("evalReasonDetails"), "User ID"); + Assert.assertEquals(result.getFlagMetadata().getString("evalReasonTargetId"), "bool_target_id"); + } + @Test public void testResolveIntegerVariable() { IDevCycleClient dvcClient = mock(IDevCycleClient.class); @@ -249,4 +284,38 @@ public void testGetObjectEvaluation() { Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString()); Assert.assertNull(result.getErrorCode()); } + + @Test + public void testGetObjectEvaluationWithDevCycleEvalReason() { + Map jsonData = new LinkedHashMap<>(); + jsonData.put("strVal", "some string"); + jsonData.put("boolVal", true); + jsonData.put("numVal", 123); + + Map defaultJsonData = new LinkedHashMap<>(); + + IDevCycleClient dvcClient = mock(IDevCycleClient.class); + when(dvcClient.isInitialized()).thenReturn(true); + when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(jsonData).defaultValue(defaultJsonData).type(Variable.TypeEnum.JSON).eval(new EvalReason("SPLIT", "User ID", "json_target_id")).build()); + + DevCycleProvider provider = new DevCycleProvider(dvcClient); + + Value defaultValue = new Value(Structure.mapToStructure(defaultJsonData)); + + ProviderEvaluation result = provider.getObjectEvaluation("some-flag", defaultValue, new ImmutableContext("user-1234")); + Assert.assertNotNull(result); + Assert.assertNotNull(result.getValue()); + Assert.assertTrue(result.getValue().isStructure()); + + result.getValue().asStructure().asObjectMap().forEach((k, v) -> { + Assert.assertTrue(jsonData.containsKey(k)); + Assert.assertEquals(jsonData.get(k), v); + }); + + Assert.assertEquals(result.getReason(), "SPLIT"); + Assert.assertNull(result.getErrorCode()); + Assert.assertNotNull(result.getFlagMetadata()); + Assert.assertEquals(result.getFlagMetadata().getString("evalReasonDetails"), "User ID"); + Assert.assertEquals(result.getFlagMetadata().getString("evalReasonTargetId"), "json_target_id"); + } }