Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3'
implementation 'com.github.zafarkhaja:java-semver:0.10.2'
implementation "com.squareup.okhttp3:okhttp:4.12.0"
// For base64 that works on on Android API 21
implementation "net.iharder:base64:2.3.8"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think dropping in a mutually compatible library is fine. However, we faced a similar challenge with PHP and caching, and opted to have the upstream SDK essentially pass in the library. I wonder if we should consider doing similar here, basically having this SDK take an interface that can Base64 encode/decode and then Java SDK will pass in something that uses java.util.Base64 and Android SDK something that uses android.util.Base64?

Note it seems you're doing something similar for JSON mapping

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of that too, but since this is a static method, it will require me to add a lot more indirection in the SDK. If you're ok with that level of indirection, I can add it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added indirection is OK.
What do you think about a "compatibility" class to capture everything, like

interface EppoApiCompat {
String base64Encode(...)}
FlagConfigResponse parseUfcJson(String...)
...
// could do string joining, but the algo is simple enough to just have our own loop.
String joinStrings(String[] arrayOfStrings){...}
}

It can be an param to the EppoClient constructor and be passed around as needed. Test harnesses can use a default implementation based on java.util here (or android.util in android-sdk). wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me. We're still investigating whether we want to modify the Eppo SDK further, so I won't make this update yet, but if we decide to press forward, I'll definitely incorporate this change.

// For LRU and expiring maps
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'org.slf4j:slf4j-api:2.0.17'
Expand Down
61 changes: 55 additions & 6 deletions src/main/java/cloud/eppo/BaseEppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

import cloud.eppo.api.*;
import cloud.eppo.cache.AssignmentCacheEntry;
import cloud.eppo.json.JacksonMapper;
import cloud.eppo.json.JacksonMapperNode;
import cloud.eppo.json.Mapper;
import cloud.eppo.json.MapperException;
import cloud.eppo.json.MapperJsonProcessingException;
import cloud.eppo.json.MapperNode;
import cloud.eppo.logging.Assignment;
import cloud.eppo.logging.AssignmentLogger;
import cloud.eppo.logging.BanditAssignment;
Expand All @@ -27,9 +33,7 @@

