diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 6a206c0e..9e939f4a 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -3,6 +3,13 @@ import static cloud.eppo.Constants.DEFAULT_JITTER_INTERVAL_RATIO; import static cloud.eppo.Constants.DEFAULT_POLLING_INTERVAL_MILLIS; import static cloud.eppo.Utils.throwIfEmptyOrNull; +import static cloud.eppo.Utils.throwIfNull; +import static cloud.eppo.ValuedFlagEvaluationResultType.BAD_VALUE_TYPE; +import static cloud.eppo.ValuedFlagEvaluationResultType.BAD_VARIATION_TYPE; +import static cloud.eppo.ValuedFlagEvaluationResultType.FLAG_DISABLED; +import static cloud.eppo.ValuedFlagEvaluationResultType.NO_ALLOCATION; +import static cloud.eppo.ValuedFlagEvaluationResultType.NO_FLAG_CONFIG; +import static cloud.eppo.ValuedFlagEvaluationResultType.OK; import cloud.eppo.api.*; import cloud.eppo.cache.AssignmentCacheEntry; @@ -190,28 +197,33 @@ protected CompletableFuture loadConfigurationAsync() { return future; } - protected EppoValue getTypedAssignment( - String flagKey, - String subjectKey, - Attributes subjectAttributes, - EppoValue defaultValue, - VariationType expectedType) { - + @NotNull + protected ValuedFlagEvaluationResult getTypedAssignmentResult( + @NotNull String flagKey, + @NotNull String subjectKey, + @NotNull Attributes subjectAttributes, + @NotNull EppoValue defaultValue, + @NotNull VariationType expectedType) { throwIfEmptyOrNull(flagKey, "flagKey must not be empty"); throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty"); + throwIfNull(subjectAttributes, "subjectAttributes must not be empty"); + throwIfNull(defaultValue, "defaultValue must not be empty"); + throwIfNull(expectedType, "expectedType must not be empty"); - Configuration config = getConfiguration(); + @NotNull final Configuration config = getConfiguration(); - FlagConfig flag = config.getFlag(flagKey); - if (flag == null) { + @Nullable final FlagConfig maybeFlag = config.getFlag(flagKey); + if (maybeFlag == null) { log.warn("no configuration found for key: {}", flagKey); - return defaultValue; + return new ValuedFlagEvaluationResult(defaultValue, null, NO_FLAG_CONFIG); } + @NotNull final FlagConfig flag = maybeFlag; + if (!flag.isEnabled()) { log.info( "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); - return defaultValue; + return new ValuedFlagEvaluationResult(defaultValue, null, FLAG_DISABLED); } if (flag.getVariationType() != expectedType) { @@ -220,68 +232,88 @@ protected EppoValue getTypedAssignment( flagKey, flag.getVariationType(), expectedType); - return defaultValue; + return new ValuedFlagEvaluationResult(defaultValue, null, BAD_VARIATION_TYPE); } - FlagEvaluationResult evaluationResult = + @NotNull final FlagEvaluationResult evaluationResult = FlagEvaluator.evaluateFlag( flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated()); - EppoValue assignedValue = - evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; - - if (assignedValue != null && !valueTypeMatchesExpected(expectedType, assignedValue)) { - log.warn( + @Nullable final FlagEvaluationAllocationKeyAndVariation allocationKeyAndVariation = + evaluationResult.getAllocationKeyAndVariation(); + + @NotNull final ValuedFlagEvaluationResult valuedEvaluationResult; + if (allocationKeyAndVariation == null) { + valuedEvaluationResult = new ValuedFlagEvaluationResult(defaultValue, evaluationResult, NO_ALLOCATION); + } else { + @NotNull final EppoValue assignedValue = allocationKeyAndVariation.getVariation().getValue(); + if (!valueTypeMatchesExpected(expectedType, assignedValue)) { + log.warn( "no assigned variation because the flag type doesn't match the variation type: {} has type {}, variation value is {}", flagKey, flag.getVariationType(), assignedValue); - return defaultValue; - } + return new ValuedFlagEvaluationResult(defaultValue, evaluationResult, BAD_VALUE_TYPE); + } else { + valuedEvaluationResult = new ValuedFlagEvaluationResult(assignedValue, evaluationResult, OK); + if (assignmentLogger != null && evaluationResult.doLog()) { - if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { - - try { - String allocationKey = evaluationResult.getAllocationKey(); - String experimentKey = - flagKey - + '-' - + allocationKey; // Our experiment key is derived by hyphenating the flag key and - // allocation key - String variationKey = evaluationResult.getVariation().getKey(); - Map extraLogging = evaluationResult.getExtraLogging(); - Map metaData = buildLogMetaData(config.isConfigObfuscated()); - - Assignment assignment = - new Assignment( - experimentKey, - flagKey, - allocationKey, - variationKey, - subjectKey, - subjectAttributes, - extraLogging, - metaData); - - // Deduplication of assignment logging is possible by providing an `IAssignmentCache`. - // Default to true, only avoid logging if there's a cache hit. - boolean logAssignment = true; - AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment); - if (assignmentCache != null) { - logAssignment = assignmentCache.putIfAbsent(cacheEntry); - } + try { + @NotNull final String allocationKey = allocationKeyAndVariation.getAllocationKey(); + @NotNull final String experimentKey = + flagKey + + '-' + + allocationKey; // Our experiment key is derived by hyphenating the flag key and + // allocation key + @NotNull final String variationKey = allocationKeyAndVariation.getVariation().getKey(); + @NotNull final Map extraLogging = evaluationResult.getExtraLogging(); + @NotNull final Map metaData = buildLogMetaData(config.isConfigObfuscated()); + + @NotNull final Assignment assignment = + new Assignment( + experimentKey, + flagKey, + allocationKey, + variationKey, + subjectKey, + subjectAttributes, + extraLogging, + metaData); + final boolean logAssignment; + @NotNull final AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment); + if (assignmentCache != null) { + logAssignment = assignmentCache.putIfAbsent(cacheEntry); + } else { + // Deduplication of assignment logging is possible by providing an `IAssignmentCache`. + // Default to true, only avoid logging if there's a cache hit. + logAssignment = true; + } - if (logAssignment) { - assignmentLogger.logAssignment(assignment); + if (logAssignment) { + assignmentLogger.logAssignment(assignment); + } + } catch (Exception e) { + log.error("Error logging assignment: {}", e.getMessage(), e); + } } - - } catch (Exception e) { - log.error("Error logging assignment: {}", e.getMessage(), e); } } - return assignedValue != null ? assignedValue : defaultValue; + return valuedEvaluationResult; + } + + @NotNull + protected EppoValue getTypedAssignment( + @NotNull String flagKey, + @NotNull String subjectKey, + @NotNull Attributes subjectAttributes, + @NotNull EppoValue defaultValue, + @NotNull VariationType expectedType) { + + @NotNull final ValuedFlagEvaluationResult valuedEvaluationResult = getTypedAssignmentResult( + flagKey, subjectKey, subjectAttributes, defaultValue, expectedType); + return valuedEvaluationResult.getValue(); } - private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue value) { + private boolean valueTypeMatchesExpected(@NotNull VariationType expectedType, @NotNull EppoValue value) { boolean typeMatch; switch (expectedType) { case BOOLEAN: @@ -312,14 +344,14 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v return typeMatch; } - public boolean getBooleanAssignment(String flagKey, String subjectKey, boolean defaultValue) { + public boolean getBooleanAssignment(@NotNull String flagKey, @NotNull String subjectKey, boolean defaultValue) { return this.getBooleanAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } public boolean getBooleanAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, boolean defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, @@ -332,14 +364,14 @@ public boolean getBooleanAssignment( } } - public int getIntegerAssignment(String flagKey, String subjectKey, int defaultValue) { + public int getIntegerAssignment(@NotNull String flagKey, @NotNull String subjectKey, int defaultValue) { return getIntegerAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } public int getIntegerAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, int defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, @@ -352,14 +384,16 @@ public int getIntegerAssignment( } } - public Double getDoubleAssignment(String flagKey, String subjectKey, double defaultValue) { + @NotNull + public Double getDoubleAssignment(@NotNull String flagKey, @NotNull String subjectKey, double defaultValue) { return getDoubleAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } + @NotNull public Double getDoubleAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, double defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, @@ -372,14 +406,16 @@ public Double getDoubleAssignment( } } - public String getStringAssignment(String flagKey, String subjectKey, String defaultValue) { + @NotNull + public String getStringAssignment(@NotNull String flagKey, @NotNull String subjectKey, @NotNull String defaultValue) { return this.getStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } + @NotNull public String getStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, @NotNull String defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, @@ -402,7 +438,8 @@ public String getStringAssignment( * @param defaultValue the default value to return if the flag is not found * @return the JSON string value of the assignment */ - public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode defaultValue) { + @NotNull + public JsonNode getJSONAssignment(@NotNull String flagKey, @NotNull String subjectKey, @NotNull JsonNode defaultValue) { return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } @@ -416,17 +453,19 @@ public JsonNode getJSONAssignment(String flagKey, String subjectKey, JsonNode de * @param defaultValue the default value to return if the flag is not found * @return the JSON string value of the assignment */ + @NotNull public JsonNode getJSONAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, JsonNode defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, @NotNull JsonNode defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, subjectAttributes, EppoValue.valueOf(defaultValue.toString()), VariationType.JSON); - return parseJsonString(value.stringValue()); + @Nullable final JsonNode jsonValue = parseJsonString(value.stringValue()); + return jsonValue != null ? jsonValue : defaultValue; } catch (Exception e) { return throwIfNotGraceful(e, defaultValue); } @@ -442,10 +481,11 @@ public JsonNode getJSONAssignment( * @param defaultValue the default value to return if the flag is not found * @return the JSON string value of the assignment */ + @NotNull public String getJSONStringAssignment( - String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + @NotNull String flagKey, @NotNull String subjectKey, @NotNull Attributes subjectAttributes, @NotNull String defaultValue) { try { - EppoValue value = + @NotNull final EppoValue value = this.getTypedAssignment( flagKey, subjectKey, @@ -468,11 +508,13 @@ public String getJSONStringAssignment( * @param defaultValue the default value to return if the flag is not found * @return the JSON string value of the assignment */ - public String getJSONStringAssignment(String flagKey, String subjectKey, String defaultValue) { + @NotNull + public String getJSONStringAssignment(@NotNull String flagKey, @NotNull String subjectKey, @NotNull String defaultValue) { return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - private JsonNode parseJsonString(String jsonString) { + @Nullable + private JsonNode parseJsonString(@NotNull String jsonString) { try { return mapper.readTree(jsonString); } catch (JsonProcessingException e) { @@ -551,15 +593,17 @@ public BanditResult getBanditAction( } } + @NotNull private Map buildLogMetaData(boolean isConfigObfuscated) { - HashMap metaData = new HashMap<>(); + @NotNull final HashMap metaData = new HashMap<>(); metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString()); metaData.put("sdkLanguage", sdkName); metaData.put("sdkLibVersion", sdkVersion); return metaData; } - private T throwIfNotGraceful(Exception e, T defaultValue) { + @NotNull + private T throwIfNotGraceful(@NotNull Exception e, @NotNull T defaultValue) { if (this.isGracefulMode) { log.info("error getting assignment value: {}", e.getMessage()); return defaultValue; diff --git a/src/main/java/cloud/eppo/FlagEvaluationAllocationKeyAndVariation.java b/src/main/java/cloud/eppo/FlagEvaluationAllocationKeyAndVariation.java new file mode 100644 index 00000000..e39c93c9 --- /dev/null +++ b/src/main/java/cloud/eppo/FlagEvaluationAllocationKeyAndVariation.java @@ -0,0 +1,54 @@ +package cloud.eppo; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +import cloud.eppo.ufc.dto.Variation; + +/** + * Holds an allocation key and a variation because these two values + * either both need to be null or both need to be not-null in a + * FlagEvaluationResult. This makes nullability easier to enforce. + */ +public class FlagEvaluationAllocationKeyAndVariation { + @NotNull + final String allocationKey; + @NotNull final Variation variation; + + public FlagEvaluationAllocationKeyAndVariation(@NotNull String allocationKey, @NotNull Variation variation) { + this.allocationKey = allocationKey; + this.variation = variation; + } + + @Override + public boolean equals(@Nullable Object o) { + if (o == null || getClass() != o.getClass()) return false; + FlagEvaluationAllocationKeyAndVariation that = (FlagEvaluationAllocationKeyAndVariation) o; + return Objects.equals(allocationKey, that.allocationKey) && Objects.equals(variation, that.variation); + } + + @Override + public int hashCode() { + return Objects.hash(allocationKey, variation); + } + + @Override + public String toString() { + return "AllocationKeyAndVariation{" + + "allocationKey='" + allocationKey + '\'' + + ", variation=" + variation + + '}'; + } + + @NotNull + public String getAllocationKey() { + return allocationKey; + } + + @NotNull + public Variation getVariation() { + return variation; + } +} diff --git a/src/main/java/cloud/eppo/FlagEvaluationResult.java b/src/main/java/cloud/eppo/FlagEvaluationResult.java index 18d25235..45b68a26 100644 --- a/src/main/java/cloud/eppo/FlagEvaluationResult.java +++ b/src/main/java/cloud/eppo/FlagEvaluationResult.java @@ -1,88 +1,115 @@ package cloud.eppo; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import cloud.eppo.api.Attributes; +import cloud.eppo.ufc.dto.FlagConfig; import cloud.eppo.ufc.dto.Variation; import java.util.Map; import java.util.Objects; public class FlagEvaluationResult { - - private final String flagKey; - private final String subjectKey; - private final Attributes subjectAttributes; - private final String allocationKey; - private final Variation variation; - private final Map extraLogging; + @NotNull private final FlagConfig flag; + @NotNull private final String flagKey; + @NotNull private final String subjectKey; + @NotNull private final Attributes subjectAttributes; + @Nullable private final FlagEvaluationAllocationKeyAndVariation allocationKeyAndVariation; + @NotNull private final Map extraLogging; private final boolean doLog; public FlagEvaluationResult( - String flagKey, - String subjectKey, - Attributes subjectAttributes, - String allocationKey, - Variation variation, - Map extraLogging, + @NotNull FlagConfig flag, + @NotNull String flagKey, + @NotNull String subjectKey, + @NotNull Attributes subjectAttributes, + @Nullable FlagEvaluationAllocationKeyAndVariation allocationKeyAndVariation, + @NotNull Map extraLogging, boolean doLog) { + throwIfNull(flag, "flag must not be null"); + throwIfNull(flagKey, "flagKey must not be null"); + throwIfNull(subjectKey, "subjectKey must not be null"); + throwIfNull(subjectAttributes, "subjectAttributes must not be null"); + throwIfNull(extraLogging, "extraLogging must not be null"); + + this.flag = flag; this.flagKey = flagKey; this.subjectKey = subjectKey; this.subjectAttributes = subjectAttributes; - this.allocationKey = allocationKey; - this.variation = variation; + this.allocationKeyAndVariation = allocationKeyAndVariation; this.extraLogging = extraLogging; this.doLog = doLog; } - @Override + @Override @NotNull public String toString() { return "FlagEvaluationResult{" + - "flagKey='" + flagKey + '\'' + + "flag='" + flag + '\'' + + ", flagKey='" + flagKey + '\'' + ", subjectKey='" + subjectKey + '\'' + ", subjectAttributes=" + subjectAttributes + - ", allocationKey='" + allocationKey + '\'' + - ", variation=" + variation + + ", allocationKeyAndVariation='" + allocationKeyAndVariation + '\'' + ", extraLogging=" + extraLogging + ", doLog=" + doLog + '}'; } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; FlagEvaluationResult that = (FlagEvaluationResult) o; return doLog == that.doLog + && Objects.equals(flag, that.flag) && Objects.equals(flagKey, that.flagKey) && Objects.equals(subjectKey, that.subjectKey) && Objects.equals(subjectAttributes, that.subjectAttributes) - && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(variation, that.variation) + && Objects.equals(allocationKeyAndVariation, that.allocationKeyAndVariation) && Objects.equals(extraLogging, that.extraLogging); } @Override public int hashCode() { - return Objects.hash(flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + return Objects.hash(flag, flagKey, subjectKey, subjectAttributes, allocationKeyAndVariation, extraLogging, doLog); } + @NotNull + public FlagConfig getFlag() { + return flag; + } + + @NotNull public String getFlagKey() { return flagKey; } + @NotNull public String getSubjectKey() { return subjectKey; } + @NotNull public Attributes getSubjectAttributes() { return subjectAttributes; } + @Nullable + public FlagEvaluationAllocationKeyAndVariation getAllocationKeyAndVariation() { + return allocationKeyAndVariation; + } + + @Nullable public String getAllocationKey() { - return allocationKey; + return allocationKeyAndVariation != null ? allocationKeyAndVariation.allocationKey : null; } + @Nullable public Variation getVariation() { - return variation; + return allocationKeyAndVariation != null ? allocationKeyAndVariation.variation : null; } + @NotNull public Map getExtraLogging() { return extraLogging; } diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java index 0a2e78f7..164863ee 100644 --- a/src/main/java/cloud/eppo/FlagEvaluator.java +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -2,6 +2,12 @@ import static cloud.eppo.Utils.base64Decode; import static cloud.eppo.Utils.getShard; +import static cloud.eppo.Utils.notNullBase64Decode; +import static cloud.eppo.Utils.throwIfEmptyOrNull; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; @@ -11,6 +17,8 @@ import cloud.eppo.ufc.dto.Shard; import cloud.eppo.ufc.dto.Split; import cloud.eppo.ufc.dto.Variation; +import cloud.eppo.ufc.dto.VariationType; + import java.util.Date; import java.util.HashMap; import java.util.LinkedList; @@ -19,22 +27,28 @@ public class FlagEvaluator { + @NotNull public static FlagEvaluationResult evaluateFlag( - FlagConfig flag, - String flagKey, - String subjectKey, - Attributes subjectAttributes, + @NotNull FlagConfig flag, + @NotNull String flagKey, + @NotNull String subjectKey, + @NotNull Attributes subjectAttributes, boolean isConfigObfuscated) { - Date now = new Date(); + throwIfNull(flag, "flag must not be null"); + throwIfNull(flagKey, "flagKey must not be null"); + throwIfNull(subjectKey, "subjectKey must not be null"); + throwIfNull(subjectAttributes, "subjectAttributes must not be null"); + + @NotNull final Date now = new Date(); - Variation variation = null; - String allocationKey = null; - Map extraLogging = new HashMap<>(); + @Nullable Variation variation = null; + @Nullable String allocationKey = null; + @NotNull Map extraLogging = new HashMap<>(); boolean doLog = false; // If flag is disabled; use an empty list of allocations so that the empty result is returned // Note: this is a safety check; disabled flags should be filtered upstream - List allocationsToConsider = + @NotNull final List allocationsToConsider = flag.isEnabled() && flag.getAllocations() != null ? flag.getAllocations() : new LinkedList<>(); @@ -51,7 +65,7 @@ public static FlagEvaluationResult evaluateFlag( // For convenience, we will automatically include the subject key as the "id" attribute if // none is provided - Attributes subjectAttributesToEvaluate = new Attributes(subjectAttributes); + @NotNull final Attributes subjectAttributesToEvaluate = new Attributes(subjectAttributes); if (!subjectAttributesToEvaluate.containsKey("id")) { subjectAttributesToEvaluate.put("id", subjectKey); } @@ -66,7 +80,7 @@ public static FlagEvaluationResult evaluateFlag( } // This allocation has matched; find variation - for (Split split : allocation.getSplits()) { + for (@NotNull final Split split : allocation.getSplits()) { if (allShardsMatch(split, subjectKey, flag.getTotalShards(), isConfigObfuscated)) { // Variation and extra logging is determined by the relevant split variation = flag.getVariations().get(split.getVariationKey()); @@ -90,29 +104,38 @@ public static FlagEvaluationResult evaluateFlag( if (isConfigObfuscated) { // Need to unobfuscate for the returned evaluation result if (allocationKey != null) { - allocationKey = base64Decode(allocationKey); + allocationKey = notNullBase64Decode(allocationKey); } if (variation != null) { - String key = base64Decode(variation.getKey()); - EppoValue decodedValue = EppoValue.nullValue(); - if (!variation.getValue().isNull()) { - String stringValue = base64Decode(variation.getValue().stringValue()); - switch (flag.getVariationType()) { - case BOOLEAN: - decodedValue = EppoValue.valueOf("true".equals(stringValue)); - break; - case INTEGER: - case NUMERIC: - decodedValue = EppoValue.valueOf(Double.parseDouble(stringValue)); - break; - case STRING: - case JSON: - decodedValue = EppoValue.valueOf(stringValue); - break; - default: - throw new UnsupportedOperationException( - "Unexpected variation type for decoding obfuscated variation: " - + flag.getVariationType()); + @NotNull final String key = notNullBase64Decode(variation.getKey()); + @NotNull final EppoValue decodedValue; + if (variation.getValue().isNull()) { + decodedValue = EppoValue.nullValue(); + } else { + @NotNull final String stringValue = notNullBase64Decode(variation.getValue().stringValue()); + @Nullable final VariationType variationType = flag.getVariationType(); + if (variationType == null) { + throw new UnsupportedOperationException( + "Unexpected variation type for decoding obfuscated variation: " + + variationType); + } else { + switch (variationType) { + case BOOLEAN: + decodedValue = EppoValue.valueOf("true".equals(stringValue)); + break; + case INTEGER: + case NUMERIC: + decodedValue = EppoValue.valueOf(Double.parseDouble(stringValue)); + break; + case STRING: + case JSON: + decodedValue = EppoValue.valueOf(stringValue); + break; + default: + throw new UnsupportedOperationException( + "Unexpected variation type for decoding obfuscated variation: " + + variationType); + } } } variation = new Variation(key, decodedValue); @@ -120,11 +143,11 @@ public static FlagEvaluationResult evaluateFlag( // Deobfuscate extraLogging if present if (extraLogging != null && !extraLogging.isEmpty()) { - Map deobfuscatedExtraLogging = new HashMap<>(); - for (Map.Entry entry : extraLogging.entrySet()) { + @NotNull final Map deobfuscatedExtraLogging = new HashMap<>(); + for (@NotNull final Map.Entry entry : extraLogging.entrySet()) { try { - String deobfuscatedKey = base64Decode(entry.getKey()); - String deobfuscatedValue = base64Decode(entry.getValue()); + @Nullable final String deobfuscatedKey = base64Decode(entry.getKey()); + @Nullable final String deobfuscatedValue = base64Decode(entry.getValue()); deobfuscatedExtraLogging.put(deobfuscatedKey, deobfuscatedValue); } catch (Exception e) { // If deobfuscation fails, keep the original key-value pair @@ -135,18 +158,26 @@ public static FlagEvaluationResult evaluateFlag( } } + @Nullable final FlagEvaluationAllocationKeyAndVariation allocationKeyAndVariation; + if (allocationKey != null && variation != null) { + // if allocationKey != null then variation is also != null + allocationKeyAndVariation = new FlagEvaluationAllocationKeyAndVariation(allocationKey, variation); + } else { + allocationKeyAndVariation = null; + } + return new FlagEvaluationResult( - flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + flag, flagKey, subjectKey, subjectAttributes, allocationKeyAndVariation, extraLogging, doLog); } private static boolean allShardsMatch( - Split split, String subjectKey, int totalShards, boolean isObfuscated) { + @NotNull Split split, @NotNull String subjectKey, int totalShards, boolean isObfuscated) { if (split.getShards() == null || split.getShards().isEmpty()) { // Default to matching if no explicit shards return true; } - for (Shard shard : split.getShards()) { + for (@NotNull final Shard shard : split.getShards()) { if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { return false; } @@ -157,14 +188,16 @@ private static boolean allShardsMatch( } private static boolean matchesShard( - Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { - String salt = shard.getSalt(); + @NotNull Shard shard, @NotNull String subjectKey, int totalShards, boolean isObfuscated) { + @NotNull final String salt; if (isObfuscated) { - salt = base64Decode(salt); + salt = notNullBase64Decode(shard.getSalt()); + } else { + salt = shard.getSalt(); } - String hashKey = salt + "-" + subjectKey; - int assignedShard = getShard(hashKey, totalShards); - for (ShardRange range : shard.getRanges()) { + @NotNull final String hashKey = salt + "-" + subjectKey; + final int assignedShard = getShard(hashKey, totalShards); + for (@NotNull final ShardRange range : shard.getRanges()) { if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { return true; } diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 3b151881..d12c4f52 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -9,18 +9,21 @@ import java.util.Base64; import java.util.Date; import java.util.Locale; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public final class Utils { - private static final ThreadLocal UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); - private static final Logger log = LoggerFactory.getLogger(Utils.class); - private static final ThreadLocal md = buildMd5MessageDigest(); + @NotNull private static final ThreadLocal UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); + @NotNull private static final Logger log = LoggerFactory.getLogger(Utils.class); + @NotNull private static final ThreadLocal md = buildMd5MessageDigest(); - @SuppressWarnings("AnonymousHasLambdaAlternative") + @NotNull @SuppressWarnings("AnonymousHasLambdaAlternative") private static ThreadLocal buildMd5MessageDigest() { return new ThreadLocal() { - @Override + @Override @NotNull protected MessageDigest initialValue() { try { return MessageDigest.getInstance("MD5"); @@ -31,14 +34,14 @@ protected MessageDigest initialValue() { }; } - @SuppressWarnings("AnonymousHasLambdaAlternative") + @NotNull @SuppressWarnings("AnonymousHasLambdaAlternative") private static ThreadLocal buildUtcIsoDateFormat() { return new ThreadLocal() { - @Override + @Override @NotNull protected SimpleDateFormat initialValue() { // Note: we don't use DateTimeFormatter.ISO_DATE so that this supports older Android // versions - SimpleDateFormat dateFormat = + @NotNull final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); return dateFormat; @@ -46,10 +49,22 @@ protected SimpleDateFormat initialValue() { }; } - public static void throwIfEmptyOrNull(String input, String errorMessage) { + @NotNull + public static String throwIfEmptyOrNull(@Nullable String input, @NotNull String errorMessage) { if (input == null || input.isEmpty()) { throw new IllegalArgumentException(errorMessage); } + + return input; + } + + @NotNull + public static R throwIfNull(@Nullable R input, @NotNull String errorMessage) { + if (input == null) { + throw new IllegalArgumentException(errorMessage); + } + + return input; } /** @@ -77,10 +92,10 @@ public static String getMD5Hex(String input) { * (inclusive) and a max value (exclusive) This is useful for randomly bucketing subjects or * shuffling bandit actions */ - public static int getShard(String input, int maxShardValue) { + public static int getShard(@NotNull String input, int maxShardValue) { // md5 the input md.get().reset(); - byte[] md5Bytes = md.get().digest(input.getBytes()); + @NotNull final byte[] md5Bytes = md.get().digest(input.getBytes()); // Extract the first 4 bytes (8 digits) and convert to a long long value = 0; @@ -92,12 +107,13 @@ public static int getShard(String input, int maxShardValue) { return (int) (value % maxShardValue); } - public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { + @Nullable + public static Date parseUtcISODateNode(@Nullable JsonNode isoDateStringElement) { if (isoDateStringElement == null || isoDateStringElement.isNull()) { return null; } - String isoDateString = isoDateStringElement.asText(); - Date result = null; + @NotNull final String isoDateString = isoDateStringElement.asText(); + @Nullable Date result = null; try { result = UTC_ISO_DATE_FORMAT.get().parse(isoDateString); } catch (ParseException e) { @@ -107,7 +123,7 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { if (result == null) { // Date may be encoded - String decodedIsoDateString = base64Decode(isoDateString); + @NotNull final String decodedIsoDateString = notNullBase64Decode(isoDateString); try { result = UTC_ISO_DATE_FORMAT.get().parse(decodedIsoDateString); } catch (ParseException e) { @@ -118,21 +134,34 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { return result; } - public static String getISODate(Date date) { + @NotNull + public static String getISODate(@NotNull Date date) { return UTC_ISO_DATE_FORMAT.get().format(date); } - public static String base64Encode(String input) { + @Nullable + public static String base64Encode(@Nullable String input) { if (input == null) { return null; } + return notNullBase64Encode(input); + } + + @NotNull + public static String notNullBase64Encode(@NotNull String input) { return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); } - public static String base64Decode(String input) { + @Nullable + public static String base64Decode(@Nullable String input) { if (input == null) { return null; } + return notNullBase64Decode(input); + } + + @NotNull + public static String notNullBase64Decode(@NotNull String input) { byte[] decodedBytes = Base64.getDecoder().decode(input); if (decodedBytes.length == 0 && !input.isEmpty()) { throw new RuntimeException( diff --git a/src/main/java/cloud/eppo/ValuedFlagEvaluationResult.java b/src/main/java/cloud/eppo/ValuedFlagEvaluationResult.java new file mode 100644 index 00000000..0544b943 --- /dev/null +++ b/src/main/java/cloud/eppo/ValuedFlagEvaluationResult.java @@ -0,0 +1,73 @@ +package cloud.eppo; + +import static cloud.eppo.Utils.throwIfNull; +import static cloud.eppo.ValuedFlagEvaluationResultType.OK; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +import cloud.eppo.api.EppoValue; + +/** + * The result of BaseEppoClient.getTypedAssignmentResult(). + * If BaseEppoClient can't call FlagEvaluator.evaluateFlag(), + * then evaluationResult will be null. + */ +public class ValuedFlagEvaluationResult { + @NotNull private final EppoValue value; + @Nullable private final FlagEvaluationResult evaluationResult; + @NotNull private final ValuedFlagEvaluationResultType type; + + public ValuedFlagEvaluationResult( + @NotNull EppoValue value, + @Nullable FlagEvaluationResult evaluationResult, + @NotNull ValuedFlagEvaluationResultType type) { + throwIfNull(value, "value must not be null"); + throwIfNull(type, "type must not be null"); + if (type == OK) { + throwIfNull(evaluationResult, "evaluationResult must not be null if type is " + OK); + } + + this.value = value; + this.evaluationResult = evaluationResult; + this.type = type; + } + + @Override + public boolean equals(@Nullable Object o) { + if (o == null || getClass() != o.getClass()) return false; + ValuedFlagEvaluationResult that = (ValuedFlagEvaluationResult) o; + return Objects.equals(value, that.value) && Objects.equals(evaluationResult, that.evaluationResult) && type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(value, evaluationResult, type); + } + + @Override + public String toString() { + return "ValuedFlagEvaluationResult{" + + "value=" + value + + ", evaluationResult=" + evaluationResult + + ", type=" + type + + '}'; + } + + @NotNull + public EppoValue getValue() { + return value; + } + + @Nullable + public FlagEvaluationResult getEvaluationResult() { + return evaluationResult; + } + + @NotNull + public ValuedFlagEvaluationResultType getType() { + return type; + } +} diff --git a/src/main/java/cloud/eppo/ValuedFlagEvaluationResultType.java b/src/main/java/cloud/eppo/ValuedFlagEvaluationResultType.java new file mode 100644 index 00000000..4851477c --- /dev/null +++ b/src/main/java/cloud/eppo/ValuedFlagEvaluationResultType.java @@ -0,0 +1,14 @@ +package cloud.eppo; + +/** + * Possible reasons for a default value from a call to BaseEppoClient.getTypedAssignmentResult() + */ +public enum ValuedFlagEvaluationResultType { + NO_FLAG_CONFIG, + FLAG_DISABLED, + BAD_VARIATION_TYPE, + BAD_VALUE_TYPE, + NO_ALLOCATION, + OK, + ; +} diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index aee8bae5..f3cb3d37 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -1,123 +1,184 @@ package cloud.eppo.api; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import cloud.eppo.ufc.dto.EppoValueType; -import java.util.Iterator; import java.util.List; import java.util.Objects; public class EppoValue { - protected final EppoValueType type; - protected Boolean boolValue; - protected Double doubleValue; - protected String stringValue; - protected List stringArrayValue; + @NotNull protected final EppoValueType type; + @Nullable protected final Boolean boolValue; + @Nullable protected final Double doubleValue; + @Nullable protected final String stringValue; + @Nullable protected final List stringArrayValue; protected EppoValue() { - this.type = EppoValueType.NULL; + this( + EppoValueType.NULL, + null, + null, + null, + null + ); } protected EppoValue(boolean boolValue) { - this.boolValue = boolValue; - this.type = EppoValueType.BOOLEAN; + this( + EppoValueType.BOOLEAN, + boolValue, + null, + null, + null + ); } protected EppoValue(double doubleValue) { + this( + EppoValueType.NUMBER, + null, + doubleValue, + null, + null + ); + } + + protected EppoValue(@NotNull String stringValue) { + this( + EppoValueType.STRING, + null, + null, + throwIfNull(stringValue, "stringValue must not be null"), + null + ); + } + + protected EppoValue(@NotNull List stringArrayValue) { + this( + EppoValueType.ARRAY_OF_STRING, + null, + null, + null, + throwIfNull(stringArrayValue, "stringArrayValue must not be null") + ); + } + + private EppoValue( + @NotNull EppoValueType type, + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + throwIfNull(type, "type must not be null"); + + this.type = type; + this.boolValue = boolValue; this.doubleValue = doubleValue; - this.type = EppoValueType.NUMBER; - } - - protected EppoValue(String stringValue) { this.stringValue = stringValue; - this.type = EppoValueType.STRING; - } - - protected EppoValue(List stringArrayValue) { this.stringArrayValue = stringArrayValue; - this.type = EppoValueType.ARRAY_OF_STRING; } + @NotNull public static EppoValue nullValue() { return new EppoValue(); } + @NotNull public static EppoValue valueOf(boolean boolValue) { return new EppoValue(boolValue); } + @NotNull public static EppoValue valueOf(double doubleValue) { return new EppoValue(doubleValue); } - public static EppoValue valueOf(String stringValue) { + @NotNull + public static EppoValue valueOf(@NotNull String stringValue) { return new EppoValue(stringValue); } - public static EppoValue valueOf(List value) { + @NotNull + public static EppoValue valueOf(@NotNull List value) { return new EppoValue(value); } public boolean booleanValue() { - return this.boolValue; + @Nullable final Boolean boolValue = this.boolValue; + if (boolValue == null) { + throw new NullPointerException("boolValue is null for type: " + type); + } + return boolValue; + } + + public int intValue() { + @Nullable final Double doubleValue = this.doubleValue; + if (doubleValue == null) { + throw new NullPointerException("doubleValue is null for type: " + type); + } + return doubleValue.intValue(); } public double doubleValue() { - return this.doubleValue; + @Nullable final Double doubleValue = this.doubleValue; + if (doubleValue == null) { + throw new NullPointerException("doubleValue is null for type: " + type); + } + return doubleValue; } + @NotNull public String stringValue() { - return this.stringValue; + @Nullable final String stringValue = this.stringValue; + if (stringValue == null) { + throw new NullPointerException("stringValue is null for type: " + type); + } + return stringValue; } public List stringArrayValue() { - return this.stringArrayValue; + @Nullable final List stringArrayValue = this.stringArrayValue; + if (stringArrayValue == null) { + throw new NullPointerException("stringArrayValue is null for type: " + type); + } + return stringArrayValue; } public boolean isNull() { - return type == EppoValueType.NULL; + return type.isNull(); } public boolean isBoolean() { - return this.type == EppoValueType.BOOLEAN; + return this.type.isBoolean(); } public boolean isNumeric() { - return this.type == EppoValueType.NUMBER; + return this.type.isNumeric(); } public boolean isString() { - return this.type == EppoValueType.STRING; + return this.type.isString(); } public boolean isStringArray() { - return type == EppoValueType.ARRAY_OF_STRING; + return type.isStringArray(); } + @NotNull public EppoValueType getType() { return type; } - @Override + @Override @NotNull public String toString() { - switch (this.type) { - case BOOLEAN: - return this.boolValue.toString(); - case NUMBER: - return this.doubleValue.toString(); - case STRING: - return this.stringValue; - case ARRAY_OF_STRING: - // Android21 back-compatability - return joinStringArray(this.stringArrayValue); - case NULL: - return ""; - default: - throw new UnsupportedOperationException( - "Cannot stringify Eppo Value type " + this.type.name()); - } + return type.toString(boolValue, doubleValue, stringValue, stringArrayValue); } @Override - public boolean equals(Object otherObject) { + public boolean equals(@Nullable Object otherObject) { if (this == otherObject) { return true; } @@ -136,21 +197,4 @@ public boolean equals(Object otherObject) { public int hashCode() { return Objects.hash(type, boolValue, doubleValue, stringValue, stringArrayValue); } - - /** This method is to allow for Android 21 support; String.join was introduced in API 26 */ - private static String joinStringArray(List stringArray) { - if (stringArray == null || stringArray.isEmpty()) { - return ""; - } - String delimiter = ", "; - StringBuilder stringBuilder = new StringBuilder(); - Iterator iterator = stringArray.iterator(); - while (iterator.hasNext()) { - stringBuilder.append(iterator.next()); - if (iterator.hasNext()) { - stringBuilder.append(delimiter); - } - } - return stringBuilder.toString(); - } } diff --git a/src/main/java/cloud/eppo/cache/AssignmentCacheEntry.java b/src/main/java/cloud/eppo/cache/AssignmentCacheEntry.java index e4522470..8936f54c 100644 --- a/src/main/java/cloud/eppo/cache/AssignmentCacheEntry.java +++ b/src/main/java/cloud/eppo/cache/AssignmentCacheEntry.java @@ -1,34 +1,42 @@ package cloud.eppo.cache; +import static cloud.eppo.Utils.throwIfNull; + import cloud.eppo.logging.Assignment; import cloud.eppo.logging.BanditAssignment; import java.util.Objects; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public class AssignmentCacheEntry { - private final AssignmentCacheKey key; - private final AssignmentCacheValue value; + @NotNull private final AssignmentCacheKey key; + @NotNull private final AssignmentCacheValue value; public AssignmentCacheEntry( @NotNull AssignmentCacheKey key, @NotNull AssignmentCacheValue value) { + throwIfNull(key, "key must not be null"); + throwIfNull(value, "value must not be null"); + this.key = key; this.value = value; } - public static AssignmentCacheEntry fromVariationAssignment(Assignment assignment) { + @NotNull + public static AssignmentCacheEntry fromVariationAssignment(@NotNull Assignment assignment) { return new AssignmentCacheEntry( new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()), new VariationCacheValue(assignment.getAllocation(), assignment.getVariation())); } - public static AssignmentCacheEntry fromBanditAssignment(BanditAssignment assignment) { + @NotNull + public static AssignmentCacheEntry fromBanditAssignment(@NotNull BanditAssignment assignment) { return new AssignmentCacheEntry( new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()), new BanditCacheValue(assignment.getBandit(), assignment.getAction())); } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; AssignmentCacheEntry that = (AssignmentCacheEntry) o; return Objects.equals(key, that.key) @@ -40,7 +48,7 @@ public int hashCode() { return Objects.hash(key, value); } - @Override + @Override @NotNull public String toString() { return "AssignmentCacheEntry{" + "key=" + key + diff --git a/src/main/java/cloud/eppo/ufc/dto/Allocation.java b/src/main/java/cloud/eppo/ufc/dto/Allocation.java index 075dba4b..004d7366 100644 --- a/src/main/java/cloud/eppo/ufc/dto/Allocation.java +++ b/src/main/java/cloud/eppo/ufc/dto/Allocation.java @@ -1,25 +1,35 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfEmptyOrNull; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.Date; import java.util.List; import java.util.Objects; import java.util.Set; public class Allocation { - private String key; - private Set rules; - private Date startAt; - private Date endAt; - private List splits; + @NotNull private String key; + @NotNull private Set rules; + @Nullable private Date startAt; + @Nullable private Date endAt; + @NotNull private List splits; private boolean doLog; public Allocation( - String key, - Set rules, - Date startAt, - Date endAt, - List splits, + @NotNull String key, + @NotNull Set rules, + @Nullable Date startAt, + @Nullable Date endAt, + @NotNull List splits, boolean doLog) { + throwIfNull(key, "key must not be null"); + throwIfNull(rules, "rules must not be null"); + throwIfNull(splits, "splits must not be null"); + this.key = key; this.rules = rules; this.startAt = startAt; @@ -28,7 +38,7 @@ public Allocation( this.doLog = doLog; } - @Override + @Override @NotNull public String toString() { return "Allocation{" + "key='" + key + '\'' + @@ -41,7 +51,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; Allocation that = (Allocation) o; return doLog == that.doLog @@ -57,43 +67,54 @@ public int hashCode() { return Objects.hash(key, rules, startAt, endAt, splits, doLog); } + @NotNull public String getKey() { return key; } - public void setKey(String key) { + public void setKey(@NotNull String key) { + throwIfEmptyOrNull(key, "key must not be null"); + this.key = key; } + @NotNull public Set getRules() { return rules; } - public void setRules(Set rules) { + public void setRules(@NotNull Set rules) { + throwIfNull(rules, "rules must not be null"); + this.rules = rules; } + @Nullable public Date getStartAt() { return startAt; } - public void setStartAt(Date startAt) { + public void setStartAt(@Nullable Date startAt) { this.startAt = startAt; } + @Nullable public Date getEndAt() { return endAt; } - public void setEndAt(Date endAt) { + public void setEndAt(@Nullable Date endAt) { this.endAt = endAt; } + @NotNull public List getSplits() { return splits; } - public void setSplits(List splits) { + public void setSplits(@NotNull List splits) { + throwIfNull(splits, "splits must not be null"); + this.splits = splits; } diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java b/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java index 8298233c..a274d410 100644 --- a/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java @@ -1,20 +1,31 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.Objects; public class BanditFlagVariation { - private final String banditKey; - private final String flagKey; - private final String allocationKey; - private final String variationKey; - private final String variationValue; + @NotNull private final String banditKey; + @NotNull private final String flagKey; + @NotNull private final String allocationKey; + @NotNull private final String variationKey; + @NotNull private final String variationValue; public BanditFlagVariation( - String banditKey, - String flagKey, - String allocationKey, - String variationKey, - String variationValue) { + @NotNull String banditKey, + @NotNull String flagKey, + @NotNull String allocationKey, + @NotNull String variationKey, + @NotNull String variationValue) { + throwIfNull(banditKey, "banditKey must not be null"); + throwIfNull(flagKey, "flagKey must not be null"); + throwIfNull(allocationKey, "allocationKey must not be null"); + throwIfNull(variationKey, "variationKey must not be null"); + throwIfNull(variationValue, "variationValue must not be null"); + this.banditKey = banditKey; this.flagKey = flagKey; this.allocationKey = allocationKey; @@ -22,7 +33,7 @@ public BanditFlagVariation( this.variationValue = variationValue; } - @Override + @Override @NotNull public String toString() { return "BanditFlagVariation{" + "banditKey='" + banditKey + '\'' + @@ -34,7 +45,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; BanditFlagVariation that = (BanditFlagVariation) o; return Objects.equals(banditKey, that.banditKey) @@ -49,22 +60,27 @@ public int hashCode() { return Objects.hash(banditKey, flagKey, allocationKey, variationKey, variationValue); } + @NotNull public String getBanditKey() { return banditKey; } + @NotNull public String getFlagKey() { return flagKey; } + @NotNull public String getAllocationKey() { return allocationKey; } + @NotNull public String getVariationKey() { return variationKey; } + @NotNull public String getVariationValue() { return variationValue; } diff --git a/src/main/java/cloud/eppo/ufc/dto/EppoValueType.java b/src/main/java/cloud/eppo/ufc/dto/EppoValueType.java index 83ea62b5..5defcbd5 100644 --- a/src/main/java/cloud/eppo/ufc/dto/EppoValueType.java +++ b/src/main/java/cloud/eppo/ufc/dto/EppoValueType.java @@ -1,9 +1,198 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Iterator; +import java.util.List; + public enum EppoValueType { - NULL, - BOOLEAN, - NUMBER, - STRING, - ARRAY_OF_STRING, + NULL { + public boolean isNull() { + return true; + } + + public boolean isBoolean() { + return false; + } + + public boolean isNumeric() { + return false; + } + + public boolean isString() { + return false; + } + + public boolean isStringArray() { + return false; + } + + @Override @NotNull + public String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + return ""; + } + }, + BOOLEAN { + public boolean isNull() { + return false; + } + + public boolean isBoolean() { + return true; + } + + public boolean isNumeric() { + return false; + } + + public boolean isString() { + return false; + } + + public boolean isStringArray() { + return false; + } + + @Override @NotNull + public String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + throwIfNull(boolValue, "boolValue must not be null"); + return boolValue.toString(); + } + }, + NUMBER { + public boolean isNull() { + return false; + } + + public boolean isBoolean() { + return false; + } + + public boolean isNumeric() { + return true; + } + + public boolean isString() { + return false; + } + + public boolean isStringArray() { + return false; + } + + @Override @NotNull + public String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + throwIfNull(doubleValue, "doubleValue must not be null"); + return doubleValue.toString(); + } + }, + STRING { + public boolean isNull() { + return false; + } + + public boolean isBoolean() { + return false; + } + + public boolean isNumeric() { + return false; + } + + public boolean isString() { + return true; + } + + public boolean isStringArray() { + return false; + } + + @Override @NotNull + public String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + throwIfNull(stringValue, "stringValue must not be null"); + return stringValue; + } + }, + ARRAY_OF_STRING { + public boolean isNull() { + return false; + } + + public boolean isBoolean() { + return false; + } + + public boolean isNumeric() { + return false; + } + + public boolean isString() { + return false; + } + + public boolean isStringArray() { + return true; + } + + @Override @NotNull + public String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue) { + throwIfNull(stringArrayValue, "stringArray must not be null"); + return EppoValueType.joinStringArray(stringArrayValue); + } + }, + ; + + public abstract boolean isNull(); + public abstract boolean isBoolean(); + public abstract boolean isNumeric(); + public abstract boolean isString(); + public abstract boolean isStringArray(); + + @NotNull + public abstract String toString( + @Nullable Boolean boolValue, + @Nullable Double doubleValue, + @Nullable String stringValue, + @Nullable List stringArrayValue); + + /** This method is to allow for Android 21 support; String.join was introduced in API 26 */ + @NotNull + private static String joinStringArray(@NotNull List stringArray) { + if (stringArray.isEmpty()) { + return ""; + } + String delimiter = ", "; + StringBuilder stringBuilder = new StringBuilder(); + Iterator iterator = stringArray.iterator(); + while (iterator.hasNext()) { + stringBuilder.append(iterator.next()); + if (iterator.hasNext()) { + stringBuilder.append(delimiter); + } + } + return stringBuilder.toString(); + } } diff --git a/src/main/java/cloud/eppo/ufc/dto/FlagConfig.java b/src/main/java/cloud/eppo/ufc/dto/FlagConfig.java index 521a5098..4d515074 100644 --- a/src/main/java/cloud/eppo/ufc/dto/FlagConfig.java +++ b/src/main/java/cloud/eppo/ufc/dto/FlagConfig.java @@ -1,33 +1,46 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.List; import java.util.Map; import java.util.Objects; public class FlagConfig { - private final String key; + @NotNull private final String key; private final boolean enabled; private final int totalShards; - private final VariationType variationType; - private final Map variations; - private final List allocations; + @Nullable private final VariationType variationType; + @NotNull private final Map variations; + @NotNull private final List sortedVariationKeys; + @NotNull private final List allocations; public FlagConfig( - String key, + @NotNull String key, boolean enabled, int totalShards, - VariationType variationType, - Map variations, - List allocations) { + @Nullable VariationType variationType, + @NotNull Map variations, + @NotNull List sortedVariationKeys, + @NotNull List allocations) { + throwIfNull(key, "key must not be null"); + throwIfNull(variations, "variations must not be null"); + throwIfNull(sortedVariationKeys, "sortedVariationKeys must not be null"); + throwIfNull(allocations, "allocations must not be null"); + this.key = key; this.enabled = enabled; this.totalShards = totalShards; this.variationType = variationType; this.variations = variations; + this.sortedVariationKeys = sortedVariationKeys; this.allocations = allocations; } - @Override + @Override @NotNull public String toString() { return "FlagConfig{" + "key='" + key + '\'' + @@ -35,12 +48,13 @@ public String toString() { ", totalShards=" + totalShards + ", variationType=" + variationType + ", variations=" + variations + + ", sortedVariationKeys=" + sortedVariationKeys + ", allocations=" + allocations + '}'; } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; FlagConfig that = (FlagConfig) o; return enabled == that.enabled @@ -48,14 +62,16 @@ public boolean equals(Object o) { && Objects.equals(key, that.key) && variationType == that.variationType && Objects.equals(variations, that.variations) + && Objects.equals(sortedVariationKeys, that.sortedVariationKeys) && Objects.equals(allocations, that.allocations); } @Override public int hashCode() { - return Objects.hash(key, enabled, totalShards, variationType, variations, allocations); + return Objects.hash(key, enabled, totalShards, variationType, variations, sortedVariationKeys, allocations); } + @NotNull public String getKey() { return this.key; } @@ -68,14 +84,22 @@ public boolean isEnabled() { return enabled; } + @Nullable public VariationType getVariationType() { return variationType; } + @NotNull public Map getVariations() { return variations; } + @NotNull + public List getSortedVariationKeys() { + return sortedVariationKeys; + } + + @NotNull public List getAllocations() { return allocations; } diff --git a/src/main/java/cloud/eppo/ufc/dto/OperatorType.java b/src/main/java/cloud/eppo/ufc/dto/OperatorType.java index 8a80a624..e5bbcc5c 100644 --- a/src/main/java/cloud/eppo/ufc/dto/OperatorType.java +++ b/src/main/java/cloud/eppo/ufc/dto/OperatorType.java @@ -1,6 +1,10 @@ package cloud.eppo.ufc.dto; import static cloud.eppo.Utils.getMD5Hex; +import static cloud.eppo.Utils.throwIfEmptyOrNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.HashMap; import java.util.Map; @@ -16,36 +20,41 @@ public enum OperatorType { NOT_ONE_OF("NOT_ONE_OF"), IS_NULL("IS_NULL"); - public final String value; - private static final Map valuesToOperatorType = + @NotNull public final String value; + @NotNull private static final Map valuesToOperatorType = buildValueToOperatorTypeMap(); - private static final Map hashesToOperatorType = + @NotNull private static final Map hashesToOperatorType = buildHashToOperatorTypeMap(); + @NotNull private static Map buildValueToOperatorTypeMap() { - Map result = new HashMap<>(); - for (OperatorType type : OperatorType.values()) { + @NotNull final Map result = new HashMap<>(); + for (@NotNull final OperatorType type : OperatorType.values()) { result.put(type.value, type); } return result; } + @NotNull private static Map buildHashToOperatorTypeMap() { - Map result = new HashMap<>(); - for (OperatorType type : OperatorType.values()) { + @NotNull final Map result = new HashMap<>(); + for (@NotNull final OperatorType type : OperatorType.values()) { result.put(getMD5Hex(type.value), type); } return result; } - OperatorType(String value) { + OperatorType(@NotNull String value) { + throwIfEmptyOrNull(value, "value must not be null"); + this.value = value; } + @Nullable public static OperatorType fromString(String value) { // First we try obfuscated lookup as in client situations we'll care more about ingestion // performance - OperatorType type = hashesToOperatorType.get(value); + @Nullable OperatorType type = hashesToOperatorType.get(value); // Then we'll try non-obfuscated lookup if (type == null) { type = valuesToOperatorType.get(value); diff --git a/src/main/java/cloud/eppo/ufc/dto/Shard.java b/src/main/java/cloud/eppo/ufc/dto/Shard.java index 0dba161c..2b72dff6 100644 --- a/src/main/java/cloud/eppo/ufc/dto/Shard.java +++ b/src/main/java/cloud/eppo/ufc/dto/Shard.java @@ -1,20 +1,28 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import cloud.eppo.model.ShardRange; import java.util.Objects; import java.util.Set; public class Shard { - private final String salt; - private final Set ranges; + @NotNull private final String salt; + @NotNull private final Set ranges; + + public Shard(@NotNull String salt, @NotNull Set ranges) { + throwIfNull(salt, "salt must not be null"); + throwIfNull(ranges, "ranges must not be null"); - public Shard(String salt, Set ranges) { this.salt = salt; this.ranges = ranges; } - @Override + @Override @NotNull public String toString() { return "Shard{" + "salt='" + salt + '\'' + @@ -23,7 +31,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; Shard shard = (Shard) o; return Objects.equals(salt, shard.salt) @@ -35,10 +43,12 @@ public int hashCode() { return Objects.hash(salt, ranges); } + @NotNull public String getSalt() { return salt; } + @NotNull public Set getRanges() { return ranges; } diff --git a/src/main/java/cloud/eppo/ufc/dto/Split.java b/src/main/java/cloud/eppo/ufc/dto/Split.java index 1dca9ed9..611853d4 100644 --- a/src/main/java/cloud/eppo/ufc/dto/Split.java +++ b/src/main/java/cloud/eppo/ufc/dto/Split.java @@ -1,21 +1,33 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.Map; import java.util.Objects; import java.util.Set; public class Split { - private final String variationKey; - private final Set shards; - private final Map extraLogging; + @NotNull private final String variationKey; + @NotNull private final Set shards; + @NotNull private final Map extraLogging; + + public Split( + @NotNull String variationKey, + @NotNull Set shards, + @NotNull Map extraLogging) { + throwIfNull(variationKey, "variationKey must not be null"); + throwIfNull(shards, "shards must not be null"); + throwIfNull(extraLogging, "extraLogging must not be null"); - public Split(String variationKey, Set shards, Map extraLogging) { this.variationKey = variationKey; this.shards = shards; this.extraLogging = extraLogging; } - @Override + @Override @NotNull public String toString() { return "Split{" + "variationKey='" + variationKey + '\'' + @@ -25,7 +37,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; Split split = (Split) o; return Objects.equals(variationKey, split.variationKey) @@ -38,14 +50,17 @@ public int hashCode() { return Objects.hash(variationKey, shards, extraLogging); } + @NotNull public String getVariationKey() { return variationKey; } + @NotNull public Set getShards() { return shards; } + @NotNull public Map getExtraLogging() { return extraLogging; } diff --git a/src/main/java/cloud/eppo/ufc/dto/TargetingCondition.java b/src/main/java/cloud/eppo/ufc/dto/TargetingCondition.java index bc84751d..f5be02c1 100644 --- a/src/main/java/cloud/eppo/ufc/dto/TargetingCondition.java +++ b/src/main/java/cloud/eppo/ufc/dto/TargetingCondition.java @@ -1,21 +1,33 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.Objects; import cloud.eppo.api.EppoValue; public class TargetingCondition { - private final OperatorType operator; - private final String attribute; - private final EppoValue value; + @NotNull private final OperatorType operator; + @NotNull private final String attribute; + @NotNull private final EppoValue value; + + public TargetingCondition( + @NotNull OperatorType operator, + @NotNull String attribute, + @NotNull EppoValue value) { + throwIfNull(operator, "operator must not be null"); + throwIfNull(attribute, "attribute must not be null"); + throwIfNull(value, "value must not be null"); - public TargetingCondition(OperatorType operator, String attribute, EppoValue value) { this.operator = operator; this.attribute = attribute; this.value = value; } - @Override + @Override @NotNull public String toString() { return "TargetingCondition{" + "operator=" + operator + @@ -25,7 +37,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; TargetingCondition that = (TargetingCondition) o; return operator == that.operator @@ -38,14 +50,17 @@ public int hashCode() { return Objects.hash(operator, attribute, value); } + @NotNull public OperatorType getOperator() { return operator; } + @NotNull public String getAttribute() { return attribute; } + @NotNull public EppoValue getValue() { return value; } diff --git a/src/main/java/cloud/eppo/ufc/dto/Variation.java b/src/main/java/cloud/eppo/ufc/dto/Variation.java index 948f62af..7aeb9724 100644 --- a/src/main/java/cloud/eppo/ufc/dto/Variation.java +++ b/src/main/java/cloud/eppo/ufc/dto/Variation.java @@ -1,19 +1,27 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.Objects; import cloud.eppo.api.EppoValue; public class Variation { - private final String key; - private final EppoValue value; + @NotNull private final String key; + @NotNull private final EppoValue value; + + public Variation(@NotNull String key, @NotNull EppoValue value) { + throwIfNull(key, "key must not be null"); + throwIfNull(value, "value must not be null"); - public Variation(String key, EppoValue value) { this.key = key; this.value = value; } - @Override + @Override @NotNull public String toString() { return "Variation{" + "key='" + key + '\'' + @@ -22,7 +30,7 @@ public String toString() { } @Override - public boolean equals(Object o) { + public boolean equals(@Nullable Object o) { if (o == null || getClass() != o.getClass()) return false; Variation variation = (Variation) o; return Objects.equals(key, variation.key) @@ -34,10 +42,12 @@ public int hashCode() { return Objects.hash(key, value); } + @NotNull public String getKey() { return this.key; } + @NotNull public EppoValue getValue() { return value; } diff --git a/src/main/java/cloud/eppo/ufc/dto/VariationType.java b/src/main/java/cloud/eppo/ufc/dto/VariationType.java index 49e00b19..6742beaf 100644 --- a/src/main/java/cloud/eppo/ufc/dto/VariationType.java +++ b/src/main/java/cloud/eppo/ufc/dto/VariationType.java @@ -1,20 +1,28 @@ package cloud.eppo.ufc.dto; +import static cloud.eppo.Utils.throwIfNull; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + public enum VariationType { BOOLEAN("BOOLEAN"), INTEGER("INTEGER"), NUMERIC("NUMERIC"), STRING("STRING"), - JSON("JSON"); + JSON("JSON"), + ; - public final String value; + @NotNull public final String value; - VariationType(String value) { + VariationType(@NotNull String value) { + throwIfNull(value, "value must not be null"); this.value = value; } - public static VariationType fromString(String value) { - for (VariationType type : VariationType.values()) { + @Nullable + public static VariationType fromString(@NotNull String value) { + for (@NotNull final VariationType type : VariationType.values()) { if (type.value.equals(value)) { return type; } diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java index 09ec5b36..72caf5b8 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java @@ -8,13 +8,16 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class EppoValueDeserializer extends StdDeserializer { - private static final Logger log = LoggerFactory.getLogger(EppoValueDeserializer.class); + @NotNull private static final Logger log = LoggerFactory.getLogger(EppoValueDeserializer.class); - protected EppoValueDeserializer(Class vc) { + protected EppoValueDeserializer(@Nullable Class vc) { super(vc); } @@ -27,13 +30,14 @@ public EppoValue deserialize(JsonParser jp, DeserializationContext ctxt) throws return deserializeNode(jp.getCodec().readTree(jp)); } - public EppoValue deserializeNode(JsonNode node) { - EppoValue result; + @NotNull + public EppoValue deserializeNode(@Nullable JsonNode node) { + @NotNull final EppoValue result; if (node == null || node.isNull()) { result = EppoValue.nullValue(); } else if (node.isArray()) { - List stringArray = new ArrayList<>(); - for (JsonNode arrayElement : node) { + @NotNull final List stringArray = new ArrayList<>(); + for (@NotNull final JsonNode arrayElement : node) { if (arrayElement.isValueNode() && arrayElement.isTextual()) { stringArray.add(arrayElement.asText()); } else { diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index 7c48a50f..e95314db 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -13,6 +13,9 @@ import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,64 +87,70 @@ public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt return new FlagConfigResponse(flags, banditReferences, dataFormat); } - private FlagConfig deserializeFlag(JsonNode jsonNode) { - String key = jsonNode.get("key").asText(); + @NotNull + private FlagConfig deserializeFlag(@NotNull JsonNode jsonNode) { + @NotNull final String key = jsonNode.get("key").asText(); boolean enabled = jsonNode.get("enabled").asBoolean(); int totalShards = jsonNode.get("totalShards").asInt(); - VariationType variationType = VariationType.fromString(jsonNode.get("variationType").asText()); - Map variations = deserializeVariations(jsonNode.get("variations")); - List allocations = deserializeAllocations(jsonNode.get("allocations")); + @Nullable final VariationType variationType = VariationType.fromString(jsonNode.get("variationType").asText()); + @NotNull final Map variations = deserializeVariations(jsonNode.get("variations")); + @NotNull final List sortedVariationKeys = new ArrayList<>(variations.keySet()); + Collections.sort(sortedVariationKeys); + @NotNull final List allocations = deserializeAllocations(jsonNode.get("allocations")); - return new FlagConfig(key, enabled, totalShards, variationType, variations, allocations); + return new FlagConfig(key, enabled, totalShards, variationType, variations, sortedVariationKeys, allocations); } - private Map deserializeVariations(JsonNode jsonNode) { - Map variations = new HashMap<>(); + @NotNull + private Map deserializeVariations(@Nullable JsonNode jsonNode) { + @NotNull final Map variations = new HashMap<>(); if (jsonNode == null) { return variations; } - for (Iterator> it = jsonNode.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - String key = entry.getValue().get("key").asText(); - EppoValue value = eppoValueDeserializer.deserializeNode(entry.getValue().get("value")); + for (@NotNull final Iterator> it = jsonNode.fields(); it.hasNext(); ) { + @NotNull final Map.Entry entry = it.next(); + @NotNull final String key = entry.getValue().get("key").asText(); + @NotNull final EppoValue value = eppoValueDeserializer.deserializeNode(entry.getValue().get("value")); variations.put(entry.getKey(), new Variation(key, value)); } return variations; } - private List deserializeAllocations(JsonNode jsonNode) { - List allocations = new ArrayList<>(); + @NotNull + private List deserializeAllocations(@Nullable JsonNode jsonNode) { + @NotNull final List allocations = new ArrayList<>(); if (jsonNode == null) { return allocations; } - for (JsonNode allocationNode : jsonNode) { - String key = allocationNode.get("key").asText(); - Set rules = deserializeTargetingRules(allocationNode.get("rules")); - Date startAt = parseUtcISODateNode(allocationNode.get("startAt")); - Date endAt = parseUtcISODateNode(allocationNode.get("endAt")); - List splits = deserializeSplits(allocationNode.get("splits")); + for (@NotNull final JsonNode allocationNode : jsonNode) { + @NotNull final String key = allocationNode.get("key").asText(); + @NotNull final Set rules = deserializeTargetingRules(allocationNode.get("rules")); + @Nullable final Date startAt = parseUtcISODateNode(allocationNode.get("startAt")); + @Nullable final Date endAt = parseUtcISODateNode(allocationNode.get("endAt")); + @NotNull final List splits = deserializeSplits(allocationNode.get("splits")); boolean doLog = allocationNode.get("doLog").asBoolean(); allocations.add(new Allocation(key, rules, startAt, endAt, splits, doLog)); } return allocations; } - private Set deserializeTargetingRules(JsonNode jsonNode) { - Set targetingRules = new HashSet<>(); + @NotNull + private Set deserializeTargetingRules(@Nullable JsonNode jsonNode) { + @NotNull final Set targetingRules = new HashSet<>(); if (jsonNode == null || !jsonNode.isArray()) { return targetingRules; } - for (JsonNode ruleNode : jsonNode) { - Set conditions = new HashSet<>(); - for (JsonNode conditionNode : ruleNode.get("conditions")) { - String attribute = conditionNode.get("attribute").asText(); - String operatorKey = conditionNode.get("operator").asText(); - OperatorType operator = OperatorType.fromString(operatorKey); + for (@NotNull final JsonNode ruleNode : jsonNode) { + @NotNull final Set conditions = new HashSet<>(); + for (@NotNull final JsonNode conditionNode : ruleNode.get("conditions")) { + @NotNull final String attribute = conditionNode.get("attribute").asText(); + @NotNull final String operatorKey = conditionNode.get("operator").asText(); + @Nullable final OperatorType operator = OperatorType.fromString(operatorKey); if (operator == null) { log.warn("Unknown operator \"{}\"", operatorKey); continue; } - EppoValue value = eppoValueDeserializer.deserializeNode(conditionNode.get("value")); + @NotNull final EppoValue value = eppoValueDeserializer.deserializeNode(conditionNode.get("value")); conditions.add(new TargetingCondition(operator, attribute, value)); } targetingRules.add(new TargetingRule(conditions)); @@ -150,19 +159,20 @@ private Set deserializeTargetingRules(JsonNode jsonNode) { return targetingRules; } - private List deserializeSplits(JsonNode jsonNode) { - List splits = new ArrayList<>(); + @NotNull + private List deserializeSplits(@Nullable JsonNode jsonNode) { + @NotNull final List splits = new ArrayList<>(); if (jsonNode == null || !jsonNode.isArray()) { return splits; } - for (JsonNode splitNode : jsonNode) { - String variationKey = splitNode.get("variationKey").asText(); - Set shards = deserializeShards(splitNode.get("shards")); - Map extraLogging = new HashMap<>(); - JsonNode extraLoggingNode = splitNode.get("extraLogging"); + for (@NotNull final JsonNode splitNode : jsonNode) { + @NotNull final String variationKey = splitNode.get("variationKey").asText(); + @NotNull final Set shards = deserializeShards(splitNode.get("shards")); + @NotNull final Map extraLogging = new HashMap<>(); + @Nullable final JsonNode extraLoggingNode = splitNode.get("extraLogging"); if (extraLoggingNode != null && extraLoggingNode.isObject()) { - for (Iterator> it = extraLoggingNode.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); + for (@NotNull final Iterator> it = extraLoggingNode.fields(); it.hasNext(); ) { + @NotNull final Map.Entry entry = it.next(); extraLogging.put(entry.getKey(), entry.getValue().asText()); } } @@ -172,17 +182,18 @@ private List deserializeSplits(JsonNode jsonNode) { return splits; } - private Set deserializeShards(JsonNode jsonNode) { - Set shards = new HashSet<>(); + @NotNull + private Set deserializeShards(@Nullable JsonNode jsonNode) { + @NotNull final Set shards = new HashSet<>(); if (jsonNode == null || !jsonNode.isArray()) { return shards; } - for (JsonNode shardNode : jsonNode) { - String salt = shardNode.get("salt").asText(); - Set ranges = new HashSet<>(); - for (JsonNode rangeNode : shardNode.get("ranges")) { - int start = rangeNode.get("start").asInt(); - int end = rangeNode.get("end").asInt(); + for (@NotNull final JsonNode shardNode : jsonNode) { + @NotNull final String salt = shardNode.get("salt").asText(); + @NotNull final Set ranges = new HashSet<>(); + for (@NotNull final JsonNode rangeNode : shardNode.get("ranges")) { + final int start = rangeNode.get("start").asInt(); + final int end = rangeNode.get("end").asInt(); ranges.add(new ShardRange(start, end)); } shards.add(new Shard(salt, ranges)); @@ -190,18 +201,19 @@ private Set deserializeShards(JsonNode jsonNode) { return shards; } - private BanditReference deserializeBanditReference(JsonNode jsonNode) { - String modelVersion = jsonNode.get("modelVersion").asText(); - List flagVariations = new ArrayList<>(); - JsonNode flagVariationsNode = jsonNode.get("flagVariations"); + @NotNull + private BanditReference deserializeBanditReference(@NotNull JsonNode jsonNode) { + @NotNull final String modelVersion = jsonNode.get("modelVersion").asText(); + @NotNull final List flagVariations = new ArrayList<>(); + @Nullable final JsonNode flagVariationsNode = jsonNode.get("flagVariations"); if (flagVariationsNode != null && flagVariationsNode.isArray()) { - for (JsonNode flagVariationNode : flagVariationsNode) { - String banditKey = flagVariationNode.get("key").asText(); - String flagKey = flagVariationNode.get("flagKey").asText(); - String allocationKey = flagVariationNode.get("allocationKey").asText(); - String variationKey = flagVariationNode.get("variationKey").asText(); - String variationValue = flagVariationNode.get("variationValue").asText(); - BanditFlagVariation flagVariation = + for (@NotNull final JsonNode flagVariationNode : flagVariationsNode) { + @NotNull final String banditKey = flagVariationNode.get("key").asText(); + @NotNull final String flagKey = flagVariationNode.get("flagKey").asText(); + @NotNull final String allocationKey = flagVariationNode.get("allocationKey").asText(); + @NotNull final String variationKey = flagVariationNode.get("variationKey").asText(); + @NotNull final String variationValue = flagVariationNode.get("variationValue").asText(); + @NotNull final BanditFlagVariation flagVariation = new BanditFlagVariation( banditKey, flagKey, allocationKey, variationKey, variationValue); flagVariations.add(flagVariation); diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 4c805440..644e2c17 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -1,5 +1,8 @@ package cloud.eppo; +import static cloud.eppo.ValuedFlagEvaluationResultType.BAD_VARIATION_TYPE; +import static cloud.eppo.ValuedFlagEvaluationResultType.NO_FLAG_CONFIG; +import static cloud.eppo.ValuedFlagEvaluationResultType.OK; import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile; import static cloud.eppo.helpers.AssignmentTestCase.runTestCase; import static cloud.eppo.helpers.TestUtils.mockHttpError; @@ -339,6 +342,110 @@ public void testErrorGracefulModeOff() { "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); } + @Test + public void testResultErrorGracefulModeOn() throws JsonProcessingException { + initClient(true, false); + + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); + doThrow(new RuntimeException("Exception thrown by mock")) + .when(spyClient) + .getTypedAssignmentResult( + anyString(), + anyString(), + any(Attributes.class), + any(EppoValue.class), + any(VariationType.class)); + + assertTrue(spyClient.getBooleanAssignment("experiment1", "subject1", true)); + assertFalse(spyClient.getBooleanAssignment("experiment1", "subject1", new Attributes(), false)); + + assertEquals(10, spyClient.getIntegerAssignment("experiment1", "subject1", 10)); + assertEquals(0, spyClient.getIntegerAssignment("experiment1", "subject1", new Attributes(), 0)); + + assertEquals(1.2345, spyClient.getDoubleAssignment("experiment1", "subject1", 1.2345), 0.0001); + assertEquals( + 0.0, + spyClient.getDoubleAssignment("experiment1", "subject1", new Attributes(), 0.0), + 0.0001); + + assertEquals("default", spyClient.getStringAssignment("experiment1", "subject1", "default")); + assertEquals( + "", spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); + + assertEquals( + mapper.readTree("{\"a\": 1, \"b\": false}").toString(), + spyClient + .getJSONAssignment( + "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}")) + .toString()); + + assertEquals( + "{\"a\": 1, \"b\": false}", + spyClient.getJSONStringAssignment("subject1", "experiment1", "{\"a\": 1, \"b\": false}")); + + assertEquals( + mapper.readTree("{}").toString(), + spyClient + .getJSONAssignment("subject1", "experiment1", new Attributes(), mapper.readTree("{}")) + .toString()); + } + + @Test + public void testResultErrorGracefulModeOff() { + initClient(false, false); + + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); + doThrow(new RuntimeException("Exception thrown by mock")) + .when(spyClient) + .getTypedAssignmentResult( + anyString(), + anyString(), + any(Attributes.class), + any(EppoValue.class), + any(VariationType.class)); + + assertThrows( + RuntimeException.class, + () -> spyClient.getBooleanAssignment("experiment1", "subject1", true)); + assertThrows( + RuntimeException.class, + () -> spyClient.getBooleanAssignment("experiment1", "subject1", new Attributes(), false)); + + assertThrows( + RuntimeException.class, + () -> spyClient.getIntegerAssignment("experiment1", "subject1", 10)); + assertThrows( + RuntimeException.class, + () -> spyClient.getIntegerAssignment("experiment1", "subject1", new Attributes(), 0)); + + assertThrows( + RuntimeException.class, + () -> spyClient.getDoubleAssignment("experiment1", "subject1", 1.2345)); + assertThrows( + RuntimeException.class, + () -> spyClient.getDoubleAssignment("experiment1", "subject1", new Attributes(), 0.0)); + + assertThrows( + RuntimeException.class, + () -> spyClient.getStringAssignment("experiment1", "subject1", "default")); + assertThrows( + RuntimeException.class, + () -> spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); + + assertThrows( + RuntimeException.class, + () -> + spyClient.getJSONAssignment( + "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}"))); + assertThrows( + RuntimeException.class, + () -> + spyClient.getJSONAssignment( + "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); + } + @Test public void testInvalidConfigJSON() { @@ -502,6 +609,46 @@ public void testAssignmentEventCorrectlyCreated() { assertEquals(expectedMeta, capturedAssignment.getMetaData()); } + @Test + public void testAssignmentEventCorrectlyCreatedResult() { + Date testStart = new Date(); + initClient(); + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", EppoValue.valueOf(30)); + subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); + ValuedFlagEvaluationResult valuedEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric_flag", "alice", subjectAttributes, EppoValue.valueOf(0.0), VariationType.NUMERIC); + + assertEquals(3.1415926, valuedEvaluationResult.getValue().doubleValue(), 0.0000001); + assertNotNull(valuedEvaluationResult.getEvaluationResult()); + assertEquals(OK, valuedEvaluationResult.getType()); + + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + Assignment capturedAssignment = assignmentLogCaptor.getValue(); + assertEquals("numeric_flag-rollout", capturedAssignment.getExperiment()); + assertEquals("numeric_flag", capturedAssignment.getFeatureFlag()); + assertEquals("rollout", capturedAssignment.getAllocation()); + assertEquals( + "pi", + capturedAssignment + .getVariation()); // Note: unlike this test, typically variation keys will just be the + // value for everything not JSON + assertEquals("alice", capturedAssignment.getSubject()); + assertEquals(subjectAttributes, capturedAssignment.getSubjectAttributes()); + assertEquals(new HashMap<>(), capturedAssignment.getExtraLogging()); + assertTrue(capturedAssignment.getTimestamp().after(testStart)); + Date inTheNearFuture = new Date(System.currentTimeMillis() + 1); + assertTrue(capturedAssignment.getTimestamp().before(inTheNearFuture)); + + Map expectedMeta = new HashMap<>(); + expectedMeta.put("obfuscated", "false"); + expectedMeta.put("sdkLanguage", "java"); + expectedMeta.put("sdkLibVersion", "100.1.0"); + + assertEquals(expectedMeta, capturedAssignment.getMetaData()); + } + @Test public void testAssignmentNotDeduplicatedWithoutCache() { initClient(); @@ -520,6 +667,24 @@ public void testAssignmentNotDeduplicatedWithoutCache() { verify(mockAssignmentLogger, times(2)).logAssignment(assignmentLogCaptor.capture()); } + @Test + public void testAssignmentNotDeduplicatedWithoutCacheResult() { + initClient(); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", EppoValue.valueOf(30)); + subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); + + // Get the assignment twice + eppoClient.getTypedAssignmentResult("numeric_flag", "alice", subjectAttributes, EppoValue.valueOf(0.0), VariationType.NUMERIC); + eppoClient.getTypedAssignmentResult("numeric_flag", "alice", subjectAttributes, EppoValue.valueOf(0.0), VariationType.NUMERIC); + + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + + // `logAssignment` should be called twice; + verify(mockAssignmentLogger, times(2)).logAssignment(assignmentLogCaptor.capture()); + } + @Test public void testAssignmentEventCorrectlyDeduplicated() { initClientWithAssignmentCache(new LRUInMemoryAssignmentCache(1024)); @@ -573,45 +738,36 @@ public void testAssignmentEventCorrectlyDeduplicatedFromBackgroundThreads() { int numThreads = 10; final CountDownLatch threadStartCountDownLatch = new CountDownLatch(numThreads); final CountDownLatch getAssignmentStartCountDownLatch = new CountDownLatch(1); - final List assignments = - Collections.synchronizedList(Arrays.asList(new Integer[numThreads])); - ExecutorService pool = - Executors.newFixedThreadPool( - numThreads, - new ThreadFactory() { - private final AtomicInteger threadIndexAtomicInteger = new AtomicInteger(0); - - @Override - public Thread newThread(@NotNull Runnable runnable) { - final int threadIndex = threadIndexAtomicInteger.getAndIncrement(); - return new Thread( - runnable, - "testAssignmentEventCorrectlyDeduplicatedFromBackgroundThreads-" + threadIndex); - } - }); - try { + final List assignments = Collections.synchronizedList(Arrays.asList(new Integer[numThreads])); + try (ExecutorService pool = Executors.newFixedThreadPool(numThreads, new ThreadFactory() { + private final AtomicInteger threadIndexAtomicInteger = new AtomicInteger(0); + @Override + public Thread newThread(@NotNull Runnable runnable) { + final int threadIndex = threadIndexAtomicInteger.getAndIncrement(); + return new Thread(runnable, "testAssignmentEventCorrectlyDeduplicatedFromBackgroundThreads-" + threadIndex); + } + })) { for (int i = 0; i < numThreads; i += 1) { final int threadIndex = i; pool.execute( - () -> { - threadStartCountDownLatch.countDown(); - boolean shouldStart; - try { - shouldStart = getAssignmentStartCountDownLatch.await(1000, TimeUnit.SECONDS); - } catch (InterruptedException ignored) { - shouldStart = false; - } - final Integer assignment; - if (shouldStart) { - assignment = - eppoClient.getIntegerAssignment( - "numeric-one-of", "alice", subjectAttributes, 0); - } else { - assignment = null; - } - - assignments.set(threadIndex, assignment); - }); + () -> { + threadStartCountDownLatch.countDown(); + boolean shouldStart; + try { + shouldStart = getAssignmentStartCountDownLatch.await(1000, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + shouldStart = false; + } + final Integer assignment; + if (shouldStart) { + assignment = eppoClient.getIntegerAssignment("numeric-one-of", "alice", subjectAttributes, 0); + } else { + assignment = null; + } + + assignments.set(threadIndex, assignment); + } + ); } boolean shouldStart; @@ -623,16 +779,6 @@ public Thread newThread(@NotNull Runnable runnable) { assertTrue(shouldStart, "All worker threads did not start"); getAssignmentStartCountDownLatch.countDown(); - } finally { - pool.shutdown(); - try { - if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { - pool.shutdownNow(); - } - } catch (InterruptedException e) { - pool.shutdownNow(); - Thread.currentThread().interrupt(); - } } final List expectedAssignments; @@ -648,6 +794,55 @@ public Thread newThread(@NotNull Runnable runnable) { verify(mockAssignmentLogger, times(1)).logAssignment(any(Assignment.class)); } + @Test + public void testAssignmentEventCorrectlyDeduplicatedResult() { + initClientWithAssignmentCache(new LRUInMemoryAssignmentCache(1024)); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("number", EppoValue.valueOf("123456789")); + + // Get the assignment twice + ValuedFlagEvaluationResult valuedEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric-one-of", "alice", subjectAttributes, EppoValue.valueOf(0), VariationType.INTEGER); + eppoClient.getTypedAssignmentResult("numeric-one-of", "alice", subjectAttributes, EppoValue.valueOf(0), VariationType.INTEGER); + + // `2` matches the attribute `number` value of "123456789" + assertEquals(2, valuedEvaluationResult.getValue().intValue()); + assertNotNull(valuedEvaluationResult.getEvaluationResult()); + assertEquals(OK, valuedEvaluationResult.getType()); + + // `logAssignment` should be called only once. + verify(mockAssignmentLogger, times(1)).logAssignment(any(Assignment.class)); + + // Now, change the assigned value to get a logged entry. `number="1"` will map to the assignment + // of `1`. + subjectAttributes.put("number", EppoValue.valueOf("1")); + + // Get the assignment + ValuedFlagEvaluationResult newValuedEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric-one-of", "alice", subjectAttributes, EppoValue.valueOf(0), VariationType.INTEGER); + assertEquals(1, newValuedEvaluationResult.getValue().intValue()); + assertNotNull(newValuedEvaluationResult.getEvaluationResult()); + assertEquals(OK, newValuedEvaluationResult.getType()); + + // Verify a new log call + verify(mockAssignmentLogger, times(2)).logAssignment(any(Assignment.class)); + + // Change back to the original variation to ensure it is not still cached after the previous + // value evicted it. + subjectAttributes.put("number", EppoValue.valueOf("123456789")); + + // Get the assignment + ValuedFlagEvaluationResult oldValuedEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric-one-of", "alice", subjectAttributes, EppoValue.valueOf(0), VariationType.INTEGER); + assertEquals(2, oldValuedEvaluationResult.getValue().intValue()); + assertNotNull(oldValuedEvaluationResult.getEvaluationResult()); + assertEquals(OK, oldValuedEvaluationResult.getType()); + + // Verify a new log call + verify(mockAssignmentLogger, times(3)).logAssignment(any(Assignment.class)); + } + @Test public void testAssignmentLogErrorNonFatal() { initClient(); @@ -663,6 +858,41 @@ public void testAssignmentLogErrorNonFatal() { verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); } + @Test + public void testAssignmentLogErrorNonFatalResult() { + initClient(); + doThrow(new RuntimeException("Mock Assignment Logging Error")) + .when(mockAssignmentLogger) + .logAssignment(any()); + ValuedFlagEvaluationResult valuedEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric_flag", "alice", new Attributes(), EppoValue.valueOf(0.0), VariationType.NUMERIC); + + assertEquals(3.1415926, valuedEvaluationResult.getValue().doubleValue(), 0.0000001); + assertEquals(OK, valuedEvaluationResult.getType()); + assertNotNull(valuedEvaluationResult.getEvaluationResult()); + + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + } + + @Test + public void testBadFlagKeyReturnsNoFlagConfigAndNullEvaluationResult() { + initClient(); + EppoValue defaultValue = EppoValue.valueOf("default"); + ValuedFlagEvaluationResult valuedFlagEvaluationResult = + eppoClient.getTypedAssignmentResult("does_not_exist", "bob", new Attributes(), defaultValue, VariationType.STRING); + assertEquals(new ValuedFlagEvaluationResult(defaultValue, null, NO_FLAG_CONFIG), valuedFlagEvaluationResult); + } + + @Test + public void testBadVariationTypeReturnsBadVariationTypeAndNullEvaluationResult() { + initClient(); + EppoValue defaultValue = EppoValue.valueOf("default"); + ValuedFlagEvaluationResult valuedFlagEvaluationResult = + eppoClient.getTypedAssignmentResult("numeric_flag", "alice", new Attributes(), defaultValue, VariationType.STRING); + assertEquals(new ValuedFlagEvaluationResult(defaultValue, null, BAD_VARIATION_TYPE), valuedFlagEvaluationResult); + } + @Test public void testGracefulPolling() { Timer pollTimer = new Timer(); diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index c4c7c212..432cb875 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -51,7 +51,7 @@ public void testDisabledFlag() { @Test public void testNoAllocations() { Map variations = createVariations("a"); - FlagConfig flag = createFlag("flag", true, variations, null); + FlagConfig flag = createFlag("flag", true, variations, Collections.emptyList()); FlagEvaluationResult result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); @@ -272,6 +272,9 @@ public void testObfuscated() { EppoValue.valueOf(base64Encode(variationToEncode.getValue().stringValue()))); encodedVariations.put(encodedVariationKey, newVariation); } + List sortedEncodedVariationKeys = encodedVariations.keySet().stream() + .sorted() + .collect(Collectors.toList()); // Encode the allocations List encodedAllocations = allocations.stream() @@ -280,7 +283,7 @@ public void testObfuscated() { allocationToEncode.setKey(base64Encode(allocationToEncode.getKey())); TargetingCondition encodedCondition; Set encodedRules = new HashSet<>(); - if (allocationToEncode.getRules() != null) { + if (!allocationToEncode.getRules().isEmpty()) { // assume just a single rule with a single string-valued condition TargetingCondition conditionToEncode = allocationToEncode @@ -331,6 +334,7 @@ public void testObfuscated() { flag.getTotalShards(), flag.getVariationType(), encodedVariations, + sortedEncodedVariationKeys, encodedAllocations); FlagEvaluationResult result = FlagEvaluator.evaluateFlag( @@ -368,7 +372,7 @@ public void testObfuscatedExtraLogging() { obfuscatedExtraLogging.put(base64Encode("anotherKey"), base64Encode("anotherValue")); List splits = new ArrayList<>(); - splits.add(new Split("a", null, obfuscatedExtraLogging)); + splits.add(new Split("a", Collections.emptySet(), obfuscatedExtraLogging)); List allocations = createAllocations("test", splits); @@ -411,6 +415,9 @@ public void testObfuscatedExtraLogging() { allocationToEncode.doLog()); }) .collect(Collectors.toList()); + List sortedEncodedVariationKeys = encodedVariations.keySet().stream() + .sorted() + .collect(Collectors.toList()); // Create the obfuscated flag FlagConfig obfuscatedFlag = @@ -420,6 +427,7 @@ public void testObfuscatedExtraLogging() { flag.getTotalShards(), flag.getVariationType(), encodedVariations, + sortedEncodedVariationKeys, encodedAllocations); // Test with obfuscated config @@ -479,7 +487,7 @@ private Set createShards(String salt, Integer rangeStart, Integer rangeEn } private List createSplits(String variationKey) { - return createSplits(variationKey, null); + return createSplits(variationKey, Collections.emptySet()); } private List createSplits(String variationKey, Set shards) { @@ -494,7 +502,7 @@ private Set createRules(String attribute, OperatorType operator, } private List createAllocations(String allocationKey, List splits) { - return createAllocations(allocationKey, splits, null); + return createAllocations(allocationKey, splits, Collections.emptySet()); } private List createAllocations( @@ -508,6 +516,10 @@ private FlagConfig createFlag( boolean enabled, Map variations, List allocations) { - return new FlagConfig(key, enabled, 10, VariationType.STRING, variations, allocations); + + List sortedVariationKeys = variations.keySet().stream() + .sorted() + .collect(Collectors.toList()); + return new FlagConfig(key, enabled, 10, VariationType.STRING, variations, sortedVariationKeys, allocations); } } diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 54360033..bec8d3c5 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -68,6 +68,7 @@ public void getFlagType_shouldReturnCorrectType() { 1, VariationType.STRING, Collections.emptyMap(), + Collections.emptyList(), Collections.emptyList()); // Create configuration with this flag @@ -92,6 +93,7 @@ public void getFlagType_withObfuscatedConfig_shouldReturnCorrectType() { 1, VariationType.NUMERIC, Collections.emptyMap(), + Collections.emptyList(), Collections.emptyList()); // Create configuration with this flag using MD5 hash of the flag key