Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions lib/src/main/java/growthbook/sdk/java/model/GBContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,7 +56,7 @@ public GBContext(
Boolean isQaMode,
@Nullable String url,
Boolean allowUrlOverrides,
@Nullable Map<String, Integer> forcedVariationsMap,
@Nullable Map<String, ?> forcedVariationsMap,
@Nullable TrackingCallback trackingCallback,
@Nullable FeatureUsageCallback featureUsageCallback,
@Nullable StickyBucketService stickyBucketService,
Expand All @@ -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;
Expand Down Expand Up @@ -210,6 +211,10 @@ public void setFeaturesJson(String featuresJson) {
@Nullable
private Map<String, Integer> forcedVariationsMap;

public void setForcedVariationsMap(@Nullable Map<String, ?> forcedVariationsMap) {
this.forcedVariationsMap = ForcedVariationsUtils.normalize(forcedVariationsMap);
}

/**
* Service that provide functionality of Sticky Bucketing
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,9 +14,26 @@
import java.util.Map;

@Data
@Builder
@Slf4j
public class GlobalContext {
@Builder
public GlobalContext(
@Nullable Map<String, Feature<?>> features,
@Nullable JsonObject savedGroups,
@Nullable List<Experiment> experiments,
@Nullable Boolean enabled,
@Nullable Boolean qaMode,
@Nullable Map<String, ?> forcedVariations,
@Nullable Map<String, Object> forcedFeatureValues
) {
this.features = features;
this.savedGroups = savedGroups;
this.experiments = experiments;
this.enabled = enabled;
this.qaMode = qaMode;
this.forcedVariations = ForcedVariationsUtils.normalize(forcedVariations);
this.forcedFeatureValues = forcedFeatureValues;
}


/**
Expand Down Expand Up @@ -45,6 +63,10 @@ public class GlobalContext {
@Nullable
private Map<String, Integer> forcedVariations;

public void setForcedVariations(@Nullable Map<String, ?> forcedVariations) {
this.forcedVariations = ForcedVariationsUtils.normalize(forcedVariations);
}

@Getter
@Nullable
private Map<String, Object> forcedFeatureValues;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,7 +45,7 @@ public Options(@Nullable Boolean enabled,
@Nullable FeatureRefreshCallback featureRefreshCallback,
@Nullable JsonObject globalAttributes,
@Nullable Map<String, Object> globalForcedFeatureValues,
@Nullable Map<String, Integer> globalForcedVariationsMap,
@Nullable Map<String, ?> globalForcedVariationsMap,
@Nullable GbCacheManager cacheManager,
@Nullable CacheMode cacheMode,
@Nullable String cacheDirectory
Expand All @@ -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;
Expand Down Expand Up @@ -178,6 +179,10 @@ public Options(@Nullable Boolean enabled,
@Nullable
private Map<String, Integer> globalForcedVariationsMap;

public void setGlobalForcedVariationsMap(@Nullable Map<String, ?> globalForcedVariationsMap) {
this.globalForcedVariationsMap = ForcedVariationsUtils.normalize(globalForcedVariationsMap);
}

public FeatureRefreshStrategy getRefreshingStrategy() {
if (this.refreshStrategy == null) {
return FeatureRefreshStrategy.STALE_WHILE_REVALIDATE;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -107,7 +105,7 @@ public static class UserContextBuilder {
private Map<String, StickyAssignmentsDocument> stickyBucketAssignmentDocs;

@Nullable
private Map<String, Integer> forcedVariationsMap;
private Map<String, ?> forcedVariationsMap;

@Nullable
private Map<String, Object> forcedFeatureValues;
Expand Down Expand Up @@ -139,7 +137,7 @@ public UserContextBuilder stickyBucketAssignmentDocs(Map<String, StickyAssignmen
return this;
}

public UserContextBuilder forcedVariationsMap(Map<String, Integer> forcedVariationsMap) {
public UserContextBuilder forcedVariationsMap(Map<String, ?> forcedVariationsMap) {
this.forcedVariationsMap = forcedVariationsMap;
return this;
}
Expand Down
119 changes: 119 additions & 0 deletions lib/src/main/java/growthbook/sdk/java/util/ForcedVariationsUtils.java
Original file line number Diff line number Diff line change
@@ -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<String, Integer> normalize(@Nullable Map<String, ?> source) {
Map<String, Integer> normalized = new HashMap<>();
if (source == null) {
return normalized;
}

for (Map.Entry<String, ?> 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;
}
}
17 changes: 17 additions & 0 deletions lib/src/test/java/growthbook/sdk/java/GBContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ void canBeConstructed() {
assertNotNull(subject);
}

@Test
void normalizesForcedVariationsFromExternalNumericMap() {
Map<String, Object> 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;
Expand Down
11 changes: 7 additions & 4 deletions lib/src/test/java/growthbook/sdk/java/GrowthBookTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HashMap<String, Integer>>() {}.getType();
HashMap<String, Integer> forcedVariations = jsonUtils.gson.fromJson(testCase.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType);
Type forcedVariationsType = new TypeToken<HashMap<String, Object>>() {}.getType();
HashMap<String, Object> rawForcedVariations = jsonUtils.gson.fromJson(testCase.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType);
Map<String, Integer> forcedVariations = ForcedVariationsUtils.normalize(rawForcedVariations);

JsonElement savedGroupsJson = testCase.get(1).getAsJsonObject().get("savedGroups");
JsonObject savedGroups = savedGroupsJson == null ? null : (JsonObject) savedGroupsJson;
Expand Down Expand Up @@ -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<HashMap<String, Integer>>() {}.getType();
HashMap<String, Integer> forcedVariations = jsonUtils.gson.fromJson(itemArray.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType);
Type forcedVariationsType = new TypeToken<HashMap<String, Object>>() {}.getType();
HashMap<String, Object> rawForcedVariations = jsonUtils.gson.fromJson(itemArray.get(1).getAsJsonObject().get("forcedVariations"), forcedVariationsType);
Map<String, Integer> forcedVariations = ForcedVariationsUtils.normalize(rawForcedVariations);

JsonElement savedGroups = itemArray.get(1).getAsJsonObject().get("savedGroups");
JsonObject savedGroupToPass = savedGroups == null ? null : savedGroups.getAsJsonObject();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ void userForcedVariationOverridesGlobalValue() {
assertTrue(result.getInExperiment());
}

@Test
void userForcedVariationAcceptsWholeNumberDoubleFromExternalInput() {
Map<String, Integer> globalForcedVariations = new HashMap<>();
globalForcedVariations.put("perf-experiment", 0);

Map<String, Object> userForcedVariations = new HashMap<>();
userForcedVariations.put("perf-experiment", 1.0);
userForcedVariations.put("ignored", "abc");

EvaluationContext context = buildContext(
null,
null,
globalForcedVariations,
userForcedVariations,
createAttributes("user-1")
);

ExperimentResult<String> 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"));
Expand All @@ -91,8 +114,8 @@ void absentAttributesStillEvaluatesExperimentSafely() {
private EvaluationContext buildContext(
Map<String, Object> globalForcedFeatures,
Map<String, Object> userForcedFeatures,
Map<String, Integer> globalForcedVariations,
Map<String, Integer> userForcedVariations,
Map<String, ?> globalForcedVariations,
Map<String, ?> userForcedVariations,
JsonObject attributes
) {
GlobalContext global = GlobalContext.builder()
Expand Down
Loading
Loading