public class BaseEppoClient {
private static final Logger log = LoggerFactory.getLogger(BaseEppoClient.class);
private final ObjectMapper mapper =
new ObjectMapper()
.registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this?
private final Mapper mapper;

protected final ConfigurationRequestor requestor;

Expand Down Expand Up @@ -73,6 +77,46 @@ protected BaseEppoClient(
@Nullable CompletableFuture<Configuration> initialConfiguration,
@Nullable IAssignmentCache assignmentCache,
@Nullable IAssignmentCache banditAssignmentCache) {
this(
new JacksonMapper(),
apiKey,
sdkName,
sdkVersion,
host,
apiBaseUrl,
assignmentLogger,
banditLogger,
configurationStore,
isGracefulMode,
expectObfuscatedConfig,
supportBandits,
initialConfiguration,
assignmentCache,
banditAssignmentCache
);
}

// It is important that the bandit assignment cache expire with a short-enough TTL to last about
// one user session.
// The recommended is 10 minutes (per @Sven)
/** @param host To be removed in v4. use `apiBaseUrl` instead. */
protected BaseEppoClient(
@NotNull Mapper mapper,
@NotNull String apiKey,
@NotNull String sdkName,
@NotNull String sdkVersion,
@Deprecated @Nullable String host,
@Nullable String apiBaseUrl,
@Nullable AssignmentLogger assignmentLogger,
@Nullable BanditLogger banditLogger,
@Nullable IConfigurationStore configurationStore,
boolean isGracefulMode,
boolean expectObfuscatedConfig,
boolean supportBandits,
@Nullable CompletableFuture<Configuration> initialConfiguration,
@Nullable IAssignmentCache assignmentCache,
@Nullable IAssignmentCache banditAssignmentCache) {
this.mapper = mapper;

if (apiKey == null) {
throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key");
Expand Down Expand Up @@ -436,7 +480,12 @@ public JsonNode getJSONAssignment(
subjectAttributes,
EppoValue.valueOf(defaultValue.toString()),
VariationType.JSON);
return parseJsonString(value.stringValue());
MapperNode mapperNode = parseJsonString(value.stringValue());
if (mapperNode != null) {
return ((JacksonMapperNode) mapperNode).getJsonNode();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want to stay away from Jackson-specific stuff here?

I vaguely recall one of the main reasons we used Jackson is because it could more easily differentiate between arrays and objects than Java's built-in JSON parsing. But if we code around that, we may be able to remove Jackson entirely. Unless it's much slower on parsing, which may matter for Android app initialization times. In which case, I think upstream-provided parser as you're doing here may be the move.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this API returns a Jackson-specific type, we must rely on Jackson in this specific method. I might create a parallel method that returns a MapperNode when I finalize this PR

} else {
return null;
}
} catch (Exception e) {
return throwIfNotGraceful(e, defaultValue);
}
Expand Down Expand Up @@ -482,10 +531,10 @@ public String getJSONStringAssignment(String flagKey, String subjectKey, String
return this.getJSONStringAssignment(flagKey, subjectKey, new Attributes(), defaultValue);
}

private JsonNode parseJsonString(String jsonString) {
private MapperNode parseJsonString(String jsonString) {
try {
return mapper.readTree(jsonString);
} catch (JsonProcessingException e) {
} catch (MapperJsonProcessingException e) {
return null;
}
}
Expand Down
99 changes: 99 additions & 0 deletions src/main/java/cloud/eppo/ConfigurationRequestorJava6.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cloud.eppo;

import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

import cloud.eppo.api.Configuration;
import cloud.eppo.callback.CallbackManager;

public class ConfigurationRequestorJava6 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To help maximize code reuse and prevent drift, what if this extends the non-java-6 flavor and then uses the template pattern, with only the language level differences overwritten in the ConfigurationRequestorJava6 class?

Note: I don't know if this is possible for building. A quick chat with AI suggests its not, and what you have is the way to go 😞

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we end up in callback hell if we just refactored the core to use callbacks instead of CompletableFutures? The initialization code is certainly cleaner with CompletableFutures

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd actually like to get rid of CompletableFuture if possible and provide callbacks because CompletableFuture relies on the ForkJoin Pool, and clients would probably like to minimize the number of thread pools in their apps.

You could certainly provide wrapper APIs that expose CompletableFuture, but some clients are going to use RxJava (which has its own thread pools), and some will use Kotlin coroutines (which has its own thread pools). I imagine customers would like to avoid creating one more thread at startup if possible.

A simple way to do this would be to provide a synchronous API like I did here, and then provide a CompletableFuture wrapper around it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that moving a direction of un-opinionated (i.e. un thread-pool coupled) async/futures/callbacks is ideal in an SDK that is used across such a vast array of deployments.

In your opinion, what is the "async" API that most java developers would prefer to see on the SDK? Callbacks, CompletableFutures or something else? I am eager to get your feedback here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think callbacks make the most sense, and then if you wanted to go for extra credit, you could publish extension libraries with RxJava2, RxJava3, Kotlin Coroutines, and CompletableFutures bindings.

Modern Android developers are mostly using Kotlin Coroutines now. RxJava2 was common until about 2020, so there are probably still a lot of apps out there using it. I don't think RxJava3 is very common because it came out around the same time as Kotlin Coroutines.

private static final Logger log = LoggerFactory.getLogger(ConfigurationRequestorJava6.class);
private enum ConfigState {
UNSET,
INITIAL_SET,
REMOTE_SET,
;
}

private final EppoHttpClient client;
private final IConfigurationStoreJava6 configurationStoreJava6;
private final boolean expectObfuscatedConfig;
private final boolean supportBandits;

private final Object configStateLock = new Object();
private ConfigState configState;

private final CallbackManager<Configuration> configChangeManager = new CallbackManager<>();

public ConfigurationRequestorJava6(
@NotNull IConfigurationStoreJava6 configurationStoreJava6,
@NotNull EppoHttpClient client,
boolean expectObfuscatedConfig,
boolean supportBandits) {
this.configurationStoreJava6 = configurationStoreJava6;
this.client = client;
this.expectObfuscatedConfig = expectObfuscatedConfig;
this.supportBandits = supportBandits;

synchronized (configStateLock) {
configState = ConfigState.UNSET;
}
}

// Synchronously set the initial configuration.
public boolean setInitialConfigurationJava6(@NotNull Configuration configuration) {
synchronized (configStateLock) {
switch(configState) {
case UNSET:
configState = ConfigState.INITIAL_SET;
break;
case INITIAL_SET:
throw new IllegalStateException("Initial configuration has already been set");
case REMOTE_SET:
return false;
}
}

saveConfigurationAndNotifyJava6(configuration);
return true;
}

/** Loads configuration synchronously from the API server. */
void fetchAndSaveFromRemoteJava6() {
log.debug("Fetching configuration");

// Reuse the `lastConfig` as its bandits may be useful
Configuration lastConfig = configurationStoreJava6.getConfiguration();

byte[] flagConfigurationJsonBytes = client.getJava6(Constants.FLAG_CONFIG_ENDPOINT);
Configuration.Builder configBuilder =
Configuration.builder(flagConfigurationJsonBytes, expectObfuscatedConfig)
.banditParametersFromConfig(lastConfig);

if (supportBandits && configBuilder.requiresUpdatedBanditModels()) {
byte[] banditParametersJsonBytes = client.getJava6(Constants.BANDIT_ENDPOINT);
configBuilder.banditParameters(banditParametersJsonBytes);
}

synchronized (configStateLock) {
configState = ConfigState.REMOTE_SET;
}
saveConfigurationAndNotifyJava6(configBuilder.build());
}

private void saveConfigurationAndNotifyJava6(Configuration configuration) {
configurationStoreJava6.saveConfigurationJava6(configuration);
synchronized (configChangeManager) {
configChangeManager.notifyCallbacks(configuration);
}
}

public Runnable onConfigurationChange(Consumer<Configuration> callback) {
return configChangeManager.subscribe(callback);
}
}
24 changes: 24 additions & 0 deletions src/main/java/cloud/eppo/ConfigurationStoreJava6.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cloud.eppo;

import org.jetbrains.annotations.NotNull;

import java.util.concurrent.CompletableFuture;

import cloud.eppo.api.Configuration;

/** Memory-only configuration store. */
public class ConfigurationStoreJava6 implements IConfigurationStoreJava6 {

// this is the fallback value if no configuration is provided (i.e. by fetch or initial config).
@NotNull private volatile Configuration configuration = Configuration.emptyConfig();

public ConfigurationStoreJava6() {}

public void saveConfigurationJava6(@NotNull final Configuration configuration) {
this.configuration = configuration;
}

@NotNull public Configuration getConfiguration() {
return configuration;
}
}
39 changes: 39 additions & 0 deletions src/main/java/cloud/eppo/EppoHttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.HttpUrl;
Expand Down Expand Up @@ -54,6 +56,43 @@ public byte[] get(String path) {
}
}

public byte[] getJava6(String path) {
Request request = buildRequest(path);
Call call = client.newCall(request);
Response response = null;
try {
response = call.execute();
try {
if (response.isSuccessful() && response.body() != null) {
log.debug("Fetch successful");
return response.body().bytes();
} else {
if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) {
new RuntimeException("Invalid API key");
} else {
log.debug("Fetch failed with status code: {}", response.code());
new RuntimeException("Bad response from URL " + request.url());
}
}
} catch (IOException ex) {
new RuntimeException(
"Failed to read response from URL {}" + request.url(), ex);
} finally {
if (response != null) {
response.close();
}
}
} catch (IOException e) {
log.error(
"Http request failure: {} {}",
e.getMessage(),
Arrays.toString(e.getStackTrace()),
e);
new RuntimeException("Unable to fetch from URL " + request.url());
}
throw new RuntimeException("Java compiler not smart enough to know all paths are covered");
}

