diff --git a/.github/workflows/lint-test-sdk.yml b/.github/workflows/lint-test-sdk.yml index 906bad00..fc161162 100644 --- a/.github/workflows/lint-test-sdk.yml +++ b/.github/workflows/lint-test-sdk.yml @@ -32,4 +32,4 @@ jobs: gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} - name: Run tests - run: ./gradlew check --no-daemon + run: make test diff --git a/.gitignore b/.gitignore index d44182c9..2e8aa62f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +src/test/resources/shared .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..f5041c80 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +# Make settings - @see https://tech.davis-hansson.com/p/make/ +SHELL := bash +.ONESHELL: +.SHELLFLAGS := -eu -o pipefail -c +.DELETE_ON_ERROR: +MAKEFLAGS += --warn-undefined-variables +MAKEFLAGS += --no-builtin-rules + +# Log levels +DEBUG := $(shell printf "\e[2D\e[35m") +INFO := $(shell printf "\e[2D\e[36m🔵 ") +OK := $(shell printf "\e[2D\e[32m🟢 ") +WARN := $(shell printf "\e[2D\e[33m🟡 ") +ERROR := $(shell printf "\e[2D\e[31m🔴 ") +END := $(shell printf "\e[0m") + + +.PHONY: default +default: help + +## help - Print help message. +.PHONY: help +help: Makefile + @echo "usage: make " + @sed -n 's/^##//p' $< + +.PHONY: build +build: test-data + ./gradlew assemble + +## test-data +testDataDir := src/test/resources/shared +tempDir := ${testDataDir}/temp +gitDataDir := ${tempDir}/sdk-test-data +branchName := main +githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git +.PHONY: test-data +test-data: + rm -rf $(testDataDir) + mkdir -p ${tempDir} + git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} + cp -r ${gitDataDir}/ufc ${testDataDir} + rm ${testDataDir}/ufc/bandit-tests/*.dynamic-typing.json + rm -rf ${tempDir} + +.PHONY: test +test: test-data build + ./gradlew check --no-daemon diff --git a/build.gradle b/build.gradle index 92e2ddd0..ded52ad4 100644 --- a/build.gradle +++ b/build.gradle @@ -6,11 +6,14 @@ plugins { } group = 'cloud.eppo' -version = '2.1.0-SNAPSHOT' +version = '3.0.0-SNAPSHOT' ext.isReleaseVersion = !version.endsWith("SNAPSHOT") dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'com.github.zafarkhaja:java-semver:0.10.2' + implementation "com.squareup.okhttp3:okhttp:4.12.0" + // For UFC DTOs implementation 'commons-codec:commons-codec:1.17.0' implementation 'org.slf4j:slf4j-api:2.0.13' @@ -19,10 +22,19 @@ dependencies { testImplementation 'org.skyscreamer:jsonassert:1.5.3' testImplementation 'commons-io:commons-io:2.11.0' testImplementation "com.google.truth:truth:1.4.4" + testImplementation 'org.mockito:mockito-core:4.11.0' + testImplementation 'org.mockito:mockito-inline:4.11.0' } test { useJUnitPlatform() + testLogging { + events "started", "passed", "skipped", "failed" + exceptionFormat "full" + showExceptions true + showCauses true + showStackTraces true + } } spotless { @@ -50,11 +62,17 @@ java { withSourcesJar() } +tasks.register('testJar', Jar) { + archiveClassifier.set('tests') + from sourceSets.test.output +} + publishing { publications { mavenJava(MavenPublication) { artifactId = 'sdk-common-jvm' from components.java + artifact testJar // Include the test-jar in the published artifacts versionMapping { usage('java-api') { fromResolutionOf('runtimeClasspath') @@ -102,7 +120,7 @@ publishing { // Custom task to ensure we can conditionally publish either a release or snapshot artifact // based on a command line switch. See github workflow files for more details on usage. -task checkVersion { +tasks.register('checkVersion') { doLast { if (!project.hasProperty('release') && !project.hasProperty('snapshot')) { throw new GradleException("You must specify either -Prelease or -Psnapshot") @@ -123,21 +141,25 @@ tasks.named('publish').configure { } // Conditionally enable or disable publishing tasks -tasks.withType(PublishToMavenRepository) { +tasks.withType(PublishToMavenRepository).configureEach { onlyIf { project.ext.has('shouldPublish') && project.ext.shouldPublish } } -signing { - sign publishing.publications.mavenJava - if (System.env['CI']) { - useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE) +if (!project.gradle.startParameter.taskNames.contains('publishToMavenLocal')) { + signing { + sign publishing.publications.mavenJava + if (System.env['CI']) { + useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE) + } } } - javadoc { + failOnError = false + options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('failOnError', false) if (JavaVersion.current().isJava9Compatible()) { options.addBooleanOption('html5', true) } diff --git a/src/main/java/cloud/eppo/BanditEvaluationResult.java b/src/main/java/cloud/eppo/BanditEvaluationResult.java new file mode 100644 index 00000000..3b3366a4 --- /dev/null +++ b/src/main/java/cloud/eppo/BanditEvaluationResult.java @@ -0,0 +1,73 @@ +package cloud.eppo; + +import cloud.eppo.ufc.dto.DiscriminableAttributes; + +public class BanditEvaluationResult { + + private final String flagKey; + private final String subjectKey; + private final DiscriminableAttributes subjectAttributes; + private final String actionKey; + private final DiscriminableAttributes actionAttributes; + private final double actionScore; + private final double actionWeight; + private final double gamma; + private final double optimalityGap; + + public BanditEvaluationResult( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + String actionKey, + DiscriminableAttributes actionAttributes, + double actionScore, + double actionWeight, + double gamma, + double optimalityGap) { + this.flagKey = flagKey; + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.actionKey = actionKey; + this.actionAttributes = actionAttributes; + this.actionScore = actionScore; + this.actionWeight = actionWeight; + this.gamma = gamma; + this.optimalityGap = optimalityGap; + } + + public String getFlagKey() { + return flagKey; + } + + public String getSubjectKey() { + return subjectKey; + } + + public DiscriminableAttributes getSubjectAttributes() { + return subjectAttributes; + } + + public String getActionKey() { + return actionKey; + } + + public DiscriminableAttributes getActionAttributes() { + return actionAttributes; + } + + public double getActionScore() { + return actionScore; + } + + public double getActionWeight() { + return actionWeight; + } + + public double getGamma() { + return gamma; + } + + public double getOptimalityGap() { + return optimalityGap; + } +} diff --git a/src/main/java/cloud/eppo/BanditEvaluator.java b/src/main/java/cloud/eppo/BanditEvaluator.java new file mode 100644 index 00000000..6b5b4b5d --- /dev/null +++ b/src/main/java/cloud/eppo/BanditEvaluator.java @@ -0,0 +1,168 @@ +package cloud.eppo; + +import cloud.eppo.ufc.dto.*; +import java.util.*; +import java.util.stream.Collectors; + +public class BanditEvaluator { + + private static final int BANDIT_ASSIGNMENT_SHARDS = 10000; // hard-coded for now + + public static BanditEvaluationResult evaluateBandit( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + BanditModelData modelData) { + Map actionScores = scoreActions(subjectAttributes, actions, modelData); + Map actionWeights = + weighActions(actionScores, modelData.getGamma(), modelData.getActionProbabilityFloor()); + String selectedActionKey = selectAction(flagKey, subjectKey, actionWeights); + + // Compute optimality gap in terms of score + double topScore = + actionScores.values().stream().mapToDouble(Double::doubleValue).max().orElse(0); + double optimalityGap = topScore - actionScores.get(selectedActionKey); + + return new BanditEvaluationResult( + flagKey, + subjectKey, + subjectAttributes, + selectedActionKey, + actions.get(selectedActionKey), + actionScores.get(selectedActionKey), + actionWeights.get(selectedActionKey), + modelData.getGamma(), + optimalityGap); + } + + private static Map scoreActions( + DiscriminableAttributes subjectAttributes, Actions actions, BanditModelData modelData) { + return actions.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> { + String actionName = e.getKey(); + DiscriminableAttributes actionAttributes = e.getValue(); + + // get all coefficients known to the model for this action + BanditCoefficients banditCoefficients = + modelData.getCoefficients().get(actionName); + + if (banditCoefficients == null) { + // Unknown action; return the default action score + return modelData.getDefaultActionScore(); + } + + // Score the action using the provided attributes + double actionScore = banditCoefficients.getIntercept(); + actionScore += + scoreContextForCoefficients( + actionAttributes.getNumericAttributes(), + banditCoefficients.getActionNumericCoefficients()); + actionScore += + scoreContextForCoefficients( + actionAttributes.getCategoricalAttributes(), + banditCoefficients.getActionCategoricalCoefficients()); + actionScore += + scoreContextForCoefficients( + subjectAttributes.getNumericAttributes(), + banditCoefficients.getSubjectNumericCoefficients()); + actionScore += + scoreContextForCoefficients( + subjectAttributes.getCategoricalAttributes(), + banditCoefficients.getSubjectCategoricalCoefficients()); + + return actionScore; + })); + } + + private static double scoreContextForCoefficients( + Attributes attributes, Map coefficients) { + + double totalScore = 0.0; + + for (BanditAttributeCoefficients attributeCoefficients : coefficients.values()) { + EppoValue contextValue = attributes.get(attributeCoefficients.getAttributeKey()); + // The coefficient implementation knows how to score + double attributeScore = attributeCoefficients.scoreForAttributeValue(contextValue); + totalScore += attributeScore; + } + + return totalScore; + } + + private static Map weighActions( + Map actionScores, double gamma, double actionProbabilityFloor) { + Double highestScore = null; + String highestScoredAction = null; + for (Map.Entry actionScore : actionScores.entrySet()) { + if (highestScore == null + || actionScore.getValue() > highestScore + || actionScore + .getValue() + .equals(highestScore) // note: we break ties for scores by action name + && actionScore.getKey().compareTo(highestScoredAction) < 0) { + highestScore = actionScore.getValue(); + highestScoredAction = actionScore.getKey(); + } + } + + // Weigh all the actions using their score + Map actionWeights = new HashMap<>(); + double totalNonHighestWeight = 0.0; + for (Map.Entry actionScore : actionScores.entrySet()) { + if (actionScore.getKey().equals(highestScoredAction)) { + // The highest scored action is weighed at the end + continue; + } + + // Compute weight (probability) + double unboundedProbability = + 1 / (actionScores.size() + (gamma * (highestScore - actionScore.getValue()))); + double minimumProbability = actionProbabilityFloor / actionScores.size(); + double boundedProbability = Math.max(unboundedProbability, minimumProbability); + totalNonHighestWeight += boundedProbability; + + actionWeights.put(actionScore.getKey(), boundedProbability); + } + + // Weigh the highest scoring action (defensively preventing a negative probability) + double weightForHighestScore = Math.max(1 - totalNonHighestWeight, 0); + actionWeights.put(highestScoredAction, weightForHighestScore); + return actionWeights; + } + + private static String selectAction( + String flagKey, String subjectKey, Map actionWeights) { + // Deterministically "shuffle" the actions + // This way as action weights shift, a bunch of users who were on the edge of one action won't + // all be shifted to the same new action at the same time. + List shuffledActionKeys = + actionWeights.keySet().stream() + .sorted( + Comparator.comparingInt( + (String actionKey) -> + ShardUtils.getShard( + flagKey + "-" + subjectKey + "-" + actionKey, + BANDIT_ASSIGNMENT_SHARDS)) + .thenComparing(actionKey -> actionKey)) + .collect(Collectors.toList()); + + // Select action from the shuffled actions, based on weight + double assignedShard = + ShardUtils.getShard(flagKey + "-" + subjectKey, BANDIT_ASSIGNMENT_SHARDS); + double assignmentWeightThreshold = assignedShard / (double) BANDIT_ASSIGNMENT_SHARDS; + double cumulativeWeight = 0; + String assignedAction = null; + for (String actionKey : shuffledActionKeys) { + cumulativeWeight += actionWeights.get(actionKey); + if (cumulativeWeight > assignmentWeightThreshold) { + assignedAction = actionKey; + break; + } + } + return assignedAction; + } +} diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java new file mode 100644 index 00000000..8ed889ec --- /dev/null +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -0,0 +1,452 @@ +package cloud.eppo; + +import static cloud.eppo.Utils.getMD5Hex; +import static cloud.eppo.Utils.throwIfEmptyOrNull; + +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.logging.BanditAssignment; +import cloud.eppo.logging.BanditLogger; +import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BaseEppoClient { + private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class); + private final ObjectMapper mapper = + new ObjectMapper() + .registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this? + + protected static final String DEFAULT_HOST = "https://fscdn.eppo.cloud"; + protected final ConfigurationRequestor requestor; + + private final ConfigurationStore configurationStore; + private final AssignmentLogger assignmentLogger; + private final BanditLogger banditLogger; + private final String sdkName; + private final String sdkVersion; + private final boolean isConfigObfuscated; + private boolean isGracefulMode; + + // Fields useful for testing in situations where we want to mock the http client or configuration + // store (accessed via reflection) + /** @noinspection FieldMayBeFinal */ + private static EppoHttpClient httpClientOverride = null; + + protected BaseEppoClient( + String apiKey, + String sdkName, + String sdkVersion, + String host, + AssignmentLogger assignmentLogger, + BanditLogger banditLogger, + boolean isGracefulMode, + boolean expectObfuscatedConfig) { + + if (apiKey == null) { + throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key"); + } + if (sdkName == null || sdkVersion == null) { + throw new IllegalArgumentException( + "Unable to initialize Eppo SDK due to missing SDK name or version"); + } + if (host == null) { + host = DEFAULT_HOST; + } + + EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion); + this.configurationStore = new ConfigurationStore(); + requestor = new ConfigurationRequestor(configurationStore, httpClient); + this.assignmentLogger = assignmentLogger; + this.banditLogger = banditLogger; + this.isGracefulMode = isGracefulMode; + // Save SDK name and version to include in logger metadata + this.sdkName = sdkName; + this.sdkVersion = sdkVersion; + // For now, the configuration is only obfuscated for Android clients + this.isConfigObfuscated = expectObfuscatedConfig; + + // TODO: caching initialization (such as setting an API-key-specific prefix + // will probably involve passing in configurationStore to the constructor + } + + private EppoHttpClient buildHttpClient( + String host, String apiKey, String sdkName, String sdkVersion) { + EppoHttpClient httpClient; + if (httpClientOverride != null) { + // Test/Debug - Client is mocked entirely + httpClient = httpClientOverride; + } else { + // Normal operation + httpClient = new EppoHttpClient(host, apiKey, sdkName, sdkVersion); + } + return httpClient; + } + + protected void loadConfiguration() { + requestor.load(); + } + + // TODO: async way to refresh for android + + protected EppoValue getTypedAssignment( + String flagKey, + String subjectKey, + Attributes subjectAttributes, + EppoValue defaultValue, + VariationType expectedType) { + + throwIfEmptyOrNull(flagKey, "flagKey must not be empty"); + throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty"); + + String flagKeyForLookup = flagKey; + if (isConfigObfuscated) { + flagKeyForLookup = getMD5Hex(flagKey); + } + + FlagConfig flag = requestor.getConfiguration(flagKeyForLookup); + if (flag == null) { + log.warn("no configuration found for key: {}", flagKey); + return defaultValue; + } + + if (!flag.isEnabled()) { + log.info( + "no assigned variation because the experiment or feature flag is disabled: {}", flagKey); + return defaultValue; + } + + if (flag.getVariationType() != expectedType) { + log.warn( + "no assigned variation because the flag type doesn't match the requested type: {} has type {}, requested {}", + flagKey, + flag.getVariationType(), + expectedType); + return defaultValue; + } + + FlagEvaluationResult evaluationResult = + FlagEvaluator.evaluateFlag( + flag, flagKey, subjectKey, subjectAttributes, isConfigObfuscated); + EppoValue assignedValue = + evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null; + + if (assignedValue != null && !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; + } + + if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) { + 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(); + + Assignment assignment = + new Assignment( + experimentKey, + flagKey, + allocationKey, + variationKey, + subjectKey, + subjectAttributes, + extraLogging, + metaData); + try { + assignmentLogger.logAssignment(assignment); + } catch (Exception e) { + log.warn("Error logging assignment: {}", e.getMessage(), e); + } + } + + return assignedValue != null ? assignedValue : defaultValue; + } + + private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue value) { + boolean typeMatch; + switch (expectedType) { + case BOOLEAN: + typeMatch = value.isBoolean(); + break; + case INTEGER: + typeMatch = + value.isNumeric() + // Java has no isInteger check so we check using mod + && value.doubleValue() % 1 == 0; + break; + case NUMERIC: + typeMatch = value.isNumeric(); + break; + case STRING: + typeMatch = value.isString(); + break; + case JSON: + typeMatch = + value.isString() + // Eppo leaves JSON as a JSON string; to verify it's valid we attempt to parse + && parseJsonString(value.stringValue()) != null; + break; + default: + throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); + } + + return typeMatch; + } + + public boolean getBooleanAssignment(String flagKey, String subjectKey, boolean defaultValue) { + return this.getBooleanAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public boolean getBooleanAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, boolean defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.BOOLEAN); + return value.booleanValue(); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public int getIntegerAssignment(String flagKey, String subjectKey, int defaultValue) { + return getIntegerAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public int getIntegerAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, int defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.INTEGER); + return Double.valueOf(value.doubleValue()).intValue(); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public Double getDoubleAssignment(String flagKey, String subjectKey, double defaultValue) { + return getDoubleAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public Double getDoubleAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, double defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.NUMERIC); + return value.doubleValue(); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + public String getStringAssignment(String flagKey, String subjectKey, String defaultValue) { + return this.getStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + public String getStringAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, String defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.STRING); + return value.stringValue(); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. + * If the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @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) { + return getJSONAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a {@link JsonNode}. + * If the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @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, Attributes subjectAttributes, JsonNode defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue.toString()), + VariationType.JSON); + return parseJsonString(value.stringValue()); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key, subject key and subject attributes as + * a JSON string. If the flag is not found, does not match the requested type or is disabled, + * defaultValue is returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @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, Attributes subjectAttributes, String defaultValue) { + try { + EppoValue value = + this.getTypedAssignment( + flagKey, + subjectKey, + subjectAttributes, + EppoValue.valueOf(defaultValue), + VariationType.JSON); + return value.stringValue(); + } catch (Exception e) { + return throwIfNotGraceful(e, defaultValue); + } + } + + /** + * Returns the assignment for the provided feature flag key and subject key as a JSON String. If + * the flag is not found, does not match the requested type or is disabled, defaultValue is + * returned. + * + * @param flagKey the feature flag key + * @param subjectKey the subject key + * @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) { + return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); + } + + private JsonNode parseJsonString(String jsonString) { + try { + return mapper.readTree(jsonString); + } catch (JsonProcessingException e) { + return null; + } + } + + public BanditResult getBanditAction( + String flagKey, + String subjectKey, + DiscriminableAttributes subjectAttributes, + Actions actions, + String defaultValue) { + BanditResult result = new BanditResult(defaultValue, null); + try { + String assignedVariation = + getStringAssignment( + flagKey, subjectKey, subjectAttributes.getAllAttributes(), defaultValue); + + // Update result to reflect that we've been assigned a variation + result = new BanditResult(assignedVariation, null); + + String banditKey = configurationStore.banditKeyForVariation(flagKey, assignedVariation); + if (banditKey != null && !actions.isEmpty()) { + BanditParameters banditParameters = configurationStore.getBanditParameters(banditKey); + BanditEvaluationResult banditResult = + BanditEvaluator.evaluateBandit( + flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData()); + + // Update result to reflect that we've been assigned an action + result = new BanditResult(assignedVariation, banditResult.getActionKey()); + + if (banditLogger != null) { + try { + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + banditResult.getActionKey(), + banditResult.getActionWeight(), + banditResult.getOptimalityGap(), + banditParameters.getModelVersion(), + subjectAttributes.getNumericAttributes(), + subjectAttributes.getCategoricalAttributes(), + banditResult.getActionAttributes().getNumericAttributes(), + banditResult.getActionAttributes().getCategoricalAttributes(), + buildLogMetaData()); + + banditLogger.logBanditAssignment(banditAssignment); + } catch (Exception e) { + log.warn("Error logging bandit assignment: {}", e.getMessage(), e); + } + } + } + return result; + } catch (Exception e) { + return throwIfNotGraceful(e, result); + } + } + + private Map buildLogMetaData() { + 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) { + if (this.isGracefulMode) { + log.info("error getting assignment value: {}", e.getMessage()); + return defaultValue; + } + throw new RuntimeException(e); + } + + public void setIsGracefulFailureMode(boolean isGracefulFailureMode) { + this.isGracefulMode = isGracefulFailureMode; + } +} diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java new file mode 100644 index 00000000..70e8457b --- /dev/null +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -0,0 +1,58 @@ +package cloud.eppo; + +import cloud.eppo.ufc.dto.FlagConfig; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// TODO: handle bandit stuff +public class ConfigurationRequestor { + private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestor.class); + + private final EppoHttpClient client; + private final ConfigurationStore configurationStore; + private final Set loadedBanditModelVersions; + + public ConfigurationRequestor(ConfigurationStore configurationStore, EppoHttpClient client) { + this.configurationStore = configurationStore; + this.client = client; + this.loadedBanditModelVersions = new HashSet<>(); + } + + // TODO: async loading for android + public void load() { + log.debug("Fetching configuration"); + String flagConfigurationJsonString = requestBody("/api/flag-config/v1/config"); + configurationStore.setFlagsFromJsonString(flagConfigurationJsonString); + + Set neededModelVersions = configurationStore.banditModelVersions(); + boolean needBanditParameters = !loadedBanditModelVersions.containsAll(neededModelVersions); + if (needBanditParameters) { + String banditParametersJsonString = requestBody("/api/flag-config/v1/bandits"); + configurationStore.setBanditParametersFromJsonString(banditParametersJsonString); + // Record the model versions that we just loaded, so we can compare when the store is later + // updated + loadedBanditModelVersions.clear(); + loadedBanditModelVersions.addAll(configurationStore.banditModelVersions()); + } + } + + private String requestBody(String route) { + Response response = client.get(route); + if (!response.isSuccessful() || response.body() == null) { + throw new RuntimeException("Failed to fetch from " + route); + } + try { + return response.body().string(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public FlagConfig getConfiguration(String flagKey) { + return configurationStore.getFlag(flagKey); + } +} diff --git a/src/main/java/cloud/eppo/ConfigurationStore.java b/src/main/java/cloud/eppo/ConfigurationStore.java new file mode 100644 index 00000000..c1f11408 --- /dev/null +++ b/src/main/java/cloud/eppo/ConfigurationStore.java @@ -0,0 +1,105 @@ +package cloud.eppo; + +import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConfigurationStore { + private static final Logger log = LoggerFactory.getLogger(ConfigurationStore.class); + private final ObjectMapper mapper = new ObjectMapper().registerModule(EppoModule.eppoModule()); + + private Map flags; + private Map banditReferences; + private Map banditParameters; + + public ConfigurationStore() { + flags = new ConcurrentHashMap<>(); + banditReferences = new ConcurrentHashMap<>(); + banditParameters = new ConcurrentHashMap<>(); + } + + public void setFlagsFromJsonString(String jsonString) { + FlagConfigResponse config; + + try { + config = mapper.readValue(jsonString, FlagConfigResponse.class); + } catch (JsonProcessingException e) { + log.error("Unable to parse flag configuration response"); + throw new RuntimeException(e); + } + + if (config == null || config.getFlags() == null) { + log.warn("Flags missing in configuration response"); + flags = new ConcurrentHashMap<>(); + banditReferences = new ConcurrentHashMap<>(); + } else { + // TODO: atomic flags to prevent clobbering like android does + // Record that flags were set from a response so we don't later clobber them with a + // slow cache read + flags = new ConcurrentHashMap<>(config.getFlags()); + banditReferences = new ConcurrentHashMap<>(config.getBanditReferences()); + log.debug("Loaded {} flags from configuration response", flags.size()); + } + } + + public FlagConfig getFlag(String flagKey) { + if (flags == null) { + log.warn("Request for flag {} before flags have been loaded", flagKey); + return null; + } else if (flags.isEmpty()) { + log.warn("Request for flag {} with empty flags", flagKey); + } + return flags.get(flagKey); + } + + public String banditKeyForVariation(String flagKey, String variationValue) { + // Note: In practice this double loop should be quite quick as the number of bandits and bandit + // variations will be small. Should this ever change, we can optimize things. + for (Map.Entry banditEntry : banditReferences.entrySet()) { + BanditReference banditReference = banditEntry.getValue(); + for (BanditFlagVariation banditFlagVariation : banditReference.getFlagVariations()) { + if (banditFlagVariation.getFlagKey().equals(flagKey) + && banditFlagVariation.getVariationValue().equals(variationValue)) { + return banditEntry.getKey(); + } + } + } + return null; + } + + public Set banditModelVersions() { + return banditReferences.values().stream() + .map(BanditReference::getModelVersion) + .collect(Collectors.toSet()); + } + + public void setBanditParametersFromJsonString(String jsonString) { + BanditParametersResponse config; + + try { + config = mapper.readValue(jsonString, BanditParametersResponse.class); + } catch (JsonProcessingException e) { + log.error("Unable to parse bandit parameters response"); + throw new RuntimeException(e); + } + + if (config == null || config.getBandits() == null) { + log.warn("Bandit missing in bandit parameters response"); + banditParameters = new ConcurrentHashMap<>(); + } else { + banditParameters = new ConcurrentHashMap<>(config.getBandits()); + log.debug("Loaded {} bandit models from configuration response", banditParameters.size()); + } + } + + public BanditParameters getBanditParameters(String banditKey) { + return banditParameters.get(banditKey); + } +} diff --git a/src/main/java/cloud/eppo/rac/Constants.java b/src/main/java/cloud/eppo/Constants.java similarity index 97% rename from src/main/java/cloud/eppo/rac/Constants.java rename to src/main/java/cloud/eppo/Constants.java index b0da77ac..dd4b32d9 100644 --- a/src/main/java/cloud/eppo/rac/Constants.java +++ b/src/main/java/cloud/eppo/Constants.java @@ -1,4 +1,4 @@ -package cloud.eppo.rac; +package cloud.eppo; /** Constants Class */ public class Constants { diff --git a/src/main/java/cloud/eppo/EppoHttpClient.java b/src/main/java/cloud/eppo/EppoHttpClient.java new file mode 100644 index 00000000..54d66db4 --- /dev/null +++ b/src/main/java/cloud/eppo/EppoHttpClient.java @@ -0,0 +1,107 @@ +package cloud.eppo; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EppoHttpClient { + private static final Logger log = LoggerFactory.getLogger(EppoHttpClient.class); + + private final OkHttpClient client; + + private final String baseUrl; + private final String apiKey; + private final String sdkName; + private final String sdkVersion; + + public EppoHttpClient(String baseUrl, String apiKey, String sdkName, String sdkVersion) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.sdkName = sdkName; + this.sdkVersion = sdkVersion; + this.client = buildOkHttpClient(); + } + + private static OkHttpClient buildOkHttpClient() { + OkHttpClient.Builder builder = + new OkHttpClient() + .newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS); + + return builder.build(); + } + + // TODO: use this for Java, callback for Android; clean as needed + public Response get(String path) { + HttpUrl httpUrl = + HttpUrl.parse(baseUrl + path) + .newBuilder() + .addQueryParameter("apiKey", apiKey) + .addQueryParameter("sdkName", sdkName) + .addQueryParameter("sdkVersion", sdkVersion) + .build(); + + Request request = new Request.Builder().url(httpUrl).build(); + try { + return client.newCall(request).execute(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void get(String path, EppoHttpClientRequestCallback callback) { + HttpUrl httpUrl = + HttpUrl.parse(baseUrl + path) + .newBuilder() + .addQueryParameter("apiKey", apiKey) + .addQueryParameter("sdkName", sdkName) + .addQueryParameter("sdkVersion", sdkVersion) + .build(); + + Request request = new Request.Builder().url(httpUrl).build(); + client + .newCall(request) + .enqueue( + new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + log.debug("Fetch successful"); + try { + callback.onSuccess(response.body().string()); + } catch (IOException ex) { + callback.onFailure("Failed to read response from URL " + httpUrl); + } + } else { + if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { + callback.onFailure("Invalid API key"); + } else { + log.debug("Fetch failed with status code: {}", response.code()); + callback.onFailure("Bad response from URL " + httpUrl); + } + } + response.close(); + } + + @Override + public void onFailure(Call call, IOException e) { + log.error( + "Http request failure: {} {}", + e.getMessage(), + Arrays.toString(e.getStackTrace()), + e); + callback.onFailure("Unable to fetch from URL " + httpUrl); + } + }); + } +} diff --git a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java new file mode 100644 index 00000000..6d67a2c4 --- /dev/null +++ b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java @@ -0,0 +1,7 @@ +package cloud.eppo; + +public interface EppoHttpClientRequestCallback { + void onSuccess(String responseBody); + + void onFailure(String errorMessage); +} diff --git a/src/main/java/cloud/eppo/FlagEvaluationResult.java b/src/main/java/cloud/eppo/FlagEvaluationResult.java new file mode 100644 index 00000000..c5a82e07 --- /dev/null +++ b/src/main/java/cloud/eppo/FlagEvaluationResult.java @@ -0,0 +1,61 @@ +package cloud.eppo; + +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.Variation; +import java.util.Map; + +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; + private final boolean doLog; + + public FlagEvaluationResult( + String flagKey, + String subjectKey, + Attributes subjectAttributes, + String allocationKey, + Variation variation, + Map extraLogging, + boolean doLog) { + this.flagKey = flagKey; + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.allocationKey = allocationKey; + this.variation = variation; + this.extraLogging = extraLogging; + this.doLog = doLog; + } + + public String getFlagKey() { + return flagKey; + } + + public String getSubjectKey() { + return subjectKey; + } + + public Attributes getSubjectAttributes() { + return subjectAttributes; + } + + public String getAllocationKey() { + return allocationKey; + } + + public Variation getVariation() { + return variation; + } + + public Map getExtraLogging() { + return extraLogging; + } + + public boolean doLog() { + return doLog; + } +} diff --git a/src/main/java/cloud/eppo/FlagEvaluator.java b/src/main/java/cloud/eppo/FlagEvaluator.java new file mode 100644 index 00000000..5c69c89f --- /dev/null +++ b/src/main/java/cloud/eppo/FlagEvaluator.java @@ -0,0 +1,158 @@ +package cloud.eppo; + +import static cloud.eppo.ShardUtils.getShard; +import static cloud.eppo.Utils.base64Decode; + +import cloud.eppo.model.ShardRange; +import cloud.eppo.ufc.dto.Allocation; +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.FlagConfig; +import cloud.eppo.ufc.dto.Shard; +import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.Variation; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class FlagEvaluator { + + public static FlagEvaluationResult evaluateFlag( + FlagConfig flag, + String flagKey, + String subjectKey, + Attributes subjectAttributes, + boolean isConfigObfuscated) { + Date now = new Date(); + + Variation variation = null; + String allocationKey = null; + 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 = + flag.isEnabled() && flag.getAllocations() != null + ? flag.getAllocations() + : new LinkedList<>(); + + for (Allocation allocation : allocationsToConsider) { + if (allocation.getStartAt() != null && allocation.getStartAt().after(now)) { + // Allocation not yet active + continue; + } + if (allocation.getEndAt() != null && allocation.getEndAt().before(now)) { + // Allocation no longer active + continue; + } + + // For convenience, we will automatically include the subject key as the "id" attribute if + // none is provided + Attributes subjectAttributesToEvaluate = new Attributes(subjectAttributes); + if (!subjectAttributesToEvaluate.containsKey("id")) { + subjectAttributesToEvaluate.put("id", subjectKey); + } + + if (allocation.getRules() != null + && !allocation.getRules().isEmpty() + && RuleEvaluator.findMatchingRule( + subjectAttributesToEvaluate, allocation.getRules(), isConfigObfuscated) + == null) { + // Rules are defined, but none match + continue; + } + + // This allocation has matched; find variation + for (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()); + if (variation == null) { + throw new RuntimeException("Unknown split variation key: " + split.getVariationKey()); + } + extraLogging = split.getExtraLogging(); + break; + } + } + + if (variation != null) { + // We only evaluate the first relevant allocation + allocationKey = allocation.getKey(); + // doLog is determined by the allocation + doLog = allocation.doLog(); + break; + } + } + + if (isConfigObfuscated) { + // Need to unobfuscate for the returned evaluation result + allocationKey = base64Decode(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()); + } + } + variation = new Variation(key, decodedValue); + } + } + + return new FlagEvaluationResult( + flagKey, subjectKey, subjectAttributes, allocationKey, variation, extraLogging, doLog); + } + + private static boolean allShardsMatch( + Split split, 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()) { + if (!matchesShard(shard, subjectKey, totalShards, isObfuscated)) { + return false; + } + } + + // If here, matchesShard() was true for each shard + return true; + } + + private static boolean matchesShard( + Shard shard, String subjectKey, int totalShards, boolean isObfuscated) { + String salt = shard.getSalt(); + if (isObfuscated) { + salt = base64Decode(salt); + } + String hashKey = salt + "-" + subjectKey; + int assignedShard = getShard(hashKey, totalShards); + for (ShardRange range : shard.getRanges()) { + if (assignedShard >= range.getStart() && assignedShard < range.getEnd()) { + return true; + } + } + + // If here, the shard was not in any of the shard's ranges + return false; + } +} diff --git a/src/main/java/cloud/eppo/RuleEvaluator.java b/src/main/java/cloud/eppo/RuleEvaluator.java new file mode 100644 index 00000000..d2250f17 --- /dev/null +++ b/src/main/java/cloud/eppo/RuleEvaluator.java @@ -0,0 +1,208 @@ +package cloud.eppo; + +import static cloud.eppo.Utils.base64Decode; +import static cloud.eppo.Utils.getMD5Hex; + +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.OperatorType; +import cloud.eppo.ufc.dto.TargetingCondition; +import cloud.eppo.ufc.dto.TargetingRule; +import com.github.zafarkhaja.semver.Version; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public class RuleEvaluator { + + public static TargetingRule findMatchingRule( + Attributes subjectAttributes, Set rules, boolean isObfuscated) { + for (TargetingRule rule : rules) { + if (allConditionsMatch(subjectAttributes, rule.getConditions(), isObfuscated)) { + return rule; + } + } + return null; + } + + private static boolean allConditionsMatch( + Attributes subjectAttributes, Set conditions, boolean isObfuscated) { + for (TargetingCondition condition : conditions) { + if (!evaluateCondition(subjectAttributes, condition, isObfuscated)) { + return false; + } + } + return true; + } + + private static boolean evaluateCondition( + Attributes subjectAttributes, TargetingCondition condition, boolean isObfuscated) { + EppoValue conditionValue = condition.getValue(); + String attributeKey = condition.getAttribute(); + EppoValue attributeValue = null; + if (isObfuscated) { + // attribute names are hashed + for (Map.Entry entry : subjectAttributes.entrySet()) { + if (getMD5Hex(entry.getKey()).equals(attributeKey)) { + attributeValue = entry.getValue(); + break; + } + } + } else { + attributeValue = subjectAttributes.get(attributeKey); + } + + // First we do any NULL check + boolean attributeValueIsNull = attributeValue == null || attributeValue.isNull(); + OperatorType operator = condition.getOperator(); + if (operator == OperatorType.IS_NULL) { + boolean expectNull = + isObfuscated + ? getMD5Hex("true").equals(conditionValue.stringValue()) + : conditionValue.booleanValue(); + return expectNull && attributeValueIsNull || !expectNull && !attributeValueIsNull; + } else if (attributeValueIsNull) { + // Any check other than IS NULL should fail if the attribute value is null + return false; + } + + if (operator.isInequalityComparison()) { + Double conditionNumber = null; + if (isObfuscated && conditionValue.isString()) { + // it may be an encoded number + try { + conditionNumber = Double.parseDouble(base64Decode(conditionValue.stringValue())); + } catch (Exception e) { + // not a number + } + } else if (conditionValue.isNumeric()) { + conditionNumber = conditionValue.doubleValue(); + } + + boolean numericComparison = attributeValue.isNumeric() && conditionNumber != null; + + // Android API version 21 does not have access to the java.util.Optional class. + // Version.tryParse returns an Optional would be ideal. + // Instead, use Version.parse which throws an exception if the string is not a valid SemVer. + // We front-load the parsing here so many evaluation of gte, gt, lte, lt operations + // more straight-forward. + Version valueSemVer = null; + Version conditionSemVer = null; + try { + valueSemVer = Version.parse(attributeValue.stringValue()); + String conditionSemVerString = condition.getValue().stringValue(); + if (isObfuscated) { + conditionSemVerString = base64Decode(conditionSemVerString); + } + conditionSemVer = Version.parse(conditionSemVerString); + } catch (Exception e) { + // no-op + } + + // Performing this check satisfies the compiler that the possibly + // null value can be safely accessed later. + boolean semVerComparison = valueSemVer != null && conditionSemVer != null; + + switch (operator) { + case GREATER_THAN_OR_EQUAL_TO: + if (numericComparison) { + return attributeValue.doubleValue() >= conditionNumber; + } + + if (semVerComparison) { + return valueSemVer.isHigherThanOrEquivalentTo(conditionSemVer); + } + + return false; + case GREATER_THAN: + if (numericComparison) { + return attributeValue.doubleValue() > conditionNumber; + } + + if (semVerComparison) { + return valueSemVer.isHigherThan(conditionSemVer); + } + + return false; + case LESS_THAN_OR_EQUAL_TO: + if (numericComparison) { + return attributeValue.doubleValue() <= conditionNumber; + } + + if (semVerComparison) { + return valueSemVer.isLowerThanOrEquivalentTo(conditionSemVer); + } + + return false; + case LESS_THAN: + if (numericComparison) { + return attributeValue.doubleValue() < conditionNumber; + } + + if (semVerComparison) { + return valueSemVer.isLowerThan(conditionSemVer); + } + + return false; + default: + throw new IllegalStateException("Unexpected inequality operator: " + operator); + } + } + + if (operator.isListComparison()) { + boolean expectMatch = operator == OperatorType.ONE_OF; + boolean matchFound = false; + for (String arrayString : conditionValue.stringArrayValue()) { + String comparisonString = castAttributeForListComparison(attributeValue); + if (isObfuscated) { + // List comparisons use hashes for checking exact match + comparisonString = getMD5Hex(comparisonString); + } + if (arrayString.equals(comparisonString)) { + matchFound = true; + break; + } + } + return expectMatch && matchFound || !expectMatch && !matchFound; + } + + if (operator == OperatorType.MATCHES || operator == OperatorType.NOT_MATCHES) { + // Regexes require decoding + String patternString = condition.getValue().stringValue(); + if (isObfuscated) { + patternString = base64Decode(patternString); + } + + // Use find() to support partial matching + Pattern pattern = Pattern.compile(patternString); + boolean patternFound = pattern.matcher(attributeValue.toString()).find(); + return (operator == OperatorType.MATCHES) == patternFound; + } + + throw new IllegalStateException("Unexpected rule operator: " + operator); + } + + /** + * IN and NOT IN checks are not strongly typed, as the user is only entering in strings Thus we + * need to cast the attribute to a string before hashing and checking + */ + private static String castAttributeForListComparison(EppoValue attributeValue) { + if (attributeValue.isBoolean()) { + return Boolean.valueOf(attributeValue.booleanValue()).toString(); + } else if (attributeValue.isNumeric()) { + double doubleValue = attributeValue.doubleValue(); + int intValue = Double.valueOf(attributeValue.doubleValue()).intValue(); + return doubleValue == intValue ? String.valueOf(intValue) : String.valueOf(doubleValue); + } else if (attributeValue.isString()) { + return attributeValue.stringValue(); + } else if (attributeValue.isStringArray()) { + return Collections.singletonList(attributeValue.stringArrayValue()).toString(); + } else if (attributeValue.isNull()) { + return ""; + } else { + throw new IllegalArgumentException( + "Unknown EppoValue type for casting for list comparison: " + attributeValue); + } + } +} diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index fbb83903..b5ba8770 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.ParseException; @@ -16,6 +17,12 @@ public final class Utils { private static final SimpleDateFormat UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); private static final Logger log = LoggerFactory.getLogger(Utils.class); + public static void throwIfEmptyOrNull(String input, String errorMessage) { + if (input == null || input.isEmpty()) { + throw new IllegalArgumentException(errorMessage); + } + } + public static String getMD5Hex(String input) { MessageDigest md; try { @@ -33,7 +40,7 @@ public static String getMD5Hex(String input) { return hashText.toString(); } - public static Date parseUtcISODateElement(JsonNode isoDateStringElement) { + public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { if (isoDateStringElement == null || isoDateStringElement.isNull()) { return null; } @@ -63,6 +70,13 @@ public static String getISODate(Date date) { return UTC_ISO_DATE_FORMAT.format(date); } + public static String base64Encode(String input) { + if (input == null) { + return null; + } + return Base64.encodeBase64String(input.getBytes(StandardCharsets.UTF_8)); + } + public static String base64Decode(String input) { if (input == null) { return null; diff --git a/src/main/java/cloud/eppo/rac/exception/InvalidApiKeyException.java b/src/main/java/cloud/eppo/exception/InvalidApiKeyException.java similarity index 80% rename from src/main/java/cloud/eppo/rac/exception/InvalidApiKeyException.java rename to src/main/java/cloud/eppo/exception/InvalidApiKeyException.java index eab12fc9..f5ce12b0 100644 --- a/src/main/java/cloud/eppo/rac/exception/InvalidApiKeyException.java +++ b/src/main/java/cloud/eppo/exception/InvalidApiKeyException.java @@ -1,4 +1,4 @@ -package cloud.eppo.rac.exception; +package cloud.eppo.exception; public class InvalidApiKeyException extends RuntimeException { public InvalidApiKeyException(String message) { diff --git a/src/main/java/cloud/eppo/rac/dto/AssignmentLogData.java b/src/main/java/cloud/eppo/logging/Assignment.java similarity index 54% rename from src/main/java/cloud/eppo/rac/dto/AssignmentLogData.java rename to src/main/java/cloud/eppo/logging/Assignment.java index 761c2eff..c6072dfe 100644 --- a/src/main/java/cloud/eppo/rac/dto/AssignmentLogData.java +++ b/src/main/java/cloud/eppo/logging/Assignment.java @@ -1,31 +1,38 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.logging; +import cloud.eppo.ufc.dto.Attributes; import java.util.Date; +import java.util.Map; -/** Assignment Log Data Class */ -public class AssignmentLogData { +public class Assignment { + private final Date timestamp; private final String experiment; private final String featureFlag; private final String allocation; private final String variation; - private final Date timestamp; private final String subject; - private final EppoAttributes subjectAttributes; + private final Attributes subjectAttributes; + private final Map extraLogging; + private final Map metaData; - public AssignmentLogData( + public Assignment( String experiment, String featureFlag, String allocation, String variation, String subject, - EppoAttributes subjectAttributes) { + Attributes subjectAttributes, + Map extraLogging, + Map metaData) { + this.timestamp = new Date(); this.experiment = experiment; this.featureFlag = featureFlag; this.allocation = allocation; this.variation = variation; - this.timestamp = new Date(); this.subject = subject; this.subjectAttributes = subjectAttributes; + this.extraLogging = extraLogging; + this.metaData = metaData; } public String getExperiment() { @@ -44,15 +51,33 @@ public String getVariation() { return variation; } + public String getSubject() { + return subject; + } + public Date getTimestamp() { return timestamp; } - public String getSubject() { - return subject; + public Attributes getSubjectAttributes() { + return subjectAttributes; } - public EppoAttributes getSubjectAttributes() { - return subjectAttributes; + public Map getExtraLogging() { + return extraLogging; + } + + public Map getMetaData() { + return metaData; + } + + @Override + public String toString() { + return "Subject " + + subject + + " assigned to variation " + + variation + + " in experiment " + + experiment; } } diff --git a/src/main/java/cloud/eppo/logging/AssignmentLogger.java b/src/main/java/cloud/eppo/logging/AssignmentLogger.java new file mode 100644 index 00000000..14f8f6fb --- /dev/null +++ b/src/main/java/cloud/eppo/logging/AssignmentLogger.java @@ -0,0 +1,5 @@ +package cloud.eppo.logging; + +public interface AssignmentLogger { + void logAssignment(Assignment assignment); +} diff --git a/src/main/java/cloud/eppo/logging/BanditAssignment.java b/src/main/java/cloud/eppo/logging/BanditAssignment.java new file mode 100644 index 00000000..c2e2d9a8 --- /dev/null +++ b/src/main/java/cloud/eppo/logging/BanditAssignment.java @@ -0,0 +1,101 @@ +package cloud.eppo.logging; + +import cloud.eppo.ufc.dto.Attributes; +import java.util.Date; +import java.util.Map; + +public class BanditAssignment { + private final Date timestamp; + private final String featureFlag; + private final String bandit; + private final String subject; + private final String action; + private final Double actionProbability; + private final Double optimalityGap; + private final String modelVersion; + private final Attributes subjectNumericAttributes; + private final Attributes subjectCategoricalAttributes; + private final Attributes actionNumericAttributes; + private final Attributes actionCategoricalAttributes; + private final Map metaData; + + public BanditAssignment( + String featureFlag, + String bandit, + String subject, + String action, + Double actionProbability, + Double optimalityGap, + String modelVersion, + Attributes subjectNumericAttributes, + Attributes subjectCategoricalAttributes, + Attributes actionNumericAttributes, + Attributes actionCategoricalAttributes, + Map metaData) { + this.timestamp = new Date(); + this.featureFlag = featureFlag; + this.bandit = bandit; + this.subject = subject; + this.action = action; + this.actionProbability = actionProbability; + this.optimalityGap = optimalityGap; + this.modelVersion = modelVersion; + this.subjectNumericAttributes = subjectNumericAttributes; + this.subjectCategoricalAttributes = subjectCategoricalAttributes; + this.actionNumericAttributes = actionNumericAttributes; + this.actionCategoricalAttributes = actionCategoricalAttributes; + this.metaData = metaData; + } + + public Date getTimestamp() { + return timestamp; + } + + public String getFeatureFlag() { + return featureFlag; + } + + public String getBandit() { + return bandit; + } + + public String getSubject() { + return subject; + } + + public String getAction() { + return action; + } + + public Double getActionProbability() { + return actionProbability; + } + + public Double getOptimalityGap() { + return optimalityGap; + } + + public String getModelVersion() { + return modelVersion; + } + + public Attributes getSubjectNumericAttributes() { + return subjectNumericAttributes; + } + + public Attributes getSubjectCategoricalAttributes() { + return subjectCategoricalAttributes; + } + + public Attributes getActionNumericAttributes() { + return actionNumericAttributes; + } + + public Attributes getActionCategoricalAttributes() { + return actionCategoricalAttributes; + } + + public Map getMetaData() { + return metaData; + } +} diff --git a/src/main/java/cloud/eppo/logging/BanditLogger.java b/src/main/java/cloud/eppo/logging/BanditLogger.java new file mode 100644 index 00000000..f693833a --- /dev/null +++ b/src/main/java/cloud/eppo/logging/BanditLogger.java @@ -0,0 +1,5 @@ +package cloud.eppo.logging; + +public interface BanditLogger { + void logBanditAssignment(BanditAssignment banditAssignment); +} diff --git a/src/main/java/cloud/eppo/rac/deserializer/EppoValueDeserializer.java b/src/main/java/cloud/eppo/rac/deserializer/EppoValueDeserializer.java deleted file mode 100644 index a0ccdec7..00000000 --- a/src/main/java/cloud/eppo/rac/deserializer/EppoValueDeserializer.java +++ /dev/null @@ -1,82 +0,0 @@ -package cloud.eppo.rac.deserializer; - -import cloud.eppo.rac.dto.EppoValue; -import cloud.eppo.rac.exception.UnsupportedEppoValue; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.node.JsonNodeType; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** Eppo Value Deserializer Class */ -public class EppoValueDeserializer extends StdDeserializer { - - public EppoValueDeserializer() { - this((Class) null); - } - - protected EppoValueDeserializer(Class vc) { - super(vc); - } - - protected EppoValueDeserializer(JavaType valueType) { - super(valueType); - } - - protected EppoValueDeserializer(StdDeserializer src) { - super(src); - } - - /** - * This function is used to deserialize JSON to EppoValue - * - * @param jsonParser - * @param deserializationContext - * @return - * @throws IOException - */ - @Override - public EppoValue deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) - throws IOException { - JsonNode node = jsonParser.getCodec().readTree(jsonParser); - return parseEppoValue(node); - } - - /** - * This function is used to parse json node to create Eppo Value - * - * @param node - * @return - */ - private EppoValue parseEppoValue(JsonNode node) { - switch (node.getNodeType()) { - case ARRAY: - List array = new ArrayList<>(); - if (node.size() == 0) { - return EppoValue.valueOf(new ArrayList<>()); - } - if (node.get(0).getNodeType() != JsonNodeType.STRING) { - throw new UnsupportedEppoValue("Unsupported Eppo Values"); - } - for (int i = 0; i < node.size(); i++) { - array.add(node.get(i).asText()); - } - return EppoValue.valueOf(array); - case NUMBER: - return EppoValue.valueOf(node.asDouble()); - case STRING: - return EppoValue.valueOf(node.asText()); - case BOOLEAN: - return EppoValue.valueOf(node.asBoolean()); - case OBJECT: - case POJO: - return EppoValue.valueOf(node); - default: - return EppoValue.nullValue(); - } - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/AlgorithmType.java b/src/main/java/cloud/eppo/rac/dto/AlgorithmType.java deleted file mode 100644 index ff8c3175..00000000 --- a/src/main/java/cloud/eppo/rac/dto/AlgorithmType.java +++ /dev/null @@ -1,9 +0,0 @@ -package cloud.eppo.rac.dto; - -public enum AlgorithmType { - CONSTANT, - CONTEXTUAL_BANDIT, - OVERRIDE; - - AlgorithmType() {} -} diff --git a/src/main/java/cloud/eppo/rac/dto/Allocation.java b/src/main/java/cloud/eppo/rac/dto/Allocation.java deleted file mode 100644 index 9be8dfc9..00000000 --- a/src/main/java/cloud/eppo/rac/dto/Allocation.java +++ /dev/null @@ -1,26 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; - -public class Allocation { - private final double percentExposure; - private final List variations; - - @JsonCreator - public Allocation( - @JsonProperty("percentExposure") double percentExposure, - @JsonProperty("allocationKey") List variations) { - this.percentExposure = percentExposure; - this.variations = variations; - } - - public double getPercentExposure() { - return percentExposure; - } - - public List getVariations() { - return variations; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/BanditLogData.java b/src/main/java/cloud/eppo/rac/dto/BanditLogData.java deleted file mode 100644 index 4e1a2e5e..00000000 --- a/src/main/java/cloud/eppo/rac/dto/BanditLogData.java +++ /dev/null @@ -1,87 +0,0 @@ -package cloud.eppo.rac.dto; - -import java.util.Date; -import java.util.Map; - -/** Assignment Log Data Class */ -public class BanditLogData { - private final Date timestamp; - private final String experiment; - private final String banditKey; - private final String subject; - private final String action; - private final Double actionProbability; - private final String modelVersion; - private final Map subjectNumericAttributes; - private final Map subjectCategoricalAttributes; - private final Map actionNumericAttributes; - private final Map actionCategoricalAttributes; - - public BanditLogData( - String experiment, - String banditKey, - String subject, - String action, - Double actionProbability, - String modelVersion, - Map subjectNumericAttributes, - Map subjectCategoricalAttributes, - Map actionNumericAttributes, - Map actionCategoricalAttributes) { - this.timestamp = new Date(); - this.experiment = experiment; - this.banditKey = banditKey; - this.subject = subject; - this.action = action; - this.actionProbability = actionProbability; - this.modelVersion = modelVersion; - this.subjectNumericAttributes = subjectNumericAttributes; - this.subjectCategoricalAttributes = subjectCategoricalAttributes; - this.actionNumericAttributes = actionNumericAttributes; - this.actionCategoricalAttributes = actionCategoricalAttributes; - } - - public Date getTimestamp() { - return timestamp; - } - - public String getExperiment() { - return experiment; - } - - public String getBanditKey() { - return banditKey; - } - - public String getSubject() { - return subject; - } - - public String getAction() { - return action; - } - - public Double getActionProbability() { - return actionProbability; - } - - public String getModelVersion() { - return modelVersion; - } - - public Map getSubjectNumericAttributes() { - return subjectNumericAttributes; - } - - public Map getSubjectCategoricalAttributes() { - return subjectCategoricalAttributes; - } - - public Map getActionNumericAttributes() { - return actionNumericAttributes; - } - - public Map getActionCategoricalAttributes() { - return actionCategoricalAttributes; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/BanditParametersResponse.java b/src/main/java/cloud/eppo/rac/dto/BanditParametersResponse.java deleted file mode 100644 index 4c9cfa3e..00000000 --- a/src/main/java/cloud/eppo/rac/dto/BanditParametersResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package cloud.eppo.rac.dto; - -import cloud.eppo.rac.deserializer.BanditsDeserializer; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import java.util.Date; -import java.util.Map; - -public class BanditParametersResponse { - @JsonProperty private Date updatedAt; - - @JsonDeserialize(using = BanditsDeserializer.class) - private Map bandits; - - public BanditParametersResponse() {} - - public Map getBandits() { - return bandits; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/Condition.java b/src/main/java/cloud/eppo/rac/dto/Condition.java deleted file mode 100644 index e10d48d5..00000000 --- a/src/main/java/cloud/eppo/rac/dto/Condition.java +++ /dev/null @@ -1,44 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** Rule's Condition Class */ -public class Condition { - private final OperatorType operator; - private final String attribute; - private final EppoValue value; - - @JsonCreator - public Condition( - @JsonProperty("operator") OperatorType operator, - @JsonProperty("attribute") String attribute, - @JsonProperty("value") EppoValue value) { - this.operator = operator; - this.attribute = attribute; - this.value = value; - } - - @Override - public String toString() { - return "[Operator: " - + operator - + " | Attribute: " - + attribute - + " | Value: " - + value.toString() - + "]"; - } - - public OperatorType getOperator() { - return operator; - } - - public String getAttribute() { - return attribute; - } - - public EppoValue getValue() { - return value; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/EppoAttributes.java b/src/main/java/cloud/eppo/rac/dto/EppoAttributes.java deleted file mode 100644 index 40f1bd31..00000000 --- a/src/main/java/cloud/eppo/rac/dto/EppoAttributes.java +++ /dev/null @@ -1,78 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.HashMap; -import java.util.Map; - -/** Subject Attributes Class */ -public class EppoAttributes extends HashMap { - - public EppoAttributes() { - super(); - } - - public EppoAttributes(Map initialValues) { - super(initialValues); - } - - public String serializeToJSONString() { - return EppoAttributes.serializeAttributesToJSONString(this); - } - - public static String serializeAttributesToJSONString(Map attributes) { - return EppoAttributes.serializeAttributesToJSONString(attributes, false); - } - - public static String serializeNonNullAttributesToJSONString(Map attributes) { - return EppoAttributes.serializeAttributesToJSONString(attributes, true); - } - - private static String serializeAttributesToJSONString( - Map attributes, boolean omitNulls) { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode result = mapper.createObjectNode(); - - for (Map.Entry entry : attributes.entrySet()) { - String attributeName = entry.getKey(); - Object attributeValue = entry.getValue(); - - if (attributeValue instanceof EppoValue) { - EppoValue eppoValue = (EppoValue) attributeValue; - if (eppoValue.isNull()) { - if (!omitNulls) { - result.putNull(attributeName); - } - continue; - } - if (eppoValue.isNumeric()) { - result.put(attributeName, eppoValue.doubleValue()); - continue; - } - if (eppoValue.isBoolean()) { - result.put(attributeName, eppoValue.boolValue()); - continue; - } - // fall back put treating any other eppo values as a string - result.put(attributeName, eppoValue.toString()); - } else if (attributeValue instanceof Double) { - Double doubleValue = (Double) attributeValue; - result.put(attributeName, doubleValue); - } else if (attributeValue == null) { - if (!omitNulls) { - result.putNull(attributeName); - } - } else { - // treat everything else as a string - result.put(attributeName, attributeValue.toString()); - } - } - - try { - return mapper.writeValueAsString(result); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/EppoClientConfig.java b/src/main/java/cloud/eppo/rac/dto/EppoClientConfig.java deleted file mode 100644 index 8e46e0e2..00000000 --- a/src/main/java/cloud/eppo/rac/dto/EppoClientConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package cloud.eppo.rac.dto; - -/** Eppo Client Config class */ -public class EppoClientConfig { - private final String apiKey; - private final String baseUrl; - private final IAssignmentLogger assignmentLogger; - private final IBanditLogger banditLogger; - - /** When set to true, the client will not throw an exception when it encounters an error. */ - private boolean isGracefulMode = true; - - public EppoClientConfig( - String apiKey, - String baseUrl, - IAssignmentLogger assignmentLogger, - IBanditLogger banditLogger) { - this.apiKey = apiKey; - this.baseUrl = baseUrl; - this.assignmentLogger = assignmentLogger; - this.banditLogger = banditLogger; - } - - public boolean isGracefulMode() { - return isGracefulMode; - } - - public void setGracefulMode(boolean gracefulMode) { - isGracefulMode = gracefulMode; - } - - public String getApiKey() { - return apiKey; - } - - public String getBaseUrl() { - return baseUrl; - } - - public IAssignmentLogger getAssignmentLogger() { - return assignmentLogger; - } - - public IBanditLogger getBanditLogger() { - return banditLogger; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/EppoValue.java b/src/main/java/cloud/eppo/rac/dto/EppoValue.java deleted file mode 100644 index 49402bc4..00000000 --- a/src/main/java/cloud/eppo/rac/dto/EppoValue.java +++ /dev/null @@ -1,147 +0,0 @@ -package cloud.eppo.rac.dto; - -import cloud.eppo.rac.deserializer.EppoValueDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import java.util.Collections; -import java.util.List; - -/** Eppo Custom value class */ -@JsonDeserialize(using = EppoValueDeserializer.class) -public class EppoValue { - private final EppoValueType type; - private String stringValue; - private Double doubleValue; - private Boolean boolValue; - private JsonNode jsonValue; - private List stringArrayValue; - - private EppoValue(String stringValue) { - this.stringValue = stringValue; - this.type = stringValue != null ? EppoValueType.STRING : EppoValueType.NULL; - } - - private EppoValue(Double doubleValue) { - this.doubleValue = doubleValue; - this.type = doubleValue != null ? EppoValueType.NUMBER : EppoValueType.NULL; - } - - private EppoValue(Boolean boolValue) { - this.boolValue = boolValue; - this.type = boolValue != null ? EppoValueType.BOOLEAN : EppoValueType.NULL; - } - - private EppoValue(List stringArrayValue) { - this.stringArrayValue = stringArrayValue; - this.type = stringArrayValue != null ? EppoValueType.ARRAY_OF_STRING : EppoValueType.NULL; - } - - private EppoValue(JsonNode jsonValue) { - this.jsonValue = jsonValue; - this.type = EppoValueType.JSON_NODE; - } - - public static EppoValue valueOf(String stringValue) { - return new EppoValue(stringValue); - } - - public static EppoValue valueOf(double doubleValue) { - return new EppoValue(doubleValue); - } - - public static EppoValue valueOf(boolean boolValue) { - return new EppoValue(boolValue); - } - - public static EppoValue valueOf(JsonNode jsonValue) { - return new EppoValue(jsonValue); - } - - public static EppoValue valueOf(List value) { - return new EppoValue(value); - } - - public static EppoValue nullValue() { - return new EppoValue((String) null); - } - - public double doubleValue() { - return this.doubleValue; - } - - public String stringValue() { - return this.stringValue; - } - - public boolean boolValue() { - return this.boolValue; - } - - public JsonNode jsonNodeValue() { - return this.jsonValue; - } - - public List arrayValue() { - return this.stringArrayValue; - } - - public boolean isString() { - return this.type == EppoValueType.STRING; - } - - public boolean isNumeric() { - return this.type == EppoValueType.NUMBER; - } - - public boolean isBoolean() { - return this.type == EppoValueType.BOOLEAN; - } - - public boolean isArray() { - return type == EppoValueType.ARRAY_OF_STRING; - } - - public boolean isJson() { - return type == EppoValueType.JSON_NODE; - } - - public boolean isNull() { - return type == EppoValueType.NULL; - } - - /** - * Converts the EppoValue into a string representation. NOTE: Take care when updating this method - * as it's currently used by the IN and NOT IN target rule evaluations. - * - * @return String the string representation of the EppoValue - */ - @Override - public String toString() { - switch (this.type) { - case STRING: - return this.stringValue; - case NUMBER: - // By default, `String.valueOf()` will include at least one decimal place. - // Though numeric flags can either be integers or floating-point types. And target - // rule logic will cast a number type to a String before evaluating `oneOf` or `notOneOf` - // rules. - // The logic below ensures the cast to string better represents the intended numeric - // field type. - // - // @see https://docs.geteppo.com/feature-flagging/flag-variations#numeric-flags - // @see https://docs.geteppo.com/feature-flagging/targeting#supported-rule-operators - if (this.doubleValue.intValue() == this.doubleValue) { - return String.valueOf(this.doubleValue.intValue()); - } - return String.valueOf(this.doubleValue); - case BOOLEAN: - return this.boolValue.toString(); - case ARRAY_OF_STRING: - return Collections.singletonList(this.stringArrayValue).toString(); - case JSON_NODE: - return this.jsonValue.toString(); - default: // NULL - return ""; - } - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/EppoValueType.java b/src/main/java/cloud/eppo/rac/dto/EppoValueType.java deleted file mode 100644 index 0587c67f..00000000 --- a/src/main/java/cloud/eppo/rac/dto/EppoValueType.java +++ /dev/null @@ -1,11 +0,0 @@ -package cloud.eppo.rac.dto; - -/** Type Supported by EppoValue */ -public enum EppoValueType { - NUMBER, - STRING, - BOOLEAN, - NULL, - ARRAY_OF_STRING, - JSON_NODE, -} diff --git a/src/main/java/cloud/eppo/rac/dto/ExperimentConfiguration.java b/src/main/java/cloud/eppo/rac/dto/ExperimentConfiguration.java deleted file mode 100644 index 1e39e248..00000000 --- a/src/main/java/cloud/eppo/rac/dto/ExperimentConfiguration.java +++ /dev/null @@ -1,59 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** Experiment Configuration Class */ -public class ExperimentConfiguration { - private final String name; - private final boolean enabled; - private final int subjectShards; - private final Map typedOverrides = new HashMap<>(); - private final Map allocations; - private final List rules; - - @JsonCreator - public ExperimentConfiguration( - @JsonProperty("name") String name, - @JsonProperty("enabled") boolean enabled, - @JsonProperty("subjectShards") int subjectShards, - @JsonProperty("allocations") Map allocations, - @JsonProperty("rules") List rules) { - this.name = name; - this.enabled = enabled; - this.subjectShards = subjectShards; - this.allocations = allocations; - this.rules = rules; - } - - public Allocation getAllocation(String allocationKey) { - return allocations.get(allocationKey); - } - - public String getName() { - return name; - } - - public boolean isEnabled() { - return enabled; - } - - public int getSubjectShards() { - return subjectShards; - } - - public Map getTypedOverrides() { - return typedOverrides; - } - - public List getRules() { - return rules; - } - - public Map getAllocations() { - return allocations; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/ExperimentConfigurationResponse.java b/src/main/java/cloud/eppo/rac/dto/ExperimentConfigurationResponse.java deleted file mode 100644 index 3832dc81..00000000 --- a/src/main/java/cloud/eppo/rac/dto/ExperimentConfigurationResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Map; - -/** Experiment Configuration Response Class */ -public class ExperimentConfigurationResponse { - private final Map flags; - - @JsonCreator - public ExperimentConfigurationResponse( - @JsonProperty("flags") Map flags) { - this.flags = flags; - } - - public Map getFlags() { - return flags; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/IAssignmentLogger.java b/src/main/java/cloud/eppo/rac/dto/IAssignmentLogger.java deleted file mode 100644 index c2d95099..00000000 --- a/src/main/java/cloud/eppo/rac/dto/IAssignmentLogger.java +++ /dev/null @@ -1,6 +0,0 @@ -package cloud.eppo.rac.dto; - -/** Assignment Logger Interface */ -public interface IAssignmentLogger { - void logAssignment(AssignmentLogData logData); -} diff --git a/src/main/java/cloud/eppo/rac/dto/IBanditLogger.java b/src/main/java/cloud/eppo/rac/dto/IBanditLogger.java deleted file mode 100644 index 4084a44f..00000000 --- a/src/main/java/cloud/eppo/rac/dto/IBanditLogger.java +++ /dev/null @@ -1,6 +0,0 @@ -package cloud.eppo.rac.dto; - -/** Assignment Logger Interface */ -public interface IBanditLogger { - void logBanditAction(BanditLogData logData); -} diff --git a/src/main/java/cloud/eppo/rac/dto/OperatorType.java b/src/main/java/cloud/eppo/rac/dto/OperatorType.java deleted file mode 100644 index 41ca0f72..00000000 --- a/src/main/java/cloud/eppo/rac/dto/OperatorType.java +++ /dev/null @@ -1,18 +0,0 @@ -package cloud.eppo.rac.dto; - -/** Operation Supported */ -public enum OperatorType { - MATCHES("MATCHES"), - GTE("GTE"), - GT("GT"), - LTE("LTE"), - LT("LT"), - ONE_OF("ONE_OF"), - NOT_ONE_OF("NOT_ONE_OF"); - - public final String label; - - OperatorType(String label) { - this.label = label; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/Rule.java b/src/main/java/cloud/eppo/rac/dto/Rule.java deleted file mode 100644 index 0b8be468..00000000 --- a/src/main/java/cloud/eppo/rac/dto/Rule.java +++ /dev/null @@ -1,27 +0,0 @@ -package cloud.eppo.rac.dto; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; - -/** Rule Class */ -public class Rule { - private final String allocationKey; - private final List conditions; - - @JsonCreator - public Rule( - @JsonProperty("allocationKey") String allocationKey, - @JsonProperty("conditions") List conditions) { - this.allocationKey = allocationKey; - this.conditions = conditions; - } - - public String getAllocationKey() { - return allocationKey; - } - - public List getConditions() { - return conditions; - } -} diff --git a/src/main/java/cloud/eppo/rac/dto/Variation.java b/src/main/java/cloud/eppo/rac/dto/Variation.java deleted file mode 100644 index ab2e67cb..00000000 --- a/src/main/java/cloud/eppo/rac/dto/Variation.java +++ /dev/null @@ -1,45 +0,0 @@ -package cloud.eppo.rac.dto; - -import cloud.eppo.model.ShardRange; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** Experiment's Variation Class */ -public class Variation { - private final String name; - private EppoValue typedValue; - private final ShardRange shardRange; - private final AlgorithmType algorithmType; - - @JsonCreator - public Variation( - @JsonProperty("name") String name, - @JsonProperty("typedValue") EppoValue typedValue, - @JsonProperty("shardRange") ShardRange shardRange, - @JsonProperty("algorithmType") AlgorithmType algorithmType) { - this.name = name; - this.typedValue = typedValue; - this.shardRange = shardRange; - this.algorithmType = algorithmType; - } - - public String getName() { - return name; - } - - public EppoValue getTypedValue() { - return typedValue; - } - - public void setTypedValue(EppoValue typedValue) { - this.typedValue = typedValue; - } - - public ShardRange getShardRange() { - return shardRange; - } - - public AlgorithmType getAlgorithmType() { - return algorithmType; - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/EppoClientIsNotInitializedException.java b/src/main/java/cloud/eppo/rac/exception/EppoClientIsNotInitializedException.java deleted file mode 100644 index b4832639..00000000 --- a/src/main/java/cloud/eppo/rac/exception/EppoClientIsNotInitializedException.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class EppoClientIsNotInitializedException extends RuntimeException { - public EppoClientIsNotInitializedException(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/ExperimentConfigurationNotFound.java b/src/main/java/cloud/eppo/rac/exception/ExperimentConfigurationNotFound.java deleted file mode 100644 index e02b3ad4..00000000 --- a/src/main/java/cloud/eppo/rac/exception/ExperimentConfigurationNotFound.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class ExperimentConfigurationNotFound extends RuntimeException { - public ExperimentConfigurationNotFound(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/InvalidInputException.java b/src/main/java/cloud/eppo/rac/exception/InvalidInputException.java deleted file mode 100644 index 3a5f8148..00000000 --- a/src/main/java/cloud/eppo/rac/exception/InvalidInputException.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class InvalidInputException extends RuntimeException { - public InvalidInputException(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/InvalidSubjectAttribute.java b/src/main/java/cloud/eppo/rac/exception/InvalidSubjectAttribute.java deleted file mode 100644 index 0866ee5c..00000000 --- a/src/main/java/cloud/eppo/rac/exception/InvalidSubjectAttribute.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class InvalidSubjectAttribute extends RuntimeException { - public InvalidSubjectAttribute(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/NetworkException.java b/src/main/java/cloud/eppo/rac/exception/NetworkException.java deleted file mode 100644 index 0c8876f1..00000000 --- a/src/main/java/cloud/eppo/rac/exception/NetworkException.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class NetworkException extends RuntimeException { - public NetworkException(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/NetworkRequestNotAllowed.java b/src/main/java/cloud/eppo/rac/exception/NetworkRequestNotAllowed.java deleted file mode 100644 index 601c88e2..00000000 --- a/src/main/java/cloud/eppo/rac/exception/NetworkRequestNotAllowed.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class NetworkRequestNotAllowed extends RuntimeException { - public NetworkRequestNotAllowed(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/rac/exception/UnsupportedEppoValue.java b/src/main/java/cloud/eppo/rac/exception/UnsupportedEppoValue.java deleted file mode 100644 index 6608167c..00000000 --- a/src/main/java/cloud/eppo/rac/exception/UnsupportedEppoValue.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo.rac.exception; - -public class UnsupportedEppoValue extends RuntimeException { - public UnsupportedEppoValue(String message) { - super(message); - } -} diff --git a/src/main/java/cloud/eppo/ufc/dto/Actions.java b/src/main/java/cloud/eppo/ufc/dto/Actions.java new file mode 100644 index 00000000..59910862 --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/Actions.java @@ -0,0 +1,5 @@ +package cloud.eppo.ufc.dto; + +import java.util.Map; + +public interface Actions extends Map {} diff --git a/src/main/java/cloud/eppo/ufc/dto/Attributes.java b/src/main/java/cloud/eppo/ufc/dto/Attributes.java new file mode 100644 index 00000000..ddb8e48a --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/Attributes.java @@ -0,0 +1,64 @@ +package cloud.eppo.ufc.dto; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public class Attributes extends HashMap implements DiscriminableAttributes { + public Attributes() { + super(); + } + + public Attributes(Map startingAttributes) { + super(startingAttributes); + } + + public EppoValue put(String key, String value) { + return super.put(key, EppoValue.valueOf(value)); + } + + public EppoValue put(String key, int value) { + return super.put(key, EppoValue.valueOf(value)); + } + + public EppoValue put(String key, long value) { + return super.put(key, EppoValue.valueOf(value)); + } + + public EppoValue put(String key, float value) { + return super.put(key, EppoValue.valueOf(value)); + } + + public EppoValue put(String key, double value) { + return super.put(key, EppoValue.valueOf(value)); + } + + public EppoValue put(String key, boolean value) { + return super.put(key, EppoValue.valueOf(value)); + } + + @Override + public Attributes getNumericAttributes() { + Map numericValuesOnly = + super.entrySet().stream() + .filter(entry -> entry.getValue().isNumeric()) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + + return new Attributes(numericValuesOnly); + } + + @Override + public Attributes getCategoricalAttributes() { + Map nonNullNonNumericValuesOnly = + super.entrySet().stream() + .filter(entry -> !entry.getValue().isNumeric() && !entry.getValue().isNull()) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + + return new Attributes(nonNullNonNumericValuesOnly); + } + + @Override + public Attributes getAllAttributes() { + return this; + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditActions.java b/src/main/java/cloud/eppo/ufc/dto/BanditActions.java new file mode 100644 index 00000000..5536d8c7 --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/BanditActions.java @@ -0,0 +1,20 @@ +package cloud.eppo.ufc.dto; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class BanditActions extends HashMap implements Actions { + public BanditActions() { + super(); + } + + public BanditActions(Map actionsWithContext) { + super(actionsWithContext); + } + + public BanditActions(Set actionKeys) { + this(actionKeys.stream().collect(Collectors.toMap(s -> s, s -> new ContextAttributes()))); + } +} diff --git a/src/main/java/cloud/eppo/rac/dto/AttributeCoefficients.java b/src/main/java/cloud/eppo/ufc/dto/BanditAttributeCoefficients.java similarity index 54% rename from src/main/java/cloud/eppo/rac/dto/AttributeCoefficients.java rename to src/main/java/cloud/eppo/ufc/dto/BanditAttributeCoefficients.java index 8b50efe0..8acca462 100644 --- a/src/main/java/cloud/eppo/rac/dto/AttributeCoefficients.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditAttributeCoefficients.java @@ -1,6 +1,7 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; + +public interface BanditAttributeCoefficients { -public interface AttributeCoefficients { String getAttributeKey(); double scoreForAttributeValue(EppoValue attributeValue); diff --git a/src/main/java/cloud/eppo/rac/dto/BanditCategoricalAttributeCoefficients.java b/src/main/java/cloud/eppo/ufc/dto/BanditCategoricalAttributeCoefficients.java similarity index 92% rename from src/main/java/cloud/eppo/rac/dto/BanditCategoricalAttributeCoefficients.java rename to src/main/java/cloud/eppo/ufc/dto/BanditCategoricalAttributeCoefficients.java index 8cae6dd9..e0e1bdf3 100644 --- a/src/main/java/cloud/eppo/rac/dto/BanditCategoricalAttributeCoefficients.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditCategoricalAttributeCoefficients.java @@ -1,10 +1,10 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class BanditCategoricalAttributeCoefficients implements AttributeCoefficients { +public class BanditCategoricalAttributeCoefficients implements BanditAttributeCoefficients { private final Logger logger = LoggerFactory.getLogger(BanditCategoricalAttributeCoefficients.class); private final String attributeKey; @@ -18,6 +18,11 @@ public BanditCategoricalAttributeCoefficients( this.valueCoefficients = valueCoefficients; } + @Override + public String getAttributeKey() { + return attributeKey; + } + public double scoreForAttributeValue(EppoValue attributeValue) { if (attributeValue == null || attributeValue.isNull()) { return missingValueCoefficient; @@ -35,11 +40,6 @@ public double scoreForAttributeValue(EppoValue attributeValue) { return coefficient != null ? coefficient : missingValueCoefficient; } - @Override - public String getAttributeKey() { - return attributeKey; - } - public Double getMissingValueCoefficient() { return missingValueCoefficient; } diff --git a/src/main/java/cloud/eppo/rac/dto/BanditCoefficients.java b/src/main/java/cloud/eppo/ufc/dto/BanditCoefficients.java similarity index 77% rename from src/main/java/cloud/eppo/rac/dto/BanditCoefficients.java rename to src/main/java/cloud/eppo/ufc/dto/BanditCoefficients.java index c0241af3..e5c30fb9 100644 --- a/src/main/java/cloud/eppo/rac/dto/BanditCoefficients.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditCoefficients.java @@ -1,4 +1,4 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; import java.util.Map; @@ -13,16 +13,16 @@ public class BanditCoefficients { public BanditCoefficients( String actionKey, Double intercept, - Map subjectNumericCoefficients, - Map subjectCategoricalCoefficients, - Map actionNumericCoefficients, - Map actionCategoricalCoefficients) { + Map subjectNumericAttributeCoefficients, + Map subjectCategoricalAttributeCoefficients, + Map actionNumericAttributeCoefficients, + Map actionCategoricalAttributeCoefficients) { this.actionKey = actionKey; this.intercept = intercept; - this.subjectNumericCoefficients = subjectNumericCoefficients; - this.subjectCategoricalCoefficients = subjectCategoricalCoefficients; - this.actionNumericCoefficients = actionNumericCoefficients; - this.actionCategoricalCoefficients = actionCategoricalCoefficients; + this.subjectNumericCoefficients = subjectNumericAttributeCoefficients; + this.subjectCategoricalCoefficients = subjectCategoricalAttributeCoefficients; + this.actionNumericCoefficients = actionNumericAttributeCoefficients; + this.actionCategoricalCoefficients = actionCategoricalAttributeCoefficients; } public String getActionKey() { diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java b/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java new file mode 100644 index 00000000..fa515140 --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/BanditFlagVariation.java @@ -0,0 +1,42 @@ +package cloud.eppo.ufc.dto; + +public class BanditFlagVariation { + private final String banditKey; + private final String flagKey; + private final String allocationKey; + private final String variationKey; + private final String variationValue; + + public BanditFlagVariation( + String banditKey, + String flagKey, + String allocationKey, + String variationKey, + String variationValue) { + this.banditKey = banditKey; + this.flagKey = flagKey; + this.allocationKey = allocationKey; + this.variationKey = variationKey; + this.variationValue = variationValue; + } + + public String getBanditKey() { + return banditKey; + } + + public String getFlagKey() { + return flagKey; + } + + public String getAllocationKey() { + return allocationKey; + } + + public String getVariationKey() { + return variationKey; + } + + public String getVariationValue() { + return variationValue; + } +} diff --git a/src/main/java/cloud/eppo/rac/dto/BanditModelData.java b/src/main/java/cloud/eppo/ufc/dto/BanditModelData.java similarity index 96% rename from src/main/java/cloud/eppo/rac/dto/BanditModelData.java rename to src/main/java/cloud/eppo/ufc/dto/BanditModelData.java index ce328397..2897fbaa 100644 --- a/src/main/java/cloud/eppo/rac/dto/BanditModelData.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditModelData.java @@ -1,4 +1,4 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; import java.util.Map; diff --git a/src/main/java/cloud/eppo/rac/dto/BanditNumericAttributeCoefficients.java b/src/main/java/cloud/eppo/ufc/dto/BanditNumericAttributeCoefficients.java similarity index 90% rename from src/main/java/cloud/eppo/rac/dto/BanditNumericAttributeCoefficients.java rename to src/main/java/cloud/eppo/ufc/dto/BanditNumericAttributeCoefficients.java index 149d778f..86ba7c3a 100644 --- a/src/main/java/cloud/eppo/rac/dto/BanditNumericAttributeCoefficients.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditNumericAttributeCoefficients.java @@ -1,9 +1,9 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class BanditNumericAttributeCoefficients implements AttributeCoefficients { +public class BanditNumericAttributeCoefficients implements BanditAttributeCoefficients { private final Logger logger = LoggerFactory.getLogger(BanditNumericAttributeCoefficients.class); private final String attributeKey; private final Double coefficient; @@ -16,6 +16,11 @@ public BanditNumericAttributeCoefficients( this.missingValueCoefficient = missingValueCoefficient; } + @Override + public String getAttributeKey() { + return attributeKey; + } + @Override public double scoreForAttributeValue(EppoValue attributeValue) { if (attributeValue == null || attributeValue.isNull()) { @@ -27,11 +32,6 @@ public double scoreForAttributeValue(EppoValue attributeValue) { return coefficient * attributeValue.doubleValue(); } - @Override - public String getAttributeKey() { - return attributeKey; - } - public Double getCoefficient() { return coefficient; } diff --git a/src/main/java/cloud/eppo/rac/dto/BanditParameters.java b/src/main/java/cloud/eppo/ufc/dto/BanditParameters.java similarity index 96% rename from src/main/java/cloud/eppo/rac/dto/BanditParameters.java rename to src/main/java/cloud/eppo/ufc/dto/BanditParameters.java index 0b61dc84..e0e3374a 100644 --- a/src/main/java/cloud/eppo/rac/dto/BanditParameters.java +++ b/src/main/java/cloud/eppo/ufc/dto/BanditParameters.java @@ -1,4 +1,4 @@ -package cloud.eppo.rac.dto; +package cloud.eppo.ufc.dto; import java.util.Date; diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditParametersResponse.java b/src/main/java/cloud/eppo/ufc/dto/BanditParametersResponse.java new file mode 100644 index 00000000..9b64f0e8 --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/BanditParametersResponse.java @@ -0,0 +1,21 @@ +package cloud.eppo.ufc.dto; + +import java.util.HashMap; +import java.util.Map; + +public class BanditParametersResponse { + + private final Map bandits; + + public BanditParametersResponse() { + this.bandits = new HashMap<>(); + } + + public BanditParametersResponse(Map bandits) { + this.bandits = bandits; + } + + public Map getBandits() { + return bandits; + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditReference.java b/src/main/java/cloud/eppo/ufc/dto/BanditReference.java new file mode 100644 index 00000000..2879580e --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/BanditReference.java @@ -0,0 +1,21 @@ +package cloud.eppo.ufc.dto; + +import java.util.List; + +public class BanditReference { + private final String modelVersion; + private final List flagVariations; + + public BanditReference(String modelVersion, List flagVariations) { + this.modelVersion = modelVersion; + this.flagVariations = flagVariations; + } + + public String getModelVersion() { + return modelVersion; + } + + public List getFlagVariations() { + return flagVariations; + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/BanditResult.java b/src/main/java/cloud/eppo/ufc/dto/BanditResult.java new file mode 100644 index 00000000..ad5d7a3a --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/BanditResult.java @@ -0,0 +1,19 @@ +package cloud.eppo.ufc.dto; + +public class BanditResult { + private final String variation; + private final String action; + + public BanditResult(String variation, String action) { + this.variation = variation; + this.action = action; + } + + public String getVariation() { + return variation; + } + + public String getAction() { + return action; + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/ContextAttributes.java b/src/main/java/cloud/eppo/ufc/dto/ContextAttributes.java new file mode 100644 index 00000000..05700f32 --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/ContextAttributes.java @@ -0,0 +1,45 @@ +package cloud.eppo.ufc.dto; + +import java.util.HashMap; +import java.util.Map; + +public class ContextAttributes implements DiscriminableAttributes { + + private Attributes numericAttributes; + private Attributes categoricalAttributes; + + public ContextAttributes() { + this(new Attributes(), new Attributes()); + } + + public ContextAttributes(Attributes numericAttributes, Attributes categoricalAttributes) { + this.numericAttributes = numericAttributes; + this.categoricalAttributes = categoricalAttributes; + } + + @Override + public Attributes getNumericAttributes() { + return numericAttributes; + } + + public void setNumericAttributes(Attributes numericAttributes) { + this.numericAttributes = numericAttributes; + } + + @Override + public Attributes getCategoricalAttributes() { + return categoricalAttributes; + } + + public void setCategoricalAttributes(Attributes categoricalAttributes) { + this.categoricalAttributes = categoricalAttributes; + } + + @Override + public Attributes getAllAttributes() { + Map allAttributes = new HashMap<>(); + allAttributes.putAll(numericAttributes); + allAttributes.putAll(categoricalAttributes); + return new Attributes(allAttributes); + } +} diff --git a/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java b/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java new file mode 100644 index 00000000..80b5ee3e --- /dev/null +++ b/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java @@ -0,0 +1,10 @@ +package cloud.eppo.ufc.dto; + +public interface DiscriminableAttributes { + + Attributes getNumericAttributes(); + + Attributes getCategoricalAttributes(); + + Attributes getAllAttributes(); +} diff --git a/src/main/java/cloud/eppo/ufc/dto/EppoValue.java b/src/main/java/cloud/eppo/ufc/dto/EppoValue.java index 358b579a..31730bec 100644 --- a/src/main/java/cloud/eppo/ufc/dto/EppoValue.java +++ b/src/main/java/cloud/eppo/ufc/dto/EppoValue.java @@ -1,6 +1,7 @@ package cloud.eppo.ufc.dto; import java.util.List; +import java.util.Objects; public class EppoValue { protected final EppoValueType type; @@ -107,4 +108,25 @@ public String toString() { "Cannot stringify Eppo Value type " + this.type.name()); } } + + @Override + public boolean equals(Object otherObject) { + if (this == otherObject) { + return true; + } + if (otherObject == null || getClass() != otherObject.getClass()) { + return false; + } + EppoValue otherEppoValue = (EppoValue) otherObject; + return type == otherEppoValue.type + && Objects.equals(boolValue, otherEppoValue.boolValue) + && Objects.equals(doubleValue, otherEppoValue.doubleValue) + && Objects.equals(stringValue, otherEppoValue.stringValue) + && Objects.equals(stringArrayValue, otherEppoValue.stringArrayValue); + } + + @Override + public int hashCode() { + return Objects.hash(type, boolValue, doubleValue, stringValue, stringArrayValue); + } } diff --git a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java index 653321b8..3f8cbb8e 100644 --- a/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java +++ b/src/main/java/cloud/eppo/ufc/dto/FlagConfigResponse.java @@ -5,16 +5,23 @@ public class FlagConfigResponse { private final Map flags; + private final Map banditReferences; - public FlagConfigResponse(Map flags) { + public FlagConfigResponse( + Map flags, Map banditReferences) { this.flags = flags; + this.banditReferences = banditReferences; } public FlagConfigResponse() { - this(new ConcurrentHashMap<>()); + this(new ConcurrentHashMap<>(), new ConcurrentHashMap<>()); } public Map getFlags() { return this.flags; } + + public Map getBanditReferences() { + return this.banditReferences; + } } diff --git a/src/main/java/cloud/eppo/ufc/dto/SubjectAttributes.java b/src/main/java/cloud/eppo/ufc/dto/SubjectAttributes.java deleted file mode 100644 index 998d94f1..00000000 --- a/src/main/java/cloud/eppo/ufc/dto/SubjectAttributes.java +++ /dev/null @@ -1,30 +0,0 @@ -package cloud.eppo.ufc.dto; - -import java.util.HashMap; -import java.util.Map; - -public class SubjectAttributes extends HashMap { - public SubjectAttributes() { - super(); - } - - public SubjectAttributes(Map startingAttributes) { - super(startingAttributes); - } - - public EppoValue put(String key, String value) { - return super.put(key, EppoValue.valueOf(value)); - } - - public EppoValue put(String key, int value) { - return super.put(key, EppoValue.valueOf(value)); - } - - public EppoValue put(String key, long value) { - return super.put(key, EppoValue.valueOf(value)); - } - - public EppoValue put(String key, boolean value) { - return super.put(key, EppoValue.valueOf(value)); - } -} diff --git a/src/main/java/cloud/eppo/rac/deserializer/BanditsDeserializer.java b/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java similarity index 87% rename from src/main/java/cloud/eppo/rac/deserializer/BanditsDeserializer.java rename to src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java index 9690e065..6913c387 100644 --- a/src/main/java/cloud/eppo/rac/deserializer/BanditsDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java @@ -1,6 +1,6 @@ -package cloud.eppo.rac.deserializer; +package cloud.eppo.ufc.dto.adapters; -import cloud.eppo.rac.dto.*; +import cloud.eppo.ufc.dto.*; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; @@ -11,21 +11,38 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BanditParametersResponseDeserializer + extends StdDeserializer { + private static final Logger log = + LoggerFactory.getLogger(BanditParametersResponseDeserializer.class); -public class BanditsDeserializer extends StdDeserializer> { // Note: public default constructor is required by Jackson - public BanditsDeserializer() { + public BanditParametersResponseDeserializer() { this(null); } - protected BanditsDeserializer(Class vc) { + protected BanditParametersResponseDeserializer(Class vc) { super(vc); } @Override - public Map deserialize( + public BanditParametersResponse deserialize( JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - JsonNode banditsNode = jsonParser.getCodec().readTree(jsonParser); + JsonNode rootNode = jsonParser.getCodec().readTree(jsonParser); + if (rootNode == null || !rootNode.isObject()) { + log.warn("no top-level JSON object"); + return new BanditParametersResponse(); + } + + JsonNode banditsNode = rootNode.get("bandits"); + if (banditsNode == null || !banditsNode.isObject()) { + log.warn("no root-level bandits object"); + return new BanditParametersResponse(); + } + Map bandits = new HashMap<>(); banditsNode .iterator() @@ -59,7 +76,8 @@ public Map deserialize( new BanditParameters(banditKey, updatedAt, modelName, modelVersion, modelData); bandits.put(banditKey, parameters); }); - return bandits; + + return new BanditParametersResponse(bandits); } private BanditCoefficients parseActionCoefficientsNode(JsonNode actionCoefficientsNode) { diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java index 4147b86d..50d9fc6e 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java @@ -1,5 +1,6 @@ package cloud.eppo.ufc.dto.adapters; +import cloud.eppo.ufc.dto.BanditParametersResponse; import cloud.eppo.ufc.dto.EppoValue; import cloud.eppo.ufc.dto.FlagConfigResponse; import com.fasterxml.jackson.databind.module.SimpleModule; @@ -9,9 +10,12 @@ public class EppoModule { public static SimpleModule eppoModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(FlagConfigResponse.class, new FlagConfigResponseDeserializer()); + module.addDeserializer( + BanditParametersResponse.class, new BanditParametersResponseDeserializer()); module.addDeserializer(EppoValue.class, new EppoValueDeserializer()); module.addSerializer(EppoValue.class, new EppoValueSerializer()); module.addSerializer(Date.class, new DateSerializer()); + // TODO: add bandit deserializer return module; } } 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 3001fe70..98a50e0e 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -1,6 +1,6 @@ package cloud.eppo.ufc.dto.adapters; -import static cloud.eppo.Utils.parseUtcISODateElement; +import static cloud.eppo.Utils.parseUtcISODateNode; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.*; @@ -9,7 +9,6 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -36,41 +35,59 @@ public FlagConfigResponseDeserializer() { @Override public FlagConfigResponse deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JacksonException { - JsonNode rootElement = jp.getCodec().readTree(jp); + JsonNode rootNode = jp.getCodec().readTree(jp); - if (rootElement == null || !rootElement.isObject()) { + if (rootNode == null || !rootNode.isObject()) { log.warn("no top-level JSON object"); return new FlagConfigResponse(); } - ObjectNode rootObject = (ObjectNode) rootElement; - JsonNode flagsElement = rootObject.get("flags"); - if (flagsElement == null) { - log.warn("no root-level flags property"); + JsonNode flagsNode = rootNode.get("flags"); + if (flagsNode == null || !flagsNode.isObject()) { + log.warn("no root-level flags object"); return new FlagConfigResponse(); } + Map flags = new ConcurrentHashMap<>(); - ObjectNode flagsObject = (ObjectNode) flagsElement; - for (Map.Entry flagEntry : flagsObject.properties()) { - FlagConfig flagConfig = deserializeFlag(flagEntry.getValue(), ctxt); - flags.put(flagEntry.getKey(), flagConfig); + + flagsNode + .fields() + .forEachRemaining( + field -> { + FlagConfig flagConfig = deserializeFlag(field.getValue()); + flags.put(field.getKey(), flagConfig); + }); + + Map banditReferences = new ConcurrentHashMap<>(); + if (rootNode.has("banditReferences")) { + JsonNode banditReferencesNode = rootNode.get("banditReferences"); + if (!banditReferencesNode.isObject()) { + log.warn("root-level banditReferences property is present but not a JSON object"); + } else { + banditReferencesNode + .fields() + .forEachRemaining( + field -> { + BanditReference banditReference = deserializeBanditReference(field.getValue()); + banditReferences.put(field.getKey(), banditReference); + }); + } } - return new FlagConfigResponse(flags); + return new FlagConfigResponse(flags, banditReferences); } - private FlagConfig deserializeFlag(JsonNode jsonNode, DeserializationContext context) { + private FlagConfig deserializeFlag(JsonNode jsonNode) { 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"), context); - List allocations = deserializeAllocations(jsonNode.get("allocations"), context); + Map variations = deserializeVariations(jsonNode.get("variations")); + List allocations = deserializeAllocations(jsonNode.get("allocations")); return new FlagConfig(key, enabled, totalShards, variationType, variations, allocations); } - private Map deserializeVariations( - JsonNode jsonNode, DeserializationContext context) { + private Map deserializeVariations(JsonNode jsonNode) { Map variations = new HashMap<>(); if (jsonNode == null) { return variations; @@ -84,16 +101,16 @@ private Map deserializeVariations( return variations; } - private List deserializeAllocations(JsonNode jsonNode, DeserializationContext ctxt) { + private List deserializeAllocations(JsonNode jsonNode) { 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"), ctxt); - Date startAt = parseUtcISODateElement(allocationNode.get("startAt")); - Date endAt = parseUtcISODateElement(allocationNode.get("endAt")); + Set rules = deserializeTargetingRules(allocationNode.get("rules")); + Date startAt = parseUtcISODateNode(allocationNode.get("startAt")); + Date endAt = parseUtcISODateNode(allocationNode.get("endAt")); List splits = deserializeSplits(allocationNode.get("splits")); boolean doLog = allocationNode.get("doLog").asBoolean(); allocations.add(new Allocation(key, rules, startAt, endAt, splits, doLog)); @@ -101,8 +118,7 @@ private List deserializeAllocations(JsonNode jsonNode, Deserializati return allocations; } - private Set deserializeTargetingRules( - JsonNode jsonNode, DeserializationContext context) { + private Set deserializeTargetingRules(JsonNode jsonNode) { Set targetingRules = new HashSet<>(); if (jsonNode == null || !jsonNode.isArray()) { return targetingRules; @@ -165,4 +181,24 @@ 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"); + 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 = + new BanditFlagVariation( + banditKey, flagKey, allocationKey, variationKey, variationValue); + flagVariations.add(flagVariation); + } + } + return new BanditReference(modelVersion, flagVariations); + } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java new file mode 100644 index 00000000..5d93c1af --- /dev/null +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -0,0 +1,287 @@ +package cloud.eppo; + +import static cloud.eppo.helpers.BanditTestCase.parseBanditTestCaseFile; +import static cloud.eppo.helpers.BanditTestCase.runBanditTestCase; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import cloud.eppo.helpers.*; +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.logging.BanditAssignment; +import cloud.eppo.logging.BanditLogger; +import cloud.eppo.ufc.dto.*; +import java.io.File; +import java.util.*; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BaseEppoClientBanditTest { + private static final Logger log = LoggerFactory.getLogger(BaseEppoClientBanditTest.class); + private static final String DUMMY_BANDIT_API_KEY = + "dummy-bandits-api-key"; // Will load bandit-flags-v1 + private static final String TEST_HOST = + "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; + + private static final AssignmentLogger mockAssignmentLogger = mock(AssignmentLogger.class); + private static final BanditLogger mockBanditLogger = mock(BanditLogger.class); + private static final Date testStart = new Date(); + + private static BaseEppoClient eppoClient; + + // TODO: possibly consolidate code between this and the non-bandit test + + @BeforeAll + public static void initClient() { + eppoClient = + new BaseEppoClient( + DUMMY_BANDIT_API_KEY, + "java", + "3.0.0", + TEST_HOST, + mockAssignmentLogger, + mockBanditLogger, + false, + false); + + eppoClient.loadConfiguration(); + + log.info("Test client initialized"); + } + + @BeforeEach + public void reset() { + clearInvocations(mockAssignmentLogger); + clearInvocations(mockBanditLogger); + doNothing().when(mockBanditLogger).logBanditAssignment(any()); + eppoClient.setIsGracefulFailureMode(false); + } + + @ParameterizedTest + @MethodSource("getBanditTestData") + public void testUnobfuscatedBanditAssignments(File testFile) { + BanditTestCase testCase = parseBanditTestCaseFile(testFile); + runBanditTestCase(testCase, eppoClient); + } + + public static Stream getBanditTestData() { + return BanditTestCase.getBanditTestData(); + } + + @SuppressWarnings("ExtractMethodRecommender") + @Test + public void testBanditLogsAction() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = new BanditActions(); + + Attributes nikeAttributes = new Attributes(); + nikeAttributes.put("brand_affinity", 1.5); + nikeAttributes.put("loyalty_tier", "silver"); + actions.put("nike", nikeAttributes); + + Attributes adidasAttributes = new Attributes(); + adidasAttributes.put("brand_affinity", -1.0); + adidasAttributes.put("loyalty_tier", "bronze"); + actions.put("adidas", adidasAttributes); + + Attributes rebookAttributes = new Attributes(); + rebookAttributes.put("brand_affinity", 0.5); + rebookAttributes.put("loyalty_tier", "gold"); + actions.put("reebok", rebookAttributes); + + BanditResult banditResult = + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment + assertEquals("banner_bandit", banditResult.getVariation()); + assertEquals("adidas", banditResult.getAction()); + + Date inTheNearFuture = new Date(System.currentTimeMillis() + 1); + + // Verify experiment assignment log + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + Assignment capturedAssignment = assignmentLogCaptor.getValue(); + assertTrue(capturedAssignment.getTimestamp().after(testStart)); + assertTrue(capturedAssignment.getTimestamp().before(inTheNearFuture)); + assertEquals("banner_bandit_flag-training", capturedAssignment.getExperiment()); + assertEquals(flagKey, capturedAssignment.getFeatureFlag()); + assertEquals("training", capturedAssignment.getAllocation()); + assertEquals("banner_bandit", capturedAssignment.getVariation()); + assertEquals(subjectKey, capturedAssignment.getSubject()); + assertEquals(subjectAttributes, capturedAssignment.getSubjectAttributes()); + assertEquals("false", capturedAssignment.getMetaData().get("obfuscated")); + + // Verify bandit log + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); + BanditAssignment capturedBanditAssignment = banditLogCaptor.getValue(); + assertTrue(capturedBanditAssignment.getTimestamp().after(testStart)); + assertTrue(capturedBanditAssignment.getTimestamp().before(inTheNearFuture)); + assertEquals(flagKey, capturedBanditAssignment.getFeatureFlag()); + assertEquals("banner_bandit", capturedBanditAssignment.getBandit()); + assertEquals(subjectKey, capturedBanditAssignment.getSubject()); + assertEquals("adidas", capturedBanditAssignment.getAction()); + assertEquals(0.099, capturedBanditAssignment.getActionProbability(), 0.0002); + assertEquals("v123", capturedBanditAssignment.getModelVersion()); + + Attributes expectedSubjectNumericAttributes = new Attributes(); + expectedSubjectNumericAttributes.put("age", 25); + assertEquals( + expectedSubjectNumericAttributes, capturedBanditAssignment.getSubjectNumericAttributes()); + + Attributes expectedSubjectCategoricalAttributes = new Attributes(); + expectedSubjectCategoricalAttributes.put("country", "USA"); + expectedSubjectCategoricalAttributes.put("gender_identity", "female"); + assertEquals( + expectedSubjectCategoricalAttributes, + capturedBanditAssignment.getSubjectCategoricalAttributes()); + + Attributes expectedActionNumericAttributes = new Attributes(); + expectedActionNumericAttributes.put("brand_affinity", -1.0); + assertEquals( + expectedActionNumericAttributes, capturedBanditAssignment.getActionNumericAttributes()); + + Attributes expectedActionCategoricalAttributes = new Attributes(); + expectedActionCategoricalAttributes.put("loyalty_tier", "bronze"); + assertEquals( + expectedActionCategoricalAttributes, + capturedBanditAssignment.getActionCategoricalAttributes()); + + assertEquals("false", capturedBanditAssignment.getMetaData().get("obfuscated")); + } + + @Test + public void testNoBanditLogsWhenNotBandit() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "anthony"; + Attributes subjectAttributes = new Attributes(); + + BanditActions actions = new BanditActions(); + actions.put("nike", new Attributes()); + actions.put("adidas", new Attributes()); + + BanditResult banditResult = + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "default"); + + // Verify assignment + assertEquals("control", banditResult.getVariation()); + assertNull(banditResult.getAction()); + + // Assignment won't log because the "analysis" allocation has doLog set to false + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(0)).logAssignment(assignmentLogCaptor.capture()); + // Bandit won't log because no bandit action was taken + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(0)).logBanditAssignment(banditLogCaptor.capture()); + } + + @Test + public void testNoBanditLogsWhenNoActions() { + String flagKey = "banner_bandit_flag"; + String subjectKey = "bob"; + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", 25); + subjectAttributes.put("country", "USA"); + subjectAttributes.put("gender_identity", "female"); + + BanditActions actions = new BanditActions(); + + BanditResult banditResult = + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); + + // Verify assignment + assertEquals("banner_bandit", banditResult.getVariation()); + assertNull(banditResult.getAction()); + + // The variation assignment should have been logged + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + + // No bandit log since no actions to consider + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(0)).logBanditAssignment(banditLogCaptor.capture()); + } + + @Test + public void testBanditErrorGracefulModeOff() { + eppoClient.setIsGracefulFailureMode( + false); // Should be set by @BeforeEach but repeated here for test clarity + try (MockedStatic mockedStatic = mockStatic(BanditEvaluator.class)) { + // Configure the mock to throw an exception + mockedStatic + .when(() -> BanditEvaluator.evaluateBandit(anyString(), anyString(), any(), any(), any())) + .thenThrow(new RuntimeException("Intentional Bandit Error")); + + // Assert that the exception is thrown when the method is called + BanditActions actions = new BanditActions(); + actions.put("nike", new Attributes()); + actions.put("adidas", new Attributes()); + assertThrows( + RuntimeException.class, + () -> + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default")); + } + } + + @Test + public void testBanditErrorGracefulModeOn() { + eppoClient.setIsGracefulFailureMode(true); + try (MockedStatic mockedStatic = mockStatic(BanditEvaluator.class)) { + // Configure the mock to throw an exception + mockedStatic + .when(() -> BanditEvaluator.evaluateBandit(anyString(), anyString(), any(), any(), any())) + .thenThrow(new RuntimeException("Intentional Bandit Error")); + + // Assert that the exception is thrown when the method is called + BanditActions actions = new BanditActions(); + actions.put("nike", new Attributes()); + actions.put("adidas", new Attributes()); + BanditResult banditResult = + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default"); + assertEquals("banner_bandit", banditResult.getVariation()); + assertNull(banditResult.getAction()); + } + } + + @Test + public void testBanditLogErrorNonFatal() { + initClient(); + doThrow(new RuntimeException("Mock Bandit Logging Error")) + .when(mockBanditLogger) + .logBanditAssignment(any()); + + BanditActions actions = new BanditActions(); + actions.put("nike", new Attributes()); + actions.put("adidas", new Attributes()); + BanditResult banditResult = + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default"); + assertEquals("banner_bandit", banditResult.getVariation()); + assertEquals("nike", banditResult.getAction()); + + ArgumentCaptor banditLogCaptor = + ArgumentCaptor.forClass(BanditAssignment.class); + verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); + } +} diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java new file mode 100644 index 00000000..80670daf --- /dev/null +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -0,0 +1,263 @@ +package cloud.eppo; + +import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile; +import static cloud.eppo.helpers.AssignmentTestCase.runTestCase; +import static cloud.eppo.helpers.TestUtils.mockHttpResponse; +import static cloud.eppo.helpers.TestUtils.setBaseClientHttpClientOverrideField; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import cloud.eppo.helpers.AssignmentTestCase; +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.VariationType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.util.*; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BaseEppoClientTest { + private static final Logger log = LoggerFactory.getLogger(BaseEppoClientTest.class); + private static final String DUMMY_FLAG_API_KEY = "dummy-flags-api-key"; // Will load flags-v1 + private static final String TEST_HOST = + "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; + private final ObjectMapper mapper = + new ObjectMapper().registerModule(AssignmentTestCase.assignmentTestCaseModule()); + + private BaseEppoClient eppoClient; + private AssignmentLogger mockAssignmentLogger; + + // TODO: async init client tests + + private void initClient() { + initClient(false, false); + } + + private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { + mockAssignmentLogger = mock(AssignmentLogger.class); + + eppoClient = + new BaseEppoClient( + DUMMY_FLAG_API_KEY, + isConfigObfuscated ? "android" : "java", + "3.0.0", + TEST_HOST, + mockAssignmentLogger, + null, + isGracefulMode, + isConfigObfuscated); + + eppoClient.loadConfiguration(); + log.info("Test client initialized"); + } + + @BeforeEach + public void cleanUp() { + // TODO: Clear any caches + setBaseClientHttpClientOverrideField(null); + } + + @ParameterizedTest + @MethodSource("getAssignmentTestData") + public void testUnobfuscatedAssignments(File testFile) { + initClient(false, false); + AssignmentTestCase testCase = parseTestCaseFile(testFile); + runTestCase(testCase, eppoClient); + } + + @ParameterizedTest + @MethodSource("getAssignmentTestData") + public void testObfuscatedAssignments(File testFile) { + initClient(false, true); + AssignmentTestCase testCase = parseTestCaseFile(testFile); + runTestCase(testCase, eppoClient); + } + + private static Stream getAssignmentTestData() { + return AssignmentTestCase.getAssignmentTestData(); + } + + @Test + public void testErrorGracefulModeOn() throws JsonProcessingException { + initClient(true, false); + + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); + doThrow(new RuntimeException("Exception thrown by mock")) + .when(spyClient) + .getTypedAssignment( + 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 testErrorGracefulModeOff() { + initClient(false, false); + + BaseEppoClient realClient = eppoClient; + BaseEppoClient spyClient = spy(realClient); + doThrow(new RuntimeException("Exception thrown by mock")) + .when(spyClient) + .getTypedAssignment( + 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() { + + mockHttpResponse(TEST_HOST, "{}"); + + initClient(false, false); + + String result = eppoClient.getStringAssignment("dummy subject", "dummy flag", "not-populated"); + assertEquals("not-populated", result); + } + + @Test + public void testAssignmentEventCorrectlyCreated() { + Date testStart = new Date(); + initClient(); + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("age", EppoValue.valueOf(30)); + subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); + double assignment = + eppoClient.getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); + + assertEquals(3.1415926, assignment, 0.0000001); + + 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", "3.0.0"); + + assertEquals(expectedMeta, capturedAssignment.getMetaData()); + } + + @Test + public void testAssignmentLogErrorNonFatal() { + initClient(); + doThrow(new RuntimeException("Mock Assignment Logging Error")) + .when(mockAssignmentLogger) + .logAssignment(any()); + double assignment = + eppoClient.getDoubleAssignment("numeric_flag", "alice", new Attributes(), 0.0); + + assertEquals(3.1415926, assignment, 0.0000001); + + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + } + + // TODO: tests for the cache +} diff --git a/src/test/java/cloud/eppo/EppoValueTest.java b/src/test/java/cloud/eppo/EppoValueTest.java new file mode 100644 index 00000000..19b7dfe7 --- /dev/null +++ b/src/test/java/cloud/eppo/EppoValueTest.java @@ -0,0 +1,14 @@ +package cloud.eppo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cloud.eppo.ufc.dto.EppoValue; +import org.junit.jupiter.api.Test; + +public class EppoValueTest { + @Test + public void testDoubleValue() { + EppoValue eppoValue = EppoValue.valueOf(123.4567); + assertEquals(123.4567, eppoValue.doubleValue(), 0.0); + } +} diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java new file mode 100644 index 00000000..d5effda1 --- /dev/null +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -0,0 +1,425 @@ +package cloud.eppo; + +import static cloud.eppo.Utils.base64Encode; +import static cloud.eppo.Utils.getMD5Hex; +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.model.ShardRange; +import cloud.eppo.ufc.dto.Allocation; +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.FlagConfig; +import cloud.eppo.ufc.dto.OperatorType; +import cloud.eppo.ufc.dto.Shard; +import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.TargetingCondition; +import cloud.eppo.ufc.dto.TargetingRule; +import cloud.eppo.ufc.dto.Variation; +import cloud.eppo.ufc.dto.VariationType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +public class FlagEvaluatorTest { + + @Test + public void testDisabledFlag() { + Map variations = createVariations("a"); + Set shards = createShards("salt"); + List splits = createSplits("a", shards); + List allocations = createAllocations("allocation", splits); + FlagConfig flag = createFlag("flag", false, variations, allocations); + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + + assertEquals(flag.getKey(), result.getFlagKey()); + assertEquals("subjectKey", result.getSubjectKey()); + assertEquals(new Attributes(), result.getSubjectAttributes()); + assertNull(result.getAllocationKey()); + assertNull(result.getVariation()); + assertFalse(result.doLog()); + } + + @Test + public void testNoAllocations() { + Map variations = createVariations("a"); + FlagConfig flag = createFlag("flag", true, variations, null); + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + + assertEquals(flag.getKey(), result.getFlagKey()); + assertEquals("subjectKey", result.getSubjectKey()); + assertEquals(new Attributes(), result.getSubjectAttributes()); + assertNull(result.getAllocationKey()); + assertNull(result.getVariation()); + assertFalse(result.doLog()); + } + + @Test + public void testSimpleFlag() { + Map variations = createVariations("a"); + Set shards = createShards("salt", 0, 10); + List splits = createSplits("a", shards); + List allocations = createAllocations("allocation", splits); + FlagConfig flag = createFlag("flag", true, variations, allocations); + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + + assertEquals(flag.getKey(), result.getFlagKey()); + assertEquals("subjectKey", result.getSubjectKey()); + assertEquals(new Attributes(), result.getSubjectAttributes()); + assertEquals("allocation", result.getAllocationKey()); + assertEquals("A", result.getVariation().getValue().stringValue()); + assertTrue(result.doLog()); + } + + @Test + public void testIDTargetingCondition() { + Map variations = createVariations("a"); + List splits = createSplits("a"); + + List values = new LinkedList<>(); + values.add("alice"); + values.add("bob"); + EppoValue value = EppoValue.valueOf(values); + Set rules = createRules("id", OperatorType.ONE_OF, value); + + List allocations = createAllocations("allocation", splits, rules); + FlagConfig flag = createFlag("key", true, variations, allocations); + + // Check that subjectKey is evaluated as the "id" attribute + + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "alice", new Attributes(), false); + + assertEquals("A", result.getVariation().getValue().stringValue()); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "bob", new Attributes(), false); + + assertEquals("A", result.getVariation().getValue().stringValue()); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", new Attributes(), false); + + assertNull(result.getVariation()); + + // Check that an explicitly passed-in "id" attribute takes precedence + + Attributes aliceAttributes = new Attributes(); + aliceAttributes.put("id", "charlie"); + result = FlagEvaluator.evaluateFlag(flag, "flag", "alice", aliceAttributes, false); + + assertNull(result.getVariation()); + + Attributes charlieAttributes = new Attributes(); + charlieAttributes.put("id", "alice"); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "charlie", charlieAttributes, false); + + assertEquals("A", result.getVariation().getValue().stringValue()); + } + + @Test + public void testCatchAllAllocation() { + Map variations = createVariations("a", "b"); + List splits = createSplits("a"); + List allocations = createAllocations("default", splits); + FlagConfig flag = createFlag("key", true, variations, allocations); + + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + + assertEquals("default", result.getAllocationKey()); + assertEquals("A", result.getVariation().getValue().stringValue()); + assertTrue(result.doLog()); + } + + @Test + public void testMultipleAllocations() { + Map variations = createVariations("a", "b"); + List firstAllocationSplits = createSplits("b"); + Set rules = + createRules("email", OperatorType.MATCHES, EppoValue.valueOf(".*example\\.com$")); + List allocations = createAllocations("first", firstAllocationSplits, rules); + + List defaultSplits = createSplits("a"); + allocations.addAll(createAllocations("default", defaultSplits)); + FlagConfig flag = createFlag("key", true, variations, allocations); + + Attributes matchingEmailAttributes = new Attributes(); + matchingEmailAttributes.put("email", "eppo@example.com"); + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", matchingEmailAttributes, false); + assertEquals("B", result.getVariation().getValue().stringValue()); + + Attributes unknownEmailAttributes = new Attributes(); + unknownEmailAttributes.put("email", "eppo@test.com"); + result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", unknownEmailAttributes, false); + assertEquals("A", result.getVariation().getValue().stringValue()); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "subjectKey", new Attributes(), false); + assertEquals("A", result.getVariation().getValue().stringValue()); + } + + @Test + public void testVariationShardRanges() { + Map variations = createVariations("a", "b", "c"); + Set trafficShards = createShards("traffic", 0, 5); + + Set shardsA = createShards("split", 0, 3); + shardsA.addAll(trafficShards); // both splits include the same traffic shard + List firstAllocationSplits = createSplits("a", shardsA); + + Set shardsB = createShards("split", 3, 6); + shardsB.addAll(trafficShards); // both splits include the same traffic shard + firstAllocationSplits.addAll(createSplits("b", shardsB)); + + List allocations = createAllocations("first", firstAllocationSplits); + + List defaultSplits = createSplits("c"); + allocations.addAll(createAllocations("default", defaultSplits)); + + FlagConfig flag = createFlag("key", true, variations, allocations); + + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subject4", new Attributes(), false); + + assertEquals("A", result.getVariation().getValue().stringValue()); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "subject13", new Attributes(), false); + + assertEquals("B", result.getVariation().getValue().stringValue()); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "subject14", new Attributes(), false); + + assertEquals("C", result.getVariation().getValue().stringValue()); + } + + @Test + public void testAllocationStartAndEndAt() { + Map variations = createVariations("a"); + List splits = createSplits("a"); + List allocations = createAllocations("allocation", splits); + FlagConfig flag = createFlag("key", true, variations, allocations); + + // Start off with today being between startAt and endAt + Date now = new Date(); + long oneDayInMilliseconds = 1000L * 60 * 60 * 24; + Date startAt = new Date(now.getTime() - oneDayInMilliseconds); + Date endAt = new Date(now.getTime() + oneDayInMilliseconds); + + Allocation allocation = allocations.get(0); + allocation.setStartAt(startAt); + allocation.setEndAt(endAt); + + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + + assertEquals("A", result.getVariation().getValue().stringValue()); + assertTrue(result.doLog()); + + // Make both start startAt and endAt in the future + allocation.setStartAt(new Date(now.getTime() + oneDayInMilliseconds)); + allocation.setEndAt(new Date(now.getTime() + 2 * oneDayInMilliseconds)); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + + assertNull(result.getVariation()); + assertFalse(result.doLog()); + + // Make both startAt and endAt in the past + allocation.setStartAt(new Date(now.getTime() - 2 * oneDayInMilliseconds)); + allocation.setEndAt(new Date(now.getTime() - oneDayInMilliseconds)); + + result = FlagEvaluator.evaluateFlag(flag, "flag", "subject", new Attributes(), false); + + assertNull(result.getVariation()); + assertFalse(result.doLog()); + } + + @Test + public void testObfuscated() { + // Note: this is NOT a comprehensive test of obfuscation (many operators and value types are + // excluded, as are startAt and endAt) + // Much more is covered by EppoClientTest + + Map variations = createVariations("a", "b"); + List firstAllocationSplits = createSplits("b"); + Set rules = + createRules("email", OperatorType.MATCHES, EppoValue.valueOf(".*example\\.com$")); + List allocations = createAllocations("first", firstAllocationSplits, rules); + + List defaultSplits = createSplits("a"); + allocations.addAll(createAllocations("default", defaultSplits)); + // Hash the flag key (done in-place) + FlagConfig flag = createFlag(getMD5Hex("flag"), true, variations, allocations); + + // Encode the variations (done by creating new map as keys change) + Map encodedVariations = new HashMap<>(); + for (Map.Entry variationEntry : variations.entrySet()) { + String encodedVariationKey = base64Encode(variationEntry.getKey()); + Variation variationToEncode = variationEntry.getValue(); + Variation newVariation = + new Variation( + encodedVariationKey, + EppoValue.valueOf(base64Encode(variationToEncode.getValue().stringValue()))); + encodedVariations.put(encodedVariationKey, newVariation); + } + // Encode the allocations + List encodedAllocations = + allocations.stream() + .map( + allocationToEncode -> { + allocationToEncode.setKey(base64Encode(allocationToEncode.getKey())); + TargetingCondition encodedCondition; + Set encodedRules = new HashSet<>(); + if (allocationToEncode.getRules() != null) { + // assume just a single rule with a single string-valued condition + TargetingCondition conditionToEncode = + allocationToEncode + .getRules() + .iterator() + .next() + .getConditions() + .iterator() + .next(); + String attribute = getMD5Hex(conditionToEncode.getAttribute()); + EppoValue value = + EppoValue.valueOf(base64Encode(conditionToEncode.getValue().stringValue())); + encodedCondition = + new TargetingCondition(conditionToEncode.getOperator(), attribute, value); + encodedRules.add( + new TargetingRule( + new HashSet<>(Collections.singletonList(encodedCondition)))); + encodedRules.addAll( + allocationToEncode.getRules().stream() + .skip(1) + .collect(Collectors.toList())); + } + List encodedSplits = + allocationToEncode.getSplits().stream() + .map( + splitToEncode -> + new Split( + base64Encode(splitToEncode.getVariationKey()), + splitToEncode.getShards(), + splitToEncode.getExtraLogging())) + .collect(Collectors.toList()); + return new Allocation( + allocationToEncode.getKey(), + encodedRules, + allocationToEncode.getStartAt(), + allocationToEncode.getEndAt(), + encodedSplits, + allocationToEncode.doLog()); + }) + .collect(Collectors.toList()); + + Attributes matchingEmailAttributes = new Attributes(); + matchingEmailAttributes.put("email", "eppo@example.com"); + FlagConfig obfuscatedFlag = + new FlagConfig( + flag.getKey(), + flag.isEnabled(), + flag.getTotalShards(), + flag.getVariationType(), + encodedVariations, + encodedAllocations); + FlagEvaluationResult result = + FlagEvaluator.evaluateFlag( + obfuscatedFlag, "flag", "subjectKey", matchingEmailAttributes, true); + + // Expect an unobfuscated evaluation result + assertEquals("flag", result.getFlagKey()); + assertEquals("subjectKey", result.getSubjectKey()); + assertEquals(matchingEmailAttributes, result.getSubjectAttributes()); + assertEquals("first", result.getAllocationKey()); + assertEquals("B", result.getVariation().getValue().stringValue()); + assertTrue(result.doLog()); + + Attributes unknownEmailAttributes = new Attributes(); + unknownEmailAttributes.put("email", "eppo@test.com"); + result = + FlagEvaluator.evaluateFlag( + obfuscatedFlag, "flag", "subjectKey", unknownEmailAttributes, true); + assertEquals("A", result.getVariation().getValue().stringValue()); + + result = + FlagEvaluator.evaluateFlag(obfuscatedFlag, "flag", "subjectKey", new Attributes(), true); + assertEquals("A", result.getVariation().getValue().stringValue()); + } + + private Map createVariations(String key) { + return createVariations(key, null, null); + } + + private Map createVariations(String key1, String key2) { + return createVariations(key1, key2, null); + } + + private Map createVariations(String key1, String key2, String key3) { + String[] keys = {key1, key2, key3}; + Map variations = new HashMap<>(); + for (String key : keys) { + if (key != null) { + // Use the uppercase key as the dummy value + Variation variation = new Variation(key, EppoValue.valueOf(key.toUpperCase())); + variations.put(variation.getKey(), variation); + } + } + return variations; + } + + private Set createShards(String salt) { + return createShards(salt, null, null); + } + + private Set createShards(String salt, Integer rangeStart, Integer rangeEnd) { + Set ranges = new HashSet<>(); + if (rangeStart != null) { + ShardRange range = new ShardRange(rangeStart, rangeEnd); + ranges = new HashSet<>(Collections.singletonList(range)); + } + return new HashSet<>(Collections.singletonList(new Shard(salt, ranges))); + } + + private List createSplits(String variationKey) { + return createSplits(variationKey, null); + } + + private List createSplits(String variationKey, Set shards) { + Split split = new Split(variationKey, shards, new HashMap<>()); + return new ArrayList<>(Collections.singletonList(split)); + } + + private Set createRules(String attribute, OperatorType operator, EppoValue value) { + Set conditions = new HashSet<>(); + conditions.add(new TargetingCondition(operator, attribute, value)); + return new HashSet<>(Collections.singletonList(new TargetingRule(conditions))); + } + + private List createAllocations(String allocationKey, List splits) { + return createAllocations(allocationKey, splits, null); + } + + private List createAllocations( + String allocationKey, List splits, Set rules) { + Allocation allocation = new Allocation(allocationKey, rules, null, null, splits, true); + return new ArrayList<>(Collections.singletonList(allocation)); + } + + private FlagConfig createFlag( + String key, + boolean enabled, + Map variations, + List allocations) { + return new FlagConfig(key, enabled, 10, VariationType.STRING, variations, allocations); + } +} diff --git a/src/test/java/cloud/eppo/RuleEvaluatorTest.java b/src/test/java/cloud/eppo/RuleEvaluatorTest.java new file mode 100644 index 00000000..cee5e594 --- /dev/null +++ b/src/test/java/cloud/eppo/RuleEvaluatorTest.java @@ -0,0 +1,285 @@ +package cloud.eppo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.OperatorType; +import cloud.eppo.ufc.dto.TargetingCondition; +import cloud.eppo.ufc.dto.TargetingRule; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +public class RuleEvaluatorTest { + + public TargetingRule createRule(Set conditions) { + return new TargetingRule(conditions); + } + + public void addConditionToRule(TargetingRule TargetingRule, TargetingCondition condition) { + TargetingRule.getConditions().add(condition); + } + + public void addNumericConditionToRule(TargetingRule TargetingRule) { + TargetingCondition condition1 = + new TargetingCondition( + OperatorType.GREATER_THAN_OR_EQUAL_TO, "price", EppoValue.valueOf(10)); + TargetingCondition condition2 = + new TargetingCondition(OperatorType.LESS_THAN_OR_EQUAL_TO, "price", EppoValue.valueOf(20)); + + addConditionToRule(TargetingRule, condition1); + addConditionToRule(TargetingRule, condition2); + } + + public void addSemVerConditionToRule(TargetingRule TargetingRule) { + TargetingCondition condition1 = + new TargetingCondition( + OperatorType.GREATER_THAN_OR_EQUAL_TO, "appVersion", EppoValue.valueOf("1.5.0")); + TargetingCondition condition2 = + new TargetingCondition(OperatorType.LESS_THAN, "appVersion", EppoValue.valueOf("2.2.0")); + + addConditionToRule(TargetingRule, condition1); + addConditionToRule(TargetingRule, condition2); + } + + public void addRegexConditionToRule(TargetingRule TargetingRule) { + TargetingCondition condition = + new TargetingCondition( + OperatorType.MATCHES, "match", EppoValue.valueOf("example\\.(com|org)")); + addConditionToRule(TargetingRule, condition); + } + + public void addOneOfConditionWithStrings(TargetingRule rule) { + List values = Arrays.asList("value1", "value2"); + TargetingCondition condition = + new TargetingCondition(OperatorType.ONE_OF, "oneOf", EppoValue.valueOf(values)); + addConditionToRule(rule, condition); + } + + public void addOneOfConditionWithIntegers(TargetingRule rule) { + List values = Arrays.asList("1", "2"); + TargetingCondition condition = + new TargetingCondition(OperatorType.ONE_OF, "oneOf", EppoValue.valueOf(values)); + addConditionToRule(rule, condition); + } + + public void addOneOfConditionWithDoubles(TargetingRule rule) { + List values = Arrays.asList("1.5", "2.7"); + TargetingCondition condition = + new TargetingCondition(OperatorType.ONE_OF, "oneOf", EppoValue.valueOf(values)); + addConditionToRule(rule, condition); + } + + public void addOneOfConditionWithBoolean(TargetingRule rule) { + List values = Collections.singletonList("true"); + TargetingCondition condition = + new TargetingCondition(OperatorType.ONE_OF, "oneOf", EppoValue.valueOf(values)); + addConditionToRule(rule, condition); + } + + public void addNotOneOfTargetingCondition(TargetingRule TargetingRule) { + List values = Arrays.asList("value1", "value2"); + TargetingCondition condition = + new TargetingCondition(OperatorType.NOT_ONE_OF, "oneOf", EppoValue.valueOf(values)); + addConditionToRule(TargetingRule, condition); + } + + public void addNameToSubjectAttribute(Attributes subjectAttributes) { + subjectAttributes.put("name", "test"); + } + + public void addPriceToSubjectAttribute(Attributes subjectAttributes) { + subjectAttributes.put("price", "30"); + } + + @Test + public void testMatchesAnyRuleWithEmptyConditions() { + Set targetingRules = new HashSet<>(); + final TargetingRule targetingRuleWithEmptyConditions = createRule(new HashSet<>()); + targetingRules.add(targetingRuleWithEmptyConditions); + Attributes subjectAttributes = new Attributes(); + addNameToSubjectAttribute(subjectAttributes); + + assertEquals( + targetingRuleWithEmptyConditions, + RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithEmptyRules() { + Set targetingRules = new HashSet<>(); + Attributes subjectAttributes = new Attributes(); + addNameToSubjectAttribute(subjectAttributes); + + assertNull(RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWhenNoRuleMatches() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addNumericConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + addPriceToSubjectAttribute(subjectAttributes); + + assertNull(RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWhenRuleMatches() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addNumericConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("price", 15); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWhenRuleMatchesWithSemVer() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addSemVerConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("appVersion", "1.15.5"); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWhenThrowInvalidSubjectAttribute() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addNumericConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("price", EppoValue.valueOf("abcd")); + + assertNull(RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithRegexCondition() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addRegexConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("match", EppoValue.valueOf("test@example.com")); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithRegexConditionNotMatched() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addRegexConditionToRule(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("match", EppoValue.valueOf("123")); + + assertNull(RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithNotOneOfRule() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addNotOneOfTargetingCondition(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf("value3")); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithNotOneOfRuleNotPassed() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addNotOneOfTargetingCondition(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf("value1")); + + assertNull(RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithOneOfRuleOnString() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addOneOfConditionWithStrings(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf("value1")); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithOneOfRuleOnInteger() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addOneOfConditionWithIntegers(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf(2)); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithOneOfRuleOnDouble() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addOneOfConditionWithDoubles(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf(1.5)); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } + + @Test + public void testMatchesAnyRuleWithOneOfRuleOnBoolean() { + Set targetingRules = new HashSet<>(); + TargetingRule targetingRule = createRule(new HashSet<>()); + addOneOfConditionWithBoolean(targetingRule); + targetingRules.add(targetingRule); + + Attributes subjectAttributes = new Attributes(); + subjectAttributes.put("oneOf", EppoValue.valueOf(true)); + + assertEquals( + targetingRule, RuleEvaluator.findMatchingRule(subjectAttributes, targetingRules, false)); + } +} diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 0874ab96..892b11e6 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -1,6 +1,6 @@ package cloud.eppo; -import static cloud.eppo.Utils.parseUtcISODateElement; +import static cloud.eppo.Utils.parseUtcISODateNode; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -12,15 +12,15 @@ public class UtilsTest { @Test - public void testParseUtcISODateElement() throws JsonProcessingException { + public void testParseUtcISODateNode() throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); JsonNode jsonNode = mapper.readTree("\"2024-05-01T16:13:26.651Z\""); - Date parsedDate = parseUtcISODateElement(jsonNode); + Date parsedDate = parseUtcISODateNode(jsonNode); Date expectedDate = new Date(1714580006651L); assertEquals(expectedDate, parsedDate); jsonNode = mapper.readTree("null"); - parsedDate = parseUtcISODateElement(jsonNode); + parsedDate = parseUtcISODateNode(jsonNode); assertNull(parsedDate); - assertNull(parseUtcISODateElement(null)); + assertNull(parseUtcISODateNode(null)); } } diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java new file mode 100644 index 00000000..4513f359 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -0,0 +1,186 @@ +package cloud.eppo.helpers; + +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.VariationType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.params.provider.Arguments; + +public class AssignmentTestCase { + private final String flag; + private final VariationType variationType; + private final TestCaseValue defaultValue; + private final List subjects; + + public AssignmentTestCase( + String flag, + VariationType variationType, + TestCaseValue defaultValue, + List subjects) { + this.flag = flag; + this.variationType = variationType; + this.defaultValue = defaultValue; + this.subjects = subjects; + } + + public String getFlag() { + return flag; + } + + public VariationType getVariationType() { + return variationType; + } + + public TestCaseValue getDefaultValue() { + return defaultValue; + } + + public List getSubjects() { + return subjects; + } + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(assignmentTestCaseModule()); + + public static SimpleModule assignmentTestCaseModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); + return module; + } + + public static Stream getAssignmentTestData() { + File testCaseFolder = new File("src/test/resources/shared/ufc/tests"); + File[] testCaseFiles = testCaseFolder.listFiles(); + assertNotNull(testCaseFiles); + assertTrue(testCaseFiles.length > 0); + List arguments = new ArrayList<>(); + for (File testCaseFile : testCaseFiles) { + arguments.add(Arguments.of(testCaseFile)); + } + return arguments.stream(); + } + + public static AssignmentTestCase parseTestCaseFile(File testCaseFile) { + AssignmentTestCase testCase; + try { + String json = FileUtils.readFileToString(testCaseFile, "UTF8"); + + testCase = mapper.readValue(json, AssignmentTestCase.class); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + return testCase; + } + + public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoClient) { + String flagKey = testCase.getFlag(); + TestCaseValue defaultValue = testCase.getDefaultValue(); + assertFalse(testCase.getSubjects().isEmpty()); + + for (SubjectAssignment subjectAssignment : testCase.getSubjects()) { + String subjectKey = subjectAssignment.getSubjectKey(); + Attributes subjectAttributes = subjectAssignment.getSubjectAttributes(); + + // Depending on the variation type, we will need to change which assignment method we call and + // how we get the default value + switch (testCase.getVariationType()) { + case BOOLEAN: + boolean boolAssignment = + eppoClient.getBooleanAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, boolAssignment); + break; + case INTEGER: + int intAssignment = + eppoClient.getIntegerAssignment( + flagKey, + subjectKey, + subjectAttributes, + Double.valueOf(defaultValue.doubleValue()).intValue()); + assertAssignment(flagKey, subjectAssignment, intAssignment); + break; + case NUMERIC: + double doubleAssignment = + eppoClient.getDoubleAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, doubleAssignment); + break; + case STRING: + String stringAssignment = + eppoClient.getStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, stringAssignment); + break; + case JSON: + JsonNode jsonAssignment = + eppoClient.getJSONAssignment( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, jsonAssignment); + break; + default: + throw new UnsupportedOperationException( + "Unexpected variation type " + + testCase.getVariationType() + + " for " + + flagKey + + " test case"); + } + } + } + + /** Helper method for asserting a subject assignment with a useful failure message. */ + private static void assertAssignment( + String flagKey, SubjectAssignment expectedSubjectAssignment, T assignment) { + + if (assignment == null) { + fail( + "Unexpected null " + + flagKey + + " assignment for subject " + + expectedSubjectAssignment.getSubjectKey()); + } + + String failureMessage = + "Incorrect " + + flagKey + + " assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + if (assignment instanceof Boolean) { + assertEquals( + expectedSubjectAssignment.getAssignment().booleanValue(), assignment, failureMessage); + } else if (assignment instanceof Integer) { + assertEquals( + Double.valueOf(expectedSubjectAssignment.getAssignment().doubleValue()).intValue(), + assignment, + failureMessage); + } else if (assignment instanceof Double) { + assertEquals( + expectedSubjectAssignment.getAssignment().doubleValue(), + (Double) assignment, + 0.000001, + failureMessage); + } else if (assignment instanceof String) { + assertEquals( + expectedSubjectAssignment.getAssignment().stringValue(), assignment, failureMessage); + } else if (assignment instanceof JsonNode) { + assertEquals( + expectedSubjectAssignment.getAssignment().jsonValue().toString(), + assignment.toString(), + failureMessage); + } else { + throw new IllegalArgumentException( + "Unexpected assignment type " + assignment.getClass().getCanonicalName()); + } + } +} diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java new file mode 100644 index 00000000..c318fcaa --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -0,0 +1,69 @@ +package cloud.eppo.helpers; + +import cloud.eppo.ufc.dto.Attributes; +import cloud.eppo.ufc.dto.EppoValue; +import cloud.eppo.ufc.dto.VariationType; +import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class AssignmentTestCaseDeserializer extends StdDeserializer { + private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); + + public AssignmentTestCaseDeserializer() { + super(AssignmentTestCase.class); + } + + @Override + public AssignmentTestCase deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode rootNode = parser.getCodec().readTree(parser); + String flag = rootNode.get("flag").asText(); + VariationType variationType = VariationType.fromString(rootNode.get("variationType").asText()); + TestCaseValue defaultValue = deserializeTestCaseValue(rootNode.get("defaultValue")); + List subjects = deserializeSubjectAssignments(rootNode.get("subjects")); + return new AssignmentTestCase(flag, variationType, defaultValue, subjects); + } + + private List deserializeSubjectAssignments(JsonNode jsonNode) { + List subjectAssignments = new ArrayList<>(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode subjectAssignmentNode : jsonNode) { + String subjectKey = subjectAssignmentNode.get("subjectKey").asText(); + + Attributes subjectAttributes = new Attributes(); + JsonNode attributesNode = subjectAssignmentNode.get("subjectAttributes"); + if (attributesNode != null && attributesNode.isObject()) { + for (Iterator> it = attributesNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String attributeName = entry.getKey(); + EppoValue attributeValue = eppoValueDeserializer.deserializeNode(entry.getValue()); + subjectAttributes.put(attributeName, attributeValue); + } + } + + TestCaseValue assignment = + deserializeTestCaseValue(subjectAssignmentNode.get("assignment")); + + subjectAssignments.add(new SubjectAssignment(subjectKey, subjectAttributes, assignment)); + } + } + + return subjectAssignments; + } + + private TestCaseValue deserializeTestCaseValue(JsonNode jsonNode) { + if (jsonNode != null && (jsonNode.isObject() || jsonNode.isArray())) { + return TestCaseValue.valueOf(jsonNode); + } else { + return TestCaseValue.copyOf(eppoValueDeserializer.deserializeNode(jsonNode)); + } + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java b/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java new file mode 100644 index 00000000..f606ffcf --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java @@ -0,0 +1,36 @@ +package cloud.eppo.helpers; + +import cloud.eppo.ufc.dto.Actions; +import cloud.eppo.ufc.dto.BanditResult; +import cloud.eppo.ufc.dto.ContextAttributes; + +public class BanditSubjectAssignment { + private final String subjectKey; + private final ContextAttributes subjectAttributes; + private final Actions actions; + private final BanditResult assignment; + + public BanditSubjectAssignment( + String subjectKey, ContextAttributes attributes, Actions actions, BanditResult assignment) { + this.subjectKey = subjectKey; + this.subjectAttributes = attributes; + this.actions = actions; + this.assignment = assignment; + } + + public String getSubjectKey() { + return subjectKey; + } + + public ContextAttributes getSubjectAttributes() { + return subjectAttributes; + } + + public Actions getActions() { + return actions; + } + + public BanditResult getAssignment() { + return assignment; + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditTestCase.java b/src/test/java/cloud/eppo/helpers/BanditTestCase.java new file mode 100644 index 00000000..5c6efe67 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditTestCase.java @@ -0,0 +1,117 @@ +package cloud.eppo.helpers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.ufc.dto.Actions; +import cloud.eppo.ufc.dto.BanditResult; +import cloud.eppo.ufc.dto.ContextAttributes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.params.provider.Arguments; + +public class BanditTestCase { + private final String flag; + private final String defaultValue; + private final List subjects; + private String fileName; + + public BanditTestCase(String flag, String defaultValue, List subjects) { + this.flag = flag; + this.defaultValue = defaultValue; + this.subjects = subjects; + } + + public String getFlag() { + return flag; + } + + public String getDefaultValue() { + return defaultValue; + } + + public List getSubjects() { + return subjects; + } + + public static Stream getBanditTestData() { + File testCaseFolder = new File("src/test/resources/shared/ufc/bandit-tests"); + File[] testCaseFiles = testCaseFolder.listFiles(); + assertNotNull(testCaseFiles); + assertTrue(testCaseFiles.length > 0); + List arguments = new ArrayList<>(); + for (File testCaseFile : testCaseFiles) { + arguments.add(Arguments.of(testCaseFile)); + } + return arguments.stream(); + } + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(banditTestCaseModule()); + + public static SimpleModule banditTestCaseModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(BanditTestCase.class, new BanditTestCaseDeserializer()); + return module; + } + + public static BanditTestCase parseBanditTestCaseFile(File testCaseFile) { + BanditTestCase testCase; + try { + String json = FileUtils.readFileToString(testCaseFile, "UTF8"); + testCase = mapper.readValue(json, BanditTestCase.class); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + return testCase; + } + + public static void runBanditTestCase(BanditTestCase testCase, BaseEppoClient eppoClient) { + assertFalse(testCase.getSubjects().isEmpty()); + + String flagKey = testCase.getFlag(); + String defaultValue = testCase.getDefaultValue(); + + for (BanditSubjectAssignment subjectAssignment : testCase.getSubjects()) { + String subjectKey = subjectAssignment.getSubjectKey(); + ContextAttributes attributes = subjectAssignment.getSubjectAttributes(); + Actions actions = subjectAssignment.getActions(); + BanditResult assignment = + eppoClient.getBanditAction(flagKey, subjectKey, attributes, actions, defaultValue); + assertBanditAssignment(flagKey, subjectAssignment, assignment); + } + } + + /** Helper method for asserting a bandit assignment with a useful failure message. */ + private static void assertBanditAssignment( + String flagKey, BanditSubjectAssignment expectedSubjectAssignment, BanditResult assignment) { + String failureMessage = + "Incorrect " + + flagKey + + " variation assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + assertEquals( + expectedSubjectAssignment.getAssignment().getVariation(), + assignment.getVariation(), + failureMessage); + + failureMessage = + "Incorrect " + + flagKey + + " action assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + assertEquals( + expectedSubjectAssignment.getAssignment().getAction(), + assignment.getAction(), + failureMessage); + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java new file mode 100644 index 00000000..f555bc5a --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java @@ -0,0 +1,87 @@ +package cloud.eppo.helpers; + +import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.util.*; + +public class BanditTestCaseDeserializer extends StdDeserializer { + private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); + + public BanditTestCaseDeserializer() { + super(BanditTestCase.class); + } + + @Override + public BanditTestCase deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode rootNode = parser.getCodec().readTree(parser); + String flag = rootNode.get("flag").asText(); + String defaultValue = rootNode.get("defaultValue").asText(); + List subjects = + deserializeSubjectBanditAssignments(rootNode.get("subjects")); + return new BanditTestCase(flag, defaultValue, subjects); + } + + private List deserializeSubjectBanditAssignments(JsonNode jsonNode) { + List subjectAssignments = new ArrayList<>(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode subjectAssignmentNode : jsonNode) { + String subjectKey = subjectAssignmentNode.get("subjectKey").asText(); + JsonNode attributesNode = subjectAssignmentNode.get("subjectAttributes"); + ContextAttributes attributes = new ContextAttributes(); + if (attributesNode != null && attributesNode.isObject()) { + Attributes numericAttributes = + deserializeAttributes(attributesNode.get("numericAttributes")); + Attributes categoricalAttributes = + deserializeAttributes(attributesNode.get("categoricalAttributes")); + attributes = new ContextAttributes(numericAttributes, categoricalAttributes); + } + Actions actions = deserializeActions(subjectAssignmentNode.get("actions")); + JsonNode assignmentNode = subjectAssignmentNode.get("assignment"); + String variationAssignment = assignmentNode.get("variation").asText(); + JsonNode actionAssignmentNode = assignmentNode.get("action"); + String actionAssignment = + actionAssignmentNode.isNull() ? null : actionAssignmentNode.asText(); + BanditResult assignment = new BanditResult(variationAssignment, actionAssignment); + subjectAssignments.add( + new BanditSubjectAssignment(subjectKey, attributes, actions, assignment)); + } + } + + return subjectAssignments; + } + + private Actions deserializeActions(JsonNode jsonNode) { + BanditActions actions = new BanditActions(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode actionNode : jsonNode) { + String actionKey = actionNode.get("actionKey").asText(); + Attributes numericAttributes = deserializeAttributes(actionNode.get("numericAttributes")); + Attributes categoricalAttributes = + deserializeAttributes(actionNode.get("categoricalAttributes")); + ContextAttributes attributes = + new ContextAttributes(numericAttributes, categoricalAttributes); + actions.put(actionKey, attributes); + } + } + return actions; + } + + private Attributes deserializeAttributes(JsonNode jsonNode) { + Attributes attributes = new Attributes(); + if (jsonNode != null && jsonNode.isObject()) { + for (Iterator> it = jsonNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String attributeName = entry.getKey(); + EppoValue attributeValue = eppoValueDeserializer.deserializeNode(entry.getValue()); + attributes.put(attributeName, attributeValue); + } + } + return attributes; + } +} diff --git a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java new file mode 100644 index 00000000..647f47e8 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java @@ -0,0 +1,28 @@ +package cloud.eppo.helpers; + +import cloud.eppo.ufc.dto.Attributes; + +public class SubjectAssignment { + private final String subjectKey; + private final Attributes subjectAttributes; + private final TestCaseValue assignment; + + public SubjectAssignment( + String subjectKey, Attributes subjectAttributes, TestCaseValue assignment) { + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.assignment = assignment; + } + + public String getSubjectKey() { + return subjectKey; + } + + public Attributes getSubjectAttributes() { + return subjectAttributes; + } + + public TestCaseValue getAssignment() { + return assignment; + } +} diff --git a/src/test/java/cloud/eppo/helpers/TestCaseValue.java b/src/test/java/cloud/eppo/helpers/TestCaseValue.java new file mode 100644 index 00000000..dd278fad --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/TestCaseValue.java @@ -0,0 +1,62 @@ +package cloud.eppo.helpers; + +import cloud.eppo.ufc.dto.EppoValue; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +public class TestCaseValue extends EppoValue { + private JsonNode jsonValue; + + private TestCaseValue() { + super(); + } + + private TestCaseValue(boolean boolValue) { + super(boolValue); + } + + private TestCaseValue(double doubleValue) { + super(doubleValue); + } + + private TestCaseValue(String stringValue) { + super(stringValue); + } + + private TestCaseValue(List stringArrayValue) { + super(stringArrayValue); + } + + private TestCaseValue(JsonNode jsonValue) { + super(jsonValue.toString()); + this.jsonValue = jsonValue; + } + + public static TestCaseValue copyOf(EppoValue eppoValue) { + if (eppoValue.isNull()) { + return new TestCaseValue(); + } else if (eppoValue.isBoolean()) { + return new TestCaseValue(eppoValue.booleanValue()); + } else if (eppoValue.isNumeric()) { + return new TestCaseValue(eppoValue.doubleValue()); + } else if (eppoValue.isString()) { + return new TestCaseValue(eppoValue.stringValue()); + } else if (eppoValue.isStringArray()) { + return new TestCaseValue(eppoValue.stringArrayValue()); + } else { + throw new IllegalArgumentException("Unable to copy EppoValue: " + eppoValue); + } + } + + public static TestCaseValue valueOf(JsonNode jsonValue) { + return new TestCaseValue(jsonValue); + } + + public boolean isJson() { + return this.jsonValue != null; + } + + public JsonNode jsonValue() { + return this.jsonValue; + } +} diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java new file mode 100644 index 00000000..ab0c65e6 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -0,0 +1,62 @@ +package cloud.eppo.helpers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.EppoHttpClient; +import cloud.eppo.EppoHttpClientRequestCallback; +import java.lang.reflect.Field; +import okhttp3.*; + +public class TestUtils { + + @SuppressWarnings("SameParameterValue") + public static void mockHttpResponse(String host, String responseBody) { + // Create a mock instance of EppoHttpClient + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + // Mock sync get + Response dummyResponse = + new Response.Builder() + // Used by test + .code(200) + .body(ResponseBody.create(responseBody, MediaType.get("application/json"))) + // Below properties are required to build the Response (but unused) + .request(new Request.Builder().url(host).build()) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .build(); + when(mockHttpClient.get(anyString())).thenReturn(dummyResponse); + + // Mock async get + doAnswer( + invocation -> { + EppoHttpClientRequestCallback callback = invocation.getArgument(1); + callback.onSuccess(responseBody); + return null; // doAnswer doesn't require a return value + }) + .when(mockHttpClient) + .get(anyString(), any(EppoHttpClientRequestCallback.class)); + + setBaseClientHttpClientOverrideField(mockHttpClient); + } + + public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { + setBaseClientOverrideField("httpClientOverride", httpClient); + } + + /** Uses reflection to set a static override field used for tests (e.g., httpClientOverride) */ + @SuppressWarnings("SameParameterValue") + public static void setBaseClientOverrideField(String fieldName, T override) { + try { + Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField(fieldName); + httpClientOverrideField.setAccessible(true); + httpClientOverrideField.set(null, override); + httpClientOverrideField.setAccessible(false); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/cloud/eppo/rac/deserializer/EppoValueDeserializerTest.java b/src/test/java/cloud/eppo/rac/deserializer/EppoValueDeserializerTest.java deleted file mode 100644 index 246e4195..00000000 --- a/src/test/java/cloud/eppo/rac/deserializer/EppoValueDeserializerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package cloud.eppo.rac.deserializer; - -import cloud.eppo.rac.dto.EppoValue; -import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class EppoValueDeserializerTest { - private final ObjectMapper mapper = new ObjectMapper().registerModule(EppoModule.eppoModule()); - - @DisplayName("Test deserializing double") - @Test - void testDeserializingDouble() throws Exception { - SingleEppoValue object = mapper.readValue("{\"value\": 1}", SingleEppoValue.class); - Assertions.assertEquals(object.value.doubleValue(), 1); - } - - @DisplayName("Test deserializing boolean") - @Test - void testDeserializingBoolean() throws Exception { - SingleEppoValue object = mapper.readValue("{\"value\": true}", SingleEppoValue.class); - Assertions.assertTrue(object.value.boolValue()); - } - - @DisplayName("Test deserializing string") - @Test - void testDeserializingString() throws Exception { - SingleEppoValue object = mapper.readValue("{\"value\": \"true\"}", SingleEppoValue.class); - Assertions.assertEquals(object.value.stringValue(), "true"); - } - - @DisplayName("Test deserializing array") - @Test - void testDeserializingArray() throws Exception { - SingleEppoValue object = - mapper.readValue("{\"value\": [\"value1\", \"value2\"]}", SingleEppoValue.class); - Assertions.assertTrue(object.value.arrayValue().contains("value1")); - } - - @DisplayName("Test deserializing null") - @Test - void testDeserializingNull() throws Exception { - SingleEppoValue object = mapper.readValue("{\"value\": null}", SingleEppoValue.class); - Assertions.assertNull(object.value); - } - - @DisplayName("Test deserializing random object") - @Test - void testDeserializingRandomObject() throws Exception { - SingleEppoValue object = - mapper.readValue("{\"value\": {\"test\" : \"test\"}}", SingleEppoValue.class); - Assertions.assertEquals( - 0, object.value.jsonNodeValue().get("test").textValue().compareTo("test")); - } - - static class SingleValue { - public T value; - } - - static class SingleEppoValue extends SingleValue {} -} diff --git a/src/test/java/cloud/eppo/rac/deserializer/RacDeserializationTest.java b/src/test/java/cloud/eppo/rac/deserializer/RacDeserializationTest.java deleted file mode 100644 index d28f67b3..00000000 --- a/src/test/java/cloud/eppo/rac/deserializer/RacDeserializationTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package cloud.eppo.rac.deserializer; - -import static com.google.common.truth.Truth.assertThat; - -import cloud.eppo.rac.dto.*; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.File; -import java.util.Arrays; -import java.util.List; -import org.apache.commons.io.FileUtils; -import org.junit.jupiter.api.Test; - -public class RacDeserializationTest { - private final ObjectMapper objectMapper = - new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - @Test - public void testDeserialization() throws JsonProcessingException { - File mockRacResponse = new File("src/test/resources/rac-experiments-v3.json"); - String jsonString = readResource(mockRacResponse); - ExperimentConfigurationResponse response = - objectMapper.readValue(jsonString, ExperimentConfigurationResponse.class); - assertThat(response.getFlags().keySet().size()).isEqualTo(10); - ExperimentConfiguration experiment = response.getFlags().get("randomization_algo"); - assertThat(experiment.getSubjectShards()).isEqualTo(10000); - assertThat(experiment.getAllocations().keySet().size()).isEqualTo(1); - Allocation allocation = experiment.getAllocations().get("allocation-experiment-1"); - assertThat(allocation.getPercentExposure()).isEqualTo(0.4533); - assertThat(experiment.getRules().get(0).getAllocationKey()) - .isEqualTo("allocation-experiment-1"); - assertThat(allocation.getVariations().get(0).getName()).isEqualTo("control"); - assertThat(allocation.getVariations().get(0).getAlgorithmType()) - .isEqualTo(AlgorithmType.OVERRIDE); - assertThat(allocation.getVariations().get(0).getShardRange().getStart()).isEqualTo(0); - assertThat(allocation.getVariations().get(0).getShardRange().getEnd()).isEqualTo(3333); - assertThat(allocation.getVariations().get(1).getName()).isEqualTo("red"); - assertThat(allocation.getVariations().get(2).getName()).isEqualTo("green"); - ExperimentConfiguration targetingRulesExp = - response.getFlags().get("targeting_rules_experiment"); - List conditions = targetingRulesExp.getRules().get(0).getConditions(); - assertThat(conditions.get(0).getValue().arrayValue()) - .isEqualTo(Arrays.asList("iOS", "Android")); - } - - private static String readResource(File mockRacResponse) { - try { - return FileUtils.readFileToString(mockRacResponse, "UTF8"); - } catch (Exception e) { - throw new RuntimeException("Error reading mock RAC data: " + e.getMessage(), e); - } - } -} diff --git a/src/test/java/cloud/eppo/rac/dto/EppoAttributesTest.java b/src/test/java/cloud/eppo/rac/dto/EppoAttributesTest.java deleted file mode 100644 index 61ab7ea6..00000000 --- a/src/test/java/cloud/eppo/rac/dto/EppoAttributesTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package cloud.eppo.rac.dto; - -import java.util.HashMap; -import java.util.Map; -import org.json.JSONException; -import org.junit.jupiter.api.Test; -import org.skyscreamer.jsonassert.JSONAssert; - -class EppoAttributesTest { - @Test - void testSerializeEppoAttributesToJSONString() throws JSONException { - EppoAttributes eppoAttributes = new EppoAttributes(); - eppoAttributes.put("boolean", EppoValue.valueOf(false)); - eppoAttributes.put("number", EppoValue.valueOf(1.234)); - eppoAttributes.put("string", EppoValue.valueOf("hello")); - eppoAttributes.put("null", EppoValue.nullValue()); - - String serializedJSONString = eppoAttributes.serializeToJSONString(); - String expectedJson = - "{ \"boolean\": false, \"number\": 1.234, \"string\": \"hello\", \"null\": null }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - - // Try omitting nulls now - serializedJSONString = EppoAttributes.serializeNonNullAttributesToJSONString(eppoAttributes); - expectedJson = "{ \"boolean\": false, \"number\": 1.234, \"string\": \"hello\" }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - } - - @Test - void testSerializeNumericAttributesToJSONString() throws JSONException { - Map numericAttributes = new HashMap<>(); - numericAttributes.put("positive", 12.3); - numericAttributes.put("negative", -45.6); - numericAttributes.put("integer", 43.0); - numericAttributes.put("null", null); - - String serializedJSONString = EppoAttributes.serializeAttributesToJSONString(numericAttributes); - String expectedJson = - "{ \"positive\": 12.3, \"negative\": -45.6, \"integer\": 43, \"null\": null }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - - // Try omitting nulls now - serializedJSONString = EppoAttributes.serializeNonNullAttributesToJSONString(numericAttributes); - expectedJson = "{ \"positive\": 12.3, \"negative\": -45.6, \"integer\": 43 }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - } - - @Test - void testSerializeCategoricalAttributesToJSONString() throws JSONException { - Map categoricalAttributes = new HashMap<>(); - categoricalAttributes.put("a", "apple"); - categoricalAttributes.put("b", "banana"); - categoricalAttributes.put("null", null); - - String serializedJSONString = - EppoAttributes.serializeAttributesToJSONString(categoricalAttributes); - String expectedJson = "{ \"a\": \"apple\", \"b\": \"banana\", \"null\": null }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - - // Try omitting nulls now - serializedJSONString = - EppoAttributes.serializeNonNullAttributesToJSONString(categoricalAttributes); - expectedJson = "{ \"a\": \"apple\", \"b\": \"banana\" }"; - - JSONAssert.assertEquals(expectedJson, serializedJSONString, true); - } -} diff --git a/src/test/java/cloud/eppo/rac/dto/ShardUtilsTest.java b/src/test/java/cloud/eppo/rac/dto/ShardUtilsTest.java deleted file mode 100644 index 3fe928d5..00000000 --- a/src/test/java/cloud/eppo/rac/dto/ShardUtilsTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package cloud.eppo.rac.dto; - -import cloud.eppo.ShardUtils; -import cloud.eppo.model.ShardRange; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class ShardUtilsTest { - ShardRange createShardRange(int start, int end) { - return new ShardRange(start, end); - } - - @DisplayName("Test Shard.isShardInRange() positive case") - @Test - void testIsShardInRangePositiveCase() { - ShardRange range = createShardRange(10, 20); - Assertions.assertTrue(ShardUtils.isShardInRange(15, range)); - } - - @DisplayName("Test Shard.isShardInRange() negative case") - @Test - void testIsShardInRangeNegativeCase() { - ShardRange range = createShardRange(10, 20); - Assertions.assertTrue(ShardUtils.isShardInRange(15, range)); - } - - @DisplayName("Test Shard.getShard()") - @Test - void testGetShard() { - final int MAX_SHARD_VALUE = 200; - int shardValue = ShardUtils.getShard("test-user", MAX_SHARD_VALUE); - Assertions.assertTrue(shardValue >= 0 & shardValue <= MAX_SHARD_VALUE); - } -} diff --git a/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java b/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java new file mode 100644 index 00000000..1891923b --- /dev/null +++ b/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java @@ -0,0 +1,150 @@ +package cloud.eppo.ufc.deserializer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cloud.eppo.ufc.dto.*; +import cloud.eppo.ufc.dto.adapters.EppoModule; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.io.IOException; +import java.util.Map; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Test; + +class BanditParametersResponseDeserializerTest { + private final ObjectMapper mapper = new ObjectMapper().registerModule(EppoModule.eppoModule()); + + @Test + public void testDeserializingBandits() throws IOException { + String jsonString = + FileUtils.readFileToString( + new File("src/test/resources/bandits-parameters-1.json"), "UTF8"); + BanditParametersResponse responseObject = + this.mapper.readValue(jsonString, BanditParametersResponse.class); + + assertEquals(2, responseObject.getBandits().size()); + BanditParameters parameters = responseObject.getBandits().get("banner-bandit"); + assertEquals("banner-bandit", parameters.getBanditKey()); + assertEquals("falcon", parameters.getModelName()); + assertEquals("v123", parameters.getModelVersion()); + + BanditModelData modelData = parameters.getModelData(); + assertEquals(1.0, modelData.getGamma()); + assertEquals(0.0, modelData.getDefaultActionScore()); + assertEquals(0.0, modelData.getActionProbabilityFloor()); + + Map coefficients = modelData.getCoefficients(); + assertEquals(2, coefficients.size()); + + // Nike + + BanditCoefficients nikeCoefficients = coefficients.get("nike"); + assertEquals("nike", nikeCoefficients.getActionKey()); + assertEquals(1.0, nikeCoefficients.getIntercept()); + + // Nike subject coefficients + + Map nikeSubjectNumericCoefficients = + nikeCoefficients.getSubjectNumericCoefficients(); + assertEquals(1, nikeSubjectNumericCoefficients.size()); + + BanditNumericAttributeCoefficients nikeAccountAgeCoefficients = + nikeSubjectNumericCoefficients.get("account_age"); + assertEquals("account_age", nikeAccountAgeCoefficients.getAttributeKey()); + assertEquals(0.3, nikeAccountAgeCoefficients.getCoefficient()); + assertEquals(0.0, nikeAccountAgeCoefficients.getMissingValueCoefficient()); + + Map nikeSubjectCategoricalCoefficients = + nikeCoefficients.getSubjectCategoricalCoefficients(); + assertEquals(1, nikeSubjectCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients nikeGenderIdentityCoefficients = + nikeSubjectCategoricalCoefficients.get("gender_identity"); + assertEquals("gender_identity", nikeGenderIdentityCoefficients.getAttributeKey()); + assertEquals(2.3, nikeGenderIdentityCoefficients.getMissingValueCoefficient()); + Map nikeGenderIdentityCoefficientValues = + nikeGenderIdentityCoefficients.getValueCoefficients(); + assertEquals(2, nikeGenderIdentityCoefficientValues.size()); + assertEquals(0.5, nikeGenderIdentityCoefficientValues.get("female")); + assertEquals(-0.5, nikeGenderIdentityCoefficientValues.get("male")); + + // Nike action coefficients + + Map nikeActionNumericCoefficients = + nikeCoefficients.getActionNumericCoefficients(); + assertEquals(1, nikeActionNumericCoefficients.size()); + + BanditNumericAttributeCoefficients nikeBrandAffinityCoefficient = + nikeActionNumericCoefficients.get("brand_affinity"); + assertEquals("brand_affinity", nikeBrandAffinityCoefficient.getAttributeKey()); + assertEquals(1.0, nikeBrandAffinityCoefficient.getCoefficient()); + assertEquals(-0.1, nikeBrandAffinityCoefficient.getMissingValueCoefficient()); + + Map nikeActionCategoricalCoefficients = + nikeCoefficients.getActionCategoricalCoefficients(); + assertEquals(1, nikeActionCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients nikeLoyaltyCoefficients = + nikeActionCategoricalCoefficients.get("loyalty_tier"); + assertEquals("loyalty_tier", nikeLoyaltyCoefficients.getAttributeKey()); + assertEquals(0.0, nikeLoyaltyCoefficients.getMissingValueCoefficient()); + Map nikeLoyaltyCoefficientValues = + nikeLoyaltyCoefficients.getValueCoefficients(); + assertEquals(3, nikeLoyaltyCoefficientValues.size()); + assertEquals(4.5, nikeLoyaltyCoefficientValues.get("gold")); + assertEquals(3.2, nikeLoyaltyCoefficientValues.get("silver")); + assertEquals(1.9, nikeLoyaltyCoefficientValues.get("bronze")); + + // Adidas + + BanditCoefficients adidasCoefficients = coefficients.get("adidas"); + assertEquals("adidas", adidasCoefficients.getActionKey()); + assertEquals(1.1, adidasCoefficients.getIntercept()); + + // Adidas subject coefficients + + Map adidasSubjectNumericCoefficients = + adidasCoefficients.getSubjectNumericCoefficients(); + assertEquals(0, adidasSubjectNumericCoefficients.size()); + + Map adidasSubjectCategoricalCoefficients = + adidasCoefficients.getSubjectCategoricalCoefficients(); + assertEquals(1, adidasSubjectCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients adidasGenderIdentityCoefficient = + adidasSubjectCategoricalCoefficients.get("gender_identity"); + assertEquals("gender_identity", adidasGenderIdentityCoefficient.getAttributeKey()); + assertEquals(0.45, adidasGenderIdentityCoefficient.getMissingValueCoefficient()); + Map adidasGenderIdentityCoefficientValues = + adidasGenderIdentityCoefficient.getValueCoefficients(); + assertEquals(2, adidasGenderIdentityCoefficientValues.size()); + assertEquals(0.0, adidasGenderIdentityCoefficientValues.get("female")); + assertEquals(0.3, adidasGenderIdentityCoefficientValues.get("male")); + + // Adidas action coefficients + + Map adidasActionNumericCoefficients = + adidasCoefficients.getActionNumericCoefficients(); + assertEquals(1, nikeActionNumericCoefficients.size()); + + BanditNumericAttributeCoefficients adidasBrandAffinityCoefficient = + adidasActionNumericCoefficients.get("brand_affinity"); + assertEquals("brand_affinity", adidasBrandAffinityCoefficient.getAttributeKey()); + assertEquals(2.0, adidasBrandAffinityCoefficient.getCoefficient()); + assertEquals(1.2, adidasBrandAffinityCoefficient.getMissingValueCoefficient()); + + Map adidasActionCategoricalCoefficients = + adidasCoefficients.getActionCategoricalCoefficients(); + assertEquals(1, adidasActionCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients adidasPurchasedLast30Coefficient = + adidasActionCategoricalCoefficients.get("purchased_last_30_days"); + assertEquals("purchased_last_30_days", adidasPurchasedLast30Coefficient.getAttributeKey()); + assertEquals(0.0, adidasPurchasedLast30Coefficient.getMissingValueCoefficient()); + Map adidasPurchasedLast30CoefficientValues = + adidasPurchasedLast30Coefficient.getValueCoefficients(); + assertEquals(2, adidasPurchasedLast30CoefficientValues.size()); + assertEquals(9.0, adidasPurchasedLast30CoefficientValues.get("true")); + assertEquals(0.0, adidasPurchasedLast30CoefficientValues.get("false")); + } +} diff --git a/src/test/resources/bandits-parameters-1.json b/src/test/resources/bandits-parameters-1.json new file mode 100644 index 00000000..18d421a7 --- /dev/null +++ b/src/test/resources/bandits-parameters-1.json @@ -0,0 +1,101 @@ +{ + "updatedAt": "2023-09-13T04:52:06.462Z", + "bandits": { + "banner-bandit": { + "banditKey": "banner-bandit", + "modelName": "falcon", + "updatedAt": "2023-09-13T04:52:06.462Z", + "modelVersion": "v123", + "modelData": { + "gamma": 1.0, + "defaultActionScore": 0.0, + "actionProbabilityFloor": 0.0, + "coefficients": { + "nike": { + "actionKey": "nike", + "intercept": 1.0, + "actionNumericCoefficients": [ + { + "attributeKey": "brand_affinity", + "coefficient": 1.0, + "missingValueCoefficient": -0.1 + } + ], + "actionCategoricalCoefficients": [ + { + "attributeKey": "loyalty_tier", + "valueCoefficients": { + "gold": 4.5, + "silver": 3.2, + "bronze": 1.9 + }, + "missingValueCoefficient": 0.0 + } + ], + "subjectNumericCoefficients": [ + { + "attributeKey": "account_age", + "coefficient": 0.3, + "missingValueCoefficient": 0.0 + } + ], + "subjectCategoricalCoefficients": [ + { + "attributeKey": "gender_identity", + "valueCoefficients": { + "female": 0.5, + "male": -0.5 + }, + "missingValueCoefficient": 2.3 + } + ] + }, + "adidas": { + "actionKey": "adidas", + "intercept": 1.1, + "actionNumericCoefficients": [ + { + "attributeKey": "brand_affinity", + "coefficient": 2.0, + "missingValueCoefficient": 1.2 + } + ], + "actionCategoricalCoefficients": [ + { + "attributeKey": "purchased_last_30_days", + "valueCoefficients": { + "true": 9.0, + "false": 0.0 + }, + "missingValueCoefficient": 0.0 + } + ], + "subjectNumericCoefficients": [], + "subjectCategoricalCoefficients": [ + { + "attributeKey": "gender_identity", + "valueCoefficients": { + "female": 0.0, + "male": 0.3 + }, + "missingValueCoefficient": 0.45 + } + ] + } + } + } + }, + "cold-start-bandit": { + "banditKey": "cold-start-bandit", + "modelName": "falcon", + "updatedAt": "2023-09-13T04:52:06.462Z", + "modelVersion": "cold start", + "modelData": { + "gamma": 1.0, + "defaultActionScore": 0.0, + "actionProbabilityFloor": 0.0, + "coefficients": {} + } + } + } +} diff --git a/src/test/resources/rac-experiments-v3.json b/src/test/resources/rac-experiments-v3.json deleted file mode 100644 index c6fb46fa..00000000 --- a/src/test/resources/rac-experiments-v3.json +++ /dev/null @@ -1,559 +0,0 @@ -{ - "flags": { - "randomization_algo": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-1", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-1": { - "percentExposure": 0.4533, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 3333 - }, - "algorithmType": "OVERRIDE" - }, - { - "name": "red", - "value": "red", - "typedValue": "red", - "shardRange": { - "start": 3333, - "end": 6666 - }, - "algorithmType": "CONSTANT" - }, - { - "name": "green", - "value": "green", - "typedValue": "green", - "shardRange": { - "start": 6666, - "end": 10000 - }, - "algorithmType": "CONSTANT" - } - ] - } - } - }, - "new_user_onboarding": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-2", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-2": { - "percentExposure": 0.9500, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 2500 - } - }, - { - "name": "red", - "value": "red", - "typedValue": "red", - "shardRange": { - "start": 2500, - "end": 5000 - } - }, - { - "name": "green", - "value": "green", - "typedValue": "green", - "shardRange": { - "start": 5000, - "end": 7500 - } - }, - { - "name": "purple", - "value": "purple", - "typedValue": "purple", - "shardRange": { - "start": 7500, - "end": 10000 - } - } - ] - } - } - }, - "disabled_experiment_with_overrides": { - "subjectShards": 10000, - "overrides": { - "0bcbfc2660c78c549b0fbf870e3dc3ea": "treatment", - "a90ea45116d251a43da56e03d3dd7275": "control", - "e5cb922bc7e1a13636e361a424b4a3f3": "control", - "50a681dcd4046400e5c675e85b69b4ac": "control" - }, - "typedOverrides": { - "0bcbfc2660c78c549b0fbf870e3dc3ea": "treatment", - "a90ea45116d251a43da56e03d3dd7275": "control", - "e5cb922bc7e1a13636e361a424b4a3f3": "control", - "50a681dcd4046400e5c675e85b69b4ac": "control" - }, - "enabled": false, - "rules": [ - { - "allocationKey": "allocation-experiment-3", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-3": { - "percentExposure": 1, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 5000 - } - }, - { - "name": "treatment", - "value": "treatment", - "typedValue": "treatment", - "shardRange": { - "start": 5000, - "end": 10000 - } - } - ] - } - } - }, - "targeting_rules_experiment": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-4", - "conditions": [ - { - "value": [ - "iOS", - "Android" - ], - "operator": "ONE_OF", - "attribute": "device" - }, - { - "value": 1, - "operator": "GT", - "attribute": "version" - } - ] - }, - { - "allocationKey": "allocation-experiment-4", - "conditions": [ - { - "value": [ - "China" - ], - "operator": "NOT_ONE_OF", - "attribute": "country" - } - ] - }, - { - "allocationKey": "allocation-experiment-4", - "conditions": [ - { - "value": ".*geteppo.com", - "operator": "MATCHES", - "attribute": "email" - } - ] - } - ], - "allocations": { - "allocation-experiment-4": { - "percentExposure": 1, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 5000 - } - }, - { - "name": "treatment", - "value": "treatment", - "typedValue": "treatment", - "shardRange": { - "start": 5000, - "end": 10000 - } - } - ] - } - } - }, - "experiment_with_numeric_variations": { - "subjectShards": 10000, - "overrides": { - "0bcbfc2660c78c549b0fbf870e3dc3ea": "5", - "a90ea45116d251a43da56e03d3dd7275": "10", - "e5cb922bc7e1a13636e361a424b4a3f3": "10", - "50a681dcd4046400e5c675e85b69b4ac": "10" - }, - "typedOverrides": { - "0bcbfc2660c78c549b0fbf870e3dc3ea": 5, - "a90ea45116d251a43da56e03d3dd7275": 10, - "e5cb922bc7e1a13636e361a424b4a3f3": 10, - "50a681dcd4046400e5c675e85b69b4ac": 10 - }, - "enabled": false, - "rules": [ - { - "allocationKey": "allocation-experiment-5", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-5": { - "percentExposure": 1, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "50", - "typedValue": 50, - "shardRange": { - "start": 0, - "end": 5000 - } - }, - { - "name": "treatment", - "value": "100", - "typedValue": 100, - "shardRange": { - "start": 5000, - "end": 10000 - } - } - ] - } - } - }, - "experiment_with_boolean_variations": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-6", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-6": { - "percentExposure": 1, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "true", - "typedValue": true, - "shardRange": { - "start": 0, - "end": 5000 - } - }, - { - "name": "treatment", - "value": "false", - "typedValue": false, - "shardRange": { - "start": 5000, - "end": 10000 - } - } - ] - } - } - }, - "experiment_with_json_variations": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-7", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-7": { - "percentExposure": 1, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "{\"test\":true}", - "typedValue": { - "test": true - }, - "shardRange": { - "start": 0, - "end": 5000 - } - }, - { - "name": "treatment", - "value": "{\"test\":false}", - "typedValue": { - "test": false - }, - "shardRange": { - "start": 5000, - "end": 10000 - } - } - ] - } - } - }, - "test_bandit_1": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "bandit", - "conditions": [] - } - ], - "allocations": { - "bandit": { - "percentExposure": 0.4533, - "statusQuoVariationKey": null, - "shippedVariationKey": null, - "holdouts": [], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 2000 - }, - "algorithmType": "CONSTANT" - }, - { - "name": "bandit", - "value": "banner-bandit", - "typedValue": "banner-bandit", - "shardRange": { - "start": 2000, - "end": 10000 - }, - "algorithmType": "CONTEXTUAL_BANDIT" - } - ] - } - } - }, - "experiment_with_holdout": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-1", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-1": { - "percentExposure": 0.4533, - "statusQuoVariationKey": "variation-7", - "shippedVariationKey": null, - "holdouts": [ - { - "holdoutKey": "holdout-2", - "statusQuoShardRange": { - "start": 4321, - "end": 4521 - }, - "shippedShardRange": null - }, - { - "holdoutKey": "holdout-3", - "statusQuoShardRange": { - "start": 8765, - "end": 8965 - }, - "shippedShardRange": null - } - ], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 3333 - }, - "variationKey": "variation-7" - }, - { - "name": "red", - "value": "red", - "typedValue": "red", - "shardRange": { - "start": 3333, - "end": 6666 - }, - "variationKey": "variation-8" - }, - { - "name": "green", - "value": "green", - "typedValue": "green", - "shardRange": { - "start": 6666, - "end": 10000 - }, - "variationKey": "variation-9" - } - ] - } - } - }, - "rollout_with_holdout": { - "subjectShards": 10000, - "overrides": {}, - "typedOverrides": {}, - "enabled": true, - "rules": [ - { - "allocationKey": "allocation-experiment-1", - "conditions": [] - } - ], - "allocations": { - "allocation-experiment-1": { - "percentExposure": 0.4533, - "statusQuoVariationKey": "variation-7", - "shippedVariationKey": "variation-8", - "holdouts": [ - { - "holdoutKey": "holdout-2", - "statusQuoShardRange": { - "start": 4321, - "end": 4421 - }, - "shippedShardRange": { - "start": 4421, - "end": 4521 - } - }, - { - "holdoutKey": "holdout-3", - "statusQuoShardRange": { - "start": 8765, - "end": 8865 - }, - "shippedShardRange": { - "start": 8865, - "end": 8965 - } - } - ], - "variations": [ - { - "name": "control", - "value": "control", - "typedValue": "control", - "shardRange": { - "start": 0, - "end": 0 - }, - "variationKey": "variation-7" - }, - { - "name": "red", - "value": "red", - "typedValue": "red", - "shardRange": { - "start": 0, - "end": 10000 - }, - "variationKey": "variation-8" - }, - { - "name": "green", - "value": "green", - "typedValue": "green", - "shardRange": { - "start": 0, - "end": 0 - }, - "variationKey": "variation-9" - } - ] - } - } - } - } -}