diff --git a/build.gradle b/build.gradle index 3388cd24..b8cebf05 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'cloud.eppo' -version = '3.10.1' +version = '4.0.0' ext.isReleaseVersion = !version.endsWith("SNAPSHOT") java { @@ -17,12 +17,12 @@ java { } 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 LRU and expiring maps implementation 'org.apache.commons:commons-collections4:4.5.0' implementation 'org.slf4j:slf4j-api:2.0.17' + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3' testImplementation 'org.slf4j:slf4j-simple:2.0.17' testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index ea16d787..2ecabee2 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -11,15 +11,9 @@ 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 java.util.Timer; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -27,9 +21,6 @@ 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 final ConfigurationRequestor requestor; @@ -38,62 +29,48 @@ public class BaseEppoClient { private final BanditLogger banditLogger; private final String sdkName; private final String sdkVersion; - private boolean isGracefulMode; + protected boolean isGracefulMode; private final IAssignmentCache assignmentCache; private final IAssignmentCache banditAssignmentCache; private Timer pollTimer; - @Nullable protected CompletableFuture getInitialConfigFuture() { - return initialConfigFuture; - } - - private final CompletableFuture initialConfigFuture; - // 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; + private static IEppoHttpClient httpClientOverride = null; // 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 String apiKey, + @NotNull String sdkKey, @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 Configuration initialConfiguration, @Nullable IAssignmentCache assignmentCache, @Nullable IAssignmentCache banditAssignmentCache) { - if (apiBaseUrl == null) { - apiBaseUrl = host != null ? Constants.appendApiPathToHost(host) : Constants.DEFAULT_BASE_URL; - } - this.assignmentCache = assignmentCache; this.banditAssignmentCache = banditAssignmentCache; - EppoHttpClient httpClient = - buildHttpClient(apiBaseUrl, new SDKKey(apiKey), sdkName, sdkVersion); + IEppoHttpClient httpClient = + buildHttpClient(apiBaseUrl, new SDKKey(sdkKey), sdkName, sdkVersion); this.configurationStore = configurationStore != null ? configurationStore : new ConfigurationStore(); // For now, the configuration is only obfuscated for Android clients - requestor = - new ConfigurationRequestor( - this.configurationStore, httpClient, expectObfuscatedConfig, supportBandits); - initialConfigFuture = - initialConfiguration != null - ? requestor.setInitialConfiguration(initialConfiguration) - : null; + requestor = new ConfigurationRequestor(this.configurationStore, httpClient, supportBandits); + + if (initialConfiguration != null) { + requestor.activateConfiguration(initialConfiguration); + } this.assignmentLogger = assignmentLogger; this.banditLogger = banditLogger; @@ -103,7 +80,7 @@ protected BaseEppoClient( this.sdkVersion = sdkVersion; } - private EppoHttpClient buildHttpClient( + private IEppoHttpClient buildHttpClient( String apiBaseUrl, SDKKey sdkKey, String sdkName, String sdkVersion) { ApiEndpoints endpointHelper = new ApiEndpoints(sdkKey, apiBaseUrl); @@ -112,13 +89,18 @@ private EppoHttpClient buildHttpClient( : new EppoHttpClient(endpointHelper.getBaseUrl(), sdkKey.getToken(), sdkName, sdkVersion); } - protected void loadConfiguration() { + public void activateConfiguration(@NotNull Configuration configuration) { + requestor.activateConfiguration(configuration); + } + + protected void fetchAndActivateConfiguration() { try { requestor.fetchAndSaveFromRemote(); - } catch (Exception ex) { - log.error("Encountered Exception while loading configuration", ex); - if (!isGracefulMode) { - throw ex; + } catch (Throwable e) { + if (isGracefulMode) { + log.error(e.getMessage(), e); + } else { + throw e; } } } @@ -160,7 +142,14 @@ protected void startPolling(long pollingIntervalMs, long pollingJitterMs) { new FetchConfigurationTask( () -> { log.debug("[Eppo SDK] Polling callback"); - this.loadConfiguration(); + try { + this.fetchAndActivateConfiguration(); + } catch (Exception ex) { + log.error("Encountered Exception while loading configuration", ex); + if (!isGracefulMode) { + throw ex; + } + } }, pollTimer, pollingIntervalMs, @@ -172,22 +161,21 @@ protected void startPolling(long pollingIntervalMs, long pollingJitterMs) { fetchConfigurationsTask.scheduleNext(); } - protected CompletableFuture loadConfigurationAsync() { - CompletableFuture future = new CompletableFuture<>(); + protected void fetchAndActivateConfigurationAsync(EppoActionCallback callback) { - requestor - .fetchAndSaveFromRemoteAsync() - .exceptionally( - ex -> { - log.error("Encountered Exception while loading configuration", ex); - if (!isGracefulMode) { - future.completeExceptionally(ex); - } - return null; - }) - .thenAccept(future::complete); + requestor.fetchAndSaveFromRemoteAsync( + new EppoActionCallback() { + @Override + public void onSuccess(Configuration data) { + callback.onSuccess(data); + } - return future; + @Override + public void onFailure(Throwable error) { + log.error("Encountered Exception while loading configuration", error); + callback.onFailure(error); + } + }); } protected EppoValue getTypedAssignment( @@ -306,10 +294,7 @@ private boolean valueTypeMatchesExpected(VariationType expectedType, EppoValue v 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; + typeMatch = value.isString() && Utils.isValidJson(value.stringValue()); break; default: throw new IllegalArgumentException("Unexpected type for type checking: " + expectedType); @@ -398,46 +383,6 @@ public String getStringAssignment( } } - /** - * 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, @@ -478,14 +423,6 @@ public String getJSONStringAssignment(String flagKey, String subjectKey, String 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, @@ -565,7 +502,7 @@ private Map buildLogMetaData(boolean isConfigObfuscated) { return metaData; } - private T throwIfNotGraceful(Exception e, T defaultValue) { + protected T throwIfNotGraceful(Exception e, T defaultValue) { if (this.isGracefulMode) { log.info("error getting assignment value: {}", e.getMessage()); return defaultValue; @@ -580,11 +517,11 @@ public void setIsGracefulFailureMode(boolean isGracefulFailureMode) { /** * Subscribe to changes to the configuration. * - * @param callback A function to be executed when the configuration changes. + * @param callback A listener which is notified of configuration changes. * @return a Runnable which, when called unsubscribes the callback from configuration change * events. */ - public Runnable onConfigurationChange(Consumer callback) { + public Runnable onConfigurationChange(Configuration.Callback callback) { return requestor.onConfigurationChange(callback); } diff --git a/src/main/java/cloud/eppo/ConfigurationRequestor.java b/src/main/java/cloud/eppo/ConfigurationRequestor.java index f20f8aa8..711ac449 100644 --- a/src/main/java/cloud/eppo/ConfigurationRequestor.java +++ b/src/main/java/cloud/eppo/ConfigurationRequestor.java @@ -1,10 +1,8 @@ package cloud.eppo; import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoActionCallback; import cloud.eppo.callback.CallbackManager; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,73 +10,36 @@ public class ConfigurationRequestor { private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestor.class); - private final EppoHttpClient client; + private final IEppoHttpClient client; private final IConfigurationStore configurationStore; - private final boolean expectObfuscatedConfig; private final boolean supportBandits; - private CompletableFuture remoteFetchFuture = null; - private CompletableFuture configurationFuture = null; - private boolean initialConfigSet = false; - - private final CallbackManager configChangeManager = new CallbackManager<>(); + private final CallbackManager configChangeManager = + new CallbackManager<>( + // no lambdas before java8 + new CallbackManager.Dispatcher() { + @Override + public void dispatch(Configuration.Callback callback, Configuration data) { + callback.accept(data); + } + }); public ConfigurationRequestor( @NotNull IConfigurationStore configurationStore, - @NotNull EppoHttpClient client, - boolean expectObfuscatedConfig, + @NotNull IEppoHttpClient client, boolean supportBandits) { this.configurationStore = configurationStore; this.client = client; - this.expectObfuscatedConfig = expectObfuscatedConfig; this.supportBandits = supportBandits; } - // Synchronously set the initial configuration. - public void setInitialConfiguration(@NotNull Configuration configuration) { - if (initialConfigSet || this.configurationFuture != null) { - throw new IllegalStateException("Initial configuration has already been set"); - } - - initialConfigSet = saveConfigurationAndNotify(configuration).thenApply(v -> true).join(); - } - /** - * Asynchronously sets the initial configuration. Resolves to `true` if the initial configuration - * was used, false if not (due to being empty, a fetched config taking precedence, etc.) + * Synchronously sets and activates the initial configuration. + * + * @param configuration The configuration to activate */ - public CompletableFuture setInitialConfiguration( - @NotNull CompletableFuture configurationFuture) { - if (initialConfigSet || this.configurationFuture != null) { - throw new IllegalStateException("Configuration future has already been set"); - } - this.configurationFuture = - configurationFuture - .thenApply( - (config) -> { - synchronized (configurationStore) { - if (config == null || config.isEmpty()) { - log.debug("Initial configuration future returned empty/null"); - return false; - } else if (remoteFetchFuture != null - && remoteFetchFuture.isDone() - && !remoteFetchFuture.isCompletedExceptionally()) { - // Don't clobber a successful fetch. - log.debug("Fetch has completed; ignoring initial config load."); - return false; - } else { - initialConfigSet = - saveConfigurationAndNotify(config).thenApply((s) -> true).join(); - return true; - } - } - }) - .exceptionally( - (e) -> { - log.error("Error setting initial config", e); - return false; - }); - return this.configurationFuture; + public void activateConfiguration(@NotNull Configuration configuration) { + saveConfigurationAndNotify(configuration); } /** Loads configuration synchronously from the API server. */ @@ -90,70 +51,65 @@ void fetchAndSaveFromRemote() { byte[] flagConfigurationJsonBytes = client.get(Constants.FLAG_CONFIG_ENDPOINT); Configuration.Builder configBuilder = - Configuration.builder(flagConfigurationJsonBytes, expectObfuscatedConfig) - .banditParametersFromConfig(lastConfig); + Configuration.builder(flagConfigurationJsonBytes).banditParametersFromConfig(lastConfig); if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { byte[] banditParametersJsonBytes = client.get(Constants.BANDIT_ENDPOINT); configBuilder.banditParameters(banditParametersJsonBytes); } - saveConfigurationAndNotify(configBuilder.build()).join(); + saveConfigurationAndNotify(configBuilder.build()); } /** Loads configuration asynchronously from the API server, off-thread. */ - CompletableFuture fetchAndSaveFromRemoteAsync() { + void fetchAndSaveFromRemoteAsync(EppoActionCallback callback) { log.debug("Fetching configuration from API server"); final Configuration lastConfig = configurationStore.getConfiguration(); - if (remoteFetchFuture != null && !remoteFetchFuture.isDone()) { - log.debug("Remote fetch is active. Cancelling and restarting"); - remoteFetchFuture.cancel(true); - remoteFetchFuture = null; - } - - remoteFetchFuture = - client - .getAsync(Constants.FLAG_CONFIG_ENDPOINT) - .thenCompose( - flagConfigJsonBytes -> { - synchronized (this) { - Configuration.Builder configBuilder = - Configuration.builder(flagConfigJsonBytes, expectObfuscatedConfig) - .banditParametersFromConfig( - lastConfig); // possibly reuse last bandit models loaded. - - if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { - byte[] banditParametersJsonBytes; - try { - banditParametersJsonBytes = - client.getAsync(Constants.BANDIT_ENDPOINT).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Error fetching from remote: " + e.getMessage()); - throw new RuntimeException(e); - } - if (banditParametersJsonBytes != null) { - configBuilder.banditParameters(banditParametersJsonBytes); - } - } - - return saveConfigurationAndNotify(configBuilder.build()); - } - }); - return remoteFetchFuture; - } + client.getAsync( + Constants.FLAG_CONFIG_ENDPOINT, + new IEppoHttpClient.Callback() { + @Override + public void onSuccess(byte[] flagConfigJsonBytes) { + synchronized (this) { + Configuration.Builder configBuilder = + Configuration.builder(flagConfigJsonBytes) + .banditParametersFromConfig( + lastConfig); // possibly reuse last bandit models loaded. + + if (supportBandits && configBuilder.requiresUpdatedBanditModels()) { + byte[] banditParametersJsonBytes; + + banditParametersJsonBytes = client.get(Constants.BANDIT_ENDPOINT); + + if (banditParametersJsonBytes != null) { + configBuilder.banditParameters(banditParametersJsonBytes); + } + } + + Configuration config = configBuilder.build(); + saveConfigurationAndNotify(config); + callback.onSuccess(config); + } + } - private CompletableFuture saveConfigurationAndNotify(Configuration configuration) { - CompletableFuture saveFuture = configurationStore.saveConfiguration(configuration); - return saveFuture.thenRun( - () -> { - synchronized (configChangeManager) { - configChangeManager.notifyCallbacks(configuration); + @Override + public void onFailure(Throwable error) { + log.error( + "Failed to fetch configuration from API server: {}", error.getMessage(), error); + callback.onFailure(error); } }); } - public Runnable onConfigurationChange(Consumer callback) { + private void saveConfigurationAndNotify(Configuration configuration) { + configurationStore.saveConfiguration(configuration); + synchronized (configChangeManager) { + configChangeManager.notifyCallbacks(configuration); + } + } + + public Runnable onConfigurationChange(Configuration.Callback callback) { return configChangeManager.subscribe(callback); } } diff --git a/src/main/java/cloud/eppo/ConfigurationStore.java b/src/main/java/cloud/eppo/ConfigurationStore.java index 6a01bc2c..45a2fb67 100644 --- a/src/main/java/cloud/eppo/ConfigurationStore.java +++ b/src/main/java/cloud/eppo/ConfigurationStore.java @@ -1,7 +1,6 @@ package cloud.eppo; import cloud.eppo.api.Configuration; -import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; /** Memory-only configuration store. */ @@ -12,9 +11,8 @@ public class ConfigurationStore implements IConfigurationStore { public ConfigurationStore() {} - public CompletableFuture saveConfiguration(@NotNull final Configuration configuration) { + public void saveConfiguration(@NotNull final Configuration configuration) { this.configuration = configuration; - return CompletableFuture.completedFuture(null); } @NotNull public Configuration getConfiguration() { diff --git a/src/main/java/cloud/eppo/EppoHttpClient.java b/src/main/java/cloud/eppo/EppoHttpClient.java index 656e844f..ad79b160 100644 --- a/src/main/java/cloud/eppo/EppoHttpClient.java +++ b/src/main/java/cloud/eppo/EppoHttpClient.java @@ -1,22 +1,15 @@ package cloud.eppo; +import cloud.eppo.exception.InvalidApiKeyException; import java.io.IOException; import java.net.HttpURLConnection; -import java.util.Arrays; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; +import okhttp3.*; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class EppoHttpClient { +public class EppoHttpClient implements IEppoHttpClient { private static final Logger log = LoggerFactory.getLogger(EppoHttpClient.class); private final OkHttpClient client; @@ -44,58 +37,54 @@ private static OkHttpClient buildOkHttpClient() { return builder.build(); } + @Override public byte[] get(String path) { - try { - // Wait and return the async get. - return getAsync(path).get(); - } catch (InterruptedException | ExecutionException e) { - log.error("Config fetch interrupted", e); - throw new RuntimeException(e); + Request request = buildRequest(path); + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + return response.body().bytes(); + } + + throw response.code() == HttpURLConnection.HTTP_FORBIDDEN + ? new InvalidApiKeyException("Invalid API key") + : new HttpException("Fetch failed with status code: " + response.code()); + + } catch (IOException e) { + throw new HttpException("Http request failure", e); } } - public CompletableFuture getAsync(String path) { - CompletableFuture future = new CompletableFuture<>(); + @Override + public void getAsync(String path, Callback callback) { Request request = buildRequest(path); client .newCall(request) .enqueue( - new Callback() { + new okhttp3.Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) { if (response.isSuccessful() && response.body() != null) { - log.debug("Fetch successful"); try { - future.complete(response.body().bytes()); + callback.onSuccess(response.body().bytes()); } catch (IOException ex) { - future.completeExceptionally( - new RuntimeException( + callback.onFailure( + new HttpException( "Failed to read response from URL {}" + request.url(), ex)); } } else { - if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { - future.completeExceptionally(new RuntimeException("Invalid API key")); - } else { - log.debug("Fetch failed with status code: {}", response.code()); - future.completeExceptionally( - new RuntimeException("Bad response from URL " + request.url())); - } + callback.onFailure( + response.code() == HttpURLConnection.HTTP_FORBIDDEN + ? new InvalidApiKeyException("Invalid API key") + : new HttpException("Bad response from URL " + request.url())); } response.close(); } @Override public void onFailure(@NotNull Call call, @NotNull IOException e) { - log.error( - "Http request failure: {} {}", - e.getMessage(), - Arrays.toString(e.getStackTrace()), - e); - future.completeExceptionally( - new RuntimeException("Unable to fetch from URL " + request.url())); + callback.onFailure(e); } }); - return future; } private Request buildRequest(String path) { diff --git a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java deleted file mode 100644 index 6d67a2c4..00000000 --- a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.eppo; - -public interface EppoHttpClientRequestCallback { - void onSuccess(String responseBody); - - void onFailure(String errorMessage); -} diff --git a/src/main/java/cloud/eppo/IConfigurationStore.java b/src/main/java/cloud/eppo/IConfigurationStore.java index a1666e9e..61339589 100644 --- a/src/main/java/cloud/eppo/IConfigurationStore.java +++ b/src/main/java/cloud/eppo/IConfigurationStore.java @@ -1,7 +1,6 @@ package cloud.eppo; import cloud.eppo.api.Configuration; -import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; /** @@ -9,7 +8,8 @@ * persisting configuration data across sessions. */ public interface IConfigurationStore { + @NotNull Configuration getConfiguration(); - CompletableFuture saveConfiguration(Configuration configuration); + void saveConfiguration(Configuration configuration); } diff --git a/src/main/java/cloud/eppo/IEppoHttpClient.java b/src/main/java/cloud/eppo/IEppoHttpClient.java new file mode 100644 index 00000000..f5387797 --- /dev/null +++ b/src/main/java/cloud/eppo/IEppoHttpClient.java @@ -0,0 +1,21 @@ +package cloud.eppo; + +import cloud.eppo.api.EppoActionCallback; + +public interface IEppoHttpClient { + byte[] get(String path); + + void getAsync(String path, Callback callback); + + interface Callback extends EppoActionCallback {} + + class HttpException extends RuntimeException { + public HttpException(String message) { + super(message); + } + + public HttpException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 41c9874b..f22de75e 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -1,14 +1,18 @@ package cloud.eppo; -import com.fasterxml.jackson.databind.JsonNode; -import java.nio.charset.StandardCharsets; +import cloud.eppo.api.EppoValue; +import cloud.eppo.exception.JsonParsingException; +import cloud.eppo.ufc.dto.BanditParametersResponse; +import cloud.eppo.ufc.dto.FlagConfigResponse; 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 java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +20,16 @@ public final class Utils { private static final SimpleDateFormat UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); private static final Logger log = LoggerFactory.getLogger(Utils.class); private static final MessageDigest md = buildMd5MessageDigest(); + private static Base64Codec base64Codec; + private static JsonDeserializer jsonDeserializer; + + public static void setBase64Codec(@NotNull Base64Codec base64Codec) { + Utils.base64Codec = base64Codec; + } + + public static void setJsonDeserializer(@NotNull JsonDeserializer jsonDeserializer) { + Utils.jsonDeserializer = jsonDeserializer; + } private static MessageDigest buildMd5MessageDigest() { try { @@ -71,11 +85,49 @@ public static int getShard(String input, int maxShardValue) { return (int) (value % maxShardValue); } - public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { - if (isoDateStringElement == null || isoDateStringElement.isNull()) { + public static String getISODate(Date date) { + return UTC_ISO_DATE_FORMAT.format(date); + } + + /** + * An implementation of the Base64Codec is required to be set before these methods work. ex: + * Utils.setBase64Codec(new JavaBase64Codec()); + */ + public static String base64Encode(String input) { + if (base64Codec == null) { + throw new RuntimeException("Base64 codec not initialized"); + } + if (input == null) { + return null; + } + return base64Codec.base64Encode(input); + } + + /** + * An implementation of the Base64Codec is required to be set before these methods work. ex: + * Utils.setBase64Codec(new JavaBase64Codec()); + */ + public static String base64Decode(String input) { + if (base64Codec == null) { + throw new RuntimeException("Base64 codec not initialized"); + } + if (input == null) { + return null; + } + return base64Codec.base64Decode(input); + } + + private static SimpleDateFormat buildUtcIsoDateFormat() { + // Note: we don't use DateTimeFormatter.ISO_DATE so that this supports older Android versions + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + return dateFormat; + } + + public static Date parseUtcISODateString(@Nullable String isoDateString) { + if (isoDateString == null) { return null; } - String isoDateString = isoDateStringElement.asText(); Date result = null; try { result = UTC_ISO_DATE_FORMAT.parse(isoDateString); @@ -93,37 +145,52 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { log.warn("Date \"{}\" not in ISO date format", isoDateString); } } - return result; } - public static String getISODate(Date date) { - return UTC_ISO_DATE_FORMAT.format(date); + private static void verifyJsonParser() { + if (jsonDeserializer == null) { + throw new RuntimeException("JSON Parser not initialized/set on Utils"); + } } - public static String base64Encode(String input) { - if (input == null) { - return null; - } - return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); + public static FlagConfigResponse parseFlagConfigResponse(byte[] jsonString) + throws JsonParsingException { + verifyJsonParser(); + return jsonDeserializer.parseFlagConfigResponse(jsonString); } - public static String base64Decode(String input) { - if (input == null) { - return null; - } - byte[] decodedBytes = Base64.getDecoder().decode(input); - 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"); - } - return new String(decodedBytes); + public static BanditParametersResponse parseBanditParametersResponse(byte[] jsonString) + throws JsonParsingException { + verifyJsonParser(); + return jsonDeserializer.parseBanditParametersResponse(jsonString); } - private static SimpleDateFormat buildUtcIsoDateFormat() { - // Note: we don't use DateTimeFormatter.ISO_DATE so that this supports older Android versions - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); - dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); - return dateFormat; + public static boolean isValidJson(String json) { + verifyJsonParser(); + return jsonDeserializer.isValidJson(json); + } + + public static String serializeAttributesToJSONString( + Map map, boolean omitNulls) { + verifyJsonParser(); + return jsonDeserializer.serializeAttributesToJSONString(map, omitNulls); + } + + public interface Base64Codec { + String base64Encode(String input); + + String base64Decode(String input); + } + + public interface JsonDeserializer { + FlagConfigResponse parseFlagConfigResponse(byte[] jsonString) throws JsonParsingException; + + BanditParametersResponse parseBanditParametersResponse(byte[] jsonString) + throws JsonParsingException; + + boolean isValidJson(String json); + + String serializeAttributesToJSONString(Map map, boolean omitNulls); } } diff --git a/src/main/java/cloud/eppo/api/Attributes.java b/src/main/java/cloud/eppo/api/Attributes.java index 2406d171..8bad0dc5 100644 --- a/src/main/java/cloud/eppo/api/Attributes.java +++ b/src/main/java/cloud/eppo/api/Attributes.java @@ -1,8 +1,6 @@ package cloud.eppo.api; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import cloud.eppo.Utils; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -67,40 +65,6 @@ public Attributes getAllAttributes() { /** Serializes the attributes to a JSON string, omitting attributes with a null value. */ public String serializeNonNullAttributesToJSONString() { - return serializeAttributesToJSONString(true); - } - - @SuppressWarnings("SameParameterValue") - private String serializeAttributesToJSONString(boolean omitNulls) { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode result = mapper.createObjectNode(); - - for (Map.Entry entry : entrySet()) { - String attributeName = entry.getKey(); - EppoValue attributeValue = entry.getValue(); - - if (attributeValue == null || attributeValue.isNull()) { - if (!omitNulls) { - result.putNull(attributeName); - } - } else { - if (attributeValue.isNumeric()) { - result.put(attributeName, attributeValue.doubleValue()); - continue; - } - if (attributeValue.isBoolean()) { - result.put(attributeName, attributeValue.booleanValue()); - continue; - } - // fall back put treating any other eppo values as a string - result.put(attributeName, attributeValue.toString()); - } - } - - try { - return mapper.writeValueAsString(result); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + return Utils.serializeAttributesToJSONString(this, true); } } diff --git a/src/main/java/cloud/eppo/api/Configuration.java b/src/main/java/cloud/eppo/api/Configuration.java index 8b1e8376..3b50d967 100644 --- a/src/main/java/cloud/eppo/api/Configuration.java +++ b/src/main/java/cloud/eppo/api/Configuration.java @@ -2,12 +2,9 @@ import static cloud.eppo.Utils.getMD5Hex; +import cloud.eppo.Utils; +import cloud.eppo.exception.JsonParsingException; 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; import java.util.Set; @@ -49,8 +46,24 @@ * then check `requiresBanditModels()`. */ public class Configuration { - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(EppoModule.eppoModule()); + /** + * Callback for operations involving Configuration changes. + * + *

This interface is used to notify listeners when a configuration has been updated or changed. + * Implementations should handle the new configuration appropriately, such as updating cached + * values or triggering dependent operations. + * + *

Thread safety: Callbacks may be invoked on any thread, including the calling thread or a + * background thread. Implementations should be thread-safe and avoid blocking operations. + */ + public interface Callback { + /** + * Called when a new configuration is available. + * + * @param configuration The updated configuration, may be null in some error scenarios + */ + void accept(Configuration configuration); + } private static final byte[] emptyFlagsBytes = "{ \"flags\": {}, \"format\": \"SERVER\" }".getBytes(); @@ -79,20 +92,6 @@ public class Configuration { this.bandits = bandits; this.isConfigObfuscated = isConfigObfuscated; - // Graft the `forServer` boolean into the flagConfigJson' - if (flagConfigJson != null && flagConfigJson.length != 0) { - try { - JsonNode jNode = mapper.readTree(flagConfigJson); - FlagConfigResponse.Format format = - isConfigObfuscated - ? FlagConfigResponse.Format.CLIENT - : FlagConfigResponse.Format.SERVER; - ((ObjectNode) jNode).put("format", format.toString()); - flagConfigJson = mapper.writeValueAsBytes(jNode); - } catch (IOException e) { - log.error("Error adding `format` field to FlagConfigResponse JSON"); - } - } this.flagConfigJson = flagConfigJson; this.banditParamsJson = banditParamsJson; } @@ -170,14 +169,14 @@ public boolean isEmpty() { return flags == null || flags.isEmpty(); } + public Set getFlagKeys() { + return flags == null ? Collections.emptySet() : flags.keySet(); + } + public static Builder builder(byte[] flagJson) { return new Builder(flagJson); } - @Deprecated // isConfigObfuscated is determined from the byte payload - public static Builder builder(byte[] flagJson, boolean isConfigObfuscated) { - return new Builder(flagJson, isConfigObfuscated); - } /** * Builder to create the immutable config object. * @@ -197,24 +196,13 @@ private static FlagConfigResponse parseFlagResponse(byte[] flagJson) { 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) { + return Utils.parseFlagConfigResponse(flagJson); + } catch (JsonParsingException e) { throw new RuntimeException(e); } } - @Deprecated // isConfigObfuscated is determined from the byte payload - public Builder(String flagJson, boolean isConfigObfuscated) { - this(flagJson.getBytes(), parseFlagResponse(flagJson.getBytes()), isConfigObfuscated); - } - - @Deprecated // isConfigObfuscated is determined from the byte payload - public Builder(byte[] flagJson, boolean isConfigObfuscated) { - this(flagJson, parseFlagResponse(flagJson), isConfigObfuscated); - } - public Builder(byte[] flagJson, FlagConfigResponse flagConfigResponse) { this( flagJson, @@ -284,8 +272,8 @@ public Builder banditParameters(byte[] banditParameterJson) { } BanditParametersResponse config; try { - config = mapper.readValue(banditParameterJson, BanditParametersResponse.class); - } catch (IOException e) { + config = Utils.parseBanditParametersResponse(banditParameterJson); + } catch (JsonParsingException e) { log.error("Unable to parse bandit parameters JSON"); throw new RuntimeException(e); } diff --git a/src/main/java/cloud/eppo/api/EppoActionCallback.java b/src/main/java/cloud/eppo/api/EppoActionCallback.java new file mode 100644 index 00000000..deead8fc --- /dev/null +++ b/src/main/java/cloud/eppo/api/EppoActionCallback.java @@ -0,0 +1,29 @@ +package cloud.eppo.api; + +/** + * Interface for handling asynchronous operation results. + * + *

This callback interface provides methods to handle both successful completion and failure + * scenarios for asynchronous operations. Implementations should expect that exactly one of the + * callback methods (either onSuccess or onFailure) will be invoked for each operation. + * + *

Thread safety: Callbacks may be invoked on any thread, including the calling thread or a + * background thread. Implementations should be thread-safe and avoid blocking operations. + * + * @param The type of data returned on successful completion + */ +public interface EppoActionCallback { + /** + * Called when an operation completes successfully. + * + * @param data The result data from the operation, may be null in some cases + */ + void onSuccess(T data); + + /** + * Called when an operation fails. + * + * @param error The error that caused the operation to fail + */ + void onFailure(Throwable error); +} diff --git a/src/main/java/cloud/eppo/callback/CallbackManager.java b/src/main/java/cloud/eppo/callback/CallbackManager.java index a227cb27..9934ed74 100644 --- a/src/main/java/cloud/eppo/callback/CallbackManager.java +++ b/src/main/java/cloud/eppo/callback/CallbackManager.java @@ -3,7 +3,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,12 +12,25 @@ * * @param The type of data that will be passed to the callbacks */ -public class CallbackManager { +public class CallbackManager { + /** + * Interface for dispatching data to callbacks. + * + * @param The type of data to dispatch + * @param The type of callback to dispatch to + */ + public interface Dispatcher { + void dispatch(C callback, T data); + } + + private final Dispatcher dispatcher; + private static final Logger log = LoggerFactory.getLogger(CallbackManager.class); - private final Map> subscribers; + private final Map subscribers; - public CallbackManager() { + public CallbackManager(@NotNull Dispatcher dispatcher) { this.subscribers = new ConcurrentHashMap<>(); + this.dispatcher = dispatcher; } /** @@ -26,11 +39,16 @@ public CallbackManager() { * @param callback The callback function to be called with event data * @return A Runnable that can be called to unsubscribe the callback */ - public Runnable subscribe(Consumer callback) { + public Runnable subscribe(C callback) { String id = UUID.randomUUID().toString(); subscribers.put(id, callback); - return () -> subscribers.remove(id); + return new Runnable() { + @Override + public void run() { + subscribers.remove(id); + } + }; } /** @@ -39,16 +57,13 @@ public Runnable subscribe(Consumer callback) { * @param data The data to pass to all callbacks */ public void notifyCallbacks(T data) { - subscribers - .values() - .forEach( - callback -> { - try { - callback.accept(data); - } catch (Exception e) { - log.error("Eppo SDK: Error thrown by callback: {}", e.getMessage()); - } - }); + Iterable subs = subscribers.values(); + for (C sub : subs) { + try { + dispatcher.dispatch(sub, data); + } catch (Exception ignored) { + } + } } /** Remove all subscribers. */ diff --git a/src/main/java/cloud/eppo/exception/JsonParsingException.java b/src/main/java/cloud/eppo/exception/JsonParsingException.java new file mode 100644 index 00000000..87fcb0ec --- /dev/null +++ b/src/main/java/cloud/eppo/exception/JsonParsingException.java @@ -0,0 +1,7 @@ +package cloud.eppo.exception; + +public class JsonParsingException extends Throwable { + public JsonParsingException(Throwable e) { + super(e); + } +} diff --git a/src/main/java/cloud/eppo/model/ShardRange.java b/src/main/java/cloud/eppo/model/ShardRange.java index b03e06c9..63fe40ca 100644 --- a/src/main/java/cloud/eppo/model/ShardRange.java +++ b/src/main/java/cloud/eppo/model/ShardRange.java @@ -1,15 +1,11 @@ package cloud.eppo.model; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - /** Shard Range Class */ public class ShardRange { private final int start; private int end; - @JsonCreator - public ShardRange(@JsonProperty("start") int start, @JsonProperty("end") int end) { + public ShardRange(int start, int end) { this.start = start; this.end = end; } diff --git a/src/test/java/cloud/eppo/ApiEndpointsTest.java b/src/test/java/cloud/eppo/ApiEndpointsTest.java index dd947081..75bb30e7 100644 --- a/src/test/java/cloud/eppo/ApiEndpointsTest.java +++ b/src/test/java/cloud/eppo/ApiEndpointsTest.java @@ -2,11 +2,18 @@ import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.helpers.JavaBase64Codec; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; public class ApiEndpointsTest { final SDKKey plainKey = new SDKKey("flat token"); + @BeforeAll + public static void setUp() { + Utils.setBase64Codec(new JavaBase64Codec()); + } + @Test public void testDefaultBaseUrl() { ApiEndpoints endpoints = new ApiEndpoints(plainKey, null); diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index 84587ed5..a414a12b 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -15,7 +15,6 @@ import java.io.File; import java.io.IOException; import java.util.*; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.apache.commons.io.FileUtils; @@ -55,6 +54,11 @@ public class BaseEppoClientBanditTest { private static final Map assignmentCache = new HashMap<>(); private static final Map banditAssignmentCache = new HashMap<>(); + static { + Utils.setBase64Codec(new JavaBase64Codec()); + Utils.setJsonDeserializer(new JacksonJsonDeserializer()); + } + @BeforeEach public void resetCaches() { assignmentCache.clear(); @@ -68,20 +72,18 @@ public static void initClient() { DUMMY_BANDIT_API_KEY, "java", "3.0.0", - null, TEST_HOST, mockAssignmentLogger, mockBanditLogger, null, false, - false, true, null, new AbstractAssignmentCache(assignmentCache) {}, new ExpiringInMemoryAssignmentCache( banditAssignmentCache, 50, TimeUnit.MILLISECONDS) {}); - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); log.info("Test client initialized"); } @@ -89,23 +91,20 @@ public static void initClient() { private BaseEppoClient initClientWithData( final String initialFlagConfiguration, final String initialBanditParameters) { - CompletableFuture initialConfig = - CompletableFuture.completedFuture( - Configuration.builder(initialFlagConfiguration.getBytes(), false) - .banditParameters(initialBanditParameters) - .build()); + Configuration initialConfig = + Configuration.builder(initialFlagConfiguration.getBytes()) + .banditParameters(initialBanditParameters) + .build(); return new BaseEppoClient( DUMMY_BANDIT_API_KEY, "java", "3.0.0", - null, TEST_HOST, mockAssignmentLogger, mockBanditLogger, null, false, - false, true, initialConfig, null, @@ -471,7 +470,7 @@ public void testWithInitialConfiguration() { assertEquals("adidas", result.getAction()); // Demonstrate that loaded configuration is different from the initial string passed above. - client.loadConfiguration(); + client.fetchAndActivateConfiguration(); BanditResult banditResult = client.getBanditAction( "banner_bandit_flag", "subject", new Attributes(), actions, "default"); diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 7c95ccdb..44f43a85 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -11,25 +11,24 @@ import cloud.eppo.api.*; import cloud.eppo.cache.LRUInMemoryAssignmentCache; import cloud.eppo.helpers.AssignmentTestCase; +import cloud.eppo.helpers.JacksonJsonDeserializer; +import cloud.eppo.helpers.JavaBase64Codec; +import cloud.eppo.helpers.TestUtils; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.ufc.dto.FlagConfig; import cloud.eppo.ufc.dto.VariationType; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; import java.io.IOException; -import java.net.URL; import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; +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.stream.Stream; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; import org.apache.commons.io.FileUtils; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -49,8 +48,10 @@ public class BaseEppoClientTest { private static final String TEST_BASE_URL = TEST_API_CLOUD_FUNCTION_URL + (TEST_BRANCH != null ? "/b/" + TEST_BRANCH : "") + "/api"; - private final ObjectMapper mapper = - new ObjectMapper().registerModule(AssignmentTestCase.assignmentTestCaseModule()); + static { + Utils.setBase64Codec(new JavaBase64Codec()); + Utils.setJsonDeserializer(new JacksonJsonDeserializer()); + } private BaseEppoClient eppoClient; private AssignmentLogger mockAssignmentLogger; @@ -58,82 +59,108 @@ public class BaseEppoClientTest { private final File initialFlagConfigFile = new File("src/test/resources/static/initial-flag-config.json"); - // TODO: async init client tests - private void initClient() { - initClient(false, false); + initClient(false, "java", true); } private void initClientWithData( - final CompletableFuture initialFlagConfiguration, - boolean isConfigObfuscated, - boolean isGracefulMode) { + final Configuration initialFlagConfiguration, boolean isGracefulMode) { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = new BaseEppoClient( DUMMY_FLAG_API_KEY, - isConfigObfuscated ? "android" : "java", + "java", "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, isGracefulMode, - isConfigObfuscated, true, initialFlagConfiguration, null, null); } - private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { + private void initClient(boolean isGracefulMode, String sdkName, boolean loadConfig) { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = new BaseEppoClient( DUMMY_FLAG_API_KEY, - isConfigObfuscated ? "android" : "java", + sdkName, "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, isGracefulMode, - isConfigObfuscated, true, null, null, null); - eppoClient.loadConfiguration(); + if (loadConfig) { + try { + eppoClient.fetchAndActivateConfiguration(); + } catch (Exception e) { + if (!isGracefulMode) { + throw e; + } + } + } log.info("Test client initialized"); } - private CompletableFuture initClientAsync( - boolean isGracefulMode, boolean isConfigObfuscated) { + interface InitCallback extends EppoActionCallback {} + + private void initClientAsync(boolean isGracefulMode, InitCallback initCallback) { mockAssignmentLogger = mock(AssignmentLogger.class); eppoClient = new BaseEppoClient( DUMMY_FLAG_API_KEY, - isConfigObfuscated ? "android" : "java", + "java", "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, isGracefulMode, - isConfigObfuscated, true, null, null, null); - return eppoClient.loadConfigurationAsync(); + // The common SDK doesn't actually have an "initialization" method. This method stands in for + // "build and instance + // and activate some configuration". + // Thus, we must provide a fallback if graceful mode is true and activating config fails. + InitCallback onInit = + new InitCallback() { + + @Override + public void onSuccess(Configuration data) { + if (data == null) { + data = Configuration.emptyConfig(); + // Fetch and activate did not produce a config, so we set an empty one. + eppoClient.activateConfiguration(data); + } + initCallback.onSuccess(data); + } + + @Override + public void onFailure(Throwable error) { + if (isGracefulMode) { + initCallback.onSuccess(null); + } else { + initCallback.onFailure(error); + } + } + }; + + eppoClient.fetchAndActivateConfigurationAsync(onInit); } private void initClientWithAssignmentCache(IAssignmentCache cache) { @@ -144,23 +171,21 @@ private void initClientWithAssignmentCache(IAssignmentCache cache) { DUMMY_FLAG_API_KEY, "java", "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, true, - false, true, null, cache, null); - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); log.info("Test client initialized"); } - @BeforeEach + @AfterEach public void cleanUp() { // TODO: Clear any caches setBaseClientHttpClientOverrideField(null); @@ -169,7 +194,7 @@ public void cleanUp() { @ParameterizedTest @MethodSource("getAssignmentTestData") public void testUnobfuscatedAssignments(File testFile) { - initClient(false, false); + initClient(false, "java", true); AssignmentTestCase testCase = parseTestCaseFile(testFile); runTestCase(testCase, eppoClient); } @@ -177,7 +202,7 @@ public void testUnobfuscatedAssignments(File testFile) { @ParameterizedTest @MethodSource("getAssignmentTestData") public void testObfuscatedAssignments(File testFile) { - initClient(false, true); + initClient(false, "android", true); AssignmentTestCase testCase = parseTestCaseFile(testFile); runTestCase(testCase, eppoClient); } @@ -186,52 +211,9 @@ private static Stream getAssignmentTestData() { return AssignmentTestCase.getAssignmentTestData(); } - @Test - public void testBaseUrlBackwardsCompatibility() throws IOException, InterruptedException { - // Base client must be buildable with a HOST (i.e. no `/api` postfix) - mockAssignmentLogger = mock(AssignmentLogger.class); - - MockWebServer mockWebServer = new MockWebServer(); - URL mockServerBaseUrl = mockWebServer.url("").url(); // get base url of mockwebserver - - // Remove trailing slash to mimic typical "host" parameter of "https://fscdn.eppo.cloud" - String testHost = mockServerBaseUrl.toString().replaceAll("/$", ""); - - mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); - - eppoClient = - new BaseEppoClient( - DUMMY_FLAG_API_KEY, - "java", - "100.1.0", - testHost, - null, - mockAssignmentLogger, - null, - null, - false, - false, - true, - null, - null, - null); - - eppoClient.loadConfiguration(); - - // Test what path the call was sent to - RecordedRequest request = mockWebServer.takeRequest(); - assertNotNull(request); - assertEquals("GET", request.getMethod()); - - // The "/api" part comes from appending it on to a "host" parameter but not a base URL param. - assertEquals( - "/api/flag-config/v1/config?apiKey=dummy-flags-api-key&sdkName=java&sdkVersion=100.1.0", - request.getPath()); - } - @Test public void testErrorGracefulModeOn() throws JsonProcessingException { - initClient(true, false); + initClient(true, "java", true); BaseEppoClient realClient = eppoClient; BaseEppoClient spyClient = spy(realClient); @@ -260,27 +242,14 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { 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); + initClient(false, "java", true); BaseEppoClient realClient = eppoClient; BaseEppoClient spyClient = spy(realClient); @@ -324,13 +293,8 @@ public void testErrorGracefulModeOff() { assertThrows( RuntimeException.class, () -> - spyClient.getJSONAssignment( - "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}"))); - assertThrows( - RuntimeException.class, - () -> - spyClient.getJSONAssignment( - "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); + spyClient.getJSONStringAssignment( + "subject1", "experiment1", "{\"a\": 1, \"b\": false}")); } @Test @@ -338,25 +302,19 @@ public void testInvalidConfigJSON() { mockHttpResponse("{}"); - initClient(false, false); + initClient(false, "java", true); String result = eppoClient.getStringAssignment("dummy flag", "dummy subject", "not-populated"); assertEquals("not-populated", result); } - private CompletableFuture immediateConfigFuture( - String config, boolean isObfuscated) { - return CompletableFuture.completedFuture( - Configuration.builder(config.getBytes(), isObfuscated).build()); - } - @Test public void testGracefulInitializationFailure() { // Set up bad HTTP response mockHttpError(); // Initialize and no exception should be thrown. - assertDoesNotThrow(() -> initClient(true, false)); + assertDoesNotThrow(() -> initClient(true, "java", true)); } @Test @@ -365,7 +323,7 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() { mockHttpError(); // Initialize and no exception should be thrown. - assertDoesNotThrow(() -> initClient(true, false)); + assertDoesNotThrow(() -> initClient(true, "java", true)); assertEquals("default", eppoClient.getStringAssignment("experiment1", "subject1", "default")); } @@ -377,7 +335,7 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitializeNonGraceful // Initialize and no exception should be thrown. try { - initClient(false, false); + initClient(false, "java", true); } catch (RuntimeException e) { // Expected assertEquals("Intentional Error", e.getMessage()); @@ -392,34 +350,69 @@ public void testNonGracefulInitializationFailure() { mockHttpError(); // Initialize and assert exception thrown - assertThrows(Exception.class, () -> initClient(false, false)); + assertThrows(Exception.class, () -> initClient(false, "java", true)); } @Test - public void testGracefulAsyncInitializationFailure() { + public void testGracefulAsyncInitializationFailure() throws InterruptedException { // Set up bad HTTP response mockHttpError(); + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean initialized = new AtomicBoolean(false); + // Initialize - CompletableFuture init = initClientAsync(true, false); + initClientAsync( + true, + new InitCallback() { + @Override + public void onSuccess(Configuration data) { + initialized.set(true); + initLatch.countDown(); + } + + @Override + public void onFailure(Throwable error) { + initLatch.countDown(); + } + }); // Wait for initialization; future should not complete exceptionally (equivalent of exception // being thrown). - init.join(); - assertFalse(init.isCompletedExceptionally()); + assertTrue(initLatch.await(1, TimeUnit.SECONDS)); + assertTrue(initialized.get()); } @Test - public void testNonGracefulAsyncInitializationFailure() { + public void testNonGracefulAsyncInitializationFailure() throws InterruptedException { // Set up bad HTTP response mockHttpError(); + CountDownLatch initLatch = new CountDownLatch(1); + AtomicBoolean initialized = new AtomicBoolean(false); + final Throwable[] failure = {null}; + // Initialize - CompletableFuture init = initClientAsync(false, false); + initClientAsync( + false, + new InitCallback() { + @Override + public void onSuccess(Configuration data) { + initialized.set(true); + initLatch.countDown(); + } - // Exceptions thrown in CompletableFutures are wrapped in a CompletionException. - assertThrows(CompletionException.class, init::join); - assertTrue(init.isCompletedExceptionally()); + @Override + public void onFailure(Throwable error) { + failure[0] = error; + initLatch.countDown(); + } + }); + + assertTrue(initLatch.await(1, TimeUnit.SECONDS)); + assertNotNull(failure[0]); + assertInstanceOf(RuntimeException.class, failure[0]); + assertFalse(initialized.get()); } @Test @@ -427,13 +420,13 @@ public void testWithInitialConfiguration() { try { String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, "UTF8"); - initClientWithData(immediateConfigFuture(flagConfig, false), false, true); + initClientWithData(Configuration.builder(flagConfig.getBytes()).build(), true); double result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); assertEquals(5, result); // Demonstrate that loaded configuration is different from the initial string passed above. - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); double updatedResult = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); assertEquals(3.1415926, updatedResult); } catch (IOException e) { @@ -442,20 +435,28 @@ public void testWithInitialConfiguration() { } @Test - public void testWithInitialConfigurationFuture() throws IOException { - CompletableFuture futureConfig = new CompletableFuture<>(); - byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); + public void testWithActivatedConfiguration() { + try { + String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, "UTF8"); + Configuration configToActivate = Configuration.builder(flagConfig.getBytes()).build(); - initClientWithData(futureConfig, false, true); + initClient(false, "java", false); - double result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); - assertEquals(0, result); + // Result is the default until we activate the config. + double firstResult = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); + assertEquals(0, firstResult); - // Now, complete the initial config future and check the value. - futureConfig.complete(Configuration.builder(flagConfig, false).build()); + eppoClient.activateConfiguration(configToActivate); + double result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); + assertEquals(5, result); - result = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); - assertEquals(5, result); + // Demonstrate that loaded configuration is different from the initial string passed above. + eppoClient.fetchAndActivateConfiguration(); + double updatedResult = eppoClient.getDoubleAssignment("numeric_flag", "dummy subject", 0); + assertEquals(3.1415926, updatedResult); + } catch (IOException e) { + throw new RuntimeException(e); + } } @Test @@ -601,7 +602,10 @@ public void run() { @Test public void testPolling() { - EppoHttpClient httpClient = mockHttpResponse(BOOL_FLAG_CONFIG); + TestUtils.MockHttpClient httpClient = mockHttpResponse(BOOL_FLAG_CONFIG); + + TestUtils.MockHttpClient spyClient = spy(httpClient); + setBaseClientHttpClientOverrideField(spyClient); BaseEppoClient client = eppoClient = @@ -609,30 +613,29 @@ public void testPolling() { DUMMY_FLAG_API_KEY, "java", "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, false, - false, true, null, null, null); - client.loadConfiguration(); + client.fetchAndActivateConfiguration(); client.startPolling(20); // Method will be called immediately on init - verify(httpClient, times(1)).get(anyString()); + + verify(spyClient, times(1)).get(anyString()); assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); // Sleep for 25 ms to allow another polling cycle to complete sleepUninterruptedly(25); // Now, the method should have been called twice - verify(httpClient, times(2)).get(anyString()); + verify(spyClient, times(2)).get(anyString()); eppoClient.stopPolling(); assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); @@ -640,10 +643,10 @@ public void testPolling() { sleepUninterruptedly(25); // No more calls since stopped - verify(httpClient, times(2)).get(anyString()); + verify(spyClient, times(2)).get(anyString()); // Set up a different config to be served - when(httpClient.get(anyString())).thenReturn(DISABLED_BOOL_FLAG_CONFIG.getBytes()); + spyClient.changeResponse(DISABLED_BOOL_FLAG_CONFIG.getBytes()); client.startPolling(20); // True until the next config is fetched. @@ -682,7 +685,7 @@ public void testGetConfigurationWithInitialConfig() { String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, "UTF8"); // Initialize client with initial configuration - initClientWithData(immediateConfigFuture(flagConfig, false), false, true); + initClientWithData(Configuration.builder(flagConfig.getBytes()).build(), true); // Get configuration Configuration config = eppoClient.getConfiguration(); @@ -699,7 +702,7 @@ public void testGetConfigurationWithInitialConfig() { assertNull(config.getFlag("no_allocations_flag")); // Load new configuration - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); // Get updated configuration Configuration updatedConfig = eppoClient.getConfiguration(); @@ -724,13 +727,11 @@ public void testGetConfigurationBeforeInitialization() { DUMMY_FLAG_API_KEY, "java", "100.1.0", - null, TEST_BASE_URL, mockAssignmentLogger, null, null, false, - false, true, null, null, @@ -742,8 +743,9 @@ public void testGetConfigurationBeforeInitialization() { // Verify we get an empty configuration assertNotNull(config); assertTrue(config.isEmpty()); + assertEquals(Collections.emptySet(), config.getFlagKeys()); - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); // Get configuration again after loading Configuration nextConfig = eppoClient.getConfiguration(); @@ -751,6 +753,7 @@ public void testGetConfigurationBeforeInitialization() { // Verify we get an empty configuration assertNotNull(nextConfig); assertFalse(nextConfig.isEmpty()); + assertFalse(nextConfig.getFlagKeys().isEmpty()); } @SuppressWarnings("SameParameterValue") diff --git a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java index 982153fb..b1902a4c 100644 --- a/src/test/java/cloud/eppo/ConfigurationRequestorTest.java +++ b/src/test/java/cloud/eppo/ConfigurationRequestorTest.java @@ -3,178 +3,149 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoActionCallback; +import cloud.eppo.helpers.TestUtils; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +/** Tests for the ConfigurationRequestor class. */ public class ConfigurationRequestorTest { 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 testInitialConfigurationFuture() throws IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); - - CompletableFuture futureConfig = new CompletableFuture<>(); - byte[] flagConfig = FileUtils.readFileToByteArray(initialFlagConfigFile); - - requestor.setInitialConfiguration(futureConfig); - - // verify config is empty to start - assertTrue(configStore.getConfiguration().isEmpty()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); - - futureConfig.complete(Configuration.builder(flagConfig, false).build()); + private ConfigurationStore mockConfigStore; + private IEppoHttpClient mockHttpClient; + private ConfigurationRequestor requestor; - assertFalse(configStore.getConfiguration().isEmpty()); - Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); - assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); + @BeforeEach + public void setup() { + mockConfigStore = mock(ConfigurationStore.class); + mockHttpClient = mock(EppoHttpClient.class); + requestor = new ConfigurationRequestor(mockConfigStore, mockHttpClient, true); } @Test - public void testInitialConfigurationDoesntClobberFetch() throws IOException { + public void testBrokenFetchDoesntClobberCache() throws IOException, InterruptedException { IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + TestUtils.DelayedHttpClient mockHttpClient = new TestUtils.DelayedHttpClient("".getBytes()); ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + new ConfigurationRequestor(configStore, mockHttpClient, true); - CompletableFuture initialConfigFuture = new CompletableFuture<>(); String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - String fetchedFlagConfig = - FileUtils.readFileToString(differentFlagConfigFile, StandardCharsets.UTF_8); - - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); - - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); - - assertTrue(configStore.getConfiguration().isEmpty()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(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. - CompletableFuture handle = requestor.fetchAndSaveFromRemoteAsync(); - - // Resolve the fetch and then the initialConfig - configFetchFuture.complete(fetchedFlagConfig.getBytes(StandardCharsets.UTF_8)); - initialConfigFuture.complete(new Configuration.Builder(flagConfig, false).build()); + // Set initial config and verify that it has been set. + requestor.activateConfiguration(Configuration.builder(flagConfig.getBytes()).build()); assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); - // `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 IOException { - IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); - - CompletableFuture initialConfigFuture = new CompletableFuture<>(); - String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); - - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); - - assertTrue(configStore.getConfiguration().isEmpty()); - Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); - - requestor.fetchAndSaveFromRemoteAsync(); - - // Resolve the initial config - initialConfigFuture.complete(new Configuration.Builder(flagConfig, false).build()); + CountDownLatch latch = new CountDownLatch(1); + requestor.fetchAndSaveFromRemoteAsync( + createCallback(() -> fail("Unexpected success"), () -> latch.countDown())); // Error out the fetch - configFetchFuture.completeExceptionally(new Exception("Intentional exception")); + mockHttpClient.fail(new Exception("Intentional exception")); assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); // `numeric_flag` is only in the cache which should be available assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + + // Ensure fetch failure callback is called + assertTrue(latch.await(1, TimeUnit.SECONDS)); } @Test - public void testCacheWritesAfterBrokenFetch() throws IOException { + public void testCacheWritesAfterBrokenFetch() throws IOException, InterruptedException { IConfigurationStore configStore = Mockito.spy(new ConfigurationStore()); - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + TestUtils.DelayedHttpClient mockHttpClient = new TestUtils.DelayedHttpClient("".getBytes()); ConfigurationRequestor requestor = - new ConfigurationRequestor(configStore, mockHttpClient, false, true); + new ConfigurationRequestor(configStore, mockHttpClient, true); - CompletableFuture initialConfigFuture = new CompletableFuture<>(); String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); - CompletableFuture configFetchFuture = new CompletableFuture<>(); - - when(mockHttpClient.getAsync("/flag-config/v1/config")).thenReturn(configFetchFuture); - - // Set initial config and verify that no config has been set yet. - requestor.setInitialConfiguration(initialConfigFuture); Mockito.verify(configStore, Mockito.times(0)).saveConfiguration(any()); - - // default configuration is empty config. assertTrue(configStore.getConfiguration().isEmpty()); + assertEquals(Collections.emptySet(), configStore.getConfiguration().getFlagKeys()); + + CountDownLatch latch = new CountDownLatch(1); + requestor.fetchAndSaveFromRemoteAsync( + createCallback(() -> fail("Unexpected success"), () -> latch.countDown())); - // Fetch from remote with an error - requestor.fetchAndSaveFromRemoteAsync(); - configFetchFuture.completeExceptionally(new Exception("Intentional exception")); + // Error out the fetch + mockHttpClient.fail(new Exception("Intentional exception")); - // Resolve the initial config after the fetch throws an error. - initialConfigFuture.complete(new Configuration.Builder(flagConfig, false).build()); + // Set initial config and verify that it has been set. + requestor.activateConfiguration(Configuration.builder(flagConfig.getBytes()).build()); - // Verify that a configuration was saved by the requestor Mockito.verify(configStore, Mockito.times(1)).saveConfiguration(any()); assertFalse(configStore.getConfiguration().isEmpty()); + assertFalse(configStore.getConfiguration().getFlagKeys().isEmpty()); // `numeric_flag` is only in the cache which should be available assertNotNull(configStore.getConfiguration().getFlag("numeric_flag")); assertNull(configStore.getConfiguration().getFlag("boolean_flag")); + + // Ensure fetch failure callback is called + assertTrue(latch.await(1, TimeUnit.SECONDS)); } - private ConfigurationStore mockConfigStore; - private EppoHttpClient mockHttpClient; - private ConfigurationRequestor requestor; + /** Helper class for tracking configurations received via callbacks. */ + static class ListAddingConfigCallback implements Configuration.Callback { + public final List results = new ArrayList<>(); - @BeforeEach - public void setup() { - mockConfigStore = mock(ConfigurationStore.class); - mockHttpClient = mock(EppoHttpClient.class); - requestor = new ConfigurationRequestor(mockConfigStore, mockHttpClient, false, true); + @Override + public void accept(Configuration configuration) { + results.add(configuration); + } + } + + /** + * Creates a simple success/failure callback with the provided actions. + * + * @param onSuccessAction Action to perform on success + * @param onFailureAction Action to perform on failure + * @return A configured callback + */ + private EppoActionCallback createCallback( + Runnable onSuccessAction, Runnable onFailureAction) { + return new EppoActionCallback() { + @Override + public void onSuccess(Configuration data) { + if (onSuccessAction != null) { + onSuccessAction.run(); + } + } + + @Override + public void onFailure(Throwable error) { + if (onFailureAction != null) { + onFailureAction.run(); + } + } + }; } @Test @@ -182,41 +153,51 @@ public void testConfigurationChangeListener() throws IOException { // Setup mock response String flagConfig = FileUtils.readFileToString(initialFlagConfigFile, StandardCharsets.UTF_8); when(mockHttpClient.get(anyString())).thenReturn(flagConfig.getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); - List receivedConfigs = new ArrayList<>(); + ListAddingConfigCallback receivedConfigs = new ListAddingConfigCallback(); // Subscribe to configuration changes - Runnable unsubscribe = requestor.onConfigurationChange(receivedConfigs::add); + Runnable unsubscribe = requestor.onConfigurationChange(receivedConfigs); // Initial fetch should trigger the callback requestor.fetchAndSaveFromRemote(); - assertEquals(1, receivedConfigs.size()); + assertEquals(1, receivedConfigs.results.size()); // Another fetch should trigger the callback again (fetches aren't optimized with eTag yet). requestor.fetchAndSaveFromRemote(); - assertEquals(2, receivedConfigs.size()); + assertEquals(2, receivedConfigs.results.size()); // Unsubscribe should prevent further callbacks unsubscribe.run(); requestor.fetchAndSaveFromRemote(); - assertEquals(2, receivedConfigs.size()); // Count should remain the same + assertEquals(2, receivedConfigs.results.size()); // Count should remain the same } @Test public void testMultipleConfigurationChangeListeners() { // Setup mock response when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn(CompletableFuture.completedFuture(null)); 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()); + Runnable unsubscribe1 = + requestor.onConfigurationChange( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + callCount1.incrementAndGet(); + } + }); + Runnable unsubscribe2 = + requestor.onConfigurationChange( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + callCount2.incrementAndGet(); + } + }); // Fetch should trigger both callbacks requestor.fetchAndSaveFromRemote(); @@ -238,34 +219,43 @@ public void testMultipleConfigurationChangeListeners() { @Test public void testConfigurationChangeListenerIgnoresFailedFetch() { - // Setup mock response to simulate failure - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Fetch failed")); + // throw on get + when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("fetch failed")); AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + requestor.onConfigurationChange( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + callCount.incrementAndGet(); + } + }); - // Failed fetch should not trigger the callback + // Failed save should not trigger the callback try { requestor.fetchAndSaveFromRemote(); - } catch (Exception e) { - // Expected + } catch (RuntimeException e) { + // Pass } assertEquals(0, callCount.get()); } @Test public void testConfigurationChangeListenerIgnoresFailedSave() { - // Setup mock responses + // throw on get when(mockHttpClient.get(anyString())).thenReturn("{}".getBytes()); - when(mockConfigStore.saveConfiguration(any())) - .thenReturn( - CompletableFuture.supplyAsync( - () -> { - throw new RuntimeException("Save failed"); - })); + doThrow(new RuntimeException("Save failed")) + .when(mockConfigStore) + .saveConfiguration(any(Configuration.class)); AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + requestor.onConfigurationChange( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + callCount.incrementAndGet(); + } + }); // Failed save should not trigger the callback try { @@ -277,24 +267,37 @@ public void testConfigurationChangeListenerIgnoresFailedSave() { } @Test - public void testConfigurationChangeListenerAsyncSave() { + public void testConfigurationChangeListenerAsyncSave() throws InterruptedException { // Setup mock responses - when(mockHttpClient.getAsync(anyString())) - .thenReturn(CompletableFuture.completedFuture("{\"flags\":{}}".getBytes())); + TestUtils.DelayedHttpClient mockHttpClient = + new TestUtils.DelayedHttpClient("{\"flags\":{}}".getBytes()); + requestor = new ConfigurationRequestor(mockConfigStore, mockHttpClient, true); - CompletableFuture saveFuture = new CompletableFuture<>(); - when(mockConfigStore.saveConfiguration(any())).thenReturn(saveFuture); + CountDownLatch countDownLatch = new CountDownLatch(1); - AtomicInteger callCount = new AtomicInteger(0); - requestor.onConfigurationChange(v -> callCount.incrementAndGet()); + requestor.onConfigurationChange( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + countDownLatch.countDown(); + } + }); // Start fetch - CompletableFuture fetch = requestor.fetchAndSaveFromRemoteAsync(); - assertEquals(0, callCount.get()); // Callback should not be called yet + requestor.fetchAndSaveFromRemoteAsync( + createCallback( + () -> { + // This callback should be notified after the registered listeners have been notified. + assertEquals(0, countDownLatch.getCount()); + }, + () -> fail("unexpected failure"))); + + assertEquals(1, countDownLatch.getCount()); // Fetch not yet completed // Complete the save - saveFuture.complete(null); - fetch.join(); - assertEquals(1, callCount.get()); // Callback should be called after save completes + mockHttpClient.flush(); + + assertTrue(countDownLatch.await(1, TimeUnit.SECONDS)); + assertEquals(0, countDownLatch.getCount()); // Callback should be called after save completes } } diff --git a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java index 3dd24802..3e9206e7 100644 --- a/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/ProfileBaseEppoClientTest.java @@ -39,19 +39,17 @@ public static void initClient() { DUMMY_FLAG_API_KEY, "java", "3.0.0", - null, TEST_HOST, noOpAssignmentLogger, null, null, false, - false, true, null, null, null); - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); log.info("Test client initialized"); } diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 63358df0..75c0f14f 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -1,6 +1,7 @@ package cloud.eppo; import static cloud.eppo.Utils.*; +import static cloud.eppo.helpers.JacksonJsonDeserializer.parseUtcISODateNode; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; diff --git a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java index 54360033..96d6d218 100644 --- a/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java +++ b/src/test/java/cloud/eppo/api/ConfigurationBuilderTest.java @@ -3,10 +3,10 @@ import static cloud.eppo.Utils.getMD5Hex; import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.helpers.dto.adapters.EppoModule; import cloud.eppo.ufc.dto.FlagConfig; import cloud.eppo.ufc.dto.FlagConfigResponse; import cloud.eppo.ufc.dto.VariationType; -import cloud.eppo.ufc.dto.adapters.EppoModule; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.Collections; @@ -35,7 +35,7 @@ public void testHydrateConfigFromBytesForServer_false() { @Test public void testBuildConfigAddsForServer_true() throws IOException { byte[] jsonBytes = "{ \"flags\":{} }".getBytes(); - Configuration config = Configuration.builder(jsonBytes, false).build(); + Configuration config = Configuration.builder(jsonBytes).build(); assertFalse(config.isConfigObfuscated()); byte[] serializedFlags = config.serializeFlagConfigToBytes(); @@ -45,19 +45,6 @@ public void testBuildConfigAddsForServer_true() throws IOException { assertEquals(rehydratedConfig.getFormat(), FlagConfigResponse.Format.SERVER); } - @Test - public void testBuildConfigAddsForServer_false() throws IOException { - byte[] jsonBytes = "{ \"flags\":{} }".getBytes(); - Configuration config = Configuration.builder(jsonBytes, true).build(); - assertTrue(config.isConfigObfuscated()); - - byte[] serializedFlags = config.serializeFlagConfigToBytes(); - FlagConfigResponse rehydratedConfig = - mapper.readValue(serializedFlags, FlagConfigResponse.class); - - assertEquals(rehydratedConfig.getFormat(), FlagConfigResponse.Format.CLIENT); - } - @Test public void getFlagType_shouldReturnCorrectType() { // Create a flag config with a STRING variation type diff --git a/src/test/java/cloud/eppo/callback/CallbackManagerTest.java b/src/test/java/cloud/eppo/callback/CallbackManagerTest.java index 97ba0c7b..b5ae5ac7 100644 --- a/src/test/java/cloud/eppo/callback/CallbackManagerTest.java +++ b/src/test/java/cloud/eppo/callback/CallbackManagerTest.java @@ -8,12 +8,24 @@ public class CallbackManagerTest { + private CallbackManager> createCallbackManager() { + return new CallbackManager<>( + // Can't use a lambda as they were only introduced in java8 + new CallbackManager.Dispatcher>() { + @Override + public void dispatch(List callback, String data) { + callback.add(data); + } + }); + } + @Test public void testSubscribeAndNotify() { - CallbackManager CallbackManager = new CallbackManager<>(); + CallbackManager> CallbackManager = createCallbackManager(); + List received = new ArrayList<>(); - Runnable unsubscribe = CallbackManager.subscribe(received::add); + Runnable unsubscribe = CallbackManager.subscribe(received); CallbackManager.notifyCallbacks("test message"); assertEquals(1, received.size()); @@ -26,15 +38,20 @@ public void testSubscribeAndNotify() { @Test public void testThrowingCallback() { - CallbackManager manager = new CallbackManager<>(); + // The helper-created manager includes a dispatcher which pushes the data to the `add` method. + CallbackManager> manager = createCallbackManager(); List received = new ArrayList<>(); - Runnable unsubscribe1 = - manager.subscribe( - (s) -> { - throw new RuntimeException("test message"); - }); - Runnable unsubscribe2 = manager.subscribe(received::add); + List throwingList = + new ArrayList() { + @Override + public boolean add(String o) { + throw new RuntimeException("test message"); + } + }; + + Runnable unsubscribe1 = manager.subscribe(throwingList); + Runnable unsubscribe2 = manager.subscribe(received); manager.notifyCallbacks("value"); assertEquals(1, received.size()); @@ -42,12 +59,20 @@ public void testThrowingCallback() { @Test public void testMultipleSubscribers() { - CallbackManager manager = new CallbackManager<>(); + CallbackManager> manager = + new CallbackManager<>( + new CallbackManager.Dispatcher>() { + + @Override + public void dispatch(List callback, Integer data) { + callback.add(data); + } + }); List received1 = new ArrayList<>(); List received2 = new ArrayList<>(); - manager.subscribe(received1::add); - manager.subscribe(received2::add); + manager.subscribe(received1); + manager.subscribe(received2); manager.notifyCallbacks(42); @@ -59,11 +84,11 @@ public void testMultipleSubscribers() { @Test public void testUnsubscribe() { - CallbackManager manager = new CallbackManager<>(); + CallbackManager> manager = createCallbackManager(); List received = new ArrayList<>(); - Runnable unsubscribe1 = manager.subscribe(received::add); - Runnable unsubscribe2 = manager.subscribe(received::add); + Runnable unsubscribe1 = manager.subscribe(received); + Runnable unsubscribe2 = manager.subscribe(received); manager.notifyCallbacks("value"); assertEquals(2, received.size()); @@ -83,12 +108,12 @@ public void testUnsubscribe() { @Test public void testClear() { - CallbackManager manager = new CallbackManager<>(); + CallbackManager> manager = createCallbackManager(); List received = new ArrayList<>(); - manager.subscribe(received::add); - manager.subscribe(received::add); + manager.subscribe(received); + manager.subscribe(received); manager.notifyCallbacks("value"); assertEquals(2, received.size()); diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index f7250167..57e56e07 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -5,6 +5,7 @@ import cloud.eppo.BaseEppoClient; import cloud.eppo.api.Attributes; import cloud.eppo.ufc.dto.VariationType; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; @@ -122,10 +123,19 @@ public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoC assertAssignment(flagKey, subjectAssignment, stringAssignment); break; case JSON: - JsonNode jsonAssignment = - eppoClient.getJSONAssignment( - flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); - assertAssignment(flagKey, subjectAssignment, jsonAssignment); + try { + JsonNode jsonAssignment = + mapper.readTree( + eppoClient.getJSONStringAssignment( + flagKey, + subjectKey, + subjectAttributes, + testCase.getDefaultValue().stringValue())); + assertAssignment(flagKey, subjectAssignment, jsonAssignment); + + } catch (JsonProcessingException ex) { + throw new RuntimeException(ex); + } break; default: throw new UnsupportedOperationException( diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java index 76ae6cc2..adfcac6d 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -2,8 +2,8 @@ import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; +import cloud.eppo.helpers.dto.adapters.EppoValueDeserializer; 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; diff --git a/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java index 679cb887..d20112ae 100644 --- a/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java @@ -1,7 +1,7 @@ package cloud.eppo.helpers; import cloud.eppo.api.*; -import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; +import cloud.eppo.helpers.dto.adapters.EppoValueDeserializer; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; diff --git a/src/test/java/cloud/eppo/helpers/JacksonJsonDeserializer.java b/src/test/java/cloud/eppo/helpers/JacksonJsonDeserializer.java new file mode 100644 index 00000000..a709ee88 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/JacksonJsonDeserializer.java @@ -0,0 +1,92 @@ +package cloud.eppo.helpers; + +import cloud.eppo.Utils; +import cloud.eppo.api.EppoValue; +import cloud.eppo.exception.JsonParsingException; +import cloud.eppo.helpers.dto.adapters.EppoModule; +import cloud.eppo.ufc.dto.BanditParametersResponse; +import cloud.eppo.ufc.dto.FlagConfigResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.util.Date; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JacksonJsonDeserializer implements Utils.JsonDeserializer { + + private static final Logger log = LoggerFactory.getLogger(JacksonJsonDeserializer.class); + private final ObjectMapper mapper = new ObjectMapper().registerModule(EppoModule.eppoModule()); + + @Override + public FlagConfigResponse parseFlagConfigResponse(byte[] jsonString) throws JsonParsingException { + try { + return mapper.readValue(jsonString, FlagConfigResponse.class); + } catch (IOException e) { + throw new JsonParsingException(e); + } + } + + @Override + public BanditParametersResponse parseBanditParametersResponse(byte[] jsonString) + throws JsonParsingException { + try { + return mapper.readValue(jsonString, BanditParametersResponse.class); + } catch (IOException e) { + throw new JsonParsingException(e); + } + } + + @Override + public boolean isValidJson(String json) { + try { + return mapper.readTree(json) != null; + } catch (JsonProcessingException e) { + return false; + } + } + + @Override + public String serializeAttributesToJSONString(Map map, boolean omitNulls) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode result = mapper.createObjectNode(); + + for (Map.Entry entry : map.entrySet()) { + String attributeName = entry.getKey(); + EppoValue attributeValue = entry.getValue(); + + if (attributeValue == null || attributeValue.isNull()) { + if (!omitNulls) { + result.putNull(attributeName); + } + } else { + if (attributeValue.isNumeric()) { + result.put(attributeName, attributeValue.doubleValue()); + continue; + } + if (attributeValue.isBoolean()) { + result.put(attributeName, attributeValue.booleanValue()); + continue; + } + // fall back put treating any other eppo values as a string + result.put(attributeName, attributeValue.toString()); + } + } + try { + return mapper.writeValueAsString(result); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static Date parseUtcISODateNode(JsonNode isoDateStringElement) { + if (isoDateStringElement == null || isoDateStringElement.isNull()) { + return null; + } + String isoDateString = isoDateStringElement.asText(); + return Utils.parseUtcISODateString(isoDateString); + } +} diff --git a/src/test/java/cloud/eppo/helpers/JavaBase64Codec.java b/src/test/java/cloud/eppo/helpers/JavaBase64Codec.java new file mode 100644 index 00000000..87296265 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/JavaBase64Codec.java @@ -0,0 +1,22 @@ +package cloud.eppo.helpers; + +import cloud.eppo.Utils; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class JavaBase64Codec implements Utils.Base64Codec { + @Override + public String base64Encode(String input) { + return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8))); + } + + @Override + public String base64Decode(String input) { + byte[] decodedBytes = Base64.getDecoder().decode(input); + 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"); + } + return new String(decodedBytes); + } +} diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java index 4b9a5822..b7f5943b 100644 --- a/src/test/java/cloud/eppo/helpers/TestUtils.java +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -1,49 +1,29 @@ package cloud.eppo.helpers; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - import cloud.eppo.BaseEppoClient; -import cloud.eppo.EppoHttpClient; +import cloud.eppo.IEppoHttpClient; +import cloud.eppo.Utils; import java.lang.reflect.Field; -import java.util.concurrent.CompletableFuture; -import okhttp3.*; public class TestUtils { - @SuppressWarnings("SameParameterValue") - public static EppoHttpClient mockHttpResponse(String responseBody) { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenReturn(responseBody.getBytes()); + static { + Utils.setJsonDeserializer(new JacksonJsonDeserializer()); + } - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.complete(responseBody.getBytes()); + @SuppressWarnings("SameParameterValue") + public static MockHttpClient mockHttpResponse(String responseBody) { + MockHttpClient mockHttpClient = new MockHttpClient(responseBody.getBytes()); setBaseClientHttpClientOverrideField(mockHttpClient); return mockHttpClient; } public static void mockHttpError() { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Intentional Error")); - - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.completeExceptionally(new RuntimeException("Intentional Error")); - - setBaseClientHttpClientOverrideField(mockHttpClient); + setBaseClientHttpClientOverrideField(new ThrowingHttpClient()); } - public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { + public static void setBaseClientHttpClientOverrideField(IEppoHttpClient httpClient) { setBaseClientOverrideField("httpClientOverride", httpClient); } @@ -59,4 +39,69 @@ public static void setBaseClientOverrideField(String fieldName, T override) throw new RuntimeException(e); } } + + public static class MockHttpClient extends DelayedHttpClient { + public MockHttpClient(byte[] responseBody) { + super(responseBody); + flush(); + } + + public void changeResponse(byte[] responseBody) { + this.responseBody = responseBody; + } + } + + public static class ThrowingHttpClient implements IEppoHttpClient { + + @Override + public byte[] get(String path) { + throw new RuntimeException("Intentional Error"); + } + + @Override + public void getAsync(String path, Callback callback) { + callback.onFailure(new RuntimeException("Intentional Error")); + } + } + + public static class DelayedHttpClient implements IEppoHttpClient { + protected byte[] responseBody; + private Callback callback; + private boolean flushed = false; + private Throwable error = null; + + public DelayedHttpClient(byte[] responseBody) { + this.responseBody = responseBody; + } + + @Override + public byte[] get(String path) { + return responseBody; + } + + @Override + public void getAsync(String path, Callback callback) { + if (flushed) { + callback.onSuccess(responseBody); + } else if (error != null) { + callback.onFailure(error); + } else { + this.callback = callback; + } + } + + public void fail(Throwable error) { + this.error = error; + if (this.callback != null) { + this.callback.onFailure(error); + } + } + + public void flush() { + flushed = true; + if (callback != null) { + callback.onSuccess(responseBody); + } + } + } } diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java b/src/test/java/cloud/eppo/helpers/dto/adapters/BanditParametersResponseDeserializer.java similarity index 99% rename from src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/BanditParametersResponseDeserializer.java index 6913c387..1b2be38c 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/BanditParametersResponseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/BanditParametersResponseDeserializer.java @@ -1,4 +1,4 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; import cloud.eppo.ufc.dto.*; import com.fasterxml.jackson.core.JsonParser; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java b/src/test/java/cloud/eppo/helpers/dto/adapters/DateSerializer.java similarity index 94% rename from src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/DateSerializer.java index 2ea23176..1f1d52ed 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/DateSerializer.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/DateSerializer.java @@ -1,4 +1,4 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; import static cloud.eppo.Utils.getISODate; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoModule.java similarity index 95% rename from src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/EppoModule.java index 7066377c..77f29ca8 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoModule.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoModule.java @@ -1,4 +1,4 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; import cloud.eppo.api.EppoValue; import cloud.eppo.ufc.dto.BanditParametersResponse; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueDeserializer.java similarity index 97% rename from src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueDeserializer.java index 09ec5b36..e835e72b 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueDeserializer.java @@ -1,4 +1,4 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; import cloud.eppo.api.EppoValue; import com.fasterxml.jackson.core.JsonParser; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueSerializer.java similarity index 95% rename from src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueSerializer.java index 8bd10bd6..6545146e 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/EppoValueSerializer.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/EppoValueSerializer.java @@ -1,4 +1,4 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; import cloud.eppo.api.EppoValue; import com.fasterxml.jackson.core.JsonGenerator; diff --git a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/src/test/java/cloud/eppo/helpers/dto/adapters/FlagConfigResponseDeserializer.java similarity index 98% rename from src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java rename to src/test/java/cloud/eppo/helpers/dto/adapters/FlagConfigResponseDeserializer.java index 7c48a50f..d88c2857 100644 --- a/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/dto/adapters/FlagConfigResponseDeserializer.java @@ -1,6 +1,6 @@ -package cloud.eppo.ufc.dto.adapters; +package cloud.eppo.helpers.dto.adapters; -import static cloud.eppo.Utils.parseUtcISODateNode; +import static cloud.eppo.helpers.JacksonJsonDeserializer.parseUtcISODateNode; import cloud.eppo.api.EppoValue; import cloud.eppo.model.ShardRange; diff --git a/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java b/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java index 1891923b..f30a0b49 100644 --- a/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java +++ b/src/test/java/cloud/eppo/ufc/deserializer/BanditParametersResponseDeserializerTest.java @@ -2,8 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import cloud.eppo.helpers.dto.adapters.EppoModule; 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; diff --git a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java index 24105543..193d928f 100644 --- a/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java +++ b/src/test/java/cloud/eppo/ufc/deserializer/FlagConfigResponseDeserializerTest.java @@ -2,9 +2,9 @@ import static org.junit.jupiter.api.Assertions.*; +import cloud.eppo.helpers.dto.adapters.EppoModule; import cloud.eppo.model.ShardRange; 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.FileReader;