Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GB-23 GB-24 Networking with the GBFeaturesRepository #16

Merged
merged 27 commits into from
Feb 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c27709e
base initialization of GBFeaturesRepository w/ constructor & builder
tinahollygb Feb 1, 2023
f1f06e9
add Version file to track SDK version & docs
tinahollygb Feb 1, 2023
1694c33
make unencrypted feature requests
tinahollygb Feb 2, 2023
e2dd0e3
tests for fetching features from encrypted endpoints & payloads
tinahollygb Feb 2, 2023
e9fecbc
add test for verifying user-agent header
tinahollygb Feb 2, 2023
b94de99
remove public modifier for internal constructor used for unit tests
tinahollygb Feb 2, 2023
faf2018
docs & tests for the exceptions thrown by feature repo
tinahollygb Feb 2, 2023
5ccafbe
make FeatureFetchException public
tinahollygb Feb 2, 2023
81bc3d8
add all missing doc comments
tinahollygb Feb 2, 2023
0281168
add TTL for cache refresh (unused); refactor: onSuccess(Response)
tinahollygb Feb 3, 2023
de52755
disable real network calls
tinahollygb Feb 3, 2023
d071077
separate getter for featuresJson
tinahollygb Feb 3, 2023
6610202
add auto-refresh functionality
tinahollygb Feb 3, 2023
c9156c5
trigger build?
tinahollygb Feb 3, 2023
9afafc5
try different java distro
tinahollygb Feb 3, 2023
be7f95c
call sdk update in jitpack
tinahollygb Feb 3, 2023
6839fd1
restore jitpack.yml
tinahollygb Feb 3, 2023
9dbf446
build?
tinahollygb Feb 3, 2023
4c408d6
add logging
tinahollygb Feb 3, 2023
a694841
add time-based tests for testing cache invalidation/features refreshing
tinahollygb Feb 3, 2023
e206e2c
remove logs
tinahollygb Feb 3, 2023
8c627a9
trim decrypted json
tinahollygb Feb 3, 2023
b06f027
add feature refresh callback support
tinahollygb Feb 3, 2023
9c5e2d1
move cache-related tests to separate file and disable
tinahollygb Feb 3, 2023
b37229e
all onRefresh callbacks for non-encrypted endpoints too
tinahollygb Feb 3, 2023
86b3ae7
DRY up refreshing of features; real networking tests use remote endpoint
tinahollygb Feb 14, 2023
fc7bcd5
trim after decrypting in GBContext
tinahollygb Feb 14, 2023
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- [Requirements](#requirements)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [Releasing a new version](#releasing-a-new-version)

## Requirements

Expand All @@ -16,3 +18,16 @@

- [Usage Guide](https://docs.growthbook.io/lib/java)
- [JavaDoc class documentation](https://growthbook.github.io/growthbook-sdk-java/)


## Contributing

### Releasing a new version

For now we are manually managing the version number.

When making a new release, ensure the file `growthbook/sdk/java/Version.java` has the version matching the tag and release. For example, if you are releasing version `0.3.0`, the following criteria should be met:

- the tag should be `0.3.0`
- the release should be `0.3.0`
- the contents of the `Version.java` file should include the version as `static final String SDK_VERSION = "0.3.0";`
2 changes: 1 addition & 1 deletion jitpack.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
jdk:
- openjdk17
- openjdk17
3 changes: 3 additions & 0 deletions lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ dependencies {
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation 'com.google.code.gson:gson:2.9.1'

// https://square.github.io/okhttp
implementation 'com.squareup.okhttp3:okhttp:4.10.0'

// Adds getter, setter and builder boilerplate
// https://projectlombok.org/
// https://mvnrepository.com/artifact/org.projectlombok/lombok
Expand Down
5 changes: 4 additions & 1 deletion lib/src/main/java/growthbook/sdk/java/DecryptionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public class DecryptionUtils {
/**
* INTERNAL: This class is used internally to decrypt an encrypted features response
*/
class DecryptionUtils {
public static String decrypt(String payload, String encryptionKey) {
if (!payload.contains(".")) {
throw new IllegalArgumentException("Invalid payload");
Expand Down
66 changes: 66 additions & 0 deletions lib/src/main/java/growthbook/sdk/java/FeatureFetchException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package growthbook.sdk.java;

import lombok.Getter;

/**
* This error is thrown by {@link GBFeaturesRepository}
* You can call getErrorCode() to get an enum of various error types you can handle.
*
* CONFIGURATION_ERROR:
* - an encryptionKey was provided but the endpoint does not support encryption so decryption fails
* - no features were found for an unencrypted endpoint
* NO_RESPONSE_ERROR:
* - there was no response body
* UNKNOWN:
* - there was an unknown error that occurred when attempting to make the request.
*/
public class FeatureFetchException extends Exception {

/**
* Allows you to identify an error by its unique error code.
* Separate from the custom message.
*/
@Getter
private final FeatureFetchErrorCode errorCode;

/**
* Error codes available for a {@link FeatureFetchException}
*/
public enum FeatureFetchErrorCode {
/**
* - an encryptionKey was provided but the endpoint does not support encryption so decryption fails
* - no features were found for an unencrypted endpoint
*/
CONFIGURATION_ERROR,

/**
* - there was no response body
*/
NO_RESPONSE_ERROR,

/**
* - there was an unknown error that occurred when attempting to make the request.
*/
UNKNOWN,
}


/**
* Create an exception with error code and custom message
* @param errorCode {@link FeatureFetchErrorCode}
* @param errorMessage Custom error message string
*/
public FeatureFetchException(FeatureFetchErrorCode errorCode, String errorMessage) {
super(errorCode.toString() + " : " + errorMessage);
this.errorCode = errorCode;
}

/**
* Create an exception with error code
* @param errorCode {@link FeatureFetchErrorCode}
*/
public FeatureFetchException(FeatureFetchErrorCode errorCode) {
super(errorCode.toString());
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package growthbook.sdk.java;

public interface FeatureRefreshCallback {
void onRefresh(String featuresJson);
}
2 changes: 1 addition & 1 deletion lib/src/main/java/growthbook/sdk/java/GBContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public GBContext(
if (featuresJson == null) {
this.featuresJson = "{}";
} else if (encryptionKey != null) {
this.featuresJson = DecryptionUtils.decrypt(featuresJson, encryptionKey);
this.featuresJson = DecryptionUtils.decrypt(featuresJson, encryptionKey).trim();
} else {
this.featuresJson = featuresJson;
}
Expand Down
260 changes: 260 additions & 0 deletions lib/src/main/java/growthbook/sdk/java/GBFeaturesRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package growthbook.sdk.java;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import lombok.Builder;
import lombok.Getter;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;

import javax.annotation.Nullable;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
* This class can be created with its `builder()` or constructor.
* It will fetch the features from the endpoint provided.
* Initialize with {@link GBFeaturesRepository#initialize()}
* Get the features JSON with {@link GBFeaturesRepository#getFeaturesJson()}.
* You would provide the features JSON when creating the {@link GBContext}
*/
public class GBFeaturesRepository implements IGBFeaturesRepository {

@Getter
private final String endpoint;

@Nullable @Getter
private final String encryptionKey;

@Getter
private final Integer ttlSeconds;

@Getter
private Long expiresAt;

private final OkHttpClient okHttpClient;

private final ArrayList<FeatureRefreshCallback> refreshCallbacks = new ArrayList<>();

/**
* Allows you to get the features JSON from the provided {@link GBFeaturesRepository#getEndpoint()}.
* You must call {@link GBFeaturesRepository#initialize()} before calling this method
* or your features would not have loaded.
*/
private String featuresJson = "{}";

/**
* Create a new GBFeaturesRepository
* @param endpoint SDK Endpoint URL
* @param encryptionKey optional key for decrypting encrypted payload
* @param ttlSeconds How often the cache should be invalidated (default: 60)
*/
@Builder
public GBFeaturesRepository(
String endpoint,
@Nullable String encryptionKey,
@Nullable Integer ttlSeconds
) {
if (endpoint == null) {
throw new IllegalArgumentException("endpoint cannot be null");
}

this.endpoint = endpoint;
this.encryptionKey = encryptionKey;
this.ttlSeconds = ttlSeconds == null ? 60 : ttlSeconds;
this.refreshExpiresAt();
this.okHttpClient = this.initializeHttpClient();
}

/**
* INTERNAL: This constructor is for using for unit tests
* @param okHttpClient mock HTTP client
* @param endpoint SDK Endpoint URL
* @param encryptionKey optional key for decrypting encrypted payload
*/
GBFeaturesRepository(
OkHttpClient okHttpClient,
@Nullable String endpoint,
@Nullable String encryptionKey,
@Nullable Integer ttlSeconds
) {
this.encryptionKey = encryptionKey;
this.endpoint = endpoint;
this.ttlSeconds = ttlSeconds == null ? 60 : ttlSeconds;
this.refreshExpiresAt();
this.okHttpClient = okHttpClient;
}

public String getFeaturesJson() {
if (isCacheExpired()) {
this.enqueueFeatureRefreshRequest();
this.refreshExpiresAt();
}

return this.featuresJson;
}

@Override
public void onFeaturesRefresh(FeatureRefreshCallback callback) {
this.refreshCallbacks.add(callback);
}

@Override
public void clearCallbacks() {
this.refreshCallbacks.clear();
}

private void enqueueFeatureRefreshRequest() {
GBFeaturesRepository self = this;

Request request = new Request.Builder()
.url(this.endpoint)
.build();

this.okHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NotNull Call call, @NotNull IOException e) {
// OkHttp will auto-retry on failure
}

@Override
public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
try {
self.onSuccess(response);
} catch (FeatureFetchException e) {
e.printStackTrace();
}
}
});
}

@Override
public void initialize() throws FeatureFetchException {
fetchFeatures();
}

private OkHttpClient initializeHttpClient() {
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new GBFeaturesRepositoryRequestInterceptor())
.retryOnConnectionFailure(true)
.build();

return client;
}

private void refreshExpiresAt() {
this.expiresAt = Instant.now().getEpochSecond() + this.ttlSeconds;
}

private Boolean isCacheExpired() {
long now = Instant.now().getEpochSecond();
return now >= this.expiresAt;
}

/**
* Performs a network request to fetch the features from the GrowthBook API
* with the provided endpoint.
* If an encryptionKey is provided, it is assumed the features endpoint is using encrypted features.
* This method will attempt to decrypt the encrypted features with the provided encryptionKey.
*/
private void fetchFeatures() throws FeatureFetchException {
if (this.endpoint == null) {
throw new IllegalArgumentException("endpoint cannot be null");
}

Request request = new Request.Builder()
.url(this.endpoint)
.build();

try (Response response = this.okHttpClient.newCall(request).execute()) {
this.onSuccess(response);
} catch (IOException e) {
e.printStackTrace();

throw new FeatureFetchException(
FeatureFetchException.FeatureFetchErrorCode.UNKNOWN,
e.getMessage()
);
}
}

/**
* Handles the successful features fetching response
* @param response Successful response
*/
private void onSuccess(Response response) throws FeatureFetchException {
try {
ResponseBody responseBody = response.body();
if (responseBody == null) {
throw new FeatureFetchException(
FeatureFetchException.FeatureFetchErrorCode.NO_RESPONSE_ERROR
);
}

JsonObject jsonObject = GrowthBookJsonUtils.getInstance()
.gson.fromJson(responseBody.string(), JsonObject.class);

// Features will be refreshed as either an encrypted or un-encrypted JSON string
String refreshedFeatures;

if (this.encryptionKey != null) {
// Use encrypted features at responseBody.encryptedFeatures
JsonElement encryptedFeaturesJsonElement = jsonObject.get("encryptedFeatures");
if (encryptedFeaturesJsonElement == null) {
throw new FeatureFetchException(
FeatureFetchException.FeatureFetchErrorCode.CONFIGURATION_ERROR,
"encryptionKey provided but endpoint not encrypted"
);
}

String encryptedFeaturesJson = encryptedFeaturesJsonElement.getAsString();
refreshedFeatures = DecryptionUtils.decrypt(encryptedFeaturesJson, this.encryptionKey).trim();
} else {
// Use unencrypted features at responseBody.features
JsonElement featuresJsonElement = jsonObject.get("features");
if (featuresJsonElement == null) {
throw new FeatureFetchException(
FeatureFetchException.FeatureFetchErrorCode.CONFIGURATION_ERROR,
"No features found"
);
}

refreshedFeatures = featuresJsonElement.toString().trim();
}

this.featuresJson = refreshedFeatures;

this.refreshCallbacks.forEach(featureRefreshCallback -> {
featureRefreshCallback.onRefresh(this.featuresJson);
});
} catch (IOException e) {
e.printStackTrace();

throw new FeatureFetchException(
FeatureFetchException.FeatureFetchErrorCode.UNKNOWN,
e.getMessage()
);
}
}



/**
* Appends User-Agent info to the request headers.
*/
private static class GBFeaturesRepositoryRequestInterceptor implements Interceptor {

@NotNull
@Override
public Response intercept(@NotNull Chain chain) throws IOException {
Request modifiedRequest = chain.request()
.newBuilder()
.header("User-Agent", "growthbook-sdk-java/" + Version.SDK_VERSION)
.build();

return chain.proceed(modifiedRequest);
}
}
}
Loading