diff --git a/build.gradle b/build.gradle index da0a59e0..f43e1a7b 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,8 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3' implementation 'com.github.zafarkhaja:java-semver:0.10.2' implementation "com.squareup.okhttp3:okhttp:4.12.0" + // For base64 that works on on Android API 21 + implementation "net.iharder:base64:2.3.8" // For LRU and expiring maps implementation 'org.apache.commons:commons-collections4:4.4' implementation 'org.slf4j:slf4j-api:2.0.17' diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 281c3b25..291f9d7e 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -6,6 +6,12 @@ import cloud.eppo.api.*; import cloud.eppo.cache.AssignmentCacheEntry; +import cloud.eppo.json.JacksonMapper; +import cloud.eppo.json.JacksonMapperNode; +import cloud.eppo.json.Mapper; +import cloud.eppo.json.MapperException; +import cloud.eppo.json.MapperJsonProcessingException; +import cloud.eppo.json.MapperNode; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditAssignment; @@ -27,9 +33,7 @@ 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? + private final Mapper mapper; protected final ConfigurationRequestor requestor; @@ -73,6 +77,46 @@ protected BaseEppoClient( @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, @Nullable IAssignmentCache banditAssignmentCache) { + this( + new JacksonMapper(), + apiKey, + sdkName, + sdkVersion, + host, + apiBaseUrl, + assignmentLogger, + banditLogger, + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + supportBandits, + initialConfiguration, + assignmentCache, + banditAssignmentCache + ); + } + + // It is important that the bandit assignment cache expire with a short-enough TTL to last about + // one user session. + // The recommended is 10 minutes (per @Sven) + /** @param host To be removed in v4. use `apiBaseUrl` instead. */ + protected BaseEppoClient( + @NotNull Mapper mapper, + @NotNull String apiKey, + @NotNull String sdkName, + @NotNull String sdkVersion, + @Deprecated @Nullable String host, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + @Nullable BanditLogger banditLogger, + @Nullable IConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + boolean supportBandits, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + @Nullable IAssignmentCache banditAssignmentCache) { + this.mapper = mapper; if (apiKey == null) { throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key"); @@ -436,7 +480,12 @@ public JsonNode getJSONAssignment( subjectAttributes, EppoValue.valueOf(defaultValue.toString()), VariationType.JSON); - return parseJsonString(value.stringValue()); + MapperNode mapperNode = parseJsonString(value.stringValue()); + if (mapperNode != null) { + return ((JacksonMapperNode) mapperNode).getJsonNode(); + } else { + return null; + } } catch (Exception e) { return throwIfNotGraceful(e, defaultValue); } @@ -482,10 +531,10 @@ public String getJSONStringAssignment(String flagKey, String subjectKey, String return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue); } - private JsonNode parseJsonString(String jsonString) { + private MapperNode parseJsonString(String jsonString) { try { return mapper.readTree(jsonString); - } catch (JsonProcessingException e) { + } catch (MapperJsonProcessingException e) { return null; } } diff --git a/src/main/java/cloud/eppo/ConfigurationRequestorJava6.java b/src/main/java/cloud/eppo/ConfigurationRequestorJava6.java new file mode 100644 index 00000000..cd5954b3 --- /dev/null +++ b/src/main/java/cloud/eppo/ConfigurationRequestorJava6.java @@ -0,0 +1,99 @@ +package cloud.eppo; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import cloud.eppo.api.Configuration; +import cloud.eppo.callback.CallbackManager; + +public class ConfigurationRequestorJava6 { + private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestorJava6.class); + private enum ConfigState { + UNSET, + INITIAL_SET, + REMOTE_SET, + ; + } + + private final EppoHttpClient client; + private final IConfigurationStoreJava6 configurationStoreJava6; + private final boolean expectObfuscatedConfig; + private final boolean supportBandits; + + private final Object configStateLock = new Object(); + private ConfigState configState; + + private final CallbackManager configChangeManager = new CallbackManager<>(); + + public ConfigurationRequestorJava6( + @NotNull IConfigurationStoreJava6 configurationStoreJava6, + @NotNull EppoHttpClient client, + boolean expectObfuscatedConfig, + boolean supportBandits) { + this.configurationStoreJava6 = configurationStoreJava6; + this.client = client; + this.expectObfuscatedConfig = expectObfuscatedConfig; + this.supportBandits = supportBandits; + + synchronized (configStateLock) { + configState = ConfigState.UNSET; + } + } + + // Synchronously set the initial configuration. + public boolean setInitialConfigurationJava6(@NotNull Configuration configuration) { + synchronized (configStateLock) { + switch(configState) { + case UNSET: + configState = ConfigState.INITIAL_SET; + break; + case INITIAL_SET: + throw new IllegalStateException("Initial configuration has already been set"); + case REMOTE_SET: + return false; + } + } + + saveConfigurationAndNotifyJava6(configuration); + return true; + } + + /** Loads configuration synchronously from the API server. */ + void fetchAndSaveFromRemoteJava6() { + log.debug("Fetching configuration"); + + // Reuse the `lastConfig` as its bandits may be useful + Configuration lastConfig = configurationStoreJava6.getConfiguration(); + + byte[] flagConfigurationJsonBytes = client.getJava6(Constants.FLAG_CONFIG_ENDPOINT); + Configuration.Builder configBuilder = + Configuration.builder(flagConfigurationJsonBytes, expectObfuscatedConfig) + .banditParametersFromConfig(lastConfig); + + if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { + byte[] banditParametersJsonBytes = client.getJava6(Constants.BANDIT_ENDPOINT); + configBuilder.banditParameters(banditParametersJsonBytes); + } + + synchronized (configStateLock) { + configState = ConfigState.REMOTE_SET; + } + saveConfigurationAndNotifyJava6(configBuilder.build()); + } + + private void saveConfigurationAndNotifyJava6(Configuration configuration) { + configurationStoreJava6.saveConfigurationJava6(configuration); + synchronized (configChangeManager) { + configChangeManager.notifyCallbacks(configuration); + } + } + + public Runnable onConfigurationChange(Consumer callback) { + return configChangeManager.subscribe(callback); + } +} diff --git a/src/main/java/cloud/eppo/ConfigurationStoreJava6.java b/src/main/java/cloud/eppo/ConfigurationStoreJava6.java new file mode 100644 index 00000000..9149634a --- /dev/null +++ b/src/main/java/cloud/eppo/ConfigurationStoreJava6.java @@ -0,0 +1,24 @@ +package cloud.eppo; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +import cloud.eppo.api.Configuration; + +/** Memory-only configuration store. */ +public class ConfigurationStoreJava6 implements IConfigurationStoreJava6 { + + // this is the fallback value if no configuration is provided (i.e. by fetch or initial config). + @NotNull private volatile Configuration configuration = Configuration.emptyConfig(); + + public ConfigurationStoreJava6() {} + + public void saveConfigurationJava6(@NotNull final Configuration configuration) { + this.configuration = configuration; + } + + @NotNull public Configuration getConfiguration() { + return configuration; + } +} diff --git a/src/main/java/cloud/eppo/EppoHttpClient.java b/src/main/java/cloud/eppo/EppoHttpClient.java index 656e844f..69dab48b 100644 --- a/src/main/java/cloud/eppo/EppoHttpClient.java +++ b/src/main/java/cloud/eppo/EppoHttpClient.java @@ -6,6 +6,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.HttpUrl; @@ -54,6 +56,43 @@ public byte[] get(String path) { } } + public byte[] getJava6(String path) { + Request request = buildRequest(path); + Call call = client.newCall(request); + Response response = null; + try { + response = call.execute(); + try { + if (response.isSuccessful() && response.body() != null) { + log.debug("Fetch successful"); + return response.body().bytes(); + } else { + if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { + new RuntimeException("Invalid API key"); + } else { + log.debug("Fetch failed with status code: {}", response.code()); + new RuntimeException("Bad response from URL " + request.url()); + } + } + } catch (IOException ex) { + new RuntimeException( + "Failed to read response from URL {}" + request.url(), ex); + } finally { + if (response != null) { + response.close(); + } + } + } catch (IOException e) { + log.error( + "Http request failure: {} {}", + e.getMessage(), + Arrays.toString(e.getStackTrace()), + e); + new RuntimeException("Unable to fetch from URL " + request.url()); + } + throw new RuntimeException("Java compiler not smart enough to know all paths are covered"); + } + public CompletableFuture getAsync(String path) { CompletableFuture future = new CompletableFuture<>(); Request request = buildRequest(path); diff --git a/src/main/java/cloud/eppo/IConfigurationStoreJava6.java b/src/main/java/cloud/eppo/IConfigurationStoreJava6.java new file mode 100644 index 00000000..7d432c01 --- /dev/null +++ b/src/main/java/cloud/eppo/IConfigurationStoreJava6.java @@ -0,0 +1,18 @@ +package cloud.eppo; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import cloud.eppo.api.Configuration; + +/** + * Common interface for extensions of this SDK to support caching and other strategies for + * persisting configuration data across sessions. + */ +public interface IConfigurationStoreJava6 { + @NotNull Configuration getConfiguration(); + + void saveConfigurationJava6(Configuration configuration); +} diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 41c9874b..3dd388a0 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -1,12 +1,15 @@ package cloud.eppo; import com.fasterxml.jackson.databind.JsonNode; + +import net.iharder.Base64; + +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.Base64; import java.util.Date; import java.util.Locale; import org.slf4j.Logger; @@ -105,14 +108,21 @@ public static String base64Encode(String input) { if (input == null) { return null; } - return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); + return Base64.encodeBytes(input.getBytes(StandardCharsets.UTF_8)); } public static String base64Decode(String input) { if (input == null) { return null; } - byte[] decodedBytes = Base64.getDecoder().decode(input); + byte[] decodedBytes; + try { + decodedBytes = Base64.decode(input); + } catch (IOException rethrow) { + // java.util.Base64 throws IllegalArgumentException + // on base64 format errors + throw new IllegalArgumentException(rethrow); + } if (decodedBytes.length == 0 && !input.isEmpty()) { throw new RuntimeException( "zero byte output from Base64; if not running on Android hardware be sure to use RobolectricTestRunner"); diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 8b1e8376..d6a795fc 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -2,11 +2,10 @@ import static cloud.eppo.Utils.getMD5Hex; +import cloud.eppo.json.JacksonMapper; +import cloud.eppo.json.Mapper; +import cloud.eppo.json.MapperNode; import cloud.eppo.ufc.dto.*; -import cloud.eppo.ufc.dto.adapters.EppoModule; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.*; import java.util.Collections; import java.util.Map; @@ -49,9 +48,6 @@ * then check `requiresBanditModels()`. */ public class Configuration { - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(EppoModule.eppoModule()); - private static final byte[] emptyFlagsBytes = "{ \"flags\": {}, \"format\": \"SERVER\" }".getBytes(); @@ -74,6 +70,26 @@ public class Configuration { boolean isConfigObfuscated, byte[] flagConfigJson, byte[] banditParamsJson) { + this( + new JacksonMapper(), + flags, + banditReferences, + bandits, + isConfigObfuscated, + flagConfigJson, + banditParamsJson + ); + } + + /** Default visibility for tests. */ + Configuration( + Mapper mapper, + Map flags, + Map banditReferences, + Map bandits, + boolean isConfigObfuscated, + byte[] flagConfigJson, + byte[] banditParamsJson) { this.flags = flags; this.banditReferences = banditReferences; this.bandits = bandits; @@ -82,13 +98,13 @@ public class Configuration { // Graft the `forServer` boolean into the flagConfigJson' if (flagConfigJson != null && flagConfigJson.length != 0) { try { - JsonNode jNode = mapper.readTree(flagConfigJson); + MapperNode node = mapper.readTree(flagConfigJson); FlagConfigResponse.Format format = isConfigObfuscated ? FlagConfigResponse.Format.CLIENT : FlagConfigResponse.Format.SERVER; - ((ObjectNode) jNode).put("format", format.toString()); - flagConfigJson = mapper.writeValueAsBytes(jNode); + node.put("format", format.toString()); + flagConfigJson = mapper.writeValueAsBytes(node); } catch (IOException e) { log.error("Error adding `format` field to FlagConfigResponse JSON"); } @@ -99,6 +115,7 @@ public class Configuration { public static Configuration emptyConfig() { return new Configuration( + new JacksonMapper(), Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), @@ -184,7 +201,7 @@ public static Builder builder(byte[] flagJson, boolean isConfigObfuscated) { * @see Configuration for usage. */ public static class Builder { - + private final Mapper mapper; private final boolean isConfigObfuscated; private final Map flags; private final Map banditReferences; @@ -192,12 +209,11 @@ public static class Builder { private final byte[] flagJson; private byte[] banditParamsJson; - private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { + private static FlagConfigResponse parseFlagResponse(Mapper mapper, byte[] flagJson) { if (flagJson == null || flagJson.length == 0) { log.warn("Null or empty configuration string. Call `Configuration.Empty()` instead"); return null; } - FlagConfigResponse config; try { return mapper.readValue(flagJson, FlagConfigResponse.class); } catch (IOException e) { @@ -207,16 +223,32 @@ private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { @Deprecated // isConfigObfuscated is determined from the byte payload public Builder(String flagJson, boolean isConfigObfuscated) { - this(flagJson.getBytes(), parseFlagResponse(flagJson.getBytes()), isConfigObfuscated); + this(new JacksonMapper(), flagJson, isConfigObfuscated); + } + + private Builder(Mapper mapper, String flagJson, boolean isConfigObfuscated) { + this(mapper, flagJson.getBytes(), parseFlagResponse(mapper, flagJson.getBytes()), isConfigObfuscated); } @Deprecated // isConfigObfuscated is determined from the byte payload public Builder(byte[] flagJson, boolean isConfigObfuscated) { - this(flagJson, parseFlagResponse(flagJson), isConfigObfuscated); + this(new JacksonMapper(), flagJson, isConfigObfuscated); + } + + private Builder(Mapper mapper, byte[] flagJson, boolean isConfigObfuscated) { + this(mapper, flagJson, parseFlagResponse(mapper, flagJson), isConfigObfuscated); } public Builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { this( + new JacksonMapper(), + flagJson, + flagConfigResponse); + } + + private Builder(Mapper mapper, byte[] flagJson, FlagConfigResponse flagConfigResponse) { + this( + mapper, flagJson, flagConfigResponse, flagConfigResponse.getFormat() == FlagConfigResponse.Format.CLIENT); @@ -224,13 +256,26 @@ public Builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { /** Use this constructor when the FlagConfigResponse has the `forServer` field populated. */ public Builder(byte[] flagJson) { - this(flagJson, parseFlagResponse(flagJson)); + this(new JacksonMapper(), flagJson); + } + + private Builder(Mapper mapper, byte[] flagJson) { + this(mapper, flagJson, parseFlagResponse(mapper, flagJson)); + } + + public Builder( + byte[] flagJson, + @Nullable FlagConfigResponse flagConfigResponse, + boolean isConfigObfuscated) { + this(new JacksonMapper(), flagJson, flagConfigResponse, isConfigObfuscated); } public Builder( + Mapper mapper, byte[] flagJson, @Nullable FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { + this.mapper = mapper; this.isConfigObfuscated = isConfigObfuscated; this.flagJson = flagJson; if (flagConfigResponse == null @@ -303,7 +348,7 @@ public Builder banditParameters(byte[] banditParameterJson) { public Configuration build() { return new Configuration( - flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson); + mapper, flags, banditReferences, bandits, isConfigObfuscated, flagJson, banditParamsJson); } } } diff --git a/src/main/java/cloud/eppo/api/EppoValue.java b/src/main/java/cloud/eppo/api/EppoValue.java index d9bc7ba4..cc958fe6 100644 --- a/src/main/java/cloud/eppo/api/EppoValue.java +++ b/src/main/java/cloud/eppo/api/EppoValue.java @@ -101,7 +101,17 @@ public String toString() { case STRING: return this.stringValue; case ARRAY_OF_STRING: - return String.join(" ,", this.stringArrayValue); + if (this.stringArrayValue.isEmpty()) { + return ""; + } else { + StringBuilder stringBuilder = new StringBuilder(); + String delimiter = " ,"; + for (String string : this.stringArrayValue) { + stringBuilder.append(string).append(delimiter); + } + stringBuilder.setLength(stringBuilder.length() - delimiter.length()); + return stringBuilder.toString(); + } case NULL: return ""; default: diff --git a/src/main/java/cloud/eppo/json/JacksonMapper.java b/src/main/java/cloud/eppo/json/JacksonMapper.java new file mode 100644 index 00000000..a942cb45 --- /dev/null +++ b/src/main/java/cloud/eppo/json/JacksonMapper.java @@ -0,0 +1,51 @@ +package cloud.eppo.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +import cloud.eppo.ufc.dto.adapters.EppoModule; + +public class JacksonMapper implements Mapper { + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(EppoModule.eppoModule()); + @Override + public MapperNode readTree(byte[] content) throws MapperException { + try { + JsonNode jsonNode = mapper.readTree(content); + return new JacksonMapperNode(jsonNode); + } catch (IOException e) { + throw new MapperException(e); + } + } + + @Override + public MapperNode readTree(String content) throws MapperJsonProcessingException { + try { + JsonNode jsonNode = mapper.readTree(content); + return new JacksonMapperNode(jsonNode); + } catch (JsonProcessingException e) { + throw new MapperJsonProcessingException(e); + } + } + + @Override + public T readValue(byte[] src, Class valueType) throws IOException { + try { + return mapper.readValue(src, valueType); + } catch (IOException e) { + throw new MapperException(e); + } + } + + @Override + public byte[] writeValueAsBytes(MapperNode value) throws MapperException { + try { + return mapper.writeValueAsBytes(((JacksonMapperNode)value).getJsonNode()); + } catch (IOException e) { + throw new MapperException(e); + } + } +} diff --git a/src/main/java/cloud/eppo/json/JacksonMapperNode.java b/src/main/java/cloud/eppo/json/JacksonMapperNode.java new file mode 100644 index 00000000..f846565c --- /dev/null +++ b/src/main/java/cloud/eppo/json/JacksonMapperNode.java @@ -0,0 +1,21 @@ +package cloud.eppo.json; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class JacksonMapperNode implements MapperNode { + private final JsonNode jsonNode; + + public JacksonMapperNode(JsonNode jsonNode) { + this.jsonNode = jsonNode; + } + + @Override + public void put(String fieldName, String v) { + ((ObjectNode) jsonNode).put(fieldName, v); + } + + public JsonNode getJsonNode() { + return jsonNode; + } +} diff --git a/src/main/java/cloud/eppo/json/Mapper.java b/src/main/java/cloud/eppo/json/Mapper.java new file mode 100644 index 00000000..ed8131a8 --- /dev/null +++ b/src/main/java/cloud/eppo/json/Mapper.java @@ -0,0 +1,10 @@ +package cloud.eppo.json; + +import java.io.IOException; + +public interface Mapper { + MapperNode readTree(byte[] content) throws MapperException; + MapperNode readTree(String content) throws MapperJsonProcessingException; + T readValue(byte[] src, Class valueType) throws IOException; + byte[] writeValueAsBytes(MapperNode value) throws MapperException; +} diff --git a/src/main/java/cloud/eppo/json/MapperException.java b/src/main/java/cloud/eppo/json/MapperException.java new file mode 100644 index 00000000..e1d708f6 --- /dev/null +++ b/src/main/java/cloud/eppo/json/MapperException.java @@ -0,0 +1,15 @@ +package cloud.eppo.json; + +import java.io.IOException; + +public class MapperException extends IOException { + public MapperException(String message) { + super(message); + } + public MapperException(Throwable cause) { + super(cause); + } + public MapperException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/cloud/eppo/json/MapperJsonProcessingException.java b/src/main/java/cloud/eppo/json/MapperJsonProcessingException.java new file mode 100644 index 00000000..e6d8eb4c --- /dev/null +++ b/src/main/java/cloud/eppo/json/MapperJsonProcessingException.java @@ -0,0 +1,15 @@ +package cloud.eppo.json; + +public class MapperJsonProcessingException extends MapperException { + public MapperJsonProcessingException(String message) { + super(message); + } + + public MapperJsonProcessingException(Throwable cause) { + super(cause); + } + + public MapperJsonProcessingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/cloud/eppo/json/MapperNode.java b/src/main/java/cloud/eppo/json/MapperNode.java new file mode 100644 index 00000000..29030836 --- /dev/null +++ b/src/main/java/cloud/eppo/json/MapperNode.java @@ -0,0 +1,5 @@ +package cloud.eppo.json; + +public interface MapperNode { + void put(String fieldName, String v); +} diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorJava6Test.java b/src/test/java/cloud/eppo/ConfigurationRequestorJava6Test.java new file mode 100644 index 00000000..02a41311 --- /dev/null +++ b/src/test/java/cloud/eppo/ConfigurationRequestorJava6Test.java @@ -0,0 +1,424 @@ +package cloud.eppo; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import net.bytebuddy.implementation.bytecode.Throw; + +import org.apache.commons.io.FileUtils; +import org.checkerframework.checker.units.qual.A; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.File; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import cloud.eppo.api.Configuration; + +public class ConfigurationRequestorJava6Test { + private final File initialFlagConfigFile = + new File("src/test/resources/static/initial-flag-config.json"); + private final File differentFlagConfigFile = + new File("src/test/resources/static/boolean-flag.json"); + + @Test + public void testInitialConfigurationJava6() throws IOException { + IConfigurationStoreJava6 configStore = Mockito.spy(new ConfigurationStoreJava6()); + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + ConfigurationRequestorJava6 requestor = + new ConfigurationRequestorJava6(configStore, mockHttpClient, false, true); + + byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); + + // verify config is empty to start + assertTrue(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(0)).saveConfigurationJava6(any()); + + boolean success = requestor.setInitialConfigurationJava6(Configuration.builder(flagConfig, false).build()); + assertTrue(success); + + assertFalse(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(1)).saveConfigurationJava6(any()); + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + } + + @Test + public void testInitialConfigurationJava6DoesntClobberFetch() throws Exception { + IConfigurationStoreJava6 configStore = Mockito.spy(new ConfigurationStoreJava6()); + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + ConfigurationRequestorJava6 requestor = + new ConfigurationRequestorJava6(configStore, mockHttpClient, false, true); + + byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); + byte[] fetchedFlagConfig = FileUtils.readFileToByteArray(differentFlagConfigFile); + + when(mockHttpClient.getJava6(Constants.FLAG_CONFIG_ENDPOINT)).thenReturn(fetchedFlagConfig); + + assertTrue(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(0)).saveConfigurationJava6(any()); + + // The initial config contains only one flag keyed `numeric_flag`. The fetch response has only + // one flag keyed + // `boolean_flag`. We make sure to complete the fetch future first to verify the cache load does + // not overwrite it. + requestor.fetchAndSaveFromRemoteJava6(); + + assertFalse(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(1)).saveConfigurationJava6(any()); + + // set the initial config + boolean success = requestor.setInitialConfigurationJava6(Configuration.builder(flagConfig, false).build()); + assertFalse(success); + + // `numeric_flag` is only in the cache which should have been ignored. + assertNull(configStore.getConfiguration().getFlag("numeric_flag")); + + // `boolean_flag` is available only from the fetch + assertNotNull(configStore.getConfiguration().getFlag("boolean_flag")); + } + + @Test + public void testBrokenFetchDoesntClobberCache() throws Exception { + IConfigurationStoreJava6 configStore = Mockito.spy(new ConfigurationStoreJava6()); + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + ConfigurationRequestorJava6 requestor = + new ConfigurationRequestorJava6(configStore, mockHttpClient, false, true); + + byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); + + CountDownLatch httpCountDownLatch = new CountDownLatch(1); + BlockingQueue blockingQueue = new ArrayBlockingQueue<>(1); + when(mockHttpClient.getJava6(Constants.FLAG_CONFIG_ENDPOINT)) + .thenAnswer((Answer) invocation -> { + httpCountDownLatch.countDown(); + throw blockingQueue.take(); + }); + + CountDownLatch requestorCountDownLatch = new CountDownLatch(1); + Thread requestorThread = new Thread(() -> { + try { + requestor.fetchAndSaveFromRemoteJava6(); + } finally { + requestorCountDownLatch.countDown(); + } + }, "testBrokenFetchDoesntClobberCache-requestorThread"); + requestorThread.start(); + + // Make sure we made the HTTP request + assertTrue(httpCountDownLatch.await(1000, TimeUnit.MILLISECONDS)); + + // verify that no config has been set yet. + assertTrue(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(0)).saveConfigurationJava6(any()); + + // Set initial config + requestor.setInitialConfigurationJava6(Configuration.builder(flagConfig, false).build()); + + // Error out the fetch + blockingQueue.put(new Exception("Intentional exception")); + + // Make sure we finished processing the HTTP request + assertTrue(requestorCountDownLatch.await(1000, TimeUnit.MILLISECONDS)); + + assertFalse(configStore.getConfiguration().isEmpty()); + Mockito.verify(configStore, Mockito.times(1)).saveConfigurationJava6(any()); + + // `numeric_flag` is only in the cache which should be available + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + + assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + } + + @Test + public void testCacheWritesAfterBrokenFetch() throws Exception { + IConfigurationStoreJava6 configStore = Mockito.spy(new ConfigurationStoreJava6()); + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + ConfigurationRequestorJava6 requestor = + new ConfigurationRequestorJava6(configStore, mockHttpClient, false, true); + + String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); + + when(mockHttpClient.getJava6(Constants.FLAG_CONFIG_ENDPOINT)) + .thenThrow(new RuntimeException("Intentional exception")); + + // verify that no config has been set yet. + Mockito.verify(configStore, Mockito.times(0)).saveConfigurationJava6(any()); + + // default configuration is empty config. + assertTrue(configStore.getConfiguration().isEmpty()); + + // Fetch from remote with an error + try { + requestor.fetchAndSaveFromRemoteJava6(); + fail("Expected RuntimeException"); + } catch (RuntimeException ignored) { + } + + // Set the initial config after the fetch throws an error. + boolean success = requestor.setInitialConfigurationJava6(new Configuration.Builder(flagConfig, false).build()); + assertTrue(success); + + // Verify that a configuration was saved by the requestor + Mockito.verify(configStore, Mockito.times(1)).saveConfigurationJava6(any()); + assertFalse(configStore.getConfiguration().isEmpty()); + + // `numeric_flag` is only in the cache which should be available + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + + assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + } + + private static ServerSocket findServerSocket() { + int startPort = 8000; + int endPort = 1000; + int port = startPort; + ServerSocket serverSocket = null; + do { + try { + serverSocket = new ServerSocket(port); + } catch (IOException ignore) { + port++; + } + } while (serverSocket == null && port < endPort); + if (serverSocket == null) { + fail("Couldn't allocate ServerSocket between [" + startPort + ", " + endPort + ")"); + } + + return serverSocket; + } + + @Test + public void testInterruptedFetchDoesNotClobberCache() throws Exception { + try (ServerSocket serverSocket = findServerSocket()) { + IConfigurationStoreJava6 configStore = Mockito.spy(new ConfigurationStoreJava6()); + EppoHttpClient realHttpClient = new EppoHttpClient( + "http://localhost:" + serverSocket.getLocalPort(), + "apiKey", + "sdkName", + "sdkVersion" + ); + + ConfigurationRequestorJava6 requestor = + new ConfigurationRequestorJava6(configStore, realHttpClient, false, true); + + // verify that no config has been set yet. + Mockito.verify(configStore, Mockito.times(0)).saveConfigurationJava6(any()); + + // default configuration is empty config. + assertTrue(configStore.getConfiguration().isEmpty()); + + CountDownLatch serverSocketCountDownLatch = new CountDownLatch(1); + Thread serverSocketThread = new Thread(() -> { + try { + Socket ignored = serverSocket.accept(); + // intentionally don't close the ignored socket. It'll get closed + // when we close the ServerSocket + serverSocketCountDownLatch.countDown(); + } catch (IOException ignored) { + } + }, "testInterruptedFetchDoesNotClobberCache-serverSocket"); + serverSocketThread.start(); + + // Fetch from remote and later interrupt + CountDownLatch requestorCountDownLatch = new CountDownLatch(1); + AtomicBoolean expectExceptionAtomicBoolean = new AtomicBoolean(); + AtomicReference unexpectedExceptionAtomicReference = new AtomicReference<>(); + AtomicReference expectedExceptionAtomicReference = new AtomicReference<>(); + Thread requestorThread = new Thread(() -> { + try { + requestor.fetchAndSaveFromRemoteJava6(); + } catch (RuntimeException expected) { + if (expectExceptionAtomicBoolean.get()) { + expectedExceptionAtomicReference.set(expected); + } else { + unexpectedExceptionAtomicReference.set(expected); + } + } + requestorCountDownLatch.countDown(); + }, "testInterruptedFetchDoesNotClobberCache-requestor"); + requestorThread.start(); + + // Wait until we connect to the "server" + assertTrue(serverSocketCountDownLatch.await(1000, TimeUnit.MILLISECONDS)); + + String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); + + // Set the initial config. + boolean success = requestor.setInitialConfigurationJava6(new Configuration.Builder(flagConfig, false).build()); + assertTrue(success); + + // Verify that a configuration was saved by the requestor + Mockito.verify(configStore, Mockito.times(1)).saveConfigurationJava6(any()); + assertFalse(configStore.getConfiguration().isEmpty()); + + expectExceptionAtomicBoolean.set(true); + requestorThread.interrupt(); + assertTrue(requestorCountDownLatch.await(10000, TimeUnit.MILLISECONDS)); + Throwable unexpectedException = unexpectedExceptionAtomicReference.get(); + assertNull(unexpectedException); + + Throwable expectedException = expectedExceptionAtomicReference.get(); + assertNotNull(expectedException); + assertEquals(RuntimeException.class, expectedException.getClass()); + + // `numeric_flag` is only in the cache which should be available + assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + + assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + } + } + + private ConfigurationStoreJava6 mockConfigStoreJava6; + private EppoHttpClient mockHttpClient; + private ConfigurationRequestorJava6 requestor; + + @BeforeEach + public void setup() { + mockConfigStoreJava6 = mock(ConfigurationStoreJava6.class); + mockHttpClient = mock(EppoHttpClient.class); + requestor = new ConfigurationRequestorJava6(mockConfigStoreJava6, mockHttpClient, false, true); + } + + @Test + public void testConfigurationChangeListener() throws IOException { + // Setup mock response + String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); + when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); + + List receivedConfigs = new ArrayList<>(); + + // Subscribe to configuration changes + Runnable unsubscribe = requestor.onConfigurationChange(receivedConfigs::add); + + // Initial fetch should trigger the callback + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(1, receivedConfigs.size()); + + // Another fetch should trigger the callback again (fetches aren't optimized with eTag yet). + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(2, receivedConfigs.size()); + + // Unsubscribe should prevent further callbacks + unsubscribe.run(); + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(2, receivedConfigs.size()); // Count should remain the same + } + + @Test + public void testMultipleConfigurationChangeListeners() { + // Setup mock response + when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + + AtomicInteger callCount1 = new AtomicInteger(0); + AtomicInteger callCount2 = new AtomicInteger(0); + + // Subscribe multiple listeners + Runnable unsubscribe1 = requestor.onConfigurationChange(v -> callCount1.incrementAndGet()); + Runnable unsubscribe2 = requestor.onConfigurationChange(v -> callCount2.incrementAndGet()); + + // Fetch should trigger both callbacks + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(1, callCount1.get()); + assertEquals(1, callCount2.get()); + + // Unsubscribe first listener + unsubscribe1.run(); + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(1, callCount1.get()); // Should not increase + assertEquals(2, callCount2.get()); // Should increase + + // Unsubscribe second listener + unsubscribe2.run(); + requestor.fetchAndSaveFromRemoteJava6(); + assertEquals(1, callCount1.get()); // Should not increase + assertEquals(2, callCount2.get()); // Should not increase + } + + @Test + public void testConfigurationChangeListenerIgnoresFailedFetch() { + // Setup mock response to simulate failure + when(mockHttpClient.getJava6(anyString())).thenThrow(new RuntimeException("Fetch failed")); + + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + + // Failed fetch should not trigger the callback + try { + requestor.fetchAndSaveFromRemoteJava6(); + fail("Expected RuntimeException"); + } catch (RuntimeException e) { + // Expected + } + assertEquals(0, callCount.get()); + } + + @Test + public void testConfigurationChangeListenerIgnoresFailedSave() { + // Setup mock responses + when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); + doThrow(new RuntimeException("Save failed")) + .when(mockConfigStoreJava6).saveConfigurationJava6(any()); + + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + + // Failed save should not trigger the callback + try { + requestor.fetchAndSaveFromRemoteJava6(); + fail("Expected RuntimeException"); + } catch (RuntimeException e) { + // Pass + } + assertEquals(0, callCount.get()); + } + + @Test + public void testConfigurationChangeListenerSave() throws Exception { + BlockingQueue blockingQueue = new ArrayBlockingQueue<>(1); + // Setup mock responses + when(mockHttpClient.getJava6(anyString())) + .thenAnswer((Answer) invocation -> blockingQueue.take()); + + AtomicInteger callCount = new AtomicInteger(0); + requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + + // Start fetch + CountDownLatch countDownLatch = new CountDownLatch(1); + Thread requestorThread = new Thread(() -> { + requestor.fetchAndSaveFromRemoteJava6(); + countDownLatch.countDown(); + }, "testConfigurationChangeListenerSave-requesterThread"); + requestorThread.start(); + assertEquals(0, callCount.get()); // Callback should not be called yet + + // Complete the save + blockingQueue.put("{\"flags\":{}}".getBytes()); + // Verify that the fetch completed + assertTrue(countDownLatch.await(1000, TimeUnit.MILLISECONDS)); + assertEquals(1, callCount.get()); // Callback should be called after save completes + } +}