From 8dae94dd92d0e483d8d7db03182e736ed098ce08 Mon Sep 17 00:00:00 2001 From: Tina Holly <113377031+tinahollygb@users.noreply.github.com> Date: Tue, 1 Aug 2023 15:45:26 -0700 Subject: [PATCH] Server-Sent Events (SSE) support (#36) * WIP realtime features repo * add SSE config to existing GBFeaturesRepository * interceptor notes * fix real networking test * WIP: event stream connection * add error for SSE * add constructor w/o okHttpClient * no timeout for SSE connections * add test server to test SSE refreshing * implement retry functionality * fallback to SWR refresh strategy * refactor: separate method to process response JSON string * update features json from SSE payload * remove custom http client from the builder class * rm log statements * bump version to 0.9.0 --- lib/build.gradle | 4 +- .../sdk/java/FeatureFetchException.java | 5 + .../sdk/java/FeatureRefreshStrategy.java | 6 + .../sdk/java/GBFeaturesRepository.java | 282 ++++++++++++++---- ...BFeaturesRepositoryRequestInterceptor.java | 24 ++ .../java/growthbook/sdk/java/Version.java | 2 +- .../GBFeaturesRepositoryRefreshingTest.java | 19 +- .../sdk/java/GBFeaturesRepositoryTest.java | 64 ++-- .../sdk/java/testhelpers/SSETestServer.java | 71 +++++ 9 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 lib/src/main/java/growthbook/sdk/java/FeatureRefreshStrategy.java create mode 100644 lib/src/main/java/growthbook/sdk/java/GBFeaturesRepositoryRequestInterceptor.java create mode 100644 lib/src/test/java/growthbook/sdk/java/testhelpers/SSETestServer.java diff --git a/lib/build.gradle b/lib/build.gradle index 84090e0a..ef48762d 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -38,7 +38,9 @@ dependencies { implementation 'com.google.code.gson:gson:2.9.1' // https://square.github.io/okhttp - implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp-sse + implementation 'com.squareup.okhttp3:okhttp-sse:4.11.0' // Adds getter, setter and builder boilerplate // https://projectlombok.org/ diff --git a/lib/src/main/java/growthbook/sdk/java/FeatureFetchException.java b/lib/src/main/java/growthbook/sdk/java/FeatureFetchException.java index d89c9d02..030e78b0 100644 --- a/lib/src/main/java/growthbook/sdk/java/FeatureFetchException.java +++ b/lib/src/main/java/growthbook/sdk/java/FeatureFetchException.java @@ -38,6 +38,11 @@ public enum FeatureFetchErrorCode { */ NO_RESPONSE_ERROR, + /** + * - could not establish a connection to the events (server-sent events) for feature updates + */ + SSE_CONNECTION_ERROR, + /** * - there was an unknown error that occurred when attempting to make the request. */ diff --git a/lib/src/main/java/growthbook/sdk/java/FeatureRefreshStrategy.java b/lib/src/main/java/growthbook/sdk/java/FeatureRefreshStrategy.java new file mode 100644 index 00000000..30441b3d --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/FeatureRefreshStrategy.java @@ -0,0 +1,6 @@ +package growthbook.sdk.java; + +public enum FeatureRefreshStrategy { + STALE_WHILE_REVALIDATE, + SERVER_SENT_EVENTS +} diff --git a/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepository.java b/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepository.java index ba9b916c..ddde510a 100644 --- a/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepository.java +++ b/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepository.java @@ -5,13 +5,17 @@ import lombok.Builder; import lombok.Getter; import okhttp3.*; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; 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; +import java.util.Objects; +import java.util.concurrent.TimeUnit; /** * This class can be created with its `builder()` or constructor. @@ -23,23 +27,38 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { @Getter - private final String endpoint; + private final String featuresEndpoint; + + @Getter + private final String eventsEndpoint; + + @Getter + private FeatureRefreshStrategy refreshStrategy; @Nullable @Getter private final String encryptionKey; @Getter - private final Integer ttlSeconds; + private final Integer swrTtlSeconds; @Getter private Long expiresAt; private final OkHttpClient okHttpClient; + @Nullable + private OkHttpClient sseHttpClient; + private final ArrayList refreshCallbacks = new ArrayList<>(); + private Boolean initialized = false; + + private Boolean sseAllowed = false; + @Nullable private Request sseRequest = null; + @Nullable private EventSource sseEventSource = null; + /** - * Allows you to get the features JSON from the provided {@link GBFeaturesRepository#getEndpoint()}. + * Allows you to get the features JSON from the provided {@link GBFeaturesRepository#getFeaturesEndpoint()}. * You must call {@link GBFeaturesRepository#initialize()} before calling this method * or your features would not have loaded. */ @@ -47,50 +66,74 @@ public class GBFeaturesRepository implements IGBFeaturesRepository { /** * Create a new GBFeaturesRepository - * @param endpoint SDK Endpoint URL + * @param apiHost The GrowthBook API host (default: https://cdn.growthbook.io) + * @param clientKey Your client ID, e.g. sdk-abc123 * @param encryptionKey optional key for decrypting encrypted payload - * @param ttlSeconds How often the cache should be invalidated (default: 60) + * @param swrTtlSeconds How often the cache should be invalidated when using {@link FeatureRefreshStrategy#STALE_WHILE_REVALIDATE} (default: 60) */ @Builder public GBFeaturesRepository( - String endpoint, + @Nullable String apiHost, + String clientKey, @Nullable String encryptionKey, - @Nullable Integer ttlSeconds + @Nullable FeatureRefreshStrategy refreshStrategy, + @Nullable Integer swrTtlSeconds ) { - 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(); + this(apiHost, clientKey, encryptionKey, refreshStrategy, swrTtlSeconds, null); } /** - * INTERNAL: This constructor is for using for unit tests - * @param okHttpClient mock HTTP client - * @param endpoint SDK Endpoint URL + * Create a new GBFeaturesRepository + * @param apiHost The GrowthBook API host (default: https://cdn.growthbook.io) + * @param clientKey Your client ID, e.g. sdk-abc123 * @param encryptionKey optional key for decrypting encrypted payload + * @param swrTtlSeconds How often the cache should be invalidated when using {@link FeatureRefreshStrategy#STALE_WHILE_REVALIDATE} (default: 60) + * @param okHttpClient HTTP client (optional) */ - GBFeaturesRepository( - OkHttpClient okHttpClient, - @Nullable String endpoint, + public GBFeaturesRepository( + @Nullable String apiHost, + String clientKey, @Nullable String encryptionKey, - @Nullable Integer ttlSeconds + @Nullable FeatureRefreshStrategy refreshStrategy, + @Nullable Integer swrTtlSeconds, + @Nullable OkHttpClient okHttpClient ) { + if (clientKey == null) throw new IllegalArgumentException("clientKey cannot be null"); + + // Set the defaults when the user does not provide them + if (apiHost == null) { + apiHost = "https://cdn.growthbook.io"; + } + this.refreshStrategy = refreshStrategy == null ? FeatureRefreshStrategy.STALE_WHILE_REVALIDATE : refreshStrategy; + + // Build the endpoints from the apiHost and clientKey + this.featuresEndpoint = apiHost + "/api/features/" + clientKey; + this.eventsEndpoint = apiHost + "/sub/" + clientKey; + this.encryptionKey = encryptionKey; - this.endpoint = endpoint; - this.ttlSeconds = ttlSeconds == null ? 60 : ttlSeconds; + this.swrTtlSeconds = swrTtlSeconds == null ? 60 : swrTtlSeconds; this.refreshExpiresAt(); - this.okHttpClient = okHttpClient; + + // Use provided OkHttpClient or create a new one + if (okHttpClient == null) { + this.okHttpClient = this.initializeHttpClient(); + } else { + // TODO: Check for valid interceptor + this.okHttpClient = okHttpClient; + } } public String getFeaturesJson() { - if (isCacheExpired()) { - this.enqueueFeatureRefreshRequest(); - this.refreshExpiresAt(); + switch (this.refreshStrategy) { + case STALE_WHILE_REVALIDATE: + if (isCacheExpired()) { + this.enqueueFeatureRefreshRequest(); + this.refreshExpiresAt(); + } + return this.featuresJson; + + case SERVER_SENT_EVENTS: + return this.featuresJson; } return this.featuresJson; @@ -117,7 +160,7 @@ private void enqueueFeatureRefreshRequest() { GBFeaturesRepository self = this; Request request = new Request.Builder() - .url(this.endpoint) + .url(this.featuresEndpoint) .build(); this.okHttpClient.newCall(request).enqueue(new Callback() { @@ -139,9 +182,87 @@ public void onResponse(@NotNull Call call, @NotNull Response response) throws IO @Override public void initialize() throws FeatureFetchException { - fetchFeatures(); + if (this.initialized) return; + + switch (this.refreshStrategy) { + case STALE_WHILE_REVALIDATE: + fetchFeatures(); + break; + + case SERVER_SENT_EVENTS: + fetchFeatures(); + initializeSSE(); + break; + } + + this.initialized = true; } + private void initializeSSE() { + if (!this.sseAllowed) { + System.out.printf("\nFalling back to stale-while-revalidate refresh strategy. 'X-Sse-Support: enabled' not present on resource returned at %s", this.featuresEndpoint); + this.refreshStrategy = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE; + } + + createEventSourceListenerAndStartListening(); + } + + /** + * Creates an SSE HTTP client if null. + * Creates and enqueues a new asynchronous request to the events endpoint. + * Assigns a close listener to recreate the connection. + */ + private void createEventSourceListenerAndStartListening() { + this.sseEventSource = null; + this.sseRequest = null; + + if (this.sseHttpClient == null) { + this.sseHttpClient = new OkHttpClient.Builder() + .addInterceptor(new GBFeaturesRepositoryRequestInterceptor()) + .retryOnConnectionFailure(true) + .connectTimeout(0, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .writeTimeout(0, TimeUnit.SECONDS) + .build(); + } + + this.sseRequest = new Request.Builder() + .url(this.eventsEndpoint) + .header("Accept", "application/json; q=0.5") + .addHeader("Accept", "text/event-stream") + .build(); + + this.sseEventSource = EventSources + .createFactory(this.sseHttpClient) + .newEventSource(sseRequest, new GBEventSourceListener(new GBEventSourceHandler() { + @Override + public void onClose(EventSource eventSource) { + eventSource.cancel(); + createEventSourceListenerAndStartListening(); + } + + @Override + public void onFeaturesResponse(String featuresJsonResponse) throws FeatureFetchException { + onResponseJson(featuresJsonResponse); + } + })); + this.sseHttpClient.newCall(sseRequest).enqueue(new Callback() { + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + System.out.println("SSE connection failed"); + e.printStackTrace(); + } + + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { + // We don't do anything with this response + } + }); + } + + /** + * @return A new {@link OkHttpClient} with an interceptor {@link GBFeaturesRepositoryRequestInterceptor} + */ private OkHttpClient initializeHttpClient() { OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(new GBFeaturesRepositoryRequestInterceptor()) @@ -152,7 +273,7 @@ private OkHttpClient initializeHttpClient() { } private void refreshExpiresAt() { - this.expiresAt = Instant.now().getEpochSecond() + this.ttlSeconds; + this.expiresAt = Instant.now().getEpochSecond() + this.swrTtlSeconds; } private Boolean isCacheExpired() { @@ -167,15 +288,18 @@ private Boolean isCacheExpired() { * 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"); + if (this.featuresEndpoint == null) { + throw new IllegalArgumentException("features endpoint cannot be null"); } Request request = new Request.Builder() - .url(this.endpoint) + .url(this.featuresEndpoint) .build(); try (Response response = this.okHttpClient.newCall(request).execute()) { + String sseSupportHeader = response.header("x-sse-support"); + this.sseAllowed = Objects.equals(sseSupportHeader, "enabled"); + this.onSuccess(response); } catch (IOException e) { e.printStackTrace(); @@ -188,20 +312,13 @@ private void fetchFeatures() throws FeatureFetchException { } /** - * Handles the successful features fetching response - * @param response Successful response + * Reads the response JSON properties `features` or `encryptedFeatures`, and decrypts if necessary + * @param responseJsonString JSON response object */ - private void onSuccess(Response response) throws FeatureFetchException { + private void onResponseJson(String responseJsonString) 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); + .gson.fromJson(responseJsonString, JsonObject.class); // Features will be refreshed as either an encrypted or un-encrypted JSON string String refreshedFeatures; @@ -236,7 +353,7 @@ private void onSuccess(Response response) throws FeatureFetchException { this.refreshCallbacks.forEach(featureRefreshCallback -> { featureRefreshCallback.onRefresh(this.featuresJson); }); - } catch (IOException | DecryptionUtils.DecryptionException e) { + } catch (DecryptionUtils.DecryptionException e) { e.printStackTrace(); throw new FeatureFetchException( @@ -246,22 +363,69 @@ private void onSuccess(Response response) throws FeatureFetchException { } } - - /** - * Appends User-Agent info to the request headers. + * Handles the successful features fetching response + * @param response Successful response */ - private static class GBFeaturesRepositoryRequestInterceptor implements Interceptor { + private void onSuccess(Response response) throws FeatureFetchException { + try { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new FeatureFetchException( + FeatureFetchException.FeatureFetchErrorCode.NO_RESPONSE_ERROR + ); + } + + onResponseJson(responseBody.string()); + } catch (IOException e) { + e.printStackTrace(); + + throw new FeatureFetchException( + FeatureFetchException.FeatureFetchErrorCode.UNKNOWN, + e.getMessage() + ); + } + } + + private interface GBEventSourceHandler { + void onClose(EventSource eventSource); + void onFeaturesResponse(String featuresJsonResponse) throws FeatureFetchException; + } + + private static class GBEventSourceListener extends EventSourceListener { + private final GBEventSourceHandler handler; + + public GBEventSourceListener(GBEventSourceHandler handler) { + this.handler = handler; + } - @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(); + public void onClosed(@NotNull EventSource eventSource) { + super.onClosed(eventSource); + handler.onClose(eventSource); + } + + @Override + public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) { + super.onEvent(eventSource, id, type, data); + + if (data.trim().equals("")) return; + + try { + handler.onFeaturesResponse(data); + } catch (FeatureFetchException e) { + e.printStackTrace(); + } + } + + @Override + public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { + super.onFailure(eventSource, t, response); + } - return chain.proceed(modifiedRequest); + @Override + public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { + super.onOpen(eventSource, response); } } } diff --git a/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepositoryRequestInterceptor.java b/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepositoryRequestInterceptor.java new file mode 100644 index 00000000..14bc23a0 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/GBFeaturesRepositoryRequestInterceptor.java @@ -0,0 +1,24 @@ +package growthbook.sdk.java; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +/** + * Appends User-Agent info to the request headers. + */ +public class GBFeaturesRepositoryRequestInterceptor implements Interceptor { + @NotNull + @Override + public Response intercept(@NotNull Interceptor.Chain chain) throws IOException { + Request modifiedRequest = chain.request() + .newBuilder() + .header("User-Agent", "growthbook-sdk-java/" + Version.SDK_VERSION) + .build(); + + return chain.proceed(modifiedRequest); + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/Version.java b/lib/src/main/java/growthbook/sdk/java/Version.java index 26a2402d..95798476 100644 --- a/lib/src/main/java/growthbook/sdk/java/Version.java +++ b/lib/src/main/java/growthbook/sdk/java/Version.java @@ -6,5 +6,5 @@ public class Version { private Version() {} - static final String SDK_VERSION = "0.5.0"; + static final String SDK_VERSION = "0.9.0"; } diff --git a/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryRefreshingTest.java b/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryRefreshingTest.java index d3b9b071..9b223fbc 100644 --- a/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryRefreshingTest.java +++ b/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryRefreshingTest.java @@ -33,10 +33,12 @@ void refreshesFeaturesWhenGetFeaturesCalledAfterCacheExpired() throws IOExceptio String encryptionKey = "o0maZL/O7AphxcbRvaJIzw=="; OkHttpClient mockOkHttpClient = mockHttpClient(fakeResponseJson); GBFeaturesRepository subject = new GBFeaturesRepository( - mockOkHttpClient, "http://localhost:80", + "sdk-abc123", encryptionKey, - ttlSeconds + FeatureRefreshStrategy.STALE_WHILE_REVALIDATE, + ttlSeconds, + mockOkHttpClient ); subject.initialize(); @@ -65,11 +67,14 @@ void doesNotRefreshFeaturesWhenGetFeaturesCalledWithinCacheTime() throws IOExcep "}"; String encryptionKey = "o0maZL/O7AphxcbRvaJIzw=="; OkHttpClient mockOkHttpClient = mockHttpClient(fakeResponseJson); + GBFeaturesRepository subject = new GBFeaturesRepository( - mockOkHttpClient, "http://localhost:80", + "sdk-abc123", encryptionKey, - ttlSeconds + FeatureRefreshStrategy.STALE_WHILE_REVALIDATE, + ttlSeconds, + mockOkHttpClient ); subject.initialize(); @@ -98,10 +103,12 @@ void refreshesFeaturesWhenGetFeaturesCalledAfterCacheExpired_multipleTimes() thr String encryptionKey = "o0maZL/O7AphxcbRvaJIzw=="; OkHttpClient mockOkHttpClient = mockHttpClient(fakeResponseJson); GBFeaturesRepository subject = new GBFeaturesRepository( - mockOkHttpClient, "http://localhost:80", + "sdk-abc123", encryptionKey, - ttlSeconds + FeatureRefreshStrategy.STALE_WHILE_REVALIDATE, + ttlSeconds, + mockOkHttpClient ); subject.initialize(); diff --git a/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java b/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java index ae669291..65c0390e 100644 --- a/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java +++ b/lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java @@ -5,8 +5,7 @@ import java.io.IOException; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -16,25 +15,32 @@ class GBFeaturesRepositoryTest { @Test void canBeConstructed_withNullEncryptionKey() { GBFeaturesRepository subject = new GBFeaturesRepository( - "https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", + "https://cdn.growthbook.io", + "java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", + null, + null, null, null ); assertNotNull(subject); - assertEquals("https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", subject.getEndpoint()); + assertEquals("https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", subject.getFeaturesEndpoint()); + assertEquals(FeatureRefreshStrategy.STALE_WHILE_REVALIDATE, subject.getRefreshStrategy()); } @Test void canBeConstructed_withEncryptionKey() { GBFeaturesRepository subject = new GBFeaturesRepository( - "https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD", + "https://cdn.growthbook.io", + "sdk-862b5mHcP9XPugqD", "BhB1wORFmZLTDjbvstvS8w==", + null, + null, null ); assertNotNull(subject); - assertEquals("https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD", subject.getEndpoint()); + assertEquals("https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD", subject.getFeaturesEndpoint()); assertEquals("BhB1wORFmZLTDjbvstvS8w==", subject.getEncryptionKey()); } @@ -42,41 +48,39 @@ void canBeConstructed_withEncryptionKey() { void canBeBuilt_withNullEncryptionKey() { GBFeaturesRepository subject = GBFeaturesRepository .builder() - .endpoint("https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8") + .apiHost("https://cdn.growthbook.io") + .clientKey("java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8") .build(); assertNotNull(subject); - assertEquals("https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", subject.getEndpoint()); + assertEquals("https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", subject.getFeaturesEndpoint()); } @Test void canBeBuilt_withEncryptionKey() { GBFeaturesRepository subject = GBFeaturesRepository .builder() - .endpoint("https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD") + .apiHost("https://cdn.growthbook.io") + .clientKey("sdk-862b5mHcP9XPugqD") .encryptionKey("BhB1wORFmZLTDjbvstvS8w==") .build(); assertNotNull(subject); - assertEquals("https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD", subject.getEndpoint()); + assertEquals("https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD", subject.getFeaturesEndpoint()); assertEquals("BhB1wORFmZLTDjbvstvS8w==", subject.getEncryptionKey()); } /* @Test void canFetchUnencryptedFeatures_real() throws FeatureFetchException { - GBFeaturesRepository subject = new GBFeaturesRepository( - "https://cdn.growthbook.io/api/features/java_NsrWldWd5bxQJZftGsWKl7R2yD2LtAK8C8EUYh9L8", - null, - null - ); + GBFeaturesRepository subject = GBFeaturesRepository.builder() + .apiHost("https://cdn.growthbook.io") + .clientKey("sdk-pGmC6LrsiUoEUcpZ") + .build(); subject.initialize(); - assertEquals( - "{\"banner_text\":{\"defaultValue\":\"Welcome to Acme Donuts!\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"Bienvenue au Beignets Acme !\"},{\"condition\":{\"country\":\"spain\"},\"force\":\"¡Bienvenidos y bienvenidas a Donas Acme!\"}]},\"dark_mode\":{\"defaultValue\":false,\"rules\":[{\"condition\":{\"loggedIn\":true},\"force\":true,\"coverage\":0.5,\"hashAttribute\":\"id\"}]},\"donut_price\":{\"defaultValue\":2.5,\"rules\":[{\"condition\":{\"employee\":true},\"force\":0}]},\"meal_overrides_gluten_free\":{\"defaultValue\":{\"meal_type\":\"standard\",\"dessert\":\"Strawberry Cheesecake\"},\"rules\":[{\"condition\":{\"dietaryRestrictions\":{\"$elemMatch\":{\"$eq\":\"gluten_free\"}}},\"force\":{\"meal_type\":\"gf\",\"dessert\":\"French Vanilla Ice Cream\"}}]}}", - subject.getFeaturesJson() - ); + assertTrue(subject.getFeaturesJson().startsWith("{\"banner_text\":{\"defaultValue\":\"Welcome to Acme Donuts!\"")); } */ @@ -86,10 +90,12 @@ void canFetchUnencryptedFeatures_mockedResponse() throws FeatureFetchException, OkHttpClient mockOkHttpClient = mockHttpClient(fakeResponseJson); GBFeaturesRepository subject = new GBFeaturesRepository( - mockOkHttpClient, "http://localhost:80", + "sdk-abc123", null, - null + null, + null, + mockOkHttpClient ); subject.initialize(); @@ -100,13 +106,16 @@ void canFetchUnencryptedFeatures_mockedResponse() throws FeatureFetchException, /* @Test void canFetchEncryptedFeatures_real() throws FeatureFetchException { - String endpoint = "https://cdn.growthbook.io/api/features/sdk-862b5mHcP9XPugqD"; String encryptionKey = "BhB1wORFmZLTDjbvstvS8w=="; - GBFeaturesRepository subject = new GBFeaturesRepository(endpoint, encryptionKey, null); + GBFeaturesRepository subject = GBFeaturesRepository.builder() + .apiHost("https://cdn.growthbook.io") + .clientKey("sdk-862b5mHcP9XPugqD") + .encryptionKey(encryptionKey) + .build(); subject.initialize(); - String expected = "{\"greeting\":{\"defaultValue\":\"hello\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"bonjour\"},{\"condition\":{\"country\":\"mexico\"},\"force\":\"holaaaaa\"}]}}"; + String expected = "{\"greeting\":{\"defaultValue\":\"hello, this is a message from encrypted features!\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"bonjour\"},{\"condition\":{\"country\":\"mexico\"},\"force\":\"holaaaaa\"}]}}"; String actual = subject.getFeaturesJson(); System.out.println(actual); @@ -126,10 +135,12 @@ void canFetchEncryptedFeatures_mockedResponse() throws IOException, FeatureFetch OkHttpClient mockOkHttpClient = mockHttpClient(fakeResponseJson); GBFeaturesRepository subject = new GBFeaturesRepository( - mockOkHttpClient, "http://localhost:80", + "abc-123", encryptionKey, - null + null, + null, + mockOkHttpClient ); subject.initialize(); @@ -148,6 +159,7 @@ void testUserAgentHeaders() throws FeatureFetchException { } */ + /** * Create a mock instance of {@link OkHttpClient} * @param serializedBody JSON string response diff --git a/lib/src/test/java/growthbook/sdk/java/testhelpers/SSETestServer.java b/lib/src/test/java/growthbook/sdk/java/testhelpers/SSETestServer.java new file mode 100644 index 00000000..79845c4e --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/testhelpers/SSETestServer.java @@ -0,0 +1,71 @@ +package growthbook.sdk.java.testhelpers; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import growthbook.sdk.java.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +/** + * Run this to test an actual SSE implementation end-to-end. + */ +public class SSETestServer { + public static void main(String[] args) throws IOException, FeatureFetchException { + // Unencrypted + GBFeaturesRepository featuresRepository = GBFeaturesRepository.builder() + .apiHost("https://cdn.growthbook.io") + .clientKey("sdk-pGmC6LrsiUoEUcpZ") + .refreshStrategy(FeatureRefreshStrategy.SERVER_SENT_EVENTS) + .build(); + + // Encrypted +// GBFeaturesRepository featuresRepository = GBFeaturesRepository.builder() +// .apiHost("https://cdn.growthbook.io") +// .clientKey("sdk-862b5mHcP9XPugqD") +// .encryptionKey("BhB1wORFmZLTDjbvstvS8w==") +// .refreshStrategy(FeatureRefreshStrategy.SERVER_SENT_EVENTS) +// .build(); + + featuresRepository.initialize(); + + System.out.println("SSE Test server: http://localhost:8081/ping"); + + HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0); + server.createContext("/ping", new TestServerHandler(featuresRepository)); + server.setExecutor(null); + server.start(); + } + + private static class TestServerHandler implements HttpHandler { + private final GBFeaturesRepository featuresRepository; + + TestServerHandler(GBFeaturesRepository featuresRepository) { + this.featuresRepository = featuresRepository; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + // Setup GrowthBook SDK + GBContext context = GBContext.builder() + .featuresJson(featuresRepository.getFeaturesJson()) + .build(); + GrowthBook growthBook = new GrowthBook(context); + + // Get a feature value + String randomString = growthBook.getFeatureValue("greeting", "????"); + + // Create a response + String response = "pong: "; + response += randomString; + + // Send response + exchange.sendResponseHeaders(200, response.length()); + OutputStream os = exchange.getResponseBody(); + os.write(response.getBytes()); + os.close(); + } + } +}