public CompletableFuture<byte[]> getAsync(String path) {
CompletableFuture<byte[]> future = new CompletableFuture<>();
Request request = buildRequest(path);
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/cloud/eppo/IConfigurationStoreJava6.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cloud.eppo;

import org.jetbrains.annotations.NotNull;

import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

import cloud.eppo.api.Configuration;

/**
* Common interface for extensions of this SDK to support caching and other strategies for
* persisting configuration data across sessions.
*/
public interface IConfigurationStoreJava6 {
@NotNull Configuration getConfiguration();

void saveConfigurationJava6(Configuration configuration);
}
16 changes: 13 additions & 3 deletions src/main/java/cloud/eppo/Utils.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cloud.eppo;

import com.fasterxml.jackson.databind.JsonNode;

import net.iharder.Base64;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date;
import java.util.Locale;
import org.slf4j.Logger;
Expand Down Expand Up @@ -105,14 +108,21 @@ public static String base64Encode(String input) {
if (input == null) {
return null;
}
return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8)));
return Base64.encodeBytes(input.getBytes(StandardCharsets.UTF_8));
}

public static String base64Decode(String input) {
if (input == null) {
return null;
}
byte[] decodedBytes = Base64.getDecoder().decode(input);
byte[] decodedBytes;
try {
decodedBytes = Base64.decode(input);
} catch (IOException rethrow) {
// java.util.Base64 throws IllegalArgumentException
// on base64 format errors
throw new IllegalArgumentException(rethrow);
}
if (decodedBytes.length == 0 && !input.isEmpty()) {
throw new RuntimeException(
"zero byte output from Base64; if not running on Android hardware be sure to use RobolectricTestRunner");
Expand Down
Loading