diff --git a/lib/src/main/java/growthbook/sdk/java/model/GBContext.java b/lib/src/main/java/growthbook/sdk/java/model/GBContext.java index dd69a310..b30a6193 100644 --- a/lib/src/main/java/growthbook/sdk/java/model/GBContext.java +++ b/lib/src/main/java/growthbook/sdk/java/model/GBContext.java @@ -6,6 +6,7 @@ import growthbook.sdk.java.callback.TrackingCallback; import growthbook.sdk.java.multiusermode.util.TransformationUtil; import growthbook.sdk.java.stickyBucketing.StickyBucketService; +import growthbook.sdk.java.util.ForcedVariationsUtils; import lombok.Builder; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -55,7 +56,7 @@ public GBContext( Boolean isQaMode, @Nullable String url, Boolean allowUrlOverrides, - @Nullable Map forcedVariationsMap, + @Nullable Map forcedVariationsMap, @Nullable TrackingCallback trackingCallback, @Nullable FeatureUsageCallback featureUsageCallback, @Nullable StickyBucketService stickyBucketService, @@ -78,7 +79,7 @@ public GBContext( this.isQaMode = isQaMode != null && isQaMode; this.allowUrlOverride = allowUrlOverrides != null && allowUrlOverrides; this.url = url; - this.forcedVariationsMap = forcedVariationsMap == null ? new HashMap<>() : forcedVariationsMap; + this.forcedVariationsMap = ForcedVariationsUtils.normalize(forcedVariationsMap); this.trackingCallback = trackingCallback; this.featureUsageCallback = featureUsageCallback; this.stickyBucketService = stickyBucketService; @@ -210,6 +211,10 @@ public void setFeaturesJson(String featuresJson) { @Nullable private Map forcedVariationsMap; + public void setForcedVariationsMap(@Nullable Map forcedVariationsMap) { + this.forcedVariationsMap = ForcedVariationsUtils.normalize(forcedVariationsMap); + } + /** * Service that provide functionality of Sticky Bucketing */ diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/GlobalContext.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/GlobalContext.java index 5385d4a7..313db0b0 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/GlobalContext.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/GlobalContext.java @@ -3,6 +3,7 @@ import com.google.gson.JsonObject; import growthbook.sdk.java.model.Experiment; import growthbook.sdk.java.model.Feature; +import growthbook.sdk.java.util.ForcedVariationsUtils; import lombok.Builder; import lombok.Data; import lombok.Getter; @@ -13,9 +14,26 @@ import java.util.Map; @Data -@Builder @Slf4j public class GlobalContext { + @Builder + public GlobalContext( + @Nullable Map> features, + @Nullable JsonObject savedGroups, + @Nullable List experiments, + @Nullable Boolean enabled, + @Nullable Boolean qaMode, + @Nullable Map forcedVariations, + @Nullable Map forcedFeatureValues + ) { + this.features = features; + this.savedGroups = savedGroups; + this.experiments = experiments; + this.enabled = enabled; + this.qaMode = qaMode; + this.forcedVariations = ForcedVariationsUtils.normalize(forcedVariations); + this.forcedFeatureValues = forcedFeatureValues; + } /** @@ -45,6 +63,10 @@ public class GlobalContext { @Nullable private Map forcedVariations; + public void setForcedVariations(@Nullable Map forcedVariations) { + this.forcedVariations = ForcedVariationsUtils.normalize(forcedVariations); + } + @Getter @Nullable private Map forcedFeatureValues; diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java index 0271e377..3c78f81b 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java @@ -13,6 +13,7 @@ import growthbook.sdk.java.sandbox.CacheMode; import growthbook.sdk.java.stickyBucketing.InMemoryStickyBucketServiceImpl; import growthbook.sdk.java.stickyBucketing.StickyBucketService; +import growthbook.sdk.java.util.ForcedVariationsUtils; import lombok.Builder; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -44,7 +45,7 @@ public Options(@Nullable Boolean enabled, @Nullable FeatureRefreshCallback featureRefreshCallback, @Nullable JsonObject globalAttributes, @Nullable Map globalForcedFeatureValues, - @Nullable Map globalForcedVariationsMap, + @Nullable Map globalForcedVariationsMap, @Nullable GbCacheManager cacheManager, @Nullable CacheMode cacheMode, @Nullable String cacheDirectory @@ -67,7 +68,7 @@ public Options(@Nullable Boolean enabled, this.featureRefreshCallback = featureRefreshCallback; this.globalAttributes = globalAttributes; this.globalForcedFeatureValues = globalForcedFeatureValues; - this.globalForcedVariationsMap = globalForcedVariationsMap; + this.globalForcedVariationsMap = ForcedVariationsUtils.normalize(globalForcedVariationsMap); this.cacheManager = cacheManager; this.cacheMode = cacheMode == null ? CacheMode.AUTO : cacheMode; this.cacheDirectory = cacheDirectory; @@ -178,6 +179,10 @@ public Options(@Nullable Boolean enabled, @Nullable private Map globalForcedVariationsMap; + public void setGlobalForcedVariationsMap(@Nullable Map globalForcedVariationsMap) { + this.globalForcedVariationsMap = ForcedVariationsUtils.normalize(globalForcedVariationsMap); + } + public FeatureRefreshStrategy getRefreshingStrategy() { if (this.refreshStrategy == null) { return FeatureRefreshStrategy.STALE_WHILE_REVALIDATE; diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/UserContext.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/UserContext.java index d2e83c64..f6756c28 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/UserContext.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/UserContext.java @@ -1,13 +1,13 @@ package growthbook.sdk.java.multiusermode.configurations; import com.google.gson.JsonObject; +import growthbook.sdk.java.util.ForcedVariationsUtils; import growthbook.sdk.java.model.StickyAssignmentsDocument; import growthbook.sdk.java.multiusermode.util.TransformationUtil; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; -import java.util.HashMap; import java.util.Map; @Slf4j @@ -36,9 +36,7 @@ private UserContext(UserContextBuilder userContextBuilder) { attributes = userContextBuilder.attributes == null ? new JsonObject() : userContextBuilder.attributes; url = userContextBuilder.url; stickyBucketAssignmentDocs = userContextBuilder.stickyBucketAssignmentDocs; - forcedVariationsMap = userContextBuilder.forcedVariationsMap == null - ? new HashMap<>() - : userContextBuilder.forcedVariationsMap; + forcedVariationsMap = ForcedVariationsUtils.normalize(userContextBuilder.forcedVariationsMap); forcedFeatureValues = userContextBuilder.forcedFeatureValues; attributesJson = userContextBuilder.attributesJson; } @@ -107,7 +105,7 @@ public static class UserContextBuilder { private Map stickyBucketAssignmentDocs; @Nullable - private Map forcedVariationsMap; + private Map forcedVariationsMap; @Nullable private Map forcedFeatureValues; @@ -139,7 +137,7 @@ public UserContextBuilder stickyBucketAssignmentDocs(Map forcedVariationsMap) { + public UserContextBuilder forcedVariationsMap(Map forcedVariationsMap) { this.forcedVariationsMap = forcedVariationsMap; return this; } diff --git a/lib/src/main/java/growthbook/sdk/java/util/ForcedVariationsUtils.java b/lib/src/main/java/growthbook/sdk/java/util/ForcedVariationsUtils.java new file mode 100644 index 00000000..af15d4e3 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/util/ForcedVariationsUtils.java @@ -0,0 +1,119 @@ +package growthbook.sdk.java.util; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; + +/** + * Normalizes forced variation values from external inputs into integer variation IDs. + */ +@Slf4j +@UtilityClass +public class ForcedVariationsUtils { + + private static final BigInteger MIN_INTEGER = BigInteger.valueOf(Integer.MIN_VALUE); + private static final BigInteger MAX_INTEGER = BigInteger.valueOf(Integer.MAX_VALUE); + private static final BigDecimal MIN_INTEGER_DECIMAL = BigDecimal.valueOf(Integer.MIN_VALUE); + private static final BigDecimal MAX_INTEGER_DECIMAL = BigDecimal.valueOf(Integer.MAX_VALUE); + + public Map normalize(@Nullable Map source) { + Map normalized = new HashMap<>(); + if (source == null) { + return normalized; + } + + for (Map.Entry entry : source.entrySet()) { + Integer variation = toVariationId(entry.getValue()); + if (variation != null) { + normalized.put(entry.getKey(), variation); + } else { + log.warn("Ignoring invalid forced variation value for key '{}': {}", entry.getKey(), entry.getValue()); + } + } + + return normalized; + } + + @Nullable + private Integer toVariationId(@Nullable Object value) { + if (value == null) { + return null; + } + if (value instanceof JsonElement) { + return toVariationId((JsonElement) value); + } + if (value instanceof Number) { + return toVariationId((Number) value); + } + + return null; + } + + @Nullable + private Integer toVariationId(JsonElement element) { + if (!element.isJsonPrimitive()) { + return null; + } + + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + double value = primitive.getAsDouble(); + return Double.isFinite(value) ? toInteger(BigDecimal.valueOf(value)) : null; + } + + return null; + } + + @Nullable + private Integer toVariationId(Number value) { + if (value instanceof Integer || value instanceof Short || value instanceof Byte) { + return value.intValue(); + } + if (value instanceof Long) { + long longValue = value.longValue(); + return isInIntegerRange(longValue) ? (int) longValue : null; + } + if (value instanceof BigInteger) { + BigInteger integer = (BigInteger) value; + return isInIntegerRange(integer) ? integer.intValue() : null; + } + if (value instanceof BigDecimal) { + return toInteger((BigDecimal) value); + } + if (value instanceof Double || value instanceof Float) { + double doubleValue = value.doubleValue(); + return Double.isFinite(doubleValue) ? toInteger(BigDecimal.valueOf(doubleValue)) : null; + } + + return null; + } + + @Nullable + private Integer toInteger(BigDecimal value) { + BigDecimal normalized = value.stripTrailingZeros(); + if (normalized.scale() > 0 || !isInIntegerRange(normalized)) { + return null; + } + + return normalized.intValue(); + } + + private boolean isInIntegerRange(long value) { + return value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE; + } + + private boolean isInIntegerRange(BigInteger value) { + return value.compareTo(MIN_INTEGER) >= 0 && value.compareTo(MAX_INTEGER) <= 0; + } + + private boolean isInIntegerRange(BigDecimal value) { + return value.compareTo(MIN_INTEGER_DECIMAL) >= 0 && value.compareTo(MAX_INTEGER_DECIMAL) <= 0; + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/GBContextTest.java b/lib/src/test/java/growthbook/sdk/java/GBContextTest.java index 498b1ff2..fc55b60e 100644 --- a/lib/src/test/java/growthbook/sdk/java/GBContextTest.java +++ b/lib/src/test/java/growthbook/sdk/java/GBContextTest.java @@ -72,6 +72,23 @@ void canBeConstructed() { assertNotNull(subject); } + @Test + void normalizesForcedVariationsFromExternalNumericMap() { + Map forcedVariations = new HashMap<>(); + forcedVariations.put("integer", 1); + forcedVariations.put("double", 1.0); + forcedVariations.put("invalid", "abc"); + + GBContext subject = GBContext + .builder() + .forcedVariationsMap(forcedVariations) + .build(); + + assertEquals(Integer.valueOf(1), subject.getForcedVariationsMap().get("integer")); + assertEquals(Integer.valueOf(1), subject.getForcedVariationsMap().get("double")); + assertFalse(subject.getForcedVariationsMap().containsKey("invalid")); + } + @Test void hasGetterSetterForInitialState() { Boolean isEnabled = true; diff --git a/lib/src/test/java/growthbook/sdk/java/GrowthBookTest.java b/lib/src/test/java/growthbook/sdk/java/GrowthBookTest.java index 1f80cec5..b462ee5e 100644 --- a/lib/src/test/java/growthbook/sdk/java/GrowthBookTest.java +++ b/lib/src/test/java/growthbook/sdk/java/GrowthBookTest.java @@ -32,6 +32,7 @@ import growthbook.sdk.java.testhelpers.PaperCupsConfig; import growthbook.sdk.java.testhelpers.TestCasesJsonHelper; import growthbook.sdk.java.testhelpers.TestContext; +import growthbook.sdk.java.util.ForcedVariationsUtils; import growthbook.sdk.java.util.GrowthBookJsonUtils; import lombok.Getter; import org.junit.jupiter.api.Test; @@ -65,8 +66,9 @@ void test_evalFeature() { JsonElement attributesJson = testCase.get(1).getAsJsonObject().get("attributes"); String attributesJsonAsStringOrNull = attributesJson == null ? null : attributesJson.toString(); - Type forcedVariationsType = new TypeToken>() {}.getType(); - HashMap forcedVariations = jsonUtils.gson.fromJson(testCase.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType); + Type forcedVariationsType = new TypeToken>() {}.getType(); + HashMap rawForcedVariations = jsonUtils.gson.fromJson(testCase.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType); + Map forcedVariations = ForcedVariationsUtils.normalize(rawForcedVariations); JsonElement savedGroupsJson = testCase.get(1).getAsJsonObject().get("savedGroups"); JsonObject savedGroups = savedGroupsJson == null ? null : (JsonObject) savedGroupsJson; @@ -343,8 +345,9 @@ void test_runExperiment() { JsonElement attributesJson = itemArray.get(1).getAsJsonObject().get("attributes"); String attributesJsonString = attributesJson == null ? "null" : attributesJson.toString(); - Type forcedVariationsType = new TypeToken>() {}.getType(); - HashMap forcedVariations = jsonUtils.gson.fromJson(itemArray.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType); + Type forcedVariationsType = new TypeToken>() {}.getType(); + HashMap rawForcedVariations = jsonUtils.gson.fromJson(itemArray.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType); + Map forcedVariations = ForcedVariationsUtils.normalize(rawForcedVariations); JsonElement savedGroups = itemArray.get(1).getAsJsonObject().get("savedGroups"); JsonObject savedGroupToPass = savedGroups == null ? null : savedGroups.getAsJsonObject(); diff --git a/lib/src/test/java/growthbook/sdk/java/evaluators/ForcedOverridesEvaluatorTest.java b/lib/src/test/java/growthbook/sdk/java/evaluators/ForcedOverridesEvaluatorTest.java index b76256f0..30e98a7e 100644 --- a/lib/src/test/java/growthbook/sdk/java/evaluators/ForcedOverridesEvaluatorTest.java +++ b/lib/src/test/java/growthbook/sdk/java/evaluators/ForcedOverridesEvaluatorTest.java @@ -69,6 +69,29 @@ void userForcedVariationOverridesGlobalValue() { assertTrue(result.getInExperiment()); } + @Test + void userForcedVariationAcceptsWholeNumberDoubleFromExternalInput() { + Map globalForcedVariations = new HashMap<>(); + globalForcedVariations.put("perf-experiment", 0); + + Map userForcedVariations = new HashMap<>(); + userForcedVariations.put("perf-experiment", 1.0); + userForcedVariations.put("ignored", "abc"); + + EvaluationContext context = buildContext( + null, + null, + globalForcedVariations, + userForcedVariations, + createAttributes("user-1") + ); + + ExperimentResult result = experimentEvaluator.evaluateExperiment(createExperiment(), context, null); + + assertEquals(Integer.valueOf(1), result.getVariationId()); + assertTrue(result.getInExperiment()); + } + @Test void absentForcedMapsStillEvaluatesSafelyInMultiUserMode() { EvaluationContext context = buildContext(null, null, null, null, createAttributes("user-1")); @@ -91,8 +114,8 @@ void absentAttributesStillEvaluatesExperimentSafely() { private EvaluationContext buildContext( Map globalForcedFeatures, Map userForcedFeatures, - Map globalForcedVariations, - Map userForcedVariations, + Map globalForcedVariations, + Map userForcedVariations, JsonObject attributes ) { GlobalContext global = GlobalContext.builder() diff --git a/lib/src/test/java/growthbook/sdk/java/multiusermode/OptionsTest.java b/lib/src/test/java/growthbook/sdk/java/multiusermode/OptionsTest.java new file mode 100644 index 00000000..aa3fde56 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/multiusermode/OptionsTest.java @@ -0,0 +1,28 @@ +package growthbook.sdk.java.multiusermode; + +import growthbook.sdk.java.multiusermode.configurations.Options; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class OptionsTest { + @Test + void normalizesGlobalForcedVariationsFromExternalNumericMap() { + Map forcedVariations = new HashMap<>(); + forcedVariations.put("integer", 1); + forcedVariations.put("double", 1.0); + forcedVariations.put("invalid", true); + + Options options = Options.builder() + .globalForcedVariationsMap(forcedVariations) + .build(); + + assertEquals(Integer.valueOf(1), options.getGlobalForcedVariationsMap().get("integer")); + assertEquals(Integer.valueOf(1), options.getGlobalForcedVariationsMap().get("double")); + assertFalse(options.getGlobalForcedVariationsMap().containsKey("invalid")); + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/testhelpers/TestContext.java b/lib/src/test/java/growthbook/sdk/java/testhelpers/TestContext.java index a5301ad9..5085fb70 100644 --- a/lib/src/test/java/growthbook/sdk/java/testhelpers/TestContext.java +++ b/lib/src/test/java/growthbook/sdk/java/testhelpers/TestContext.java @@ -23,5 +23,5 @@ public class TestContext { public String url; @Nullable - public HashMap forcedVariations; + public HashMap forcedVariations; } diff --git a/lib/src/test/java/growthbook/sdk/java/util/ForcedVariationsUtilsTest.java b/lib/src/test/java/growthbook/sdk/java/util/ForcedVariationsUtilsTest.java new file mode 100644 index 00000000..3b733073 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/util/ForcedVariationsUtilsTest.java @@ -0,0 +1,48 @@ +package growthbook.sdk.java.util; + +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ForcedVariationsUtilsTest { + @Test + void normalizesSupportedNumericValues() { + Map source = new HashMap<>(); + source.put("integer", 1); + source.put("double", 1.0); + source.put("long", 2L); + source.put("bigDecimal", new BigDecimal("3.0")); + source.put("jsonNumber", new JsonPrimitive(4.0)); + + Map result = ForcedVariationsUtils.normalize(source); + + assertEquals(Integer.valueOf(1), result.get("integer")); + assertEquals(Integer.valueOf(1), result.get("double")); + assertEquals(Integer.valueOf(2), result.get("long")); + assertEquals(Integer.valueOf(3), result.get("bigDecimal")); + assertEquals(Integer.valueOf(4), result.get("jsonNumber")); + } + + @Test + void ignoresUnsupportedValuesWithoutThrowing() { + Map source = new HashMap<>(); + source.put("fractional", 1.5); + source.put("text", "abc"); + source.put("boolean", true); + source.put("object", new JsonObject()); + + Map result = ForcedVariationsUtils.normalize(source); + + assertFalse(result.containsKey("fractional")); + assertFalse(result.containsKey("text")); + assertFalse(result.containsKey("boolean")); + assertFalse(result.containsKey("object")); + } +}