Skip to content

Commit 575d8c7

Browse files
authored
feat: Allow initialization using a JSON string. (#38)
* Allow passing of initial configuration string(s) * bump minor version * Config obfuscation is independent of initial config * move everything into Configuration class and make it immutable
1 parent db7ce6d commit 575d8c7

10 files changed

+502
-134
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = 'cloud.eppo'
9-
version = '3.0.3-SNAPSHOT'
9+
version = '3.1.0-SNAPSHOT'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

1212
java {

src/main/java/cloud/eppo/BaseEppoClient.java

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package cloud.eppo;
22

3-
import static cloud.eppo.Utils.getMD5Hex;
43
import static cloud.eppo.Utils.throwIfEmptyOrNull;
54

65
import cloud.eppo.api.*;
@@ -32,7 +31,6 @@ public class BaseEppoClient {
3231
private final BanditLogger banditLogger;
3332
private final String sdkName;
3433
private final String sdkVersion;
35-
private final boolean isConfigObfuscated;
3634
private boolean isGracefulMode;
3735

3836
// Fields useful for testing in situations where we want to mock the http client or configuration
@@ -49,6 +47,28 @@ protected BaseEppoClient(
4947
BanditLogger banditLogger,
5048
boolean isGracefulMode,
5149
boolean expectObfuscatedConfig) {
50+
this(
51+
apiKey,
52+
sdkName,
53+
sdkVersion,
54+
host,
55+
assignmentLogger,
56+
banditLogger,
57+
isGracefulMode,
58+
expectObfuscatedConfig,
59+
null);
60+
}
61+
62+
protected BaseEppoClient(
63+
String apiKey,
64+
String sdkName,
65+
String sdkVersion,
66+
String host,
67+
AssignmentLogger assignmentLogger,
68+
BanditLogger banditLogger,
69+
boolean isGracefulMode,
70+
boolean expectObfuscatedConfig,
71+
Configuration initialConfiguration) {
5272

5373
if (apiKey == null) {
5474
throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key");
@@ -62,16 +82,16 @@ protected BaseEppoClient(
6282
}
6383

6484
EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion);
65-
this.configurationStore = new ConfigurationStore();
66-
requestor = new ConfigurationRequestor(configurationStore, httpClient);
85+
this.configurationStore = new ConfigurationStore(initialConfiguration);
86+
87+
// For now, the configuration is only obfuscated for Android clients
88+
requestor = new ConfigurationRequestor(configurationStore, httpClient, expectObfuscatedConfig);
6789
this.assignmentLogger = assignmentLogger;
6890
this.banditLogger = banditLogger;
6991
this.isGracefulMode = isGracefulMode;
7092
// Save SDK name and version to include in logger metadata
7193
this.sdkName = sdkName;
7294
this.sdkVersion = sdkVersion;
73-
// For now, the configuration is only obfuscated for Android clients
74-
this.isConfigObfuscated = expectObfuscatedConfig;
7595

7696
// TODO: caching initialization (such as setting an API-key-specific prefix
7797
// will probably involve passing in configurationStore to the constructor
@@ -106,12 +126,9 @@ protected EppoValue getTypedAssignment(
106126
throwIfEmptyOrNull(flagKey, "flagKey must not be empty");
107127
throwIfEmptyOrNull(subjectKey, "subjectKey must not be empty");
108128

109-
String flagKeyForLookup = flagKey;
110-
if (isConfigObfuscated) {
111-
flagKeyForLookup = getMD5Hex(flagKey);
112-
}
129+
Configuration config = configurationStore.getConfiguration();
113130

114-
FlagConfig flag = requestor.getConfiguration(flagKeyForLookup);
131+
FlagConfig flag = config.getFlag(flagKey);
115132
if (flag == null) {
116133
log.warn("no configuration found for key: {}", flagKey);
117134
return defaultValue;
@@ -134,7 +151,7 @@ protected EppoValue getTypedAssignment(
134151

135152
FlagEvaluationResult evaluationResult =
136153
FlagEvaluator.evaluateFlag(
137-
flag, flagKey, subjectKey, subjectAttributes, isConfigObfuscated);
154+
flag, flagKey, subjectKey, subjectAttributes, config.isConfigObfuscated());
138155
EppoValue assignedValue =
139156
evaluationResult.getVariation() != null ? evaluationResult.getVariation().getValue() : null;
140157

@@ -156,7 +173,7 @@ protected EppoValue getTypedAssignment(
156173
// allocation key
157174
String variationKey = evaluationResult.getVariation().getKey();
158175
Map<String, String> extraLogging = evaluationResult.getExtraLogging();
159-
Map<String, String> metaData = buildLogMetaData();
176+
Map<String, String> metaData = buildLogMetaData(config.isConfigObfuscated());
160177

161178
Assignment assignment =
162179
new Assignment(
@@ -384,6 +401,7 @@ public BanditResult getBanditAction(
384401
Actions actions,
385402
String defaultValue) {
386403
BanditResult result = new BanditResult(defaultValue, null);
404+
final Configuration config = configurationStore.getConfiguration();
387405
try {
388406
String assignedVariation =
389407
getStringAssignment(
@@ -392,9 +410,9 @@ public BanditResult getBanditAction(
392410
// Update result to reflect that we've been assigned a variation
393411
result = new BanditResult(assignedVariation, null);
394412

395-
String banditKey = configurationStore.banditKeyForVariation(flagKey, assignedVariation);
413+
String banditKey = config.banditKeyForVariation(flagKey, assignedVariation);
396414
if (banditKey != null && !actions.isEmpty()) {
397-
BanditParameters banditParameters = configurationStore.getBanditParameters(banditKey);
415+
BanditParameters banditParameters = config.getBanditParameters(banditKey);
398416
BanditEvaluationResult banditResult =
399417
BanditEvaluator.evaluateBandit(
400418
flagKey, subjectKey, subjectAttributes, actions, banditParameters.getModelData());
@@ -417,7 +435,7 @@ public BanditResult getBanditAction(
417435
subjectAttributes.getCategoricalAttributes(),
418436
banditResult.getActionAttributes().getNumericAttributes(),
419437
banditResult.getActionAttributes().getCategoricalAttributes(),
420-
buildLogMetaData());
438+
buildLogMetaData(config.isConfigObfuscated()));
421439

422440
banditLogger.logBanditAssignment(banditAssignment);
423441
} catch (Exception e) {
@@ -431,7 +449,7 @@ public BanditResult getBanditAction(
431449
}
432450
}
433451

434-
private Map<String, String> buildLogMetaData() {
452+
private Map<String, String> buildLogMetaData(boolean isConfigObfuscated) {
435453
HashMap<String, String> metaData = new HashMap<>();
436454
metaData.put("obfuscated", Boolean.valueOf(isConfigObfuscated).toString());
437455
metaData.put("sdkLanguage", sdkName);
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package cloud.eppo;
2+
3+
import static cloud.eppo.Utils.getMD5Hex;
4+
5+
import cloud.eppo.ufc.dto.*;
6+
import cloud.eppo.ufc.dto.adapters.EppoModule;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import java.io.IOException;
9+
import java.util.Collections;
10+
import java.util.Map;
11+
import java.util.Set;
12+
import java.util.stream.Collectors;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
16+
/**
17+
* Encapsulates the Flag Configuration and Bandit parameters in an immutable object with a complete
18+
* and coherent state.
19+
*
20+
* <p>A Builder is used to prepare and then create am immutable data structure containing both flag
21+
* and bandit configurations. An intermediate step is required in building the configuration to
22+
* accommodate the as-needed loading of bandit parameters as a network call may not be needed if
23+
* there are no bandits referenced by the flag configuration.
24+
*
25+
* <p>Usage: Building with just flag configuration (unobfuscated is default) <code>
26+
* Configuration config = new Configuration.Builder(flagConfigJsonString).build();
27+
* </code>
28+
*
29+
* <p>Building with bandits (known configuration) <code>
30+
* Configuration config = new Configuration.Builder(flagConfigJsonString).banditParameters(banditConfigJson).build();
31+
* </code>
32+
*
33+
* <p>Conditionally loading bandit models (with or without an existing bandit config JSON string).
34+
* <code>
35+
* Configuration.Builder configBuilder = new Configuration.Builder(flagConfigJsonString).banditParameters(banditConfigJson);
36+
* if (configBuilder.requiresBanditModels()) {
37+
* // Load the bandit parameters encoded in a JSON string
38+
* configBuilder.banditParameters(banditParameterJsonString);
39+
* }
40+
* Configuration config = configBuilder.build();
41+
* </code>
42+
*
43+
* <p>
44+
*
45+
* <p>Hint: when loading new Flag configuration values, set the current bandit models in the builder
46+
* then check `requiresBanditModels()`.
47+
*/
48+
public class Configuration {
49+
private static final Logger log = LoggerFactory.getLogger(Configuration.class);
50+
private final Map<String, BanditReference> banditReferences;
51+
private final Map<String, FlagConfig> flags;
52+
private final Map<String, BanditParameters> bandits;
53+
private final boolean isConfigObfuscated;
54+
55+
@SuppressWarnings("unused")
56+
private final byte[] flagConfigJson;
57+
58+
private final byte[] banditParamsJson;
59+
60+
private Configuration(
61+
Map<String, FlagConfig> flags,
62+
Map<String, BanditReference> banditReferences,
63+
Map<String, BanditParameters> bandits,
64+
boolean isConfigObfuscated,
65+
byte[] flagConfigJson,
66+
byte[] banditParamsJson) {
67+
this.flags = flags;
68+
this.banditReferences = banditReferences;
69+
this.bandits = bandits;
70+
this.isConfigObfuscated = isConfigObfuscated;
71+
this.flagConfigJson = flagConfigJson;
72+
this.banditParamsJson = banditParamsJson;
73+
}
74+
75+
public static Configuration emptyConfig() {
76+
return new Configuration(
77+
Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), false, null, null);
78+
}
79+
80+
public FlagConfig getFlag(String flagKey) {
81+
String flagKeyForLookup = flagKey;
82+
if (isConfigObfuscated()) {
83+
flagKeyForLookup = getMD5Hex(flagKey);
84+
}
85+
86+
if (flags == null) {
87+
log.warn("Request for flag {} before flags have been loaded", flagKey);
88+
return null;
89+
} else if (flags.isEmpty()) {
90+
log.warn("Request for flag {} with empty flags", flagKey);
91+
}
92+
return flags.get(flagKeyForLookup);
93+
}
94+
95+
public String banditKeyForVariation(String flagKey, String variationValue) {
96+
// Note: In practice this double loop should be quite quick as the number of bandits and bandit
97+
// variations will be small. Should this ever change, we can optimize things.
98+
for (Map.Entry<String, BanditReference> banditEntry : banditReferences.entrySet()) {
99+
BanditReference banditReference = banditEntry.getValue();
100+
for (BanditFlagVariation banditFlagVariation : banditReference.getFlagVariations()) {
101+
if (banditFlagVariation.getFlagKey().equals(flagKey)
102+
&& banditFlagVariation.getVariationValue().equals(variationValue)) {
103+
return banditEntry.getKey();
104+
}
105+
}
106+
}
107+
return null;
108+
}
109+
110+
public BanditParameters getBanditParameters(String banditKey) {
111+
return bandits.get(banditKey);
112+
}
113+
114+
public boolean isConfigObfuscated() {
115+
return isConfigObfuscated;
116+
}
117+
118+
/**
119+
* Builder to create the immutable config object.
120+
*
121+
* @see cloud.eppo.Configuration for usage.
122+
*/
123+
public static class Builder {
124+
private static final ObjectMapper mapper =
125+
new ObjectMapper().registerModule(EppoModule.eppoModule());
126+
127+
private final boolean isConfigObfuscated;
128+
private final Map<String, FlagConfig> flags;
129+
private Map<String, BanditReference> banditReferences;
130+
private Map<String, BanditParameters> bandits = Collections.emptyMap();
131+
private final byte[] flagJson;
132+
private byte[] banditParamsJson;
133+
134+
public Builder(String flagJson, boolean isConfigObfuscated) {
135+
this(flagJson.getBytes(), isConfigObfuscated);
136+
}
137+
138+
public Builder(byte[] flagJson, boolean isConfigObfuscated) {
139+
this.isConfigObfuscated = isConfigObfuscated;
140+
141+
if (flagJson == null || flagJson.length == 0) {
142+
throw new RuntimeException(
143+
"Null or empty configuration string. Call `Configuration.Empty()` instead");
144+
}
145+
146+
// Build the flags config from the json string.
147+
FlagConfigResponse config;
148+
try {
149+
config = mapper.readValue(flagJson, FlagConfigResponse.class);
150+
} catch (IOException e) {
151+
throw new RuntimeException(e);
152+
}
153+
154+
if (config == null || config.getFlags() == null) {
155+
log.warn("'flags' map missing in flag definition JSON");
156+
flags = Collections.emptyMap();
157+
this.flagJson = null;
158+
} else {
159+
flags = Collections.unmodifiableMap(config.getFlags());
160+
banditReferences = Collections.unmodifiableMap(config.getBanditReferences());
161+
this.flagJson = flagJson;
162+
log.debug("Loaded {} flag definitions from flag definition JSON", flags.size());
163+
}
164+
}
165+
166+
public boolean requiresBanditModels() {
167+
Set<String> neededModelVersions = referencedBanditModelVersion();
168+
return !loadedBanditModelVersions().containsAll(neededModelVersions);
169+
}
170+
171+
public Set<String> loadedBanditModelVersions() {
172+
return bandits.values().stream()
173+
.map(BanditParameters::getModelVersion)
174+
.collect(Collectors.toSet());
175+
}
176+
177+
public Set<String> referencedBanditModelVersion() {
178+
return banditReferences.values().stream()
179+
.map(BanditReference::getModelVersion)
180+
.collect(Collectors.toSet());
181+
}
182+
183+
public Builder banditParametersFromConfig(Configuration currentConfig) {
184+
if (currentConfig == null || currentConfig.bandits == null) {
185+
bandits = Collections.emptyMap();
186+
} else {
187+
bandits = currentConfig.bandits;
188+
banditParamsJson = currentConfig.banditParamsJson;
189+
}
190+
return this;
191+
}
192+
193+
public Builder banditParameters(String banditParameterJson) {
194+
return banditParameters(banditParameterJson.getBytes());
195+
}
196+
197+
public Builder banditParameters(byte[] banditParameterJson) {
198+
BanditParametersResponse config;
199+
try {
200+
config = mapper.readValue(banditParameterJson, BanditParametersResponse.class);
201+
} catch (IOException e) {
202+
log.error("Unable to parse bandit parameters JSON");
203+
throw new RuntimeException(e);
204+
}
205+
206+
if (config == null || config.getBandits() == null) {
207+
log.warn("`bandits` map missing in bandit parameters JSON");
208+
bandits = Collections.emptyMap();
209+
} else {
210+
bandits = Collections.unmodifiableMap(config.getBandits());
211+
log.debug("Loaded {} bandit models from bandit parameters JSON", bandits.size());
212+
}
213+
214+
return this;
215+
}
216+
217+
public Configuration build() {
218+
return new Configuration(
219+
flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson);
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)