From 340d0ec86bfad4a73d154a9379f779496969663f Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 1 Jun 2023 21:43:06 +1200 Subject: [PATCH 01/22] major revision of the Java SDK Driven by use-updates and the new analytics API --- client-java-android/pom.xml | 4 +- .../AndroidFeatureHubClientFactory.java | 15 +- .../featurehub/android/FeatureHubClient.java | 107 +++--- .../android/FeatureHubClientMockSpec.groovy | 4 +- .../android/FeatureHubClientSpec.groovy | 8 +- .../android/FeatureHubClientRunner.java | 4 +- client-java-core/pom.xml | 2 +- .../client/AbstractFeatureRepository.java | 41 --- .../featurehub/client/AnalyticsCollector.java | 8 - .../featurehub/client/BaseClientContext.java | 171 +++++---- .../io/featurehub/client/ClientContext.java | 17 +- .../client/ClientEvalFeatureContext.java | 17 +- .../client/ClientFeatureRepository.java | 299 ++++++++++------ .../client/EdgeFeatureHubConfig.java | 118 +++---- .../io/featurehub/client/EdgeService.java | 16 +- .../client/FeatureHubClientFactory.java | 9 +- .../featurehub/client/FeatureHubConfig.java | 48 +-- .../featurehub/client/FeatureRepository.java | 82 ++--- .../client/FeatureRepositoryContext.java | 4 - .../io/featurehub/client/FeatureState.java | 53 ++- .../featurehub/client/FeatureStateBase.java | 164 +++++---- .../featurehub/client/FeatureStateUtils.java | 22 -- .../io/featurehub/client/FeatureStore.java | 58 --- .../client/GoogleAnalyticsApiClient.java | 8 - .../client/GoogleAnalyticsCollector.java | 92 ----- .../client/InternalFeatureRepository.java | 83 +++++ .../client/{Readyness.java => Readiness.java} | 2 +- .../featurehub/client/ReadynessListener.java | 5 - .../client/RepositoryEventHandler.java | 5 + .../client/ServerEvalFeatureContext.java | 81 ++--- .../client/analytics/AnalyticsAdapter.java | 26 ++ .../client/analytics/AnalyticsEvent.java | 39 ++ .../client/analytics/AnalyticsEventName.java | 7 + .../client/analytics/AnalyticsFeature.java | 46 +++ .../AnalyticsFeaturesCollection.java | 34 ++ .../AnalyticsFeaturesCollectionContext.java | 35 ++ .../client/analytics/AnalyticsPlugin.java | 15 + .../client/analytics/AnalyticsProvider.java | 30 ++ .../analytics/FeatureHubAnalyticsValue.java | 47 +++ .../client/edge/EdgeRetryService.java | 6 + .../featurehub/client/edge/EdgeRetryer.java | 33 ++ .../featurehub/client/utils/SdkVersion.java | 2 +- .../client/EdgeFeatureHubConfigSpec.groovy | 10 +- .../featurehub/client/RepositorySpec.groovy | 20 +- .../client/ServerEvalContextSpec.groovy | 2 +- client-java-jersey/pom.xml | 4 +- .../client/jersey/JerseyClient.java | 328 ----------------- .../jersey/JerseyFeatureHubClientFactory.java | 4 +- .../client/jersey/JerseySSEClient.java | 50 ++- .../client/jersey/JerseyClientSample.java | 2 +- client-java-jersey3/pom.xml | 4 +- .../client/jersey/JerseyClient.java | 334 ------------------ .../jersey/JerseyFeatureHubClientFactory.java | 12 +- .../client/jersey/JerseySSEClient.java | 51 ++- .../client/jersey/JerseyClientSample.java | 5 +- client-java-sse/pom.xml | 4 +- .../io/featurehub/edge/sse/SSEClient.java | 55 +-- .../featurehub/edge/sse/SSEClientFactory.java | 15 +- .../featurehub/edge/sse/SSEClientSpec.groovy | 27 +- .../featurehub/edge/sse/SSEClientRunner.java | 8 +- examples/migration-check/pom.xml | 4 +- .../io/featurehub/migrationcheck/Main.java | 10 +- examples/todo-java/pom.xml | 6 +- .../main/java/todo/backend/Application.java | 9 +- .../main/java/todo/backend/FeatureHub.java | 9 +- .../java/todo/backend/FeatureHubSource.java | 34 +- 66 files changed, 1290 insertions(+), 1584 deletions(-) delete mode 100644 client-java-core/src/main/java/io/featurehub/client/AbstractFeatureRepository.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/AnalyticsCollector.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/FeatureRepositoryContext.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/FeatureStore.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java rename client-java-core/src/main/java/io/featurehub/client/{Readyness.java => Readiness.java} (71%) delete mode 100644 client-java-core/src/main/java/io/featurehub/client/ReadynessListener.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java delete mode 100644 client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyClient.java delete mode 100644 client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java diff --git a/client-java-android/pom.xml b/client-java-android/pom.xml index 1ce4552..b7440cd 100644 --- a/client-java-android/pom.xml +++ b/client-java-android/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-android - 2.3-SNAPSHOT + 3.1-SNAPSHOT java-client-android @@ -47,7 +47,7 @@ io.featurehub.sdk java-client-core - [3, 4) + [4, 5) diff --git a/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java b/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java index c5c50bc..1f6c646 100644 --- a/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java +++ b/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java @@ -3,14 +3,21 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; +import io.featurehub.client.InternalFeatureRepository; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import java.util.Arrays; import java.util.function.Supplier; public class AndroidFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(final FeatureHubConfig config, final FeatureStore repository) { - return () -> new FeatureHubClient(config.baseUrl(), Arrays.asList(config.apiKey()), repository, config); + public Supplier createEdgeService(@NotNull final FeatureHubConfig config, + @Nullable final InternalFeatureRepository repository) { + return () -> new FeatureHubClient(repository, config); + } + + @Override + public Supplier createEdgeService(@NotNull FeatureHubConfig config) { + return createEdgeService(config, null); } } diff --git a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java b/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java index 2ae1403..54f717b 100644 --- a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java +++ b/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; import io.featurehub.client.utils.SdkVersion; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureState; @@ -23,7 +23,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -35,10 +34,10 @@ public class FeatureHubClient implements EdgeService { private static final Logger log = LoggerFactory.getLogger(FeatureHubClient.class); - private final FeatureStore repository; - private final Call.Factory client; + @NotNull private final InternalFeatureRepository repository; + @NotNull private final Call.Factory client; private boolean makeRequests; - private final String url; + @NotNull private final String url; private final ObjectMapper mapper = new ObjectMapper(); @Nullable private String xFeaturehubHeader; @@ -52,11 +51,15 @@ public class FeatureHubClient implements EdgeService { private long whenPollingCacheExpires; private final boolean clientSideEvaluation; - private final FeatureHubConfig config; - private final ExecutorService executorService; + @NotNull private final FeatureHubConfig config; + @NotNull private final ExecutorService executorService; + + public FeatureHubClient(@Nullable InternalFeatureRepository repository, + Call.@NotNull Factory client, @NotNull FeatureHubConfig config, int timeoutInSeconds) { + if (repository == null) { + repository = (InternalFeatureRepository) config.getRepository(); + } - public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository, - Call.Factory client, FeatureHubConfig config, int timeoutInSeconds) { this.repository = repository; this.client = client; this.config = config; @@ -65,20 +68,14 @@ public FeatureHubClient(String host, Collection sdkUrls, FeatureStore re // ensure the poll has expired the first time we ask for it whenPollingCacheExpires = System.currentTimeMillis() - 100; - if (host != null && sdkUrls != null && !sdkUrls.isEmpty()) { - this.clientSideEvaluation = sdkUrls.stream().anyMatch(FeatureHubConfig::sdkKeyIsClientSideEvaluated); - - this.makeRequests = true; + this.clientSideEvaluation = !config.isServerEvaluation(); + this.makeRequests = true; + executorService = makeExecutorService(); - executorService = makeExecutorService(); + url = config.baseUrl() + "/features?" + config.apiKeys().stream().map(u -> "apiKey=" + u).collect(Collectors.joining("&")); - url = host + "/features?" + sdkUrls.stream().map(u -> "apiKey=" + u).collect(Collectors.joining("&")); - - if (clientSideEvaluation) { - checkForUpdates(); - } - } else { - throw new RuntimeException("FeatureHubClient initialized without any sdkUrls"); + if (clientSideEvaluation) { + checkForUpdates(null); } } @@ -86,34 +83,46 @@ protected ExecutorService makeExecutorService() { return Executors.newWorkStealingPool(); } - public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository, FeatureHubConfig config, + public FeatureHubClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, int timeoutInSeconds) { - this(host, sdkUrls, repository, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); + this(repository, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); + } + + public FeatureHubClient(@NotNull FeatureHubConfig config, + int timeoutInSeconds) { + this(null, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); + } + + public FeatureHubClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this(repository, (Call.Factory) new OkHttpClient(), config, 180); } - public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository, FeatureHubConfig config) { - this(host, sdkUrls, repository, (Call.Factory) new OkHttpClient(), config, 180); + public FeatureHubClient(@NotNull FeatureHubConfig config) { + this(null, (Call.Factory) new OkHttpClient(), config, 180); } private final static TypeReference> ref = new TypeReference>(){}; private boolean busy = false; - private boolean triggeredAtLeastOnce = false; private boolean headerChanged = false; - private List> waitingClients = new ArrayList<>(); + private List> waitingClients = new ArrayList<>(); protected Long now() { return System.currentTimeMillis(); } - public boolean checkForUpdates() { + public boolean checkForUpdates(@Nullable CompletableFuture change) { final boolean breakCache = now() > whenPollingCacheExpires || headerChanged; final boolean ask = makeRequests && !busy && !stopped && breakCache; headerChanged = false; if (ask) { + if (change != null) { + // we are going to call, so we take a note of who we need to tell + waitingClients.add(change); + } + busy = true; - triggeredAtLeastOnce = true; String url = this.url + "&contextSha=" + xContextSha; log.debug("Url is {}", url); @@ -152,7 +161,7 @@ protected String getEtag() { return etag; } - protected void setEtag(String etag) { + protected void setEtag(@Nullable String etag) { this.etag = etag; } @@ -167,7 +176,7 @@ public void processCacheControlHeader(@NotNull String cacheControlHeader) { if (matcher.find()) { final String interval = matcher.group().split("=")[1]; try { - Long newInterval = Long.parseLong(interval); + long newInterval = Long.parseLong(interval); if (newInterval > 0) { this.pollingInterval = newInterval; } @@ -179,7 +188,7 @@ public void processCacheControlHeader(@NotNull String cacheControlHeader) { protected void processFailure(@NotNull IOException e) { log.error("Unable to call for features", e); - repository.notify(SSEResultState.FAILURE, null); + repository.notify(SSEResultState.FAILURE); busy = false; completeReadiness(); } @@ -212,7 +221,7 @@ protected void processResponse(Response response) throws IOException { } }); - repository.notify(states); + repository.updateFeatures(states); completeReadiness(); if (response.code() == 236) { @@ -226,7 +235,7 @@ protected void processResponse(Response response) throws IOException { } else if (response.code() == 400 || response.code() == 404) { makeRequests = false; log.error("Server indicated an error with our requests making future ones pointless."); - repository.notify(SSEResultState.FAILURE, null); + repository.notify(SSEResultState.FAILURE); completeReadiness(); } } @@ -236,10 +245,10 @@ boolean canMakeRequests() { return makeRequests && !stopped; } - boolean isStopped() { return stopped; } + public boolean isStopped() { return stopped; } private void completeReadiness() { - List> current = waitingClients; + List> current = waitingClients; waitingClients = new ArrayList<>(); current.forEach(c -> { try { @@ -251,17 +260,17 @@ private void completeReadiness() { } @Override - public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); + public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); headerChanged = (newHeader != null && !newHeader.equals(xFeaturehubHeader)); xFeaturehubHeader = newHeader; xContextSha = contextSha; - if (checkForUpdates() || busy) { + if (busy) { waitingClients.add(change); - } else { + } else if (!checkForUpdates(change)) { change.complete(repository.getReadyness()); } @@ -292,13 +301,17 @@ public void close() { } @Override - public boolean isRequiresReplacementOnHeaderChange() { - return false; - } + public Future poll() { + final CompletableFuture change = new CompletableFuture<>(); - @Override - public void poll() { - checkForUpdates(); + if (busy) { + waitingClients.add(change); + } else if (!checkForUpdates(change)) { + // not even planning to ask + change.complete(repository.getReadyness()); + } + + return change; } public long getWhenPollingCacheExpires() { diff --git a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy index dd7155b..887a98a 100644 --- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy +++ b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy @@ -2,7 +2,7 @@ package io.featurehub.android import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig -import io.featurehub.client.FeatureStore +import io.featurehub.client.InternalFeatureRepository import io.featurehub.sse.model.FeatureEnvironmentCollection import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -12,7 +12,7 @@ class FeatureHubClientMockSpec extends Specification { MockWebServer mockWebServer FeatureHubClient client FeatureHubConfig config - FeatureStore store + InternalFeatureRepository store ObjectMapper mapper def setup() { diff --git a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy index a7e57b9..728266b 100644 --- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy +++ b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy @@ -1,7 +1,7 @@ package io.featurehub.android import io.featurehub.client.FeatureHubConfig -import io.featurehub.client.FeatureStore +import io.featurehub.client.InternalFeatureRepository import okhttp3.Call import okhttp3.Request import spock.lang.Specification @@ -9,7 +9,7 @@ import spock.lang.Specification class FeatureHubClientSpec extends Specification { Call.Factory client Call call; - FeatureStore repo + InternalFeatureRepository repo FeatureHubClient fhc def "a null sdk url will never trigger a call"() { @@ -17,7 +17,7 @@ class FeatureHubClientSpec extends Specification { call = Mock() def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig), 0) and: "check for updates" - fhc.checkForUpdates() + fhc.checkForUpdates(change) then: thrown RuntimeException } @@ -40,7 +40,7 @@ class FeatureHubClientSpec extends Specification { and: "i specify a header" fhc.contextChange("fred=mary", "bonkers") when: "i check for updates" - fhc.checkForUpdates() + fhc.checkForUpdates(change) then: 1 == 1 } diff --git a/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java index 553ac78..dcfe332 100644 --- a/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java +++ b/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java @@ -17,13 +17,13 @@ public static void main(String[] args) throws Exception { "default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); final ClientContext ctx = config.newContext(); - ctx.getRepository().addReadynessListener(rl -> System.out.println("readyness " + rl.toString())); + ctx.getRepository().addReadinessListener(rl -> System.out.println("readyness " + rl.toString())); final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); FeatureRepository cfr = ctx.getRepository(); - cfr.addReadynessListener((rl) -> System.out.println("Readyness is " + rl)); + cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); System.out.println("Wait for readyness or hit enter if server eval key"); diff --git a/client-java-core/pom.xml b/client-java-core/pom.xml index 3d76dd1..2e61bfb 100644 --- a/client-java-core/pom.xml +++ b/client-java-core/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-core - 3.4-SNAPSHOT + 4.1-SNAPSHOT java-client-core diff --git a/client-java-core/src/main/java/io/featurehub/client/AbstractFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/AbstractFeatureRepository.java deleted file mode 100644 index 08dff7b..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/AbstractFeatureRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.featurehub.client; - -import java.util.Map; - -public abstract class AbstractFeatureRepository implements FeatureRepository { - - @Override - public FeatureState getFeatureState(Feature feature) { - return this.getFeatureState(feature.name()); - } - - @Override - public boolean exists(Feature key) { - return exists(key.name()); - } - - @Override - public boolean isEnabled(Feature key) { - return isEnabled(key.name()); - } - - @Override - public boolean isEnabled(String name) { - return Boolean.TRUE.equals(getFeatureState(name).getBoolean()); - } - - @Override - public FeatureRepository logAnalyticsEvent(String action, Map other) { - return logAnalyticsEvent(action, other, null); - } - - @Override - public FeatureRepository logAnalyticsEvent(String action, ClientContext ctx) { - return logAnalyticsEvent(action, null, ctx); - } - - @Override - public FeatureRepository logAnalyticsEvent(String action) { - return logAnalyticsEvent(action, null, null); - } -} diff --git a/client-java-core/src/main/java/io/featurehub/client/AnalyticsCollector.java b/client-java-core/src/main/java/io/featurehub/client/AnalyticsCollector.java deleted file mode 100644 index db8cffa..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/AnalyticsCollector.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.featurehub.client; - -import java.util.List; -import java.util.Map; - -public interface AnalyticsCollector { - void logEvent(String action, Map other, List featureStateAtCurrentTime); -} diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index 30d1aff..9f5731b 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -1,39 +1,56 @@ package io.featurehub.client; +import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.analytics.AnalyticsFeature; +import io.featurehub.client.analytics.AnalyticsFeaturesCollection; +import io.featurehub.client.analytics.AnalyticsFeaturesCollectionContext; +import io.featurehub.client.analytics.FeatureHubAnalyticsValue; +import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.StrategyAttributeCountryName; import io.featurehub.sse.model.StrategyAttributeDeviceName; import io.featurehub.sse.model.StrategyAttributePlatformName; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -public abstract class BaseClientContext implements ClientContext { +abstract class BaseClientContext implements ClientContext { + private static final Logger log = LoggerFactory.getLogger(BaseClientContext.class); + protected final EdgeService edgeService; + public static final String USER_KEY = "userkey"; public static final String SESSION_KEY = "session"; public static final String COUNTRY_KEY = "country"; public static final String DEVICE_KEY = "device"; public static final String PLATFORM_KEY = "platform"; public static final String VERSION_KEY = "version"; - public static final String C_ID = "cid"; - protected final Map> clientContext = new ConcurrentHashMap<>(); - protected final FeatureRepositoryContext repository; + protected final Map> attributes = new ConcurrentHashMap<>(); + protected final InternalFeatureRepository repository; protected final FeatureHubConfig config; - public BaseClientContext(FeatureRepositoryContext repository, FeatureHubConfig config) { + public BaseClientContext(InternalFeatureRepository repository, FeatureHubConfig config, EdgeService edgeService) { this.repository = repository; this.config = config; + this.edgeService = edgeService; + } + + @Override + public EdgeService getEdgeService() { + return edgeService; } @Override public String get(String key, String defaultValue) { - if (clientContext.containsKey(key)) { - final List vals = clientContext.get(key); + if (attributes.containsKey(key)) { + final List vals = attributes.get(key); return vals.isEmpty() ? defaultValue : vals.get(0); } @@ -42,103 +59,156 @@ public String get(String key, String defaultValue) { @Override public @NotNull List<@NotNull String> getAttrs(String key, @NotNull String defaultValue) { - final List attrs = clientContext.get(key); + final List attrs = attributes.get(key); return attrs == null ? Arrays.asList(defaultValue) : attrs; } - @Override - public @NotNull List<@NotNull String> getAttrs(String key) { - return clientContext.get(key); - } - @Override public ClientContext userKey(String userKey) { - clientContext.put(USER_KEY, Collections.singletonList(userKey)); + attributes.put(USER_KEY, Collections.singletonList(userKey)); return this; } @Override public ClientContext sessionKey(String sessionKey) { - clientContext.put(SESSION_KEY, Collections.singletonList(sessionKey)); + attributes.put(SESSION_KEY, Collections.singletonList(sessionKey)); return this; } @Override public ClientContext country(StrategyAttributeCountryName countryName) { - clientContext.put(COUNTRY_KEY, Collections.singletonList(countryName.toString())); + attributes.put(COUNTRY_KEY, Collections.singletonList(countryName.toString())); return this; } @Override public ClientContext device(StrategyAttributeDeviceName deviceName) { - clientContext.put(DEVICE_KEY, Collections.singletonList(deviceName.toString())); + attributes.put(DEVICE_KEY, Collections.singletonList(deviceName.toString())); return this; } @Override public ClientContext platform(StrategyAttributePlatformName platformName) { - clientContext.put(PLATFORM_KEY, Collections.singletonList(platformName.toString())); + attributes.put(PLATFORM_KEY, Collections.singletonList(platformName.toString())); return this; } @Override public ClientContext version(String version) { - clientContext.put(VERSION_KEY, Collections.singletonList(version)); + attributes.put(VERSION_KEY, Collections.singletonList(version)); return this; } @Override public ClientContext attr(String name, String value) { - clientContext.put(name, Collections.singletonList(value)); + attributes.put(name, Collections.singletonList(value)); return this; } @Override public ClientContext attrs(String name, List values) { - clientContext.put(name, values); + attributes.put(name, values); return this; } + void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, + @NotNull FeatureValueType valueType) { + + repository.execute(() -> { + try { + repository.used(key, id, valueType, val, attributes, analyticsUserKey()); + edgeService.poll().get(); + } catch (Exception e) { + log.error("Failed to poll", e); + } + }); + } + + @Nullable String analyticsUserKey() { + return getAttr("session", getAttr("userkey")); + } + + + protected void recordFeatureChangedForUser(FeatureStateBase feature) { + repository.recordAnalyticsEvent(new AnalyticsFeature( + new FeatureHubAnalyticsValue(feature.withContext(this)), attributes, + analyticsUserKey())); + } + + protected void recordRelativeValuesForUser() { + repository.recordAnalyticsEvent(fillAnalyticsCollection(repository.getAnalyticsProvider().createAnalyticsCollectionEvent())); + } + + protected AnalyticsEvent fillAnalyticsCollection(AnalyticsEvent event) { + event.setUserKey(analyticsUserKey()); + + if (event instanceof AnalyticsFeaturesCollection) { + ((AnalyticsFeaturesCollection)event).setFeatureValues( + repository.getFeatureKeys().stream().map((k) -> + new FeatureHubAnalyticsValue(repository.getFeat(k))).collect(Collectors.toList())); + } + + if (event instanceof AnalyticsFeaturesCollectionContext) { + ((AnalyticsFeaturesCollectionContext)event).setAttributes(attributes); + } + + return event; + } + + @Override + @Nullable public String getAttr(@NotNull String name, @Nullable String defaultVal) { + String val = getAttr(name); + return val == null ? defaultVal : val; + } + + @Override + @Nullable public String getAttr(@NotNull String name) { + return attributes.containsKey(name) ? attributes.get(name).get(0) : null; + } + + @Override + @Nullable public List getAttrs(@NotNull String name) { + return attributes.getOrDefault(name, null); + } + @Override public ClientContext clear() { - clientContext.clear(); + attributes.clear(); return this; } @Override public Map> context() { - return clientContext; + return Collections.unmodifiableMap(attributes); } @Override public String defaultPercentageKey() { - if (clientContext.containsKey(SESSION_KEY)) { - return clientContext.get(SESSION_KEY).get(0); + if (attributes.containsKey(SESSION_KEY)) { + return attributes.get(SESSION_KEY).get(0); } - if (clientContext.containsKey(USER_KEY)) { - return clientContext.get(USER_KEY).get(0); + if (attributes.containsKey(USER_KEY)) { + return attributes.get(USER_KEY).get(0); } return null; } @Override - public FeatureState feature(String name) { - final FeatureState fs = getRepository().getFeatureState(name); - - return getRepository().isServerEvaluation() ? fs : fs.withContext(this); + public @NotNull FeatureState feature(String name) { + return repository.getFeat(name).withContext(this); } @Override - public List allFeatures() { + public @NotNull List> allFeatures() { boolean isServerEvaluation = getRepository().isServerEvaluation(); - return getRepository().getAllFeatures().stream() - .map(f -> isServerEvaluation ? f : f.withContext(this)) + return repository.getFeatureKeys().stream() + .map(f -> repository.getFeat(f).withContext(this)) .collect(Collectors.toList()); } @Override - public FeatureState feature(Feature name) { + public @NotNull FeatureState feature(Feature name) { return feature(name.name()); } @@ -169,39 +239,18 @@ public boolean exists(Feature key) { return exists(key.name()); } - @Override - public FeatureRepository getRepository() { + public @NotNull FeatureRepository getRepository() { return repository; } @Override public boolean exists(String key) { - return repository.exists(key); + return feature(key).exists(); } @Override - public ClientContext logAnalyticsEvent(String action, Map other) { - String user = get(USER_KEY, null); - - if (user != null) { - if (other == null) { - other = new HashMap<>(); - } - - if (!other.containsKey(C_ID)) { - other.put(C_ID, user); - } - } - - repository.logAnalyticsEvent(action, other, this); - - return this; - } - - @Override - public ClientContext logAnalyticsEvent(String action) { - return logAnalyticsEvent(action, null); + public void close() { + edgeService.close(); } - } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 4ac717b..7306fc8 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -26,6 +26,10 @@ public interface ClientContext { ClientContext clear(); + @Nullable String getAttr(@NotNull String name); + @Nullable String getAttr(@NotNull String name, @Nullable String defaultVal); + @Nullable List getAttrs(@NotNull String name); + /** * Triggers the build and setting of this context. * @@ -36,15 +40,12 @@ public interface ClientContext { Map> context(); String defaultPercentageKey(); - FeatureState feature(String name); - FeatureState feature(Feature name); - List allFeatures(); - - FeatureRepository getRepository(); - EdgeService getEdgeService(); + @NotNull FeatureState feature(String name); + @NotNull FeatureState feature(Feature name); + @NotNull List> allFeatures(); - ClientContext logAnalyticsEvent(String action, Map other); - ClientContext logAnalyticsEvent(String action); + @NotNull FeatureRepository getRepository(); + @NotNull EdgeService getEdgeService(); /** * true if it is a boolean feature and is true within this context. diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java index e6dbcea..5c98fa7 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java @@ -8,13 +8,10 @@ * This class is ONLY used when we are doing client side evaluation. So the edge service stays the same. */ class ClientEvalFeatureContext extends BaseClientContext { - private final EdgeService edgeService; - public ClientEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContext repository, + public ClientEvalFeatureContext(FeatureHubConfig config, InternalFeatureRepository repository, EdgeService edgeService) { - super(repository, config); - - this.edgeService = edgeService; + super(repository, config, edgeService); } // this doesn't matter for client eval @@ -30,16 +27,6 @@ public Future build() { }); } - @Override - public FeatureState feature(String name) { - return repository.getFeatureState(name).withContext(this); - } - - @Override - public EdgeService getEdgeService() { - return edgeService; - } - /** * We never close anything, it is controlled in the FeatureConfig */ diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 513c429..287172b 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -5,44 +5,72 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.featurehub.client.analytics.AnalyticsAdapter; +import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.client.analytics.FeatureHubAnalyticsValue; import io.featurehub.sse.model.FeatureRolloutStrategy; +import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.SSEResultState; import io.featurehub.strategies.matchers.MatcherRegistry; import io.featurehub.strategies.percentage.PercentageMumurCalculator; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Consumer; import java.util.stream.Collectors; -public class ClientFeatureRepository extends AbstractFeatureRepository - implements FeatureRepositoryContext { +public class ClientFeatureRepository implements InternalFeatureRepository { + private static class Callback implements RepositoryEventHandler { + private final List> handlers; + public final Consumer callback; + + public Callback(List> handlers, Consumer callback) { + this.handlers = handlers; + this.handlers.add(this); + this.callback = callback; + } + + @Override + public void cancel() { + this.handlers.remove(this); + } + } + private static final Logger log = LoggerFactory.getLogger(ClientFeatureRepository.class); // feature-key, feature-state - private final Map features = new ConcurrentHashMap<>(); + private final Map> features = new ConcurrentHashMap<>(); + private final Map> featuresById = new ConcurrentHashMap<>(); private final ExecutorService executor; - private final ObjectMapper mapper; private boolean hasReceivedInitialState = false; - private final List analyticsCollectors = new ArrayList<>(); - private Readyness readyness = Readyness.NotReady; - private final List readynessListeners = new ArrayList<>(); + private Readiness readiness = Readiness.NotReady; + private final List> readinessListeners = new ArrayList<>(); + private final List> newStateAvailableHandlers = new ArrayList<>(); + private final List>> featureUpdateHandlers = new ArrayList<>(); private final List featureValueInterceptors = new ArrayList<>(); + private final List> analyticsHandlers = new ArrayList<>(); + private AnalyticsProvider analyticsProvider = new AnalyticsProvider.DefaultAnalyticsProvider(); + private ObjectMapper jsonConfigObjectMapper; private final ApplyFeature applyFeature; + private AnalyticsAdapter analyticsAdapter; private boolean serverEvaluation = false; // the client tells us, we pass it out to others private final TypeReference> FEATURE_LIST_TYPEDEF = new TypeReference>() {}; public ClientFeatureRepository(ExecutorService executor, ApplyFeature applyFeature) { - mapper = initializeMapper(); - - jsonConfigObjectMapper = mapper; + jsonConfigObjectMapper = initializeMapper(); this.executor = executor; @@ -78,33 +106,27 @@ protected static ExecutorService getExecutor(int threadPoolSize) { return Executors.newFixedThreadPool(threadPoolSize); } - public void setJsonConfigObjectMapper(ObjectMapper jsonConfigObjectMapper) { + public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { this.jsonConfigObjectMapper = jsonConfigObjectMapper; } - @Override - public boolean exists(String key) { - return features.containsKey(key); - } - @Override public boolean isServerEvaluation() { return serverEvaluation; } - public Readyness getReadyness() { - return readyness; + public @NotNull Readiness getReadyness() { + return getReadiness(); } @Override - public FeatureRepository addAnalyticCollector(AnalyticsCollector collector) { - analyticsCollectors.add(collector); - return this; + public @NotNull Readiness getReadiness() { + return readiness; } @Override - public FeatureRepository registerValueInterceptor( - boolean allowFeatureOverride, FeatureValueInterceptor interceptor) { + public @NotNull FeatureRepository registerValueInterceptor( + boolean allowFeatureOverride, @NotNull FeatureValueInterceptor interceptor) { featureValueInterceptors.add( new FeatureValueInterceptorHolder(allowFeatureOverride, interceptor)); @@ -112,8 +134,28 @@ public FeatureRepository registerValueInterceptor( } @Override - public void notify(SSEResultState state, String data) { - log.trace("received state {} data {}", state, data); + public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { + this.analyticsProvider = provider; + } + + @Override + public @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback) { + return new Callback<>(newStateAvailableHandlers, callback); + } + + @Override + public @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback) { + return new Callback<>(featureUpdateHandlers, callback); + } + + @Override + public @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback) { + return new Callback<>(analyticsHandlers, callback); + } + + @Override + public void notify(SSEResultState state) { + log.trace("received state {}", state); if (state == null) { log.warn("Unexpected null state"); } else { @@ -121,49 +163,40 @@ public void notify(SSEResultState state, String data) { switch (state) { case ACK: case BYE: - break; case DELETE_FEATURE: - deleteFeature(mapper.readValue(data, io.featurehub.sse.model.FeatureState.class)); - break; case FEATURE: - featureUpdate(mapper.readValue(data, io.featurehub.sse.model.FeatureState.class)); - break; case FEATURES: - List features = - mapper.readValue(data, FEATURE_LIST_TYPEDEF); - notify(features); break; case FAILURE: - readyness = Readyness.Failed; + readiness = Readiness.Failed; broadcastReadyness(); break; } } catch (Exception e) { - log.error("Unable to process data `{}` for state `{}`", data, state, e); + log.error("Unable to process state `{}`", state, e); } } } @Override - public void notify(List states, boolean force) { - states.forEach(s -> featureUpdate(s, force)); + public void updateFeatures(List features) { + + } + + @Override + public void updateFeatures(List states, boolean force) { + states.forEach(s -> updateFeature(s, force)); if (!hasReceivedInitialState) { - checkForInvalidFeatures(); hasReceivedInitialState = true; - readyness = Readyness.Ready; + readiness = Readiness.Ready; broadcastReadyness(); - } else if (readyness != Readyness.Ready) { - readyness = Readyness.Ready; + } else if (readiness != Readiness.Ready) { + readiness = Readiness.Ready; broadcastReadyness(); } } - @Override - public List getFeatureValueInterceptors() { - return featureValueInterceptors; - } - @Override public Applied applyFeature( List strategies, String key, String featureValueId, ClientContext cac) { @@ -186,8 +219,8 @@ public void setServerEvaluation(boolean val) { } @Override - public void notReady() { - readyness = Readyness.NotReady; + public void repositoryNotReady() { + readiness = Readiness.NotReady; broadcastReadyness(); } @@ -196,8 +229,8 @@ public void close() { log.info("featurehub repository closing"); features.clear(); - readyness = Readyness.NotReady; - readynessListeners.stream().forEach(rl -> rl.notify(readyness)); + readiness = Readiness.NotReady; + readinessListeners.forEach(rl -> rl.callback.accept(readiness)); executor.shutdownNow(); @@ -205,104 +238,146 @@ public void close() { } @Override - public void notify(List states) { - notify(states, false); - } - - @Override - public FeatureRepository addReadynessListener(ReadynessListener rl) { - this.readynessListeners.add(rl); + public @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer rl) { + final Callback callback = new Callback<>(readinessListeners, rl); + this.readinessListeners.add(callback); if (!executor.isShutdown()) { // let it know what the current state is - executor.execute(() -> rl.notify(readyness)); + executor.execute(() -> rl.accept(readiness)); } - return this; + return callback; } private void broadcastReadyness() { if (!executor.isShutdown()) { - readynessListeners.forEach((rl) -> executor.execute(() -> rl.notify(readyness))); + readinessListeners.forEach((rl) -> executor.execute(() -> rl.callback.accept(readiness))); } } - private void deleteFeature(io.featurehub.sse.model.FeatureState readValue) { + public void deleteFeature(io.featurehub.sse.model.FeatureState readValue) { readValue.setValue(null); - featureUpdate(readValue); + updateFeature(readValue); } - private void checkForInvalidFeatures() { - String invalidKeys = - features.values().stream() - .filter(v -> v.getKey() == null) - .map(FeatureState::getKey) - .collect(Collectors.joining(", ")); - if (invalidKeys.length() > 0) { - log.error("FeatureHub error: application is requesting use of invalid keys: {}", invalidKeys); - } + @Override + public @NotNull List> getAllFeatures() { + return new ArrayList<>(features.values()); } @Override - public FeatureState getFeatureState(String key) { - return features.computeIfAbsent( - key, - key1 -> { - if (hasReceivedInitialState) { - log.error( - "FeatureHub error: application requesting use of invalid key after initialization: `{}`", - key1); - } - - return new FeatureStateBase(null, this, key); - }); + public @NotNull Set getFeatureKeys() { + return features.keySet(); } @Override - public List getAllFeatures() { - return new ArrayList<>(features.values()); + public @NotNull FeatureState feature(String key) { + return getFeat(key); } @Override - public FeatureRepository logAnalyticsEvent( - String action, Map other, ClientContext ctx) { - // take a snapshot of the current state of the features - List featureStateAtCurrentTime = - features.values().stream() - .map(f -> ctx == null ? f : f.withContext(ctx)) - .filter(FeatureState::isSet) - .map(f -> ((FeatureStateBase)f).analyticsCopy()) - .collect(Collectors.toList()); - - executor.execute( - () -> - analyticsCollectors.forEach( - (c) -> c.logEvent(action, other, featureStateAtCurrentTime))); - - return this; + public @NotNull FeatureState feature(String key, Class clazz) { + return getFeat(key, clazz); } - private void featureUpdate(io.featurehub.sse.model.FeatureState featureState) { - featureUpdate(featureState, false); + public boolean updateFeature(io.featurehub.sse.model.FeatureState featureState) { + return updateFeature(featureState, false); } - private void featureUpdate(io.featurehub.sse.model.FeatureState featureState, boolean force) { - FeatureStateBase holder = features.get(featureState.getKey()); - if (holder == null || holder.getKey() == null) { - holder = new FeatureStateBase(holder, this, featureState.getKey()); + @Override + public boolean updateFeature(io.featurehub.sse.model.FeatureState featureState, boolean force) { + FeatureStateBase holder = features.get(featureState.getKey()); + if (holder == null || holder._featureState == null) { + holder = new FeatureStateBase<>(this, featureState.getKey()); features.put(featureState.getKey(), holder); - } else if (!force && holder._featureState != null) { - if (holder._featureState.getVersion() > featureState.getVersion() - || (holder._featureState.getVersion().equals(featureState.getVersion()) + } else if (!force) { + long existingVersion = holder._featureState.getVersion() == null ? -1 : holder._featureState.getVersion(); + long newVersion = featureState.getVersion() == null ? -1 : featureState.getVersion(); + if (existingVersion > newVersion + || (newVersion == existingVersion && !FeatureStateUtils.changed( holder._featureState.getValue(), featureState.getValue()))) { - // if the old version is newer, or they are the same version and the value hasn't changed. + // if the old existingVersion is newer, or they are the same existingVersion and the value hasn't changed. // it can change with server side evaluation based on user data - return; + return false; } } holder.setFeatureState(featureState); + featuresById.put(featureState.getId(), holder); + + if (hasReceivedInitialState) { + broadcastFeatureUpdatedListeners(holder); + } + + return true; + } + + public FeatureStateBase getFeat(String key) { + return getFeat(key, Boolean.class); + } + + @Override + @SuppressWarnings("unchecked") // it is all fake anyway + public FeatureStateBase getFeat(String key, Class clazz) { + return (FeatureStateBase) features.computeIfAbsent( + key, + key1 -> { + if (hasReceivedInitialState) { + log.error( + "FeatureHub error: application requesting use of invalid key after initialization: `{}`", + key1); + } + + return new FeatureStateBase(this, key); + }); + } + + private void broadcastFeatureUpdatedListeners(FeatureState fs) { + featureUpdateHandlers.forEach((handler) -> execute(() -> handler.callback.accept(fs))); + } + + @Override + public void recordAnalyticsEvent(AnalyticsEvent event) { + analyticsHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); + } + + @Override + public void repositoryEmpty() { + readiness = Readiness.Ready; + broadcastReadyness(); + } + + @Override + public void used(String key, UUID id, FeatureValueType valueType, Object value, Map> attributes, + String analyticsUserKey) { + recordAnalyticsEvent(analyticsProvider.createAnalyticsFeature(new FeatureHubAnalyticsValue(id.toString(), key, + value, valueType + ), attributes, analyticsUserKey)); + } + + @Override + public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, String key) { + return featureValueInterceptors.stream() + .filter(vi -> !locked || vi.allowLockOverride) + .map( + vi -> { + FeatureValueInterceptor.ValueMatch vm = vi.interceptor.getValue(key); + if (vm != null && vm.matched) { + return vm; + } else { + return null; + } + }) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + @Override + public AnalyticsProvider getAnalyticsProvider() { + return analyticsProvider; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 3205f64..33cc845 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -6,8 +6,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collections; +import java.util.List; import java.util.ServiceLoader; import java.util.concurrent.Future; +import java.util.function.Consumer; import java.util.function.Supplier; public class EdgeFeatureHubConfig implements FeatureHubConfig { @@ -19,22 +22,31 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @NotNull private final String edgeUrl; @NotNull - private final String apiKey; + private final List apiKeys; + @NotNull + private InternalFeatureRepository repository = new ClientFeatureRepository(); @Nullable - private FeatureRepositoryContext repository; + private EdgeService edgeService; @Nullable - private Supplier edgeService; + private Supplier edgeServiceSupplier; + + @Nullable private ServerEvalFeatureContext serverEvalFeatureContext; @Nullable private EdgeService edgeClient; public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { + this(edgeUrl, Collections.singletonList(apiKey)); + } + + public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull List apiKeys) { + this.apiKeys = apiKeys; - if (apiKey == null || edgeUrl == null) { - throw new RuntimeException("Both edge url and sdk key must be set."); + if (this.apiKeys.isEmpty()) { + throw new RuntimeException("Cannot use empty list of sdk keys"); } - serverEvaluation = !FeatureHubConfig.sdkKeyIsClientSideEvaluated(apiKey); + serverEvaluation = !FeatureHubConfig.sdkKeyIsClientSideEvaluated(apiKeys); if (edgeUrl.endsWith("/")) { edgeUrl = edgeUrl.substring(0, edgeUrl.length()-1); @@ -45,9 +57,8 @@ public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { } this.edgeUrl = String.format("%s", edgeUrl); - this.apiKey = apiKey; - realtimeUrl = String.format("%s/features/%s", edgeUrl, apiKey); + realtimeUrl = String.format("%s/features/%s", edgeUrl, apiKeys.get(0)); } @Override @@ -59,7 +70,12 @@ public String getRealtimeUrl() { @Override @NotNull public String apiKey() { - return apiKey; + return apiKeys.get(0); + } + + @Override + public List apiKeys() { + return apiKeys; } @Override @@ -89,37 +105,16 @@ public boolean isServerEvaluation() { @Override @NotNull public ClientContext newContext() { - return newContext(null, null); - } - - @Override - @NotNull - public ClientContext newContext(@Nullable FeatureRepositoryContext repository, - @Nullable Supplier edgeService) { - if (repository == null) { - if (this.repository == null) { - this.repository = new ClientFeatureRepository(); - } - - repository = this.repository; - } - - if (edgeService == null) { - if (this.edgeService == null) { - this.edgeService = loadEdgeService(repository); - } - - edgeService = this.edgeService; + if (this.edgeService == null) { + this.edgeService = loadEdgeService(repository).get(); } if (isServerEvaluation()) { - return new ServerEvalFeatureContext(this, repository, edgeService); - } + if (serverEvalFeatureContext == null) { + serverEvalFeatureContext = new ServerEvalFeatureContext(this, repository, edgeService); + } - // we are using a single connection to the remote server, so we hold onto the - // edge client. If they call close on here it will allow it to be reopened. - if (edgeClient == null) { - edgeClient = edgeService.get(); + return serverEvalFeatureContext; } return new ClientEvalFeatureContext(this, repository, edgeClient); @@ -129,57 +124,50 @@ public ClientContext newContext(@Nullable FeatureRepositoryContext repository, * dynamically load an edge service implementation */ @NotNull - protected Supplier loadEdgeService(@NotNull FeatureRepositoryContext repository) { - ServiceLoader loader = ServiceLoader.load(FeatureHubClientFactory.class); - - for(FeatureHubClientFactory f : loader) { - Supplier edgeService = f.createEdgeService(this, repository); - if (edgeService != null) { - return edgeService; + protected Supplier loadEdgeService(@NotNull InternalFeatureRepository repository) { + if (edgeServiceSupplier == null) { + ServiceLoader loader = ServiceLoader.load(FeatureHubClientFactory.class); + + for (FeatureHubClientFactory f : loader) { + Supplier edgeService = f.createEdgeService(this, repository); + if (edgeService != null) { + edgeServiceSupplier = edgeService; + break; + } } } + if (edgeServiceSupplier != null) + return edgeServiceSupplier; + throw new RuntimeException("Unable to find an edge service for featurehub, please include one on classpath."); } @Override - public void setRepository(@NotNull FeatureRepositoryContext repository) { - this.repository = repository; + public void setRepository(@NotNull FeatureRepository repository) { + this.repository = (InternalFeatureRepository) repository; } @Override @NotNull - public FeatureRepositoryContext getRepository() { - if (repository == null) { - repository = new ClientFeatureRepository(); - } - + public FeatureRepository getRepository() { return repository; } @Override public void setEdgeService(@NotNull Supplier edgeService) { - this.edgeService = edgeService; + this.edgeServiceSupplier = edgeService; } @Override @NotNull public Supplier getEdgeService() { - if (edgeService == null) { - edgeService = loadEdgeService(getRepository()); - } - - return edgeService; - } - - @Override - public void addReadynessListener(@NotNull ReadynessListener readynessListener) { - getRepository().addReadynessListener(readynessListener); + return loadEdgeService(repository); } @Override - public void addAnalyticCollector(@NotNull AnalyticsCollector collector) { - getRepository().addAnalyticCollector(collector); + public @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener) { + return repository.addReadinessListener(readinessListener); } @Override @@ -189,8 +177,8 @@ public void registerValueInterceptor(boolean allowLockOverride, @NotNull Feature @Override @NotNull - public Readyness getReadyness() { - return getRepository().getReadyness(); + public Readiness getReadiness() { + return getRepository().getReadiness(); } @Override diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java b/client-java-core/src/main/java/io/featurehub/client/EdgeService.java index 335ad6c..5af1f77 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeService.java @@ -13,7 +13,7 @@ public interface EdgeService { * @return a completable future when it has actually changed */ @NotNull - Future contextChange(@Nullable String newHeader, String contextSha); + Future contextChange(@Nullable String newHeader, String contextSha); /** * are we doing client side evaluation? @@ -21,6 +21,12 @@ public interface EdgeService { */ boolean isClientEvaluation(); + /** + * Has been stopped for some reason + * @return true if stopped + */ + boolean isStopped(); + /** * Shut down this service */ @@ -29,7 +35,9 @@ public interface EdgeService { @NotNull FeatureHubConfig getConfig(); - boolean isRequiresReplacementOnHeaderChange(); - - void poll(); + /** + * @return a future which will be completed when the poll has finished. for SSE this will be the 1st return, for + * REST it will be the response. + */ + Future poll(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java index 58880c1..16af154 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java @@ -1,5 +1,8 @@ package io.featurehub.client; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.util.function.Supplier; public interface FeatureHubClientFactory { @@ -7,8 +10,10 @@ public interface FeatureHubClientFactory { * allows the creation of a new edge service without knowing about the underlying implementation. * depending on which library is included, this will automatically be created. * - * @param url - the full edge url + * @param config - the full edge config * @return */ - Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository); + Supplier createEdgeService(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); + + Supplier createEdgeService(@NotNull FeatureHubConfig config); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 6ed3633..d5d75df 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,18 +1,23 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; +import org.jetbrains.annotations.NotNull; +import java.util.Collection; +import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.List; public interface FeatureHubConfig { /** * What is the fully deconstructed URL for the server? */ - String getRealtimeUrl(); + @NotNull String getRealtimeUrl(); - String apiKey(); + @NotNull String apiKey(); + @NotNull List<@NotNull String> apiKeys(); - String baseUrl(); + @NotNull String baseUrl(); /** * If you are using a client evaluated feature context, this will initialise the service and block until @@ -31,43 +36,24 @@ public interface FeatureHubConfig { * * @return a new context */ - ClientContext newContext(); + @NotNull ClientContext newContext(); - /** - * Allows you to create a new context for the user. - * - * @param repository - this repository is for this call only, it is not remembered, you should set the repository - * on repository() to make it the default. - * - * @param edgeService - this edgeService is for this call only, it is not remembered, you should set it on - * edgeService() to make it the default - * @return a new context - */ - ClientContext newContext(FeatureRepositoryContext repository, Supplier edgeService); - - static boolean sdkKeyIsClientSideEvaluated(String sdkKey) { - return sdkKey.contains("*"); + static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { + return sdkKey.stream().anyMatch(key -> key.contains("*")); } - void setRepository(FeatureRepositoryContext repository); - FeatureRepositoryContext getRepository(); + void setRepository(FeatureRepository repository); + @NotNull FeatureRepository getRepository(); void setEdgeService(Supplier edgeService); - Supplier getEdgeService(); + @NotNull Supplier getEdgeService(); /** * Allows you to specify a readyness listener to trigger every time the repository goes from * being in any way not reaay, to ready. - * @param readynessListener - */ - void addReadynessListener(ReadynessListener readynessListener); - - /** - * Allows you to specify an analytics collector - * - * @param collector + * @param readinessListener */ - void addAnalyticCollector(AnalyticsCollector collector); + @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener); /** * Allows you to register a value interceptor @@ -80,7 +66,7 @@ static boolean sdkKeyIsClientSideEvaluated(String sdkKey) { * Allows you to query the state of the repository's readyness - such as in a heartbeat API * @return */ - Readyness getReadyness(); + @NotNull Readiness getReadiness(); /** * Allows you to override how your config will be deserialized when "getJson" is called. diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java index 33d407b..a8960bf 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -1,78 +1,45 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.analytics.AnalyticsProvider; +import org.jetbrains.annotations.NotNull; import java.util.List; -import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; public interface FeatureRepository { /** * Changes in readyness for the repository. It can become ready and then fail if subsequent * calls fail. * - * @param readynessListener - a callback lambda + * @param readinessListener - a callback lambda * @return - this FeatureRepository */ - FeatureRepository addReadynessListener(ReadynessListener readynessListener); + @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener); - /** - * @deprecated - * Get a feature state isolated from the API. Always try and use the context. - * - * @param key - the key of the feature - * @return - the FeatureStateHolder referring to this key, can exist but not refer to an actual feature - */ - FeatureState getFeatureState(String key); - FeatureState getFeatureState(Feature feature); - - List getAllFeatures(); - - // replaces getFlag and its myriad combinations with a pure boolean response, true if set and is true, otherwise false - - /** - * @deprecated - please migrate to using the ClientContext - */ - boolean isEnabled(String name); - /** - * @deprecated - please migrate to using the ClientContext - */ - boolean isEnabled(Feature key); - - /** - * @deprecated - please migrate to using the ClientContext - */ - FeatureRepository logAnalyticsEvent(String action, Map other); - /** - * @deprecated - please migrate to using the ClientContext - */ - FeatureRepository logAnalyticsEvent(String action); - FeatureRepository logAnalyticsEvent(String action, Map other, ClientContext ctx); - FeatureRepository logAnalyticsEvent(String action, ClientContext ctx); - - /** - * Register an analytics collector - * - * @param collector - a class implementing the AnalyticsCollector interface - * @return - thimvn s - */ - FeatureRepository addAnalyticCollector(AnalyticsCollector collector); + @NotNull List> getAllFeatures(); + @NotNull Set getFeatureKeys(); + @NotNull FeatureState feature(String key); + @NotNull FeatureState feature(String key, Class clazz); /** * Adds interceptor support for feature values. * * @param allowLockOverride - is this interceptor allowed to override the lock? i.e. if the feature is locked, we * ignore the interceptor - * @param interceptor - the interceptor + * @param interceptor - the interceptor * @return the instance of the repo for chaining */ - FeatureRepository registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor); + @NotNull FeatureRepository registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); + void registerAnalyticsProvider(@NotNull AnalyticsProvider provider); - /** - * Is this repository ready to connect to. - * - * @return Readyness status - */ - Readyness getReadyness(); + @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback); + @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback); + @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback); + + @NotNull Readiness getReadiness(); /** * Lets the SDK override the configuration of the JSON mapper in case they have special techniques they use. @@ -80,16 +47,7 @@ public interface FeatureRepository { * @param jsonConfigObjectMapper - an ObjectMapper configured for client use. This defaults to the same one * used to deserialize */ - void setJsonConfigObjectMapper(ObjectMapper jsonConfigObjectMapper); - - /** - * @deprecated - please migrate to using the ClientContext - */ - boolean exists(String key); - /** - * @deprecated - please migrate to using the ClientContext - */ - boolean exists(Feature key); + void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper); boolean isServerEvaluation(); diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepositoryContext.java b/client-java-core/src/main/java/io/featurehub/client/FeatureRepositoryContext.java deleted file mode 100644 index 7dddfc7..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepositoryContext.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.featurehub.client; - -public interface FeatureRepositoryContext extends FeatureRepository, FeatureStore { -} diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java b/client-java-core/src/main/java/io/featurehub/client/FeatureState.java index 1c282d7..68adc42 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureState.java @@ -1,35 +1,68 @@ package io.featurehub.client; +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.math.BigDecimal; -public interface FeatureState { - String getKey(); +public interface FeatureState { + /** + * @return - The key, it is always set, even if this is a feature that doesn't exist in the underlying repository + */ + @NotNull String getKey(); - String getString(); + @Nullable String getString(); - Boolean getBoolean(); + /** + * @deprecated recommend now using the getFlag() method + */ + @Deprecated() + @Nullable Boolean getBoolean(); - BigDecimal getNumber(); + /** + * use isEnabled() if you want to have true/false regardless + * @return - true if the feature is a flag, has a value and it is true + */ + @Nullable Boolean getFlag(); - String getRawJson(); + /** + * Gets the value raw and tries to make it appear as the type you request, regardless of + * the underlying type. If it is a boolean and you ask for it as a string, it will still be a bool and + * will cause a compile error. + * + * @param clazz for fake typing + * @return the determined value (can be overridden by feature value interceptors) + */ + @Nullable K getValue(Class clazz); + + @Nullable BigDecimal getNumber(); - T getJson(Class type); + @Nullable String getRawJson(); + + @Nullable T getJson(Class type); /** * true if the flag is boolean and is true - */ + */ boolean isEnabled(); boolean isSet(); + /** + * @return Are we dealing with a feature that actually exists in the underlying repository? + */ + boolean exists(); + boolean isLocked(); /** * Adds a listener to a feature. Do *not* add a listener to a context in server mode, where you are creating * lots of contexts as this will lead to a memory leak. + * * @param listener */ - void addListener(FeatureListener listener); + void addListener(@NotNull FeatureListener listener); - FeatureState withContext(ClientContext ctx); + @Nullable FeatureValueType getType(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index d2a1c94..de077a0 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -1,6 +1,7 @@ package io.featurehub.client; import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,40 +11,48 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.UUID; /** - * This class is just the base class to avoid a whole lot of duplication effort and to ensure the + * This class is just the base class to avoid a lot of duplication effort and to ensure the * maximum performance for each feature in updating its listeners and knowing what type it is. */ -public class FeatureStateBase implements FeatureState { +public class FeatureStateBase implements FeatureState { private static final Logger log = LoggerFactory.getLogger(FeatureStateBase.class); protected final String key; protected io.featurehub.sse.model.FeatureState _featureState; List listeners = new ArrayList<>(); - protected ClientContext context; - protected FeatureStore featureStore; - protected FeatureStateBase parentHolder; + protected BaseClientContext context; + protected FeatureStateBase parentHolder; + protected final InternalFeatureRepository repository; public FeatureStateBase( - FeatureStateBase oldHolder, FeatureStore featureStore, String key) { - this(featureStore, key); - - if (oldHolder != null) { - this.listeners = oldHolder.listeners; - } + InternalFeatureRepository repository, + FeatureStateBase parentHolder, String key) { + this(repository, key); + this.parentHolder = parentHolder; } - public FeatureStateBase(FeatureStore featureStore, String key) { + public FeatureStateBase(InternalFeatureRepository repository, String key) { this.key = key; - this.featureStore = featureStore; + this.repository = repository; } - public FeatureState withContext(ClientContext context) { - final FeatureStateBase copy = _copy(); + public FeatureStateBase withContext(BaseClientContext context) { + final FeatureStateBase copy = _copy(); copy.context = context; return copy; } + @NotNull protected FeatureStateBase topFeatureState() { + if (parentHolder == null) { + return this; + } + + return parentHolder.topFeatureState(); + } + + @Nullable protected io.featurehub.sse.model.FeatureState featureState() { // clones for analytics will set the feature state if (_featureState != null) { @@ -60,17 +69,23 @@ protected io.featurehub.sse.model.FeatureState featureState() { } protected void notifyListeners() { - listeners.forEach((sl) -> featureStore.execute(() -> sl.notify(this))); + listeners.forEach((sl) -> repository.execute(() -> sl.notify(this))); + } + + public String getId() { + io.featurehub.sse.model.FeatureState fs = featureState(); + return (fs == null) ? "" : fs.getId().toString(); } @Override - public String getKey() { + public @NotNull String getKey() { return key; } @Override public boolean isLocked() { - return this.featureState() != null && this.featureState().getL() == Boolean.TRUE; + final io.featurehub.sse.model.FeatureState featureState = this.featureState(); + return featureState != null && featureState.getL() == Boolean.TRUE; } @Override @@ -79,7 +94,13 @@ public String getString() { } @Override + @Deprecated public Boolean getBoolean() { + return getFlag(); + } + + @Override + public Boolean getFlag() { Object val = getValue(FeatureValueType.BOOLEAN); if (val == null) { @@ -93,30 +114,68 @@ public Boolean getBoolean() { return Boolean.TRUE.equals(val); } - private Object getValue(FeatureValueType type) { - // unlike js, locking is registered on a per interceptor basis - FeatureValueInterceptor.ValueMatch vm = findIntercept(); + @Nullable + private Object getValue(@Nullable FeatureValueType type) { + return internalGetValue(type, true); + } + + @Override + public FeatureValueType getType() { + io.featurehub.sse.model.FeatureState fs = featureState(); + + return (fs == null) ? null : fs.getType(); + } + + public Object getAnalyticsFreeValue() { + return internalGetValue(null, false); + } + + @Override + public K getValue(Class clazz) { + return clazz.cast(internalGetValue(null, true)); + } + + private Object internalGetValue(@Nullable FeatureValueType passedType, boolean triggerUsage) { + final io.featurehub.sse.model.FeatureState featureState = featureState(); + + boolean locked = featureState != null && Boolean.TRUE.equals(featureState.getL()); + + // unlike js, locking is registered on a per-interceptor basis + FeatureValueInterceptor.ValueMatch vm = repository.findIntercept(locked, key); if (vm != null) { return vm.value; } - final io.featurehub.sse.model.FeatureState featureState = featureState(); - if (featureState == null || featureState.getType() != type) { + if (featureState == null || ( passedType == null && featureState.getType() == null )) { + return null; + } + + final FeatureValueType type = passedType == null ? featureState.getType() : passedType; + + if (featureState.getType() != type) { return null; } if (context != null) { final Applied applied = - featureStore.applyFeature( + repository.applyFeature( featureState.getStrategies(), key, featureState.getId().toString(), context); if (applied.isMatched()) { - return applied.getValue() == null ? null : applied.getValue(); + return triggerUsage ? used(key, featureState.getId(), applied.getValue(), type) : applied.getValue(); } } - return featureState.getValue(); + return triggerUsage ? used(key, featureState.getId(), featureState.getValue(), type) : featureState.getValue(); + } + + Object used(@NotNull String key, @NotNull UUID id, @Nullable Object value, @NotNull FeatureValueType type) { + if (context != null) { + context.used(key, id, value, type); + } + + return value; } private String getAsString(FeatureValueType type) { @@ -146,7 +205,7 @@ public T getJson(Class type) { String rawJson = getRawJson(); try { - return rawJson == null ? null : featureStore.getJsonObjectMapper().readValue(rawJson, type); + return rawJson == null ? null : repository.getJsonObjectMapper().readValue(rawJson, type); } catch (IOException e) { log.warn("Failed to parse JSON", e); return null; @@ -155,34 +214,17 @@ public T getJson(Class type) { @Override public boolean isEnabled() { - return getBoolean() == Boolean.TRUE; + return getFlag() == Boolean.TRUE; } @Override public boolean isSet() { - return featureState() != null && getAsString(featureState().getType()) != null; - } - - protected FeatureValueInterceptor.ValueMatch findIntercept() { - boolean locked = featureState() != null && Boolean.TRUE.equals(featureState().getL()); - return featureStore.getFeatureValueInterceptors().stream() - .filter(vi -> !locked || vi.allowLockOverride) - .map( - vi -> { - FeatureValueInterceptor.ValueMatch vm = vi.interceptor.getValue(key); - if (vm != null && vm.matched) { - return vm; - } else { - return null; - } - }) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); + return getValue((FeatureValueType) null) != null; } + @Override - public void addListener(final FeatureListener listener) { + public void addListener(final @NotNull FeatureListener listener) { if (context != null) { listeners.add((fs) -> listener.notify(this)); } else { @@ -191,8 +233,8 @@ public void addListener(final FeatureListener listener) { } // stores the feature state and triggers notifyListeners if anything changed - // should the notify actually be inside the listener code? given contexts? - public FeatureState setFeatureState(io.featurehub.sse.model.FeatureState featureState) { + // should notify actually be inside the listener code? given contexts? + public FeatureState setFeatureState(io.featurehub.sse.model.FeatureState featureState) { if (featureState == null) return this; Object oldValue = getValue(type()); this._featureState = featureState; @@ -205,43 +247,45 @@ public FeatureState setFeatureState(io.featurehub.sse.model.FeatureState feature @Nullable private Object convertToRespectiveType(io.featurehub.sse.model.FeatureState featureState) { - if (featureState.getValue() == null) { + if (featureState.getValue() == null || featureState.getType() == null) { return null; } + try { switch (featureState.getType()) { case BOOLEAN: return Boolean.parseBoolean(featureState.getValue().toString()); case STRING: + case JSON: return featureState.getValue().toString(); case NUMBER: return new BigDecimal(featureState.getValue().toString()); - case JSON: - return featureState.getValue().toString(); } } catch (Exception ignored) { } + return null; } - protected FeatureState copy() { + protected FeatureState copy() { return _copy(); } - protected FeatureState analyticsCopy() { - final FeatureStateBase aCopy = _copy(); + protected FeatureState analyticsCopy() { + final FeatureStateBase aCopy = _copy(); aCopy._featureState = featureState(); return aCopy; } - protected FeatureStateBase _copy() { - final FeatureStateBase copy = new FeatureStateBase(this, featureStore, key); + protected FeatureStateBase _copy() { + final FeatureStateBase copy = new FeatureStateBase<>(repository, this, key); copy.parentHolder = this; return copy; } - protected boolean exists() { - return featureState() != null; + public boolean exists() { + final io.featurehub.sse.model.FeatureState featureState = featureState(); + return featureState != null && featureState.getVersion() != null && featureState.getVersion() != -1; } protected FeatureValueType type() { diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java index 62626e5..c8d790d 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java @@ -20,26 +20,4 @@ public static String generateXFeatureHubHeaderFromMap(Map> return attributes.entrySet().stream().map(e -> String.format("%s=%s", e.getKey(), URLEncoder.encode(String.join(",", e.getValue())))).sorted().collect(Collectors.joining(",")); } - - static boolean isActive(FeatureRepository repository, Feature feature) { - if (repository == null) { - throw new RuntimeException("You must configure your feature repository before using it."); - } - - FeatureState fs = repository.getFeatureState(feature.name()); - return Boolean.TRUE.equals(fs.getBoolean()); - } - - static boolean exists(FeatureRepository repository, Feature feature) { - FeatureState fs = repository.getFeatureState(feature.name()); - return ((FeatureStateBase)fs).exists(); - } - - static boolean isSet(FeatureRepository repository, Feature feature) { - return repository.getFeatureState(feature.name()).isEnabled(); - } - - static void addListener(FeatureRepository repository, Feature feature, FeatureListener featureListener) { - repository.getFeatureState(feature.name()).addListener(featureListener); - } } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStore.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStore.java deleted file mode 100644 index f00fcca..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStore.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.featurehub.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.sse.model.FeatureRolloutStrategy; -import io.featurehub.sse.model.FeatureState; -import io.featurehub.sse.model.SSEResultState; - -import java.util.List; - -/** - * This interface is only designed for use internally, but we won't hide it in case someone finds a - * particular need elsewhere. - */ -public interface FeatureStore { - /* - * Any incoming state changes from a multi-varied set of possible data. This comes - * from SSE. - */ - void notify(SSEResultState state, String data); - - /** - * Indicate the feature states have updated and if their versions have - * updated or no versions exist, update the repository. - * - * @param states - the features - */ - void notify(List states); - - - /** - * Update the feature states and force them to be updated, ignoring their version numbers. - * This still may not cause events to be triggered as event triggers are done on actual value changes. - * - * @param states - the list of feature states - * @param force - whether we should force the states to change - */ - void notify(List states, boolean force); - - List getFeatureValueInterceptors(); - - Applied applyFeature(List strategies, String key, String featureValueId, - ClientContext cac); - - void execute(Runnable command); - - ObjectMapper getJsonObjectMapper(); - - void setServerEvaluation(boolean val); - - /** - * Tell the repository that its features are not in a valid state. - */ - void notReady(); - - void close(); - - Readyness getReadyness(); -} diff --git a/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java b/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java deleted file mode 100644 index db0f91b..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.featurehub.client; - -public interface GoogleAnalyticsApiClient { - // if you wish to pass in the "value" field to analytics, add this to the "other" map - String GA_VALUE = "gaValue"; - - void postBatchUpdate(String batchData); -} diff --git a/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java b/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java deleted file mode 100644 index ac222ec..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.featurehub.client; - -import io.featurehub.sse.model.FeatureValueType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; -import java.util.function.Function; - -import static io.featurehub.client.BaseClientContext.C_ID; -import static io.featurehub.client.GoogleAnalyticsApiClient.GA_VALUE; -import static io.featurehub.sse.model.FeatureValueType.*; -import static java.lang.Boolean.TRUE; -import static java.nio.charset.StandardCharsets.UTF_8; - -public class GoogleAnalyticsCollector implements AnalyticsCollector { - private static final Logger log = LoggerFactory.getLogger(GoogleAnalyticsCollector.class); - private static final EnumMap> fsTypeToStringMapper = new EnumMap<>(FeatureValueType.class); - private final String uaKey; // this must be provided - private final GoogleAnalyticsApiClient client; - private String cid; // if this is null, we will look for it in "other" and log an error if it isn't there - - public GoogleAnalyticsCollector(String uaKey, String cid, GoogleAnalyticsApiClient client) { - if (uaKey == null) { - throw new RuntimeException("UA id must be provided when using the Google Analytics Collector."); - } - if (client == null) { - throw new RuntimeException("Unable to log any events as there is no client, please configure one."); - } - - this.uaKey = uaKey; - this.cid = cid; - this.client = client; - - fsTypeToStringMapper.put(BOOLEAN, state -> state.getBoolean().equals(TRUE) ? "on" : "off"); - fsTypeToStringMapper.put(STRING, FeatureState::getString); - fsTypeToStringMapper.put(NUMBER, state -> state.getNumber().toPlainString()); - } - - public void setCid(String cid) { - this.cid = cid; - } - - @Override - public void logEvent(String action, Map other, List featureStateAtCurrentTime) { - StringBuilder batchData = new StringBuilder(); - - String finalCid = cid == null ? other.get(C_ID) : cid; - - if (finalCid == null) { - log.error("There is no CID provided for GA, not logging any events."); - return; - } - - String ev; - try { - ev = (other != null && other.get(GA_VALUE) != null) - ? ("&ev=" + URLEncoder.encode(other.get(GA_VALUE), UTF_8.name())) : - ""; - - String baseForEachLine = - "v=1&tid=" + uaKey + "&cid=" + finalCid + "&t=event&ec=FeatureHub%20Event&ea=" + URLEncoder.encode(action, - UTF_8.name()) + ev + "&el="; - - featureStateAtCurrentTime.forEach((fsh) -> { - FeatureStateBase fs = (FeatureStateBase) fsh; - if (!fs.isSet()) return; - - FeatureValueType type = fs.type(); - String line = fsTypeToStringMapper.containsKey(type) ? fsTypeToStringMapper.get(type).apply(fsh) : null; - if (line == null) return; - - try { - line = URLEncoder.encode(fsh.getKey() + " : " + line, UTF_8.name()); - batchData.append(baseForEachLine); - batchData.append(line); - batchData.append("\n"); - } catch (UnsupportedEncodingException e) { // can't happen - } - }); - } catch (UnsupportedEncodingException e) { // can't happen - } - - if (batchData.length() > 0) { - client.postBatchUpdate(batchData.toString()); - } - } -} diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java new file mode 100644 index 0000000..59ad994 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -0,0 +1,83 @@ +package io.featurehub.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.sse.model.FeatureRolloutStrategy; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureValueType; +import io.featurehub.sse.model.SSEResultState; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public interface InternalFeatureRepository extends FeatureRepository { + + /* + * Any incoming state changes from a multi-varied set of possible data. This comes + * from SSE. + */ + void notify(SSEResultState state); + + /** + * Indicate the feature states have updated and if their versions have + * updated or no versions exist, update the repository. + * + * @param features - the features + */ + void updateFeatures(List features); + /** + * Update the feature states and force them to be updated, ignoring their version numbers. + * This still may not cause events to be triggered as event triggers are done on actual value changes. + * + * @param features - the list of feature states + * @param force - whether we should force the states to change + */ + void updateFeatures(List features, boolean force); + boolean updateFeature(FeatureState feature); + boolean updateFeature(FeatureState feature, boolean force); + void deleteFeature(FeatureState feature); + + FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, String key); + + Applied applyFeature(List strategies, String key, String featureValueId, + ClientContext cac); + + void execute(Runnable command); + + ObjectMapper getJsonObjectMapper(); + + void setServerEvaluation(boolean val); + + /** + * Tell the repository that its features are not in a valid state. + */ + void repositoryNotReady(); + + void close(); + + @NotNull Readiness getReadiness(); + + FeatureStateBase getFeat(String key); + FeatureStateBase getFeat(String key, Class clazz); + + void recordAnalyticsEvent(AnalyticsEvent event); + + /** + * Only called by server eval context when we swap context + */ + + /** + * Repository is empty, there are no features but repository is ready. + */ + void repositoryEmpty(); + + void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, + @NotNull Map> attributes, + @Nullable String analyticsUserKey); + + AnalyticsProvider getAnalyticsProvider(); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/Readyness.java b/client-java-core/src/main/java/io/featurehub/client/Readiness.java similarity index 71% rename from client-java-core/src/main/java/io/featurehub/client/Readyness.java rename to client-java-core/src/main/java/io/featurehub/client/Readiness.java index a3dec49..d8cfd9d 100644 --- a/client-java-core/src/main/java/io/featurehub/client/Readyness.java +++ b/client-java-core/src/main/java/io/featurehub/client/Readiness.java @@ -1,5 +1,5 @@ package io.featurehub.client; -public enum Readyness { +public enum Readiness { NotReady, Ready, Failed } diff --git a/client-java-core/src/main/java/io/featurehub/client/ReadynessListener.java b/client-java-core/src/main/java/io/featurehub/client/ReadynessListener.java deleted file mode 100644 index 6943e36..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/ReadynessListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.featurehub.client; - -public interface ReadynessListener { - void notify(Readyness readyness); -} diff --git a/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java b/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java new file mode 100644 index 0000000..a159e96 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java @@ -0,0 +1,5 @@ +package io.featurehub.client; + +public interface RepositoryEventHandler { + void cancel(); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java index 83472a2..61c7ea5 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java @@ -12,16 +12,15 @@ public class ServerEvalFeatureContext extends BaseClientContext { private static final Logger log = LoggerFactory.getLogger(ServerEvalFeatureContext.class); - private final Supplier edgeService; - private EdgeService currentEdgeService; private String xHeader; - private boolean weOwnEdge; - + private final RepositoryEventHandler newFeatureStateHandler; + private final RepositoryEventHandler featureUpdatedHandler; private final MessageDigest shaDigester; - public ServerEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContext repository, - Supplier edgeService) { - super(repository, config); + + public ServerEvalFeatureContext(FeatureHubConfig config, InternalFeatureRepository repository, + EdgeService edgeService) { + super(repository, config, edgeService); try { shaDigester = MessageDigest.getInstance("SHA-256"); @@ -29,47 +28,52 @@ public ServerEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContex throw new RuntimeException(e); } - this.edgeService = edgeService; - this.weOwnEdge = false; + newFeatureStateHandler = repository.registerNewFeatureStateAvailable((fr) -> { + recordRelativeValuesForUser(); + }); + + featureUpdatedHandler = repository.registerFeatureUpdateAvailable((fs) -> { + recordFeatureChangedForUser((FeatureStateBase)fs); + }); + } + + @Override + public void close() { + super.close(); + + newFeatureStateHandler.cancel(); + featureUpdatedHandler.cancel(); } @Override public Future build() { - String newHeader = FeatureStateUtils.generateXFeatureHubHeaderFromMap(clientContext); + String newHeader = FeatureStateUtils.generateXFeatureHubHeaderFromMap(attributes); if (newHeader != null || xHeader != null) { if ((newHeader != null && xHeader == null) || newHeader == null || !newHeader.equals(xHeader)) { xHeader = newHeader; - repository.notReady(); - - if (currentEdgeService != null && currentEdgeService.isRequiresReplacementOnHeaderChange()) { - currentEdgeService.close(); - currentEdgeService = edgeService.get(); - } + repository.repositoryNotReady(); } } - if (currentEdgeService == null) { - currentEdgeService = edgeService.get(); - weOwnEdge = true; - } - - Future change = currentEdgeService.contextChange(newHeader, + Future change = edgeService.contextChange(newHeader, newHeader == null ? "0" : bytesToHex(shaDigester.digest(newHeader.getBytes(StandardCharsets.UTF_8)))); xHeader = newHeader; CompletableFuture future = new CompletableFuture<>(); - try { - change.get(); + repository.execute(() -> { + try { + change.get(); - future.complete(this); - } catch (Exception e) { - log.error("Failed to update", e); - future.completeExceptionally(e); - } + future.complete(this); + } catch (Exception e) { + log.error("Failed to update", e); + future.completeExceptionally(e); + } + }); return future; @@ -87,23 +91,4 @@ private static String bytesToHex(byte[] hash) { } return hexString.toString(); } - - @Override - public EdgeService getEdgeService() { - return currentEdgeService; - } - - public Supplier getEdgeServiceSupplier() { return edgeService; } - - @Override - public boolean exists(String key) { - return false; - } - - @Override - public void close() { - if (weOwnEdge && currentEdgeService != null) { - currentEdgeService.close(); - } - } } diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java new file mode 100644 index 0000000..5a13986 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java @@ -0,0 +1,26 @@ +package io.featurehub.client.analytics; + +import io.featurehub.client.FeatureRepository; +import io.featurehub.client.RepositoryEventHandler; + +import java.util.LinkedList; +import java.util.List; + +public class AnalyticsAdapter { + private List plugins = new LinkedList<>(); + final FeatureRepository repository; + final RepositoryEventHandler analyticsHandlerSub; + + public AnalyticsAdapter(FeatureRepository repo) { + this.repository = repo; + analyticsHandlerSub = repo.registerAnalyticsStream(this::process); + } + + public void close() { + analyticsHandlerSub.cancel(); + } + + public void process(AnalyticsEvent event) { + plugins.forEach((p) -> p.send(event)); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java new file mode 100644 index 0000000..160061c --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java @@ -0,0 +1,39 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +public class AnalyticsEvent { + @Nullable + private String userKey; + @NotNull + private Map additionalParams = new HashMap<>(); + + public AnalyticsEvent(@Nullable String userKey) { + this.userKey = userKey; + } + + public AnalyticsEvent() { + } + + public void setUserKey(String userKey) { + this.userKey = userKey; + } + + public void setAdditionalParams(Map additionalParams) { + this.additionalParams = additionalParams; + } + + public AnalyticsEvent(@Nullable String userKey, @NotNull Map additionalParams) { + this.userKey = userKey; + this.additionalParams = additionalParams; + } + + @NotNull + Map toMap() { + return additionalParams; + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java new file mode 100644 index 0000000..3c60d35 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java @@ -0,0 +1,7 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; + +public interface AnalyticsEventName { + @NotNull String getEventName(); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java new file mode 100644 index 0000000..1138e60 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java @@ -0,0 +1,46 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AnalyticsFeature extends AnalyticsEvent implements AnalyticsEventName { + @NotNull + final Map> attributes; + @NotNull final FeatureHubAnalyticsValue feature; + + public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @NotNull Map> attributes, + @Nullable String userKey) { + this.attributes = attributes; + this.feature = feature; + } + + @NotNull public Map> getAttributes() { + return attributes; + } + + @NotNull public FeatureHubAnalyticsValue getFeature() { + return feature; + } + + @Override + public @NotNull String getEventName() { + return "feature"; + } + + @Override + @NotNull Map toMap() { + Map m = new HashMap<>(super.toMap()); + + m.putAll(attributes); + m.put("feature", feature.key); + m.put("value", feature.id); + m.put("id", feature.id); + + return Collections.unmodifiableMap(m); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java new file mode 100644 index 0000000..7e54738 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java @@ -0,0 +1,34 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AnalyticsFeaturesCollection extends AnalyticsEvent { + @NotNull List featureValues = new ArrayList<>(); + + public AnalyticsFeaturesCollection(@Nullable String userKey, @NotNull Map additionalParams) { + super(userKey, additionalParams); + } + + public void setFeatureValues(List featureValues) { + this.featureValues = featureValues; + } + + public AnalyticsFeaturesCollection() {} + + void ready() {} + + @Override + @NotNull Map toMap() { + Map m = new HashMap<>(super.toMap()); + featureValues.forEach((fv) -> m.put(fv.key, fv.value)); + + return Collections.unmodifiableMap(m); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java new file mode 100644 index 0000000..70ee693 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java @@ -0,0 +1,35 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AnalyticsFeaturesCollectionContext extends AnalyticsFeaturesCollection { + @NotNull + Map> attributes = new HashMap<>(); + + public AnalyticsFeaturesCollectionContext(@Nullable String userKey, @NotNull Map additionalParams) { + super(userKey, additionalParams); + } + + public AnalyticsFeaturesCollectionContext() { + super(); + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + + @Override + @NotNull Map toMap() { + Map m = new HashMap<>(super.toMap()); + + m.putAll(attributes); + + return Collections.unmodifiableMap(m); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java new file mode 100644 index 0000000..a9522ba --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java @@ -0,0 +1,15 @@ +package io.featurehub.client.analytics; + +import java.util.HashMap; +import java.util.Map; + +abstract public class AnalyticsPlugin { + protected final Map defaultEventParams = new HashMap<>(); + protected final boolean unnamedBecomeEventParameters; + + public AnalyticsPlugin(boolean unnamedBecomeEventParameters) { + this.unnamedBecomeEventParameters = unnamedBecomeEventParameters; + } + + abstract void send(AnalyticsEvent event); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java new file mode 100644 index 0000000..c32429b --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java @@ -0,0 +1,30 @@ +package io.featurehub.client.analytics; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public interface AnalyticsProvider { + default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, + @NotNull Map> attributes) { + return new AnalyticsFeature(feature, attributes, null); + } + + default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, + @NotNull Map> attributes, + @Nullable String userKey) { + return new AnalyticsFeature(feature, attributes, userKey); + } + + default AnalyticsFeaturesCollection createAnalyticsCollectionEvent() { + return new AnalyticsFeaturesCollection(); + } + + default AnalyticsFeaturesCollectionContext createAnalyticsContextCollectionEvent() { + return new AnalyticsFeaturesCollectionContext(); + } + + class DefaultAnalyticsProvider implements AnalyticsProvider {} +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java b/client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java new file mode 100644 index 0000000..070baa2 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java @@ -0,0 +1,47 @@ +package io.featurehub.client.analytics; + +import io.featurehub.client.FeatureStateBase; +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class FeatureHubAnalyticsValue { + @NotNull + final String id; + @NotNull + final String key; + @Nullable + final String value; + + @Nullable + static String convert(@Nullable Object value, @Nullable FeatureValueType type) { + if (type == null || value == null) { + return null; + } + + switch (type) { + case BOOLEAN: + return Boolean.TRUE.equals(value) ? "on" : "off"; + case STRING: + case NUMBER: + return value.toString(); + case JSON: + return null; + } + + return null; + } + + public FeatureHubAnalyticsValue(@NotNull String id, @NotNull String key, @Nullable Object value, + @NotNull FeatureValueType type) { + this.id = id; + this.key = key; + this.value = convert(value, type); + } + + public FeatureHubAnalyticsValue(@NotNull FeatureStateBase holder) { + this.id = holder.getId(); + this.key = holder.getKey(); + this.value = convert(holder.getAnalyticsFreeValue(), holder.getType()); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java index 8c90445..92cc051 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java +++ b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java @@ -1,6 +1,8 @@ package io.featurehub.client.edge; +import io.featurehub.client.InternalFeatureRepository; import io.featurehub.sse.model.SSEResultState; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.concurrent.ExecutorService; @@ -15,6 +17,8 @@ public interface EdgeRetryService { void edgeConfigInfo(String config); @Nullable SSEResultState fromValue(String value); + void convertSSEState(@NotNull SSEResultState state, @NotNull String data, @NotNull InternalFeatureRepository + repository); void close(); @@ -33,4 +37,6 @@ public interface EdgeRetryService { int getBackoffMultiplier(); boolean isNotFoundState(); + + boolean isStopped(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index 7b84ced..7f5d599 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -2,12 +2,17 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -29,6 +34,9 @@ public class EdgeRetryer implements EdgeRetryService { private boolean notFoundState = false; private boolean stopped = false; + private final TypeReference> FEATURE_LIST_TYPEDEF = + new TypeReference>() {}; + protected EdgeRetryer(int serverConnectTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, int backoffMultiplier, int maximumBackoffTimeMs) { this.serverConnectTimeoutMs = serverConnectTimeoutMs; @@ -102,6 +110,26 @@ public void edgeConfigInfo(String config) { } } + @Override + public void convertSSEState(@NotNull SSEResultState state, @NotNull String data, + @NotNull InternalFeatureRepository repository) { + try { + if (state == SSEResultState.FEATURES) { + List features = + repository.getJsonObjectMapper().readValue(data, FEATURE_LIST_TYPEDEF); + repository.updateFeatures(features); + } else if (state == SSEResultState.FEATURE) { + repository.updateFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } else if (state == SSEResultState.DELETE_FEATURE) { + repository.deleteFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } + } catch (JsonProcessingException jpe) { + throw new RuntimeException("JSON failed", jpe); + } + } + public void close() { executorService.shutdownNow(); } @@ -146,6 +174,11 @@ public boolean isNotFoundState() { return notFoundState; } + @Override + public boolean isStopped() { + return stopped; + } + // holds the thread for a specific period of time and then returns // while setting the next backoff incase we come back protected void backoff(int baseTime, boolean adjustBackoff) { diff --git a/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java b/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java index 9058e52..5d4cb81 100644 --- a/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java +++ b/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java @@ -10,7 +10,7 @@ public class SdkVersion { private static String version = null; private static String constructedVariant = null; - public static String SSE_API_VERSION = "1.1.2"; // while we are compiled against 1.1.3, we don't understand it + public static String SSE_API_VERSION = "1.1.3"; public static String sdkVersionHeader(String variant) { if (constructedVariant == null) { diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index 714f650..f5f8794 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -47,7 +47,7 @@ class EdgeFeatureHubConfigSpec extends Specification { config.setRepository(repo) and: "I have some values ready to set" def om = new ObjectMapper() - def readynessListener = Mock(ReadynessListener) + def readynessListener = Mock(ReadinessListener) def analyticsCollector = Mock(AnalyticsCollector) def featureValueOverride = Mock(FeatureValueInterceptor) when: "i set all the passthrough settings" @@ -57,7 +57,7 @@ class EdgeFeatureHubConfigSpec extends Specification { config.registerValueInterceptor(false, featureValueOverride) then: 1 * repo.registerValueInterceptor(false, featureValueOverride) - 1 * repo.addReadynessListener(readynessListener) + 1 * repo.addReadinessListener(readynessListener) 1 * repo.addAnalyticCollector(analyticsCollector) 1 * repo.setJsonConfigObjectMapper(om) 0 * _ // nothing else @@ -121,7 +121,7 @@ class EdgeFeatureHubConfigSpec extends Specification { def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") then: config.repository instanceof ClientFeatureRepository - config.readyness == Readyness.NotReady + config.readyness == Readiness.NotReady config.edgeService == FeatureHubTestClientFactory.edgeServiceSupplier } @@ -134,13 +134,13 @@ class EdgeFeatureHubConfigSpec extends Specification { def repo = Mock(FeatureRepositoryContext) config.repository = repo and: "i mock out the futures" - def mockRequest = Mock(Future) + def mockRequest = Mock(Future) when: config.init() then: 1 * supplier.get() >> client 1 * client.contextChange(null, '0') >> mockRequest - 1 * mockRequest.get() >> Readyness.Ready + 1 * mockRequest.get() >> Readiness.Ready 0 * _ } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy index 80099dc..9a869e4 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -32,7 +32,7 @@ class RepositorySpec extends Specification { when: "ask for the readyness status" def ready = repo.readyness then: - ready == Readyness.NotReady + ready == Readiness.NotReady } def "a set of features should trigger readyness and make all features available"() { @@ -44,12 +44,12 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON), ] and: "we have a readyness listener" - def readynessListener = Mock(ReadynessListener) - repo.addReadynessListener(readynessListener) + def readynessListener = Mock(ReadinessListener) + repo.addReadinessListener(readynessListener) when: repo.notify(SSEResultState.FEATURES, new ObjectMapper().writeValueAsString(features)) then: - 1 * readynessListener.notify(Readyness.Ready) + 1 * readynessListener.notify(Readiness.Ready) !repo.getFeatureState('banana').boolean repo.getFeatureState('banana').key == 'banana' repo.exists('banana') @@ -206,16 +206,16 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), ] and: "i notify the repo" - def mockReadyness = Mock(ReadynessListener) - repo.addReadynessListener(mockReadyness) + def mockReadyness = Mock(ReadinessListener) + repo.addReadinessListener(mockReadyness) repo.notify(features) def readyness = repo.readyness when: "i indicate failure" repo.notify(SSEResultState.FAILURE, null) then: "we swap to not ready" - repo.readyness == Readyness.Failed - readyness == Readyness.Ready - 1 * mockReadyness.notify(Readyness.Failed) + repo.readyness == Readiness.Failed + readyness == Readiness.Ready + 1 * mockReadyness.notify(Readiness.Failed) } def "ack and bye are ignored"() { @@ -229,7 +229,7 @@ class RepositorySpec extends Specification { repo.notify(SSEResultState.ACK, null) repo.notify(SSEResultState.BYE, null) then: - repo.readyness == Readyness.Ready + repo.readyness == Readiness.Ready } def "i can attach to a feature before it is added and receive notifications when it is"() { diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy index 3739811..a01cd90 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy @@ -24,7 +24,7 @@ class ServerEvalContextSpec extends Specification { scc.userKey("fred").build() scc.clear().build(); then: "" - 2 * repo.notReady() + 2 * repo.repositoryNotReady() 2 * edge.isRequiresReplacementOnHeaderChange() 2 * edge.contextChange(null, '0') >> { def future = new CompletableFuture<>() diff --git a/client-java-jersey/pom.xml b/client-java-jersey/pom.xml index f026330..1da6c56 100644 --- a/client-java-jersey/pom.xml +++ b/client-java-jersey/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-jersey - 2.8-SNAPSHOT + 3.1-SNAPSHOT java-client-jersey @@ -58,7 +58,7 @@ io.featurehub.sdk java-client-core - [3, 4) + [4, 5) diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyClient.java deleted file mode 100644 index 93a2a4c..0000000 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyClient.java +++ /dev/null @@ -1,328 +0,0 @@ -package io.featurehub.client.jersey; - -import cd.connect.openapi.support.ApiClient; -import io.featurehub.client.EdgeService; -import io.featurehub.client.Feature; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; -import io.featurehub.client.utils.SdkVersion; -import io.featurehub.sse.api.FeatureService; -import io.featurehub.sse.model.FeatureStateUpdate; -import io.featurehub.sse.model.SSEResultState; -import org.glassfish.jersey.jackson.JacksonFeature; -import org.glassfish.jersey.media.sse.EventInput; -import org.glassfish.jersey.media.sse.InboundEvent; -import org.glassfish.jersey.media.sse.SseFeature; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.inject.Singleton; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; -import javax.ws.rs.client.Invocation; -import javax.ws.rs.client.WebTarget; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -@Singleton -@Deprecated -public class JerseyClient implements EdgeService { - private static final Logger log = LoggerFactory.getLogger(JerseyClient.class); - private final WebTarget target; - private boolean initialized; - private final Executor executor; - private final FeatureStore repository; - private final FeatureService featuresService; - private boolean shutdown = false; - private boolean shutdownOnServerFailure = true; - private boolean shutdownOnEdgeFailureConnection = false; - private EventInput eventInput; - private String xFeaturehubHeader; - protected final FeatureHubConfig fhConfig; - private List> waitingClients = new ArrayList<>(); - - // only for testing - private boolean neverConnect = false; - - public JerseyClient(FeatureHubConfig config, FeatureStore repository) { - this(config, !config.isServerEvaluation(), repository, null); - } - - public JerseyClient(FeatureHubConfig config, boolean initializeOnConstruction, - FeatureStore repository, ApiClient apiClient) { - this.repository = repository; - this.fhConfig = config; - - log.trace("new jersey client created"); - - repository.setServerEvaluation(config.isServerEvaluation()); - - Client client = ClientBuilder.newBuilder() - .register(JacksonFeature.class) - .register(SseFeature.class).build(); - - target = makeEventSourceTarget(client, config.getRealtimeUrl()); - executor = makeExecutor(); - - if (apiClient == null) { - apiClient = new ApiClient(client, config.baseUrl()); - } - - featuresService = makeFeatureServiceClient(apiClient); - - if (initializeOnConstruction) { - init(); - } - } - - protected ExecutorService makeExecutor() { - // in case they keep changing the context, it will ask the server and cancel and ask and cancel - // if they are in client mode - return Executors.newFixedThreadPool(4); - } - - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - return client.target(sdkUrl); - } - - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return new FeatureServiceImpl(apiClient); - } - - public void setFeatureState(String key, FeatureStateUpdate update) { - featuresService.setFeatureState(fhConfig.apiKey(), key, update); - } - - public void setFeatureState(Feature feature, FeatureStateUpdate update) { - setFeatureState(feature.name(), update); - } - - // backoff algorithm should be configurable - private void avoidServerDdos() { - if (request != null) { - request.active = false; - request = null; - } - - try { - Thread.sleep(10000); // wait 10 seconds - } catch (InterruptedException e) { - } - - if (!shutdown) { - executor.execute(this::restartRequest); - } - } - - private CurrentRequest request; - - class CurrentRequest { - public boolean active = true; - - public void listenUntilDead() { - if (neverConnect) return; - - long start = System.currentTimeMillis(); - try { - Invocation.Builder request = target.request(); - - if (xFeaturehubHeader != null) { - request = request.header("x-featurehub", xFeaturehubHeader); - } - - request = request.header("X-SDK", SdkVersion.sdkVersionHeader("Java-Jersey2")); - - eventInput = request - .get(EventInput.class); - - while (!eventInput.isClosed()) { - final InboundEvent inboundEvent = eventInput.read(); - initialized = true; - - // we cannot force close the client input, it hangs around and waits for the server - if (!active) { - return; // ignore all data from this call, it is no longer active or relevant - } - - if (shutdown || inboundEvent == null) { // connection has been closed or is shutdown - break; - } - - log.trace("notifying of {}", inboundEvent.getName()); - - final SSEResultState state = fromValue(inboundEvent.getName()); - - if (state != null) { - repository.notify(state, inboundEvent.readData()); - } - - if (state == SSEResultState.FAILURE || state == SSEResultState.FEATURES) { - completeReadyness(); - } - - if (state == SSEResultState.FAILURE && shutdownOnServerFailure) { - log.warn("Failed to connect to FeatureHub Edge on {}, shutting down.", fhConfig.getRealtimeUrl()); - shutdown(); - } - } - } catch (Exception e) { - if (shutdownOnEdgeFailureConnection) { - log.warn("Edge connection failed, shutting down"); - repository.notify(SSEResultState.FAILURE, null); - shutdown(); - } - } - - eventInput = null; // so shutdown doesn't get confused - - initialized = false; - - if (!shutdown) { - log.trace("connection closed, reconnecting"); - // timeout should be configurable - if (System.currentTimeMillis() - start < 2000) { - executor.execute(JerseyClient.this::avoidServerDdos); - } else { - // if we have fallen out, try again - executor.execute(this::listenUntilDead); - } - } else { - completeReadyness(); // ensure we clear everyone out who is waiting - - log.trace("featurehub client shut down"); - } - } - } - - protected SSEResultState fromValue(String name) { - try { - return SSEResultState.fromValue(name); - } catch (Exception e) { - return null; // ok to have unrecognized values - } - } - - public boolean isInitialized() { - return initialized; - } - - private void restartRequest() { - log.trace("starting new request"); - if (request != null) { - request.active = false; - } - - initialized = false; - - request = new CurrentRequest(); - request.listenUntilDead(); - } - - void init() { - if (!initialized) { - executor.execute(this::restartRequest); - } - } - - /** - * Tell the client to shutdown when we next fall off. - */ - public void shutdown() { - log.trace("starting shutdown of jersey edge client"); - this.shutdown = true; - - if (request != null) { - request.active = false; - } - - if (eventInput != null) { - eventInput.close(); - } - - if (executor instanceof ExecutorService) { - ((ExecutorService)executor).shutdownNow(); - } - - log.trace("exiting shutdown of jersey edge client"); - } - - public boolean isShutdownOnServerFailure() { - return shutdownOnServerFailure; - } - - public void setShutdownOnServerFailure(boolean shutdownOnServerFailure) { - this.shutdownOnServerFailure = shutdownOnServerFailure; - } - - public boolean isShutdownOnEdgeFailureConnection() { - return shutdownOnEdgeFailureConnection; - } - - public void setShutdownOnEdgeFailureConnection(boolean shutdownOnEdgeFailureConnection) { - this.shutdownOnEdgeFailureConnection = shutdownOnEdgeFailureConnection; - } - - public String getFeaturehubContextHeader() { - return xFeaturehubHeader; - } - - @Override - public @NotNull Future contextChange(String newHeader, String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - - if (fhConfig.isServerEvaluation() && ((newHeader != null && !newHeader.equals(xFeaturehubHeader)) || !initialized)) { - xFeaturehubHeader = newHeader; - - waitingClients.add(change); - executor.execute(this::restartRequest); - } else { - change.complete(repository.getReadyness()); - } - - return change; - } - - private void completeReadyness() { - List> current = waitingClients; - waitingClients = new ArrayList<>(); - current.forEach(c -> { - try { - c.complete(repository.getReadyness()); - } catch (Exception e) { - log.error("Unable to complete future", e); - } - }); - } - - @Override - public boolean isClientEvaluation() { - return !fhConfig.isServerEvaluation(); - } - - @Override - public void close() { - shutdown(); - } - - @Override - public @NotNull FeatureHubConfig getConfig() { - return fhConfig; - } - - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - - @Override - public void poll() { - // do nothing, its SSE - } -} diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index eac5b3d..c41a839 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -3,14 +3,14 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; +import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.edge.EdgeRetryer; import java.util.function.Supplier; public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(FeatureHubConfig config, FeatureStore repository) { + public Supplier createEdgeService(FeatureHubConfig config, InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 9b26859..00703a1 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -1,13 +1,15 @@ package io.featurehub.client.jersey; +import com.fasterxml.jackson.core.type.TypeReference; import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; import io.featurehub.client.edge.EdgeConnectionState; import io.featurehub.client.edge.EdgeReconnector; import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.utils.SdkVersion; +import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; import javax.ws.rs.WebApplicationException; import javax.ws.rs.client.Client; @@ -32,19 +34,25 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { private static final Logger log = LoggerFactory.getLogger(JerseySSEClient.class); - private final FeatureStore repository; + private final InternalFeatureRepository repository; private final FeatureHubConfig config; private String xFeaturehubHeader; private final EdgeRetryService retryer; private EventInput eventSource; private final WebTarget target; - private final List> waitingClients = new ArrayList<>(); + private final List> waitingClients = new ArrayList<>(); - public JerseySSEClient(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) { + + public JerseySSEClient(InternalFeatureRepository repository, FeatureHubConfig config, EdgeRetryService retryer) { this.repository = repository; this.config = config; this.retryer = retryer; + if (config.isServerEvaluation()) { + log.warn("Jersey SSE client hangs on Context attribute changes for up to 30 seconds, it is recommending using " + + "the pure SSE client"); + } + Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class) .register(SseFeature.class).build(); @@ -60,8 +68,8 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { } @Override - public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); + public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); if (config.isServerEvaluation() && ( @@ -92,6 +100,11 @@ public boolean isClientEvaluation() { return !config.isServerEvaluation(); } + @Override + public boolean isStopped() { + return retryer.isStopped(); + } + @Override public void close() { if (eventSource != null) { @@ -108,11 +121,6 @@ public void close() { return config; } - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - protected EventInput makeEventSource() { Invocation.Builder request = target.request(); @@ -171,8 +179,8 @@ private void initEventSource() { if (state == SSEResultState.CONFIG) { retryer.edgeConfigInfo(data); - } else { - repository.notify(state, data); + } else if (data != null) { + retryer.convertSSEState(state, data, repository); } // reset the timer @@ -203,8 +211,8 @@ private void initEventSource() { log.trace("[featurehub-sdk] closed"); // we never received a satisfactory connection - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); + if (repository.getReadyness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); } // send this once we are actually disconnected and not before @@ -229,10 +237,18 @@ private void onMakeEventSourceException(Exception e) { } @Override - public void poll() { + public Future poll() { if (eventSource == null) { + final CompletableFuture change = new CompletableFuture<>(); + + waitingClients.add(change); + retryer.getExecutorService().submit(this::initEventSource); + + return change; } + + return CompletableFuture.completedFuture(repository.getReadiness()); } @Override diff --git a/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java index 0a72286..5c99a4f 100644 --- a/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java +++ b/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java @@ -27,7 +27,7 @@ public static void main(String[] args) throws Exception { FeatureRepository cfr = ctx.getRepository(); - cfr.addReadynessListener((rl) -> System.out.println("Readyness is " + rl)); + cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); System.out.println("Wait for readyness or hit enter if server eval key"); diff --git a/client-java-jersey3/pom.xml b/client-java-jersey3/pom.xml index 58701f0..3388da7 100644 --- a/client-java-jersey3/pom.xml +++ b/client-java-jersey3/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-jersey3 - 1.6-SNAPSHOT + 2.1-SNAPSHOT java-client-jersey3 @@ -60,7 +60,7 @@ io.featurehub.sdk java-client-core - [3, 4) + [4, 5) diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java deleted file mode 100644 index 1e79090..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java +++ /dev/null @@ -1,334 +0,0 @@ -package io.featurehub.client.jersey; - -import cd.connect.openapi.support.ApiClient; -import io.featurehub.client.EdgeService; -import io.featurehub.client.Feature; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; -import io.featurehub.client.utils.SdkVersion; -import io.featurehub.sse.api.FeatureService; -import io.featurehub.sse.model.FeatureStateUpdate; -import io.featurehub.sse.model.SSEResultState; -import org.glassfish.jersey.jackson.JacksonFeature; -import org.glassfish.jersey.media.sse.EventInput; -import org.glassfish.jersey.media.sse.InboundEvent; -import org.glassfish.jersey.media.sse.SseFeature; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import jakarta.inject.Singleton; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Invocation; -import jakarta.ws.rs.client.WebTarget; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -@Singleton -@Deprecated -public class JerseyClient implements EdgeService { - private static final Logger log = LoggerFactory.getLogger(JerseyClient.class); - private final WebTarget target; - private boolean initialized; - private final Executor executor; - private final FeatureStore repository; - private final FeatureService featuresService; - private boolean shutdown = false; - private boolean shutdownOnServerFailure = true; - private boolean shutdownOnEdgeFailureConnection = false; - private EventInput eventInput; - private String xFeaturehubHeader; - protected final FeatureHubConfig fhConfig; - private List> waitingClients = new ArrayList<>(); - - // only for testing - private boolean neverConnect = false; - - public JerseyClient(FeatureHubConfig config, FeatureStore repository) { - this(config, !config.isServerEvaluation(), repository, null); - } - - public JerseyClient(FeatureHubConfig config, boolean initializeOnConstruction, - FeatureStore repository, ApiClient apiClient) { - this.repository = repository; - this.fhConfig = config; - - log.trace("new jersey client created"); - - repository.setServerEvaluation(config.isServerEvaluation()); - - Client client = ClientBuilder.newBuilder() - .register(JacksonFeature.class) - .register(SseFeature.class).build(); - - target = makeEventSourceTarget(client, config.getRealtimeUrl()); - executor = makeExecutor(); - - if (apiClient == null) { - apiClient = new ApiClient(client, config.baseUrl()); - } - - featuresService = makeFeatureServiceClient(apiClient); - - if (initializeOnConstruction) { - init(); - } - } - - protected ExecutorService makeExecutor() { - // in case they keep changing the context, it will ask the server and cancel and ask and cancel - // if they are in client mode - return Executors.newFixedThreadPool(4); - } - - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - return client.target(sdkUrl); - } - - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return new FeatureServiceImpl(apiClient); - } - - public void setFeatureState(String key, FeatureStateUpdate update) { - featuresService.setFeatureState(fhConfig.apiKey(), key, update); - } - - public void setFeatureState(Feature feature, FeatureStateUpdate update) { - setFeatureState(feature.name(), update); - } - - // backoff algorithm should be configurable - private void avoidServerDdos() { - if (request != null) { - request.active = false; - request = null; - } - - try { - Thread.sleep(10000); // wait 10 seconds - } catch (InterruptedException ignored) { - } - - if (!shutdown) { - executor.execute(this::restartRequest); - } - } - - private CurrentRequest request; - - class CurrentRequest { - public boolean active = true; - - public void listenUntilDead() { - if (neverConnect) return; - - long start = System.currentTimeMillis(); - try { - Invocation.Builder request = target.request(); - - if (xFeaturehubHeader != null) { - request = request.header("x-featurehub", xFeaturehubHeader); - } - - request = request.header("X-SDK", SdkVersion.sdkVersionHeader("Java-Jersey2")); - - eventInput = request - .get(EventInput.class); - - while (!eventInput.isClosed()) { - final InboundEvent inboundEvent = eventInput.read(); - initialized = true; - - // we cannot force close the client input, it hangs around and waits for the server - if (!active) { - return; // ignore all data from this call, it is no longer active or relevant - } - - if (shutdown || inboundEvent == null) { // connection has been closed or is shutdown - break; - } - - log.trace("notifying of {}", inboundEvent.getName()); - - try { - final SSEResultState state = fromValue(inboundEvent.getName()); - - if (state != null && state != SSEResultState.CONFIG) { - repository.notify(state, inboundEvent.readData()); - } else if (state == SSEResultState.CONFIG) { - - } - - if (state == SSEResultState.FAILURE || state == SSEResultState.FEATURES) { - completeReadyness(); - } - - if (state == SSEResultState.FAILURE && shutdownOnServerFailure) { - log.warn("Failed to connect to FeatureHub Edge on {}, shutting down.", fhConfig.getRealtimeUrl()); - shutdown(); - } - } catch (Exception e) { - log.warn("Failed to parse SSE state {}", inboundEvent.getName(), e); - } - } - } catch (Exception e) { - if (shutdownOnEdgeFailureConnection) { - log.warn("Edge connection failed, shutting down"); - repository.notify(SSEResultState.FAILURE, null); - shutdown(); - } - } - - eventInput = null; // so shutdown doesn't get confused - - initialized = false; - - if (!shutdown) { - log.trace("connection closed, reconnecting"); - // timeout should be configurable - if (System.currentTimeMillis() - start < 2000) { - executor.execute(JerseyClient.this::avoidServerDdos); - } else { - // if we have fallen out, try again - executor.execute(this::listenUntilDead); - } - } else { - completeReadyness(); // ensure we clear everyone out who is waiting - - log.trace("featurehub client shut down"); - } - } - } - - protected SSEResultState fromValue(String name) { - try { - return SSEResultState.fromValue(name); - } catch (Exception e) { - return null; // ok to have unrecognized values - } - } - - public boolean isInitialized() { - return initialized; - } - - private void restartRequest() { - log.trace("starting new request"); - if (request != null) { - request.active = false; - } - - initialized = false; - - request = new CurrentRequest(); - request.listenUntilDead(); - } - - void init() { - if (!initialized) { - executor.execute(this::restartRequest); - } - } - - /** - * Tell the client to shutdown when we next fall off. - */ - public void shutdown() { - log.trace("starting shutdown of jersey edge client"); - this.shutdown = true; - - if (request != null) { - request.active = false; - } - - if (eventInput != null) { - eventInput.close(); - } - - if (executor instanceof ExecutorService) { - ((ExecutorService)executor).shutdownNow(); - } - - log.trace("exiting shutdown of jersey edge client"); - } - - public boolean isShutdownOnServerFailure() { - return shutdownOnServerFailure; - } - - public void setShutdownOnServerFailure(boolean shutdownOnServerFailure) { - this.shutdownOnServerFailure = shutdownOnServerFailure; - } - - public boolean isShutdownOnEdgeFailureConnection() { - return shutdownOnEdgeFailureConnection; - } - - public void setShutdownOnEdgeFailureConnection(boolean shutdownOnEdgeFailureConnection) { - this.shutdownOnEdgeFailureConnection = shutdownOnEdgeFailureConnection; - } - - public String getFeaturehubContextHeader() { - return xFeaturehubHeader; - } - - @Override - public @NotNull Future contextChange(String newHeader, String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - - if (fhConfig.isServerEvaluation() && ((newHeader != null && !newHeader.equals(xFeaturehubHeader)) || !initialized)) { - xFeaturehubHeader = newHeader; - - waitingClients.add(change); - executor.execute(this::restartRequest); - } else { - change.complete(repository.getReadyness()); - } - - return change; - } - - private void completeReadyness() { - List> current = waitingClients; - waitingClients = new ArrayList<>(); - current.forEach(c -> { - try { - c.complete(repository.getReadyness()); - } catch (Exception e) { - log.error("Unable to complete future", e); - } - }); - } - - @Override - public boolean isClientEvaluation() { - return !fhConfig.isServerEvaluation(); - } - - @Override - public void close() { - shutdown(); - } - - @Override - public @NotNull FeatureHubConfig getConfig() { - return fhConfig; - } - - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - - @Override - public void poll() { - // do nothing, its SSE - } -} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index eac5b3d..3dc890e 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -3,14 +3,22 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; +import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.edge.EdgeRetryer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.function.Supplier; public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(FeatureHubConfig config, FeatureStore repository) { + public Supplier createEdgeService(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } + + @Override + public Supplier createEdgeService(@NotNull FeatureHubConfig config) { + return createEdgeService(config, null); + } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 609ff45..68c3dfe 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -2,8 +2,8 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; import io.featurehub.client.edge.EdgeConnectionState; import io.featurehub.client.edge.EdgeReconnector; import io.featurehub.client.edge.EdgeRetryService; @@ -32,19 +32,28 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { private static final Logger log = LoggerFactory.getLogger(JerseySSEClient.class); - private final FeatureStore repository; + private final InternalFeatureRepository repository; private final FeatureHubConfig config; private String xFeaturehubHeader; private final EdgeRetryService retryer; private EventInput eventSource; private final WebTarget target; - private final List> waitingClients = new ArrayList<>(); + private final List> waitingClients = new ArrayList<>(); - public JerseySSEClient(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) { - this.repository = repository; + public JerseySSEClient(@NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { + this((InternalFeatureRepository) null, config, retryer); + } + public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, + @NotNull EdgeRetryService retryer) { + this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; this.config = config; this.retryer = retryer; + if (config.isServerEvaluation()) { + log.warn("Jersey SSE client hangs on Context attribute changes for up to 30 seconds, it is recommending using " + + "the pure SSE client"); + } + Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class) .register(SseFeature.class).build(); @@ -60,8 +69,8 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { } @Override - public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); + public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); if (config.isServerEvaluation() && ( @@ -92,6 +101,11 @@ public boolean isClientEvaluation() { return !config.isServerEvaluation(); } + @Override + public boolean isStopped() { + return retryer.isStopped(); + } + @Override public void close() { if (eventSource != null) { @@ -108,11 +122,6 @@ public void close() { return config; } - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - protected EventInput makeEventSource() { Invocation.Builder request = target.request(); @@ -171,8 +180,8 @@ private void initEventSource() { if (state == SSEResultState.CONFIG) { retryer.edgeConfigInfo(data); - } else { - repository.notify(state, data); + } else if (data != null) { + retryer.convertSSEState(state, data, repository); } // reset the timer @@ -203,8 +212,8 @@ private void initEventSource() { log.trace("[featurehub-sdk] closed"); // we never received a satisfactory connection - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); + if (repository.getReadyness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); } // send this once we are actually disconnected and not before @@ -229,10 +238,16 @@ private void onMakeEventSourceException(Exception e) { } @Override - public void poll() { + public Future poll() { if (eventSource == null) { + final CompletableFuture change = new CompletableFuture<>(); + + waitingClients.add(change); retryer.getExecutorService().submit(this::initEventSource); + return change; } + + return CompletableFuture.completedFuture(repository.getReadiness()); } @Override diff --git a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java index 8925806..6a533bf 100644 --- a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java +++ b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java @@ -6,7 +6,6 @@ import io.featurehub.client.Feature; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.FeatureRepository; -import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.sse.model.FeatureStateUpdate; import io.featurehub.sse.model.StrategyAttributeDeviceName; @@ -26,9 +25,9 @@ public static void main(String[] args) throws Exception { FeatureRepository cfr = config.getRepository(); - cfr.addReadynessListener((rl) -> System.out.println("Readyness is " + rl)); + cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); - config.setEdgeService(() -> new JerseySSEClient(config.getRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); + config.setEdgeService(() -> new JerseySSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); config.init(); final ClientContext ctx = config.newContext(); diff --git a/client-java-sse/pom.xml b/client-java-sse/pom.xml index 359240d..8b7dd0b 100644 --- a/client-java-sse/pom.xml +++ b/client-java-sse/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-sse - 1.5-SNAPSHOT + 2.1-SNAPSHOT java-client-sse @@ -47,7 +47,7 @@ io.featurehub.sdk java-client-core - [3, 4) + [4, 5) diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java index 91cbfac..fe76690 100644 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java +++ b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java @@ -2,8 +2,9 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; import io.featurehub.client.edge.EdgeConnectionState; import io.featurehub.client.edge.EdgeReconnector; import io.featurehub.client.edge.EdgeRetryService; @@ -28,27 +29,35 @@ public class SSEClient implements EdgeService, EdgeReconnector { private static final Logger log = LoggerFactory.getLogger(SSEClient.class); - private final FeatureStore repository; + private final InternalFeatureRepository repository; private final FeatureHubConfig config; private EventSource eventSource; private EventSource.Factory eventSourceFactory; private OkHttpClient client; private String xFeaturehubHeader; private final EdgeRetryService retryer; - private final List> waitingClients = new ArrayList<>(); + private final List> waitingClients = new ArrayList<>(); - public SSEClient(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) { - this.repository = repository; + public SSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, + @NotNull EdgeRetryService retryer) { + this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; this.config = config; this.retryer = retryer; } + public SSEClient(@NotNull FeatureHubConfig config, + @NotNull EdgeRetryService retryer) { + this(null, config, retryer); + } + @Override - public void poll() { + public Future poll() { if (eventSource == null) { initEventSource(); } + + return CompletableFuture.completedFuture(repository.getReadiness()); } private boolean connectionSaidBye; @@ -73,8 +82,8 @@ private void initEventSource() { public void onClosed(@NotNull EventSource eventSource) { log.trace("[featurehub-sdk] closed"); - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); + if (repository.getReadiness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); } // send this once we are actually disconnected and not before @@ -84,7 +93,7 @@ public void onClosed(@NotNull EventSource eventSource) { @Override public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, - @NotNull String data) { + @Nullable String data) { try { final SSEResultState state = retryer.fromValue(type); @@ -96,8 +105,8 @@ public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Null if (state == SSEResultState.CONFIG) { retryer.edgeConfigInfo(data); - } else { - repository.notify(state, data); + } else if (data != null) { + retryer.convertSSEState(state, data, repository); } // reset the timer @@ -115,7 +124,7 @@ public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Null // tell any waiting clients we are now ready if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadyness())); + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } } catch (Exception e) { log.error("[featurehub-sdk] failed to decode packet {}:{}", type, data, e); @@ -125,8 +134,8 @@ public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Null @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { log.trace("[featurehub-sdk] failed to connect to {} - {}", config.baseUrl(), response, t); - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); + if (repository.getReadiness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); } retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); @@ -154,8 +163,8 @@ protected EventSource makeEventSource(Request request, EventSourceListener liste @Override - public @NotNull Future contextChange(String newHeader, String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); + public @NotNull Future contextChange(String newHeader, String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); if (config.isServerEvaluation() && ( @@ -178,7 +187,7 @@ protected EventSource makeEventSource(Request request, EventSourceListener liste poll(); } else { - change.complete(repository.getReadyness()); + change.complete(repository.getReadiness()); } return change; @@ -189,6 +198,11 @@ public boolean isClientEvaluation() { return !config.isServerEvaluation(); } + @Override + public boolean isStopped() { + return retryer.isStopped(); + } + @Override public void close() { // don't let it try connecting again @@ -218,11 +232,6 @@ public void close() { return config; } - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return false; - } - @Override public void reconnect() { initEventSource(); diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java index 480d576..fad5bac 100644 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java +++ b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java @@ -3,15 +3,24 @@ import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; +import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.edge.EdgeRetryer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.function.Supplier; public class SSEClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository) { + public Supplier createEdgeService(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository) { return () -> - new SSEClient(repository, url, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + } + + @Override + public Supplier createEdgeService(@NotNull FeatureHubConfig config) { + return () -> + new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } } diff --git a/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy b/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy index ba128f1..3b18c58 100644 --- a/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy +++ b/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy @@ -1,14 +1,13 @@ package io.featurehub.edge.sse -import io.featurehub.client.ClientFeatureRepository + import io.featurehub.client.FeatureHubConfig -import io.featurehub.client.FeatureStore -import io.featurehub.client.Readyness +import io.featurehub.client.InternalFeatureRepository +import io.featurehub.client.Readiness import io.featurehub.client.edge.EdgeConnectionState import io.featurehub.client.edge.EdgeRetryService import io.featurehub.sse.model.SSEResultState import okhttp3.Request -import okhttp3.Response import okhttp3.sse.EventSource import okhttp3.sse.EventSourceListener import spock.lang.Specification @@ -16,7 +15,7 @@ import spock.lang.Specification class SSEClientSpec extends Specification { EventSource mockEventSource EdgeRetryService retry - FeatureStore repository + InternalFeatureRepository repository FeatureHubConfig config EventSourceListener esListener SSEClient client @@ -25,7 +24,7 @@ class SSEClientSpec extends Specification { def setup() { mockEventSource = Mock(EventSource) retry = Mock(EdgeRetryService) - repository = Mock(FeatureStore) + repository = Mock(InternalFeatureRepository) config = Mock(FeatureHubConfig) config.realtimeUrl >> "http://special" @@ -78,7 +77,7 @@ class SSEClientSpec extends Specification { 1 * retry.fromValue('features') >> SSEResultState.FEATURES 1 * retry.fromValue('bye') >> SSEResultState.BYE 1 * repository.notify(SSEResultState.FAILURE, null) - 1 * repository.readyness >> Readyness.NotReady + 1 * repository.readyness >> Readiness.NotReady } def "success then close with no bye"() { @@ -91,7 +90,7 @@ class SSEClientSpec extends Specification { 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 1 * retry.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, client) 1 * repository.notify(SSEResultState.FAILURE, null) - 1 * repository.readyness >> Readyness.NotReady + 1 * repository.readyness >> Readiness.NotReady 1 * retry.fromValue('features') >> SSEResultState.FEATURES } @@ -101,7 +100,7 @@ class SSEClientSpec extends Specification { // esListener.onOpen(mockEventSource, Mock(Response)) esListener.onFailure(mockEventSource, null, null) then: - 1 * repository.readyness >> Readyness.NotReady + 1 * repository.readyness >> Readiness.NotReady 1 * repository.notify(SSEResultState.FAILURE, null) 1 * retry.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, client) } @@ -112,10 +111,10 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, "1", "features", "data") then: 1 * repository.notify(SSEResultState.FEATURES, "data") - 1 * repository.readyness >> Readyness.Failed + 1 * repository.readyness >> Readiness.Failed 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 1 * retry.fromValue('features') >> SSEResultState.FEATURES - future.get() == Readyness.Failed + future.get() == Readiness.Failed } def "when i context change with a server side key, it creates a request with the header"() { @@ -148,13 +147,13 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, '1', 'features', 'data') then: 2 * config.serverEvaluation >> true - 2 * repository.readyness >> Readyness.Ready + 2 * repository.readyness >> Readiness.Ready 1 * retry.fromValue('features') >> SSEResultState.FEATURES request.header("x-featurehub") == "header2" future1.done future2.done - future1.get() == Readyness.Ready - future2.get() == Readyness.Ready + future1.get() == Readiness.Ready + future2.get() == Readiness.Ready } def "when config says client evaluated code, this will echo"() { diff --git a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java b/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java index 00a9bed..a78530a 100644 --- a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java +++ b/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java @@ -18,13 +18,15 @@ public static void main(String[] args) throws Exception { ClientFeatureRepository cfr = new ClientFeatureRepository(); EdgeRetryer retryer = EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build(); - final ClientContext ctx = config.newContext(cfr, () -> new SSEClient(cfr, config, retryer)).build().get(); - ctx.getRepository().addReadynessListener(rl -> System.out.println("readyness " + rl.toString())); + config.setEdgeService(() -> new SSEClient(cfr, config, retryer)); + config.setRepository(cfr); + final ClientContext ctx = config.newContext(cfr, ).build().get(); + ctx.getRepository().addReadinessListener(rl -> System.out.println("readyness " + rl.toString())); final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); - cfr.addReadynessListener((rl) -> System.out.println("Readyness is " + rl)); + cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); System.out.println("Wait for readyness or hit enter if server eval key"); diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 82f95d6..14090a5 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -29,13 +29,13 @@ io.featurehub.sdk java-client-sse - [1.4, 2) + [2.1-SNAPSHOT, 3) io.featurehub.sdk java-client-android - [2, 3) + [3.1-SNAPSHOT, 4) diff --git a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java index 41957b8..ce1fab1 100644 --- a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java +++ b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java @@ -3,7 +3,7 @@ import io.featurehub.android.FeatureHubClient; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.Readyness; +import io.featurehub.client.Readiness; import io.featurehub.edge.sse.SSEClientFactory; import org.jetbrains.annotations.NotNull; @@ -25,15 +25,13 @@ public static void main(String[] args) throws ExecutionException, InterruptedExc FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); // now we _directly_ create the REST based client, pointing it at our config and our repository - FeatureHubClient client = new FeatureHubClient(config.baseUrl(), - Collections.singletonList(config.apiKey()), - config.getRepository(), config); + FeatureHubClient client = new FeatureHubClient(config); // and now we block, waiting for it to connect and tell us if it is ready or not - if (client.contextChange(null, "0").get() == Readyness.Ready) { + if (client.contextChange(null, "0").get() == Readiness.Ready) { client.close(); // make sure you close it, it has a background thread // once it is ready, we tell the config to use SSE as its connector, and start the config going. - config.setEdgeService(new SSEClientFactory().createEdgeService(config, config.getRepository())); + config.setEdgeService(new SSEClientFactory().createEdgeService(config)); config.init(); System.out.println("ready and waiting for updates via SSE"); diff --git a/examples/todo-java/pom.xml b/examples/todo-java/pom.xml index 18856e7..820c588 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java/pom.xml @@ -33,19 +33,19 @@ io.featurehub.sdk java-client-jersey3 - [1.1, 2) + [2.1-SNAPSHOT, 3) io.featurehub.sdk java-client-sse - [1.2-SNAPSHOT, 2) + [2.1-SNAPSHOT, 3) io.featurehub.sdk java-client-android - [2, 3) + [3.1-SNAPSHOT, 4) diff --git a/examples/todo-java/src/main/java/todo/backend/Application.java b/examples/todo-java/src/main/java/todo/backend/Application.java index 06c0b94..0b14f9b 100644 --- a/examples/todo-java/src/main/java/todo/backend/Application.java +++ b/examples/todo-java/src/main/java/todo/backend/Application.java @@ -4,7 +4,7 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; -import io.featurehub.client.Readyness; +import io.featurehub.client.Readiness; import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; import org.glassfish.jersey.internal.inject.AbstractBinder; @@ -52,8 +52,8 @@ protected void configure() { // call "server.start()" here if you wish to start the application without waiting for features log.info("Waiting on a complete list of features before starting."); - fhSource.getRepository().addReadynessListener((ready) -> { - if (ready == Readyness.Ready) { + fhSource.getConfig().addReadinessListener((ready) -> { + if (ready == Readiness.Ready) { try { server.start(); } catch (IOException e) { @@ -62,13 +62,14 @@ protected void configure() { } log.info("Application started. (HTTP/2 enabled!) -> {}", BASE_URI); - } else if (ready == Readyness.Failed) { + } else if (ready == Readiness.Failed) { log.info("Connection failed, wait for it to come back up."); } }); ApplicationLifecycleManager.registerListener(trans -> { if (trans.next == LifecycleStatus.TERMINATING) { + fhSource.close(); server.shutdown(10, TimeUnit.SECONDS); } }); diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java index 452cefd..668eaf3 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java @@ -1,11 +1,12 @@ package todo.backend; import io.featurehub.client.ClientContext; -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureRepositoryContext; +import io.featurehub.client.FeatureHubConfig; + +import java.util.concurrent.Future; public interface FeatureHub { ClientContext fhClient(); - FeatureRepositoryContext getRepository(); - void poll(); + FeatureHubConfig getConfig(); + Future poll(); } diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index d944290..18e1e67 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -6,16 +6,15 @@ import io.featurehub.client.ClientContext; import io.featurehub.client.ClientFeatureRepository; import io.featurehub.client.EdgeFeatureHubConfig; -import io.featurehub.client.FeatureRepositoryContext; -import io.featurehub.client.GoogleAnalyticsCollector; +import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; -import io.featurehub.client.jersey.GoogleAnalyticsJerseyApiClient; import io.featurehub.client.jersey.JerseySSEClient; import io.featurehub.edge.sse.SSEClient; import org.jetbrains.annotations.Nullable; -import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.host") @@ -29,7 +28,7 @@ public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.sdk") String clientSdk = "jersey3"; - private final FeatureRepositoryContext repository; + private final ClientFeatureRepository repository; private final EdgeFeatureHubConfig config; @Nullable private final FeatureHubClient androidClient; @@ -42,10 +41,10 @@ public FeatureHubSource() { repository = new ClientFeatureRepository(5); repository.registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - if (analyticsCid.length() > 0 && analyticsKey.length() > 0) { - repository.addAnalyticCollector(new GoogleAnalyticsCollector(analyticsKey, analyticsCid, - new GoogleAnalyticsJerseyApiClient())); - } +// if (analyticsCid.length() > 0 && analyticsKey.length() > 0) { +// repository.addAnalyticCollector(new GoogleAnalyticsCollector(analyticsKey, analyticsCid, +// new GoogleAnalyticsJerseyApiClient())); +// } config.setRepository(repository); @@ -56,8 +55,7 @@ public FeatureHubSource() { config.setEdgeService(() -> jerseyClient); androidClient = null; } else if (clientSdk.equals("android")) { - final FeatureHubClient client = new FeatureHubClient(featureHubUrl, Collections.singleton(sdkKey), repository, - config, 1); + final FeatureHubClient client = new FeatureHubClient(config, 1); config.setEdgeService(() -> client); androidClient = client; } else if (clientSdk.equals("sse")) { @@ -77,14 +75,20 @@ public ClientContext fhClient() { } @Override - public FeatureRepositoryContext getRepository() { - return repository; + public FeatureHubConfig getConfig() { + return config; } @Override - public void poll() { + public Future poll() { if (androidClient != null) { - androidClient.poll(); + return androidClient.poll(); } + + return CompletableFuture.completedFuture(true); + } + + public void close() { + config.close(); } } From 8a03a147ee003276e0c5ddc2ea07e3b0d9f28dec Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Mon, 5 Jun 2023 21:40:50 +1200 Subject: [PATCH 02/22] major revision of the Java SDK Driven by use-updates and the new analytics API --- client-java-android/README.adoc | 24 +- .../featurehub/android/FeatureHubClient.java | 36 ++- .../android/FeatureHubClientMockSpec.groovy | 58 ++-- .../android/FeatureHubClientSpec.groovy | 52 ---- client-java-android21/README.adoc | 41 ++- client-java-api/edge-api.yaml | 254 ------------------ client-java-api/pom.xml | 21 +- .../java/io/featurehub/client/Applied.java | 8 + .../featurehub/client/BaseClientContext.java | 20 +- .../io/featurehub/client/ClientContext.java | 8 + .../client/ClientEvalFeatureContext.java | 2 +- .../client/ClientFeatureRepository.java | 102 +++---- .../client/EdgeFeatureHubConfig.java | 37 +-- .../featurehub/client/FeatureHubConfig.java | 13 +- .../io/featurehub/client/FeatureListener.java | 2 +- .../featurehub/client/FeatureRepository.java | 2 - .../featurehub/client/FeatureStateBase.java | 144 +++++----- .../io/featurehub/client/InternalContext.java | 13 + .../client/InternalFeatureRepository.java | 41 ++- .../client/ServerEvalFeatureContext.java | 5 +- .../featurehub/client/ThreadLocalContext.java | 39 +++ .../client/analytics/AnalyticsEvent.java | 10 +- .../client/analytics/AnalyticsFeature.java | 12 +- .../AnalyticsFeaturesCollection.java | 4 +- .../AnalyticsFeaturesCollectionContext.java | 4 +- .../client/analytics/AnalyticsProvider.java | 2 +- .../client/BaseClientContextSpec.groovy | 43 +++ .../client/EdgeFeatureHubConfigSpec.groovy | 106 ++++---- .../client/FeatureHubTestClientFactory.groovy | 60 ++++- .../featurehub/client/InterceptorSpec.groovy | 56 ++-- .../io/featurehub/client/ListenerSpec.groovy | 32 ++- .../featurehub/client/RepositorySpec.groovy | 236 ++++++---------- .../client/ServerEvalContextSpec.groovy | 23 +- .../io/featurehub/client/StrategySpec.groovy | 56 ++-- .../io/featurehub/client/TestContext.groovy | 20 +- client-java-jersey/pom.xml | 48 ++-- .../GoogleAnalyticsJerseyApiClient.java | 22 -- .../jersey/JerseyFeatureHubClientFactory.java | 10 +- .../client/jersey/JerseySSEClient.java | 6 +- ...atureRequiredApplicationEventListener.java | 36 +-- .../client/jersey/JerseyClientSpec.groovy | 202 +++++++------- .../client/jersey/SSETestHarness.groovy | 44 +++ .../client/jersey/JerseyClientSample.java | 16 +- .../GoogleAnalyticsJerseyApiClient.java | 22 -- .../client/jersey/JerseySSEClient.java | 8 +- ...atureRequiredApplicationEventListener.java | 38 ++- .../backend/AnalyticsRequestMeasurement.java | 27 ++ .../main/java/todo/backend/Application.java | 29 +- .../main/java/todo/backend/FeatureHub.java | 2 - .../java/todo/backend/FeatureHubSource.java | 37 ++- .../resources/FeatureAnalyticsFilter.java | 29 +- .../backend/resources/HealthResource.java | 29 ++ .../todo/backend/resources/TodoResource.java | 34 ++- 53 files changed, 1044 insertions(+), 1181 deletions(-) delete mode 100644 client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy delete mode 100644 client-java-api/edge-api.yaml create mode 100644 client-java-core/src/main/java/io/featurehub/client/InternalContext.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java create mode 100644 client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy delete mode 100644 client-java-jersey/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java create mode 100644 client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy delete mode 100644 client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java create mode 100644 examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java create mode 100644 examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java diff --git a/client-java-android/README.adoc b/client-java-android/README.adoc index d8fe6c7..d59187a 100644 --- a/client-java-android/README.adoc +++ b/client-java-android/README.adoc @@ -1,11 +1,21 @@ -= FeatureHub SDK for REST += FeatureHub SDK for REST clients == Overview -This SDK is intended for client libraries, e.g.: +This SDK operates using periodic REST requests. It should be used typically +in client libraries when near-real-time updates are not required. It operates +on a timeout + use based context where you can set a refresh period, and it +will ignore any attempts to poll for new updates outside of that timeout period. +Any request to evaluate a feature will automatically cause an attempt to poll, +so use of your application will cause features to update within the time period, +whereas lack of use will not consume any bandwidth. -- Android - so you have control over how frequently feature updates are requested, making sure the battery would not drain quickly on the device -- lambdas or cloud functions where control over the HTTP request object is desired and you only need to get the state once during the lifetime. -- other situations where updating the state of the internal repository is intermittent or desired to be consistent +It is therefore up to you as to how "fresh" you wish to keep your features, +with the knowledge that once that freshness timeout has been exceeded and +the app is evaluating features, it will re-request the feature state. + +== Considerations + +=== Library Dependencies The REST SDK *does not poll*. It allows the user of the SDK to create a polling mechanism which suites their application. @@ -18,8 +28,8 @@ Visit our official web page for more information about the platform https://www. === Dependencies This library uses: -- OKHttp 4 (for http(s)) -- Jackson (for json) +- OKHttp 4 (for http) +- Jackson 2.x (for json) - SLF4j (for logging) === Using diff --git a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java b/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java index 54f717b..7e7fa18 100644 --- a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java +++ b/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java @@ -83,11 +83,6 @@ protected ExecutorService makeExecutorService() { return Executors.newWorkStealingPool(); } - public FeatureHubClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, - int timeoutInSeconds) { - this(repository, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); - } - public FeatureHubClient(@NotNull FeatureHubConfig config, int timeoutInSeconds) { this(null, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); @@ -125,7 +120,7 @@ public boolean checkForUpdates(@Nullable CompletableFuture change) { busy = true; String url = this.url + "&contextSha=" + xContextSha; - log.debug("Url is {}", url); + log.trace("request url is {}", url); Request.Builder reqBuilder = new Request.Builder().url(url); if (xFeaturehubHeader != null) { @@ -157,7 +152,7 @@ public void onResponse(@NotNull Call call, @NotNull Response response) throws IO return ask; } - protected String getEtag() { + protected @Nullable String getEtag() { return etag; } @@ -196,6 +191,8 @@ protected void processFailure(@NotNull IOException e) { protected void processResponse(Response response) throws IOException { busy = false; + log.trace("response code is {}", response.code()); + // check the cache-control for the max-age final String cacheControlHeader = response.header("cache-control"); if (cacheControlHeader != null) { @@ -210,8 +207,15 @@ protected void processResponse(Response response) throws IOException { try (ResponseBody body = response.body()) { if (response.isSuccessful() && body != null) { - List environments = mapper.readValue(body.bytes(), ref); - log.debug("updating feature repository: {}", environments); + List environments; + + try { + environments = mapper.readValue(body.bytes(), ref); + } catch (Exception e) { + log.error("Failed to process successful response from FH Edge server", e); + processFailure(new IOException(e)); + return; + } List states = new ArrayList<>(); environments.forEach(e -> { @@ -221,6 +225,8 @@ protected void processResponse(Response response) throws IOException { } }); + log.trace("updating feature repository: {}", states); + repository.updateFeatures(states); completeReadiness(); @@ -237,7 +243,11 @@ protected void processResponse(Response response) throws IOException { log.error("Server indicated an error with our requests making future ones pointless."); repository.notify(SSEResultState.FAILURE); completeReadiness(); + } else if (response.code() >= 500) { + completeReadiness(); // we haven't changed anything, but we have to unblock clients as we can't just hang } + } catch (Exception e) { + log.error("Failed to parse response {}", response.code(), e); } } @@ -252,7 +262,7 @@ private void completeReadiness() { waitingClients = new ArrayList<>(); current.forEach(c -> { try { - c.complete(repository.getReadyness()); + c.complete(repository.getReadiness()); } catch (Exception e) { log.error("Unable to complete future", e); } @@ -271,7 +281,7 @@ private void completeReadiness() { if (busy) { waitingClients.add(change); } else if (!checkForUpdates(change)) { - change.complete(repository.getReadyness()); + change.complete(repository.getReadiness()); } return change; @@ -290,6 +300,8 @@ public void close() { if (client instanceof OkHttpClient) { ((OkHttpClient)client).dispatcher().executorService().shutdownNow(); + } else { + log.warn("client is not OKHttpClient {}", client.getClass().getName()); } executorService.shutdownNow(); @@ -308,7 +320,7 @@ public Future poll() { waitingClients.add(change); } else if (!checkForUpdates(change)) { // not even planning to ask - change.complete(repository.getReadyness()); + change.complete(repository.getReadiness()); } return change; diff --git a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy index 887a98a..2eff941 100644 --- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy +++ b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy @@ -3,6 +3,7 @@ package io.featurehub.android import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository +import io.featurehub.client.Readiness import io.featurehub.sse.model.FeatureEnvironmentCollection import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -12,17 +13,21 @@ class FeatureHubClientMockSpec extends Specification { MockWebServer mockWebServer FeatureHubClient client FeatureHubConfig config - InternalFeatureRepository store + InternalFeatureRepository repo ObjectMapper mapper def setup() { mapper = new ObjectMapper() config = Mock() - store = Mock() + repo = Mock() + config.repository >> repo mockWebServer = new MockWebServer() def url = mockWebServer.url("/").toString() - client = new FeatureHubClient(url.substring(0, url.length()-1), ["one", "two"], store, config, 0) + config.baseUrl() >> url.substring(0, url.length() - 1) + config.apiKeys() >> ["one", "two"] + config.serverEvaluation >> true + client = new FeatureHubClient(config, 0) mockWebServer.url("/features") } @@ -31,11 +36,6 @@ class FeatureHubClientMockSpec extends Specification { mockWebServer.shutdown() } - def poll() { - client.poll() - sleep(500) - } - def "a request for a known feature set with zero features"() { given: "a response" mockWebServer.enqueue(new MockResponse().with { @@ -43,9 +43,9 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(200) }) when: - poll() + client.poll().get() then: - 1 * store.notify([]) + 1 * repo.updateFeatures([]) } def "a request with an etag and a cache-control should work as expected"() { @@ -63,14 +63,17 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(236) }) when: - poll() - def etag = client.etag + def future = client.poll() def req1 = mockWebServer.takeRequest() + future.get() + def etag = client.etag and: - poll() + def future2 = client.poll() def req2 = mockWebServer.takeRequest() + future2.get() def interval = client.pollingInterval then: + 2 * repo.updateFeatures([]) req1.requestUrl.queryParameter("contextSha") == "0" etag == "etag12345" interval == 20 @@ -84,7 +87,9 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(400) }) when: - poll() + def future = client.poll() + mockWebServer.takeRequest() + future.get() then: !client.canMakeRequests() } @@ -95,7 +100,9 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(404) }) when: - poll() + def future = client.poll() + mockWebServer.takeRequest() + future.get() then: !client.canMakeRequests() } @@ -106,9 +113,24 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(500) }) when: - poll() + def future = client.poll() + mockWebServer.takeRequest() + def result = future.get() + then: + result == Readiness.NotReady + 1 * repo.getReadiness() >> Readiness.NotReady + when: "followed by a success" + mockWebServer.enqueue( new MockResponse().with { + setBody(mapper.writeValueAsString([new FeatureEnvironmentCollection().id(UUID.randomUUID()).features([])])) + setHeader("etag", "etag12345") + setResponseCode(200) + }) + and: + client.poll().get() then: client.canMakeRequests() + 1 * repo.getReadiness() >> Readiness.Ready + 1 * repo.updateFeatures(_) } def "a context header causes the connection to be tried with a contextSha"() { @@ -117,9 +139,9 @@ class FeatureHubClientMockSpec extends Specification { setResponseCode(500) }) when: - client.contextChange("header1", "sha-value") - sleep(500) + def future = client.contextChange("header1", "sha-value") def req1 = mockWebServer.takeRequest() + future.get() then: req1.requestUrl.queryParameter("contextSha") == "sha-value" req1.requestUrl.queryParameterValues("apiKey") == ["one", "two"] diff --git a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy b/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy deleted file mode 100644 index 728266b..0000000 --- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy +++ /dev/null @@ -1,52 +0,0 @@ -package io.featurehub.android - -import io.featurehub.client.FeatureHubConfig -import io.featurehub.client.InternalFeatureRepository -import okhttp3.Call -import okhttp3.Request -import spock.lang.Specification - -class FeatureHubClientSpec extends Specification { - Call.Factory client - Call call; - InternalFeatureRepository repo - FeatureHubClient fhc - - def "a null sdk url will never trigger a call"() { - when: "i initialize the client" - call = Mock() - def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig), 0) - and: "check for updates" - fhc.checkForUpdates(change) - then: - thrown RuntimeException - } - - def "a valid host and url will trigger a call when asked"() { - given: "i validly initialize the client" - call = Mock() - - client = Mock { - 1 * newCall({ Request r -> - r.header('x-featurehub') == 'fred=mary' - r.header('if-none-match') == 'jimbo' - }) >> call - } - - repo = Mock { - } - fhc = new FeatureHubClient("http://localhost", ["1234"], repo, client, Mock(FeatureHubConfig), 0) - fhc.etag = 'jimbo' - and: "i specify a header" - fhc.contextChange("fred=mary", "bonkers") - when: "i check for updates" - fhc.checkForUpdates(change) - then: - 1 == 1 - } - - // can't test any further because okhttp uses too many final classes - def "a response"() { - - } -} diff --git a/client-java-android21/README.adoc b/client-java-android21/README.adoc index 133f654..1388e4c 100644 --- a/client-java-android21/README.adoc +++ b/client-java-android21/README.adoc @@ -1,12 +1,37 @@ -= FeatureHub SDK for Android/Polling += FeatureHub SDK for Android 21/Polling == Overview -This is a variant of the whole of the `client-java-core` and `client-java-android` library composed into one artifact and -with the removal of classes that cause failure to compile (e.g. -`Supplier`) on Android version 21. It is maintained on a best efforts basis, changes are copied over from -their respective libraries as they are made. +This SDK is intended for client libraries, particularly for Android as keeping the radio on would drain the battery +quickly. It does this by making GET requests for the data but sets a period for which it will hold off making new requests (a timeout, which you can set to 0 if you have your own timer). This will allow +you to keep requesting updates but they will not actually issue calls unless the context changes +or the timeout has occurred. This is an obsolete library and while it works for versions of FeatureHub currently published, it is no longer being kept up to date with the latest APIs as they use too many features from Java 8. -Please refer to https://github.com/featurehub-io/featurehub-java-sdk/tree/main/client-java-android[Android library] -for use in Android, and https://github.com/featurehub-io/featurehub-java-sdk/tree/main/client-java-core[Core] for -the general purpose documentation on how to use this library. +This library uses: +- OKHttp 4 (for http) +- Jackson 2.11 (for json) +- SLF4j (for logging) + +If you need your Android client to use another technology, please let us know or feel free to contribute another version. + +Visit our official web page for more information about the platform https://www.featurehub.io/[here] + +== Using on Android + +As it requires internet access, you will need to add to your `AndroidManifest.xml` the usual: + +`` + +If you are using it locally and not behind https, you will also need to specify an attribute on your `` tag, +which allows clear text traffic. + +`android:usesCleartextTraffic="true"` + +You will need to store your repository in a central location, using a static or via a DI tool like Dagger.Using a static +might look something like this: + +Core uses Java's ServiceLoader capability to automatically discover the JerseyClient implementation. Please +simply follow the instructions in the https://github.com/featurehub-io/featurehub-java-sdk/tree/main/client-java-core[Java Core library]. + +As per that documentation you can manually configure the Edge provider to be the `AndroidFeatureHubClientFactory` if +you wish. diff --git a/client-java-api/edge-api.yaml b/client-java-api/edge-api.yaml deleted file mode 100644 index 67c57e6..0000000 --- a/client-java-api/edge-api.yaml +++ /dev/null @@ -1,254 +0,0 @@ -components: - schemas: - FeatureValueType: - - type: string - enum: [BOOLEAN, STRING, NUMBER, JSON] - RoleType: - - type: string - enum: [READ, LOCK, UNLOCK, CHANGE_VALUE] - BaseRolloutStrategy: - - description: if the feature in an environment is different from its default, this will be the reason for it. a rollout strategy is defined at the Application level and then applied to a specific feature value. When they are copied to the cache layer they are cloned and the feature value for that strategy is inserted into the clone and those are published. - properties: - id: {type: string} - percentage: {description: value between 0 and 1000000 - for four decimal places, - type: integer} - percentageAttributes: - type: array - description: if you don't wish to apply percentage based on user id, you can use one or more attributes defined here - items: {type: string} - value: {description: when we attach the RolloutStrategy for Dacha or SSE this lets us push the value out. Only visible in SDK and SSE Edge.} - attributes: - type: array - items: {$ref: '#/components/schemas/BaseRolloutStrategyAttribute'} - BaseRolloutStrategyAttribute: - - properties: - conditional: {$ref: '#/components/schemas/RolloutStrategyAttributeConditional'} - fieldName: {type: string} - values: - description: the value(s) associated with this rule - type: array - items: {$ref: '#/components/schemas/RolloutStrategyArrayType'} - type: {$ref: '#/components/schemas/RolloutStrategyFieldType'} - RolloutStrategyArrayType: { - description: values depend on the field type - } - RolloutStrategyFieldType: - - type: string - enum: [STRING, SEMANTIC_VERSION, NUMBER, DATE, DATETIME, BOOLEAN, IP_ADDRESS] - RolloutStrategyAttributeConditional: - - type: string - enum: [EQUALS, ENDS_WITH, STARTS_WITH, GREATER, GREATER_EQUALS, LESS, LESS_EQUALS, - NOT_EQUALS, INCLUDES, EXCLUDES, REGEX] - StrategyAttributeWellKnownNames: - - type: string - enum: [device, country, platform, userkey, session, version] - StrategyAttributeDeviceName: - - type: string - enum: [browser, mobile, desktop, server, watch, embedded] - StrategyAttributePlatformName: - - type: string - enum: [linux, windows, macos, android, ios] - StrategyAttributeCountryName: - - type: string - description: https://www.britannica.com/topic/list-of-countries-1993160 - we put these in API so everyone can have the same list - enum: [afghanistan, albania, algeria, andorra, angola, antigua_and_barbuda, - argentina, armenia, australia, austria, azerbaijan, the_bahamas, bahrain, - bangladesh, barbados, belarus, belgium, belize, benin, bhutan, bolivia, bosnia_and_herzegovina, - botswana, brazil, brunei, bulgaria, burkina_faso, burundi, cabo_verde, cambodia, - cameroon, canada, central_african_republic, chad, chile, china, colombia, - comoros, congo_democratic_republic_of_the, congo_republic_of_the, costa_rica, - cote_divoire, croatia, cuba, cyprus, czech_republic, denmark, djibouti, dominica, - dominican_republic, east_timor, ecuador, egypt, el_salvador, equatorial_guinea, - eritrea, estonia, eswatini, ethiopia, fiji, finland, france, gabon, the_gambia, - georgia, germany, ghana, greece, grenada, guatemala, guinea, guinea_bissau, - guyana, haiti, honduras, hungary, iceland, india, indonesia, iran, iraq, ireland, - israel, italy, jamaica, japan, jordan, kazakhstan, kenya, kiribati, korea_north, - korea_south, kosovo, kuwait, kyrgyzstan, laos, latvia, lebanon, lesotho, liberia, - libya, liechtenstein, lithuania, luxembourg, madagascar, malawi, malaysia, - maldives, mali, malta, marshall_islands, mauritania, mauritius, mexico, micronesia_federated_states_of, - moldova, monaco, mongolia, montenegro, morocco, mozambique, myanmar, namibia, - nauru, nepal, netherlands, new_zealand, nicaragua, niger, nigeria, north_macedonia, - norway, oman, pakistan, palau, panama, papua_new_guinea, paraguay, peru, philippines, - poland, portugal, qatar, romania, russia, rwanda, saint_kitts_and_nevis, saint_lucia, - saint_vincent_and_the_grenadines, samoa, san_marino, sao_tome_and_principe, - saudi_arabia, senegal, serbia, seychelles, sierra_leone, singapore, slovakia, - slovenia, solomon_islands, somalia, south_africa, spain, sri_lanka, sudan, - sudan_south, suriname, sweden, switzerland, syria, taiwan, tajikistan, tanzania, - thailand, togo, tonga, trinidad_and_tobago, tunisia, turkey, turkmenistan, - tuvalu, uganda, ukraine, united_arab_emirates, united_kingdom, united_states, - uruguay, uzbekistan, vanuatu, vatican_city, venezuela, vietnam, yemen, zambia, - zimbabwe] - ApplicationVersionInfo: - type: object - required: [name, version] - properties: - name: {type: string} - version: {type: string} - FeatureStateUpdate: - type: object - properties: - value: {description: the new value} - updateValue: {type: boolean, description: 'indicates whether you are trying - to update the value, as value can be null'} - lock: {description: 'set only if you wish to lock or unlock, otherwise null', - type: boolean} - SSEResultState: - type: string - description: error is an inherent state - enum: [ack, bye, failure, features, feature, delete_feature, config, error] - FeatureEnvironmentCollection: - description: This represents a collection of features as per a request from a GET api. GET's can request multiple API Keys at the same time. - x-renamed-from: Environment - required: [id] - properties: - id: {type: string, format: uuid} - features: - type: array - items: {$ref: '#/components/schemas/FeatureState'} - FeatureState: - required: [key, id] - properties: - id: {type: string, format: uuid} - key: {type: string} - l: {description: 'Is this feature locked. Usually this doesn''t matter because - the value is the value, but for FeatureInterceptors it can matter.', type: boolean} - version: {description: 'The version of the feature, this allows features to - change values and it means we don''t trigger events', type: integer, format: int64} - type: {$ref: '#/components/schemas/FeatureValueType'} - value: {description: the current value} - environmentId: {description: 'This field is filled in from the client side - in the GET api as the GET api is able to request multiple environments. - It is never passed from the server, as an array of feature states is wrapped - in an environment.', type: string, format: uuid} - strategies: - type: array - items: {$ref: '#/components/schemas/FeatureRolloutStrategy'} - FeatureRolloutStrategy: - description: This is the model for the rollout strategy as required by Dacha and Edge - allOf: - - {$ref: '#/components/schemas/BaseRolloutStrategy'} - - type: object - required: [id, attributes] - properties: - attributes: - type: array - items: {$ref: '#/components/schemas/FeatureRolloutStrategyAttribute'} - FeatureRolloutStrategyAttribute: - allOf: - - {$ref: '#/components/schemas/BaseRolloutStrategyAttribute'} - - type: object - required: [conditional, fieldName, type] -openapi: 3.0.1 -info: {x-version-api: fragment of version API, title: FeatureServiceApi, description: This describes the API clients use for accessing features, - version: 1.1.3} -paths: - /info/version: - get: - description: Gets information as to what this server is. - operationId: getInfoVersion - tags: [InfoService] - responses: - 200: - description: The basic information on this server - content: - application/json: - schema: {$ref: '#/components/schemas/ApplicationVersionInfo'} - /features/: - get: - tags: [FeatureService] - parameters: - - name: apiKey - in: query - description: A list of API keys to retrieve information about - required: true - schema: - type: array - items: {type: string} - - name: contextSha - in: query - description: A SHA of the context in string form designed to break any cache if the client changes context. It is not used by the server in any way. - required: false - schema: {type: string} - description: Requests all features for this sdkurl and disconnects - operationId: getFeatureStates - responses: - '200': - description: feature request successful, all environments you have permission to or that were found are returned - headers: - x-fh-version: - required: false - schema: {type: string} - content: - application/json: - schema: - type: array - items: {$ref: '#/components/schemas/FeatureEnvironmentCollection'} - '236': - description: its not you, its me, environment stagnant. - headers: - x-fh-version: - required: false - schema: {type: string} - content: - application/json: - schema: - type: array - items: {$ref: '#/components/schemas/FeatureEnvironmentCollection'} - '400': - description: you didn't ask for any environments - headers: - x-fh-version: - required: false - schema: {type: string} - /features/{sdkUrl}/{featureKey}: - put: - tags: [FeatureService] - parameters: - - name: sdkUrl - in: path - description: The API Key for the environment and service account - required: true - schema: {type: string} - - name: featureKey - in: path - description: The key you wish to update/action - required: true - schema: {type: string} - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/FeatureStateUpdate'} - description: Updates the feature state if allowed. - operationId: setFeatureState - responses: - '200': - description: update was accepted but not actioned because feature is already in that state - headers: - x-fh-version: - required: false - schema: {type: string} - '201': - description: update was accepted and actioned - headers: - x-fh-version: - required: false - schema: {type: string} - '202': {description: Neither lock or value was changing} - '400': {description: you have made a request that doesn't make sense. e.g. it has no data} - '403': {description: 'update was not accepted, attempted change is outside - the permissions of this user'} - '404': {description: 'something about the presented data isn''t right and - we couldn''t find it, could be the service key, the environment or the - feature'} - '412': {description: you have made a request that isn't possible. e.g. changing a value without unlocking it.} diff --git a/client-java-api/pom.xml b/client-java-api/pom.xml index a5dbf2b..eaa28d3 100644 --- a/client-java-api/pom.xml +++ b/client-java-api/pom.xml @@ -75,7 +75,7 @@ cd.connect.openapi connect-openapi-jersey3 - 8.2 + 8.8 @@ -89,7 +89,7 @@ ${project.basedir}/target/generated-sources/api io.featurehub.sse.api io.featurehub.sse.model - ${project.basedir}/edge-api.yaml + https://api.dev.featurehub.io/edge/1.1.5.yaml jersey3-api false @@ -117,23 +117,6 @@ - - - attach-final-yaml - package - - attach-artifact - - - - - ${project.basedir}/edge-api.yaml - yaml - api - - - - diff --git a/client-java-core/src/main/java/io/featurehub/client/Applied.java b/client-java-core/src/main/java/io/featurehub/client/Applied.java index a1a2695..7acc9c9 100644 --- a/client-java-core/src/main/java/io/featurehub/client/Applied.java +++ b/client-java-core/src/main/java/io/featurehub/client/Applied.java @@ -16,4 +16,12 @@ public boolean isMatched() { public Object getValue() { return value; } + + @Override + public String toString() { + return "Applied{" + + "matched=" + matched + + ", value=" + value + + '}'; + } } diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index 9f5731b..b9df377 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -22,7 +22,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -abstract class BaseClientContext implements ClientContext { +class BaseClientContext implements InternalContext { private static final Logger log = LoggerFactory.getLogger(BaseClientContext.class); protected final EdgeService edgeService; @@ -34,11 +34,9 @@ abstract class BaseClientContext implements ClientContext { public static final String VERSION_KEY = "version"; protected final Map> attributes = new ConcurrentHashMap<>(); protected final InternalFeatureRepository repository; - protected final FeatureHubConfig config; - public BaseClientContext(InternalFeatureRepository repository, FeatureHubConfig config, EdgeService edgeService) { + public BaseClientContext(InternalFeatureRepository repository, EdgeService edgeService) { this.repository = repository; - this.config = config; this.edgeService = edgeService; } @@ -111,7 +109,8 @@ public ClientContext attrs(String name, List values) { return this; } - void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, + @Override + public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, @NotNull FeatureValueType valueType) { repository.execute(() -> { @@ -155,6 +154,11 @@ protected AnalyticsEvent fillAnalyticsCollection(AnalyticsEvent event) { return event; } + @Override + public void recordAnalyticsEvent(@NotNull AnalyticsEvent event) { + repository.recordAnalyticsEvent(fillAnalyticsCollection(event)); + } + @Override @Nullable public String getAttr(@NotNull String name, @Nullable String defaultVal) { String val = getAttr(name); @@ -171,6 +175,11 @@ protected AnalyticsEvent fillAnalyticsCollection(AnalyticsEvent event) { return attributes.getOrDefault(name, null); } + @Override + public Future build() { + return CompletableFuture.completedFuture(this); + } + @Override public ClientContext clear() { attributes.clear(); @@ -201,7 +210,6 @@ public String defaultPercentageKey() { @Override public @NotNull List> allFeatures() { - boolean isServerEvaluation = getRepository().isServerEvaluation(); return repository.getFeatureKeys().stream() .map(f -> repository.getFeat(f).withContext(this)) .collect(Collectors.toList()); diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 7306fc8..566660a 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -1,5 +1,6 @@ package io.featurehub.client; +import io.featurehub.client.analytics.AnalyticsEvent; import io.featurehub.sse.model.StrategyAttributeCountryName; import io.featurehub.sse.model.StrategyAttributeDeviceName; import io.featurehub.sse.model.StrategyAttributePlatformName; @@ -62,5 +63,12 @@ public interface ClientContext { boolean exists(String key); boolean exists(Feature key); + /** + * If you have a custom analytics event you wish to record, add it here. It will capture any associated data from + * the current context if possible and add it to the analytics event. + * @param event + */ + void recordAnalyticsEvent(@NotNull AnalyticsEvent event); + void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java index 5c98fa7..2ac9c7b 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java @@ -11,7 +11,7 @@ class ClientEvalFeatureContext extends BaseClientContext { public ClientEvalFeatureContext(FeatureHubConfig config, InternalFeatureRepository repository, EdgeService edgeService) { - super(repository, config, edgeService); + super(repository, edgeService); } // this doesn't matter for client eval diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 287172b..5646306 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -1,11 +1,9 @@ package io.featurehub.client; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import io.featurehub.client.analytics.AnalyticsAdapter; import io.featurehub.client.analytics.AnalyticsEvent; import io.featurehub.client.analytics.AnalyticsProvider; import io.featurehub.client.analytics.FeatureHubAnalyticsValue; @@ -15,6 +13,7 @@ import io.featurehub.strategies.matchers.MatcherRegistry; import io.featurehub.strategies.percentage.PercentageMumurCalculator; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,7 +27,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; -import java.util.stream.Collectors; public class ClientFeatureRepository implements InternalFeatureRepository { private static class Callback implements RepositoryEventHandler { @@ -63,11 +61,6 @@ public void cancel() { private ObjectMapper jsonConfigObjectMapper; private final ApplyFeature applyFeature; - private AnalyticsAdapter analyticsAdapter; - private boolean serverEvaluation = false; // the client tells us, we pass it out to others - - private final TypeReference> FEATURE_LIST_TYPEDEF = - new TypeReference>() {}; public ClientFeatureRepository(ExecutorService executor, ApplyFeature applyFeature) { jsonConfigObjectMapper = initializeMapper(); @@ -110,11 +103,6 @@ public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapp this.jsonConfigObjectMapper = jsonConfigObjectMapper; } - @Override - public boolean isServerEvaluation() { - return serverEvaluation; - } - public @NotNull Readiness getReadyness() { return getReadiness(); } @@ -154,33 +142,29 @@ public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { } @Override - public void notify(SSEResultState state) { + public void notify(@NotNull SSEResultState state) { log.trace("received state {}", state); - if (state == null) { - log.warn("Unexpected null state"); - } else { - try { - switch (state) { - case ACK: - case BYE: - case DELETE_FEATURE: - case FEATURE: - case FEATURES: - break; - case FAILURE: - readiness = Readiness.Failed; - broadcastReadyness(); - break; - } - } catch (Exception e) { - log.error("Unable to process state `{}`", state, e); + try { + switch (state) { + case ACK: + case BYE: + case DELETE_FEATURE: + case FEATURE: + case FEATURES: + break; + case FAILURE: + readiness = Readiness.Failed; + broadcastReadyness(); + break; } + } catch (Exception e) { + log.error("Unable to process state `{}`", state, e); } } @Override - public void updateFeatures(List features) { - + public void updateFeatures(@NotNull List features) { + updateFeatures(features, false); } @Override @@ -198,26 +182,21 @@ public void updateFeatures(List states, bo } @Override - public Applied applyFeature( - List strategies, String key, String featureValueId, ClientContext cac) { + public @NotNull Applied applyFeature( + @NotNull List strategies, @NotNull String key, @NotNull String featureValueId, @NotNull ClientContext cac) { return applyFeature.applyFeature(strategies, key, featureValueId, cac); } @Override - public void execute(Runnable command) { + public void execute(@NotNull Runnable command) { executor.execute(command); } @Override - public ObjectMapper getJsonObjectMapper() { + public @NotNull ObjectMapper getJsonObjectMapper() { return jsonConfigObjectMapper; } - @Override - public void setServerEvaluation(boolean val) { - this.serverEvaluation = val; - } - @Override public void repositoryNotReady() { readiness = Readiness.NotReady; @@ -231,6 +210,7 @@ public void close() { readiness = Readiness.NotReady; readinessListeners.forEach(rl -> rl.callback.accept(readiness)); + readinessListeners.clear(); executor.shutdownNow(); @@ -240,7 +220,6 @@ public void close() { @Override public @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer rl) { final Callback callback = new Callback<>(readinessListeners, rl); - this.readinessListeners.add(callback); if (!executor.isShutdown()) { // let it know what the current state is @@ -251,12 +230,13 @@ public void close() { } private void broadcastReadyness() { + log.info("broadcasting readiness {} listener count {}", readiness, readinessListeners.size()); if (!executor.isShutdown()) { readinessListeners.forEach((rl) -> executor.execute(() -> rl.callback.accept(readiness))); } } - public void deleteFeature(io.featurehub.sse.model.FeatureState readValue) { + public void deleteFeature(@NotNull io.featurehub.sse.model.FeatureState readValue) { readValue.setValue(null); updateFeature(readValue); } @@ -281,24 +261,24 @@ public void deleteFeature(io.featurehub.sse.model.FeatureState readValue) { return getFeat(key, clazz); } - public boolean updateFeature(io.featurehub.sse.model.FeatureState featureState) { + public boolean updateFeature(@NotNull io.featurehub.sse.model.FeatureState featureState) { return updateFeature(featureState, false); } @Override - public boolean updateFeature(io.featurehub.sse.model.FeatureState featureState, boolean force) { + public boolean updateFeature(@NotNull io.featurehub.sse.model.FeatureState featureState, boolean force) { FeatureStateBase holder = features.get(featureState.getKey()); - if (holder == null || holder._featureState == null) { + if (holder == null) { holder = new FeatureStateBase<>(this, featureState.getKey()); features.put(featureState.getKey(), holder); - } else if (!force) { - long existingVersion = holder._featureState.getVersion() == null ? -1 : holder._featureState.getVersion(); + } else if (holder.feature.fs != null && !force) { + long existingVersion = holder.feature.fs.getVersion() == null ? -1 : holder.feature.fs.getVersion(); long newVersion = featureState.getVersion() == null ? -1 : featureState.getVersion(); if (existingVersion > newVersion || (newVersion == existingVersion && !FeatureStateUtils.changed( - holder._featureState.getValue(), featureState.getValue()))) { + holder.feature.fs.getValue(), featureState.getValue()))) { // if the old existingVersion is newer, or they are the same existingVersion and the value hasn't changed. // it can change with server side evaluation based on user data return false; @@ -315,13 +295,18 @@ public boolean updateFeature(io.featurehub.sse.model.FeatureState featureState, return true; } - public FeatureStateBase getFeat(String key) { + @NotNull public FeatureStateBase getFeat(@NotNull String key) { return getFeat(key, Boolean.class); } + @Override + @NotNull public FeatureStateBase getFeat(@NotNull Feature key) { + return getFeat(key.name(), Boolean.class); + } + @Override @SuppressWarnings("unchecked") // it is all fake anyway - public FeatureStateBase getFeat(String key, Class clazz) { + @NotNull public FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz) { return (FeatureStateBase) features.computeIfAbsent( key, key1 -> { @@ -335,12 +320,12 @@ public FeatureStateBase getFeat(String key, Class clazz) { }); } - private void broadcastFeatureUpdatedListeners(FeatureState fs) { + private void broadcastFeatureUpdatedListeners(@NotNull FeatureState fs) { featureUpdateHandlers.forEach((handler) -> execute(() -> handler.callback.accept(fs))); } @Override - public void recordAnalyticsEvent(AnalyticsEvent event) { + public void recordAnalyticsEvent(@NotNull AnalyticsEvent event) { analyticsHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); } @@ -351,7 +336,8 @@ public void repositoryEmpty() { } @Override - public void used(String key, UUID id, FeatureValueType valueType, Object value, Map> attributes, + public void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, + @Nullable Object value, @Nullable Map> attributes, String analyticsUserKey) { recordAnalyticsEvent(analyticsProvider.createAnalyticsFeature(new FeatureHubAnalyticsValue(id.toString(), key, value, valueType @@ -359,7 +345,7 @@ public void used(String key, UUID id, FeatureValueType valueType, Object value, } @Override - public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, String key) { + public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull String key) { return featureValueInterceptors.stream() .filter(vi -> !locked || vi.allowLockOverride) .map( @@ -377,7 +363,7 @@ public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, String k } @Override - public AnalyticsProvider getAnalyticsProvider() { + public @NotNull AnalyticsProvider getAnalyticsProvider() { return analyticsProvider; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 33cc845..757d903 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -1,6 +1,7 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.analytics.AnalyticsProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -32,9 +33,6 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @Nullable private ServerEvalFeatureContext serverEvalFeatureContext; - @Nullable - private EdgeService edgeClient; - public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { this(edgeUrl, Collections.singletonList(apiKey)); } @@ -74,7 +72,7 @@ public String apiKey() { } @Override - public List apiKeys() { + public @NotNull List apiKeys() { return apiKeys; } @@ -88,13 +86,8 @@ public String baseUrl() { * This is only intended to be used for client evaluated contexts, do not use it for server evaluated ones */ @Override - public void init() { - try { - final Future futureContext = newContext().build(); - futureContext.get(); - } catch (Exception e) { - log.error("Failed to initialize FeatureHub client", e); - } + public Future init() { + return newContext().build(); } @Override @@ -109,15 +102,17 @@ public ClientContext newContext() { this.edgeService = loadEdgeService(repository).get(); } + log.info("xx edge client {}", edgeService); + if (isServerEvaluation()) { if (serverEvalFeatureContext == null) { - serverEvalFeatureContext = new ServerEvalFeatureContext(this, repository, edgeService); + serverEvalFeatureContext = new ServerEvalFeatureContext(repository, edgeService); } return serverEvalFeatureContext; } - return new ClientEvalFeatureContext(this, repository, edgeClient); + return new ClientEvalFeatureContext(this, repository, edgeService); } /** @@ -137,8 +132,9 @@ protected Supplier loadEdgeService(@NotNull InternalFeatureReposit } } - if (edgeServiceSupplier != null) + if (edgeServiceSupplier != null) { return edgeServiceSupplier; + } throw new RuntimeException("Unable to find an edge service for featurehub, please include one on classpath."); } @@ -175,6 +171,11 @@ public void registerValueInterceptor(boolean allowLockOverride, @NotNull Feature getRepository().registerValueInterceptor(allowLockOverride, interceptor); } + @Override + public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { + getRepository().registerAnalyticsProvider(provider); + } + @Override @NotNull public Readiness getReadiness() { @@ -188,9 +189,11 @@ public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapp @Override public void close() { - if (edgeClient != null) { - edgeClient.close(); - edgeClient = null; + log.info("edge client {}", edgeService); + if (edgeService != null) { + log.info("closing edge connection"); + edgeService.close(); + edgeService = null; } } } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index d5d75df..52b617d 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,9 +1,13 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.sse.model.FeatureStateUpdate; import org.jetbrains.annotations.NotNull; import java.util.Collection; +import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.List; @@ -24,7 +28,7 @@ public interface FeatureHubConfig { * you have received your first set of features. Server Evaluated contexts should not use it because it needs * to re-request data from the server each time you change your context. */ - void init(); + Future init(); /** * The API Key indicates this is going to be server based evaluation @@ -62,6 +66,13 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { */ void registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor); + /** + * Allows the user to register a new analytics provider that determines what internal classes are + * created on analytical events + * @param provider + */ + void registerAnalyticsProvider(@NotNull AnalyticsProvider provider); + /** * Allows you to query the state of the repository's readyness - such as in a heartbeat API * @return diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java b/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java index b49c9de..2d9953f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java @@ -1,5 +1,5 @@ package io.featurehub.client; public interface FeatureListener { - void notify(FeatureState featureChanged); + void notify(FeatureState featureChanged); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java index a8960bf..b42bbe6 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -49,7 +49,5 @@ public interface FeatureRepository { */ void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper); - boolean isServerEvaluation(); - void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index de077a0..f9c22f4 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -10,7 +10,6 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.UUID; /** @@ -19,53 +18,67 @@ */ public class FeatureStateBase implements FeatureState { private static final Logger log = LoggerFactory.getLogger(FeatureStateBase.class); - protected final String key; - protected io.featurehub.sse.model.FeatureState _featureState; - List listeners = new ArrayList<>(); - protected BaseClientContext context; + protected final TopFeatureState feature; + protected final FeatureStateBase top; + protected final List listeners; + protected InternalContext context; protected FeatureStateBase parentHolder; protected final InternalFeatureRepository repository; + // any levels of the hierarchy always point to this object + class TopFeatureState { + public io.featurehub.sse.model.FeatureState fs; + public String key; // we always keep this in case the state gets reset to null + + public TopFeatureState(String key) { + this.key = key; + } + } + + // if this is a child public FeatureStateBase( - InternalFeatureRepository repository, - FeatureStateBase parentHolder, String key) { - this(repository, key); + @NotNull InternalFeatureRepository repository, + @NotNull FeatureStateBase parentHolder) { + this.repository = repository; this.parentHolder = parentHolder; + feature = parentHolder.feature; + + top = top(); + listeners = top.listeners; + } + + // this is exclusively for internal analytic copying + protected FeatureStateBase(@NotNull InternalFeatureRepository repository, @NotNull String key, + @Nullable io.featurehub.sse.model.FeatureState featureState) { + this.repository = repository; + this.parentHolder = null; + this.feature = new TopFeatureState(key); + this.feature.fs = featureState; + top = this; + this.listeners = new ArrayList<>(); } - public FeatureStateBase(InternalFeatureRepository repository, String key) { - this.key = key; + // this is for a new FeatureStateBase + public FeatureStateBase(@NotNull InternalFeatureRepository repository, String key) { this.repository = repository; + this.feature = new TopFeatureState(key); + top = this; + this.listeners = new ArrayList<>(); } - public FeatureStateBase withContext(BaseClientContext context) { + public FeatureStateBase withContext(InternalContext context) { final FeatureStateBase copy = _copy(); copy.context = context; return copy; } - @NotNull protected FeatureStateBase topFeatureState() { + // should only be used in constructor and is set once + @NotNull protected FeatureStateBase top() { if (parentHolder == null) { return this; } - return parentHolder.topFeatureState(); - } - - @Nullable - protected io.featurehub.sse.model.FeatureState featureState() { - // clones for analytics will set the feature state - if (_featureState != null) { - return _featureState; - } - - // child objects for contexts will use this - if (parentHolder != null) { - return parentHolder.featureState(); - } - - // otherwise it isn't set - return null; + return parentHolder.top(); } protected void notifyListeners() { @@ -73,19 +86,17 @@ protected void notifyListeners() { } public String getId() { - io.featurehub.sse.model.FeatureState fs = featureState(); - return (fs == null) ? "" : fs.getId().toString(); + return (feature.fs == null) ? "" : feature.fs.getId().toString(); } @Override public @NotNull String getKey() { - return key; + return feature.fs == null ? feature.key : feature.fs.getKey(); } @Override public boolean isLocked() { - final io.featurehub.sse.model.FeatureState featureState = this.featureState(); - return featureState != null && featureState.getL() == Boolean.TRUE; + return feature.fs != null && feature.fs.getL() == Boolean.TRUE; } @Override @@ -120,10 +131,8 @@ private Object getValue(@Nullable FeatureValueType type) { } @Override - public FeatureValueType getType() { - io.featurehub.sse.model.FeatureState fs = featureState(); - - return (fs == null) ? null : fs.getType(); + @Nullable public FeatureValueType getType() { + return (feature.fs == null) ? null : feature.fs.getType(); } public Object getAnalyticsFreeValue() { @@ -136,43 +145,48 @@ public K getValue(Class clazz) { } private Object internalGetValue(@Nullable FeatureValueType passedType, boolean triggerUsage) { - final io.featurehub.sse.model.FeatureState featureState = featureState(); - - boolean locked = featureState != null && Boolean.TRUE.equals(featureState.getL()); + boolean locked = feature.fs != null && Boolean.TRUE.equals(feature.fs.getL()); // unlike js, locking is registered on a per-interceptor basis - FeatureValueInterceptor.ValueMatch vm = repository.findIntercept(locked, key); + FeatureValueInterceptor.ValueMatch vm = repository.findIntercept(locked, feature.key); if (vm != null) { return vm.value; } - if (featureState == null || ( passedType == null && featureState.getType() == null )) { + if (feature.fs == null || ( passedType == null && feature.fs.getType() == null )) { return null; } - final FeatureValueType type = passedType == null ? featureState.getType() : passedType; + final FeatureValueType type = passedType == null ? feature.fs.getType() : passedType; - if (featureState.getType() != type) { + if (feature.fs.getType() != type) { return null; } - if (context != null) { + if (context != null && feature.fs.getStrategies() != null && !feature.fs.getStrategies().isEmpty()) { final Applied applied = repository.applyFeature( - featureState.getStrategies(), key, featureState.getId().toString(), context); + feature.fs.getStrategies(), feature.key, feature.fs.getId().toString(), context); + log.info("feature is {}", applied); if (applied.isMatched()) { - return triggerUsage ? used(key, featureState.getId(), applied.getValue(), type) : applied.getValue(); + return triggerUsage ? used(feature.key, feature.fs.getId(), applied.getValue(), type) : applied.getValue(); } + } else { + log.info("not matched using {}", feature.fs.getValue()); } - return triggerUsage ? used(key, featureState.getId(), featureState.getValue(), type) : featureState.getValue(); + return triggerUsage ? used(feature.key, feature.fs.getId(), feature.fs.getValue(), type) : + feature.fs.getValue(); } Object used(@NotNull String key, @NotNull UUID id, @Nullable Object value, @NotNull FeatureValueType type) { if (context != null) { context.used(key, id, value, type); + } else { + log.info("calling used with {}", value); + repository.used(key, id, type, value, null, null); } return value; @@ -235,9 +249,19 @@ public void addListener(final @NotNull FeatureListener listener) { // stores the feature state and triggers notifyListeners if anything changed // should notify actually be inside the listener code? given contexts? public FeatureState setFeatureState(io.featurehub.sse.model.FeatureState featureState) { - if (featureState == null) return this; - Object oldValue = getValue(type()); - this._featureState = featureState; + if (featureState == null) { + boolean changed = feature.fs != null; + feature.fs = featureState; + if (changed) { + notifyListeners(); + } + return this; + } + + feature.key = featureState.getKey(); + + Object oldValue = feature.fs == null ? null : feature.fs.getValue(); + feature.fs = featureState; Object value = convertToRespectiveType(featureState); if (FeatureStateUtils.changed(oldValue, value)) { notifyListeners(); @@ -272,30 +296,24 @@ protected FeatureState copy() { } protected FeatureState analyticsCopy() { - final FeatureStateBase aCopy = _copy(); - aCopy._featureState = featureState(); - return aCopy; + return new FeatureStateBase(repository, feature.key, feature.fs); } protected FeatureStateBase _copy() { - final FeatureStateBase copy = new FeatureStateBase<>(repository, this, key); - copy.parentHolder = this; - return copy; + return new FeatureStateBase<>(repository, this); } public boolean exists() { - final io.featurehub.sse.model.FeatureState featureState = featureState(); - return featureState != null && featureState.getVersion() != null && featureState.getVersion() != -1; + return feature.fs != null && feature.fs.getVersion() != null && feature.fs.getVersion() != -1; } protected FeatureValueType type() { - final io.featurehub.sse.model.FeatureState featureState = featureState(); - return featureState == null ? null : featureState.getType(); + return feature.fs == null ? null : feature.fs.getType(); } @Override public String toString() { - Object value = getValue(type()); + Object value = feature.fs == null ? null : feature.fs.getValue(); return value == null ? null : value.toString(); } } diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalContext.java b/client-java-core/src/main/java/io/featurehub/client/InternalContext.java new file mode 100644 index 0000000..def73bc --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/InternalContext.java @@ -0,0 +1,13 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +interface InternalContext extends ClientContext { + void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, + @NotNull FeatureValueType valueType); + + } diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java index 59ad994..72b4f45 100644 --- a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -20,7 +20,7 @@ public interface InternalFeatureRepository extends FeatureRepository { * Any incoming state changes from a multi-varied set of possible data. This comes * from SSE. */ - void notify(SSEResultState state); + void notify(@NotNull SSEResultState state); /** * Indicate the feature states have updated and if their versions have @@ -28,7 +28,7 @@ public interface InternalFeatureRepository extends FeatureRepository { * * @param features - the features */ - void updateFeatures(List features); + void updateFeatures(@NotNull List features); /** * Update the feature states and force them to be updated, ignoring their version numbers. * This still may not cause events to be triggered as event triggers are done on actual value changes. @@ -36,24 +36,22 @@ public interface InternalFeatureRepository extends FeatureRepository { * @param features - the list of feature states * @param force - whether we should force the states to change */ - void updateFeatures(List features, boolean force); - boolean updateFeature(FeatureState feature); - boolean updateFeature(FeatureState feature, boolean force); - void deleteFeature(FeatureState feature); + void updateFeatures(@NotNull List features, boolean force); + boolean updateFeature(@NotNull FeatureState feature); + boolean updateFeature(@NotNull FeatureState feature, boolean force); + void deleteFeature(@NotNull FeatureState feature); - FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, String key); + @Nullable FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull String key); - Applied applyFeature(List strategies, String key, String featureValueId, - ClientContext cac); + @NotNull Applied applyFeature(@NotNull List strategies, @NotNull String key, @NotNull String featureValueId, + @NotNull ClientContext cac); - void execute(Runnable command); + void execute(@NotNull Runnable command); - ObjectMapper getJsonObjectMapper(); - - void setServerEvaluation(boolean val); + @NotNull ObjectMapper getJsonObjectMapper(); /** - * Tell the repository that its features are not in a valid state. + * Tell the repository that its features are not in a valid state. Only called by server eval context. */ void repositoryNotReady(); @@ -61,14 +59,11 @@ Applied applyFeature(List strategies, String key, String @NotNull Readiness getReadiness(); - FeatureStateBase getFeat(String key); - FeatureStateBase getFeat(String key, Class clazz); - - void recordAnalyticsEvent(AnalyticsEvent event); + @NotNull FeatureStateBase getFeat(@NotNull String key); + @NotNull FeatureStateBase getFeat(@NotNull Feature key); + @NotNull FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz); - /** - * Only called by server eval context when we swap context - */ + void recordAnalyticsEvent(@NotNull AnalyticsEvent event); /** * Repository is empty, there are no features but repository is ready. @@ -76,8 +71,8 @@ Applied applyFeature(List strategies, String key, String void repositoryEmpty(); void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, - @NotNull Map> attributes, + @Nullable Map> attributes, @Nullable String analyticsUserKey); - AnalyticsProvider getAnalyticsProvider(); + @NotNull AnalyticsProvider getAnalyticsProvider(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java index 61c7ea5..d992a5c 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java @@ -8,7 +8,6 @@ import java.security.NoSuchAlgorithmException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; -import java.util.function.Supplier; public class ServerEvalFeatureContext extends BaseClientContext { private static final Logger log = LoggerFactory.getLogger(ServerEvalFeatureContext.class); @@ -18,9 +17,9 @@ public class ServerEvalFeatureContext extends BaseClientContext { private final MessageDigest shaDigester; - public ServerEvalFeatureContext(FeatureHubConfig config, InternalFeatureRepository repository, + public ServerEvalFeatureContext(InternalFeatureRepository repository, EdgeService edgeService) { - super(repository, config, edgeService); + super(repository, edgeService); try { shaDigester = MessageDigest.getInstance("SHA-256"); diff --git a/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java b/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java new file mode 100644 index 0000000..b1f9457 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java @@ -0,0 +1,39 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class ThreadLocalContext { + @NotNull + private static final ThreadLocal<@Nullable ClientContext> contexts = new ThreadLocal<>(); + @Nullable + private static FeatureHubConfig config; + + public static void setConfig(@NotNull FeatureHubConfig config) { + ThreadLocalContext.config = config; + } + + @NotNull public static ClientContext getContext() { + return context(); + } + + @NotNull public static ClientContext context() { + if (config == null) throw new RuntimeException("config not set, unable to use"); + + ClientContext ctx = contexts.get(); + + if (ctx == null) { + ctx = config.newContext(); + } + + return ctx; + } + + public static void close() { + ClientContext ctx = contexts.get(); + if (ctx != null) { + ctx.close(); + contexts.remove(); + } + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java index 160061c..786f0d8 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java @@ -23,17 +23,19 @@ public void setUserKey(String userKey) { this.userKey = userKey; } - public void setAdditionalParams(Map additionalParams) { + public void setAdditionalParams(@NotNull Map additionalParams) { this.additionalParams = additionalParams; } - public AnalyticsEvent(@Nullable String userKey, @NotNull Map additionalParams) { + public AnalyticsEvent(@Nullable String userKey, @Nullable Map additionalParams) { this.userKey = userKey; - this.additionalParams = additionalParams; + if (additionalParams != null) { + this.additionalParams = additionalParams; + } } @NotNull - Map toMap() { + protected Map toMap() { return additionalParams; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java index 1138e60..e5dbd78 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java @@ -9,17 +9,17 @@ import java.util.Map; public class AnalyticsFeature extends AnalyticsEvent implements AnalyticsEventName { - @NotNull + @Nullable final Map> attributes; @NotNull final FeatureHubAnalyticsValue feature; - public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @NotNull Map> attributes, + public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @Nullable Map> attributes, @Nullable String userKey) { this.attributes = attributes; this.feature = feature; } - @NotNull public Map> getAttributes() { + @Nullable public Map> getAttributes() { return attributes; } @@ -33,10 +33,12 @@ public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @NotNull Map< } @Override - @NotNull Map toMap() { + @NotNull protected Map toMap() { Map m = new HashMap<>(super.toMap()); - m.putAll(attributes); + if (attributes != null) { // may not be from a context + m.putAll(attributes); + } m.put("feature", feature.key); m.put("value", feature.id); m.put("id", feature.id); diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java index 7e54738..70106d6 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java @@ -12,7 +12,7 @@ public class AnalyticsFeaturesCollection extends AnalyticsEvent { @NotNull List featureValues = new ArrayList<>(); - public AnalyticsFeaturesCollection(@Nullable String userKey, @NotNull Map additionalParams) { + public AnalyticsFeaturesCollection(@Nullable String userKey, @Nullable Map additionalParams) { super(userKey, additionalParams); } @@ -25,7 +25,7 @@ public AnalyticsFeaturesCollection() {} void ready() {} @Override - @NotNull Map toMap() { + @NotNull protected Map toMap() { Map m = new HashMap<>(super.toMap()); featureValues.forEach((fv) -> m.put(fv.key, fv.value)); diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java index 70ee693..7c04aaa 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java @@ -12,7 +12,7 @@ public class AnalyticsFeaturesCollectionContext extends AnalyticsFeaturesCollect @NotNull Map> attributes = new HashMap<>(); - public AnalyticsFeaturesCollectionContext(@Nullable String userKey, @NotNull Map additionalParams) { + public AnalyticsFeaturesCollectionContext(@Nullable String userKey, @Nullable Map additionalParams) { super(userKey, additionalParams); } @@ -25,7 +25,7 @@ public void setAttributes(Map> attributes) { } @Override - @NotNull Map toMap() { + @NotNull protected Map toMap() { Map m = new HashMap<>(super.toMap()); m.putAll(attributes); diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java index c32429b..8ef31dc 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java +++ b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java @@ -13,7 +13,7 @@ default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValu } default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, - @NotNull Map> attributes, + @Nullable Map> attributes, @Nullable String userKey) { return new AnalyticsFeature(feature, attributes, userKey); } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy new file mode 100644 index 0000000..fca17f6 --- /dev/null +++ b/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy @@ -0,0 +1,43 @@ +package io.featurehub.client + +import io.featurehub.sse.model.StrategyAttributeCountryName +import io.featurehub.sse.model.StrategyAttributeDeviceName +import io.featurehub.sse.model.StrategyAttributePlatformName +import spock.lang.Specification + +class BaseClientContextSpec extends Specification { + InternalFeatureRepository repo + EdgeService edgeService + BaseClientContext ctx + + def setup() { + edgeService = Mock(EdgeService) + repo = Mock(InternalFeatureRepository) + ctx = new BaseClientContext(repo, edgeService) + } + + def "the client context encodes as expected"() { + when: "i encode the context" + def tc = ctx.userKey("DJElif") + .country(StrategyAttributeCountryName.TURKEY) + .attr("city", "Istanbul") + .attrs("musical styles", Arrays.asList("psychedelic", "deep")) + .device(StrategyAttributeDeviceName.DESKTOP) + .platform(StrategyAttributePlatformName.ANDROID) + .version("2.3.7") + .sessionKey("anjunadeep").build().get() + + and: "i do the same thing again to ensure i can reset everything" + tc.userKey("DJElif") + .country(StrategyAttributeCountryName.TURKEY) + .attr("city", "Istanbul") + .attrs("musical styles", Arrays.asList("psychedelic", "deep")) + .device(StrategyAttributeDeviceName.DESKTOP) + .platform(StrategyAttributePlatformName.ANDROID) + .version("2.3.7") + .sessionKey("anjunadeep").build().get() + then: + FeatureStateUtils.generateXFeatureHubHeaderFromMap(tc.context()) == + 'city=Istanbul,country=turkey,device=desktop,musical styles=psychedelic%2Cdeep,platform=android,session=anjunadeep,userkey=DJElif,version=2.3.7' + } +} diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index f5f8794..97655ed 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -1,99 +1,93 @@ package io.featurehub.client import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.client.analytics.AnalyticsProvider import spock.lang.Specification +import java.util.concurrent.CompletableFuture import java.util.concurrent.Future +import java.util.function.Consumer import java.util.function.Supplier class EdgeFeatureHubConfigSpec extends Specification { + FeatureHubConfig config + EdgeService edgeClient + + def setup() { + config = new EdgeFeatureHubConfig("http://localhost", "123*abc") + edgeClient = Mock(EdgeService) + config.setEdgeService { -> edgeClient } + } + def "i can create a valid client evaluated config and multiple requests for a a new context will result in a single connection"() { - given: "i have a client eval feature config" - def config = new EdgeFeatureHubConfig("http://localhost", "123*abc") - and: "i have configured a edge provider" - Supplier edgeSupplier = Mock(Supplier) - def edgeClient = Mock(EdgeService) when: "i ask for a new context" - def ctx1 = config.newContext(null, edgeSupplier) + def ctx1 = config.newContext() and: "i ask again" - def ctx2 = config.newContext(null, edgeSupplier) + def ctx2 = config.newContext() then: - 1 * edgeSupplier.get() >> edgeClient 0 * _ } def "if we use a client eval key, closing after a newContext and re-opening will get a new connection"() { - given: "i have a client eval feature config" - def config = new EdgeFeatureHubConfig("http://localhost", "123*abc") - and: "i have configured a edge provider" - Supplier edgeSupplier = Mock(Supplier) - def edgeClient = Mock(EdgeService) when: "i ask for a new context" - def ctx1 = config.newContext(null, edgeSupplier) + def ctx1 = config.newContext() config.close() and: "i ask again" - def ctx2 = config.newContext(null, edgeSupplier) + def ctx2 = config.newContext() then: - 2 * edgeSupplier.get() >> edgeClient + ctx1 != null + ctx2 != null + ctx1 != ctx2 + config.edgeService.get() == edgeClient 1 * edgeClient.close() 0 * _ } def "all the passthrough on the repository from the config works as expected"() { - given: "i have a client eval feature config" - def config = new EdgeFeatureHubConfig("http://localhost", "123*abc") - and: "i have mocked the repository and set it" - def repo = Mock(FeatureRepositoryContext) + given: "i have mocked the repository and set it" + def repo = Mock(InternalFeatureRepository) config.setRepository(repo) and: "I have some values ready to set" def om = new ObjectMapper() - def readynessListener = Mock(ReadinessListener) - def analyticsCollector = Mock(AnalyticsCollector) + Consumer readynessListener = Mock(Consumer) def featureValueOverride = Mock(FeatureValueInterceptor) + def analyticsProvider = Mock(AnalyticsProvider) when: "i set all the passthrough settings" config.setJsonConfigObjectMapper(om) - config.addReadynessListener(readynessListener) - config.addAnalyticCollector(analyticsCollector) + config.addReadinessListener(readynessListener) config.registerValueInterceptor(false, featureValueOverride) + config.registerAnalyticsProvider(analyticsProvider) then: 1 * repo.registerValueInterceptor(false, featureValueOverride) - 1 * repo.addReadinessListener(readynessListener) - 1 * repo.addAnalyticCollector(analyticsCollector) + 1 * repo.addReadinessListener(readynessListener) >> Mock(RepositoryEventHandler) 1 * repo.setJsonConfigObjectMapper(om) + 1 * repo.registerAnalyticsProvider(analyticsProvider) 0 * _ // nothing else } def "when i create a client evaluated feature context it should auto find the provider"() { - given: "i have a client eval feature config" - def config = new EdgeFeatureHubConfig("http://localhost", "123*abc") - and: "i clean up the static provider" - FeatureHubTestClientFactory.repository = null - FeatureHubTestClientFactory.config = null - FeatureHubTestClientFactory.edgeServiceSupplier = Mock(Supplier) - def edgeClient = Mock(EdgeService) + given: "i clean up the static provider" + FeatureHubTestClientFactory.fake = null + config = new EdgeFeatureHubConfig("http://localhost", "2*3") when: "i create a new client" def context = config.newContext() then: context instanceof ClientEvalFeatureContext - 1 * FeatureHubTestClientFactory.edgeServiceSupplier.get() >> edgeClient - ((ClientEvalFeatureContext)context).edgeService == edgeClient + context.repository == config.repository + context.edgeService == FeatureHubTestClientFactory.fake + context.edgeService.config == config 0 * _ } def "when i create a server evaluated feature context it should auto find the provider"() { given: "i have a client eval feature config" def config = new EdgeFeatureHubConfig("http://localhost", "123-abc") - and: "i clean up the static provider" - FeatureHubTestClientFactory.repository = null - FeatureHubTestClientFactory.config = null - FeatureHubTestClientFactory.edgeServiceSupplier = Mock(Supplier) - def edgeClient = Mock(EdgeService) when: "i create a new client" def context = config.newContext() + and: "i create a second client" + def context2 = config.newContext() then: - context instanceof ServerEvalFeatureContext - ((ServerEvalFeatureContext)context).edgeService == null - ((ServerEvalFeatureContext)context).edgeServiceSupplier == FeatureHubTestClientFactory.edgeServiceSupplier + context == context2 0 * _ } @@ -115,32 +109,24 @@ class EdgeFeatureHubConfigSpec extends Specification { } def "default repository and edge service supplier work"() { - given: "i have mocked the edge supplier" - FeatureHubTestClientFactory.edgeServiceSupplier = Mock(Supplier) when: "i have a client eval feature config" - def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") + def ctx = config.newContext() then: config.repository instanceof ClientFeatureRepository - config.readyness == Readiness.NotReady - config.edgeService == FeatureHubTestClientFactory.edgeServiceSupplier + config.readiness == Readiness.NotReady + config.edgeService.get() == edgeClient } def "i can pre-replace the repository and edge supplier and the context gets created as expected"() { given: "i have mocked the edge supplier" - def supplier = Mock(Supplier) - def client = Mock(EdgeService) - FeatureHubTestClientFactory.edgeServiceSupplier = supplier - def config = new EdgeFeatureHubConfig("http://localhost/", "123-abc") - def repo = Mock(FeatureRepositoryContext) - config.repository = repo - and: "i mock out the futures" - def mockRequest = Mock(Future) + def mockRepo = Mock(InternalFeatureRepository) + config.setRepository(mockRepo) when: - config.init() + def ctx = config.init().get() as BaseClientContext then: - 1 * supplier.get() >> client - 1 * client.contextChange(null, '0') >> mockRequest - 1 * mockRequest.get() >> Readiness.Ready + ctx.edgeService == edgeClient + ctx.repository == mockRepo + 1 * edgeClient.contextChange(null, '0') >> CompletableFuture.completedFuture(Readiness.Ready) 0 * _ } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy index 1635b36..0b6dc6c 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy @@ -1,18 +1,62 @@ package io.featurehub.client +import org.jetbrains.annotations.NotNull +import org.jetbrains.annotations.Nullable + +import java.util.concurrent.Future import java.util.function.Supplier class FeatureHubTestClientFactory implements FeatureHubClientFactory { - static Supplier edgeServiceSupplier - static FeatureHubConfig config - static FeatureStore repository + class FakeEdgeService implements EdgeService { + final InternalFeatureRepository repository + final FeatureHubConfig config + + FakeEdgeService(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this.repository = repository ?: config.repository as InternalFeatureRepository + this.config = config + } + + @Override + Future contextChange(@Nullable String newHeader, String contextSha) { + return null + } + + @Override + boolean isClientEvaluation() { + return false + } + + @Override + boolean isStopped() { + return false + } + + @Override + void close() { + } + + @NotNull + @Override + FeatureHubConfig getConfig() { + return config + } + + @Override + Future poll() { + return null + } + } + + static FakeEdgeService fake @Override - Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository) { - // save them for the test to ch eck - FeatureHubTestClientFactory.config = url - FeatureHubTestClientFactory.repository = repository + Supplier createEdgeService(FeatureHubConfig config, InternalFeatureRepository repository) { + fake = new FakeEdgeService(repository, config) + return { -> fake } + } - return edgeServiceSupplier + @Override + Supplier createEdgeService(@NotNull FeatureHubConfig config) { + return createEdgeService(config, null) } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy index eb76430..90ba1fa 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy @@ -17,11 +17,11 @@ class InterceptorSpec extends Specification { System.setProperty(name, "true") System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == 'true' - fr.getFeatureState(featureName).number == null - fr.getFeatureState("feature_none").string == null - !fr.getFeatureState("feature_none").boolean + fr.getFeat(featureName).flag + fr.getFeat(featureName).string == 'true' + fr.getFeat(featureName).number == null + fr.getFeat("feature_none").string == null + !fr.getFeat("feature_none").flag } def "we can deserialize json in an override"() { @@ -36,12 +36,12 @@ class InterceptorSpec extends Specification { System.setProperty(name, rawJson) System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - !fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == rawJson - fr.getFeatureState(featureName).rawJson == rawJson - fr.getFeatureState(featureName).getJson(BananaSample) instanceof BananaSample - fr.getFeatureState(featureName).getJson(BananaSample).sample == 18 - fr.getFeatureState("feature_none").getJson(BananaSample) == null + !fr.getFeat(featureName).flag + fr.getFeat(featureName).string == rawJson + fr.getFeat(featureName).rawJson == rawJson + fr.getFeat(featureName).getJson(BananaSample) instanceof BananaSample + fr.getFeat(featureName).getJson(BananaSample).sample == 18 + fr.getFeat("feature_none").getJson(BananaSample) == null } def "we can deserialize a number in an override"() { @@ -56,11 +56,11 @@ class InterceptorSpec extends Specification { System.setProperty(name, numString) System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - !fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == numString - fr.getFeatureState(featureName).rawJson == numString - fr.getFeatureState(featureName).number == 17.65 - fr.getFeatureState('feature_none').number == null + !fr.getFeat(featureName).flag + fr.getFeat(featureName).string == numString + fr.getFeat(featureName).rawJson == numString + fr.getFeat(featureName).number == 17.65 + fr.getFeat('feature_none').number == null } def "if system property loader is turned off, overrides are ignored"() { @@ -73,10 +73,10 @@ class InterceptorSpec extends Specification { System.setProperty(name, "true") System.clearProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE) then: - !fr.getFeatureState("feature_one").boolean - fr.getFeatureState("feature_one").string == null - fr.getFeatureState("feature_none").string == null - !fr.getFeatureState("feature_none").boolean + !fr.getFeat("feature_one").flag + fr.getFeat("feature_one").string == null + fr.getFeat("feature_none").string == null + !fr.getFeat("feature_none").flag } def "if a feature is locked, we won't call an interceptor that is overridden"() { @@ -84,9 +84,9 @@ class InterceptorSpec extends Specification { def fr = new ClientFeatureRepository(1); fr.registerValueInterceptor(false, Mock(FeatureValueInterceptor)) and: "we register a feature" - fr.notify([new FeatureState().value(true).type(FeatureValueType.BOOLEAN).key("x").id(UUID.randomUUID()).l(true)]) + fr.updateFeatures([new FeatureState().value(true).type(FeatureValueType.BOOLEAN).key("x").id(UUID.randomUUID()).l(true)]) when: "i ask for the feature" - def f = fr.getFeatureState("x").boolean + def f = fr.getFeat("x").flag then: f } @@ -102,7 +102,7 @@ class InterceptorSpec extends Specification { def peachQuantity = new FeatureState().id(UUID.randomUUID()).key('peach-quantity_or').version(1L).value(17).type(FeatureValueType.NUMBER) def peachConfig = new FeatureState().id(UUID.randomUUID()).key('peach-config_or').version(1L).value("{}").type(FeatureValueType.JSON) def features = [banana, orange, peachConfig, peachQuantity] - fr.notify(features) + fr.updateFeatures(features) when: "we set the feature override" System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + banana.key, "true") System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + orange.key, "nectarine") @@ -110,11 +110,11 @@ class InterceptorSpec extends Specification { System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + peachConfig.key, '{"sample":12}') System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - fr.getFeatureState(banana.key).boolean - fr.getFeatureState(orange.key).string == 'nectarine' - fr.getFeatureState(peachQuantity.key).number == 13 - fr.getFeatureState(peachConfig.key).rawJson == '{"sample":12}' - fr.getFeatureState(peachConfig.key).getJson(BananaSample).sample == 12 + fr.getFeat(banana.key).flag + fr.getFeat(orange.key).string == 'nectarine' + fr.getFeat(peachQuantity.key).number == 13 + fr.getFeat(peachConfig.key).rawJson == '{"sample":12}' + fr.getFeat(peachConfig.key).getJson(BananaSample).sample == 12 } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy index 8b56f25..88a46ee 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy @@ -7,34 +7,48 @@ import io.featurehub.sse.model.FeatureRolloutStrategy import io.featurehub.sse.model.FeatureRolloutStrategyAttribute import spock.lang.Specification +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + import static io.featurehub.client.BaseClientContext.USER_KEY class ListenerSpec extends Specification { def "When a listener fires, it will always attempt to take the context into account if it was listened to via a context"() { given: "i have a setup" - def fStore = Mock(FeatureStore) - fStore.getFeatureValueInterceptors() >> [] - def ctx = Mock(ClientContext) + def repo = Mock(InternalFeatureRepository) + def edge = Mock(EdgeService) +// def ctx = Mock(InternalContext) + def ctx = new BaseClientContext(repo, edge) def key = "fred" + def id = UUID.randomUUID() and: "a feature" - def feat = new FeatureStateBase(fStore, key) + def feat = new FeatureStateBase(repo, key) def ctxFeat = feat.withContext(ctx) BigDecimal n1; - BigDecimal n2; + BigDecimal n2; + // the listeners will trigger a repo.execute when they are evaluated feat.addListener({ fs -> n1 = fs.number }) ctxFeat.addListener({ fs -> n2 = fs.number }) when: "i set the feature state" - feat.setFeatureState(new io.featurehub.sse.model.FeatureState().id(UUID.randomUUID()).key(key).l(false).value(16).type(FeatureValueType.NUMBER).addStrategiesItem(new FeatureRolloutStrategy().value(12).addAttributesItem( - new FeatureRolloutStrategyAttribute().conditional(RolloutStrategyAttributeConditional.EQUALS).type(RolloutStrategyFieldType.STRING).fieldName(USER_KEY).addValuesItem("fred") + feat.setFeatureState(new io.featurehub.sse.model.FeatureState().id(id).key(key).l(false) + .value(16).type(FeatureValueType.NUMBER).addStrategiesItem(new FeatureRolloutStrategy().value(12).addAttributesItem( + new FeatureRolloutStrategyAttribute().conditional(RolloutStrategyAttributeConditional.EQUALS) + .type(RolloutStrategyFieldType.STRING).fieldName(USER_KEY).addValuesItem("fred") ))) then: n1 == 16 n2 == 12 - 2 * fStore.execute({Runnable cmd -> + 2 * repo.findIntercept(false, key) >> null // one for each listener + 3 * repo.execute({Runnable cmd -> // 2 for listeners, 1 for firing the "used" on the repo via the context cmd.run() }) - 1 * fStore.applyFeature(_, key, _, ctx) >> new Applied(true, 12) + 1 * repo.applyFeature(_, key, _, ctx) >> new Applied(true, 12) +// 1 * ctx.used(key, id, 12, FeatureValueType.NUMBER) + 1 * repo.used(key, id, FeatureValueType.NUMBER, 16, null, null) + 1 * repo.used(key, id, FeatureValueType.NUMBER, 12, {}, null) + 1 * edge.poll() >> CompletableFuture.completedFuture(Readiness.Ready) + 0 * _ } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy index 9a869e4..de3f472 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -1,16 +1,13 @@ package io.featurehub.client import com.fasterxml.jackson.databind.ObjectMapper -import io.featurehub.sse.model.FeatureValueType -import io.featurehub.sse.model.StrategyAttributeCountryName -import io.featurehub.sse.model.StrategyAttributeDeviceName -import io.featurehub.sse.model.StrategyAttributePlatformName import io.featurehub.sse.model.FeatureState +import io.featurehub.sse.model.FeatureValueType import io.featurehub.sse.model.SSEResultState import spock.lang.Specification import java.util.concurrent.ExecutorService - +import java.util.function.Consumer enum Fruit implements Feature { banana, peach, peach_quantity, peach_config, dragonfruit } @@ -44,42 +41,45 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON), ] and: "we have a readyness listener" - def readynessListener = Mock(ReadinessListener) - repo.addReadinessListener(readynessListener) - when: - repo.notify(SSEResultState.FEATURES, new ObjectMapper().writeValueAsString(features)) + Consumer readinessHandler = Mock(Consumer) + when: // have to do this in the when or it isn't tracking the mock + repo.addReadinessListener(readinessHandler) + and: + repo.updateFeatures(features) then: - 1 * readynessListener.notify(Readiness.Ready) - !repo.getFeatureState('banana').boolean - repo.getFeatureState('banana').key == 'banana' - repo.exists('banana') - repo.exists(Fruit.banana) - !repo.exists('dragonfruit') - !repo.exists(Fruit.dragonfruit) - repo.getFeatureState('banana').rawJson == null - repo.getFeatureState('banana').string == null - repo.getFeatureState('banana').number == null - repo.getFeatureState('banana').number == null - repo.getFeatureState('banana').set - !repo.getFeatureState('banana').enabled - repo.getFeatureState('peach').string == 'orange' - repo.exists('peach') - repo.exists(Fruit.peach) - repo.getFeatureState('peach').key == 'peach' - repo.getFeatureState('peach').number == null - repo.getFeatureState('peach').rawJson == null - repo.getFeatureState('peach').boolean == null - repo.getFeatureState('peach_quantity').number == 17 - repo.getFeatureState('peach_quantity').rawJson == null - repo.getFeatureState('peach_quantity').boolean == null - repo.getFeatureState('peach_quantity').string == null - repo.getFeatureState('peach_quantity').key == 'peach_quantity' - repo.getFeatureState('peach_config').rawJson == '{}' - repo.getFeatureState('peach_config').string == null - repo.getFeatureState('peach_config').number == null - repo.getFeatureState('peach_config').boolean == null - repo.getFeatureState('peach_config').key == 'peach_config' - repo.getAllFeatures().size() == 4 + 1 * readinessHandler.accept(Readiness.Ready) + 1 * readinessHandler.accept(Readiness.NotReady) + 0 * _ + !repo.getFeat('banana').flag + repo.getFeat('banana').key == 'banana' + repo.getFeat('banana').exists() + repo.getFeat(Fruit.banana).exists() + !repo.getFeat('dragonfruit').exists() + !repo.getFeat(Fruit.dragonfruit).exists() + repo.getFeat('banana').rawJson == null + repo.getFeat('banana').string == null + repo.getFeat('banana').number == null + repo.getFeat('banana').number == null + repo.getFeat('banana').set + !repo.getFeat('banana').enabled + repo.getFeat('peach').string == 'orange' + repo.getFeat('peach').exists() + repo.getFeat(Fruit.peach).exists() + repo.getFeat('peach').key == 'peach' + repo.getFeat('peach').number == null + repo.getFeat('peach').rawJson == null + repo.getFeat('peach').flag == null + repo.getFeat('peach_quantity').number == 17 + repo.getFeat('peach_quantity').rawJson == null + repo.getFeat('peach_quantity').flag == null + repo.getFeat('peach_quantity').string == null + repo.getFeat('peach_quantity').key == 'peach_quantity' + repo.getFeat('peach_config').rawJson == '{}' + repo.getFeat('peach_config').string == null + repo.getFeat('peach_config').number == null + repo.getFeat('peach_config').flag == null + repo.getFeat('peach_config').key == 'peach_config' + repo.getAllFeatures().size() == 5 } def "i can make all features available directly"() { @@ -88,23 +88,23 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), ] when: - repo.notify(features, false) - def feature = repo.getFeatureState('banana').boolean + repo.updateFeatures(features) + def feature = repo.getFeat('banana').flag and: "i make a change to the state but keep the version the same (ok because this is what rollout strategies do)" - repo.notify([ + repo.updateFeatures([ new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN), ]) - def feature2 = repo.getFeatureState('banana').boolean + def feature2 = repo.getFeat('banana').flag and: "then i make the change but up the version" - repo.notify([ + repo.updateFeatures([ new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true).type(FeatureValueType.BOOLEAN), ]) - def feature3 = repo.getFeatureState('banana').boolean + def feature3 = repo.getFeat('banana').flag and: "then i make a change but force it even if the version is the same" - repo.notify([ + repo.updateFeatures([ new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), ], true) - def feature4 = repo.getFeatureState('banana').boolean + def feature4 = repo.getFeat('banana').flag then: !feature feature2 @@ -114,7 +114,7 @@ class RepositorySpec extends Specification { def "a non existent feature is not set"() { when: "we ask for a feature that doesn't exist" - def feature = repo.getFeatureState('fred') + def feature = repo.getFeat('fred') then: !feature.enabled } @@ -123,67 +123,27 @@ class RepositorySpec extends Specification { when: "i create a feature to delete" def feature = new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN) and: "i delete a non existent feature" - repo.notify(SSEResultState.DELETE_FEATURE, new ObjectMapper().writeValueAsString(feature)) + repo.deleteFeature(feature) then: - !repo.getFeatureState('banana').enabled + !repo.getFeat('banana').enabled } def "A feature is deleted and it is now not set"() { given: "i have a feature" def feature = new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN) and: "i notify repo" - repo.notify([feature]) + repo.updateFeatures([feature]) when: "i check the feature state" - def f = repo.getFeatureState('banana').boolean + def f = repo.getFeat('banana').flag and: "i delete the feature" def featureDel = new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true).type(FeatureValueType.BOOLEAN) - repo.notify(SSEResultState.DELETE_FEATURE, new ObjectMapper().writeValueAsString(featureDel)) + repo.deleteFeature(featureDel) then: f - !repo.getFeatureState('banana').enabled + !repo.getFeat('banana').enabled } - def "i add an analytics collector and log and event"() { - given: "i have features" - def features = [ - new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), - new FeatureState().id(UUID.randomUUID()).key('peach').version(1L).value("orange").type(FeatureValueType.STRING), - new FeatureState().id(UUID.randomUUID()).key('peach_quantity').version(1L).value(17).type(FeatureValueType.NUMBER), - new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON), - ] - and: "i redefine the executor in the repository so i can prevent the event logging and update first" - List commands = [] - ExecutorService mockExecutor = [ - execute: { Runnable cmd -> commands.add(cmd) }, - shutdownNow: { -> }, - isShutdown: { false } - ] as ExecutorService - def newRepo = new ClientFeatureRepository(mockExecutor) - newRepo.notify(features) - commands.each {it.run() } // process - and: "i register a mock analytics collector" - def mockAnalytics = Mock(AnalyticsCollector) - newRepo.addAnalyticCollector(mockAnalytics) - when: "i log an event" - newRepo.logAnalyticsEvent("action", ['a': 'b']) - def heldNotificationCalls = new ArrayList(commands) - commands.clear() - and: "i change the status of the feature" - newRepo.notify(SSEResultState.FEATURE, new ObjectMapper().writeValueAsString( - new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true) - .type(FeatureValueType.BOOLEAN),)) - commands.each {it.run() } // process - heldNotificationCalls.each {it.run() } // process - then: - newRepo.getFeatureState('banana').boolean - 1 * mockAnalytics.logEvent('action', ['a': 'b'], { List f -> - f.size() == 4 - f.find({return it.key == 'banana'}) != null - !f.find({return it.key == 'banana'}).boolean - }) - } - def "a json config will properly deserialize into an object"() { given: "i have features" def features = [ @@ -192,30 +152,34 @@ class RepositorySpec extends Specification { and: "i register an alternate object mapper" repo.setJsonConfigObjectMapper(new ObjectMapper()) when: "i notify of features" - repo.notify(features) + repo.updateFeatures(features) then: 'the json object is there and deserialises' - repo.getFeatureState('banana').getJson(BananaSample) instanceof BananaSample - repo.getFeatureState(Fruit.banana).getJson(BananaSample) instanceof BananaSample - repo.getFeatureState('banana').getJson(BananaSample).sample == 12 - repo.getFeatureState(Fruit.banana).getJson(BananaSample).sample == 12 + repo.getFeat('banana').getJson(BananaSample) instanceof BananaSample + repo.getFeat(Fruit.banana).getJson(BananaSample) instanceof BananaSample + repo.getFeat('banana').getJson(BananaSample).sample == 12 + repo.getFeat(Fruit.banana).getJson(BananaSample).sample == 12 } - def "failure changes readyness to failure"() { + def "failure changes readiness to failure"() { given: "i have features" def features = [ new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), ] and: "i notify the repo" - def mockReadyness = Mock(ReadinessListener) - repo.addReadinessListener(mockReadyness) - repo.notify(features) + List statuses = [] + Consumer readynessHandler = { Readiness r -> + print("called $r") + statuses.add(r) + } + repo.addReadinessListener(readynessHandler) + repo.updateFeatures(features) def readyness = repo.readyness when: "i indicate failure" - repo.notify(SSEResultState.FAILURE, null) + repo.notify(SSEResultState.FAILURE) then: "we swap to not ready" - repo.readyness == Readiness.Failed + repo.readiness == Readiness.Failed readyness == Readiness.Ready - 1 * mockReadyness.notify(Readiness.Failed) + statuses == [Readiness.NotReady, Readiness.Ready, Readiness.Failed] } def "ack and bye are ignored"() { @@ -224,10 +188,10 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), ] and: "i notify the repo" - repo.notify(features) + repo.updateFeatures(features) when: "i ack and then bye, nothing happens" - repo.notify(SSEResultState.ACK, null) - repo.notify(SSEResultState.BYE, null) + repo.notify(SSEResultState.ACK) + repo.notify(SSEResultState.BYE) then: repo.readyness == Readiness.Ready } @@ -244,15 +208,17 @@ class RepositorySpec extends Specification { def updateListener = [] List emptyFeatures = [] features.each {f -> - def feature = repo.getFeatureState(f.key) + def feature = repo.getFeat(f.key) def listener = Mock(FeatureListener) updateListener.add(listener) feature.addListener(listener) emptyFeatures.add(feature.analyticsCopy()) } + def featureCountAfterRequestingEmptyFeatures = repo.allFeatures.size() when: "i fill in the repo" - repo.notify(features) + repo.updateFeatures(features) then: + featureCountAfterRequestingEmptyFeatures == features.size() updateListener.each { 1 * it.notify(_) } @@ -260,59 +226,35 @@ class RepositorySpec extends Specification { f.key != null !f.enabled f.string == null - f.boolean == null + f.flag == null f.rawJson == null f.number == null } features.each { it -> - repo.getFeatureState(it.key).key == it.key - repo.getFeatureState(it.key).enabled + repo.getFeat(it.key).key == it.key + repo.getFeat(it.key).enabled if (it.type == FeatureValueType.BOOLEAN) - repo.getFeatureState(it.key).boolean == it.value + repo.getFeat(it.key).flag == it.value else - repo.getFeatureState(it.key).boolean == null + repo.getFeat(it.key).flag == null if (it.type == FeatureValueType.NUMBER) - repo.getFeatureState(it.key).number == it.value + repo.getFeat(it.key).number == it.value else - repo.getFeatureState(it.key).number == null + repo.getFeat(it.key).number == null if (it.type == FeatureValueType.STRING) - repo.getFeatureState(it.key).string.equals(it.value) + repo.getFeat(it.key).string.equals(it.value) else - repo.getFeatureState(it.key).string == null + repo.getFeat(it.key).string == null if (it.type == FeatureValueType.JSON) - repo.getFeatureState(it.key).rawJson.equals(it.value) + repo.getFeat(it.key).rawJson.equals(it.value) else - repo.getFeatureState(it.key).rawJson == null + repo.getFeat(it.key).rawJson == null } } - def "the client context encodes as expected"() { - when: "i encode the context" - def tc = new TestContext().userKey("DJElif") - .country(StrategyAttributeCountryName.TURKEY) - .attr("city", "Istanbul") - .attrs("musical styles", Arrays.asList("psychedelic", "deep")) - .device(StrategyAttributeDeviceName.DESKTOP) - .platform(StrategyAttributePlatformName.ANDROID) - .version("2.3.7") - .sessionKey("anjunadeep").build().get() - - and: "i do the same thing again to ensure i can reset everything" - tc.userKey("DJElif") - .country(StrategyAttributeCountryName.TURKEY) - .attr("city", "Istanbul") - .attrs("musical styles", Arrays.asList("psychedelic", "deep")) - .device(StrategyAttributeDeviceName.DESKTOP) - .platform(StrategyAttributePlatformName.ANDROID) - .version("2.3.7") - .sessionKey("anjunadeep").build().get() - then: - FeatureStateUtils.generateXFeatureHubHeaderFromMap(tc.context()) == - 'city=Istanbul,country=turkey,device=desktop,musical styles=psychedelic%2Cdeep,platform=android,session=anjunadeep,userkey=DJElif,version=2.3.7' - } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy index a01cd90..372cdd6 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy @@ -5,37 +5,34 @@ import spock.lang.Specification import java.util.concurrent.CompletableFuture class ServerEvalContextSpec extends Specification { - def config - def repo - def edge + FeatureHubConfig config + InternalFeatureRepository repo + EdgeService edge def setup() { config = Mock(FeatureHubConfig) - repo = Mock(FeatureRepositoryContext) + repo = Mock(InternalFeatureRepository) edge = Mock(EdgeService) } def "a server eval context should allow a build which should trigger a poll"() { given: "i have the requisite setup" - def scc = new ServerEvalFeatureContext(config, repo, { -> edge}) - edge.isRequiresReplacementOnHeaderChange() >> false + def scc = new ServerEvalFeatureContext(repo, edge) when: "i attempt to build" - scc.build(); - scc.userKey("fred").build() - scc.clear().build(); + scc.build().get(); + scc.userKey("fred").build().get() + scc.clear().build().get() then: "" 2 * repo.repositoryNotReady() - 2 * edge.isRequiresReplacementOnHeaderChange() 2 * edge.contextChange(null, '0') >> { def future = new CompletableFuture<>() future.complete(scc) return future } 1 * edge.contextChange("userkey=fred", '6a1d1fa42d1c1917552a255a940792205cb62cc2efd6613ab5a3f75d0038518b') >> { - def future = new CompletableFuture<>() - future.complete(scc) - return future + return CompletableFuture.completedFuture(scc) } + 3 * repo.execute { Runnable cmd -> cmd.run() } 0 * _ } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy index 8c924dd..dab003d 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy @@ -15,6 +15,7 @@ import java.util.concurrent.ExecutorService class StrategySpec extends Specification { ClientFeatureRepository repo + EdgeService edge def setup() { def exec = [ @@ -22,7 +23,10 @@ class StrategySpec extends Specification { shutdownNow: { -> }, isShutdown: { false } ] as ExecutorService + repo = new ClientFeatureRepository(exec) + + edge = Mock(EdgeService) } def "basic boolean strategy"() { @@ -41,18 +45,18 @@ class StrategySpec extends Specification { ] )]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def cc = new TestContext(repo).country(StrategyAttributeCountryName.TURKEY) + def cc = new TestContext(repo, edge).country(StrategyAttributeCountryName.TURKEY) and: "we create a context not matching the strategy" - def ccNot = new TestContext(repo).country(StrategyAttributeCountryName.NEW_ZEALAND) + def ccNot = new TestContext(repo, edge).country(StrategyAttributeCountryName.NEW_ZEALAND) then: "without the context it is true" - repo.getFeatureState("bool1").boolean + repo.getFeat("bool1").flag and: "with the good context it is false" - !cc.feature("bool1").boolean + !cc.feature("bool1").flag !cc.isEnabled("bool1") and: "with the bad context it is true" - ccNot.feature("bool1").boolean + ccNot.feature("bool1").flag } def "number strategy"() { @@ -79,13 +83,13 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27") - def ccNoMatch = new TestContext(repo).attr("age", "18") - def ccSecond = new TestContext(repo).attr("age", "43") + def ccFirst = new TestContext(repo, edge).attr("age", "27") + def ccNoMatch = new TestContext(repo, edge).attr("age", "18") + def ccSecond = new TestContext(repo, edge).attr("age", "43") then: "without the context it is true" - repo.getFeatureState("num1").number == 16 + repo.getFeat("num1").number == 16 ccNoMatch.feature("num1").number == 16 ccSecond.feature("num1").number == 6 ccFirst.feature("num1").number == 10 @@ -115,15 +119,15 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27").platform(StrategyAttributePlatformName.IOS) - def ccNoMatch = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) - def ccSecond = new TestContext(repo).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) - def ccThird = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) - def ccEmpty = new TestContext(repo) + def ccFirst = new TestContext(repo, edge).attr("age", "27").platform(StrategyAttributePlatformName.IOS) + def ccNoMatch = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) + def ccSecond = new TestContext(repo, edge).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) + def ccThird = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) + def ccEmpty = new TestContext(repo, edge) then: "without the context it is true" - repo.getFeatureState("feat1").string == "feature" + repo.getFeat("feat1").string == "feature" ccNoMatch.feature("feat1").string == "feature" ccSecond.feature("feat1").string == "not-mobile" ccFirst.feature("feat1").string == "older-than-twenty" @@ -154,16 +158,16 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27").platform(StrategyAttributePlatformName.IOS) - def ccNoMatch = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) - def ccSecond = new TestContext(repo).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) - def ccThird = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) - def ccEmpty = new TestContext(repo) + def ccFirst = new TestContext(repo, edge).attr("age", "27").platform(StrategyAttributePlatformName.IOS) + def ccNoMatch = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) + def ccSecond = new TestContext(repo, edge).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) + def ccThird = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) + def ccEmpty = new TestContext(repo, edge) then: "without the context it is true" - repo.getFeatureState("feat1").rawJson == "feature" - repo.getFeatureState("feat1").string == null + repo.getFeat("feat1").rawJson == "feature" + repo.getFeat("feat1").string == null ccNoMatch.feature("feat1").rawJson == "feature" ccNoMatch.feature("feat1").string == null ccSecond.feature("feat1").rawJson == "not-mobile" diff --git a/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy b/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy index d5168bb..4cfb509 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy @@ -4,15 +4,13 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.Future class TestContext extends BaseClientContext { - TestContext(FeatureRepositoryContext repo) { - super(repo, null) + TestContext(InternalFeatureRepository repo, EdgeService edgeService) { + super(repo, edgeService) } @Override Future build() { - CompletableFuture x = new CompletableFuture() - x.complete(this) - return x + return CompletableFuture.completedFuture(this) } @Override @@ -20,22 +18,12 @@ class TestContext extends BaseClientContext { return null } - @Override - ClientContext logAnalyticsEvent(String action, Map other) { - return this - } - - @Override - ClientContext logAnalyticsEvent(String action) { - return this - } - @Override void close() { } @Override boolean exists(String key) { - return repository.exists(key) + return feature(key).exists() } } diff --git a/client-java-jersey/pom.xml b/client-java-jersey/pom.xml index 1da6c56..2bd6f12 100644 --- a/client-java-jersey/pom.xml +++ b/client-java-jersey/pom.xml @@ -74,48 +74,32 @@ [1.1, 2) test + + + org.glassfish.jersey.test-framework + jersey-test-framework-core + 2.36 + test + + + org.glassfish.jersey.media + jersey-media-sse + 2.36 + + - - - org.apache.maven.plugins - maven-dependency-plugin - - - extract-sse-edge-components - initialize - - copy - - - - - io.featurehub.sdk - java-client-api - 3.2 - api - yaml - ${project.basedir}/target - sse.yaml - - - true - true - - - - org.openapitools openapi-generator-maven-plugin - 5.2.1 + 6.0.1 cd.connect.openapi connect-openapi-jersey3 - 7.15 + 8.8 @@ -129,7 +113,7 @@ ${project.basedir}/target/generated-sources/api io.featurehub.sse.api io.featurehub.sse.model - ${project.basedir}/target/sse.yaml + https://api.dev.featurehub.io/edge/1.1.5.yaml jersey3-api true true diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java deleted file mode 100644 index 97169ac..0000000 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.featurehub.client.jersey; - -import io.featurehub.client.GoogleAnalyticsApiClient; - -import javax.ws.rs.client.ClientBuilder; -import javax.ws.rs.client.Entity; -import javax.ws.rs.client.WebTarget; -import javax.ws.rs.core.MediaType; - -public class GoogleAnalyticsJerseyApiClient implements GoogleAnalyticsApiClient { - private final WebTarget target; - - public GoogleAnalyticsJerseyApiClient() { - target = ClientBuilder.newBuilder() - .build().target("https://www.google-analytics.com/batch"); - } - - @Override - public void postBatchUpdate(String batchData) { - target.request().header("Host", "www.google-analytics.com").post(Entity.entity(batchData, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); - } -} diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index c41a839..5ed96d8 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -5,12 +5,20 @@ import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.edge.EdgeRetryer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.function.Supplier; public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(FeatureHubConfig config, InternalFeatureRepository repository) { + public Supplier createEdgeService(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } + + @Override + public Supplier createEdgeService(@NotNull FeatureHubConfig config) { + return createEdgeService(config, null); + } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 00703a1..808fad2 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -89,7 +89,7 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { poll(); } else { - change.complete(repository.getReadyness()); + change.complete(repository.getReadiness()); } return change; @@ -198,7 +198,7 @@ private void initEventSource() { // tell any waiting clients we are now ready if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadyness())); + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } } catch (Exception e) { log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); @@ -211,7 +211,7 @@ private void initEventSource() { log.trace("[featurehub-sdk] closed"); // we never received a satisfactory connection - if (repository.getReadyness() == Readiness.NotReady) { + if (repository.getReadiness() == Readiness.NotReady) { repository.notify(SSEResultState.FAILURE); } diff --git a/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java b/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java index 9673446..3c7554b 100644 --- a/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java +++ b/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java @@ -1,31 +1,31 @@ package io.featurehub.server.jersey; -import io.featurehub.client.FeatureRepository; +import io.featurehub.client.ThreadLocalContext; import org.glassfish.jersey.server.monitoring.ApplicationEvent; import org.glassfish.jersey.server.monitoring.ApplicationEventListener; import org.glassfish.jersey.server.monitoring.RequestEvent; import org.glassfish.jersey.server.monitoring.RequestEventListener; + import javax.ws.rs.core.Response; -import javax.inject.Inject; import java.lang.reflect.Method; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * This is an annotation scanner that is designed to allow you to annotate an API and + * indicate that it will not be exposed unless a Flag is enabled. It expects the use of the + * ThreadLocalContext to be setup and functioning. + * + * You should descend from this, add your own annotation for Priority and register it. + */ public class FeatureRequiredApplicationEventListener implements ApplicationEventListener { - private final FeatureRepository featureRepository; - - @Inject - public FeatureRequiredApplicationEventListener(FeatureRepository featureRepository) { - this.featureRepository = featureRepository; - } - @Override public void onEvent(ApplicationEvent event) { } @Override public RequestEventListener onRequest(RequestEvent requestEvent) { - return new FeatureRequiredEvent(featureRepository); + return new FeatureRequiredEvent(); } static class FeatureInfo { @@ -39,12 +39,6 @@ static class FeatureInfo { static Map featureInfo = new ConcurrentHashMap<>(); static class FeatureRequiredEvent implements RequestEventListener { - private final FeatureRepository featureRepository; - - FeatureRequiredEvent(FeatureRepository featureRepository) { - this.featureRepository = featureRepository; - } - @Override public void onEvent(RequestEvent event) { if (event.getType() == RequestEvent.Type.REQUEST_MATCHED) { @@ -56,12 +50,10 @@ private void featureCheck(RequestEvent event) { FeatureInfo fi = featureInfo.computeIfAbsent(getMethod(event), this::extractFeatureInfo); // if any of the flags mentioned are OFF, return NOT_FOUND - if (fi.features.length > 0) { - for(String feature : fi.features) { - if (Boolean.FALSE.equals(featureRepository.getFeatureState(feature).getBoolean())) { - event.getContainerRequest().abortWith(Response.status(Response.Status.NOT_FOUND).build()); - return; - } + for (String feature : fi.features) { + if (!ThreadLocalContext.getContext().feature(feature).isEnabled()) { + event.getContainerRequest().abortWith(Response.status(Response.Status.NOT_FOUND).build()); + return; } } } diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy index 803644e..83900d1 100644 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy @@ -4,118 +4,136 @@ import cd.connect.openapi.support.ApiClient import io.featurehub.client.ClientFeatureRepository import io.featurehub.client.EdgeFeatureHubConfig import io.featurehub.client.FeatureHubConfig +import io.featurehub.client.Readiness +import io.featurehub.client.edge.EdgeRetryer import io.featurehub.sse.api.FeatureService import io.featurehub.sse.model.FeatureStateUpdate +import org.glassfish.jersey.media.sse.EventOutput +import org.glassfish.jersey.media.sse.EventSource +import org.glassfish.jersey.media.sse.OutboundEvent import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Specification import javax.ws.rs.client.Client import javax.ws.rs.client.WebTarget +import javax.ws.rs.sse.OutboundSseEvent class JerseyClientSpec extends Specification { private static final Logger log = LoggerFactory.getLogger(JerseyClientSpec.class) - def targetUrl - def basePath - FeatureHubConfig sdkPartialUrl - FeatureService mockFeatureService - ClientFeatureRepository mockRepository - WebTarget mockEventSource + Closure sseClosure + FeatureHubConfig config + SSETestHarness harness - def "basic initialization test works as expect"() { - given: "i have a valid url" - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - when: "i initialize with a valid kind of sdk url" - def client = new JerseyClient(url, new ClientFeatureRepository(1)) { - @Override - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - targetUrl = sdkUrl - return super.makeEventSourceTarget(client, sdkUrl) - } - - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - basePath = apiClient.basePath - sdkPartialUrl = fhConfig - return super.makeFeatureServiceClient(apiClient) - } - } - then: "the urls are correctly initialize" - targetUrl == url.realtimeUrl - basePath == 'http://localhost:80' - sdkPartialUrl.apiKey() == 'sdk-url' + def setup() { + harness = new SSETestHarness() + harness.setUp() } - def "test the set feature sdk call"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - and: "I have a client and mock the feature service url" - def client = new JerseyClient(url, false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState("key", update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) + def teardown() { + harness.tearDown() } - def "test the set feature sdk call using a Feature"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - and: "I have a client and mock the feature service url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } + def "A basic client connect works as expected"() { + given: + EventOutput output + config = harness.getConfig(["123/345*675"]) { envId, apiKey, featureHubAttrs, extraConfig, browserHubAttrs, etag -> + output = new EventOutput() + return output } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState(InternalFeature.FEATURE, update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) - } - - def "a client side evaluation header does not trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') + when: + def future = config.newContext().build() + and: + output.write(new OutboundEvent.Builder().name("ack").id("1").build()) + output.write(new OutboundEvent.Builder().name("failure").id("2").build()) + output.close() then: - client.featurehubContextHeader == null + future.get().repository.readiness == Readiness.Failed } - def "a server side evaluation header does not trigger the context header to be set if it is null"() { - given: "i have a client with a server eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - client.neverConnect = true // groovy is groovy - when: "i set attributes" - client.contextChange(null, '0') - then: - client.featurehubContextHeader == null - - } +// def "basic initialization test works as expect"() { +// given: "i have a valid url" +// def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") +// when: "i initialize with a valid kind of sdk url" +// def client = new JerseySSEClient(null, url, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { +// @Override +// protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { +// targetUrl = sdkUrl +// return super.makeEventSourceTarget(client, sdkUrl) +// } +// } +// then: "the urls are correctly initialize" +// targetUrl == url.realtimeUrl +// basePath == 'http://localhost:80' +// sdkPartialUrl.apiKey() == 'sdk-url' +// } +// +// def "test the set feature sdk call"() { +// given: "I have a mock feature service" +// def config = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") +// def testApi = new TestSDKClient(config) +// and: "i have a feature state update" +// def update = new FeatureStateUpdate().lock(true) +// when: "I call to set a feature" +// testApi.setFeatureState(config.apiKey(), "key", update) +// then: +// mockFeatureService != null +// 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) +// } +// +// def "test the set feature sdk call using a Feature"() { +// given: "I have a mock feature service" +// mockFeatureService = Mock(FeatureService) +// and: "I have a client and mock the feature service url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) { +// @Override +// protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { +// return mockFeatureService +// } +// } +// and: "i have a feature state update" +// def update = new FeatureStateUpdate().lock(true) +// when: "I call to set a feature" +// client.setFeatureState(InternalFeature.FEATURE, update) +// then: +// mockFeatureService != null +// 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) +// } - def "a server side evaluation header does trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader != null - } +// def "a client side evaluation header does not trigger the context header to be set"() { +// given: "i have a client with a client eval url" +// def config = new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2") +// def client = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) +// and: "we set up a server" +// def harness = new SSETestHarness(config) +// +// when: "i set attributes" +// client.contextChange("fred=mary,susan", '0').get() +// then: +// +// } +// +// def "a server side evaluation header does not trigger the context header to be set if it is null"() { +// given: "i have a client with a server eval url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) +// client.neverConnect = true // groovy is groovy +// when: "i set attributes" +// client.contextChange(null, '0') +// then: +// client.featurehubContextHeader == null +// +// } +// +// def "a server side evaluation header does trigger the context header to be set"() { +// given: "i have a client with a client eval url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) +// when: "i set attributes" +// client.contextChange("fred=mary,susan", '0') +// then: +// client.featurehubContextHeader != null +// } } diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy new file mode 100644 index 0000000..ddb7859 --- /dev/null +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy @@ -0,0 +1,44 @@ +package io.featurehub.client.jersey + +import io.featurehub.client.EdgeFeatureHubConfig +import io.featurehub.client.FeatureHubConfig +import org.glassfish.jersey.media.sse.EventOutput +import org.glassfish.jersey.media.sse.SseFeature +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.test.JerseyTest + +import javax.ws.rs.GET +import javax.ws.rs.HeaderParam +import javax.ws.rs.Path +import javax.ws.rs.PathParam +import javax.ws.rs.Produces +import javax.ws.rs.QueryParam +import javax.ws.rs.core.Application + +@Path("/features") +class SSETestHarness extends JerseyTest { + Closure backhaul + + @Override + protected Application configure() { + return new ResourceConfig(SSETestHarness) + } + + @GET + @Path("{environmentId}/{apiKey}") + @Produces(SseFeature.SERVER_SENT_EVENTS) + public EventOutput features( + @PathParam("environmentId") UUID envId, + @PathParam("apiKey") String apiKey, + @HeaderParam("x-featurehub") List featureHubAttrs, // non browsers can set headers + @HeaderParam("x-fh-extraconfig") String extraConfig, + @QueryParam("xfeaturehub") String browserHubAttrs, // browsers can't set headers, + @HeaderParam("Last-Event-ID") String etag) { + return backhaul(envId, apiKey, featureHubAttrs, extraConfig, browserHubAttrs, etag) + } + + FeatureHubConfig getConfig(List apiKeys, Closure backhaul) { + this.backhaul = backhaul + return new EdgeFeatureHubConfig(target().uri.toString(), apiKeys) + } +} diff --git a/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java index 5c99a4f..e51b072 100644 --- a/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java +++ b/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java @@ -6,6 +6,7 @@ import io.featurehub.client.Feature; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.FeatureRepository; +import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.sse.model.FeatureStateUpdate; import io.featurehub.sse.model.StrategyAttributeDeviceName; import io.featurehub.sse.model.StrategyAttributePlatformName; @@ -23,7 +24,7 @@ public static void main(String[] args) throws Exception { final ClientContext ctx = config.newContext(); - final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); + final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").isEnabled(); FeatureRepository cfr = ctx.getRepository(); @@ -64,10 +65,15 @@ public static void main(String[] args) throws Exception { // @Test public void changeToggleTest() { ClientFeatureRepository cfr = new ClientFeatureRepository(5); - final JerseyClient client = - new JerseyClient(new EdgeFeatureHubConfig("http://localhost:8553", changeToggleEnv), false, - cfr, null); - client.setFeatureState("NEW_BOAT", new FeatureStateUpdate().lock(false).value(Boolean.TRUE)); + final EdgeFeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8553", changeToggleEnv); + + final JerseySSEClient client = + new JerseySSEClient(null, + config, + EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + + new TestSDKClient(config).setFeatureState( config.apiKey(),"NEW_BOAT", + new FeatureStateUpdate().lock(false).value(Boolean.TRUE)); } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java deleted file mode 100644 index 834135b..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.featurehub.client.jersey; - -import io.featurehub.client.GoogleAnalyticsApiClient; - -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.MediaType; - -public class GoogleAnalyticsJerseyApiClient implements GoogleAnalyticsApiClient { - private final WebTarget target; - - public GoogleAnalyticsJerseyApiClient() { - target = ClientBuilder.newBuilder() - .build().target("https://www.google-analytics.com/batch"); - } - - @Override - public void postBatchUpdate(String batchData) { - target.request().header("Host", "www.google-analytics.com").post(Entity.entity(batchData, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); - } -} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 68c3dfe..29e327d 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -41,7 +41,7 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { private final List> waitingClients = new ArrayList<>(); public JerseySSEClient(@NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { - this((InternalFeatureRepository) null, config, retryer); + this(null, config, retryer); } public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { @@ -90,7 +90,7 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { poll(); } else { - change.complete(repository.getReadyness()); + change.complete(repository.getReadiness()); } return change; @@ -199,7 +199,7 @@ private void initEventSource() { // tell any waiting clients we are now ready if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadyness())); + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } } catch (Exception e) { log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); @@ -212,7 +212,7 @@ private void initEventSource() { log.trace("[featurehub-sdk] closed"); // we never received a satisfactory connection - if (repository.getReadyness() == Readiness.NotReady) { + if (repository.getReadiness() == Readiness.NotReady) { repository.notify(SSEResultState.FAILURE); } diff --git a/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java b/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java index a3a83e8..fcbe460 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java +++ b/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java @@ -1,31 +1,31 @@ package io.featurehub.server.jersey; -import io.featurehub.client.FeatureRepository; +import io.featurehub.client.ThreadLocalContext; +import jakarta.ws.rs.core.Response; import org.glassfish.jersey.server.monitoring.ApplicationEvent; import org.glassfish.jersey.server.monitoring.ApplicationEventListener; import org.glassfish.jersey.server.monitoring.RequestEvent; import org.glassfish.jersey.server.monitoring.RequestEventListener; -import jakarta.ws.rs.core.Response; -import jakarta.inject.Inject; + import java.lang.reflect.Method; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * This is an annotation scanner that is designed to allow you to annotate an API and + * indicate that it will not be exposed unless a Flag is enabled. It expects the use of the + * ThreadLocalContext to be setup and functioning. + * + * You should descend from this, add your own annotation for Priority and register it. + */ public class FeatureRequiredApplicationEventListener implements ApplicationEventListener { - private final FeatureRepository featureRepository; - - @Inject - public FeatureRequiredApplicationEventListener(FeatureRepository featureRepository) { - this.featureRepository = featureRepository; - } - @Override public void onEvent(ApplicationEvent event) { } @Override public RequestEventListener onRequest(RequestEvent requestEvent) { - return new FeatureRequiredEvent(featureRepository); + return new FeatureRequiredEvent(); } static class FeatureInfo { @@ -39,12 +39,6 @@ static class FeatureInfo { static Map featureInfo = new ConcurrentHashMap<>(); static class FeatureRequiredEvent implements RequestEventListener { - private final FeatureRepository featureRepository; - - FeatureRequiredEvent(FeatureRepository featureRepository) { - this.featureRepository = featureRepository; - } - @Override public void onEvent(RequestEvent event) { if (event.getType() == RequestEvent.Type.REQUEST_MATCHED) { @@ -56,12 +50,10 @@ private void featureCheck(RequestEvent event) { FeatureInfo fi = featureInfo.computeIfAbsent(getMethod(event), this::extractFeatureInfo); // if any of the flags mentioned are OFF, return NOT_FOUND - if (fi.features.length > 0) { - for(String feature : fi.features) { - if (Boolean.FALSE.equals(featureRepository.getFeatureState(feature).getBoolean())) { - event.getContainerRequest().abortWith(Response.status(Response.Status.NOT_FOUND).build()); - return; - } + for (String feature : fi.features) { + if (!ThreadLocalContext.getContext().feature(feature).isEnabled()) { + event.getContainerRequest().abortWith(Response.status(Response.Status.NOT_FOUND).build()); + return; } } } diff --git a/examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java b/examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java new file mode 100644 index 0000000..5fb6cbf --- /dev/null +++ b/examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java @@ -0,0 +1,27 @@ +package todo.backend; + +import io.featurehub.client.analytics.AnalyticsFeaturesCollection; +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class AnalyticsRequestMeasurement extends AnalyticsFeaturesCollection { + private final long duration; + @NotNull + private final String url; + public AnalyticsRequestMeasurement(long duration, @NotNull String url) { + super(null, null); + + this.duration = duration; + this.url = url; + } + + @Override + protected @NotNull Map toMap() { + final LinkedHashMap data = new LinkedHashMap<>(super.toMap()); + data.put("duration", duration); + data.put("url", url); + return data; + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/Application.java b/examples/todo-java/src/main/java/todo/backend/Application.java index 0b14f9b..6360656 100644 --- a/examples/todo-java/src/main/java/todo/backend/Application.java +++ b/examples/todo-java/src/main/java/todo/backend/Application.java @@ -6,12 +6,14 @@ import cd.connect.lifecycle.LifecycleStatus; import io.featurehub.client.Readiness; import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.hk2.api.Immediate; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.backend.resources.FeatureAnalyticsFilter; +import todo.backend.resources.HealthResource; import todo.backend.resources.TodoResource; import jakarta.inject.Singleton; @@ -33,43 +35,24 @@ public void init() throws Exception { log.info("attempting to start on port {} - will wait for features", BASE_URI.toASCIIString()); - FeatureHubSource fhSource = new FeatureHubSource(); - // register our resources, try and tag them as singleton as they are instantiated faster ResourceConfig config = new ResourceConfig( TodoResource.class, + HealthResource.class, FeatureAnalyticsFilter.class) .register(new AbstractBinder() { @Override protected void configure() { - bind(fhSource).in(Singleton.class).to(FeatureHub.class); + bind(FeatureHubSource.class).in(Immediate.class).to(FeatureHub.class); } - }) - - ; + }); final HttpServer server = GrizzlyHttpServerFactory.createHttpServer(BASE_URI, config, false); - // call "server.start()" here if you wish to start the application without waiting for features - log.info("Waiting on a complete list of features before starting."); - fhSource.getConfig().addReadinessListener((ready) -> { - if (ready == Readiness.Ready) { - try { - server.start(); - } catch (IOException e) { - log.error("Failed to start", e); - throw new RuntimeException(e); - } - - log.info("Application started. (HTTP/2 enabled!) -> {}", BASE_URI); - } else if (ready == Readiness.Failed) { - log.info("Connection failed, wait for it to come back up."); - } - }); + server.start(); ApplicationLifecycleManager.registerListener(trans -> { if (trans.next == LifecycleStatus.TERMINATING) { - fhSource.close(); server.shutdown(10, TimeUnit.SECONDS); } }); diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java index 668eaf3..86027e0 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java @@ -6,7 +6,5 @@ import java.util.concurrent.Future; public interface FeatureHub { - ClientContext fhClient(); FeatureHubConfig getConfig(); - Future poll(); } diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index 18e1e67..70b60c2 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -2,11 +2,14 @@ import cd.connect.app.config.ConfigKey; import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; import io.featurehub.android.FeatureHubClient; import io.featurehub.client.ClientContext; import io.featurehub.client.ClientFeatureRepository; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.ThreadLocalContext; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; import io.featurehub.client.jersey.JerseySSEClient; @@ -15,6 +18,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.host") @@ -23,22 +27,19 @@ public class FeatureHubSource implements FeatureHub { String sdkKey; @ConfigKey("feature-service.google-analytics-key") String analyticsKey = ""; - @ConfigKey("feature-service.cid") - String analyticsCid = ""; @ConfigKey("feature-service.sdk") String clientSdk = "jersey3"; + @ConfigKey("feature-service.poll-interval") + Integer pollInterval = 1000; // in milliseconds - private final ClientFeatureRepository repository; private final EdgeFeatureHubConfig config; - @Nullable - private final FeatureHubClient androidClient; public FeatureHubSource() { DeclaredConfigResolver.resolve(this); config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey); - repository = new ClientFeatureRepository(5); + ClientFeatureRepository repository = new ClientFeatureRepository(5); repository.registerValueInterceptor(true, new SystemPropertyValueInterceptor()); // if (analyticsCid.length() > 0 && analyticsKey.length() > 0) { @@ -48,30 +49,31 @@ public FeatureHubSource() { config.setRepository(repository); + ThreadLocalContext.setConfig(config); + // Do this if you wish to force the connection to stay open. if (clientSdk.equals("jersey3")) { final JerseySSEClient jerseyClient = new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); config.setEdgeService(() -> jerseyClient); - androidClient = null; } else if (clientSdk.equals("android")) { - final FeatureHubClient client = new FeatureHubClient(config, 1); + final FeatureHubClient client = new FeatureHubClient(config, pollInterval); config.setEdgeService(() -> client); - androidClient = client; } else if (clientSdk.equals("sse")) { final SSEClient client = new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); config.setEdgeService(() -> client); - androidClient = null; } else { throw new RuntimeException("Unknown featurehub client"); } config.init(); - } - public ClientContext fhClient() { - return config.newContext(); + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + close(); + } + }); } @Override @@ -79,15 +81,6 @@ public FeatureHubConfig getConfig() { return config; } - @Override - public Future poll() { - if (androidClient != null) { - return androidClient.poll(); - } - - return CompletableFuture.completedFuture(true); - } - public void close() { config.close(); } diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index 6ad9803..4f105bf 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -1,32 +1,21 @@ package todo.backend.resources; -import cd.connect.app.config.ConfigKey; import cd.connect.app.config.DeclaredConfigResolver; -import io.featurehub.client.GoogleAnalyticsApiClient; -import todo.backend.FeatureHub; - +import io.featurehub.client.ThreadLocalContext; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import todo.backend.AnalyticsRequestMeasurement; + import java.io.IOException; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { - private final FeatureHub fh; - private static final AtomicLong timeout = new AtomicLong(0L); - @ConfigKey("feature-service.poll-interval") - Integer pollInterval = 200; // in milliseconds @Inject - public FeatureAnalyticsFilter(FeatureHub fh) { - this.fh = fh; - + public FeatureAnalyticsFilter() { DeclaredConfigResolver.resolve(this); } @@ -34,11 +23,6 @@ public FeatureAnalyticsFilter(FeatureHub fh) { public void filter(ContainerRequestContext requestContext) throws IOException { final long currentTime = System.currentTimeMillis(); requestContext.setProperty("startTime", currentTime); - - if (currentTime - timeout.get() > pollInterval) { - fh.poll(); - timeout.set(currentTime); - } } @Override @@ -49,12 +33,9 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont duration = System.currentTimeMillis() - start; } - Map other = new HashMap<>(); - other.put(GoogleAnalyticsApiClient.GA_VALUE, Long.toString(duration)); final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); if (matchedURIs.size() > 0) { - fh.getRepository().logAnalyticsEvent(matchedURIs.get(0), other); + ThreadLocalContext.context().recordAnalyticsEvent(new AnalyticsRequestMeasurement(duration, matchedURIs.get(0))); } - } } diff --git a/examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java new file mode 100644 index 0000000..2ab99cc --- /dev/null +++ b/examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java @@ -0,0 +1,29 @@ +package todo.backend.resources; + + +import io.featurehub.client.Readiness; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import todo.backend.FeatureHub; + +@Path("/health") +public class HealthResource { + private final FeatureHub featureHub; + + @Inject + public HealthResource(FeatureHub featureHub) { + this.featureHub = featureHub; + } + + @GET + @Path(("/liveness")) + public Response liveness() { + if (featureHub.getConfig().getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.serverError().build(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java index 59dfc72..21ec975 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java @@ -1,30 +1,29 @@ package todo.backend.resources; import io.featurehub.client.ClientContext; +import io.featurehub.client.ThreadLocalContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.api.TodoService; -import todo.backend.FeatureHub; import todo.model.Todo; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.ws.rs.NotFoundException; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -@Singleton + public class TodoResource implements TodoService { private static final Logger log = LoggerFactory.getLogger(TodoResource.class); Map> todos = new ConcurrentHashMap<>(); - private final FeatureHub featureHub; @Inject - public TodoResource(FeatureHub featureHub) { - this.featureHub = featureHub; + public TodoResource() { log.info("created"); } @@ -74,19 +73,18 @@ private String processTitle(ClientContext fhClient, String title) { return title; } - private ClientContext fhClient(String user) { + @NotNull private ClientContext fhClient(String user) { try { - return featureHub.fhClient().userKey(user).build().get(); + return ThreadLocalContext.getContext().userKey(user).build().get(); } catch (Exception e) { log.error("Unable to get context!"); + throw new WebApplicationException(e); } - - return null; } @Override - public List addTodo(String user, Todo body) { - if (body.getId() == null || body.getId().length() == 0) { + public List addTodo(@NotNull String user, Todo body) { + if (body.getId().length() == 0) { body.id(UUID.randomUUID().toString()); } @@ -97,24 +95,24 @@ public List addTodo(String user, Todo body) { } @Override - public List listTodos(String user) { + public List listTodos(@NotNull String user) { return getTodoList(getTodoMap(user), user); } @Override - public void removeAllTodos(String user) { + public void removeAllTodos(@NotNull String user) { getTodoMap(user).clear(); } @Override - public List removeTodo(String user, String id) { + public List removeTodo(@NotNull String user, @NotNull String id) { Map userTodo = getTodoMap(user); userTodo.remove(id); return getTodoList(userTodo, user); } @Override - public List resolveTodo(String id, String user) { + public List resolveTodo(@NotNull String id, @NotNull String user) { Map userTodo = getTodoMap(user); Todo todo = userTodo.get(id); From e29281c7a87c8dd9bb81e1593929d8ee14564dd9 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sat, 10 Jun 2023 18:46:46 +1200 Subject: [PATCH 03/22] amalgamating work plus completeness work --- README.adoc | 7 + .../AndroidFeatureHubClientFactory.java | 23 -- ....featurehub.client.FeatureHubClientFactory | 1 - client-java-api/pom.xml | 1 + .../io/featurehub/client/ApplyFeature.java | 4 +- .../client/ClientFeatureRepository.java | 2 +- .../client/EdgeFeatureHubConfig.java | 63 +++- .../client/FeatureHubClientFactory.java | 26 +- .../featurehub/client/FeatureHubConfig.java | 29 +- .../featurehub/client/FeatureStateBase.java | 6 +- .../java/io/featurehub/client/TestApi.java | 13 + .../io/featurehub/client/TestApiResult.java | 64 ++++ .../client/edge/EdgeRetryService.java | 4 +- .../featurehub/client/edge/EdgeRetryer.java | 33 +- .../client/FeatureHubTestClientFactory.groovy | 21 +- client-java-jersey/pom.xml | 19 +- .../client/jersey/FeatureService.java | 20 ++ .../client/jersey/FeatureServiceImpl.java | 67 ++-- .../jersey/JerseyFeatureHubClientFactory.java | 25 +- .../client/jersey/JerseySSEClient.java | 125 +++++--- .../featurehub/client/jersey/RestClient.java | 293 ++++++++++++++++++ .../client/jersey/TestSDKClient.java | 20 +- ...Spec.groovy => JerseySSEClientSpec.groovy} | 96 ++++-- .../client/jersey/RestClientSpec.groovy | 146 +++++++++ .../client/jersey/SSETestHarness.groovy | 13 +- .../src/test/resources/log4j2.xml | 7 +- client-java-jersey3/pom.xml | 20 +- .../client/jersey/FeatureService.java | 20 ++ .../client/jersey/FeatureServiceImpl.java | 70 +++-- .../jersey/JerseyFeatureHubClientFactory.java | 25 +- .../client/jersey/JerseySSEClient.java | 122 +++++--- .../featurehub/client/jersey/RestClient.java | 293 ++++++++++++++++++ .../client/jersey/TestSDKClient.java | 24 +- .../client/jersey/JerseyClientSpec.groovy | 121 -------- .../client/jersey/JerseySSEClientSpec.groovy | 193 ++++++++++++ .../client/jersey/RestClientSpec.groovy | 146 +++++++++ .../client/jersey/SSETestHarness.groovy | 48 +++ .../CHANGELOG.adoc | 0 .../README.adoc | 0 .../pom.xml | 12 +- .../okhttp/OkHttpFeatureHubFactory.java | 39 +++ .../java/io/featurehub/okhttp/RestClient.java | 30 +- .../java/io/featurehub/okhttp}/SSEClient.java | 3 +- .../java/io/featurehub/okhttp/TestClient.java | 67 ++++ ....featurehub.client.FeatureHubClientFactory | 1 + .../featurehub/okhttp/RestClientSpec.groovy | 8 +- .../featurehub/okhttp}/SSEClientSpec.groovy | 2 +- .../featurehub/okhttp/TestClientSpec.groovy | 53 ++++ .../android/FeatureHubClientRunner.java | 0 .../src/test/resources/log4j2.xml | 0 client-java-sse/CHANGELOG.adoc | 4 - client-java-sse/pom.xml | 97 ------ .../featurehub/edge/sse/SSEClientFactory.java | 26 -- ....featurehub.client.FeatureHubClientFactory | 1 - .../featurehub/edge/sse/SSEClientRunner.java | 60 ---- client-java-sse/src/test/resources/log4j2.xml | 17 - examples/migration-check/pom.xml | 8 +- .../io/featurehub/migrationcheck/Main.java | 10 +- examples/todo-java/pom.xml | 8 +- .../java/todo/backend/FeatureHubSource.java | 12 +- pom.xml | 3 +- support/composite-jersey2/pom.xml | 9 +- support/composite-jersey3/pom.xml | 9 +- 63 files changed, 2030 insertions(+), 659 deletions(-) delete mode 100644 client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java delete mode 100644 client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory create mode 100644 client-java-core/src/main/java/io/featurehub/client/TestApi.java create mode 100644 client-java-core/src/main/java/io/featurehub/client/TestApiResult.java create mode 100644 client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java create mode 100644 client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java rename client-java-jersey/src/test/groovy/io/featurehub/client/jersey/{JerseyClientSpec.groovy => JerseySSEClientSpec.groovy} (63%) create mode 100644 client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy create mode 100644 client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java create mode 100644 client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java delete mode 100644 client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy create mode 100644 client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy create mode 100644 client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy create mode 100644 client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy rename {client-java-android => client-java-okhttp}/CHANGELOG.adoc (100%) rename {client-java-android => client-java-okhttp}/README.adoc (100%) rename {client-java-android => client-java-okhttp}/pom.xml (90%) create mode 100644 client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java rename client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java => client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java (90%) rename {client-java-sse/src/main/java/io/featurehub/edge/sse => client-java-okhttp/src/main/java/io/featurehub/okhttp}/SSEClient.java (98%) create mode 100644 client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java create mode 100644 client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy => client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy (96%) rename {client-java-sse/src/test/groovy/io/featurehub/edge/sse => client-java-okhttp/src/test/groovy/io/featurehub/okhttp}/SSEClientSpec.groovy (99%) create mode 100644 client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy rename {client-java-android => client-java-okhttp}/src/test/java/io/featurehub/android/FeatureHubClientRunner.java (100%) rename {client-java-android => client-java-okhttp}/src/test/resources/log4j2.xml (100%) delete mode 100644 client-java-sse/CHANGELOG.adoc delete mode 100644 client-java-sse/pom.xml delete mode 100644 client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java delete mode 100644 client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory delete mode 100644 client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java delete mode 100644 client-java-sse/src/test/resources/log4j2.xml diff --git a/README.adoc b/README.adoc index 3a33ebc..1edc66c 100644 --- a/README.adoc +++ b/README.adoc @@ -1,5 +1,12 @@ = Java Libraries +== todo + +- ensure that all of the factories are correct and each client has a full test client + rest client + sse client and they are actually working against proper server +- implement polling via a timer in the code repo +- move the remaining examples from featurehub-examples here (quarkus & spring) + + This is the set of libraries currently supporting the Java programming language and its JDK based cousins. It currently consists of two libraries: diff --git a/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java b/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java deleted file mode 100644 index 1f6c646..0000000 --- a/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.featurehub.android; - -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureHubClientFactory; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.InternalFeatureRepository; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.function.Supplier; - -public class AndroidFeatureHubClientFactory implements FeatureHubClientFactory { - @Override - public Supplier createEdgeService(@NotNull final FeatureHubConfig config, - @Nullable final InternalFeatureRepository repository) { - return () -> new FeatureHubClient(repository, config); - } - - @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config) { - return createEdgeService(config, null); - } -} diff --git a/client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory deleted file mode 100644 index 2bcf7cc..0000000 --- a/client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory +++ /dev/null @@ -1 +0,0 @@ -io.featurehub.android.AndroidFeatureHubClientFactory diff --git a/client-java-api/pom.xml b/client-java-api/pom.xml index eaa28d3..ef27171 100644 --- a/client-java-api/pom.xml +++ b/client-java-api/pom.xml @@ -93,6 +93,7 @@ jersey3-api false + useBeanValidation=false openApiNullable=false useNullForUnknownEnumValue=true diff --git a/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java b/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java index 04c57d3..2d86b23 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java +++ b/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java @@ -50,10 +50,10 @@ public Applied applyFeature(List strategies, String key, percentage = percentageCalculator.determineClientPercentage(percentageKey, featureValueId); - log.info("percentage for {} on {} calculated at {}", defaultPercentageKey, key, percentage); + log.trace("percentage for {} on {} calculated at {}", defaultPercentageKey, key, percentage); } - log.info("comparing actual {} vs required: {}", percentage, rsi.getPercentage()); + log.trace("comparing actual {} vs required: {}", percentage, rsi.getPercentage()); int useBasePercentage = rsi.getAttributes() == null || rsi.getAttributes().isEmpty() ? basePercentageVal : 0; // if the percentage is lower than the user's key + // id of feature value then apply it diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 5646306..000d255 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -230,7 +230,7 @@ public void close() { } private void broadcastReadyness() { - log.info("broadcasting readiness {} listener count {}", readiness, readinessListeners.size()); + log.trace("broadcasting readiness {} listener count {}", readiness, readinessListeners.size()); if (!executor.isShutdown()) { readinessListeners.forEach((rl) -> executor.execute(() -> rl.callback.accept(readiness))); } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 757d903..2f6a63e 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -33,6 +33,10 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @Nullable private ServerEvalFeatureContext serverEvalFeatureContext; + @Nullable ServiceLoader loader; + + @Nullable TestApi testApi; + public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { this(edgeUrl, Collections.singletonList(apiKey)); } @@ -102,8 +106,6 @@ public ClientContext newContext() { this.edgeService = loadEdgeService(repository).get(); } - log.info("xx edge client {}", edgeService); - if (isServerEvaluation()) { if (serverEvalFeatureContext == null) { serverEvalFeatureContext = new ServerEvalFeatureContext(repository, edgeService); @@ -124,7 +126,7 @@ protected Supplier loadEdgeService(@NotNull InternalFeatureReposit ServiceLoader loader = ServiceLoader.load(FeatureHubClientFactory.class); for (FeatureHubClientFactory f : loader) { - Supplier edgeService = f.createEdgeService(this, repository); + Supplier edgeService = f.createSSEEdge(this, repository); if (edgeService != null) { edgeServiceSupplier = edgeService; break; @@ -189,11 +191,62 @@ public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapp @Override public void close() { - log.info("edge client {}", edgeService); if (edgeService != null) { - log.info("closing edge connection"); + log.trace("closing edge connection"); edgeService.close(); edgeService = null; } + if (testApi != null) { + log.trace("closing test api"); + testApi.close(); + testApi = null; + } + } + + @Override + public FeatureHubConfig streaming() { + return this; } + + private class RestConfigImpl implements RestConfig { + protected boolean useUseBased = false; + protected boolean enabled = false; + protected int interval = 180; + + private final EdgeFeatureHubConfig config; + + private RestConfigImpl(EdgeFeatureHubConfig config) { + this.config = config; + } + + @Override + public FeatureHubConfig interval(int timeoutSeconds) { + this.interval = timeoutSeconds; + enabled = true; + return config; + } + + @Override + public FeatureHubConfig interval() { + this.interval = 0; + enabled = true; + return config; + } + + @Override + public FeatureHubConfig minUpdateInterval(int timeoutSeconds) { + useUseBased = true; + enabled = true; + interval = timeoutSeconds; + return config; + } + } + + private final RestConfigImpl restConfig = new RestConfigImpl(this); + + @Override + public RestConfig rest() { + return restConfig; + } + } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java index 16af154..b8f2ea9 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java @@ -5,15 +5,21 @@ import java.util.function.Supplier; +/** + * allows the creation of a new edge service without knowing about the underlying implementation. + * depending on which library is included, this will automatically be created. + */ public interface FeatureHubClientFactory { - /** - * allows the creation of a new edge service without knowing about the underlying implementation. - * depending on which library is included, this will automatically be created. - * - * @param config - the full edge config - * @return - */ - Supplier createEdgeService(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); - - Supplier createEdgeService(@NotNull FeatureHubConfig config); + + Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); + + Supplier createSSEEdge(@NotNull FeatureHubConfig config); + + Supplier createRestEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository, + int timeoutInSeconds); + + Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds); + + Supplier createTestApi(@NotNull FeatureHubConfig config); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 52b617d..25553ec 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,16 +1,14 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.analytics.AnalyticsEvent; import io.featurehub.client.analytics.AnalyticsProvider; -import io.featurehub.sse.model.FeatureStateUpdate; import org.jetbrains.annotations.NotNull; import java.util.Collection; +import java.util.List; import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.List; public interface FeatureHubConfig { /** @@ -91,4 +89,29 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * server cleanly */ void close(); + + FeatureHubConfig streaming(); + + interface RestConfig { + /** + * creates a java Timer and will poll every X seconds. Also polls if context changes for + * server evaluated context. + * @param timeoutSeconds - the timeout between completed requests in seconds + */ + FeatureHubConfig interval(int timeoutSeconds); + + /** + * no interval, just polls once (if it hasn't polled already) and returns. If using + * server evaluated context, it will poll once if context changes and that is all. + */ + FeatureHubConfig interval(); + + /** + * @param timeoutSeconds - no active polling, will poll if feature requested after this period of time or if + * server evaluated and context changes. + */ + FeatureHubConfig minUpdateInterval(int timeoutSeconds); + } + + RestConfig rest(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index f9c22f4..7d259b8 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -169,12 +169,12 @@ private Object internalGetValue(@Nullable FeatureValueType passedType, boolean t repository.applyFeature( feature.fs.getStrategies(), feature.key, feature.fs.getId().toString(), context); - log.info("feature is {}", applied); + log.trace("feature is {}", applied); if (applied.isMatched()) { return triggerUsage ? used(feature.key, feature.fs.getId(), applied.getValue(), type) : applied.getValue(); } } else { - log.info("not matched using {}", feature.fs.getValue()); + log.trace("not matched using {}", feature.fs.getValue()); } return triggerUsage ? used(feature.key, feature.fs.getId(), feature.fs.getValue(), type) : @@ -185,7 +185,7 @@ Object used(@NotNull String key, @NotNull UUID id, @Nullable Object value, @NotN if (context != null) { context.used(key, id, value, type); } else { - log.info("calling used with {}", value); + log.trace("calling used with {}", value); repository.used(key, id, type, value, null, null); } diff --git a/client-java-core/src/main/java/io/featurehub/client/TestApi.java b/client-java-core/src/main/java/io/featurehub/client/TestApi.java new file mode 100644 index 0000000..18f07d9 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/TestApi.java @@ -0,0 +1,13 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +public interface TestApi { + @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate); + @NotNull TestApiResult setFeatureState(@NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate); + + void close(); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java b/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java new file mode 100644 index 0000000..194ac3c --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java @@ -0,0 +1,64 @@ +package io.featurehub.client; + +public class TestApiResult { + private final int code; + + public TestApiResult(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public boolean isSuccess() { + return code >= 200 && code < 300; + } + + public boolean isFailed() { + return code >= 400; + } + + /** + * update was accepted but not actioned because feature is already in that state + */ + public boolean isNotChanged() { + return code == 200 || code == 202; + } + + /** + * update was accepted and actioned + */ + public boolean isChanged() { + return code == 201; + } + + /** + * you have made a request that doesn't make sense. e.g. it has no data + */ + public boolean isNonsense() { + return code == 400; + } + + /** + * update was not accepted, attempted change is outside the permissions of this user + */ + public boolean isNotPermitted() { + return code == 403; + } + + /** + * something about the presented data isn't right and we couldn't find it, could be the service key, the + * environment or the feature + */ + public boolean isNonExistant() { + return code == 404; + } + + /** + * you have made a request that isn't possible. e.g. changing a value without unlocking it. + */ + public boolean isNotPossible() { + return code == 412; + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java index 92cc051..09ad0fa 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java +++ b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java @@ -8,7 +8,7 @@ import java.util.concurrent.ExecutorService; public interface EdgeRetryService { - void edgeResult(EdgeConnectionState state, EdgeReconnector reconnector); + void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnector reconnector); /** * Edge connected received a "config" set of data, process it @@ -17,7 +17,7 @@ public interface EdgeRetryService { void edgeConfigInfo(String config); @Nullable SSEResultState fromValue(String value); - void convertSSEState(@NotNull SSEResultState state, @NotNull String data, @NotNull InternalFeatureRepository + void convertSSEState(@NotNull SSEResultState state, String data, @NotNull InternalFeatureRepository repository); void close(); diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index 7f5d599..99fc79a 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -27,7 +27,7 @@ public class EdgeRetryer implements EdgeRetryService { private final int maximumBackoffTimeMs; // this will change over the lifetime of reconnect attempts private int currentBackoffMultiplier; - private ObjectMapper mapper = new ObjectMapper(); + private final ObjectMapper mapper = new ObjectMapper(); // if this is set, then we stop recognizing any further requests from the connection, // we can get subsequent disconnect statements. We know we cannot reconnect so we just stop. @@ -55,7 +55,7 @@ protected ExecutorService makeExecutorService() { return Executors.newFixedThreadPool(1); } - public void edgeResult(EdgeConnectionState state, EdgeReconnector reConnector) { + public void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnector reConnector) { log.trace("[featurehub-sdk] retryer triggered {}", state); if (!notFoundState && !stopped && !executorService.isShutdown()) { if (state == EdgeConnectionState.SUCCESS) { @@ -63,6 +63,7 @@ public void edgeResult(EdgeConnectionState state, EdgeReconnector reConnector) { } else if (state == EdgeConnectionState.API_KEY_NOT_FOUND) { log.warn("[featurehub-sdk] terminal failure attempting to connect to Edge, API KEY does not exist."); notFoundState = true; + stopped = true; } else if (state == EdgeConnectionState.SERVER_WAS_DISCONNECTED) { executorService.submit(() -> { backoff(serverDisconnectRetryMs, true); @@ -111,19 +112,25 @@ public void edgeConfigInfo(String config) { } @Override - public void convertSSEState(@NotNull SSEResultState state, @NotNull String data, + public void convertSSEState(@NotNull SSEResultState state, String data, @NotNull InternalFeatureRepository repository) { try { - if (state == SSEResultState.FEATURES) { - List features = - repository.getJsonObjectMapper().readValue(data, FEATURE_LIST_TYPEDEF); - repository.updateFeatures(features); - } else if (state == SSEResultState.FEATURE) { - repository.updateFeature(repository.getJsonObjectMapper().readValue(data, - io.featurehub.sse.model.FeatureState.class)); - } else if (state == SSEResultState.DELETE_FEATURE) { - repository.deleteFeature(repository.getJsonObjectMapper().readValue(data, - io.featurehub.sse.model.FeatureState.class)); + if (data != null) { + if (state == SSEResultState.FEATURES) { + List features = + repository.getJsonObjectMapper().readValue(data, FEATURE_LIST_TYPEDEF); + repository.updateFeatures(features); + } else if (state == SSEResultState.FEATURE) { + repository.updateFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } else if (state == SSEResultState.DELETE_FEATURE) { + repository.deleteFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } + } + + if (state == SSEResultState.FAILURE) { + repository.notify(state); } } catch (JsonProcessingException jpe) { throw new RuntimeException("JSON failed", jpe); diff --git a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy index 0b6dc6c..dca66d5 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy @@ -50,13 +50,28 @@ class FeatureHubTestClientFactory implements FeatureHubClientFactory { static FakeEdgeService fake @Override - Supplier createEdgeService(FeatureHubConfig config, InternalFeatureRepository repository) { + Supplier createSSEEdge(FeatureHubConfig config, InternalFeatureRepository repository) { fake = new FakeEdgeService(repository, config) return { -> fake } } @Override - Supplier createEdgeService(@NotNull FeatureHubConfig config) { - return createEdgeService(config, null) + Supplier createSSEEdge(@NotNull FeatureHubConfig config) { + return createSSEEdge(config, null) + } + + @Override + Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { + return null + } + + @Override + Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { + return null + } + + @Override + Supplier createTestApi(@NotNull FeatureHubConfig config) { + return null } } diff --git a/client-java-jersey/pom.xml b/client-java-jersey/pom.xml index 2bd6f12..ab18e0c 100644 --- a/client-java-jersey/pom.xml +++ b/client-java-jersey/pom.xml @@ -44,7 +44,7 @@ - 2.28 + 2.36 @@ -52,7 +52,7 @@ cd.connect.openapi.gensupport openapi-generator-support - 1.4 + 1.5 @@ -78,13 +78,20 @@ org.glassfish.jersey.test-framework jersey-test-framework-core - 2.36 + ${jersey.version} test - org.glassfish.jersey.media - jersey-media-sse - 2.36 + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + test + + + org.glassfish.jersey.test-framework.providers + jersey-test-framework-provider-grizzly2 + ${jersey.version} + test diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java new file mode 100644 index 0000000..ab01c19 --- /dev/null +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java @@ -0,0 +1,20 @@ +package io.featurehub.client.jersey; + +import cd.connect.openapi.support.ApiResponse; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public interface FeatureService { + @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey, + @Nullable String contextSha, + @Nullable Map extraHeaders); + int setFeatureState(@NotNull String apiKey, + @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate, + @Nullable Map extraHeaders); +} diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java index 6209ccf..f677c72 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java @@ -1,14 +1,13 @@ package io.featurehub.client.jersey; import cd.connect.openapi.support.ApiClient; +import cd.connect.openapi.support.ApiResponse; import cd.connect.openapi.support.Pair; -import io.featurehub.sse.api.FeatureService; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureStateUpdate; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import javax.ws.rs.BadRequestException; import javax.ws.rs.core.GenericType; import java.util.ArrayList; import java.util.HashMap; @@ -22,34 +21,58 @@ public FeatureServiceImpl(ApiClient apiClient) { this.apiClient = apiClient; } - @Override - public List getFeatureStates(@NotNull List apiKey, @Nullable String contextSha) { - return null; - } + public @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey, + @Nullable String contextSha, + @Nullable Map extraHeaders) { + Object localVarPostBody = new Object(); - @Override - public void setFeatureState(String apiKey, String featureKey, - FeatureStateUpdate featureStateUpdate) { - // verify the required parameter 'apiKey' is set - if (apiKey == null) { - throw new BadRequestException("Missing the required parameter 'apiKey' when calling setFeatureState"); - } + // create path and map variables /features/ + String localVarPath = "/features/"; - // verify the required parameter 'featureKey' is set - if (featureKey == null) { - throw new BadRequestException("Missing the required parameter 'featureKey' when calling setFeatureState"); + // query params + List localVarQueryParams = new ArrayList<>(); + Map localVarHeaderParams = new HashMap<>(); + Map localVarFormParams = new HashMap<>(); + + if (extraHeaders != null) { + localVarHeaderParams.putAll(extraHeaders); } + localVarQueryParams.addAll(apiClient.parameterToPairs("multi", "apiKey", apiKey)); + localVarQueryParams.addAll(apiClient.parameterToPairs("", "contextSha", contextSha)); + + final String[] localVarAccepts = { + "application/json" + }; + final String localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); + + final String[] localVarContentTypes = { + + }; + final String localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] { }; + + GenericType> localVarReturnType = new GenericType>() {}; + return apiClient.invokeAPI(localVarPath, "GET", localVarQueryParams, localVarPostBody, localVarHeaderParams, + localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + public int setFeatureState(@NotNull String apiKey, + @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate, + @Nullable Map extraHeaders) { // create path and map variables /{apiKey}/{featureKey} - String localVarPath = "/features/{apiKey}/{featureKey}" - .replaceAll("\\{" + "apiKey" + "\\}", apiKey.toString()) - .replaceAll("\\{" + "featureKey" + "\\}", featureKey.toString()); + String localVarPath = String.format("/features/%s/%s", apiKey, featureKey); // query params - List localVarQueryParams = new ArrayList(); Map localVarHeaderParams = new HashMap(); Map localVarFormParams = new HashMap(); + if (extraHeaders != null) { + localVarHeaderParams.putAll(extraHeaders); + } final String[] localVarAccepts = { "application/json" @@ -65,7 +88,7 @@ public void setFeatureState(String apiKey, String featureKey, GenericType localVarReturnType = new GenericType() {}; - apiClient.invokeAPI(localVarPath, "PUT", localVarQueryParams, featureStateUpdate, localVarHeaderParams, - localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getData(); + return apiClient.invokeAPI(localVarPath, "PUT", null, featureStateUpdate, localVarHeaderParams, + localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getStatusCode(); } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 5ed96d8..87cdf01 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -4,6 +4,7 @@ import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.TestApi; import io.featurehub.client.edge.EdgeRetryer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,13 +13,29 @@ public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config, - @Nullable InternalFeatureRepository repository) { + public Supplier createSSEEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config) { - return createEdgeService(config, null); + public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { + return createSSEEdge(config, null); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { + return () -> new RestClient(repository, null, config, timeoutInSeconds); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { + return createRestEdge(config, null, timeoutInSeconds); + } + + @Override + public Supplier createTestApi(@NotNull FeatureHubConfig config) { + return () -> new TestSDKClient(config); } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 808fad2..c8b1429 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -1,6 +1,5 @@ package io.featurehub.client.jersey; -import com.fasterxml.jackson.core.type.TypeReference; import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; @@ -9,7 +8,6 @@ import io.featurehub.client.edge.EdgeReconnector; import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.utils.SdkVersion; -import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; import javax.ws.rs.WebApplicationException; import javax.ws.rs.client.Client; @@ -31,6 +29,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.function.Consumer; public class JerseySSEClient implements EdgeService, EdgeReconnector { private static final Logger log = LoggerFactory.getLogger(JerseySSEClient.class); @@ -41,10 +40,11 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { private EventInput eventSource; private final WebTarget target; private final List> waitingClients = new ArrayList<>(); + private Consumer notify; - - public JerseySSEClient(InternalFeatureRepository repository, FeatureHubConfig config, EdgeRetryService retryer) { - this.repository = repository; + public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, + @NotNull EdgeRetryService retryer) { + this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; this.config = config; this.retryer = retryer; @@ -63,14 +63,12 @@ public JerseySSEClient(InternalFeatureRepository repository, FeatureHubConfig co target = makeEventSourceTarget(client, config.getRealtimeUrl()); } - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { + @NotNull protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { return client.target(sdkUrl); } @Override public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - if (config.isServerEvaluation() && ( (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || @@ -85,14 +83,10 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { } if (eventSource == null) { - waitingClients.add(change); - - poll(); - } else { - change.complete(repository.getReadiness()); + return poll(); } - return change; + return CompletableFuture.completedFuture(repository.getReadiness()); } @Override @@ -151,6 +145,10 @@ private void initEventSource() { boolean interrupted = false; while (!eventSource.isClosed() && !interrupted) { + if (notify != null) { // this is for testing + notify.accept(null); + } + @Nullable String data; InboundEvent event; @@ -168,57 +166,78 @@ private void initEventSource() { continue; } - try { - final SSEResultState state = retryer.fromValue(event.getName()); + connectionSaidBye = processResult(connectionSaidBye, data, event); + } - if (state == null) { // unknown state - continue; - } + if (retryer.isStopped() || eventSource.isClosed() || interrupted) { + final boolean closedOrInterrupted = eventSource.isClosed() || interrupted; - log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); + log.trace("[featurehub] closed"); + if (!eventSource.isClosed()) { + close(); + } - if (state == SSEResultState.CONFIG) { - retryer.edgeConfigInfo(data); - } else if (data != null) { - retryer.convertSSEState(state, data, repository); - } + checkForUnsatisfactoryConversation(); - // reset the timer - if (state == SSEResultState.FEATURES) { - retryer.edgeResult(EdgeConnectionState.SUCCESS, this); - } + notifyWaitingClients(); - if (state == SSEResultState.BYE) { - connectionSaidBye = true; - } + if (closedOrInterrupted) { + // send this once we are actually disconnected and not before + retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : + EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + } + } + } - if (state == SSEResultState.FAILURE) { - retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); - } + private void checkForUnsatisfactoryConversation() { + // we never received a satisfactory connection + if (repository.getReadiness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); + } + } - // tell any waiting clients we are now ready - if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); - } - } catch (Exception e) { - log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); + private boolean processResult(boolean connectionSaidBye, String data, InboundEvent event) { + try { + final SSEResultState state = retryer.fromValue(event.getName()); + + if (state == null) { // unknown state + return connectionSaidBye; } - } - if (eventSource.isClosed() || interrupted) { - close(); + log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); - log.trace("[featurehub-sdk] closed"); + if (state == SSEResultState.CONFIG) { + retryer.edgeConfigInfo(data); + } else { + retryer.convertSSEState(state, data, repository); + } + + // reset the timer + if (state == SSEResultState.FEATURES) { + retryer.edgeResult(EdgeConnectionState.SUCCESS, this); + } - // we never received a satisfactory connection - if (repository.getReadiness() == Readiness.NotReady) { - repository.notify(SSEResultState.FAILURE); + if (state == SSEResultState.BYE) { + connectionSaidBye = true; } - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + if (state == SSEResultState.FAILURE) { + retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); + } + + // tell any waiting clients we are now ready + if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { + notifyWaitingClients(); + } + } catch (Exception e) { + log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); } + + return connectionSaidBye; + } + + private void notifyWaitingClients() { + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } private void onMakeEventSourceException(Exception e) { @@ -255,4 +274,8 @@ public Future poll() { public void reconnect() { poll(); } + + public void setNotify(Consumer notify) { + this.notify = notify; + } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java new file mode 100644 index 0000000..16a807e --- /dev/null +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -0,0 +1,293 @@ +package io.featurehub.client.jersey; + +import cd.connect.openapi.support.ApiClient; +import cd.connect.openapi.support.ApiResponse; +import io.featurehub.client.EdgeService; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.SSEResultState; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class RestClient implements EdgeService { + private static final Logger log = LoggerFactory.getLogger(RestClient.class); + @NotNull + private final InternalFeatureRepository repository; + @NotNull private final FeatureService client; + @Nullable + private String xFeaturehubHeader; + // used for breaking the cache + @NotNull + private String xContextSha = "0"; + private boolean stopped = false; + @Nullable + private String etag = null; + private long pollingInterval; + + private long whenPollingCacheExpires; + private final boolean clientSideEvaluation; + @NotNull private final FeatureHubConfig config; + + /** + * a Rest client. + * + * @param repository - expected to be null, but able to be passed in because of special use cases + * @param client - expected to be null, but able to be passed in because of testing + * @param config - FH config + * @param timeoutInSeconds - use 0 for once off and for when using an actual timer + */ + public RestClient(@Nullable InternalFeatureRepository repository, + @Nullable FeatureService client, + @NotNull FeatureHubConfig config, + int timeoutInSeconds) { + if (repository == null) { + repository = (InternalFeatureRepository) config.getRepository(); + } + + this.repository = repository; + this.client = client == null ? makeClient(config) : client; + this.config = config; + this.pollingInterval = timeoutInSeconds; + + // ensure the poll has expired the first time we ask for it + whenPollingCacheExpires = System.currentTimeMillis() - 100; + + this.clientSideEvaluation = !config.isServerEvaluation(); + + if (clientSideEvaluation) { + checkForUpdates(null); + } + } + + @NotNull protected FeatureService makeClient(FeatureHubConfig config) { + Client client = ClientBuilder.newBuilder() + .register(JacksonFeature.class).build(); + + return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); + } + + public RestClient(@NotNull FeatureHubConfig config, + int timeoutInSeconds) { + this(null, null, config, timeoutInSeconds); + } + + public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this(repository, null, config, 180); + } + + public RestClient(@NotNull FeatureHubConfig config) { + this(null, null, config, 180); + } + + private boolean busy = false; + private boolean headerChanged = false; + private List> waitingClients = new ArrayList<>(); + + protected Long now() { + return System.currentTimeMillis(); + } + + public boolean checkForUpdates(@Nullable CompletableFuture change) { + final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + final boolean ask = !busy && !stopped && breakCache; + + headerChanged = false; + + if (ask) { + if (change != null) { + // we are going to call, so we take a note of who we need to tell + waitingClients.add(change); + } + + busy = true; + + Map headers = new HashMap<>(); + if (xFeaturehubHeader != null) { + headers.put("x-featurehub", xFeaturehubHeader); + } + + if (etag != null) { + headers.put("if-none-match", etag); + } + + try { + final ApiResponse> response = client.getFeatureStates(config.apiKeys(), + xContextSha, headers); + processResponse(response); + } catch (Exception e) { + processFailure(e); + } + } + + return ask; + } + + protected @Nullable String getEtag() { + return etag; + } + + protected void setEtag(@Nullable String etag) { + this.etag = etag; + } + + @Nullable public Long getPollingInterval() { + return pollingInterval; + } + + final Pattern cacheControlRegex = Pattern.compile("max-age=(\\d+)"); + + public void processCacheControlHeader(@NotNull String cacheControlHeader) { + final Matcher matcher = cacheControlRegex.matcher(cacheControlHeader); + if (matcher.find()) { + final String interval = matcher.group().split("=")[1]; + try { + long newInterval = Long.parseLong(interval); + if (newInterval > 0) { + this.pollingInterval = newInterval; + } + } catch (Exception e) { + // ignored + } + } + } + + protected void processFailure(@NotNull Exception e) { + log.error("Unable to call for features", e); + repository.notify(SSEResultState.FAILURE); + busy = false; + completeReadiness(); + } + + protected void processResponse(ApiResponse> response) throws IOException { + busy = false; + + log.trace("response code is {}", response.getStatusCode()); + + // check the cache-control for the max-age + final String cacheControlHeader = response.getResponse().getHeaderString("cache-control"); + if (cacheControlHeader != null) { + processCacheControlHeader(cacheControlHeader); + } + + // preserve the etag header if it exists + final String etagHeader = response.getResponse().getHeaderString("etag"); + if (etagHeader != null) { + this.etag = etagHeader; + } + + if (response.getStatusCode() >= 200 && response.getStatusCode() < 300) { + List states = new ArrayList<>(); + response.getData().forEach(e -> { + if (e.getFeatures() != null) { + e.getFeatures().forEach(f -> f.setEnvironmentId(e.getId())); + states.addAll(e.getFeatures()); + } + }); + + log.trace("updating feature repository: {}", states); + + repository.updateFeatures(states); + completeReadiness(); + + if (response.getStatusCode() == 236) { + this.stopped = true; // prevent any further requests + } + + // reset the polling interval to prevent unnecessary polling + if (pollingInterval > 0) { + whenPollingCacheExpires = now() + (pollingInterval * 1000); + } + } else if (response.getStatusCode() == 400 || response.getStatusCode() == 404) { + stopped = true; + log.error("Server indicated an error with our requests making future ones pointless."); + repository.notify(SSEResultState.FAILURE); + completeReadiness(); + } else if (response.getStatusCode() >= 500) { + completeReadiness(); // we haven't changed anything, but we have to unblock clients as we can't just hang + } + } + + public boolean isStopped() { return stopped; } + + private void completeReadiness() { + List> current = waitingClients; + waitingClients = new ArrayList<>(); + current.forEach(c -> { + try { + c.complete(repository.getReadiness()); + } catch (Exception e) { + log.error("Unable to complete future", e); + } + }); + } + + @Override + public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); + + headerChanged = (newHeader != null && !newHeader.equals(xFeaturehubHeader)); + + xFeaturehubHeader = newHeader; + xContextSha = contextSha; + + // if there is already another change running, you are out of luck + if (busy) { + waitingClients.add(change); + } else if (!checkForUpdates(change)) { + change.complete(repository.getReadiness()); + } + + return change; + } + + @Override + public boolean isClientEvaluation() { + return clientSideEvaluation; + } + + @Override + public void close() { + log.info("featurehub client closed."); + } + + @Override + public @NotNull FeatureHubConfig getConfig() { + return config; + } + + @Override + public Future poll() { + final CompletableFuture change = new CompletableFuture<>(); + + if (busy) { + waitingClients.add(change); + } else if (!checkForUpdates(change)) { + // not even planning to ask + change.complete(repository.getReadiness()); + } + + return change; + } + + public long getWhenPollingCacheExpires() { + return whenPollingCacheExpires; + } +} diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java index 0cea937..7a97527 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java @@ -2,6 +2,8 @@ import cd.connect.openapi.support.ApiClient; import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.TestApi; +import io.featurehub.client.TestApiResult; import io.featurehub.sse.model.FeatureStateUpdate; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; @@ -11,10 +13,12 @@ /** * This makes a simple wrapper around the TestSDK Client */ -public class TestSDKClient { +public class TestSDKClient implements TestApi { private final FeatureServiceImpl featureService; + private final FeatureHubConfig config; public TestSDKClient(FeatureHubConfig config) { + this.config = config; Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class).build(); @@ -23,8 +27,18 @@ public TestSDKClient(FeatureHubConfig config) { featureService = new FeatureServiceImpl(apiClient); } - public void setFeatureState(String apiKey, @NotNull String featureKey, + public @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { - featureService.setFeatureState(apiKey, featureKey, featureStateUpdate); + return new TestApiResult(featureService.setFeatureState(apiKey, featureKey, featureStateUpdate, null)); + } + + @Override + public @NotNull TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return new TestApiResult(featureService.setFeatureState(config.apiKey(), featureKey, featureStateUpdate, null)); + } + + @Override + public void close() { + close(); } } diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy similarity index 63% rename from client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy rename to client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy index 83900d1..92b3405 100644 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -1,54 +1,108 @@ package io.featurehub.client.jersey -import cd.connect.openapi.support.ApiClient -import io.featurehub.client.ClientFeatureRepository -import io.featurehub.client.EdgeFeatureHubConfig +import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig import io.featurehub.client.Readiness import io.featurehub.client.edge.EdgeRetryer -import io.featurehub.sse.api.FeatureService -import io.featurehub.sse.model.FeatureStateUpdate +import io.featurehub.sse.model.FeatureState +import io.featurehub.sse.model.FeatureValueType +import org.glassfish.jersey.media.sse.EventInput import org.glassfish.jersey.media.sse.EventOutput -import org.glassfish.jersey.media.sse.EventSource import org.glassfish.jersey.media.sse.OutboundEvent import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Specification -import javax.ws.rs.client.Client -import javax.ws.rs.client.WebTarget -import javax.ws.rs.sse.OutboundSseEvent - -class JerseyClientSpec extends Specification { - private static final Logger log = LoggerFactory.getLogger(JerseyClientSpec.class) +class JerseySSEClientSpec extends Specification { + private static final Logger log = LoggerFactory.getLogger(JerseySSEClientSpec.class) Closure sseClosure FeatureHubConfig config SSETestHarness harness + EventOutput output + JerseySSEClient edge + ObjectMapper mapper def setup() { + mapper = new ObjectMapper() harness = new SSETestHarness() harness.setUp() + config = harness.getConfig(["123/345*675"], { String envId, String apiKey, List featureHubAttrs, String extraConfig, String browserHubAttrs, String etag -> + output = new EventOutput() + return output + }) + + edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { + @Override + void reconnect() { + close(); + } + } + + config.setEdgeService { -> edge } } - def teardown() { + + def cleanup() { harness.tearDown() } def "A basic client connect works as expected"() { given: - EventOutput output - config = harness.getConfig(["123/345*675"]) { envId, apiKey, featureHubAttrs, extraConfig, browserHubAttrs, etag -> - output = new EventOutput() - return output + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("ack").id("1").data("hello").build()) + + edge.setNotify { EventInput i1 -> + output.write(new OutboundEvent.Builder().name("failure").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } + } + } + when: + def future = config.newContext().build() + then: + future.get().repository.readiness == Readiness.Failed + } + + def "a basic drop of all events goes to readiness"() { + given: + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("features").id("1").data(mapper.writeValueAsString([ + new FeatureState().id(UUID.randomUUID()).key("key").l(true).value(true).type(FeatureValueType.BOOLEAN).version(1)])).build()) + + edge.setNotify { EventInput i1 -> + output.write(new OutboundEvent.Builder().name("bye").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } + } + } + when: + def future = config.newContext().build() + then: + future.get().repository.readiness == Readiness.Ready + config.repository.allFeatures.size() == 1 + } + + def "a config with a stop will prevent further calls"() { + given: + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("config").id("1").data("{\"edge.stale\": true}").build()) +// edge.setNotify { EventInput i1 -> +// output.write(new OutboundEvent.Builder().name("bye").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } +// } } when: def future = config.newContext().build() - and: - output.write(new OutboundEvent.Builder().name("ack").id("1").build()) - output.write(new OutboundEvent.Builder().name("failure").id("2").build()) - output.close() then: future.get().repository.readiness == Readiness.Failed + edge.stopped } // def "basic initialization test works as expect"() { diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy new file mode 100644 index 0000000..334fb20 --- /dev/null +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -0,0 +1,146 @@ +package io.featurehub.client.jersey + +import cd.connect.openapi.support.ApiResponse +import io.featurehub.client.FeatureHubConfig +import io.featurehub.client.InternalFeatureRepository +import io.featurehub.client.Readiness +import io.featurehub.sse.model.FeatureEnvironmentCollection +import io.featurehub.sse.model.SSEResultState +import spock.lang.Specification + +import javax.ws.rs.core.Response + +class RestClientSpec extends Specification { + FeatureService featureService + RestClient client + InternalFeatureRepository repo + FeatureHubConfig config + List apiKeys + + def setup() { + apiKeys = ["123"] + featureService = Mock() + repo = Mock() + config = Mock() + config.isServerEvaluation() >> true + client = new RestClient(repo, featureService, config, 0) + } + + ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { + def response = Response.status(statusCode) + + if (data != null) + response.entity(data) + if (!headers?.isEmpty()) { + headers.forEach { key, value -> response.header(key, value)} + } + + return new ApiResponse>(statusCode, null, data, response.build()) + } + + def "a basic poll with a 200 result"() { + given: + def response = build() + when: + client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys +// 1 * config.isServerEvaluation() >> true + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + 0 * _ + } + + def "a basic poll with a 236 result will cause the client to stop"() { + given: + def response = build(236) + when: + def result = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + 0 * _ + client.stopped + result == Readiness.Ready + } + + def "a poll with a 5xx result will cause the client to complete and not change readiness"() { + given: + def response = build(503) + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.NotReady + 0 * _ + !client.stopped + result == Readiness.NotReady + } + + def "a poll with a 400 result will cause the client to stop polling and indicate failure"() { + given: + def response = build(400) + def apiKeys = ["123"] + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * repo.notify(SSEResultState.FAILURE) + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Failed + 0 * _ + client.stopped + result == Readiness.Failed + } + + def "change the header to itself and it won't run again"() { + given: + def response = build() + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + when: + def result2 = client.contextChange('new-header', '765').get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * repo.readiness >> Readiness.Ready + 1 * featureService.getFeatureStates(apiKeys, '765', ['x-featurehub': 'new-header']) >> response + } + + def "cache header will change the polling interval"() { + given: + def response = build(200, [], ['cache-control': 'blah, max-age=300']) + when: + def result = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + client.pollingInterval == 300 + 0 * _ + + } + + def "change the polling interval to 180 seconds and a second poll won't poll"() { + given: + def response = build() + client = new RestClient(repo, featureService, config, 180) + when: + def result = client.poll().get() + def result2 = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 2 * repo.readiness >> Readiness.Ready + 0 * _ + } +} diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy index ddb7859..6fcf35d 100644 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy @@ -6,7 +6,9 @@ import org.glassfish.jersey.media.sse.EventOutput import org.glassfish.jersey.media.sse.SseFeature import org.glassfish.jersey.server.ResourceConfig import org.glassfish.jersey.test.JerseyTest +import org.glassfish.jersey.test.TestProperties +import javax.inject.Singleton import javax.ws.rs.GET import javax.ws.rs.HeaderParam import javax.ws.rs.Path @@ -15,20 +17,23 @@ import javax.ws.rs.Produces import javax.ws.rs.QueryParam import javax.ws.rs.core.Application -@Path("/features") +@Singleton +@Path("features/{environmentId}/{apiKey}") class SSETestHarness extends JerseyTest { - Closure backhaul + static Closure backhaul @Override protected Application configure() { + enable(TestProperties.LOG_TRAFFIC) + enable(TestProperties.DUMP_ENTITY) +// forceSet(TestProperties.CONTAINER_PORT, "0") return new ResourceConfig(SSETestHarness) } @GET - @Path("{environmentId}/{apiKey}") @Produces(SseFeature.SERVER_SENT_EVENTS) public EventOutput features( - @PathParam("environmentId") UUID envId, + @PathParam("environmentId") String envId, @PathParam("apiKey") String apiKey, @HeaderParam("x-featurehub") List featureHubAttrs, // non browsers can set headers @HeaderParam("x-fh-extraconfig") String extraConfig, diff --git a/client-java-jersey/src/test/resources/log4j2.xml b/client-java-jersey/src/test/resources/log4j2.xml index fe1b738..5b7d8e8 100644 --- a/client-java-jersey/src/test/resources/log4j2.xml +++ b/client-java-jersey/src/test/resources/log4j2.xml @@ -1,13 +1,12 @@ - + - - + - + diff --git a/client-java-jersey3/pom.xml b/client-java-jersey3/pom.xml index 3388da7..a1691d4 100644 --- a/client-java-jersey3/pom.xml +++ b/client-java-jersey3/pom.xml @@ -43,12 +43,16 @@ HEAD + + 3.1.2 + + cd.connect.openapi.gensupport openapi-jersey3-support - 2.1 + 2.6 @@ -69,6 +73,20 @@ [1.1, 2) test + + + org.glassfish.jersey.test-framework + jersey-test-framework-core + ${jersey.version} + test + + + org.glassfish.jersey.test-framework.providers + jersey-test-framework-provider-grizzly2 + ${jersey.version} + test + + diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java new file mode 100644 index 0000000..ab01c19 --- /dev/null +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java @@ -0,0 +1,20 @@ +package io.featurehub.client.jersey; + +import cd.connect.openapi.support.ApiResponse; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public interface FeatureService { + @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey, + @Nullable String contextSha, + @Nullable Map extraHeaders); + int setFeatureState(@NotNull String apiKey, + @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate, + @Nullable Map extraHeaders); +} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java index 3f7a73a..a032d3d 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java @@ -1,13 +1,13 @@ package io.featurehub.client.jersey; import cd.connect.openapi.support.ApiClient; +import cd.connect.openapi.support.ApiResponse; import cd.connect.openapi.support.Pair; -import io.featurehub.sse.api.FeatureService; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.core.GenericType; import java.util.ArrayList; import java.util.HashMap; @@ -21,34 +21,58 @@ public FeatureServiceImpl(ApiClient apiClient) { this.apiClient = apiClient; } - @Override - public List getFeatureStates(@NotNull List sdkUrl) { - return null; - } + public @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey, + @Nullable String contextSha, + @Nullable Map extraHeaders) { + Object localVarPostBody = new Object(); - @Override - public void setFeatureState(String apiKey, @org.jetbrains.annotations.NotNull String featureKey, - @org.jetbrains.annotations.NotNull FeatureStateUpdate featureStateUpdate) { - // verify the required parameter 'apiKey' is set - if (apiKey == null) { - throw new BadRequestException("Missing the required parameter 'apiKey' when calling setFeatureState"); - } + // create path and map variables /features/ + String localVarPath = "/features/"; - // verify the required parameter 'featureKey' is set - if (featureKey == null) { - throw new BadRequestException("Missing the required parameter 'featureKey' when calling setFeatureState"); + // query params + List localVarQueryParams = new ArrayList<>(); + Map localVarHeaderParams = new HashMap<>(); + Map localVarFormParams = new HashMap<>(); + + if (extraHeaders != null) { + localVarHeaderParams.putAll(extraHeaders); } + localVarQueryParams.addAll(apiClient.parameterToPairs("multi", "apiKey", apiKey)); + localVarQueryParams.addAll(apiClient.parameterToPairs("", "contextSha", contextSha)); + + final String[] localVarAccepts = { + "application/json" + }; + final String localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); + + final String[] localVarContentTypes = { + + }; + final String localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] { }; + + GenericType> localVarReturnType = new GenericType>() {}; + return apiClient.invokeAPI(localVarPath, "GET", localVarQueryParams, localVarPostBody, localVarHeaderParams, + localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + public int setFeatureState(@NotNull String apiKey, + @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate, + @Nullable Map extraHeaders) { // create path and map variables /{apiKey}/{featureKey} - String localVarPath = "/features/{apiKey}/{featureKey}" - .replaceAll("\\{" + "apiKey" + "\\}", apiKey.toString()) - .replaceAll("\\{" + "featureKey" + "\\}", featureKey.toString()); + String localVarPath = String.format("/features/%s/%s", apiKey, featureKey); // query params - List localVarQueryParams = new ArrayList(); Map localVarHeaderParams = new HashMap(); Map localVarFormParams = new HashMap(); + if (extraHeaders != null) { + localVarHeaderParams.putAll(extraHeaders); + } final String[] localVarAccepts = { "application/json" @@ -64,7 +88,7 @@ public void setFeatureState(String apiKey, @org.jetbrains.annotations.NotNull St GenericType localVarReturnType = new GenericType() {}; - apiClient.invokeAPI(localVarPath, "PUT", localVarQueryParams, featureStateUpdate, localVarHeaderParams, - localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getData(); + return apiClient.invokeAPI(localVarPath, "PUT", null, featureStateUpdate, localVarHeaderParams, + localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getStatusCode(); } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 3dc890e..87cdf01 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -4,6 +4,7 @@ import io.featurehub.client.FeatureHubClientFactory; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.TestApi; import io.featurehub.client.edge.EdgeRetryer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,13 +13,29 @@ public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config, - @Nullable InternalFeatureRepository repository) { + public Supplier createSSEEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config) { - return createEdgeService(config, null); + public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { + return createSSEEdge(config, null); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { + return () -> new RestClient(repository, null, config, timeoutInSeconds); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { + return createRestEdge(config, null, timeoutInSeconds); + } + + @Override + public Supplier createTestApi(@NotNull FeatureHubConfig config) { + return () -> new TestSDKClient(config); } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 29e327d..1697985 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.function.Consumer; public class JerseySSEClient implements EdgeService, EdgeReconnector { private static final Logger log = LoggerFactory.getLogger(JerseySSEClient.class); @@ -39,10 +40,8 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { private EventInput eventSource; private final WebTarget target; private final List> waitingClients = new ArrayList<>(); + private Consumer notify; - public JerseySSEClient(@NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { - this(null, config, retryer); - } public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; @@ -64,14 +63,12 @@ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull target = makeEventSourceTarget(client, config.getRealtimeUrl()); } - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { + @NotNull protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { return client.target(sdkUrl); } @Override public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - if (config.isServerEvaluation() && ( (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || @@ -86,14 +83,10 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { } if (eventSource == null) { - waitingClients.add(change); - - poll(); - } else { - change.complete(repository.getReadiness()); + return poll(); } - return change; + return CompletableFuture.completedFuture(repository.getReadiness()); } @Override @@ -152,6 +145,10 @@ private void initEventSource() { boolean interrupted = false; while (!eventSource.isClosed() && !interrupted) { + if (notify != null) { // this is for testing + notify.accept(null); + } + @Nullable String data; InboundEvent event; @@ -169,57 +166,78 @@ private void initEventSource() { continue; } - try { - final SSEResultState state = retryer.fromValue(event.getName()); + connectionSaidBye = processResult(connectionSaidBye, data, event); + } - if (state == null) { // unknown state - continue; - } + if (retryer.isStopped() || eventSource.isClosed() || interrupted) { + final boolean closedOrInterrupted = eventSource.isClosed() || interrupted; - log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); + log.trace("[featurehub] closed"); + if (!eventSource.isClosed()) { + close(); + } - if (state == SSEResultState.CONFIG) { - retryer.edgeConfigInfo(data); - } else if (data != null) { - retryer.convertSSEState(state, data, repository); - } + checkForUnsatisfactoryConversation(); - // reset the timer - if (state == SSEResultState.FEATURES) { - retryer.edgeResult(EdgeConnectionState.SUCCESS, this); - } + notifyWaitingClients(); - if (state == SSEResultState.BYE) { - connectionSaidBye = true; - } + if (closedOrInterrupted) { + // send this once we are actually disconnected and not before + retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : + EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + } + } + } - if (state == SSEResultState.FAILURE) { - retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); - } + private void checkForUnsatisfactoryConversation() { + // we never received a satisfactory connection + if (repository.getReadiness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); + } + } - // tell any waiting clients we are now ready - if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); - } - } catch (Exception e) { - log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); + private boolean processResult(boolean connectionSaidBye, String data, InboundEvent event) { + try { + final SSEResultState state = retryer.fromValue(event.getName()); + + if (state == null) { // unknown state + return connectionSaidBye; } - } - if (eventSource.isClosed() || interrupted) { - close(); + log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); + + if (state == SSEResultState.CONFIG) { + retryer.edgeConfigInfo(data); + } else { + retryer.convertSSEState(state, data, repository); + } + + // reset the timer + if (state == SSEResultState.FEATURES) { + retryer.edgeResult(EdgeConnectionState.SUCCESS, this); + } - log.trace("[featurehub-sdk] closed"); + if (state == SSEResultState.BYE) { + connectionSaidBye = true; + } - // we never received a satisfactory connection - if (repository.getReadiness() == Readiness.NotReady) { - repository.notify(SSEResultState.FAILURE); + if (state == SSEResultState.FAILURE) { + retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); } - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + // tell any waiting clients we are now ready + if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { + notifyWaitingClients(); + } + } catch (Exception e) { + log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e); } + + return connectionSaidBye; + } + + private void notifyWaitingClients() { + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } private void onMakeEventSourceException(Exception e) { @@ -243,7 +261,9 @@ public Future poll() { final CompletableFuture change = new CompletableFuture<>(); waitingClients.add(change); + retryer.getExecutorService().submit(this::initEventSource); + return change; } @@ -254,4 +274,8 @@ public Future poll() { public void reconnect() { poll(); } + + public void setNotify(Consumer notify) { + this.notify = notify; + } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java new file mode 100644 index 0000000..c70466a --- /dev/null +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -0,0 +1,293 @@ +package io.featurehub.client.jersey; + +import cd.connect.openapi.support.ApiClient; +import cd.connect.openapi.support.ApiResponse; +import io.featurehub.client.EdgeService; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.Readiness; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.SSEResultState; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class RestClient implements EdgeService { + private static final Logger log = LoggerFactory.getLogger(RestClient.class); + @NotNull + private final InternalFeatureRepository repository; + @NotNull private final FeatureService client; + @Nullable + private String xFeaturehubHeader; + // used for breaking the cache + @NotNull + private String xContextSha = "0"; + private boolean stopped = false; + @Nullable + private String etag = null; + private long pollingInterval; + + private long whenPollingCacheExpires; + private final boolean clientSideEvaluation; + @NotNull private final FeatureHubConfig config; + + /** + * a Rest client. + * + * @param repository - expected to be null, but able to be passed in because of special use cases + * @param client - expected to be null, but able to be passed in because of testing + * @param config - FH config + * @param timeoutInSeconds - use 0 for once off and for when using an actual timer + */ + public RestClient(@Nullable InternalFeatureRepository repository, + @Nullable FeatureService client, + @NotNull FeatureHubConfig config, + int timeoutInSeconds) { + if (repository == null) { + repository = (InternalFeatureRepository) config.getRepository(); + } + + this.repository = repository; + this.client = client == null ? makeClient(config) : client; + this.config = config; + this.pollingInterval = timeoutInSeconds; + + // ensure the poll has expired the first time we ask for it + whenPollingCacheExpires = System.currentTimeMillis() - 100; + + this.clientSideEvaluation = !config.isServerEvaluation(); + + if (clientSideEvaluation) { + checkForUpdates(null); + } + } + + @NotNull protected FeatureService makeClient(FeatureHubConfig config) { + Client client = ClientBuilder.newBuilder() + .register(JacksonFeature.class).build(); + + return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); + } + + public RestClient(@NotNull FeatureHubConfig config, + int timeoutInSeconds) { + this(null, null, config, timeoutInSeconds); + } + + public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this(repository, null, config, 180); + } + + public RestClient(@NotNull FeatureHubConfig config) { + this(null, null, config, 180); + } + + private boolean busy = false; + private boolean headerChanged = false; + private List> waitingClients = new ArrayList<>(); + + protected Long now() { + return System.currentTimeMillis(); + } + + public boolean checkForUpdates(@Nullable CompletableFuture change) { + final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + final boolean ask = !busy && !stopped && breakCache; + + headerChanged = false; + + if (ask) { + if (change != null) { + // we are going to call, so we take a note of who we need to tell + waitingClients.add(change); + } + + busy = true; + + Map headers = new HashMap<>(); + if (xFeaturehubHeader != null) { + headers.put("x-featurehub", xFeaturehubHeader); + } + + if (etag != null) { + headers.put("if-none-match", etag); + } + + try { + final ApiResponse> response = client.getFeatureStates(config.apiKeys(), + xContextSha, headers); + processResponse(response); + } catch (Exception e) { + processFailure(e); + } + } + + return ask; + } + + protected @Nullable String getEtag() { + return etag; + } + + protected void setEtag(@Nullable String etag) { + this.etag = etag; + } + + @Nullable public Long getPollingInterval() { + return pollingInterval; + } + + final Pattern cacheControlRegex = Pattern.compile("max-age=(\\d+)"); + + public void processCacheControlHeader(@NotNull String cacheControlHeader) { + final Matcher matcher = cacheControlRegex.matcher(cacheControlHeader); + if (matcher.find()) { + final String interval = matcher.group().split("=")[1]; + try { + long newInterval = Long.parseLong(interval); + if (newInterval > 0) { + this.pollingInterval = newInterval; + } + } catch (Exception e) { + // ignored + } + } + } + + protected void processFailure(@NotNull Exception e) { + log.error("Unable to call for features", e); + repository.notify(SSEResultState.FAILURE); + busy = false; + completeReadiness(); + } + + protected void processResponse(ApiResponse> response) throws IOException { + busy = false; + + log.trace("response code is {}", response.getStatusCode()); + + // check the cache-control for the max-age + final String cacheControlHeader = response.getResponse().getHeaderString("cache-control"); + if (cacheControlHeader != null) { + processCacheControlHeader(cacheControlHeader); + } + + // preserve the etag header if it exists + final String etagHeader = response.getResponse().getHeaderString("etag"); + if (etagHeader != null) { + this.etag = etagHeader; + } + + if (response.getStatusCode() >= 200 && response.getStatusCode() < 300) { + List states = new ArrayList<>(); + response.getData().forEach(e -> { + if (e.getFeatures() != null) { + e.getFeatures().forEach(f -> f.setEnvironmentId(e.getId())); + states.addAll(e.getFeatures()); + } + }); + + log.trace("updating feature repository: {}", states); + + repository.updateFeatures(states); + completeReadiness(); + + if (response.getStatusCode() == 236) { + this.stopped = true; // prevent any further requests + } + + // reset the polling interval to prevent unnecessary polling + if (pollingInterval > 0) { + whenPollingCacheExpires = now() + (pollingInterval * 1000); + } + } else if (response.getStatusCode() == 400 || response.getStatusCode() == 404) { + stopped = true; + log.error("Server indicated an error with our requests making future ones pointless."); + repository.notify(SSEResultState.FAILURE); + completeReadiness(); + } else if (response.getStatusCode() >= 500) { + completeReadiness(); // we haven't changed anything, but we have to unblock clients as we can't just hang + } + } + + public boolean isStopped() { return stopped; } + + private void completeReadiness() { + List> current = waitingClients; + waitingClients = new ArrayList<>(); + current.forEach(c -> { + try { + c.complete(repository.getReadiness()); + } catch (Exception e) { + log.error("Unable to complete future", e); + } + }); + } + + @Override + public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { + final CompletableFuture change = new CompletableFuture<>(); + + headerChanged = (newHeader != null && !newHeader.equals(xFeaturehubHeader)); + + xFeaturehubHeader = newHeader; + xContextSha = contextSha; + + // if there is already another change running, you are out of luck + if (busy) { + waitingClients.add(change); + } else if (!checkForUpdates(change)) { + change.complete(repository.getReadiness()); + } + + return change; + } + + @Override + public boolean isClientEvaluation() { + return clientSideEvaluation; + } + + @Override + public void close() { + log.info("featurehub client closed."); + } + + @Override + public @NotNull FeatureHubConfig getConfig() { + return config; + } + + @Override + public Future poll() { + final CompletableFuture change = new CompletableFuture<>(); + + if (busy) { + waitingClients.add(change); + } else if (!checkForUpdates(change)) { + // not even planning to ask + change.complete(repository.getReadiness()); + } + + return change; + } + + public long getWhenPollingCacheExpires() { + return whenPollingCacheExpires; + } +} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java index b5755b8..5a64f00 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java @@ -2,19 +2,24 @@ import cd.connect.openapi.support.ApiClient; import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.TestApi; +import io.featurehub.client.TestApiResult; import io.featurehub.sse.model.FeatureStateUpdate; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; import org.glassfish.jersey.jackson.JacksonFeature; import org.jetbrains.annotations.NotNull; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; + /** * This makes a simple wrapper around the TestSDK Client */ -public class TestSDKClient { +public class TestSDKClient implements TestApi { private final FeatureServiceImpl featureService; + private final FeatureHubConfig config; public TestSDKClient(FeatureHubConfig config) { + this.config = config; Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class).build(); @@ -23,8 +28,17 @@ public TestSDKClient(FeatureHubConfig config) { featureService = new FeatureServiceImpl(apiClient); } - public void setFeatureState(String apiKey, @NotNull String featureKey, + public @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { - featureService.setFeatureState(apiKey, featureKey, featureStateUpdate); + return new TestApiResult(featureService.setFeatureState(apiKey, featureKey, featureStateUpdate, null)); + } + + @Override + public @NotNull TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return new TestApiResult(featureService.setFeatureState(config.apiKey(), featureKey, featureStateUpdate, null)); + } + + @Override + public void close() { } } diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy deleted file mode 100644 index 68f711a..0000000 --- a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy +++ /dev/null @@ -1,121 +0,0 @@ -package io.featurehub.client.jersey - -import cd.connect.openapi.support.ApiClient -import io.featurehub.client.ClientFeatureRepository -import io.featurehub.client.EdgeFeatureHubConfig -import io.featurehub.client.FeatureHubConfig -import io.featurehub.sse.api.FeatureService -import io.featurehub.sse.model.FeatureStateUpdate -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import spock.lang.Specification - -import jakarta.ws.rs.client.Client -import jakarta.ws.rs.client.WebTarget - -class JerseyClientSpec extends Specification { - private static final Logger log = LoggerFactory.getLogger(JerseyClientSpec.class) - def targetUrl - def basePath - FeatureHubConfig sdkPartialUrl - FeatureService mockFeatureService - ClientFeatureRepository mockRepository - WebTarget mockEventSource - - def "basic initialization test works as expect"() { - given: "i have a valid url" - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - when: "i initialize with a valid kind of sdk url" - def client = new JerseyClient(url, new ClientFeatureRepository(1)) { - @Override - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - targetUrl = sdkUrl - return super.makeEventSourceTarget(client, sdkUrl) - } - - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - basePath = apiClient.basePath - sdkPartialUrl = fhConfig - return super.makeFeatureServiceClient(apiClient) - } - } - then: "the urls are correctly initialize" - targetUrl == url.realtimeUrl - basePath == 'http://localhost:80' - sdkPartialUrl.apiKey() == 'sdk-url' - } - - def "test the set feature sdk call"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - and: "I have a client and mock the feature service url" - def client = new JerseyClient(url, false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState("key", update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) - } - - def "test the set feature sdk call using a Feature"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - and: "I have a client and mock the feature service url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState(InternalFeature.FEATURE, update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) - } - - def "a client side evaluation header does not trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader == null - } - - def "a server side evaluation header does not trigger the context header to be set if it is null"() { - given: "i have a client with a server eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - client.neverConnect = true // groovy is groovy - when: "i set attributes" - client.contextChange(null, '0') - then: - client.featurehubContextHeader == null - - } - - def "a server side evaluation header does trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader != null - } - -} diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy new file mode 100644 index 0000000..92b3405 --- /dev/null +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -0,0 +1,193 @@ +package io.featurehub.client.jersey + +import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.client.FeatureHubConfig +import io.featurehub.client.Readiness +import io.featurehub.client.edge.EdgeRetryer +import io.featurehub.sse.model.FeatureState +import io.featurehub.sse.model.FeatureValueType +import org.glassfish.jersey.media.sse.EventInput +import org.glassfish.jersey.media.sse.EventOutput +import org.glassfish.jersey.media.sse.OutboundEvent +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import spock.lang.Specification + +class JerseySSEClientSpec extends Specification { + private static final Logger log = LoggerFactory.getLogger(JerseySSEClientSpec.class) + Closure sseClosure + FeatureHubConfig config + SSETestHarness harness + EventOutput output + JerseySSEClient edge + ObjectMapper mapper + + def setup() { + mapper = new ObjectMapper() + harness = new SSETestHarness() + harness.setUp() + config = harness.getConfig(["123/345*675"], { String envId, String apiKey, List featureHubAttrs, String extraConfig, String browserHubAttrs, String etag -> + output = new EventOutput() + return output + }) + + edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { + @Override + void reconnect() { + close(); + } + } + + config.setEdgeService { -> edge } + } + + + def cleanup() { + harness.tearDown() + } + + def "A basic client connect works as expected"() { + given: + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("ack").id("1").data("hello").build()) + + edge.setNotify { EventInput i1 -> + output.write(new OutboundEvent.Builder().name("failure").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } + } + } + when: + def future = config.newContext().build() + then: + future.get().repository.readiness == Readiness.Failed + } + + def "a basic drop of all events goes to readiness"() { + given: + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("features").id("1").data(mapper.writeValueAsString([ + new FeatureState().id(UUID.randomUUID()).key("key").l(true).value(true).type(FeatureValueType.BOOLEAN).version(1)])).build()) + + edge.setNotify { EventInput i1 -> + output.write(new OutboundEvent.Builder().name("bye").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } + } + } + when: + def future = config.newContext().build() + then: + future.get().repository.readiness == Readiness.Ready + config.repository.allFeatures.size() == 1 + } + + def "a config with a stop will prevent further calls"() { + given: + edge.setNotify { EventInput i -> + output.write(new OutboundEvent.Builder().name("config").id("1").data("{\"edge.stale\": true}").build()) +// edge.setNotify { EventInput i1 -> +// output.write(new OutboundEvent.Builder().name("bye").id("2").data("{}").build()) + + edge.setNotify {EventInput i2 -> + output.close() + } +// } + } + when: + def future = config.newContext().build() + then: + future.get().repository.readiness == Readiness.Failed + edge.stopped + } + +// def "basic initialization test works as expect"() { +// given: "i have a valid url" +// def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") +// when: "i initialize with a valid kind of sdk url" +// def client = new JerseySSEClient(null, url, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { +// @Override +// protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { +// targetUrl = sdkUrl +// return super.makeEventSourceTarget(client, sdkUrl) +// } +// } +// then: "the urls are correctly initialize" +// targetUrl == url.realtimeUrl +// basePath == 'http://localhost:80' +// sdkPartialUrl.apiKey() == 'sdk-url' +// } +// +// def "test the set feature sdk call"() { +// given: "I have a mock feature service" +// def config = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") +// def testApi = new TestSDKClient(config) +// and: "i have a feature state update" +// def update = new FeatureStateUpdate().lock(true) +// when: "I call to set a feature" +// testApi.setFeatureState(config.apiKey(), "key", update) +// then: +// mockFeatureService != null +// 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) +// } +// +// def "test the set feature sdk call using a Feature"() { +// given: "I have a mock feature service" +// mockFeatureService = Mock(FeatureService) +// and: "I have a client and mock the feature service url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) { +// @Override +// protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { +// return mockFeatureService +// } +// } +// and: "i have a feature state update" +// def update = new FeatureStateUpdate().lock(true) +// when: "I call to set a feature" +// client.setFeatureState(InternalFeature.FEATURE, update) +// then: +// mockFeatureService != null +// 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) +// } + +// def "a client side evaluation header does not trigger the context header to be set"() { +// given: "i have a client with a client eval url" +// def config = new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2") +// def client = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) +// and: "we set up a server" +// def harness = new SSETestHarness(config) +// +// when: "i set attributes" +// client.contextChange("fred=mary,susan", '0').get() +// then: +// +// } +// +// def "a server side evaluation header does not trigger the context header to be set if it is null"() { +// given: "i have a client with a server eval url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) +// client.neverConnect = true // groovy is groovy +// when: "i set attributes" +// client.contextChange(null, '0') +// then: +// client.featurehubContextHeader == null +// +// } +// +// def "a server side evaluation header does trigger the context header to be set"() { +// given: "i have a client with a client eval url" +// def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), +// false, new ClientFeatureRepository(1), null) +// when: "i set attributes" +// client.contextChange("fred=mary,susan", '0') +// then: +// client.featurehubContextHeader != null +// } + +} diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy new file mode 100644 index 0000000..ea2d2c3 --- /dev/null +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -0,0 +1,146 @@ +package io.featurehub.client.jersey + +import cd.connect.openapi.support.ApiResponse +import io.featurehub.client.FeatureHubConfig +import io.featurehub.client.InternalFeatureRepository +import io.featurehub.client.Readiness +import io.featurehub.sse.model.FeatureEnvironmentCollection +import io.featurehub.sse.model.SSEResultState +import spock.lang.Specification + +import jakarta.ws.rs.core.Response + +class RestClientSpec extends Specification { + FeatureService featureService + RestClient client + InternalFeatureRepository repo + FeatureHubConfig config + List apiKeys + + def setup() { + apiKeys = ["123"] + featureService = Mock() + repo = Mock() + config = Mock() + config.isServerEvaluation() >> true + client = new RestClient(repo, featureService, config, 0) + } + + ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { + def response = Response.status(statusCode) + + if (data != null) + response.entity(data) + if (!headers?.isEmpty()) { + headers.forEach { key, value -> response.header(key, value)} + } + + return new ApiResponse>(statusCode, null, data, response.build()) + } + + def "a basic poll with a 200 result"() { + given: + def response = build() + when: + client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys +// 1 * config.isServerEvaluation() >> true + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + 0 * _ + } + + def "a basic poll with a 236 result will cause the client to stop"() { + given: + def response = build(236) + when: + def result = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + 0 * _ + client.stopped + result == Readiness.Ready + } + + def "a poll with a 5xx result will cause the client to complete and not change readiness"() { + given: + def response = build(503) + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.NotReady + 0 * _ + !client.stopped + result == Readiness.NotReady + } + + def "a poll with a 400 result will cause the client to stop polling and indicate failure"() { + given: + def response = build(400) + def apiKeys = ["123"] + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * repo.notify(SSEResultState.FAILURE) + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Failed + 0 * _ + client.stopped + result == Readiness.Failed + } + + def "change the header to itself and it won't run again"() { + given: + def response = build() + when: + def result = client.poll().get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + when: + def result2 = client.contextChange('new-header', '765').get() + then: + 1 * config.apiKeys() >> apiKeys + 1 * repo.readiness >> Readiness.Ready + 1 * featureService.getFeatureStates(apiKeys, '765', ['x-featurehub': 'new-header']) >> response + } + + def "cache header will change the polling interval"() { + given: + def response = build(200, [], ['cache-control': 'blah, max-age=300']) + when: + def result = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 1 * repo.readiness >> Readiness.Ready + client.pollingInterval == 300 + 0 * _ + + } + + def "change the polling interval to 180 seconds and a second poll won't poll"() { + given: + def response = build() + client = new RestClient(repo, featureService, config, 180) + when: + def result = client.poll().get() + def result2 = client.poll().get() + then: + 1 * repo.updateFeatures([]) + 1 * config.apiKeys() >> apiKeys + 1 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 2 * repo.readiness >> Readiness.Ready + 0 * _ + } +} diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy new file mode 100644 index 0000000..6585cfb --- /dev/null +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy @@ -0,0 +1,48 @@ +package io.featurehub.client.jersey + +import io.featurehub.client.EdgeFeatureHubConfig +import io.featurehub.client.FeatureHubConfig +import org.glassfish.jersey.media.sse.EventOutput +import org.glassfish.jersey.media.sse.SseFeature +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.test.JerseyTest +import org.glassfish.jersey.test.TestProperties + +import jakarta.ws.rs.GET +import jakarta.ws.rs.HeaderParam +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.core.Application + +@Singleton +@Path("features/{environmentId}/{apiKey}") +class SSETestHarness extends JerseyTest { + static Closure backhaul + + @Override + protected Application configure() { + enable(TestProperties.LOG_TRAFFIC) + enable(TestProperties.DUMP_ENTITY) +// forceSet(TestProperties.CONTAINER_PORT, "0") + return new ResourceConfig(SSETestHarness) + } + + @GET + @Produces(SseFeature.SERVER_SENT_EVENTS) + public EventOutput features( + @PathParam("environmentId") String envId, + @PathParam("apiKey") String apiKey, + @HeaderParam("x-featurehub") List featureHubAttrs, // non browsers can set headers + @HeaderParam("x-fh-extraconfig") String extraConfig, + @QueryParam("xfeaturehub") String browserHubAttrs, // browsers can't set headers, + @HeaderParam("Last-Event-ID") String etag) { + return backhaul(envId, apiKey, featureHubAttrs, extraConfig, browserHubAttrs, etag) + } + + FeatureHubConfig getConfig(List apiKeys, Closure backhaul) { + this.backhaul = backhaul + return new EdgeFeatureHubConfig(target().uri.toString(), apiKeys) + } +} diff --git a/client-java-android/CHANGELOG.adoc b/client-java-okhttp/CHANGELOG.adoc similarity index 100% rename from client-java-android/CHANGELOG.adoc rename to client-java-okhttp/CHANGELOG.adoc diff --git a/client-java-android/README.adoc b/client-java-okhttp/README.adoc similarity index 100% rename from client-java-android/README.adoc rename to client-java-okhttp/README.adoc diff --git a/client-java-android/pom.xml b/client-java-okhttp/pom.xml similarity index 90% rename from client-java-android/pom.xml rename to client-java-okhttp/pom.xml index b7440cd..33735c1 100644 --- a/client-java-android/pom.xml +++ b/client-java-okhttp/pom.xml @@ -3,12 +3,12 @@ 4.0.0 io.featurehub.sdk - java-client-android + java-client-okhttp 3.1-SNAPSHOT - java-client-android + java-client-okhttp - The Android (OKHttp) client for Java. + The OKHttp client for Java. Supports all three (streaming, polling, interval) https://featurehub.io @@ -56,6 +56,12 @@ 4.9.3 + + com.squareup.okhttp3 + okhttp-sse + 4.9.3 + + io.featurehub.sdk.composites sdk-composite-jackson diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java new file mode 100644 index 0000000..caca769 --- /dev/null +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java @@ -0,0 +1,39 @@ +package io.featurehub.okhttp; + +import io.featurehub.client.EdgeService; +import io.featurehub.client.FeatureHubClientFactory; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.TestApi; +import io.featurehub.client.edge.EdgeRetryer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Supplier; + +public class OkHttpFeatureHubFactory implements FeatureHubClientFactory { + @Override + public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { + return () -> new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + } + + @Override + public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { + return createSSEEdge(config, null); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { + return () -> new RestClient(repository, config, timeoutInSeconds); + } + + @Override + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { + return createRestEdge(config, null, timeoutInSeconds); + } + + @Override + public Supplier createTestApi(@NotNull FeatureHubConfig config) { + return () -> new TestClient(config); + } +} diff --git a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java similarity index 90% rename from client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java rename to client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index 7e7fa18..7314a95 100644 --- a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -1,4 +1,4 @@ -package io.featurehub.android; +package io.featurehub.okhttp; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,10 +32,10 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -public class FeatureHubClient implements EdgeService { - private static final Logger log = LoggerFactory.getLogger(FeatureHubClient.class); +public class RestClient implements EdgeService { + private static final Logger log = LoggerFactory.getLogger(RestClient.class); @NotNull private final InternalFeatureRepository repository; - @NotNull private final Call.Factory client; + @NotNull private final OkHttpClient client; private boolean makeRequests; @NotNull private final String url; private final ObjectMapper mapper = new ObjectMapper(); @@ -54,14 +54,14 @@ public class FeatureHubClient implements EdgeService { @NotNull private final FeatureHubConfig config; @NotNull private final ExecutorService executorService; - public FeatureHubClient(@Nullable InternalFeatureRepository repository, - Call.@NotNull Factory client, @NotNull FeatureHubConfig config, int timeoutInSeconds) { + public RestClient(@Nullable InternalFeatureRepository repository, + @NotNull FeatureHubConfig config, int timeoutInSeconds) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } this.repository = repository; - this.client = client; + this.client = new OkHttpClient(); this.config = config; this.pollingInterval = timeoutInSeconds; @@ -83,17 +83,17 @@ protected ExecutorService makeExecutorService() { return Executors.newWorkStealingPool(); } - public FeatureHubClient(@NotNull FeatureHubConfig config, - int timeoutInSeconds) { - this(null, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds); + public RestClient(@NotNull FeatureHubConfig config, + int timeoutInSeconds) { + this(null, config, timeoutInSeconds); } - public FeatureHubClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { - this(repository, (Call.Factory) new OkHttpClient(), config, 180); + public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this(repository, config, 180); } - public FeatureHubClient(@NotNull FeatureHubConfig config) { - this(null, (Call.Factory) new OkHttpClient(), config, 180); + public RestClient(@NotNull FeatureHubConfig config) { + this(null, config, 180); } private final static TypeReference> ref = new TypeReference>(){}; @@ -106,7 +106,7 @@ protected Long now() { } public boolean checkForUpdates(@Nullable CompletableFuture change) { - final boolean breakCache = now() > whenPollingCacheExpires || headerChanged; + final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = makeRequests && !busy && !stopped && breakCache; headerChanged = false; diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java similarity index 98% rename from client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java rename to client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java index fe76690..265fb5f 100644 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java @@ -1,9 +1,8 @@ -package io.featurehub.edge.sse; +package io.featurehub.okhttp; import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; -import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.Readiness; import io.featurehub.client.edge.EdgeConnectionState; import io.featurehub.client.edge.EdgeReconnector; diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java new file mode 100644 index 0000000..8706971 --- /dev/null +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java @@ -0,0 +1,67 @@ +package io.featurehub.okhttp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.client.TestApi; +import io.featurehub.client.TestApiResult; +import io.featurehub.client.utils.SdkVersion; +import io.featurehub.sse.model.FeatureStateUpdate; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class TestClient implements TestApi { + private static final Logger log = LoggerFactory.getLogger(TestClient.class); + private final FeatureHubConfig config; + private final OkHttpClient client = new OkHttpClient(); + + public TestClient(FeatureHubConfig config) { + this.config = config; + } + + @Override + public @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + String data; + + try { + data = + ((InternalFeatureRepository)config.getRepository()).getJsonObjectMapper().writeValueAsString(featureStateUpdate); + } catch (JsonProcessingException e) { + return new TestApiResult(500); + } + + String url = String.format("%s/%s/%s", config.baseUrl(), apiKey, featureKey); + + log.trace("test-url: {}", url); + + Request.Builder reqBuilder = + new Request.Builder() + .url(url) + .post(RequestBody.create(data, MediaType.get("application/json"))) + .addHeader("X-SDK", SdkVersion.sdkVersionHeader("Java-OKHTTP")); + + try(Response response = client.newCall(reqBuilder.build()).execute()) { + return new TestApiResult(response.code()); + } catch (IOException e) { + return new TestApiResult(500); + } + } + + @Override + public @NotNull TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return setFeatureState(config.apiKey(), featureKey, featureStateUpdate); + } + + @Override + public void close() { + client.dispatcher().executorService().shutdown(); + } +} diff --git a/client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory new file mode 100644 index 0000000..bf7cd9a --- /dev/null +++ b/client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory @@ -0,0 +1 @@ +io.featurehub.okhttp.OkHttpFeatureHubFactory diff --git a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy similarity index 96% rename from client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy rename to client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy index 2eff941..11071de 100644 --- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy +++ b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy @@ -1,4 +1,4 @@ -package io.featurehub.android +package io.featurehub.okhttp import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig @@ -9,9 +9,9 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import spock.lang.Specification -class FeatureHubClientMockSpec extends Specification { +class RestClientSpec extends Specification { + RestClient client MockWebServer mockWebServer - FeatureHubClient client FeatureHubConfig config InternalFeatureRepository repo ObjectMapper mapper @@ -27,7 +27,7 @@ class FeatureHubClientMockSpec extends Specification { config.baseUrl() >> url.substring(0, url.length() - 1) config.apiKeys() >> ["one", "two"] config.serverEvaluation >> true - client = new FeatureHubClient(config, 0) + client = new RestClient(config, 0) mockWebServer.url("/features") } diff --git a/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy similarity index 99% rename from client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy rename to client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy index 3b18c58..ac1979f 100644 --- a/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy +++ b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy @@ -1,4 +1,4 @@ -package io.featurehub.edge.sse +package io.featurehub.okhttp import io.featurehub.client.FeatureHubConfig diff --git a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy new file mode 100644 index 0000000..c0edcb0 --- /dev/null +++ b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy @@ -0,0 +1,53 @@ +package io.featurehub.okhttp + +import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.client.FeatureHubConfig +import io.featurehub.client.InternalFeatureRepository +import io.featurehub.sse.model.FeatureStateUpdate +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import spock.lang.Specification + +import java.util.concurrent.TimeUnit + +class TestClientSpec extends Specification { + MockWebServer mockWebServer + FeatureHubConfig config + InternalFeatureRepository repo + ObjectMapper mapper + TestClient client + + def setup() { + mapper = new ObjectMapper() + config = Mock() + repo = Mock() + repo.getJsonObjectMapper() >> mapper + config.repository >> repo + mockWebServer = new MockWebServer() + + def url = mockWebServer.url("/features").toString() + config.baseUrl() >> url.substring(0, url.length()) + config.apiKey() >> "one" + config.serverEvaluation >> true + client = new TestClient(config) + } + + def cleanup() { + client.close() + mockWebServer.shutdown() + } + + def "i make a call and it returns the correct value"() { + given: + def update = new FeatureStateUpdate().value(20).lock(false).updateValue(true) + client.setFeatureState('key', update) + def updateAsString = mapper.writeValueAsString(update) + when: + def req = mockWebServer.takeRequest(100, TimeUnit.MILLISECONDS) + mockWebServer.enqueue(new MockResponse().setResponseCode(200)) + then: + req.path == "/features/one/key" + req.headers.get('content-type').contains('application/json') + req.body.readUtf8() == updateAsString + } +} diff --git a/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/client-java-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java similarity index 100% rename from client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java rename to client-java-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java diff --git a/client-java-android/src/test/resources/log4j2.xml b/client-java-okhttp/src/test/resources/log4j2.xml similarity index 100% rename from client-java-android/src/test/resources/log4j2.xml rename to client-java-okhttp/src/test/resources/log4j2.xml diff --git a/client-java-sse/CHANGELOG.adoc b/client-java-sse/CHANGELOG.adoc deleted file mode 100644 index f3af608..0000000 --- a/client-java-sse/CHANGELOG.adoc +++ /dev/null @@ -1,4 +0,0 @@ -= CHANGELOG (jersey3) - -- 1.4 - adds functionality for upcoming expired environments -- 1.3 - fully functional client diff --git a/client-java-sse/pom.xml b/client-java-sse/pom.xml deleted file mode 100644 index 8b7dd0b..0000000 --- a/client-java-sse/pom.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - 4.0.0 - - io.featurehub.sdk - java-client-sse - 2.1-SNAPSHOT - java-client-sse - - - The OKHttp3 SSE client for Java. - - - https://featurehub.io - - - irina@featurehub.io - isouthwell - Irina Southwell - Anyways Labs Ltd - - - - richard@featurehub.io - rvowles - Richard Vowles - Anyways Labs Ltd - - - - - - MIT - https://opensource.org/licenses/MIT - This code resides in the customer's codebase and therefore has an MIT license. - - - - - scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git - scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git - git@github.com:featurehub-io/featurehub-java-sdk.git - HEAD - - - - - io.featurehub.sdk - java-client-core - [4, 5) - - - - com.squareup.okhttp3 - okhttp - 4.9.3 - - - - com.squareup.okhttp3 - okhttp-sse - 4.9.3 - - - - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - - - - io.featurehub.sdk.composites - sdk-composite-test - [1.1, 2) - test - - - - - - - io.repaint.maven - tiles-maven-plugin - 2.23 - true - - false - - io.featurehub.sdk.tiles:tile-java8:[1.1,2) - io.featurehub.sdk.tiles:tile-release:[1.1,2) - io.featurehub.sdk.tiles:tile-sdk:[1.1-SNAPSHOT,2) - - - - - - diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java deleted file mode 100644 index fad5bac..0000000 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.featurehub.edge.sse; - -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureHubClientFactory; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.InternalFeatureRepository; -import io.featurehub.client.edge.EdgeRetryer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.function.Supplier; - -public class SSEClientFactory implements FeatureHubClientFactory { - @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config, - @Nullable InternalFeatureRepository repository) { - return () -> - new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - } - - @Override - public Supplier createEdgeService(@NotNull FeatureHubConfig config) { - return () -> - new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - } -} diff --git a/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory deleted file mode 100644 index c76799d..0000000 --- a/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory +++ /dev/null @@ -1 +0,0 @@ -io.featurehub.edge.sse.SSEClientFactory diff --git a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java b/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java deleted file mode 100644 index a78530a..0000000 --- a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.featurehub.edge.sse; - -import io.featurehub.client.ClientContext; -import io.featurehub.client.ClientFeatureRepository; -import io.featurehub.client.EdgeFeatureHubConfig; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.edge.EdgeRetryer; -import io.featurehub.sse.model.StrategyAttributeDeviceName; -import io.featurehub.sse.model.StrategyAttributePlatformName; - -import java.util.function.Supplier; - -public class SSEClientRunner { - public static void main(String[] args) throws Exception { - FeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8903", - "default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); - - ClientFeatureRepository cfr = new ClientFeatureRepository(); - EdgeRetryer retryer = EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build(); - - config.setEdgeService(() -> new SSEClient(cfr, config, retryer)); - config.setRepository(cfr); - final ClientContext ctx = config.newContext(cfr, ).build().get(); - ctx.getRepository().addReadinessListener(rl -> System.out.println("readyness " + rl.toString())); - - final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); - - - cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); - - System.out.println("Wait for readyness or hit enter if server eval key"); - - System.in.read(); - - ctx.userKey("jimbob") - .platform(StrategyAttributePlatformName.MACOS) - .device(StrategyAttributeDeviceName.DESKTOP) - .attr("city", "istanbul").build().get(); - - System.out.println("Istanbul1 is " + val.get()); - - System.out.println("Press a key"); System.in.read(); - - System.out.println("Istanbul2 is " + val.get()); - - ctx.userKey("supine") - .attr("city", "london").build().get(); - - System.out.println("london1 is " + val.get()); - - System.out.println("Press a key"); System.in.read(); - - System.out.println("london2 is " + val.get()); - - System.out.println("Press a key to close"); System.in.read(); - - ctx.close(); - cfr.close(); - } -} diff --git a/client-java-sse/src/test/resources/log4j2.xml b/client-java-sse/src/test/resources/log4j2.xml deleted file mode 100644 index 40eed7e..0000000 --- a/client-java-sse/src/test/resources/log4j2.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 14090a5..1dcfafd 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -28,13 +28,7 @@ io.featurehub.sdk - java-client-sse - [2.1-SNAPSHOT, 3) - - - - io.featurehub.sdk - java-client-android + java-client-okhttp [3.1-SNAPSHOT, 4) diff --git a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java index ce1fab1..09c1103 100644 --- a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java +++ b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java @@ -1,14 +1,16 @@ package io.featurehub.migrationcheck; -import io.featurehub.android.FeatureHubClient; +import io.featurehub.client.edge.EdgeRetryService; +import io.featurehub.client.edge.EdgeRetryer; +import io.featurehub.okhttp.RestClient; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.Readiness; import io.featurehub.edge.sse.SSEClientFactory; +import io.featurehub.okhttp.SSEClient; import org.jetbrains.annotations.NotNull; import java.io.IOException; -import java.util.Collections; import java.util.concurrent.ExecutionException; public class Main { @@ -25,13 +27,13 @@ public static void main(String[] args) throws ExecutionException, InterruptedExc FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); // now we _directly_ create the REST based client, pointing it at our config and our repository - FeatureHubClient client = new FeatureHubClient(config); + RestClient client = new RestClient(config); // and now we block, waiting for it to connect and tell us if it is ready or not if (client.contextChange(null, "0").get() == Readiness.Ready) { client.close(); // make sure you close it, it has a background thread // once it is ready, we tell the config to use SSE as its connector, and start the config going. - config.setEdgeService(new SSEClientFactory().createEdgeService(config)); + config.setEdgeService(() -> new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); config.init(); System.out.println("ready and waiting for updates via SSE"); diff --git a/examples/todo-java/pom.xml b/examples/todo-java/pom.xml index 820c588..d39acfe 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java/pom.xml @@ -38,13 +38,7 @@ io.featurehub.sdk - java-client-sse - [2.1-SNAPSHOT, 3) - - - - io.featurehub.sdk - java-client-android + java-client-okhttp [3.1-SNAPSHOT, 4) diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index 70b60c2..f81c0ce 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -4,8 +4,7 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; -import io.featurehub.android.FeatureHubClient; -import io.featurehub.client.ClientContext; +import io.featurehub.okhttp.RestClient; import io.featurehub.client.ClientFeatureRepository; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; @@ -13,12 +12,7 @@ import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; import io.featurehub.client.jersey.JerseySSEClient; -import io.featurehub.edge.sse.SSEClient; -import org.jetbrains.annotations.Nullable; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; +import io.featurehub.okhttp.SSEClient; public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.host") @@ -57,7 +51,7 @@ public FeatureHubSource() { config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); config.setEdgeService(() -> jerseyClient); } else if (clientSdk.equals("android")) { - final FeatureHubClient client = new FeatureHubClient(config, pollInterval); + final RestClient client = new RestClient(config, pollInterval); config.setEdgeService(() -> client); } else if (clientSdk.equals("sse")) { final SSEClient client = new SSEClient(repository, config, diff --git a/pom.xml b/pom.xml index 35d1352..adf9382 100644 --- a/pom.xml +++ b/pom.xml @@ -35,9 +35,8 @@ client-java-core - client-java-android + client-java-okhttp client-java-android21 - client-java-sse client-java-jersey client-java-jersey3 client-java-api diff --git a/support/composite-jersey2/pom.xml b/support/composite-jersey2/pom.xml index b73b91d..442aceb 100644 --- a/support/composite-jersey2/pom.xml +++ b/support/composite-jersey2/pom.xml @@ -53,7 +53,7 @@ cd.connect.openapi.gensupport openapi-generator-support - 1.4 + 1.5 @@ -70,13 +70,6 @@ ${jersey.version} - - - org.glassfish.jersey.ext - jersey-proxy-client - ${jersey.version} - - org.glassfish.jersey.media diff --git a/support/composite-jersey3/pom.xml b/support/composite-jersey3/pom.xml index da2b178..bb2cb47 100644 --- a/support/composite-jersey3/pom.xml +++ b/support/composite-jersey3/pom.xml @@ -45,7 +45,7 @@ - 3.0.5 + 3.1.2 @@ -76,13 +76,6 @@ ${jersey.version} - - - org.glassfish.jersey.ext - jersey-proxy-client - ${jersey.version} - - org.glassfish.jersey.media From 6fe202df99bbc7d67264e9a4513b153d71297b68 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 6 Jul 2023 20:20:02 +1200 Subject: [PATCH 04/22] change to usage from analytics --- .../featurehub/client/BaseClientContext.java | 30 +++---- .../io/featurehub/client/ClientContext.java | 4 +- .../client/ClientFeatureRepository.java | 30 ++++--- .../client/EdgeFeatureHubConfig.java | 79 ++++++++--------- .../io/featurehub/client/EdgeService.java | 7 ++ .../client/FeatureHubClientFactory.java | 4 +- .../featurehub/client/FeatureHubConfig.java | 35 +++----- .../featurehub/client/FeatureRepository.java | 8 +- .../featurehub/client/FeatureStateBase.java | 2 +- .../client/InternalFeatureRepository.java | 10 ++- .../client/PollingDelegateEdgeService.java | 88 +++++++++++++++++++ .../client/analytics/AnalyticsAdapter.java | 26 ------ .../client/analytics/AnalyticsProvider.java | 30 ------- .../FeatureHubUsageValue.java} | 12 +-- .../featurehub/client/usage/UsageAdapter.java | 26 ++++++ .../UsageEvent.java} | 10 +-- .../UsageEventName.java} | 4 +- .../UsageFeature.java} | 12 +-- .../UsageFeaturesCollection.java} | 12 +-- .../UsageFeaturesCollectionContext.java} | 8 +- .../UsagePlugin.java} | 8 +- .../client/usage/UsageProvider.java | 30 +++++++ .../client/EdgeFeatureHubConfigSpec.groovy | 6 +- .../client/FeatureHubTestClientFactory.groovy | 9 +- .../jersey/JerseyFeatureHubClientFactory.java | 8 +- .../featurehub/client/jersey/RestClient.java | 21 +++-- client-java-jersey3/pom.xml | 2 +- .../jersey/JerseyFeatureHubClientFactory.java | 8 +- .../featurehub/client/jersey/RestClient.java | 25 +++--- .../okhttp/OkHttpFeatureHubFactory.java | 8 +- .../java/io/featurehub/okhttp/RestClient.java | 20 +++-- .../java/io/featurehub/okhttp/SSEClient.java | 5 ++ examples/migration-check/pom.xml | 1 - .../io/featurehub/migrationcheck/Main.java | 8 +- .../java/todo/backend/FeatureHubSource.java | 2 + ...ment.java => UsageRequestMeasurement.java} | 6 +- .../resources/FeatureAnalyticsFilter.java | 4 +- 37 files changed, 358 insertions(+), 250 deletions(-) create mode 100644 client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java delete mode 100644 client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java rename client-java-core/src/main/java/io/featurehub/client/{analytics/FeatureHubAnalyticsValue.java => usage/FeatureHubUsageValue.java} (67%) create mode 100644 client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsEvent.java => usage/UsageEvent.java} (73%) rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsEventName.java => usage/UsageEventName.java} (50%) rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsFeature.java => usage/UsageFeature.java} (68%) rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsFeaturesCollection.java => usage/UsageFeaturesCollection.java} (57%) rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsFeaturesCollectionContext.java => usage/UsageFeaturesCollectionContext.java} (68%) rename client-java-core/src/main/java/io/featurehub/client/{analytics/AnalyticsPlugin.java => usage/UsagePlugin.java} (57%) create mode 100644 client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java rename examples/todo-java/src/main/java/todo/backend/{AnalyticsRequestMeasurement.java => UsageRequestMeasurement.java} (69%) diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index b9df377..a2bf6c1 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -1,10 +1,10 @@ package io.featurehub.client; -import io.featurehub.client.analytics.AnalyticsEvent; -import io.featurehub.client.analytics.AnalyticsFeature; -import io.featurehub.client.analytics.AnalyticsFeaturesCollection; -import io.featurehub.client.analytics.AnalyticsFeaturesCollectionContext; -import io.featurehub.client.analytics.FeatureHubAnalyticsValue; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageFeature; +import io.featurehub.client.usage.UsageFeaturesCollection; +import io.featurehub.client.usage.UsageFeaturesCollectionContext; +import io.featurehub.client.usage.FeatureHubUsageValue; import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.StrategyAttributeCountryName; import io.featurehub.sse.model.StrategyAttributeDeviceName; @@ -129,33 +129,33 @@ public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, protected void recordFeatureChangedForUser(FeatureStateBase feature) { - repository.recordAnalyticsEvent(new AnalyticsFeature( - new FeatureHubAnalyticsValue(feature.withContext(this)), attributes, + repository.recordAnalyticsEvent(new UsageFeature( + new FeatureHubUsageValue(feature.withContext(this)), attributes, analyticsUserKey())); } protected void recordRelativeValuesForUser() { - repository.recordAnalyticsEvent(fillAnalyticsCollection(repository.getAnalyticsProvider().createAnalyticsCollectionEvent())); + repository.recordAnalyticsEvent(fillAnalyticsCollection(repository.getAnalyticsProvider().createUsageCollectionEvent())); } - protected AnalyticsEvent fillAnalyticsCollection(AnalyticsEvent event) { + protected UsageEvent fillAnalyticsCollection(UsageEvent event) { event.setUserKey(analyticsUserKey()); - if (event instanceof AnalyticsFeaturesCollection) { - ((AnalyticsFeaturesCollection)event).setFeatureValues( + if (event instanceof UsageFeaturesCollection) { + ((UsageFeaturesCollection)event).setFeatureValues( repository.getFeatureKeys().stream().map((k) -> - new FeatureHubAnalyticsValue(repository.getFeat(k))).collect(Collectors.toList())); + new FeatureHubUsageValue(repository.getFeat(k))).collect(Collectors.toList())); } - if (event instanceof AnalyticsFeaturesCollectionContext) { - ((AnalyticsFeaturesCollectionContext)event).setAttributes(attributes); + if (event instanceof UsageFeaturesCollectionContext) { + ((UsageFeaturesCollectionContext)event).setAttributes(attributes); } return event; } @Override - public void recordAnalyticsEvent(@NotNull AnalyticsEvent event) { + public void recordAnalyticsEvent(@NotNull UsageEvent event) { repository.recordAnalyticsEvent(fillAnalyticsCollection(event)); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 566660a..400204e 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -1,6 +1,6 @@ package io.featurehub.client; -import io.featurehub.client.analytics.AnalyticsEvent; +import io.featurehub.client.usage.UsageEvent; import io.featurehub.sse.model.StrategyAttributeCountryName; import io.featurehub.sse.model.StrategyAttributeDeviceName; import io.featurehub.sse.model.StrategyAttributePlatformName; @@ -68,7 +68,7 @@ public interface ClientContext { * the current context if possible and add it to the analytics event. * @param event */ - void recordAnalyticsEvent(@NotNull AnalyticsEvent event); + void recordAnalyticsEvent(@NotNull UsageEvent event); void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 000d255..3ec84bf 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -4,9 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import io.featurehub.client.analytics.AnalyticsEvent; -import io.featurehub.client.analytics.AnalyticsProvider; -import io.featurehub.client.analytics.FeatureHubAnalyticsValue; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; +import io.featurehub.client.usage.FeatureHubUsageValue; import io.featurehub.sse.model.FeatureRolloutStrategy; import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.SSEResultState; @@ -24,6 +24,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -56,8 +57,8 @@ public void cancel() { private final List> newStateAvailableHandlers = new ArrayList<>(); private final List>> featureUpdateHandlers = new ArrayList<>(); private final List featureValueInterceptors = new ArrayList<>(); - private final List> analyticsHandlers = new ArrayList<>(); - private AnalyticsProvider analyticsProvider = new AnalyticsProvider.DefaultAnalyticsProvider(); + private final List> analyticsHandlers = new ArrayList<>(); + private UsageProvider usageProvider = new UsageProvider.DefaultUsageProvider(); private ObjectMapper jsonConfigObjectMapper; private final ApplyFeature applyFeature; @@ -122,8 +123,8 @@ public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapp } @Override - public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { - this.analyticsProvider = provider; + public void registerAnalyticsProvider(@NotNull UsageProvider provider) { + this.usageProvider = provider; } @Override @@ -137,7 +138,7 @@ public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { } @Override - public @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback) { + public @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback) { return new Callback<>(analyticsHandlers, callback); } @@ -192,6 +193,11 @@ public void execute(@NotNull Runnable command) { executor.execute(command); } + @Override + public Executor getExecutor() { + return executor; + } + @Override public @NotNull ObjectMapper getJsonObjectMapper() { return jsonConfigObjectMapper; @@ -325,7 +331,7 @@ private void broadcastFeatureUpdatedListeners(@NotNull FeatureState fs) { } @Override - public void recordAnalyticsEvent(@NotNull AnalyticsEvent event) { + public void recordAnalyticsEvent(@NotNull UsageEvent event) { analyticsHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); } @@ -339,7 +345,7 @@ public void repositoryEmpty() { public void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, @Nullable Map> attributes, String analyticsUserKey) { - recordAnalyticsEvent(analyticsProvider.createAnalyticsFeature(new FeatureHubAnalyticsValue(id.toString(), key, + recordAnalyticsEvent(usageProvider.createUsageFeature(new FeatureHubUsageValue(id.toString(), key, value, valueType ), attributes, analyticsUserKey)); } @@ -363,7 +369,7 @@ public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull } @Override - public @NotNull AnalyticsProvider getAnalyticsProvider() { - return analyticsProvider; + public @NotNull UsageProvider getAnalyticsProvider() { + return usageProvider; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 2f6a63e..7b52902 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -1,7 +1,7 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.client.usage.UsageProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -37,6 +37,9 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @Nullable TestApi testApi; + private EdgeType edgeType = EdgeType.REST_TIMEOUT; + private int timeout; + public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { this(edgeUrl, Collections.singletonList(apiKey)); } @@ -126,10 +129,14 @@ protected Supplier loadEdgeService(@NotNull InternalFeatureReposit ServiceLoader loader = ServiceLoader.load(FeatureHubClientFactory.class); for (FeatureHubClientFactory f : loader) { - Supplier edgeService = f.createSSEEdge(this, repository); - if (edgeService != null) { - edgeServiceSupplier = edgeService; - break; + if (edgeType == EdgeType.STREAMING) { + edgeServiceSupplier = f.createSSEEdge(this, repository); + } else if (edgeType == EdgeType.REST_TIMEOUT) { + edgeServiceSupplier = f.createRestEdge(this, repository, timeout, false); + } else { + edgeServiceSupplier = () -> new PollingDelegateEdgeService( + f.createRestEdge(this, repository, timeout, true).get(), + repository); } } } @@ -174,7 +181,7 @@ public void registerValueInterceptor(boolean allowLockOverride, @NotNull Feature } @Override - public void registerAnalyticsProvider(@NotNull AnalyticsProvider provider) { + public void registerAnalyticsProvider(@NotNull UsageProvider provider) { getRepository().registerAnalyticsProvider(provider); } @@ -205,48 +212,40 @@ public void close() { @Override public FeatureHubConfig streaming() { + edgeType = EdgeType.STREAMING; + timeout = 0; return this; } - private class RestConfigImpl implements RestConfig { - protected boolean useUseBased = false; - protected boolean enabled = false; - protected int interval = 180; - - private final EdgeFeatureHubConfig config; - - private RestConfigImpl(EdgeFeatureHubConfig config) { - this.config = config; - } - - @Override - public FeatureHubConfig interval(int timeoutSeconds) { - this.interval = timeoutSeconds; - enabled = true; - return config; - } - - @Override - public FeatureHubConfig interval() { - this.interval = 0; - enabled = true; - return config; - } + private enum EdgeType { + STREAMING, REST_TIMEOUT, REST_POLL + } - @Override - public FeatureHubConfig minUpdateInterval(int timeoutSeconds) { - useUseBased = true; - enabled = true; - interval = timeoutSeconds; - return config; - } + @Override + public FeatureHubConfig restPoll() { + this.timeout = 180; + edgeType = EdgeType.REST_POLL; + return this; } - private final RestConfigImpl restConfig = new RestConfigImpl(this); + @Override + public FeatureHubConfig restPoll(int intervalInSeconds) { + this.timeout = intervalInSeconds; + edgeType = EdgeType.REST_POLL; + return this; + } @Override - public RestConfig rest() { - return restConfig; + public FeatureHubConfig rest(int cacheTimeoutInSeconds) { + this.timeout = cacheTimeoutInSeconds; + edgeType = EdgeType.REST_TIMEOUT; + return this; } + @Override + public FeatureHubConfig rest() { + this.timeout = 180; + edgeType = EdgeType.REST_TIMEOUT; + return this; + } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java b/client-java-core/src/main/java/io/featurehub/client/EdgeService.java index 5af1f77..5964baf 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeService.java @@ -40,4 +40,11 @@ public interface EdgeService { * REST it will be the response. */ Future poll(); + + /** + * Only used for REST interfacces, 0 otherwise, and 0 for one-shot calls. + * + * @return - current interval which can change based on data sent from server. + */ + long currentInterval(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java index b8f2ea9..e050607 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java @@ -17,9 +17,9 @@ public interface FeatureHubClientFactory { Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, - int timeoutInSeconds); + int timeoutInSeconds, boolean amPollingDelegate); - Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds); + Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate); Supplier createTestApi(@NotNull FeatureHubConfig config); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 25553ec..c9907e4 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,7 +1,7 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.client.usage.UsageProvider; import org.jetbrains.annotations.NotNull; import java.util.Collection; @@ -69,7 +69,7 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * created on analytical events * @param provider */ - void registerAnalyticsProvider(@NotNull AnalyticsProvider provider); + void registerAnalyticsProvider(@NotNull UsageProvider provider); /** * Allows you to query the state of the repository's readyness - such as in a heartbeat API @@ -92,26 +92,15 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { FeatureHubConfig streaming(); - interface RestConfig { - /** - * creates a java Timer and will poll every X seconds. Also polls if context changes for - * server evaluated context. - * @param timeoutSeconds - the timeout between completed requests in seconds - */ - FeatureHubConfig interval(int timeoutSeconds); - - /** - * no interval, just polls once (if it hasn't polled already) and returns. If using - * server evaluated context, it will poll once if context changes and that is all. - */ - FeatureHubConfig interval(); - - /** - * @param timeoutSeconds - no active polling, will poll if feature requested after this period of time or if - * server evaluated and context changes. - */ - FeatureHubConfig minUpdateInterval(int timeoutSeconds); - } + /** + * interval defaults to 180 seconds + */ + FeatureHubConfig restPoll(); + FeatureHubConfig restPoll(int intervalInSeconds); + FeatureHubConfig rest(int cacheTimeoutInSeconds); - RestConfig rest(); + /** + * cache timeout defaults to 180 seconds + */ + FeatureHubConfig rest(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java index b42bbe6..35fce9b 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -1,8 +1,8 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.analytics.AnalyticsEvent; -import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; import org.jetbrains.annotations.NotNull; import java.util.List; @@ -33,11 +33,11 @@ public interface FeatureRepository { * @return the instance of the repo for chaining */ @NotNull FeatureRepository registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); - void registerAnalyticsProvider(@NotNull AnalyticsProvider provider); + void registerAnalyticsProvider(@NotNull UsageProvider provider); @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback); @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback); - @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback); + @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback); @NotNull Readiness getReadiness(); diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index 7d259b8..4e0e1ec 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -135,7 +135,7 @@ private Object getValue(@Nullable FeatureValueType type) { return (feature.fs == null) ? null : feature.fs.getType(); } - public Object getAnalyticsFreeValue() { + public Object getUsageFreeValue() { return internalGetValue(null, false); } diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java index 72b4f45..75d588a 100644 --- a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -1,8 +1,8 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.analytics.AnalyticsEvent; -import io.featurehub.client.analytics.AnalyticsProvider; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; import io.featurehub.sse.model.FeatureRolloutStrategy; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.FeatureValueType; @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executor; public interface InternalFeatureRepository extends FeatureRepository { @@ -47,6 +48,7 @@ public interface InternalFeatureRepository extends FeatureRepository { @NotNull ClientContext cac); void execute(@NotNull Runnable command); + Executor getExecutor(); @NotNull ObjectMapper getJsonObjectMapper(); @@ -63,7 +65,7 @@ public interface InternalFeatureRepository extends FeatureRepository { @NotNull FeatureStateBase getFeat(@NotNull Feature key); @NotNull FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz); - void recordAnalyticsEvent(@NotNull AnalyticsEvent event); + void recordAnalyticsEvent(@NotNull UsageEvent event); /** * Repository is empty, there are no features but repository is ready. @@ -74,5 +76,5 @@ void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType value @Nullable Map> attributes, @Nullable String analyticsUserKey); - @NotNull AnalyticsProvider getAnalyticsProvider(); + @NotNull UsageProvider getAnalyticsProvider(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java b/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java new file mode 100644 index 0000000..c53c360 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java @@ -0,0 +1,88 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public class PollingDelegateEdgeService implements EdgeService { + @NotNull private final EdgeService edgeService; + @NotNull private final InternalFeatureRepository repo; + @NotNull private final Timer timer; + + public PollingDelegateEdgeService(@NotNull EdgeService edgeService, @NotNull InternalFeatureRepository repo) { + this.edgeService = edgeService; + this.repo = repo; + timer = new Timer(); + } + + private void loop() { + if (!edgeService.isStopped()) { + timer.schedule(new TimerTask() { + @Override + public void run() { + poll(); + } + }, edgeService.currentInterval() * 1000); + } + } + + @Override + public @NotNull Future contextChange(@Nullable String newHeader, String contextSha) { + timer.cancel(); + return CompletableFuture.supplyAsync(() -> { + try { + Readiness r = edgeService.contextChange(newHeader, contextSha).get(); + loop(); + return r; + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + }, repo.getExecutor()); + } + + @Override + public boolean isClientEvaluation() { + return edgeService.isClientEvaluation(); + } + + @Override + public boolean isStopped() { + return edgeService.isStopped(); + } + + @Override + public void close() { + timer.cancel(); + edgeService.close(); + } + + @Override + public @NotNull FeatureHubConfig getConfig() { + return edgeService.getConfig(); + } + + @Override + public Future poll() { + timer.cancel(); + return CompletableFuture.supplyAsync(() -> { + try { + Readiness r = edgeService.poll().get(); + loop(); + return r; + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + }, repo.getExecutor()); + } + + @Override + public long currentInterval() { + return edgeService.currentInterval(); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java deleted file mode 100644 index 5a13986..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsAdapter.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.featurehub.client.analytics; - -import io.featurehub.client.FeatureRepository; -import io.featurehub.client.RepositoryEventHandler; - -import java.util.LinkedList; -import java.util.List; - -public class AnalyticsAdapter { - private List plugins = new LinkedList<>(); - final FeatureRepository repository; - final RepositoryEventHandler analyticsHandlerSub; - - public AnalyticsAdapter(FeatureRepository repo) { - this.repository = repo; - analyticsHandlerSub = repo.registerAnalyticsStream(this::process); - } - - public void close() { - analyticsHandlerSub.cancel(); - } - - public void process(AnalyticsEvent event) { - plugins.forEach((p) -> p.send(event)); - } -} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java b/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java deleted file mode 100644 index 8ef31dc..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsProvider.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.featurehub.client.analytics; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.List; -import java.util.Map; - -public interface AnalyticsProvider { - default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, - @NotNull Map> attributes) { - return new AnalyticsFeature(feature, attributes, null); - } - - default AnalyticsFeature createAnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, - @Nullable Map> attributes, - @Nullable String userKey) { - return new AnalyticsFeature(feature, attributes, userKey); - } - - default AnalyticsFeaturesCollection createAnalyticsCollectionEvent() { - return new AnalyticsFeaturesCollection(); - } - - default AnalyticsFeaturesCollectionContext createAnalyticsContextCollectionEvent() { - return new AnalyticsFeaturesCollectionContext(); - } - - class DefaultAnalyticsProvider implements AnalyticsProvider {} -} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java b/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java similarity index 67% rename from client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java rename to client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java index 070baa2..7260c6c 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/FeatureHubAnalyticsValue.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java @@ -1,11 +1,11 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import io.featurehub.client.FeatureStateBase; import io.featurehub.sse.model.FeatureValueType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public class FeatureHubAnalyticsValue { +public class FeatureHubUsageValue { @NotNull final String id; @NotNull @@ -32,16 +32,16 @@ static String convert(@Nullable Object value, @Nullable FeatureValueType type) { return null; } - public FeatureHubAnalyticsValue(@NotNull String id, @NotNull String key, @Nullable Object value, - @NotNull FeatureValueType type) { + public FeatureHubUsageValue(@NotNull String id, @NotNull String key, @Nullable Object value, + @NotNull FeatureValueType type) { this.id = id; this.key = key; this.value = convert(value, type); } - public FeatureHubAnalyticsValue(@NotNull FeatureStateBase holder) { + public FeatureHubUsageValue(@NotNull FeatureStateBase holder) { this.id = holder.getId(); this.key = holder.getKey(); - this.value = convert(holder.getAnalyticsFreeValue(), holder.getType()); + this.value = convert(holder.getUsageFreeValue(), holder.getType()); } } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java new file mode 100644 index 0000000..4551dd0 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java @@ -0,0 +1,26 @@ +package io.featurehub.client.usage; + +import io.featurehub.client.FeatureRepository; +import io.featurehub.client.RepositoryEventHandler; + +import java.util.LinkedList; +import java.util.List; + +public class UsageAdapter { + private List plugins = new LinkedList<>(); + final FeatureRepository repository; + final RepositoryEventHandler usageHandlerSub; + + public UsageAdapter(FeatureRepository repo) { + this.repository = repo; + usageHandlerSub = repo.registerAnalyticsStream(this::process); + } + + public void close() { + usageHandlerSub.cancel(); + } + + public void process(UsageEvent event) { + plugins.forEach((p) -> p.send(event)); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java similarity index 73% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java index 786f0d8..559f9fe 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEvent.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java @@ -1,4 +1,4 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -6,17 +6,17 @@ import java.util.HashMap; import java.util.Map; -public class AnalyticsEvent { +public class UsageEvent { @Nullable private String userKey; @NotNull private Map additionalParams = new HashMap<>(); - public AnalyticsEvent(@Nullable String userKey) { + public UsageEvent(@Nullable String userKey) { this.userKey = userKey; } - public AnalyticsEvent() { + public UsageEvent() { } public void setUserKey(String userKey) { @@ -27,7 +27,7 @@ public void setAdditionalParams(@NotNull Map additionalParams) { this.additionalParams = additionalParams; } - public AnalyticsEvent(@Nullable String userKey, @Nullable Map additionalParams) { + public UsageEvent(@Nullable String userKey, @Nullable Map additionalParams) { this.userKey = userKey; if (additionalParams != null) { this.additionalParams = additionalParams; diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java similarity index 50% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java index 3c60d35..a421ea4 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsEventName.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java @@ -1,7 +1,7 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import org.jetbrains.annotations.NotNull; -public interface AnalyticsEventName { +public interface UsageEventName { @NotNull String getEventName(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java similarity index 68% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java index e5dbd78..7a52f91 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeature.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java @@ -1,4 +1,4 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -8,13 +8,13 @@ import java.util.List; import java.util.Map; -public class AnalyticsFeature extends AnalyticsEvent implements AnalyticsEventName { +public class UsageFeature extends UsageEvent implements UsageEventName { @Nullable final Map> attributes; - @NotNull final FeatureHubAnalyticsValue feature; + @NotNull final FeatureHubUsageValue feature; - public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @Nullable Map> attributes, - @Nullable String userKey) { + public UsageFeature(@NotNull FeatureHubUsageValue feature, @Nullable Map> attributes, + @Nullable String userKey) { this.attributes = attributes; this.feature = feature; } @@ -23,7 +23,7 @@ public AnalyticsFeature(@NotNull FeatureHubAnalyticsValue feature, @Nullable Map return attributes; } - @NotNull public FeatureHubAnalyticsValue getFeature() { + @NotNull public FeatureHubUsageValue getFeature() { return feature; } diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java similarity index 57% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java index 70106d6..11d58e1 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollection.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java @@ -1,4 +1,4 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -9,18 +9,18 @@ import java.util.List; import java.util.Map; -public class AnalyticsFeaturesCollection extends AnalyticsEvent { - @NotNull List featureValues = new ArrayList<>(); +public class UsageFeaturesCollection extends UsageEvent { + @NotNull List featureValues = new ArrayList<>(); - public AnalyticsFeaturesCollection(@Nullable String userKey, @Nullable Map additionalParams) { + public UsageFeaturesCollection(@Nullable String userKey, @Nullable Map additionalParams) { super(userKey, additionalParams); } - public void setFeatureValues(List featureValues) { + public void setFeatureValues(List featureValues) { this.featureValues = featureValues; } - public AnalyticsFeaturesCollection() {} + public UsageFeaturesCollection() {} void ready() {} diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java similarity index 68% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java index 7c04aaa..5b9c8fb 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsFeaturesCollectionContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java @@ -1,4 +1,4 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -8,15 +8,15 @@ import java.util.List; import java.util.Map; -public class AnalyticsFeaturesCollectionContext extends AnalyticsFeaturesCollection { +public class UsageFeaturesCollectionContext extends UsageFeaturesCollection { @NotNull Map> attributes = new HashMap<>(); - public AnalyticsFeaturesCollectionContext(@Nullable String userKey, @Nullable Map additionalParams) { + public UsageFeaturesCollectionContext(@Nullable String userKey, @Nullable Map additionalParams) { super(userKey, additionalParams); } - public AnalyticsFeaturesCollectionContext() { + public UsageFeaturesCollectionContext() { super(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java similarity index 57% rename from client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java index a9522ba..7a1b207 100644 --- a/client-java-core/src/main/java/io/featurehub/client/analytics/AnalyticsPlugin.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java @@ -1,15 +1,15 @@ -package io.featurehub.client.analytics; +package io.featurehub.client.usage; import java.util.HashMap; import java.util.Map; -abstract public class AnalyticsPlugin { +abstract public class UsagePlugin { protected final Map defaultEventParams = new HashMap<>(); protected final boolean unnamedBecomeEventParameters; - public AnalyticsPlugin(boolean unnamedBecomeEventParameters) { + public UsagePlugin(boolean unnamedBecomeEventParameters) { this.unnamedBecomeEventParameters = unnamedBecomeEventParameters; } - abstract void send(AnalyticsEvent event); + abstract void send(UsageEvent event); } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java new file mode 100644 index 0000000..8b715e0 --- /dev/null +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java @@ -0,0 +1,30 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public interface UsageProvider { + default UsageFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @NotNull Map> attributes) { + return new UsageFeature(feature, attributes, null); + } + + default UsageFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @Nullable Map> attributes, + @Nullable String userKey) { + return new UsageFeature(feature, attributes, userKey); + } + + default UsageFeaturesCollection createUsageCollectionEvent() { + return new UsageFeaturesCollection(); + } + + default UsageFeaturesCollectionContext createUsageContextCollectionEvent() { + return new UsageFeaturesCollectionContext(); + } + + class DefaultUsageProvider implements UsageProvider {} +} diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index 97655ed..dd01bec 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -1,13 +1,11 @@ package io.featurehub.client import com.fasterxml.jackson.databind.ObjectMapper -import io.featurehub.client.analytics.AnalyticsProvider +import io.featurehub.client.usage.UsageProvider import spock.lang.Specification import java.util.concurrent.CompletableFuture -import java.util.concurrent.Future import java.util.function.Consumer -import java.util.function.Supplier class EdgeFeatureHubConfigSpec extends Specification { FeatureHubConfig config @@ -51,7 +49,7 @@ class EdgeFeatureHubConfigSpec extends Specification { def om = new ObjectMapper() Consumer readynessListener = Mock(Consumer) def featureValueOverride = Mock(FeatureValueInterceptor) - def analyticsProvider = Mock(AnalyticsProvider) + def analyticsProvider = Mock(UsageProvider) when: "i set all the passthrough settings" config.setJsonConfigObjectMapper(om) config.addReadinessListener(readynessListener) diff --git a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy index dca66d5..bcdc1be 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy @@ -45,6 +45,11 @@ class FeatureHubTestClientFactory implements FeatureHubClientFactory { Future poll() { return null } + + @Override + long currentInterval() { + return 0 + } } static FakeEdgeService fake @@ -61,12 +66,12 @@ class FeatureHubTestClientFactory implements FeatureHubClientFactory { } @Override - Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { + Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPolling) { return null } @Override - Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { + Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPolling) { return null } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 87cdf01..5add294 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -25,13 +25,13 @@ public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { @Override public Supplier createRestEdge(@NotNull FeatureHubConfig config, - @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { - return () -> new RestClient(repository, null, config, timeoutInSeconds); + @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { + return () -> new RestClient(repository, null, config, timeoutInSeconds, amPollingDelegate); } @Override - public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { - return createRestEdge(config, null, timeoutInSeconds); + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { + return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java index 16a807e..8bffb6d 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -41,6 +41,7 @@ public class RestClient implements EdgeService { @Nullable private String etag = null; private long pollingInterval; + private final boolean amPollingDelegate; private long whenPollingCacheExpires; private final boolean clientSideEvaluation; @@ -57,11 +58,13 @@ public class RestClient implements EdgeService { public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, - int timeoutInSeconds) { + int timeoutInSeconds, + boolean amPollingDelegate) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } + this.amPollingDelegate = amPollingDelegate; this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; @@ -86,15 +89,15 @@ public RestClient(@Nullable InternalFeatureRepository repository, public RestClient(@NotNull FeatureHubConfig config, int timeoutInSeconds) { - this(null, null, config, timeoutInSeconds); + this(null, null, config, timeoutInSeconds, false); } public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { - this(repository, null, config, 180); + this(repository, null, config, 180, false); } public RestClient(@NotNull FeatureHubConfig config) { - this(null, null, config, 180); + this(null, null, config, 180, false); } private boolean busy = false; @@ -106,7 +109,8 @@ protected Long now() { } public boolean checkForUpdates(@Nullable CompletableFuture change) { - final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + final boolean breakCache = + amPollingDelegate || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = !busy && !stopped && breakCache; headerChanged = false; @@ -134,6 +138,8 @@ public boolean checkForUpdates(@Nullable CompletableFuture change) { processResponse(response); } catch (Exception e) { processFailure(e); + } finally { + busy = false; } } @@ -287,6 +293,11 @@ public Future poll() { return change; } + @Override + public long currentInterval() { + return pollingInterval; + } + public long getWhenPollingCacheExpires() { return whenPollingCacheExpires; } diff --git a/client-java-jersey3/pom.xml b/client-java-jersey3/pom.xml index a1691d4..a0e8bc1 100644 --- a/client-java-jersey3/pom.xml +++ b/client-java-jersey3/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-jersey3 - 2.1-SNAPSHOT + 3.1-SNAPSHOT java-client-jersey3 diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 87cdf01..5add294 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -25,13 +25,13 @@ public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { @Override public Supplier createRestEdge(@NotNull FeatureHubConfig config, - @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { - return () -> new RestClient(repository, null, config, timeoutInSeconds); + @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { + return () -> new RestClient(repository, null, config, timeoutInSeconds, amPollingDelegate); } @Override - public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { - return createRestEdge(config, null, timeoutInSeconds); + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { + return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java index c70466a..638f8a2 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -44,6 +44,7 @@ public class RestClient implements EdgeService { private long whenPollingCacheExpires; private final boolean clientSideEvaluation; + private final boolean amPollingDelegate; @NotNull private final FeatureHubConfig config; /** @@ -57,11 +58,12 @@ public class RestClient implements EdgeService { public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, - int timeoutInSeconds) { + int timeoutInSeconds, boolean amPollingDelegate) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } + this.amPollingDelegate = amPollingDelegate; this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; @@ -84,19 +86,6 @@ public RestClient(@Nullable InternalFeatureRepository repository, return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); } - public RestClient(@NotNull FeatureHubConfig config, - int timeoutInSeconds) { - this(null, null, config, timeoutInSeconds); - } - - public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { - this(repository, null, config, 180); - } - - public RestClient(@NotNull FeatureHubConfig config) { - this(null, null, config, 180); - } - private boolean busy = false; private boolean headerChanged = false; private List> waitingClients = new ArrayList<>(); @@ -106,7 +95,8 @@ protected Long now() { } public boolean checkForUpdates(@Nullable CompletableFuture change) { - final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + final boolean breakCache = + amPollingDelegate || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = !busy && !stopped && breakCache; headerChanged = false; @@ -287,6 +277,11 @@ public Future poll() { return change; } + @Override + public long currentInterval() { + return pollingInterval; + } + public long getWhenPollingCacheExpires() { return whenPollingCacheExpires; } diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java index caca769..13057e2 100644 --- a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java @@ -23,13 +23,13 @@ public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { } @Override - public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds) { - return () -> new RestClient(repository, config, timeoutInSeconds); + public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { + return () -> new RestClient(repository, config, timeoutInSeconds, amPollingDelegate); } @Override - public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds) { - return createRestEdge(config, null, timeoutInSeconds); + public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { + return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index 7314a95..7fdd3db 100644 --- a/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -51,15 +51,17 @@ public class RestClient implements EdgeService { private long whenPollingCacheExpires; private final boolean clientSideEvaluation; + private final boolean amPollingDelegate; @NotNull private final FeatureHubConfig config; @NotNull private final ExecutorService executorService; public RestClient(@Nullable InternalFeatureRepository repository, - @NotNull FeatureHubConfig config, int timeoutInSeconds) { + @NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } + this.amPollingDelegate = amPollingDelegate; this.repository = repository; this.client = new OkHttpClient(); this.config = config; @@ -85,15 +87,11 @@ protected ExecutorService makeExecutorService() { public RestClient(@NotNull FeatureHubConfig config, int timeoutInSeconds) { - this(null, config, timeoutInSeconds); - } - - public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { - this(repository, config, 180); + this(null, config, timeoutInSeconds, false); } public RestClient(@NotNull FeatureHubConfig config) { - this(null, config, 180); + this(null, config, 180, false); } private final static TypeReference> ref = new TypeReference>(){}; @@ -106,7 +104,8 @@ protected Long now() { } public boolean checkForUpdates(@Nullable CompletableFuture change) { - final boolean breakCache = pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + final boolean breakCache = + amPollingDelegate || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = makeRequests && !busy && !stopped && breakCache; headerChanged = false; @@ -326,6 +325,11 @@ public Future poll() { return change; } + @Override + public long currentInterval() { + return pollingInterval; + } + public long getWhenPollingCacheExpires() { return whenPollingCacheExpires; } diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java index 265fb5f..965dacd 100644 --- a/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java @@ -59,6 +59,11 @@ public Future poll() { return CompletableFuture.completedFuture(repository.getReadiness()); } + @Override + public long currentInterval() { + return 0; + } + private boolean connectionSaidBye; private void initEventSource() { diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 1dcfafd..b086c38 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -37,7 +37,6 @@ sdk-composite-logging [1.1, 2) - diff --git a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java index 09c1103..0276315 100644 --- a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java +++ b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java @@ -1,12 +1,10 @@ package io.featurehub.migrationcheck; -import io.featurehub.client.edge.EdgeRetryService; -import io.featurehub.client.edge.EdgeRetryer; -import io.featurehub.okhttp.RestClient; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.Readiness; -import io.featurehub.edge.sse.SSEClientFactory; +import io.featurehub.client.edge.EdgeRetryer; +import io.featurehub.okhttp.RestClient; import io.featurehub.okhttp.SSEClient; import org.jetbrains.annotations.NotNull; @@ -30,7 +28,7 @@ public static void main(String[] args) throws ExecutionException, InterruptedExc RestClient client = new RestClient(config); // and now we block, waiting for it to connect and tell us if it is ready or not - if (client.contextChange(null, "0").get() == Readiness.Ready) { + if (client.poll().get() == Readiness.Ready) { client.close(); // make sure you close it, it has a background thread // once it is ready, we tell the config to use SSE as its connector, and start the config going. config.setEdgeService(() -> new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index f81c0ce..9e8974f 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -23,6 +23,8 @@ public class FeatureHubSource implements FeatureHub { String analyticsKey = ""; @ConfigKey("feature-service.sdk") String clientSdk = "jersey3"; + @ConfigKey("feature-service.client") + String client = "sse"; // sse, rest, rest-poll @ConfigKey("feature-service.poll-interval") Integer pollInterval = 1000; // in milliseconds diff --git a/examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java b/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java similarity index 69% rename from examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java rename to examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java index 5fb6cbf..e202fe2 100644 --- a/examples/todo-java/src/main/java/todo/backend/AnalyticsRequestMeasurement.java +++ b/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java @@ -1,16 +1,16 @@ package todo.backend; -import io.featurehub.client.analytics.AnalyticsFeaturesCollection; +import io.featurehub.client.usage.UsageFeaturesCollection; import org.jetbrains.annotations.NotNull; import java.util.LinkedHashMap; import java.util.Map; -public class AnalyticsRequestMeasurement extends AnalyticsFeaturesCollection { +public class UsageRequestMeasurement extends UsageFeaturesCollection { private final long duration; @NotNull private final String url; - public AnalyticsRequestMeasurement(long duration, @NotNull String url) { + public UsageRequestMeasurement(long duration, @NotNull String url) { super(null, null); this.duration = duration; diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index 4f105bf..e49433a 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -7,7 +7,7 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; -import todo.backend.AnalyticsRequestMeasurement; +import todo.backend.UsageRequestMeasurement; import java.io.IOException; import java.util.List; @@ -35,7 +35,7 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); if (matchedURIs.size() > 0) { - ThreadLocalContext.context().recordAnalyticsEvent(new AnalyticsRequestMeasurement(duration, matchedURIs.get(0))); + ThreadLocalContext.context().recordAnalyticsEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); } } } From 3c1a793560c31d23cab61720891552e4ca27590a Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Wed, 26 Jul 2023 19:44:29 +1200 Subject: [PATCH 05/22] more implementation details --- .../featurehub/client/BaseClientContext.java | 18 ++++++++--------- .../io/featurehub/client/ClientContext.java | 4 ++-- .../client/ClientFeatureRepository.java | 20 +++++++++---------- .../client/EdgeFeatureHubConfig.java | 4 ++-- .../featurehub/client/FeatureHubConfig.java | 4 ++-- .../featurehub/client/FeatureRepository.java | 4 ++-- .../featurehub/client/FeatureStateBase.java | 2 +- .../client/InternalFeatureRepository.java | 6 +++--- .../featurehub/client/usage/UsageAdapter.java | 2 +- .../client/EdgeFeatureHubConfigSpec.groovy | 4 ++-- .../featurehub/client/RepositorySpec.groovy | 2 +- .../resources/FeatureAnalyticsFilter.java | 2 +- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index a2bf6c1..f961fd8 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -115,7 +115,7 @@ public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, repository.execute(() -> { try { - repository.used(key, id, valueType, val, attributes, analyticsUserKey()); + repository.used(key, id, valueType, val, attributes, usageUserKey()); edgeService.poll().get(); } catch (Exception e) { log.error("Failed to poll", e); @@ -123,23 +123,23 @@ public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, }); } - @Nullable String analyticsUserKey() { + @Nullable String usageUserKey() { return getAttr("session", getAttr("userkey")); } protected void recordFeatureChangedForUser(FeatureStateBase feature) { - repository.recordAnalyticsEvent(new UsageFeature( + repository.recordUsageEvent(new UsageFeature( new FeatureHubUsageValue(feature.withContext(this)), attributes, - analyticsUserKey())); + usageUserKey())); } protected void recordRelativeValuesForUser() { - repository.recordAnalyticsEvent(fillAnalyticsCollection(repository.getAnalyticsProvider().createUsageCollectionEvent())); + repository.recordUsageEvent(fillUsageCollection(repository.getUsageProvider().createUsageCollectionEvent())); } - protected UsageEvent fillAnalyticsCollection(UsageEvent event) { - event.setUserKey(analyticsUserKey()); + protected UsageEvent fillUsageCollection(UsageEvent event) { + event.setUserKey(usageUserKey()); if (event instanceof UsageFeaturesCollection) { ((UsageFeaturesCollection)event).setFeatureValues( @@ -155,8 +155,8 @@ protected UsageEvent fillAnalyticsCollection(UsageEvent event) { } @Override - public void recordAnalyticsEvent(@NotNull UsageEvent event) { - repository.recordAnalyticsEvent(fillAnalyticsCollection(event)); + public void recordUsageEvent(@NotNull UsageEvent event) { + repository.recordUsageEvent(fillUsageCollection(event)); } @Override diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 400204e..9c26c01 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -64,11 +64,11 @@ public interface ClientContext { boolean exists(Feature key); /** - * If you have a custom analytics event you wish to record, add it here. It will capture any associated data from + * If you have a custom usage event you wish to record, add it here. It will capture any associated data from * the current context if possible and add it to the analytics event. * @param event */ - void recordAnalyticsEvent(@NotNull UsageEvent event); + void recordUsageEvent(@NotNull UsageEvent event); void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 3ec84bf..daf6236 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -57,7 +57,7 @@ public void cancel() { private final List> newStateAvailableHandlers = new ArrayList<>(); private final List>> featureUpdateHandlers = new ArrayList<>(); private final List featureValueInterceptors = new ArrayList<>(); - private final List> analyticsHandlers = new ArrayList<>(); + private final List> usageHandlers = new ArrayList<>(); private UsageProvider usageProvider = new UsageProvider.DefaultUsageProvider(); private ObjectMapper jsonConfigObjectMapper; @@ -123,7 +123,7 @@ public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapp } @Override - public void registerAnalyticsProvider(@NotNull UsageProvider provider) { + public void registerUsageProvider(@NotNull UsageProvider provider) { this.usageProvider = provider; } @@ -138,8 +138,8 @@ public void registerAnalyticsProvider(@NotNull UsageProvider provider) { } @Override - public @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback) { - return new Callback<>(analyticsHandlers, callback); + public @NotNull RepositoryEventHandler registerUsageStream(@NotNull Consumer callback) { + return new Callback<>(usageHandlers, callback); } @Override @@ -331,8 +331,8 @@ private void broadcastFeatureUpdatedListeners(@NotNull FeatureState fs) { } @Override - public void recordAnalyticsEvent(@NotNull UsageEvent event) { - analyticsHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); + public void recordUsageEvent(@NotNull UsageEvent event) { + usageHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); } @Override @@ -344,10 +344,10 @@ public void repositoryEmpty() { @Override public void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, @Nullable Map> attributes, - String analyticsUserKey) { - recordAnalyticsEvent(usageProvider.createUsageFeature(new FeatureHubUsageValue(id.toString(), key, + String usageUserKey) { + recordUsageEvent(usageProvider.createUsageFeature(new FeatureHubUsageValue(id.toString(), key, value, valueType - ), attributes, analyticsUserKey)); + ), attributes, usageUserKey)); } @Override @@ -369,7 +369,7 @@ public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull } @Override - public @NotNull UsageProvider getAnalyticsProvider() { + public @NotNull UsageProvider getUsageProvider() { return usageProvider; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 7b52902..64e0d04 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -181,8 +181,8 @@ public void registerValueInterceptor(boolean allowLockOverride, @NotNull Feature } @Override - public void registerAnalyticsProvider(@NotNull UsageProvider provider) { - getRepository().registerAnalyticsProvider(provider); + public void registerUsageProvider(@NotNull UsageProvider provider) { + getRepository().registerUsageProvider(provider); } @Override diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index c9907e4..6211f74 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -65,11 +65,11 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { void registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor); /** - * Allows the user to register a new analytics provider that determines what internal classes are + * Allows the user to register a new usage provider that determines what internal classes are * created on analytical events * @param provider */ - void registerAnalyticsProvider(@NotNull UsageProvider provider); + void registerUsageProvider(@NotNull UsageProvider provider); /** * Allows you to query the state of the repository's readyness - such as in a heartbeat API diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java index 35fce9b..eae4550 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -33,11 +33,11 @@ public interface FeatureRepository { * @return the instance of the repo for chaining */ @NotNull FeatureRepository registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); - void registerAnalyticsProvider(@NotNull UsageProvider provider); + void registerUsageProvider(@NotNull UsageProvider provider); @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback); @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback); - @NotNull RepositoryEventHandler registerAnalyticsStream(@NotNull Consumer callback); + @NotNull RepositoryEventHandler registerUsageStream(@NotNull Consumer callback); @NotNull Readiness getReadiness(); diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index 4e0e1ec..e4f15f9 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -295,7 +295,7 @@ protected FeatureState copy() { return _copy(); } - protected FeatureState analyticsCopy() { + protected FeatureState usageCopy() { return new FeatureStateBase(repository, feature.key, feature.fs); } diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java index 75d588a..177e474 100644 --- a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -65,7 +65,7 @@ public interface InternalFeatureRepository extends FeatureRepository { @NotNull FeatureStateBase getFeat(@NotNull Feature key); @NotNull FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz); - void recordAnalyticsEvent(@NotNull UsageEvent event); + void recordUsageEvent(@NotNull UsageEvent event); /** * Repository is empty, there are no features but repository is ready. @@ -74,7 +74,7 @@ public interface InternalFeatureRepository extends FeatureRepository { void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, @Nullable Map> attributes, - @Nullable String analyticsUserKey); + @Nullable String usageUserKey); - @NotNull UsageProvider getAnalyticsProvider(); + @NotNull UsageProvider getUsageProvider(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java index 4551dd0..db96f9e 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java @@ -13,7 +13,7 @@ public class UsageAdapter { public UsageAdapter(FeatureRepository repo) { this.repository = repo; - usageHandlerSub = repo.registerAnalyticsStream(this::process); + usageHandlerSub = repo.registerUsageStream(this::process); } public void close() { diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index dd01bec..2073701 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -54,12 +54,12 @@ class EdgeFeatureHubConfigSpec extends Specification { config.setJsonConfigObjectMapper(om) config.addReadinessListener(readynessListener) config.registerValueInterceptor(false, featureValueOverride) - config.registerAnalyticsProvider(analyticsProvider) + config.registerUsageProvider(analyticsProvider) then: 1 * repo.registerValueInterceptor(false, featureValueOverride) 1 * repo.addReadinessListener(readynessListener) >> Mock(RepositoryEventHandler) 1 * repo.setJsonConfigObjectMapper(om) - 1 * repo.registerAnalyticsProvider(analyticsProvider) + 1 * repo.registerUsageProvider(analyticsProvider) 0 * _ // nothing else } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy index de3f472..6016d4b 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -212,7 +212,7 @@ class RepositorySpec extends Specification { def listener = Mock(FeatureListener) updateListener.add(listener) feature.addListener(listener) - emptyFeatures.add(feature.analyticsCopy()) + emptyFeatures.add(feature.usageCopy()) } def featureCountAfterRequestingEmptyFeatures = repo.allFeatures.size() when: "i fill in the repo" diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index e49433a..ec67dc4 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -35,7 +35,7 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); if (matchedURIs.size() > 0) { - ThreadLocalContext.context().recordAnalyticsEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + ThreadLocalContext.context().recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); } } } From c0fc9aedf569a7af6b7963921cd3f9018cc637e9 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sat, 27 Jul 2024 18:40:28 +1200 Subject: [PATCH 06/22] add in support for Segment --- .github/workflows/java.yaml | 4 +- .../featurehub/client/BaseClientContext.java | 28 +++++--- .../io/featurehub/client/ClientContext.java | 3 +- .../client/EdgeFeatureHubConfig.java | 37 +++++++++-- .../featurehub/client/FeatureHubConfig.java | 21 +++--- .../featurehub/client/FeatureStateBase.java | 12 ++-- .../featurehub/client/usage/UsageAdapter.java | 6 +- .../featurehub/client/usage/UsageEvent.java | 12 +++- ...eature.java => UsageEventWithFeature.java} | 9 +-- .../client/usage/UsageFeaturesCollection.java | 2 +- .../usage/UsageFeaturesCollectionContext.java | 2 +- .../featurehub/client/usage/UsagePlugin.java | 12 ++-- .../client/usage/UsageProvider.java | 14 ++-- .../client/jersey/JerseySSEClient.java | 5 ++ .../client/jersey/JerseyClientSample.java | 19 +++--- examples/migration-check/pom.xml | 2 +- examples/todo-java/pom.xml | 10 ++- .../main/java/todo/backend/Application.java | 7 +- .../java/todo/backend/FeatureHubSource.java | 35 ++++------ .../todo/backend/UsageRequestMeasurement.java | 10 ++- .../resources/FeatureAnalyticsFilter.java | 10 ++- .../todo/backend/resources/TodoResource.java | 11 ++-- pom.xml | 1 + .../featurehub-segment-adapter/pom.xml | 65 +++++++++++++++++++ .../segment/SegmentUsageAdapter.java | 47 ++++++++++++++ usage-adapters/pom.xml | 39 +++++++++++ 26 files changed, 320 insertions(+), 103 deletions(-) rename client-java-core/src/main/java/io/featurehub/client/usage/{UsageFeature.java => UsageEventWithFeature.java} (74%) create mode 100644 usage-adapters/featurehub-segment-adapter/pom.xml create mode 100644 usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java create mode 100644 usage-adapters/pom.xml diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index c5b0a02..e3b774f 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 11 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '11' distribution: 'temurin' diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index f961fd8..9f485d6 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -1,7 +1,7 @@ package io.featurehub.client; import io.featurehub.client.usage.UsageEvent; -import io.featurehub.client.usage.UsageFeature; +import io.featurehub.client.usage.UsageEventWithFeature; import io.featurehub.client.usage.UsageFeaturesCollection; import io.featurehub.client.usage.UsageFeaturesCollectionContext; import io.featurehub.client.usage.FeatureHubUsageValue; @@ -14,12 +14,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.stream.Collectors; class BaseClientContext implements InternalContext { @@ -112,10 +110,18 @@ public ClientContext attrs(String name, List values) { @Override public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, @NotNull FeatureValueType valueType) { + final HashMap> attrCopy = new HashMap<>(attributes); + final String userKey = usageUserKey(); + + log.trace("recording usage for key: {}, id: {}, value: {}, valueType: {}, userKey: {}, attributes: {}", + key, id, val, valueType, userKey, attrCopy); repository.execute(() -> { try { - repository.used(key, id, valueType, val, attributes, usageUserKey()); + repository.used(key, id, valueType, val, attrCopy, userKey); + + // a feature has been evaluated, so this allows us to trigger to see if the + // time limit has expired on checking for a state update. edgeService.poll().get(); } catch (Exception e) { log.error("Failed to poll", e); @@ -123,13 +129,19 @@ public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, }); } + /** + * This uniquely identifies the user of this SDK if the SDK user has chosen to do so. It can be completely opaque + * (e.g. sha of a user's email). + * + * @return null or unique identifier + */ @Nullable String usageUserKey() { return getAttr("session", getAttr("userkey")); } protected void recordFeatureChangedForUser(FeatureStateBase feature) { - repository.recordUsageEvent(new UsageFeature( + repository.recordUsageEvent(new UsageEventWithFeature( new FeatureHubUsageValue(feature.withContext(this)), attributes, usageUserKey())); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 9c26c01..5535099 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -14,7 +14,7 @@ public interface ClientContext { String get(String key, String defaultValue); @NotNull List<@NotNull String> getAttrs(String key, @NotNull String defaultValue); - @Nullable List<@NotNull String> getAttrs(String key); + @Nullable List getAttrs(@NotNull String name); ClientContext userKey(String userKey); ClientContext sessionKey(String sessionKey); @@ -29,7 +29,6 @@ public interface ClientContext { @Nullable String getAttr(@NotNull String name); @Nullable String getAttr(@NotNull String name, @Nullable String defaultVal); - @Nullable List getAttrs(@NotNull String name); /** * Triggers the build and setting of this context. diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 64e0d04..1bf5df1 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -1,7 +1,9 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.usage.UsageProvider; +import io.featurehub.client.usage.UsageAdapter; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsagePlugin; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -37,6 +39,8 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @Nullable TestApi testApi; + @NotNull private final UsageAdapter usageAdapter; + private EdgeType edgeType = EdgeType.REST_TIMEOUT; private int timeout; @@ -64,6 +68,14 @@ public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull List apiKe this.edgeUrl = String.format("%s", edgeUrl); realtimeUrl = String.format("%s/features/%s", edgeUrl, apiKeys.get(0)); + + usageAdapter = new UsageAdapter(repository); + } + + @Override + public FeatureHubConfig registerUsagePlugin(@NotNull UsagePlugin plugin) { + usageAdapter.registerPlugin(plugin); + return this; } @Override @@ -149,8 +161,9 @@ protected Supplier loadEdgeService(@NotNull InternalFeatureReposit } @Override - public void setRepository(@NotNull FeatureRepository repository) { + public FeatureHubConfig setRepository(@NotNull FeatureRepository repository) { this.repository = (InternalFeatureRepository) repository; + return this; } @Override @@ -160,8 +173,14 @@ public FeatureRepository getRepository() { } @Override - public void setEdgeService(@NotNull Supplier edgeService) { + public @NotNull InternalFeatureRepository getInternalRepository() { + return repository; + } + + @Override + public FeatureHubConfig setEdgeService(@NotNull Supplier edgeService) { this.edgeServiceSupplier = edgeService; + return this; } @Override @@ -176,13 +195,16 @@ public Supplier getEdgeService() { } @Override - public void registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor) { + public FeatureHubConfig registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor) { getRepository().registerValueInterceptor(allowLockOverride, interceptor); + return this; } + @Override - public void registerUsageProvider(@NotNull UsageProvider provider) { - getRepository().registerUsageProvider(provider); + public FeatureHubConfig recordUsageEvent(UsageEvent event) { + getInternalRepository().recordUsageEvent(event); + return this; } @Override @@ -192,8 +214,9 @@ public Readiness getReadiness() { } @Override - public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { + public FeatureHubConfig setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { getRepository().setJsonConfigObjectMapper(jsonConfigObjectMapper); + return this; } @Override diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 6211f74..0fc84e3 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,7 +1,8 @@ package io.featurehub.client; import com.fasterxml.jackson.databind.ObjectMapper; -import io.featurehub.client.usage.UsageProvider; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsagePlugin; import org.jetbrains.annotations.NotNull; import java.util.Collection; @@ -44,10 +45,11 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { return sdkKey.stream().anyMatch(key -> key.contains("*")); } - void setRepository(FeatureRepository repository); + FeatureHubConfig setRepository(FeatureRepository repository); @NotNull FeatureRepository getRepository(); + @NotNull InternalFeatureRepository getInternalRepository(); - void setEdgeService(Supplier edgeService); + FeatureHubConfig setEdgeService(Supplier edgeService); @NotNull Supplier getEdgeService(); /** @@ -62,14 +64,9 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * @param allowLockOverride * @param interceptor */ - void registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor); + FeatureHubConfig registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); - /** - * Allows the user to register a new usage provider that determines what internal classes are - * created on analytical events - * @param provider - */ - void registerUsageProvider(@NotNull UsageProvider provider); + FeatureHubConfig registerUsagePlugin(@NotNull UsagePlugin plugin); /** * Allows you to query the state of the repository's readyness - such as in a heartbeat API @@ -82,7 +79,7 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * * @param jsonConfigObjectMapper - a Jackson ObjectMapper */ - void setJsonConfigObjectMapper(ObjectMapper jsonConfigObjectMapper); + FeatureHubConfig setJsonConfigObjectMapper(ObjectMapper jsonConfigObjectMapper); /** * You should use this close if you are using a client evaluated key and wish to close the connection to the remote @@ -103,4 +100,6 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * cache timeout defaults to 180 seconds */ FeatureHubConfig rest(); + + FeatureHubConfig recordUsageEvent(UsageEvent event); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index e4f15f9..13b7d77 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -147,20 +147,22 @@ public K getValue(Class clazz) { private Object internalGetValue(@Nullable FeatureValueType passedType, boolean triggerUsage) { boolean locked = feature.fs != null && Boolean.TRUE.equals(feature.fs.getL()); - // unlike js, locking is registered on a per-interceptor basis + // the intercetor can trigger even on invalid feature keys, so we need to be able to track it FeatureValueInterceptor.ValueMatch vm = repository.findIntercept(locked, feature.key); + final FeatureValueType type = (passedType == null && feature.fs != null) ? feature.fs.getType() : passedType; + if (vm != null) { - return vm.value; + return triggerUsage && feature.fs.getId() != null ? + used(feature.key, feature.fs.getId(), vm.value, type == null ? FeatureValueType.STRING : type) : + vm.value; } if (feature.fs == null || ( passedType == null && feature.fs.getType() == null )) { return null; } - final FeatureValueType type = passedType == null ? feature.fs.getType() : passedType; - - if (feature.fs.getType() != type) { + if (feature.fs.getType() != type || type == null) { return null; } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java index db96f9e..004d0b2 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java @@ -7,7 +7,7 @@ import java.util.List; public class UsageAdapter { - private List plugins = new LinkedList<>(); + private final List plugins = new LinkedList<>(); final FeatureRepository repository; final RepositoryEventHandler usageHandlerSub; @@ -23,4 +23,8 @@ public void close() { public void process(UsageEvent event) { plugins.forEach((p) -> p.send(event)); } + + public void registerPlugin(UsagePlugin plugin) { + plugins.add(plugin); + } } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java index 559f9fe..245a007 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java @@ -7,8 +7,14 @@ import java.util.Map; public class UsageEvent { + /** + * This is the unique identifying key of the user for this event (if any) + */ @Nullable private String userKey; + /** + * This is the set of any additional parameters that a user wishes to collect over and above the context attributes + */ @NotNull private Map additionalParams = new HashMap<>(); @@ -35,7 +41,11 @@ public UsageEvent(@Nullable String userKey, @Nullable Map additi } @NotNull - protected Map toMap() { + public Map toMap() { return additionalParams; } + + @Nullable public String getUserKey() { + return userKey; + } } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java similarity index 74% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java rename to client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java index 7a52f91..33cdcf3 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeature.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java @@ -8,15 +8,16 @@ import java.util.List; import java.util.Map; -public class UsageFeature extends UsageEvent implements UsageEventName { +public class UsageEventWithFeature extends UsageEvent implements UsageEventName { @Nullable final Map> attributes; @NotNull final FeatureHubUsageValue feature; - public UsageFeature(@NotNull FeatureHubUsageValue feature, @Nullable Map> attributes, - @Nullable String userKey) { + public UsageEventWithFeature(@NotNull FeatureHubUsageValue feature, @Nullable Map> attributes, + @Nullable String userKey) { this.attributes = attributes; this.feature = feature; + setUserKey(userKey); } @Nullable public Map> getAttributes() { @@ -33,7 +34,7 @@ public UsageFeature(@NotNull FeatureHubUsageValue feature, @Nullable Map toMap() { + @NotNull public Map toMap() { Map m = new HashMap<>(super.toMap()); if (attributes != null) { // may not be from a context diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java index 11d58e1..5a32f57 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java @@ -25,7 +25,7 @@ public UsageFeaturesCollection() {} void ready() {} @Override - @NotNull protected Map toMap() { + @NotNull public Map toMap() { Map m = new HashMap<>(super.toMap()); featureValues.forEach((fv) -> m.put(fv.key, fv.value)); diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java index 5b9c8fb..eca2631 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java @@ -25,7 +25,7 @@ public void setAttributes(Map> attributes) { } @Override - @NotNull protected Map toMap() { + @NotNull public Map toMap() { Map m = new HashMap<>(super.toMap()); m.putAll(attributes); diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java index 7a1b207..38b17d6 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java @@ -5,11 +5,15 @@ abstract public class UsagePlugin { protected final Map defaultEventParams = new HashMap<>(); - protected final boolean unnamedBecomeEventParameters; +// protected final boolean unnamedBecomeEventParameters; +// +// public UsagePlugin(boolean unnamedBecomeEventParameters) { +// this.unnamedBecomeEventParameters = unnamedBecomeEventParameters; +// } - public UsagePlugin(boolean unnamedBecomeEventParameters) { - this.unnamedBecomeEventParameters = unnamedBecomeEventParameters; + public Map getDefaultEventParams() { + return defaultEventParams; } - abstract void send(UsageEvent event); + public abstract void send(UsageEvent event); } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java index 8b715e0..2f1330f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java @@ -7,15 +7,15 @@ import java.util.Map; public interface UsageProvider { - default UsageFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, - @NotNull Map> attributes) { - return new UsageFeature(feature, attributes, null); + default UsageEventWithFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @NotNull Map> attributes) { + return new UsageEventWithFeature(feature, attributes, null); } - default UsageFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, - @Nullable Map> attributes, - @Nullable String userKey) { - return new UsageFeature(feature, attributes, userKey); + default UsageEventWithFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @Nullable Map> attributes, + @Nullable String userKey) { + return new UsageEventWithFeature(feature, attributes, userKey); } default UsageFeaturesCollection createUsageCollectionEvent() { diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 1697985..a911875 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -270,6 +270,11 @@ public Future poll() { return CompletableFuture.completedFuture(repository.getReadiness()); } + @Override + public long currentInterval() { + return 0; + } + @Override public void reconnect() { poll(); diff --git a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java index 6a533bf..72a26d1 100644 --- a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java +++ b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java @@ -23,11 +23,9 @@ public static void main(String[] args) throws Exception { final FeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8064/pistachio", "default/2f4de83c-e13e-459e-b272-63e4f8b34bad/ReQpGic7lOaZuQDkxe3WD40EbtVDN1*z5iXRNRROCW4Gy2peXsr"); - FeatureRepository cfr = config.getRepository(); + config.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); - cfr.addReadinessListener((rl) -> System.out.println("Readyness is " + rl)); - - config.setEdgeService(() -> new JerseySSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); + config.setEdgeService(() -> new JerseySSEClient(config.getInternalRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); config.init(); final ClientContext ctx = config.newContext(); @@ -68,11 +66,12 @@ public static void main(String[] args) throws Exception { // @Test public void changeToggleTest() { - ClientFeatureRepository cfr = new ClientFeatureRepository(5); - final JerseyClient client = - new JerseyClient(new EdgeFeatureHubConfig("http://localhost:8553", changeToggleEnv), false, - cfr, null); - - client.setFeatureState("NEW_BOAT", new FeatureStateUpdate().lock(false).value(Boolean.TRUE)); +// ClientFeatureRepository cfr = new ClientFeatureRepository(5); +// +// final JerseyClient client = +// new JerseyClient(new EdgeFeatureHubConfig("http://localhost:8553", changeToggleEnv), false, +// cfr, null); +// +// client.setFeatureState("NEW_BOAT", new FeatureStateUpdate().lock(false).value(Boolean.TRUE)); } } diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index b086c38..1481d96 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -52,7 +52,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/examples/todo-java/pom.xml b/examples/todo-java/pom.xml index d39acfe..d68f98b 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java/pom.xml @@ -26,14 +26,14 @@ connect_todo 3.0.1 2.0.0 - 3.0.3 + 3.1.2 io.featurehub.sdk java-client-jersey3 - [2.1-SNAPSHOT, 3) + [3.1-SNAPSHOT, 4) @@ -42,6 +42,12 @@ [3.1-SNAPSHOT, 4) + + io.featurehub.sdk.java + segment-usageadapter + 1.1-SNAPSHOT + + io.featurehub.sdk.composites sdk-composite-jersey3 diff --git a/examples/todo-java/src/main/java/todo/backend/Application.java b/examples/todo-java/src/main/java/todo/backend/Application.java index 6360656..cb0ab59 100644 --- a/examples/todo-java/src/main/java/todo/backend/Application.java +++ b/examples/todo-java/src/main/java/todo/backend/Application.java @@ -4,9 +4,8 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; -import io.featurehub.client.Readiness; +import jakarta.inject.Singleton; import org.glassfish.grizzly.http.server.HttpServer; -import org.glassfish.hk2.api.Immediate; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; @@ -16,8 +15,6 @@ import todo.backend.resources.HealthResource; import todo.backend.resources.TodoResource; -import jakarta.inject.Singleton; -import java.io.IOException; import java.net.URI; import java.util.concurrent.TimeUnit; @@ -43,7 +40,7 @@ public void init() throws Exception { .register(new AbstractBinder() { @Override protected void configure() { - bind(FeatureHubSource.class).in(Immediate.class).to(FeatureHub.class); + bind(FeatureHubSource.class).in(Singleton.class).to(FeatureHub.class); } }); diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index 9e8974f..396a1a7 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -4,23 +4,21 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; +import io.featurehub.client.*; import io.featurehub.okhttp.RestClient; -import io.featurehub.client.ClientFeatureRepository; -import io.featurehub.client.EdgeFeatureHubConfig; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.ThreadLocalContext; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; import io.featurehub.client.jersey.JerseySSEClient; import io.featurehub.okhttp.SSEClient; +import io.featurehub.sdk.usageadapter.segment.SegmentUsageAdapter; public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.host") String featureHubUrl; @ConfigKey("feature-service.api-key") String sdkKey; - @ConfigKey("feature-service.google-analytics-key") - String analyticsKey = ""; + @ConfigKey("segment.write-key") + String segmentWriteKey = ""; @ConfigKey("feature-service.sdk") String clientSdk = "jersey3"; @ConfigKey("feature-service.client") @@ -28,35 +26,28 @@ public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.poll-interval") Integer pollInterval = 1000; // in milliseconds - private final EdgeFeatureHubConfig config; + private final FeatureHubConfig config; public FeatureHubSource() { DeclaredConfigResolver.resolve(this); - config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey); + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - ClientFeatureRepository repository = new ClientFeatureRepository(5); - repository.registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - -// if (analyticsCid.length() > 0 && analyticsKey.length() > 0) { -// repository.addAnalyticCollector(new GoogleAnalyticsCollector(analyticsKey, analyticsCid, -// new GoogleAnalyticsJerseyApiClient())); -// } - - config.setRepository(repository); - - ThreadLocalContext.setConfig(config); + if (!segmentWriteKey.isEmpty()) { + config.registerUsagePlugin(new SegmentUsageAdapter(segmentWriteKey)); + } // Do this if you wish to force the connection to stay open. if (clientSdk.equals("jersey3")) { - final JerseySSEClient jerseyClient = new JerseySSEClient(repository, + final JerseySSEClient jerseyClient = new JerseySSEClient(config.getInternalRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); config.setEdgeService(() -> jerseyClient); - } else if (clientSdk.equals("android")) { + } else if (clientSdk.equals("android") || clientSdk.equals("rest")) { final RestClient client = new RestClient(config, pollInterval); config.setEdgeService(() -> client); } else if (clientSdk.equals("sse")) { - final SSEClient client = new SSEClient(repository, config, + final SSEClient client = new SSEClient(config.getInternalRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); config.setEdgeService(() -> client); } else { diff --git a/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java b/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java index e202fe2..5435f6c 100644 --- a/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java +++ b/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java @@ -1,12 +1,13 @@ package todo.backend; +import io.featurehub.client.usage.UsageEventName; import io.featurehub.client.usage.UsageFeaturesCollection; import org.jetbrains.annotations.NotNull; import java.util.LinkedHashMap; import java.util.Map; -public class UsageRequestMeasurement extends UsageFeaturesCollection { +public class UsageRequestMeasurement extends UsageFeaturesCollection implements UsageEventName { private final long duration; @NotNull private final String url; @@ -18,10 +19,15 @@ public UsageRequestMeasurement(long duration, @NotNull String url) { } @Override - protected @NotNull Map toMap() { + public @NotNull Map toMap() { final LinkedHashMap data = new LinkedHashMap<>(super.toMap()); data.put("duration", duration); data.put("url", url); return data; } + + @Override + public @NotNull String getEventName() { + return "tracking"; + } } diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index ec67dc4..63fea3e 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -7,6 +7,7 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import todo.backend.FeatureHub; import todo.backend.UsageRequestMeasurement; import java.io.IOException; @@ -14,8 +15,11 @@ public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { + private final FeatureHub config; + @Inject - public FeatureAnalyticsFilter() { + public FeatureAnalyticsFilter(FeatureHub config) { + this.config = config; DeclaredConfigResolver.resolve(this); } @@ -34,8 +38,8 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont } final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); - if (matchedURIs.size() > 0) { - ThreadLocalContext.context().recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + if (!matchedURIs.isEmpty()) { + config.getConfig().recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); } } } diff --git a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java index 21ec975..aba9f28 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java @@ -9,6 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.api.TodoService; +import todo.backend.FeatureHub; import todo.model.Todo; import java.util.List; @@ -20,10 +21,12 @@ public class TodoResource implements TodoService { private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final FeatureHub config; Map> todos = new ConcurrentHashMap<>(); @Inject - public TodoResource() { + public TodoResource(FeatureHub config) { + this.config = config; log.info("created"); } @@ -75,16 +78,16 @@ private String processTitle(ClientContext fhClient, String title) { @NotNull private ClientContext fhClient(String user) { try { - return ThreadLocalContext.getContext().userKey(user).build().get(); + return config.getConfig().newContext().userKey(user).build().get(); } catch (Exception e) { - log.error("Unable to get context!"); + log.error("Unable to get context!", e); throw new WebApplicationException(e); } } @Override public List addTodo(@NotNull String user, Todo body) { - if (body.getId().length() == 0) { + if (body.getId().isEmpty()) { body.id(UUID.randomUUID().toString()); } diff --git a/pom.xml b/pom.xml index adf9382..353c48c 100644 --- a/pom.xml +++ b/pom.xml @@ -42,5 +42,6 @@ client-java-api support examples + usage-adapters diff --git a/usage-adapters/featurehub-segment-adapter/pom.xml b/usage-adapters/featurehub-segment-adapter/pom.xml new file mode 100644 index 0000000..53178dc --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + io.featurehub.sdk.java + segment-usageadapter + segment-usageadapter + 1.1-SNAPSHOT + + + + + 2.20.0 + 3.4.4 + 2.0.5 + + + + + io.featurehub.sdk + java-client-okhttp + [3.1-SNAPSHOT, 4) + + + + com.segment.analytics.java + analytics + LATEST + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1, 2) + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java8:[1.1,2) + + + + + + + diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java new file mode 100644 index 0000000..4935a92 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java @@ -0,0 +1,47 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.Analytics; +import com.segment.analytics.messages.TrackMessage; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsagePlugin; +import okhttp3.OkHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SegmentUsageAdapter extends UsagePlugin { + final Analytics analytics; + private static final Logger log = LoggerFactory.getLogger(SegmentUsageAdapter.class); + + public SegmentUsageAdapter(String segmentKey) { + analytics = Analytics.builder(segmentKey).build(); + } + + public SegmentUsageAdapter(String segmentKey, OkHttpClient okHttpClient) { + analytics = Analytics.builder(segmentKey).client(okHttpClient).build(); + } + + public SegmentUsageAdapter() { + final String segmentKey = System.getenv("FEATUREHUB_SEGMENT_KEY"); + + if (segmentKey == null) { + throw new RuntimeException("You must initialize with an env var `FEATUREHUB_SEGMENT_KEY` or provide one to the constructor"); + } + + analytics = Analytics.builder(segmentKey).build(); + } + + @Override + public void send(UsageEvent event) { + if (event instanceof UsageEventName) { + final String userId = event.getUserKey() == null ? "anonymous" : event.getUserKey(); + + log.trace("segment event {} with key {}", ((UsageEventName) event).getEventName(), userId); + + final TrackMessage.Builder builder = + TrackMessage.builder(((UsageEventName) event).getEventName()).userId(userId).properties(event.toMap()); + + analytics.enqueue(builder); + } + } +} diff --git a/usage-adapters/pom.xml b/usage-adapters/pom.xml new file mode 100644 index 0000000..1434de8 --- /dev/null +++ b/usage-adapters/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + io.featurehub.sdk.java + usage-adapter-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + featurehub-segment-adapter + + From 6c70386e964fb923a78e0fe91eb928a456ce8e3e Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 28 Jul 2024 16:32:57 +1200 Subject: [PATCH 07/22] correct the tests, upgrade the API to support feature properties --- build_alL_and_test.sh | 3 + client-java-api/pom.xml | 6 +- .../client/FeatureHubClientFactory.java | 10 ++-- .../io/featurehub/client/FeatureState.java | 3 + .../featurehub/client/FeatureStateBase.java | 16 ++++-- .../client/EdgeFeatureHubConfigSpec.groovy | 2 - .../client/FeatureHubTestClientFactory.groovy | 34 ++++++++++- .../featurehub/client/RepositorySpec.groovy | 6 +- .../client/jersey/JerseySSEClient.java | 5 ++ .../featurehub/client/jersey/RestClient.java | 8 +-- .../client/jersey/JerseySSEClientSpec.groovy | 1 + .../client/jersey/RestClientSpec.groovy | 19 ++++++- .../jersey/JerseyFeatureHubClientFactory.java | 5 ++ .../client/jersey/JerseySSEClient.java | 2 +- .../featurehub/client/jersey/RestClient.java | 8 +-- .../client/jersey/JerseySSEClientSpec.groovy | 3 +- .../client/jersey/RestClientSpec.groovy | 19 ++++++- .../client/jersey/SSETestHarness.groovy | 2 +- .../client/jersey/JerseyClientSample.java | 2 +- .../okhttp/OkHttpFeatureHubFactory.java | 5 ++ .../io/featurehub/okhttp/SSEClientSpec.groovy | 56 +++++++++++-------- setup.sh | 6 -- 22 files changed, 158 insertions(+), 63 deletions(-) create mode 100755 build_alL_and_test.sh delete mode 100755 setup.sh diff --git a/build_alL_and_test.sh b/build_alL_and_test.sh new file mode 100755 index 0000000..5c338f3 --- /dev/null +++ b/build_alL_and_test.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn -T4C clean install + diff --git a/client-java-api/pom.xml b/client-java-api/pom.xml index ef27171..f3637e0 100644 --- a/client-java-api/pom.xml +++ b/client-java-api/pom.xml @@ -70,12 +70,12 @@ org.openapitools openapi-generator-maven-plugin - 6.0.1 + 7.0.1 cd.connect.openapi connect-openapi-jersey3 - 8.8 + 9.1 @@ -89,7 +89,7 @@ ${project.basedir}/target/generated-sources/api io.featurehub.sse.api io.featurehub.sse.model - https://api.dev.featurehub.io/edge/1.1.5.yaml + https://api.dev.featurehub.io/edge/1.1.8.yaml jersey3-api false diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java index e050607..a3cc87f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java @@ -11,15 +11,15 @@ */ public interface FeatureHubClientFactory { - Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); + @NotNull Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); - Supplier createSSEEdge(@NotNull FeatureHubConfig config); + @NotNull Supplier createSSEEdge(@NotNull FeatureHubConfig config); - Supplier createRestEdge(@NotNull FeatureHubConfig config, + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate); - Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate); + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate); - Supplier createTestApi(@NotNull FeatureHubConfig config); + @NotNull Supplier createTestApi(@NotNull FeatureHubConfig config); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java b/client-java-core/src/main/java/io/featurehub/client/FeatureState.java index 68adc42..6d18457 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureState.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable; import java.math.BigDecimal; +import java.util.Map; public interface FeatureState { /** @@ -65,4 +66,6 @@ public interface FeatureState { void addListener(@NotNull FeatureListener listener); @Nullable FeatureValueType getType(); + + @NotNull Map featureProperties(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index 13b7d77..ecf87d6 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -8,9 +8,7 @@ import java.io.IOException; import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; +import java.util.*; /** * This class is just the base class to avoid a lot of duplication effort and to ensure the @@ -135,6 +133,15 @@ private Object getValue(@Nullable FeatureValueType type) { return (feature.fs == null) ? null : feature.fs.getType(); } + @Override + public @NotNull Map featureProperties() { + final TopFeatureState topFeature = top().feature; + + if (topFeature == null || topFeature.fs == null) return new LinkedHashMap<>(); + + return (topFeature.fs.getFeatureProperties() == null) ? new LinkedHashMap<>() : topFeature.fs.getFeatureProperties(); + } + public Object getUsageFreeValue() { return internalGetValue(null, false); } @@ -152,8 +159,9 @@ private Object internalGetValue(@Nullable FeatureValueType passedType, boolean t final FeatureValueType type = (passedType == null && feature.fs != null) ? feature.fs.getType() : passedType; + // was there an overridden value? if (vm != null) { - return triggerUsage && feature.fs.getId() != null ? + return triggerUsage && feature.fs != null && feature.fs.getId() != null ? used(feature.key, feature.fs.getId(), vm.value, type == null ? FeatureValueType.STRING : type) : vm.value; } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index 2073701..d74aa4b 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -54,12 +54,10 @@ class EdgeFeatureHubConfigSpec extends Specification { config.setJsonConfigObjectMapper(om) config.addReadinessListener(readynessListener) config.registerValueInterceptor(false, featureValueOverride) - config.registerUsageProvider(analyticsProvider) then: 1 * repo.registerValueInterceptor(false, featureValueOverride) 1 * repo.addReadinessListener(readynessListener) >> Mock(RepositoryEventHandler) 1 * repo.setJsonConfigObjectMapper(om) - 1 * repo.registerUsageProvider(analyticsProvider) 0 * _ // nothing else } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy index bcdc1be..7480b06 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy @@ -1,5 +1,6 @@ package io.featurehub.client +import io.featurehub.sse.model.FeatureStateUpdate import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.Nullable @@ -52,31 +53,58 @@ class FeatureHubTestClientFactory implements FeatureHubClientFactory { } } + class FakeTestApi implements TestApi { + + @Override + TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return null + } + + @Override + TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return null + } + + @Override + void close() { + + } + } + static FakeEdgeService fake + static FakeTestApi fakeTestApi @Override + @NotNull Supplier createSSEEdge(FeatureHubConfig config, InternalFeatureRepository repository) { fake = new FakeEdgeService(repository, config) return { -> fake } } @Override + @NotNull Supplier createSSEEdge(@NotNull FeatureHubConfig config) { return createSSEEdge(config, null) } @Override + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPolling) { - return null + fake = new FakeEdgeService(repository, config) + return { -> fake } } @Override + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPolling) { - return null + fake = new FakeEdgeService(config.getInternalRepository(), config) + return { -> fake } } @Override + @NotNull Supplier createTestApi(@NotNull FeatureHubConfig config) { - return null + fakeTestApi = new FakeTestApi() + return { -> fakeTestApi } } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy index 6016d4b..a4d723c 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy +++ b/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -36,7 +36,7 @@ class RepositorySpec extends Specification { given: "we have features" def features = [ new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), - new FeatureState().id(UUID.randomUUID()).key('peach').version(1L).value("orange").type(FeatureValueType.STRING), + new FeatureState().id(UUID.randomUUID()).key('peach').version(1L).value("orange").type(FeatureValueType.STRING).featureProperties(Map.of("pork", "dumplings")), new FeatureState().id(UUID.randomUUID()).key('peach_quantity').version(1L).value(17).type(FeatureValueType.NUMBER), new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON), ] @@ -53,6 +53,7 @@ class RepositorySpec extends Specification { !repo.getFeat('banana').flag repo.getFeat('banana').key == 'banana' repo.getFeat('banana').exists() + repo.getFeat('banana').featureProperties().isEmpty() repo.getFeat(Fruit.banana).exists() !repo.getFeat('dragonfruit').exists() !repo.getFeat(Fruit.dragonfruit).exists() @@ -64,12 +65,14 @@ class RepositorySpec extends Specification { !repo.getFeat('banana').enabled repo.getFeat('peach').string == 'orange' repo.getFeat('peach').exists() + repo.getFeat('peach').featureProperties() == ['pork': 'dumplings'] repo.getFeat(Fruit.peach).exists() repo.getFeat('peach').key == 'peach' repo.getFeat('peach').number == null repo.getFeat('peach').rawJson == null repo.getFeat('peach').flag == null repo.getFeat('peach_quantity').number == 17 + repo.getFeat('peach_quantity').featureProperties().isEmpty() repo.getFeat('peach_quantity').rawJson == null repo.getFeat('peach_quantity').flag == null repo.getFeat('peach_quantity').string == null @@ -79,6 +82,7 @@ class RepositorySpec extends Specification { repo.getFeat('peach_config').number == null repo.getFeat('peach_config').flag == null repo.getFeat('peach_config').key == 'peach_config' + repo.getFeat('peach_config').featureProperties().isEmpty() repo.getAllFeatures().size() == 5 } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index c8b1429..b5c1fe5 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -270,6 +270,11 @@ public Future poll() { return CompletableFuture.completedFuture(repository.getReadiness()); } + @Override + public long currentInterval() { + return 0; + } + @Override public void reconnect() { poll(); diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java index 8bffb6d..982542f 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -41,7 +41,7 @@ public class RestClient implements EdgeService { @Nullable private String etag = null; private long pollingInterval; - private final boolean amPollingDelegate; + private final boolean breakCacheOnPoll; private long whenPollingCacheExpires; private final boolean clientSideEvaluation; @@ -59,12 +59,12 @@ public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, int timeoutInSeconds, - boolean amPollingDelegate) { + boolean breakCacheOnPoll) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } - this.amPollingDelegate = amPollingDelegate; + this.breakCacheOnPoll = breakCacheOnPoll; this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; @@ -110,7 +110,7 @@ protected Long now() { public boolean checkForUpdates(@Nullable CompletableFuture change) { final boolean breakCache = - amPollingDelegate || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + breakCacheOnPoll || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = !busy && !stopped && breakCache; headerChanged = false; diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy index 92b3405..587e153 100644 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -24,6 +24,7 @@ class JerseySSEClientSpec extends Specification { def setup() { mapper = new ObjectMapper() + System.setProperty("jersey.config.test.container.port", (10000 + new Random().nextInt(1000)).toString()) harness = new SSETestHarness() harness.setUp() config = harness.getConfig(["123/345*675"], { String envId, String apiKey, List featureHubAttrs, String extraConfig, String browserHubAttrs, String etag -> diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy index 334fb20..e301018 100644 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy +++ b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -23,7 +23,7 @@ class RestClientSpec extends Specification { repo = Mock() config = Mock() config.isServerEvaluation() >> true - client = new RestClient(repo, featureService, config, 0) + client = new RestClient(repo, featureService, config, 0, false) } ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { @@ -132,7 +132,7 @@ class RestClientSpec extends Specification { def "change the polling interval to 180 seconds and a second poll won't poll"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180) + client = new RestClient(repo, featureService, config, 180, false) when: def result = client.poll().get() def result2 = client.poll().get() @@ -143,4 +143,19 @@ class RestClientSpec extends Specification { 2 * repo.readiness >> Readiness.Ready 0 * _ } + + def "change the polling interval to 180 seconds and force cache breaking"() { + given: + def response = build() + client = new RestClient(repo, featureService, config, 180, true) + when: + def result = client.poll().get() + def result2 = client.poll().get() + then: + 2 * repo.updateFeatures([]) + 2 * config.apiKeys() >> apiKeys + 2 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 2 * repo.readiness >> Readiness.Ready + 0 * _ + } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 5add294..76480dc 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -13,28 +13,33 @@ public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { return createSSEEdge(config, null); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { return () -> new RestClient(repository, null, config, timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createTestApi(@NotNull FeatureHubConfig config) { return () -> new TestSDKClient(config); } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index a911875..1296243 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -44,7 +44,7 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { - this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; + this.repository = repository == null ? config.getInternalRepository() : repository; this.config = config; this.retryer = retryer; diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java index 638f8a2..25215ec 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -44,7 +44,7 @@ public class RestClient implements EdgeService { private long whenPollingCacheExpires; private final boolean clientSideEvaluation; - private final boolean amPollingDelegate; + private final boolean breakCacheOnEveryCheck; @NotNull private final FeatureHubConfig config; /** @@ -58,12 +58,12 @@ public class RestClient implements EdgeService { public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, - int timeoutInSeconds, boolean amPollingDelegate) { + int timeoutInSeconds, boolean breakCacheOnEveryCheck) { if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } - this.amPollingDelegate = amPollingDelegate; + this.breakCacheOnEveryCheck = breakCacheOnEveryCheck; this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; @@ -96,7 +96,7 @@ protected Long now() { public boolean checkForUpdates(@Nullable CompletableFuture change) { final boolean breakCache = - amPollingDelegate || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + breakCacheOnEveryCheck || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); final boolean ask = !busy && !stopped && breakCache; headerChanged = false; diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy index 92b3405..c5923a5 100644 --- a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -24,6 +24,7 @@ class JerseySSEClientSpec extends Specification { def setup() { mapper = new ObjectMapper() + System.setProperty("jersey.config.test.container.port", (10000 + new Random().nextInt(1000)).toString()) harness = new SSETestHarness() harness.setUp() config = harness.getConfig(["123/345*675"], { String envId, String apiKey, List featureHubAttrs, String extraConfig, String browserHubAttrs, String etag -> @@ -43,7 +44,7 @@ class JerseySSEClientSpec extends Specification { def cleanup() { - harness.tearDown() + harness?.tearDown() } def "A basic client connect works as expected"() { diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy index ea2d2c3..90562f7 100644 --- a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -23,7 +23,7 @@ class RestClientSpec extends Specification { repo = Mock() config = Mock() config.isServerEvaluation() >> true - client = new RestClient(repo, featureService, config, 0) + client = new RestClient(repo, featureService, config, 0, false) } ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { @@ -132,7 +132,7 @@ class RestClientSpec extends Specification { def "change the polling interval to 180 seconds and a second poll won't poll"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180) + client = new RestClient(repo, featureService, config, 180, false) when: def result = client.poll().get() def result2 = client.poll().get() @@ -143,4 +143,19 @@ class RestClientSpec extends Specification { 2 * repo.readiness >> Readiness.Ready 0 * _ } + + def "change polling interval to 180 seconds and force breaking cache on every check"() { + given: + def response = build() + client = new RestClient(repo, featureService, config, 180, true) + when: + client.poll().get() + client.poll().get() + then: + 2 * repo.updateFeatures([]) + 2 * config.apiKeys() >> apiKeys + 2 * featureService.getFeatureStates(apiKeys, '0', [:]) >> response + 2 * repo.readiness >> Readiness.Ready + 0 * _ + } } diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy index 6585cfb..b916639 100644 --- a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy +++ b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy @@ -16,7 +16,7 @@ import jakarta.ws.rs.Produces import jakarta.ws.rs.QueryParam import jakarta.ws.rs.core.Application -@Singleton +//@Singleton @Path("features/{environmentId}/{apiKey}") class SSETestHarness extends JerseyTest { static Closure backhaul diff --git a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java index 72a26d1..5963733 100644 --- a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java +++ b/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java @@ -30,7 +30,7 @@ public static void main(String[] args) throws Exception { config.init(); final ClientContext ctx = config.newContext(); - final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); + final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").isEnabled(); System.out.println("Wait for readyness or hit enter if server eval key"); diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java index 13057e2..439e92c 100644 --- a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java +++ b/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java @@ -13,26 +13,31 @@ public class OkHttpFeatureHubFactory implements FeatureHubClientFactory { @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { return () -> new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); } @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { return createSSEEdge(config, null); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { return () -> new RestClient(repository, config, timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createTestApi(@NotNull FeatureHubConfig config) { return () -> new TestClient(config); } diff --git a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy index ac1979f..55b462b 100644 --- a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy +++ b/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy @@ -31,6 +31,7 @@ class SSEClientSpec extends Specification { client = new SSEClient(repository, config, retry) { @Override protected EventSource makeEventSource(Request req, EventSourceListener listener) { + println("returning mock event source") esListener = listener request = req return mockEventSource @@ -43,9 +44,12 @@ class SSEClientSpec extends Specification { client.poll() esListener.onEvent(mockEventSource, '1', "features", "sausage") then: - 1 * repository.notify(SSEResultState.FEATURES, "sausage") + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * retry.fromValue('features') >> SSEResultState.FEATURES // converts the "type" field + 1 * retry.convertSSEState(SSEResultState.FEATURES, "sausage", repository) + 1 * repository.getReadiness() >> Readiness.Ready 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) - 1 * retry.fromValue('features') >> SSEResultState.FEATURES + 0 * _ } def "success then bye but not close lifecycle"() { @@ -55,12 +59,15 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, '1', "bye", "sausage") then: - 1 * repository.notify(SSEResultState.FEATURES, "sausage") - 1 * repository.notify(SSEResultState.BYE, "sausage") + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * retry.convertSSEState(SSEResultState.FEATURES, "sausage", repository) + 1 * retry.convertSSEState(SSEResultState.BYE, "sausage", repository) 1 * retry.fromValue('features') >> SSEResultState.FEATURES 1 * retry.fromValue('bye') >> SSEResultState.BYE + 1 * repository.getReadiness() >> Readiness.Ready 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 0 * retry.edgeResult(EdgeConnectionState.SERVER_SAID_BYE, client) + 0 * _ } def "success then bye then close lifecycle"() { @@ -70,14 +77,16 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, '1', "bye", "sausage") esListener.onClosed(mockEventSource) then: - 1 * repository.notify(SSEResultState.FEATURES, "sausage") - 1 * repository.notify(SSEResultState.BYE, "sausage") + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * retry.convertSSEState(SSEResultState.FEATURES, "sausage", repository) + 1 * retry.convertSSEState(SSEResultState.BYE, "sausage", repository) 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 1 * retry.edgeResult(EdgeConnectionState.SERVER_SAID_BYE, client) 1 * retry.fromValue('features') >> SSEResultState.FEATURES 1 * retry.fromValue('bye') >> SSEResultState.BYE - 1 * repository.notify(SSEResultState.FAILURE, null) - 1 * repository.readyness >> Readiness.NotReady + 2 * repository.getReadiness() >> Readiness.NotReady + 1 * repository.notify(SSEResultState.FAILURE) + 0 * _ } def "success then close with no bye"() { @@ -86,12 +95,14 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, '1', "features", "sausage") esListener.onClosed(mockEventSource) then: - 1 * repository.notify(SSEResultState.FEATURES, "sausage") + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * retry.convertSSEState(SSEResultState.FEATURES, "sausage", repository) 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 1 * retry.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, client) - 1 * repository.notify(SSEResultState.FAILURE, null) - 1 * repository.readyness >> Readiness.NotReady + 1 * repository.notify(SSEResultState.FAILURE) + 2 * repository.getReadiness() >> Readiness.NotReady 1 * retry.fromValue('features') >> SSEResultState.FEATURES + 0 * _ } def "open then immediate failure"() { @@ -100,9 +111,12 @@ class SSEClientSpec extends Specification { // esListener.onOpen(mockEventSource, Mock(Response)) esListener.onFailure(mockEventSource, null, null) then: - 1 * repository.readyness >> Readiness.NotReady - 1 * repository.notify(SSEResultState.FAILURE, null) + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * config.baseUrl() >> "http://localhost" // used by trace log + 2 * repository.getReadiness() >> Readiness.NotReady + 1 * repository.notify(SSEResultState.FAILURE) 1 * retry.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, client) + 0 * _ } def "when i context change with a client side key, it gives me a future which resolves readyness"() { @@ -110,11 +124,14 @@ class SSEClientSpec extends Specification { def future = client.contextChange("header", '0') esListener.onEvent(mockEventSource, "1", "features", "data") then: - 1 * repository.notify(SSEResultState.FEATURES, "data") - 1 * repository.readyness >> Readiness.Failed + 1 * config.getRealtimeUrl() >> "http://localhost" + 1 * retry.convertSSEState(SSEResultState.FEATURES, "data", repository) + 1 * config.isServerEvaluation() >> false + 2 * repository.getReadiness() >> Readiness.Failed 1 * retry.edgeResult(EdgeConnectionState.SUCCESS, client) 1 * retry.fromValue('features') >> SSEResultState.FEATURES future.get() == Readiness.Failed + 0 * _ } def "when i context change with a server side key, it creates a request with the header"() { @@ -147,7 +164,7 @@ class SSEClientSpec extends Specification { esListener.onEvent(mockEventSource, '1', 'features', 'data') then: 2 * config.serverEvaluation >> true - 2 * repository.readyness >> Readiness.Ready + 4 * repository.getReadiness() >> Readiness.Ready 1 * retry.fromValue('features') >> SSEResultState.FEATURES request.header("x-featurehub") == "header2" future1.done @@ -178,11 +195,4 @@ class SSEClientSpec extends Specification { then: cfg == config } - - def "replacement is not required for this API, it can handle swapping SSE clients"() { - when: "i ask if it can swap headers" - def swap = client.requiresReplacementOnHeaderChange - then: - !swap - } } diff --git a/setup.sh b/setup.sh deleted file mode 100755 index 660e2f7..0000000 --- a/setup.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -cd support -mvn -f pom-tiles.xml install -mvn install -cd .. - From 9789e5489a4cb7130b926543496101daf1f643f8 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 28 Jul 2024 16:36:18 +1200 Subject: [PATCH 08/22] fix build --- .github/workflows/java.yaml | 2 +- build_alL_and_test.sh | 3 +- .../featurehub/client/BaseClientContext.java | 4 +-- .../io/featurehub/client/ClientContext.java | 11 ++++++- .../client/ClientFeatureRepository.java | 2 +- .../client/EdgeFeatureHubConfig.java | 9 ++++- .../featurehub/client/FeatureHubConfig.java | 18 ++++++++++ .../resources/FeatureAnalyticsFilter.java | 33 ++++++++++++++----- 8 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index e3b774f..102f5cf 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -15,4 +15,4 @@ jobs: distribution: 'temurin' cache: maven - name: Build with Maven - run: sh setup.sh && mvn --batch-mode --quiet package + run: MVN_OPTS="--batch-mode --quiet" sh ./build_all_and_test.sh diff --git a/build_alL_and_test.sh b/build_alL_and_test.sh index 5c338f3..a57addf 100755 --- a/build_alL_and_test.sh +++ b/build_alL_and_test.sh @@ -1,3 +1,4 @@ #!/bin/sh -cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn -T4C clean install +MAVEN_OPTS=${MVN_OPTS:-"-T4C"} +cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn $MAVEN_OPTS clean install diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index 9f485d6..d3f828c 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -150,7 +150,7 @@ protected void recordRelativeValuesForUser() { repository.recordUsageEvent(fillUsageCollection(repository.getUsageProvider().createUsageCollectionEvent())); } - protected UsageEvent fillUsageCollection(UsageEvent event) { + public @NotNull T fillUsageCollection(@NotNull T event) { event.setUserKey(usageUserKey()); if (event instanceof UsageFeaturesCollection) { @@ -167,7 +167,7 @@ protected UsageEvent fillUsageCollection(UsageEvent event) { } @Override - public void recordUsageEvent(@NotNull UsageEvent event) { + public void recordUsageEvent(@NotNull T event) { repository.recordUsageEvent(fillUsageCollection(event)); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 5535099..369236a 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -67,7 +67,16 @@ public interface ClientContext { * the current context if possible and add it to the analytics event. * @param event */ - void recordUsageEvent(@NotNull UsageEvent event); + void recordUsageEvent(@NotNull T event); + + /** + * Use this method to set all the fields of your UsageEvent. It will add the user key in, + * collect all feature values (if a UsageFeaturesCollection) and add in the context attributes (if a UsageFeaturesCollectionContext) + * @param event - the vent to fill in + * @return the filled in collection + * @param a type that extends UsageEvent + */ + @NotNull T fillUsageCollection(@NotNull T event); void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index daf6236..7514013 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -317,7 +317,7 @@ public boolean updateFeature(@NotNull io.featurehub.sse.model.FeatureState featu key, key1 -> { if (hasReceivedInitialState) { - log.error( + log.warn( "FeatureHub error: application requesting use of invalid key after initialization: `{}`", key1); } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 1bf5df1..7d432de 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -57,6 +57,13 @@ public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull List apiKe serverEvaluation = !FeatureHubConfig.sdkKeyIsClientSideEvaluated(apiKeys); + // set defaults + if (serverEvaluation) { + rest(); + } else { + streaming(); + } + if (edgeUrl.endsWith("/")) { edgeUrl = edgeUrl.substring(0, edgeUrl.length()-1); } @@ -102,7 +109,7 @@ public String baseUrl() { } /** - * This is only intended to be used for client evaluated contexts, do not use it for server evaluated ones + * This provides an async wait to trigger off the client. */ @Override public Future init() { diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 0fc84e3..2319c40 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -7,11 +7,29 @@ import java.util.Collection; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Supplier; public interface FeatureHubConfig { + + /** + * Use environment variables to create a system config. + * @return system config + */ + default FeatureHubConfig envConfig() { + return new EdgeFeatureHubConfig(System.getenv("FEATUREHUB_EDGE_URL"), System.getenv("FEATUREHUB_API_KEY")); + } + + /** + * Use system properties to create a system config. + * @return system config + */ + default FeatureHubConfig systemPropertyConfig() { + return new EdgeFeatureHubConfig(System.getProperty("featurehub.edge-url"), System.getProperty("featurehub.api-key")); + } + /** * What is the fully deconstructed URL for the server? */ diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index 63fea3e..dad1bf7 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -1,21 +1,26 @@ package todo.backend.resources; import cd.connect.app.config.DeclaredConfigResolver; +import io.featurehub.client.ClientContext; import io.featurehub.client.ThreadLocalContext; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import todo.backend.FeatureHub; import todo.backend.UsageRequestMeasurement; import java.io.IOException; import java.util.List; +import java.util.concurrent.ExecutionException; public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { private final FeatureHub config; + private static final Logger log = LoggerFactory.getLogger(FeatureAnalyticsFilter.class); @Inject public FeatureAnalyticsFilter(FeatureHub config) { @@ -27,19 +32,31 @@ public FeatureAnalyticsFilter(FeatureHub config) { public void filter(ContainerRequestContext requestContext) throws IOException { final long currentTime = System.currentTimeMillis(); requestContext.setProperty("startTime", currentTime); + final List user = requestContext.getUriInfo().getPathParameters().get("user"); + if (user != null && !user.isEmpty()) { + try { + requestContext.setProperty("context", config.getConfig().newContext().build().get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } } @Override public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { - Long start = (Long)requestContext.getProperty("startTime"); - long duration = 0; - if (start != null) { - duration = System.currentTimeMillis() - start; - } + Long start = (Long) requestContext.getProperty("startTime"); + ClientContext context = (ClientContext) requestContext.getProperty("context"); + + if (start != null && context != null) { + long duration = System.currentTimeMillis() - start; + + final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); - final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); - if (!matchedURIs.isEmpty()) { - config.getConfig().recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + if (!matchedURIs.isEmpty()) { + context.recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + } + } else { + log.error("There was not start time {} and context {}", start, context); } } } From 55b08f6363a16574b9663a0c6ef723c81b06893d Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 4 Aug 2024 16:52:14 +1200 Subject: [PATCH 09/22] add otel and segment usage adapters, add all appropriate feedback --- .github/workflows/java.yaml | 8 +- build_alL_and_test.sh | 2 + client-java-api/pom.xml | 2 +- client-java-core/pom.xml | 2 +- .../featurehub/client/BaseClientContext.java | 15 +++ .../io/featurehub/client/ClientContext.java | 3 + .../featurehub/client/usage/UsageAdapter.java | 1 + .../featurehub/client/usage/UsageEvent.java | 2 +- .../client/usage/UsageEventWithFeature.java | 2 +- .../client/usage/UsageFeaturesCollection.java | 2 +- .../usage/UsageFeaturesCollectionContext.java | 2 +- client-java-jersey/pom.xml | 2 +- client-java-jersey3/pom.xml | 2 +- client-java-okhttp/pom.xml | 2 +- examples/todo-java/.gitignore | 1 + examples/todo-java/pom.xml | 29 ++++- .../main/java/todo/backend/FeatureHub.java | 2 + .../FeatureHubClientContextThreadLocal.java | 19 +++ .../java/todo/backend/FeatureHubSource.java | 25 +++- .../resources/FeatureAnalyticsFilter.java | 3 + .../todo/backend/resources/TodoResource.java | 23 +++- .../.editorconfig | 19 +++ .../README.adoc | 19 +++ .../featurehub-opentelemetry-adapter/pom.xml | 62 ++++++++++ .../OpenTelemetryUsagePlugin.java | 65 +++++++++++ .../featurehub-segment-adapter/.editorconfig | 19 +++ .../featurehub-segment-adapter/README.adoc | 41 +++++++ .../featurehub-segment-adapter/pom.xml | 9 +- .../segment/SegmentAnalyticsSource.java | 10 ++ .../segment/SegmentMessageTransformer.java | 71 ++++++++++++ .../segment/SegmentUsageAdapter.java | 47 -------- .../segment/SegmentUsagePlugin.java | 108 ++++++++++++++++++ usage-adapters/pom.xml | 1 + 33 files changed, 547 insertions(+), 73 deletions(-) create mode 100644 examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java create mode 100644 usage-adapters/featurehub-opentelemetry-adapter/.editorconfig create mode 100644 usage-adapters/featurehub-opentelemetry-adapter/README.adoc create mode 100644 usage-adapters/featurehub-opentelemetry-adapter/pom.xml create mode 100644 usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java create mode 100644 usage-adapters/featurehub-segment-adapter/.editorconfig create mode 100644 usage-adapters/featurehub-segment-adapter/README.adoc create mode 100644 usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java create mode 100644 usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java delete mode 100644 usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java create mode 100644 usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 102f5cf..4c4daa6 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -14,5 +14,9 @@ jobs: java-version: '11' distribution: 'temurin' cache: maven - - name: Build with Maven - run: MVN_OPTS="--batch-mode --quiet" sh ./build_all_and_test.sh + - name: Install tiles + run: cd support && mvn -f pom-tiles.xml install + - name: Install support composites + run: cd support && mvn install + - name: All other things + run: mvn install diff --git a/build_alL_and_test.sh b/build_alL_and_test.sh index a57addf..18e200c 100755 --- a/build_alL_and_test.sh +++ b/build_alL_and_test.sh @@ -1,4 +1,6 @@ #!/bin/sh +set -x +export MVN_OPTS="--batch-mode --quiet" MAVEN_OPTS=${MVN_OPTS:-"-T4C"} cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn $MAVEN_OPTS clean install diff --git a/client-java-api/pom.xml b/client-java-api/pom.xml index f3637e0..f7468fd 100644 --- a/client-java-api/pom.xml +++ b/client-java-api/pom.xml @@ -128,7 +128,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) diff --git a/client-java-core/pom.xml b/client-java-core/pom.xml index 2e61bfb..9de2c7c 100644 --- a/client-java-core/pom.xml +++ b/client-java-core/pom.xml @@ -88,7 +88,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java index d3f828c..1b10589 100644 --- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -107,6 +107,21 @@ public ClientContext attrs(String name, List values) { return this; } + @Override + public ClientContext attrs(Map> values) { + attributes.clear(); + attributes.putAll(values); + + return this; + } + + @Override + public ClientContext attrsMerge(Map> values) { + attributes.putAll(values); + + return this; + } + @Override public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, @NotNull FeatureValueType valueType) { diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 369236a..b1fe6ac 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -25,6 +25,9 @@ public interface ClientContext { ClientContext attr(String name, String value); ClientContext attrs(String name, List values); + ClientContext attrs(Map> values); + ClientContext attrsMerge(Map> values); + ClientContext clear(); @Nullable String getAttr(@NotNull String name); diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java index 004d0b2..16ac773 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java @@ -1,5 +1,6 @@ package io.featurehub.client.usage; +import io.featurehub.client.ClientContext; import io.featurehub.client.FeatureRepository; import io.featurehub.client.RepositoryEventHandler; diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java index 245a007..dab4cf8 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java @@ -41,7 +41,7 @@ public UsageEvent(@Nullable String userKey, @Nullable Map additi } @NotNull - public Map toMap() { + public Map toMap() { return additionalParams; } diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java index 33cdcf3..6f4b9a0 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java @@ -41,7 +41,7 @@ public UsageEventWithFeature(@NotNull FeatureHubUsageValue feature, @Nullable Ma m.putAll(attributes); } m.put("feature", feature.key); - m.put("value", feature.id); + m.put("value", feature.value); m.put("id", feature.id); return Collections.unmodifiableMap(m); diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java index 5a32f57..6caa998 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java @@ -25,7 +25,7 @@ public UsageFeaturesCollection() {} void ready() {} @Override - @NotNull public Map toMap() { + @NotNull public Map toMap() { Map m = new HashMap<>(super.toMap()); featureValues.forEach((fv) -> m.put(fv.key, fv.value)); diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java index eca2631..b4c3cad 100644 --- a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java +++ b/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java @@ -25,7 +25,7 @@ public void setAttributes(Map> attributes) { } @Override - @NotNull public Map toMap() { + @NotNull public Map toMap() { Map m = new HashMap<>(super.toMap()); m.putAll(attributes); diff --git a/client-java-jersey/pom.xml b/client-java-jersey/pom.xml index ab18e0c..6a23be3 100644 --- a/client-java-jersey/pom.xml +++ b/client-java-jersey/pom.xml @@ -161,7 +161,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) io.featurehub.sdk.tiles:tile-sdk:[1.1-SNAPSHOT,2) diff --git a/client-java-jersey3/pom.xml b/client-java-jersey3/pom.xml index a0e8bc1..ded33ab 100644 --- a/client-java-jersey3/pom.xml +++ b/client-java-jersey3/pom.xml @@ -182,7 +182,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) io.featurehub.sdk.tiles:tile-sdk:[1.1-SNAPSHOT,2) diff --git a/client-java-okhttp/pom.xml b/client-java-okhttp/pom.xml index 33735c1..ce60cd4 100644 --- a/client-java-okhttp/pom.xml +++ b/client-java-okhttp/pom.xml @@ -100,7 +100,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) io.featurehub.sdk.tiles:tile-sdk:[1.1-SNAPSHOT,2) diff --git a/examples/todo-java/.gitignore b/examples/todo-java/.gitignore index a844e84..93369b2 100644 --- a/examples/todo-java/.gitignore +++ b/examples/todo-java/.gitignore @@ -60,3 +60,4 @@ fabric.properties /swagger-backend/app/app.js.map /swagger-backend/app/generated-interface.js /swagger-backend/app/generated-interface.js.map +/opentelemetry-javaagent.jar diff --git a/examples/todo-java/pom.xml b/examples/todo-java/pom.xml index d68f98b..6d6a18f 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java/pom.xml @@ -45,7 +45,32 @@ io.featurehub.sdk.java segment-usageadapter - 1.1-SNAPSHOT + [1.1-SNAPSHOT, 2) + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + provided + + + + io.opentelemetry.javaagent.instrumentation + opentelemetry-javaagent-jaxrs-3.0-jersey-3.0 + 2.6.0-alpha + + + + io.opentelemetry.javaagent.instrumentation + opentelemetry-javaagent-grizzly-2.3 + 2.6.0-alpha + + + + io.featurehub.sdk.java + opentelemetry-usageadapter + [1.1-SNAPSHOT, 2) @@ -188,7 +213,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java index 86027e0..41202b6 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java @@ -2,9 +2,11 @@ import io.featurehub.client.ClientContext; import io.featurehub.client.FeatureHubConfig; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; import java.util.concurrent.Future; public interface FeatureHub { FeatureHubConfig getConfig(); + SegmentAnalyticsSource segmentAnalytics(); } diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java new file mode 100644 index 0000000..e8ebecd --- /dev/null +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java @@ -0,0 +1,19 @@ +package todo.backend; + +import io.featurehub.client.ClientContext; + +public class FeatureHubClientContextThreadLocal { + private static final ThreadLocal ctx = new ThreadLocal<>(); + + public static void set(ClientContext context) { + ctx.set(context); + } + + public static ClientContext get() { + return ctx.get(); + } + + public static void clear() { + ctx.remove(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java index 396a1a7..3f6ab70 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java @@ -4,13 +4,20 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; +import com.segment.analytics.messages.Message; import io.featurehub.client.*; import io.featurehub.okhttp.RestClient; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; import io.featurehub.client.jersey.JerseySSEClient; import io.featurehub.okhttp.SSEClient; -import io.featurehub.sdk.usageadapter.segment.SegmentUsageAdapter; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.host") @@ -26,6 +33,8 @@ public class FeatureHubSource implements FeatureHub { @ConfigKey("feature-service.poll-interval") Integer pollInterval = 1000; // in milliseconds + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + private final FeatureHubConfig config; public FeatureHubSource() { @@ -35,9 +44,16 @@ public FeatureHubSource() { .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); if (!segmentWriteKey.isEmpty()) { - config.registerUsagePlugin(new SegmentUsageAdapter(segmentWriteKey)); + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, + List.of(new SegmentMessageTransformer(Message.Type.values(), + FeatureHubClientContextThreadLocal::get, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; } + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + // Do this if you wish to force the connection to stay open. if (clientSdk.equals("jersey3")) { final JerseySSEClient jerseyClient = new JerseySSEClient(config.getInternalRepository(), @@ -68,6 +84,11 @@ public FeatureHubConfig getConfig() { return config; } + @Override + public SegmentAnalyticsSource segmentAnalytics() { + return segmentAnalyticsSource; + } + public void close() { config.close(); } diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index dad1bf7..f96a36d 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -11,6 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; import todo.backend.UsageRequestMeasurement; import java.io.IOException; @@ -47,6 +48,8 @@ public void filter(ContainerRequestContext requestContext, ContainerResponseCont Long start = (Long) requestContext.getProperty("startTime"); ClientContext context = (ClientContext) requestContext.getProperty("context"); + FeatureHubClientContextThreadLocal.clear(); + if (start != null && context != null) { long duration = System.currentTimeMillis() - start; diff --git a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java index aba9f28..6250db8 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java +++ b/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java @@ -1,7 +1,6 @@ package todo.backend.resources; import io.featurehub.client.ClientContext; -import io.featurehub.client.ThreadLocalContext; import jakarta.inject.Inject; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.WebApplicationException; @@ -10,6 +9,8 @@ import org.slf4j.LoggerFactory; import todo.api.TodoService; import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import com.segment.analytics.messages.IdentifyMessage; import todo.model.Todo; import java.util.List; @@ -21,12 +22,12 @@ public class TodoResource implements TodoService { private static final Logger log = LoggerFactory.getLogger(TodoResource.class); - private final FeatureHub config; + private final FeatureHub featureHub; Map> todos = new ConcurrentHashMap<>(); @Inject public TodoResource(FeatureHub config) { - this.config = config; + this.featureHub = config; log.info("created"); } @@ -78,7 +79,21 @@ private String processTitle(ClientContext fhClient, String title) { @NotNull private ClientContext fhClient(String user) { try { - return config.getConfig().newContext().userKey(user).build().get(); + final ClientContext context = featureHub.getConfig().newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + + FeatureHubClientContextThreadLocal.set(context); + + if (featureHub.segmentAnalytics() != null) { + // this should have the current user's details augmented into it + featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); + } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; } catch (Exception e) { log.error("Unable to get context!", e); throw new WebApplicationException(e); diff --git a/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig b/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig new file mode 100644 index 0000000..03ed53d --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig @@ -0,0 +1,19 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +indent_style = space + +[*.adoc] +trim_trailing_whitespace = false +indent_style = space + diff --git a/usage-adapters/featurehub-opentelemetry-adapter/README.adoc b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc new file mode 100644 index 0000000..34c8e42 --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc @@ -0,0 +1,19 @@ +== FeatureHub Opentelemetry Usage Plugin for Java + +The purpose of this usage plugin is to allow Usage tracking events to be attached as Span events in OpenTelemetry. + +This allows you to view spans in your OpenTelemetry system and see what features were evaluated, and what their values were. + +If you record a custom FeatureHub Usage event, it will be translated as long as it implements the `UsageEventName` interface. All attributes are logged with a prefix, which defaults to `featurehub.` - but this can be changed on construction. + +The plugin does not specify a version of the OpenTelemetry libraries to use, +it expects your application will include and configure all of these. + +A simple configuration, it can be used as: + +[source,java] +---- +config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); +---- + +It is safe to include even if OpenTelemetry is not enabled on your system because it will check it has a valid setup before attempting to log to your OpenTelemetry system. diff --git a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml new file mode 100644 index 0000000..a01881a --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + io.featurehub.sdk.java + opentelemetry-usageadapter + opentelemetry-usageadapter + 1.1-SNAPSHOT + + + + + + io.featurehub.sdk + java-client-okhttp + [3.1-SNAPSHOT, 4) + + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + provided + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1, 2) + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + diff --git a/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java new file mode 100644 index 0000000..cae8e01 --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java @@ -0,0 +1,65 @@ +package io.featurehub.sdk.usageadapter.opentelemetry; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsagePlugin; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.trace.Span; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class OpenTelemetryUsagePlugin extends UsagePlugin { + private static final Logger log = LoggerFactory.getLogger(OpenTelemetryUsagePlugin.class); + + private final String prefix; + + public OpenTelemetryUsagePlugin(String prefix) { + this.prefix = prefix; + } + + public OpenTelemetryUsagePlugin() { + this("featurehub."); + } + + @Override + public void send(UsageEvent event) { + final Span current = Span.current(); + if (current != null && event instanceof UsageEventName) { + final String name = ((UsageEventName) event).getEventName(); + + final Map usageAttributes = event.toMap(); + + log.trace("opentelemetry - logging {} with attributes {}", name, usageAttributes); + + if (!usageAttributes.isEmpty()) { + final AttributesBuilder builder = Attributes.builder(); + + defaultEventParams.forEach((k, v) -> putMe(k, v, builder)); + usageAttributes.forEach((k, v) -> putMe(k, v, builder)); + + current.addEvent(prefix(name), builder.build(), Instant.now()); + } + } + } + + private String prefix(String name) { + return prefix + name; + } + + private void putMe(String k, Object v, AttributesBuilder builder) { + if (v instanceof List) { + List list = (List) v; + final String result = list.stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.joining(",")); + builder.put(prefix(k), result); + } else if (v != null) { + builder.put(prefix(k), v.toString()); + } + } +} diff --git a/usage-adapters/featurehub-segment-adapter/.editorconfig b/usage-adapters/featurehub-segment-adapter/.editorconfig new file mode 100644 index 0000000..03ed53d --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/.editorconfig @@ -0,0 +1,19 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +indent_style = space + +[*.adoc] +trim_trailing_whitespace = false +indent_style = space + diff --git a/usage-adapters/featurehub-segment-adapter/README.adoc b/usage-adapters/featurehub-segment-adapter/README.adoc new file mode 100644 index 0000000..c277640 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/README.adoc @@ -0,0 +1,41 @@ += FeatureHub Segment(TM) Usage Plugin + +https://segment.com[Segment] is as described on their website. This plugin expects the use of the Segment Java SDK and a Segment write key or a preconfigured Analytics object to be provided. + +It can be used in either or both ways: + +- to Augment existing Segment messages but adding Feature information for the current user (`context`) whenever a message is tracked +- to allow specific recording of Feature evaluation events and allow you to record your own Usage events via the FeatureHub Java SDK. + +== Augmenting Segment messages + +This Plugin provides a `SegmentMessageTransformer` class that should be given to your Analytics object when building it. + +[source,java] +---- +public SegmentMessageTransformer(Message.Type[] augmentTypes, + Supplier<@Nullable ClientContext> contextSource, + boolean useAnonymousUser, boolean setUserOnMessage); +---- + +You must specify an array of message types you wish to augment, how the transformer gets the current context when logging, whether or not to always use an anonymous user, and whether to set the user on the message at all. + +NOTE: Unfortunately at the time of writing, there is no way to detect if the Message currently being built already has context or user data and therefore not overwrite it, so this class will overwrite any existing Context information. + +This class is called synchronously by the Segment SDK when building the message in your class, so if you are - for example - in a thread when you construct your message, you could store the `ClientContext` in that thread. The Java `todo` example +does this to make sure the context is available to the message transformer. + +== Tracking Feature usage + +The Usage plgin is `SegmentUsagePlugin` and needs to be registered with the FeatureHub config. e.g. + +[source,java] +---- +config.registerUsagePlugin(new SegmentUsagePlugin(segmentWriteKey)); +---- + +If you have set an environment variable `FEATUREHUB_USAGE_SEGMENT_WRITE_KEY` or a system property `featurehub.usage.segment-write-key`. + +There are several ways to create the SegmentUsagePlugin class, please take a look at the class for the various options. + +NOTE: All copyrights to Segment belong to them. diff --git a/usage-adapters/featurehub-segment-adapter/pom.xml b/usage-adapters/featurehub-segment-adapter/pom.xml index 53178dc..fb9fd45 100644 --- a/usage-adapters/featurehub-segment-adapter/pom.xml +++ b/usage-adapters/featurehub-segment-adapter/pom.xml @@ -11,12 +11,6 @@ - - 2.20.0 - 3.4.4 - 2.0.5 - - io.featurehub.sdk @@ -55,7 +49,8 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java new file mode 100644 index 0000000..9da35f7 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java @@ -0,0 +1,10 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.Analytics; + +/** + * If you wish to implement your own plugin + */ +public interface SegmentAnalyticsSource { + Analytics getAnalytics(); +} diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java new file mode 100644 index 0000000..bc32220 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java @@ -0,0 +1,71 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.MessageTransformer; +import com.segment.analytics.messages.Message; +import com.segment.analytics.messages.MessageBuilder; +import io.featurehub.client.ClientContext; +import io.featurehub.client.usage.UsageFeaturesCollectionContext; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.function.Supplier; + +/** + * SegmentMessageTransformer is designed to allow an analytics builder to attach the current user's features + * and context information (if any). Segment's MessageBuilder has no way of getting the current context, so being + * able to add information with multiple message transformers isn't possible. + *

+ * Issue: https://github.com/segmentio/analytics-java/issues/486 + */ +public class SegmentMessageTransformer implements MessageTransformer { + private final List augmentTypes; + private final Supplier<@Nullable ClientContext> contextSource; + private final boolean useAnonymousUser; + private final boolean setUserOnMessage; + + /** + * Creates a new Segment message transformer that augments all outgoing messages of the specified type with + * the current user's feature values. + * + * @param augmentTypes - what types of message to augment + * @param contextSource - how to get the current user's context, likely to involve ThreadLocalStorage + * @param useAnonymousUser - always use an anonymous user to prevent user-tracking burn through + * @param setUserOnMessage - should we even set the user in case something else is doing that. + */ + public SegmentMessageTransformer(Message.Type[] augmentTypes, + Supplier<@Nullable ClientContext> contextSource, + boolean useAnonymousUser, boolean setUserOnMessage) { + this.augmentTypes = List.of(augmentTypes); + this.contextSource = contextSource; + this.useAnonymousUser = useAnonymousUser; + this.setUserOnMessage = setUserOnMessage; + } + + @Override + public boolean transform(MessageBuilder builder) { + final ClientContext context = contextSource.get(); + + if (context != null && augmentTypes.contains(builder.type())) { + // create a holder that will collect the user and all the respective data + final UsageFeaturesCollectionContext usage = new UsageFeaturesCollectionContext(); + + context.fillUsageCollection(usage); + + augmentUser(builder, usage); + + builder.context(usage.toMap()); + } + + return true; + } + + private void augmentUser(MessageBuilder builder, UsageFeaturesCollectionContext usage) { + if (setUserOnMessage) { + if (useAnonymousUser) { + builder.userId("anonymous"); + } else { + builder.userId(usage.getUserKey()); + } + } + } +} diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java deleted file mode 100644 index 4935a92..0000000 --- a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsageAdapter.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.featurehub.sdk.usageadapter.segment; - -import com.segment.analytics.Analytics; -import com.segment.analytics.messages.TrackMessage; -import io.featurehub.client.usage.UsageEvent; -import io.featurehub.client.usage.UsageEventName; -import io.featurehub.client.usage.UsagePlugin; -import okhttp3.OkHttpClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class SegmentUsageAdapter extends UsagePlugin { - final Analytics analytics; - private static final Logger log = LoggerFactory.getLogger(SegmentUsageAdapter.class); - - public SegmentUsageAdapter(String segmentKey) { - analytics = Analytics.builder(segmentKey).build(); - } - - public SegmentUsageAdapter(String segmentKey, OkHttpClient okHttpClient) { - analytics = Analytics.builder(segmentKey).client(okHttpClient).build(); - } - - public SegmentUsageAdapter() { - final String segmentKey = System.getenv("FEATUREHUB_SEGMENT_KEY"); - - if (segmentKey == null) { - throw new RuntimeException("You must initialize with an env var `FEATUREHUB_SEGMENT_KEY` or provide one to the constructor"); - } - - analytics = Analytics.builder(segmentKey).build(); - } - - @Override - public void send(UsageEvent event) { - if (event instanceof UsageEventName) { - final String userId = event.getUserKey() == null ? "anonymous" : event.getUserKey(); - - log.trace("segment event {} with key {}", ((UsageEventName) event).getEventName(), userId); - - final TrackMessage.Builder builder = - TrackMessage.builder(((UsageEventName) event).getEventName()).userId(userId).properties(event.toMap()); - - analytics.enqueue(builder); - } - } -} diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java new file mode 100644 index 0000000..3ae8fe4 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java @@ -0,0 +1,108 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.Analytics; +import com.segment.analytics.MessageTransformer; +import com.segment.analytics.messages.TrackMessage; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsagePlugin; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * The Segment Usage Adapter is used when you wish to track "feature" events - so when a feature is evaluated + */ +public class SegmentUsagePlugin extends UsagePlugin implements SegmentAnalyticsSource { + final Analytics analytics; + private static final Logger log = LoggerFactory.getLogger(SegmentUsagePlugin.class); + + public SegmentUsagePlugin(String segmentKey) { + analytics = Analytics.builder(segmentKey).build(); + } + + public SegmentUsagePlugin(@NotNull String segmentKey, @Nullable List segmentMessageTransformer) { + this(segmentKey, null, segmentMessageTransformer); + } + + /** + * Use this constructor if you wish to provide your own OkHttpClient with proxies, timeouts and so forth. + * + * @param segmentKey - the segment write key for a java source + * @param okHttpClient - an okhttp client configured for use + */ + public SegmentUsagePlugin(String segmentKey, @Nullable OkHttpClient okHttpClient, @Nullable List segmentMessageTransformer) { + final Analytics.Builder builder = Analytics.builder(segmentKey); + + if (okHttpClient != null) { + builder.client(okHttpClient); + } + + if (segmentMessageTransformer != null) { + segmentMessageTransformer.forEach(builder::messageTransformer); + } + + analytics = builder.build(); + } + + /** + * Use this constructor if you want/need to create your own Analytics object. + * + * @param analytics - the provided analytics object. + */ + public SegmentUsagePlugin(Analytics analytics) { + this.analytics = analytics; + } + + /** + * This constructor assumes the segment write key is an environment variable `FEATUREHUB_SEGMENT_WRITE_KEY` + * or a system property `featurehub.segment-write-key`. It will construct the analytics object directly with the key + * and all other settings being default. + */ + + public SegmentUsagePlugin() { + this(Analytics.builder(segmentKey()).build()); + } + + /** + * Use this function to get the segment write key if you wish to provide your own OkHttpClient but use the standard + * keys for segment. + * + * @return configured segment key or RuntimeException if not found. + */ + public static String segmentKey() { + String segmentKey = System.getenv("FEATUREHUB_USAGE_SEGMENT_WRITE_KEY"); + + if (segmentKey == null) { + segmentKey = System.getProperty("featurehub.usage.segment-write-key"); + + if (segmentKey == null) { + throw new RuntimeException("You must initialize with an env var `FEATUREHUB_SEGMENT_KEY` or provide one to the constructor"); + } + } + + return segmentKey; + } + + @Override + public void send(UsageEvent event) { + if (event instanceof UsageEventName) { + final String userId = event.getUserKey() == null ? "anonymous" : event.getUserKey(); + + log.trace("segment event {} with key {}", ((UsageEventName) event).getEventName(), userId); + + final TrackMessage.Builder builder = + TrackMessage.builder(((UsageEventName) event).getEventName()).userId(userId).context(event.toMap()); + + analytics.enqueue(builder); + } + } + + public Analytics getAnalytics() { + return analytics; + } +} diff --git a/usage-adapters/pom.xml b/usage-adapters/pom.xml index 1434de8..e965502 100644 --- a/usage-adapters/pom.xml +++ b/usage-adapters/pom.xml @@ -35,5 +35,6 @@ featurehub-segment-adapter + featurehub-opentelemetry-adapter From 4076ebfb5f39121a3fc995fcec39986fd6cc0971 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Mon, 19 Aug 2024 21:03:13 +1200 Subject: [PATCH 10/22] testing end to end jersey2 and jersey3 --- build_only.sh | 4 + .../java-client-jersey2}/.gitignore | 0 .../java-client-jersey2}/CHANGELOG.adoc | 0 .../java-client-jersey2}/README.adoc | 0 .../java-client-jersey2}/pom.xml | 4 +- .../client/jersey/FeatureService.java | 0 .../client/jersey/FeatureServiceImpl.java | 0 .../jersey/JerseyFeatureHubClientFactory.java | 5 +- .../client/jersey/JerseySSEClient.java | 46 +++-- .../featurehub/client/jersey/RestClient.java | 69 ++++--- .../client/jersey/TestSDKClient.java | 1 - .../server/jersey/FeatureFlagEnabled.java | 0 ...atureRequiredApplicationEventListener.java | 0 ....featurehub.client.FeatureHubClientFactory | 0 .../client/jersey/InternalFeature.groovy | 0 .../client/jersey/JerseySSEClientSpec.groovy | 0 .../client/jersey/RestClientSpec.groovy | 0 .../client/jersey/SSETestHarness.groovy | 0 .../client/jersey/JerseyClientSample.java | 0 .../src/test/resources/log4j2.xml | 0 .../java-client-jersey3}/.gitignore | 0 .../java-client-jersey3}/CHANGELOG.adoc | 0 .../java-client-jersey3}/README.adoc | 0 .../java-client-jersey3}/pom.xml | 0 .../client/jersey/FeatureService.java | 0 .../client/jersey/FeatureServiceImpl.java | 0 .../jersey/JerseyFeatureHubClientFactory.java | 10 +- .../client/jersey/JerseySSEClient.java | 49 +++-- .../featurehub/client/jersey/RestClient.java | 52 ++++- .../client/jersey/TestSDKClient.java | 0 .../server/jersey/FeatureFlagEnabled.java | 0 ...atureRequiredApplicationEventListener.java | 0 ....featurehub.client.FeatureHubClientFactory | 0 .../client/jersey/InternalFeature.groovy | 0 .../client/jersey/JerseySSEClientSpec.groovy | 0 .../client/jersey/RestClientSpec.groovy | 0 .../client/jersey/SSETestHarness.groovy | 0 .../client/jersey/JerseyClientSample.java | 0 .../src/test/resources/log4j2.xml | 0 .../java-client-okhttp}/CHANGELOG.adoc | 0 .../java-client-okhttp}/README.adoc | 0 .../java-client-okhttp}/pom.xml | 11 +- .../okhttp/OkHttpFeatureHubFactory.java | 2 +- .../java/io/featurehub/okhttp/RestClient.java | 0 .../java/io/featurehub/okhttp/SSEClient.java | 0 .../java/io/featurehub/okhttp/TestClient.java | 0 ....featurehub.client.FeatureHubClientFactory | 0 .../featurehub/okhttp/RestClientSpec.groovy | 0 .../io/featurehub/okhttp/SSEClientSpec.groovy | 0 .../featurehub/okhttp/TestClientSpec.groovy | 0 .../android/FeatureHubClientRunner.java | 0 .../src/test/resources/log4j2.xml | 0 .../client/PollingDelegateEdgeService.java | 88 --------- .../client-java-api}/README.adoc | 0 .../client-java-api}/pom.xml | 0 .../java/io/featurehub/sse/model/Package.java | 0 .../client-java-core}/.gitignore | 0 .../client-java-core}/CHANGELOG.adoc | 0 .../client-java-core}/README.adoc | 0 .../client-java-core}/pom.xml | 0 .../java/io/featurehub/client/Applied.java | 0 .../io/featurehub/client/ApplyFeature.java | 0 .../featurehub/client/BaseClientContext.java | 0 .../io/featurehub/client/ClientContext.java | 0 .../client/ClientEvalFeatureContext.java | 2 +- .../client/ClientFeatureRepository.java | 6 +- .../client/EdgeFeatureHubConfig.java | 24 +-- .../io/featurehub/client/EdgeService.java | 4 + .../java/io/featurehub/client/Feature.java | 0 .../client/FeatureHubClientFactory.java | 0 .../featurehub/client/FeatureHubConfig.java | 9 +- .../io/featurehub/client/FeatureListener.java | 0 .../featurehub/client/FeatureRepository.java | 0 .../io/featurehub/client/FeatureState.java | 0 .../featurehub/client/FeatureStateBase.java | 2 +- .../featurehub/client/FeatureStateUtils.java | 3 +- .../client/FeatureValueInterceptor.java | 0 .../client/FeatureValueInterceptorHolder.java | 0 .../io/featurehub/client/InternalContext.java | 0 .../client/InternalFeatureRepository.java | 3 +- .../client/PollingDelegateEdgeService.java | 132 +++++++++++++ .../java/io/featurehub/client/Readiness.java | 0 .../client/RepositoryEventHandler.java | 0 .../client/ServerEvalFeatureContext.java | 0 .../java/io/featurehub/client/TestApi.java | 0 .../io/featurehub/client/TestApiResult.java | 0 .../featurehub/client/ThreadLocalContext.java | 0 .../client/edge/EdgeConnectionState.java | 8 +- .../client/edge/EdgeReconnector.java | 0 .../client/edge/EdgeRetryService.java | 6 + .../featurehub/client/edge/EdgeRetryer.java | 105 ++++++++-- .../SystemPropertyValueInterceptor.java | 0 .../client/usage/FeatureHubUsageValue.java | 0 .../featurehub/client/usage/UsageAdapter.java | 0 .../featurehub/client/usage/UsageEvent.java | 0 .../client/usage/UsageEventName.java | 0 .../client/usage/UsageEventWithFeature.java | 0 .../client/usage/UsageFeaturesCollection.java | 0 .../usage/UsageFeaturesCollectionContext.java | 0 .../featurehub/client/usage/UsagePlugin.java | 0 .../client/usage/UsageProvider.java | 0 .../featurehub/client/utils/SdkVersion.java | 0 .../matchers/BooleanArrayMatcher.java | 0 .../strategies/matchers/CIDRMatch.java | 0 .../strategies/matchers/DateArrayMatcher.java | 0 .../matchers/DateTimeArrayMatcher.java | 0 .../matchers/IpAddressArrayMatcher.java | 0 .../strategies/matchers/MatcherRegistry.java | 0 .../matchers/MatcherRepository.java | 0 .../matchers/NumberArrayMatcher.java | 0 .../matchers/SemanticVersionArrayMatcher.java | 0 .../matchers/SemanticVersionComparable.java | 0 .../strategies/matchers/StrategyMatcher.java | 0 .../matchers/StringArrayMatcher.java | 0 .../percentage/Murmur3_32HashFunction.java | 0 .../percentage/PercentageCalculator.java | 0 .../percentage/PercentageMumurCalculator.java | 0 .../io/featurehub/client/BananaSample.groovy | 0 .../client/BaseClientContextSpec.groovy | 0 .../client/EdgeFeatureHubConfigSpec.groovy | 0 .../client/FeatureHubTestClientFactory.groovy | 0 .../featurehub/client/InterceptorSpec.groovy | 0 .../io/featurehub/client/ListenerSpec.groovy | 0 .../featurehub/client/RepositorySpec.groovy | 0 .../featurehub/client/SdkVersionSpec.groovy | 0 .../client/ServerEvalContextSpec.groovy | 0 .../io/featurehub/client/StrategySpec.groovy | 0 .../io/featurehub/client/TestContext.groovy | 0 .../client/edge/EdgeRetryerSpec.groovy | 8 +- .../PercentageMurmurCalculatorSpec.groovy | 0 .../src/test/resources/META-INF/MANIFEST.MF | 0 ....featurehub.client.FeatureHubClientFactory | 0 .../src/test/resources/log4j2.xml | 0 examples/{todo-java => }/.editorconfig | 0 examples/pom.xml | 6 +- examples/todo-java-jersey2/pom.xml | 182 ++++++++++++++++++ .../main/java/todo/backend/Application.java | 71 +++++++ .../java/todo/backend/FeatureHubSource.java | 38 ++-- .../resources/FeatureAnalyticsFilter.java | 64 ++++++ .../backend/resources/HealthResource.java | 29 +++ .../resources/LocalExceptionMapper.java | 32 +++ .../todo/backend/resources/TodoResource.java | 152 +++++++++++++++ .../src/test/java/todo/backend/AppRunner.java | 0 .../{todo-java => todo-java-jersey3}/pom.xml | 117 ++--------- .../main/java/todo/backend/Application.java | 12 +- .../java/todo/backend/FeatureHubSource.java | 89 +++++++++ .../resources/FeatureAnalyticsFilter.java | 1 - .../backend/resources/HealthResource.java | 0 .../resources/LocalExceptionMapper.java | 32 +++ .../todo/backend/resources/TodoResource.java | 14 +- .../src/test/java/todo/backend/AppRunner.java | 13 ++ examples/todo-java-shared/.editorconfig | 24 +++ .../.gitignore | 0 .../README.adoc | 0 examples/todo-java-shared/pom.xml | 155 +++++++++++++++ .../src/main/java/todo/Features.java | 0 .../main/java/todo/backend/FeatureHub.java | 0 .../FeatureHubClientContextThreadLocal.java | 0 .../todo/backend/UsageRequestMeasurement.java | 0 .../src/main/resources/log4j2.xml | 0 .../src/test/java/todo/backend/.keep | 1 + .../todo-api.yaml | 3 + .../{todo-java => todo-java-shared}/todo.txt | 0 pom.xml | 12 +- .../client-java-loadtest}/pom.xml | 0 .../java/io/featurehub/loadtest/LoadTest.java | 0 .../src/main/resources/log4j2.xml | 0 .../java/io/featurehub/LoadTestRunner.java | 0 support/composite-jersey2/pom.xml | 6 + .../client-java-android21}/CHANGELOG.adoc | 0 .../client-java-android21}/README.adoc | 0 .../client-java-android21}/pom.xml | 0 .../AndroidFeatureHubClientFactory.java | 0 .../featurehub/android/FeatureHubClient.java | 0 .../client/AbstractFeatureRepository.java | 0 .../featurehub/client/AnalyticsCollector.java | 0 .../java/io/featurehub/client/Applied.java | 0 .../io/featurehub/client/ApplyFeature.java | 0 .../featurehub/client/BaseClientContext.java | 0 .../io/featurehub/client/ClientContext.java | 0 .../client/ClientEvalFeatureContext.java | 0 .../client/ClientFeatureRepository.java | 0 .../client/EdgeFeatureHubConfig.java | 0 .../io/featurehub/client/EdgeService.java | 0 .../java/io/featurehub/client/Feature.java | 0 .../client/FeatureHubClientFactory.java | 0 .../featurehub/client/FeatureHubConfig.java | 0 .../io/featurehub/client/FeatureListener.java | 0 .../featurehub/client/FeatureRepository.java | 0 .../client/FeatureRepositoryContext.java | 0 .../io/featurehub/client/FeatureState.java | 0 .../featurehub/client/FeatureStateBase.java | 0 .../featurehub/client/FeatureStateUtils.java | 0 .../io/featurehub/client/FeatureStore.java | 0 .../client/FeatureValueInterceptor.java | 0 .../client/FeatureValueInterceptorHolder.java | 0 .../client/GoogleAnalyticsApiClient.java | 0 .../client/GoogleAnalyticsCollector.java | 0 .../io/featurehub/client/ObjectSupplier.java | 0 .../java/io/featurehub/client/Readyness.java | 0 .../featurehub/client/ReadynessListener.java | 0 .../client/ServerEvalFeatureContext.java | 0 .../client/edge/EdgeConnectionState.java | 0 .../client/edge/EdgeReconnector.java | 0 .../client/edge/EdgeRetryService.java | 0 .../featurehub/client/edge/EdgeRetryer.java | 0 .../SystemPropertyValueInterceptor.java | 0 .../featurehub/client/utils/SdkVersion.java | 0 .../matchers/BooleanArrayMatcher.java | 0 .../strategies/matchers/CIDRMatch.java | 0 .../strategies/matchers/DateArrayMatcher.java | 0 .../matchers/DateTimeArrayMatcher.java | 0 .../matchers/IpAddressArrayMatcher.java | 0 .../strategies/matchers/MatcherRegistry.java | 0 .../matchers/MatcherRepository.java | 0 .../matchers/NumberArrayMatcher.java | 0 .../matchers/SemanticVersionArrayMatcher.java | 0 .../matchers/SemanticVersionComparable.java | 0 .../strategies/matchers/StrategyMatcher.java | 0 .../matchers/StringArrayMatcher.java | 0 .../percentage/Murmur3_32HashFunction.java | 0 .../percentage/PercentageCalculator.java | 0 .../percentage/PercentageMumurCalculator.java | 0 ....featurehub.client.FeatureHubClientFactory | 0 .../android/FeatureHubClientSpec.groovy | 0 .../android/FeatureHubClientRunner.java | 0 .../src/test/resources/log4j2.xml | 0 .../README.adoc | 8 +- .../featurehub-opentelemetry-adapter/pom.xml | 4 +- .../OpenTelemetryUsagePlugin.java | 14 +- .../featurehub-segment-adapter/pom.xml | 4 +- 231 files changed, 1371 insertions(+), 365 deletions(-) create mode 100755 build_only.sh rename {client-java-jersey => client-implementations/java-client-jersey2}/.gitignore (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/CHANGELOG.adoc (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/README.adoc (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/pom.xml (98%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/FeatureService.java (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java (87%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java (84%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/RestClient.java (79%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/client/jersey/TestSDKClient.java (99%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java (100%) rename {client-java-jersey => client-implementations/java-client-jersey2}/src/test/resources/log4j2.xml (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/.gitignore (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/CHANGELOG.adoc (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/README.adoc (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/pom.xml (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/FeatureService.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java (100%) rename {client-java-jersey => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java (84%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java (83%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/RestClient.java (80%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/client/jersey/TestSDKClient.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java (100%) rename {client-java-jersey3 => client-implementations/java-client-jersey3}/src/test/resources/log4j2.xml (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/CHANGELOG.adoc (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/README.adoc (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/pom.xml (94%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java (96%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/main/java/io/featurehub/okhttp/RestClient.java (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/main/java/io/featurehub/okhttp/SSEClient.java (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/main/java/io/featurehub/okhttp/TestClient.java (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy (100%) rename {client-java-okhttp => client-implementations/java-client-okhttp}/src/test/java/io/featurehub/android/FeatureHubClientRunner.java (100%) rename {client-java-android21 => client-implementations/java-client-okhttp}/src/test/resources/log4j2.xml (100%) delete mode 100644 client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java rename {client-java-api => core/client-java-api}/README.adoc (100%) rename {client-java-api => core/client-java-api}/pom.xml (100%) rename {client-java-api => core/client-java-api}/src/main/java/io/featurehub/sse/model/Package.java (100%) rename {client-java-core => core/client-java-core}/.gitignore (100%) rename {client-java-core => core/client-java-core}/CHANGELOG.adoc (100%) rename {client-java-core => core/client-java-core}/README.adoc (100%) rename {client-java-core => core/client-java-core}/pom.xml (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/Applied.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ApplyFeature.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/BaseClientContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ClientContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java (96%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ClientFeatureRepository.java (98%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java (92%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/EdgeService.java (92%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/client/Feature.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureHubClientFactory.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureHubConfig.java (94%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureListener.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureRepository.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureState.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureStateBase.java (98%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/FeatureStateUtils.java (81%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/client/FeatureValueInterceptor.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/InternalContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/InternalFeatureRepository.java (97%) create mode 100644 core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/Readiness.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/RepositoryEventHandler.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/TestApi.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/TestApiResult.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/ThreadLocalContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java (65%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/client/edge/EdgeReconnector.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/edge/EdgeRetryService.java (92%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/edge/EdgeRetryer.java (69%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageAdapter.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageEvent.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageEventName.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsagePlugin.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/usage/UsageProvider.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/client/utils/SdkVersion.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java (100%) rename {client-java-android21 => core/client-java-core}/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java (100%) rename {client-java-core => core/client-java-core}/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/BananaSample.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/ListenerSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/RepositorySpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/StrategySpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/TestContext.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy (91%) rename {client-java-core => core/client-java-core}/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy (100%) rename {client-java-core => core/client-java-core}/src/test/resources/META-INF/MANIFEST.MF (100%) rename {client-java-core => core/client-java-core}/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory (100%) rename {client-java-core => core/client-java-core}/src/test/resources/log4j2.xml (100%) rename examples/{todo-java => }/.editorconfig (100%) create mode 100644 examples/todo-java-jersey2/pom.xml create mode 100644 examples/todo-java-jersey2/src/main/java/todo/backend/Application.java rename examples/{todo-java => todo-java-jersey2}/src/main/java/todo/backend/FeatureHubSource.java (65%) create mode 100644 examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java create mode 100644 examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java create mode 100644 examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java create mode 100644 examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java rename examples/{todo-java => todo-java-jersey2}/src/test/java/todo/backend/AppRunner.java (100%) rename examples/{todo-java => todo-java-jersey3}/pom.xml (64%) rename examples/{todo-java => todo-java-jersey3}/src/main/java/todo/backend/Application.java (90%) create mode 100644 examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java rename examples/{todo-java => todo-java-jersey3}/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java (98%) rename examples/{todo-java => todo-java-jersey3}/src/main/java/todo/backend/resources/HealthResource.java (100%) create mode 100644 examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java rename examples/{todo-java => todo-java-jersey3}/src/main/java/todo/backend/resources/TodoResource.java (92%) create mode 100644 examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java create mode 100644 examples/todo-java-shared/.editorconfig rename examples/{todo-java => todo-java-shared}/.gitignore (100%) rename examples/{todo-java => todo-java-shared}/README.adoc (100%) create mode 100644 examples/todo-java-shared/pom.xml rename examples/{todo-java => todo-java-shared}/src/main/java/todo/Features.java (100%) rename examples/{todo-java => todo-java-shared}/src/main/java/todo/backend/FeatureHub.java (100%) rename examples/{todo-java => todo-java-shared}/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java (100%) rename examples/{todo-java => todo-java-shared}/src/main/java/todo/backend/UsageRequestMeasurement.java (100%) rename examples/{todo-java => todo-java-shared}/src/main/resources/log4j2.xml (100%) create mode 100644 examples/todo-java-shared/src/test/java/todo/backend/.keep rename examples/{todo-java => todo-java-shared}/todo-api.yaml (97%) rename examples/{todo-java => todo-java-shared}/todo.txt (100%) rename {client-java-loadtest => support/client-java-loadtest}/pom.xml (100%) rename {client-java-loadtest => support/client-java-loadtest}/src/main/java/io/featurehub/loadtest/LoadTest.java (100%) rename {client-java-loadtest => support/client-java-loadtest}/src/main/resources/log4j2.xml (100%) rename {client-java-loadtest => support/client-java-loadtest}/src/test/java/io/featurehub/LoadTestRunner.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/CHANGELOG.adoc (100%) rename {client-java-android21 => unmaintained/client-java-android21}/README.adoc (100%) rename {client-java-android21 => unmaintained/client-java-android21}/pom.xml (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/android/FeatureHubClient.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/AbstractFeatureRepository.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/AnalyticsCollector.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/Applied.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ApplyFeature.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/BaseClientContext.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ClientContext.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ClientFeatureRepository.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/EdgeService.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/Feature.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureHubClientFactory.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureHubConfig.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureListener.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureRepository.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureRepositoryContext.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureState.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureStateBase.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureStateUtils.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureStore.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureValueInterceptor.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ObjectSupplier.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/Readyness.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ReadynessListener.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/edge/EdgeReconnector.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/edge/EdgeRetryService.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/edge/EdgeRetryer.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/client/utils/SdkVersion.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java (100%) rename {client-java-core => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy (100%) rename {client-java-android21 => unmaintained/client-java-android21}/src/test/java/io/featurehub/android/FeatureHubClientRunner.java (100%) rename {client-java-okhttp => unmaintained/client-java-android21}/src/test/resources/log4j2.xml (100%) diff --git a/build_only.sh b/build_only.sh new file mode 100755 index 0000000..eb933ac --- /dev/null +++ b/build_only.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -x +cd support && mvn -DskipTests=true -f pom-tiles.xml install && mvn install && cd .. && mvn -T4C -DskipTests=true clean install + diff --git a/client-java-jersey/.gitignore b/client-implementations/java-client-jersey2/.gitignore similarity index 100% rename from client-java-jersey/.gitignore rename to client-implementations/java-client-jersey2/.gitignore diff --git a/client-java-jersey/CHANGELOG.adoc b/client-implementations/java-client-jersey2/CHANGELOG.adoc similarity index 100% rename from client-java-jersey/CHANGELOG.adoc rename to client-implementations/java-client-jersey2/CHANGELOG.adoc diff --git a/client-java-jersey/README.adoc b/client-implementations/java-client-jersey2/README.adoc similarity index 100% rename from client-java-jersey/README.adoc rename to client-implementations/java-client-jersey2/README.adoc diff --git a/client-java-jersey/pom.xml b/client-implementations/java-client-jersey2/pom.xml similarity index 98% rename from client-java-jersey/pom.xml rename to client-implementations/java-client-jersey2/pom.xml index 6a23be3..2806af1 100644 --- a/client-java-jersey/pom.xml +++ b/client-implementations/java-client-jersey2/pom.xml @@ -3,9 +3,9 @@ 4.0.0 io.featurehub.sdk - java-client-jersey + java-client-jersey2 3.1-SNAPSHOT - java-client-jersey + java-client-jersey2 Jersey client for featurehub diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureService.java similarity index 100% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureService.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureService.java diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java similarity index 100% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java similarity index 87% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 76480dc..d4cb885 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -16,7 +16,7 @@ public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { - return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build()); } @Override @@ -29,7 +29,8 @@ public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { - return () -> new RestClient(repository, null, config, timeoutInSeconds, amPollingDelegate); + return () -> new RestClient(repository, null, config, + EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), timeoutInSeconds, amPollingDelegate); } @Override diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java similarity index 84% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index b5c1fe5..08b67fa 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -25,6 +25,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.ConnectException; +import java.net.SocketException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -44,7 +46,7 @@ public class JerseySSEClient implements EdgeService, EdgeReconnector { public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { - this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; + this.repository = repository == null ? config.getInternalRepository() : repository; this.config = config; this.retryer = retryer; @@ -58,7 +60,9 @@ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull .register(SseFeature.class).build(); client.property(ClientProperties.CONNECT_TIMEOUT, retryer.getServerConnectTimeoutMs()); - client.property(ClientProperties.READ_TIMEOUT, retryer.getServerConnectTimeoutMs()); + client.property(ClientProperties.READ_TIMEOUT, retryer.getServerReadTimeoutMs()); + + log.trace("client set connect timeout to {} and read timeout to {}", retryer.getServerConnectTimeoutMs(), retryer.getServerReadTimeoutMs()); target = makeEventSourceTarget(client, config.getRealtimeUrl()); } @@ -107,6 +111,8 @@ public void close() { } eventSource = null; + + retryer.close(); } } @@ -156,11 +162,20 @@ private void initEventSource() { event = eventSource.read(); if (event == null) { - interrupted = true; + log.trace("server read timed out"); + + if (eventSource.isClosed()) { + eventSource = null; + + retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + } + + continue; } data = event.readData(); } catch (Exception e) { + onMakeEventSourceException(e); log.error("failed read", e); interrupted = true; continue; @@ -169,10 +184,8 @@ private void initEventSource() { connectionSaidBye = processResult(connectionSaidBye, data, event); } - if (retryer.isStopped() || eventSource.isClosed() || interrupted) { - final boolean closedOrInterrupted = eventSource.isClosed() || interrupted; - - log.trace("[featurehub] closed"); + if (retryer.isStopped()) { + log.trace("[featurehub] event source closed? {} interrupted? {} retryer stopped? {}", eventSource.isClosed(), interrupted, retryer.isStopped()); if (!eventSource.isClosed()) { close(); } @@ -180,12 +193,6 @@ private void initEventSource() { checkForUnsatisfactoryConversation(); notifyWaitingClients(); - - if (closedOrInterrupted) { - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); - } } } @@ -237,21 +244,28 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve } private void notifyWaitingClients() { + log.trace("notifying {} waiting clients", waitingClients.size()); waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } private void onMakeEventSourceException(Exception e) { log.info("[featurehub-sdk] failed to connect to {}", config.getRealtimeUrl()); - if (e instanceof WebApplicationException) { + if (e instanceof ConnectException) { + retryer.edgeResult(EdgeConnectionState.CONNECTION_FAILURE, this); + } else if (e instanceof SocketException) { + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); + } else if (e instanceof WebApplicationException) { WebApplicationException wae = (WebApplicationException) e; final Response response = wae.getResponse(); if (response != null && response.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR) { retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); + } else if (response != null && (response.getStatusInfo().getStatusCode() == 403 || response.getStatusInfo().getStatusCode() == 401)) { + retryer.edgeResult(EdgeConnectionState.FAILURE, this); } else { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); } } else { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); } } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java similarity index 79% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java index 982542f..a253cc0 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -6,15 +6,19 @@ import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.Readiness; +import io.featurehub.client.edge.EdgeRetryService; +import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; +import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.jackson.JacksonFeature; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.ws.rs.RedirectionException; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import java.io.IOException; @@ -32,6 +36,7 @@ public class RestClient implements EdgeService { @NotNull private final InternalFeatureRepository repository; @NotNull private final FeatureService client; + @NotNull private final EdgeRetryService edgeRetryer; @Nullable private String xFeaturehubHeader; // used for breaking the cache @@ -41,10 +46,10 @@ public class RestClient implements EdgeService { @Nullable private String etag = null; private long pollingInterval; - private final boolean breakCacheOnPoll; private long whenPollingCacheExpires; private final boolean clientSideEvaluation; + private final boolean breakCacheOnEveryCheck; @NotNull private final FeatureHubConfig config; /** @@ -53,51 +58,40 @@ public class RestClient implements EdgeService { * @param repository - expected to be null, but able to be passed in because of special use cases * @param client - expected to be null, but able to be passed in because of testing * @param config - FH config - * @param timeoutInSeconds - use 0 for once off and for when using an actual timer + * @param edgeRetryer - used for timeouts + * @param stateTimeoutInSeconds - use 0 for once off and for when using an actual timer + * @param breakCacheOnEveryCheck - this is used by the PollingDelegate to tell the client to just do a GET when its requested to */ public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, - int timeoutInSeconds, - boolean breakCacheOnPoll) { + @NotNull EdgeRetryService edgeRetryer, + int stateTimeoutInSeconds, boolean breakCacheOnEveryCheck) { + this.edgeRetryer = edgeRetryer; if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } - this.breakCacheOnPoll = breakCacheOnPoll; + this.breakCacheOnEveryCheck = breakCacheOnEveryCheck; this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; - this.pollingInterval = timeoutInSeconds; + this.pollingInterval = stateTimeoutInSeconds; // ensure the poll has expired the first time we ask for it whenPollingCacheExpires = System.currentTimeMillis() - 100; this.clientSideEvaluation = !config.isServerEvaluation(); - - if (clientSideEvaluation) { - checkForUpdates(null); - } } @NotNull protected FeatureService makeClient(FeatureHubConfig config) { Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class).build(); - return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); - } + client.property(ClientProperties.CONNECT_TIMEOUT, edgeRetryer.getServerConnectTimeoutMs()); + client.property(ClientProperties.READ_TIMEOUT, edgeRetryer.getServerReadTimeoutMs()); - public RestClient(@NotNull FeatureHubConfig config, - int timeoutInSeconds) { - this(null, null, config, timeoutInSeconds, false); - } - - public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { - this(repository, null, config, 180, false); - } - - public RestClient(@NotNull FeatureHubConfig config) { - this(null, null, config, 180, false); + return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); } private boolean busy = false; @@ -110,9 +104,11 @@ protected Long now() { public boolean checkForUpdates(@Nullable CompletableFuture change) { final boolean breakCache = - breakCacheOnPoll || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + breakCacheOnEveryCheck || pollingInterval == 0 || (now() > whenPollingCacheExpires) || headerChanged; final boolean ask = !busy && !stopped && breakCache; + log.trace("ask {}, busy {}, stopped {}, breakCache {}", ask, busy, stopped, breakCache); + headerChanged = false; if (ask) { @@ -136,6 +132,13 @@ public boolean checkForUpdates(@Nullable CompletableFuture change) { final ApiResponse> response = client.getFeatureStates(config.apiKeys(), xContextSha, headers); processResponse(response); + } catch (RedirectionException re) { + // 304 not modified is fine + if (re.getResponse().getStatus() != 304) { + processFailure(re); + } else { // not modified + completeReadiness(); + } } catch (Exception e) { processFailure(e); } finally { @@ -236,6 +239,7 @@ protected void processResponse(ApiResponse> r private void completeReadiness() { List> current = waitingClients; waitingClients = new ArrayList<>(); + log.trace("notifying {} clients", current.size()); current.forEach(c -> { try { c.complete(repository.getReadiness()); @@ -245,6 +249,11 @@ private void completeReadiness() { }); } + @Override + public boolean needsContextChange(String newHeader, String contextSha) { + return etag == null || repository.getReadiness() != Readiness.Ready || (!isClientEvaluation() && (newHeader != null && !newHeader.equals(xFeaturehubHeader))); + } + @Override public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { final CompletableFuture change = new CompletableFuture<>(); @@ -257,8 +266,15 @@ private void completeReadiness() { // if there is already another change running, you are out of luck if (busy) { waitingClients.add(change); - } else if (!checkForUpdates(change)) { - change.complete(repository.getReadiness()); + } else { + // if we haven't evaluated the client before or is we are doing server side evaluation and the context changed + if (etag == null || !isClientEvaluation() || repository.getReadiness() != Readiness.Ready) { + if (!checkForUpdates(change)) { + change.complete(repository.getReadiness()); + } + } else { + change.complete(repository.getReadiness()); + } } return change; @@ -271,6 +287,7 @@ public boolean isClientEvaluation() { @Override public void close() { + edgeRetryer.close(); log.info("featurehub client closed."); } diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/TestSDKClient.java similarity index 99% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/TestSDKClient.java index 7a97527..28edb97 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/TestSDKClient.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/TestSDKClient.java @@ -39,6 +39,5 @@ public TestSDKClient(FeatureHubConfig config) { @Override public void close() { - close(); } } diff --git a/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java similarity index 100% rename from client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java diff --git a/client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java similarity index 100% rename from client-java-jersey/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java rename to client-implementations/java-client-jersey2/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java diff --git a/client-java-jersey/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-implementations/java-client-jersey2/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-jersey/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to client-implementations/java-client-jersey2/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy similarity index 100% rename from client-java-jersey/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy rename to client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy similarity index 100% rename from client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy rename to client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy similarity index 100% rename from client-java-jersey/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy rename to client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy similarity index 100% rename from client-java-jersey/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy rename to client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy diff --git a/client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-implementations/java-client-jersey2/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java similarity index 100% rename from client-java-jersey/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java rename to client-implementations/java-client-jersey2/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java diff --git a/client-java-jersey/src/test/resources/log4j2.xml b/client-implementations/java-client-jersey2/src/test/resources/log4j2.xml similarity index 100% rename from client-java-jersey/src/test/resources/log4j2.xml rename to client-implementations/java-client-jersey2/src/test/resources/log4j2.xml diff --git a/client-java-jersey3/.gitignore b/client-implementations/java-client-jersey3/.gitignore similarity index 100% rename from client-java-jersey3/.gitignore rename to client-implementations/java-client-jersey3/.gitignore diff --git a/client-java-jersey3/CHANGELOG.adoc b/client-implementations/java-client-jersey3/CHANGELOG.adoc similarity index 100% rename from client-java-jersey3/CHANGELOG.adoc rename to client-implementations/java-client-jersey3/CHANGELOG.adoc diff --git a/client-java-jersey3/README.adoc b/client-implementations/java-client-jersey3/README.adoc similarity index 100% rename from client-java-jersey3/README.adoc rename to client-implementations/java-client-jersey3/README.adoc diff --git a/client-java-jersey3/pom.xml b/client-implementations/java-client-jersey3/pom.xml similarity index 100% rename from client-java-jersey3/pom.xml rename to client-implementations/java-client-jersey3/pom.xml diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java similarity index 100% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/FeatureService.java diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java similarity index 100% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java diff --git a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java similarity index 84% rename from client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java index 5add294..d4cb885 100644 --- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java +++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java @@ -13,28 +13,34 @@ public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory { @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { - return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build()); } @Override + @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { return createSSEEdge(config, null); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { - return () -> new RestClient(repository, null, config, timeoutInSeconds, amPollingDelegate); + return () -> new RestClient(repository, null, config, + EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { return createRestEdge(config, null, timeoutInSeconds, amPollingDelegate); } @Override + @NotNull public Supplier createTestApi(@NotNull FeatureHubConfig config) { return () -> new TestSDKClient(config); } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java similarity index 83% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 1296243..bba87b6 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -25,6 +25,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.ConnectException; +import java.net.SocketException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -58,12 +60,15 @@ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull .register(SseFeature.class).build(); client.property(ClientProperties.CONNECT_TIMEOUT, retryer.getServerConnectTimeoutMs()); - client.property(ClientProperties.READ_TIMEOUT, retryer.getServerConnectTimeoutMs()); + client.property(ClientProperties.READ_TIMEOUT, retryer.getServerReadTimeoutMs()); + + log.trace("client set connect timeout to {} and read timeout to {}", retryer.getServerConnectTimeoutMs(), retryer.getServerReadTimeoutMs()); target = makeEventSourceTarget(client, config.getRealtimeUrl()); } - @NotNull protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { + @NotNull + protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { return client.target(sdkUrl); } @@ -73,7 +78,7 @@ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull ( (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || (xFeaturehubHeader != null && !xFeaturehubHeader.equals(newHeader)) - ) ) { + )) { log.warn("[featurehub-sdk] please only use server evaluated keys with SSE with one repository per SSE client."); @@ -156,11 +161,21 @@ private void initEventSource() { event = eventSource.read(); if (event == null) { - interrupted = true; + log.trace("server read timed out"); + + if (eventSource.isClosed()) { + eventSource = null; + + retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); + } + + continue; } + data = event.readData(); } catch (Exception e) { + onMakeEventSourceException(e); log.error("failed read", e); interrupted = true; continue; @@ -169,10 +184,8 @@ private void initEventSource() { connectionSaidBye = processResult(connectionSaidBye, data, event); } - if (retryer.isStopped() || eventSource.isClosed() || interrupted) { - final boolean closedOrInterrupted = eventSource.isClosed() || interrupted; - - log.trace("[featurehub] closed"); + if (retryer.isStopped()) { + log.trace("[featurehub] event source closed? {} interrupted? {} retryer stopped? {}", eventSource.isClosed(), interrupted, retryer.isStopped()); if (!eventSource.isClosed()) { close(); } @@ -180,12 +193,6 @@ private void initEventSource() { checkForUnsatisfactoryConversation(); notifyWaitingClients(); - - if (closedOrInterrupted) { - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); - } } } @@ -226,7 +233,7 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve } // tell any waiting clients we are now ready - if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { + if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG)) { notifyWaitingClients(); } } catch (Exception e) { @@ -242,16 +249,22 @@ private void notifyWaitingClients() { private void onMakeEventSourceException(Exception e) { log.info("[featurehub-sdk] failed to connect to {}", config.getRealtimeUrl()); - if (e instanceof WebApplicationException) { + if (e instanceof ConnectException) { + retryer.edgeResult(EdgeConnectionState.CONNECTION_FAILURE, this); + } else if (e instanceof SocketException) { + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); + } else if (e instanceof WebApplicationException) { WebApplicationException wae = (WebApplicationException) e; final Response response = wae.getResponse(); if (response != null && response.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR) { retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); + } else if (response != null && (response.getStatusInfo().getStatusCode() == 403 || response.getStatusInfo().getStatusCode() == 401)) { + retryer.edgeResult(EdgeConnectionState.FAILURE, this); } else { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); } } else { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this); } } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java similarity index 80% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java index 25215ec..3cfb2f7 100644 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -6,9 +6,13 @@ import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.Readiness; +import io.featurehub.client.edge.EdgeRetryService; +import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; +import jakarta.ws.rs.RedirectionException; +import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.jackson.JacksonFeature; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,6 +36,7 @@ public class RestClient implements EdgeService { @NotNull private final InternalFeatureRepository repository; @NotNull private final FeatureService client; + @NotNull private final EdgeRetryService edgeRetryer; @Nullable private String xFeaturehubHeader; // used for breaking the cache @@ -53,12 +58,16 @@ public class RestClient implements EdgeService { * @param repository - expected to be null, but able to be passed in because of special use cases * @param client - expected to be null, but able to be passed in because of testing * @param config - FH config - * @param timeoutInSeconds - use 0 for once off and for when using an actual timer + * @param edgeRetryer - used for timeouts + * @param stateTimeoutInSeconds - use 0 for once off and for when using an actual timer + * @param breakCacheOnEveryCheck - this is used by the PollingDelegate to tell the client to just do a GET when its requested to */ public RestClient(@Nullable InternalFeatureRepository repository, @Nullable FeatureService client, @NotNull FeatureHubConfig config, - int timeoutInSeconds, boolean breakCacheOnEveryCheck) { + @NotNull EdgeRetryService edgeRetryer, + int stateTimeoutInSeconds, boolean breakCacheOnEveryCheck) { + this.edgeRetryer = edgeRetryer; if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } @@ -67,22 +76,21 @@ public RestClient(@Nullable InternalFeatureRepository repository, this.repository = repository; this.client = client == null ? makeClient(config) : client; this.config = config; - this.pollingInterval = timeoutInSeconds; + this.pollingInterval = stateTimeoutInSeconds; // ensure the poll has expired the first time we ask for it whenPollingCacheExpires = System.currentTimeMillis() - 100; this.clientSideEvaluation = !config.isServerEvaluation(); - - if (clientSideEvaluation) { - checkForUpdates(null); - } } @NotNull protected FeatureService makeClient(FeatureHubConfig config) { Client client = ClientBuilder.newBuilder() .register(JacksonFeature.class).build(); + client.property(ClientProperties.CONNECT_TIMEOUT, edgeRetryer.getServerConnectTimeoutMs()); + client.property(ClientProperties.READ_TIMEOUT, edgeRetryer.getServerReadTimeoutMs()); + return new FeatureServiceImpl(new ApiClient(client, config.baseUrl())); } @@ -96,9 +104,11 @@ protected Long now() { public boolean checkForUpdates(@Nullable CompletableFuture change) { final boolean breakCache = - breakCacheOnEveryCheck || pollingInterval == 0 || (now() > whenPollingCacheExpires || headerChanged); + breakCacheOnEveryCheck || pollingInterval == 0 || (now() > whenPollingCacheExpires) || headerChanged; final boolean ask = !busy && !stopped && breakCache; + log.trace("ask {}, busy {}, stopped {}, breakCache {}", ask, busy, stopped, breakCache); + headerChanged = false; if (ask) { @@ -122,8 +132,17 @@ public boolean checkForUpdates(@Nullable CompletableFuture change) { final ApiResponse> response = client.getFeatureStates(config.apiKeys(), xContextSha, headers); processResponse(response); + } catch (RedirectionException re) { + // 304 not modified is fine + if (re.getResponse().getStatus() != 304) { + processFailure(re); + } else { // not modified + completeReadiness(); + } } catch (Exception e) { processFailure(e); + } finally { + busy = false; } } @@ -229,6 +248,11 @@ private void completeReadiness() { }); } + @Override + public boolean needsContextChange(String newHeader, String contextSha) { + return etag == null || repository.getReadiness() != Readiness.Ready || (!isClientEvaluation() && (newHeader != null && !newHeader.equals(xFeaturehubHeader))); + } + @Override public @NotNull Future contextChange(@Nullable String newHeader, @NotNull String contextSha) { final CompletableFuture change = new CompletableFuture<>(); @@ -241,8 +265,15 @@ private void completeReadiness() { // if there is already another change running, you are out of luck if (busy) { waitingClients.add(change); - } else if (!checkForUpdates(change)) { - change.complete(repository.getReadiness()); + } else { + // if we haven't evaluated the client before or is we are doing server side evaluation and the context changed + if (etag == null || !isClientEvaluation() || repository.getReadiness() != Readiness.Ready) { + if (!checkForUpdates(change)) { + change.complete(repository.getReadiness()); + } + } else { + change.complete(repository.getReadiness()); + } } return change; @@ -255,6 +286,7 @@ public boolean isClientEvaluation() { @Override public void close() { + edgeRetryer.close(); log.info("featurehub client closed."); } diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java similarity index 100% rename from client-java-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/TestSDKClient.java diff --git a/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java similarity index 100% rename from client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/server/jersey/FeatureFlagEnabled.java diff --git a/client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java similarity index 100% rename from client-java-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java rename to client-implementations/java-client-jersey3/src/main/java/io/featurehub/server/jersey/FeatureRequiredApplicationEventListener.java diff --git a/client-java-jersey3/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-implementations/java-client-jersey3/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-jersey3/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to client-implementations/java-client-jersey3/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy similarity index 100% rename from client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy rename to client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/InternalFeature.groovy diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy similarity index 100% rename from client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy rename to client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy similarity index 100% rename from client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy rename to client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy similarity index 100% rename from client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy rename to client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy diff --git a/client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java b/client-implementations/java-client-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java similarity index 100% rename from client-java-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java rename to client-implementations/java-client-jersey3/src/test/java/io/featurehub/client/jersey/JerseyClientSample.java diff --git a/client-java-jersey3/src/test/resources/log4j2.xml b/client-implementations/java-client-jersey3/src/test/resources/log4j2.xml similarity index 100% rename from client-java-jersey3/src/test/resources/log4j2.xml rename to client-implementations/java-client-jersey3/src/test/resources/log4j2.xml diff --git a/client-java-okhttp/CHANGELOG.adoc b/client-implementations/java-client-okhttp/CHANGELOG.adoc similarity index 100% rename from client-java-okhttp/CHANGELOG.adoc rename to client-implementations/java-client-okhttp/CHANGELOG.adoc diff --git a/client-java-okhttp/README.adoc b/client-implementations/java-client-okhttp/README.adoc similarity index 100% rename from client-java-okhttp/README.adoc rename to client-implementations/java-client-okhttp/README.adoc diff --git a/client-java-okhttp/pom.xml b/client-implementations/java-client-okhttp/pom.xml similarity index 94% rename from client-java-okhttp/pom.xml rename to client-implementations/java-client-okhttp/pom.xml index ce60cd4..e19e2d3 100644 --- a/client-java-okhttp/pom.xml +++ b/client-implementations/java-client-okhttp/pom.xml @@ -43,6 +43,11 @@ HEAD + + 4.12.0 + + + io.featurehub.sdk @@ -53,13 +58,13 @@ com.squareup.okhttp3 okhttp - 4.9.3 + ${ok.http.version} com.squareup.okhttp3 okhttp-sse - 4.9.3 + ${ok.http.version} @@ -78,7 +83,7 @@ com.squareup.okhttp3 mockwebserver - 4.9.3 + ${ok.http.version} test diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java similarity index 96% rename from client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java rename to client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java index 439e92c..b54707c 100644 --- a/client-java-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java @@ -15,7 +15,7 @@ public class OkHttpFeatureHubFactory implements FeatureHubClientFactory { @Override @NotNull public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) { - return () -> new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); + return () -> new SSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build()); } @Override diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java similarity index 100% rename from client-java-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java rename to client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java similarity index 100% rename from client-java-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java rename to client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java diff --git a/client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java similarity index 100% rename from client-java-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java rename to client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java diff --git a/client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-implementations/java-client-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to client-implementations/java-client-okhttp/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy similarity index 100% rename from client-java-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy rename to client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy diff --git a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy similarity index 100% rename from client-java-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy rename to client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy diff --git a/client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy similarity index 100% rename from client-java-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy rename to client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy diff --git a/client-java-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java similarity index 100% rename from client-java-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java rename to client-implementations/java-client-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java diff --git a/client-java-android21/src/test/resources/log4j2.xml b/client-implementations/java-client-okhttp/src/test/resources/log4j2.xml similarity index 100% rename from client-java-android21/src/test/resources/log4j2.xml rename to client-implementations/java-client-okhttp/src/test/resources/log4j2.xml diff --git a/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java b/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java deleted file mode 100644 index c53c360..0000000 --- a/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.featurehub.client; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; - -public class PollingDelegateEdgeService implements EdgeService { - @NotNull private final EdgeService edgeService; - @NotNull private final InternalFeatureRepository repo; - @NotNull private final Timer timer; - - public PollingDelegateEdgeService(@NotNull EdgeService edgeService, @NotNull InternalFeatureRepository repo) { - this.edgeService = edgeService; - this.repo = repo; - timer = new Timer(); - } - - private void loop() { - if (!edgeService.isStopped()) { - timer.schedule(new TimerTask() { - @Override - public void run() { - poll(); - } - }, edgeService.currentInterval() * 1000); - } - } - - @Override - public @NotNull Future contextChange(@Nullable String newHeader, String contextSha) { - timer.cancel(); - return CompletableFuture.supplyAsync(() -> { - try { - Readiness r = edgeService.contextChange(newHeader, contextSha).get(); - loop(); - return r; - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - }, repo.getExecutor()); - } - - @Override - public boolean isClientEvaluation() { - return edgeService.isClientEvaluation(); - } - - @Override - public boolean isStopped() { - return edgeService.isStopped(); - } - - @Override - public void close() { - timer.cancel(); - edgeService.close(); - } - - @Override - public @NotNull FeatureHubConfig getConfig() { - return edgeService.getConfig(); - } - - @Override - public Future poll() { - timer.cancel(); - return CompletableFuture.supplyAsync(() -> { - try { - Readiness r = edgeService.poll().get(); - loop(); - return r; - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - - }, repo.getExecutor()); - } - - @Override - public long currentInterval() { - return edgeService.currentInterval(); - } -} diff --git a/client-java-api/README.adoc b/core/client-java-api/README.adoc similarity index 100% rename from client-java-api/README.adoc rename to core/client-java-api/README.adoc diff --git a/client-java-api/pom.xml b/core/client-java-api/pom.xml similarity index 100% rename from client-java-api/pom.xml rename to core/client-java-api/pom.xml diff --git a/client-java-api/src/main/java/io/featurehub/sse/model/Package.java b/core/client-java-api/src/main/java/io/featurehub/sse/model/Package.java similarity index 100% rename from client-java-api/src/main/java/io/featurehub/sse/model/Package.java rename to core/client-java-api/src/main/java/io/featurehub/sse/model/Package.java diff --git a/client-java-core/.gitignore b/core/client-java-core/.gitignore similarity index 100% rename from client-java-core/.gitignore rename to core/client-java-core/.gitignore diff --git a/client-java-core/CHANGELOG.adoc b/core/client-java-core/CHANGELOG.adoc similarity index 100% rename from client-java-core/CHANGELOG.adoc rename to core/client-java-core/CHANGELOG.adoc diff --git a/client-java-core/README.adoc b/core/client-java-core/README.adoc similarity index 100% rename from client-java-core/README.adoc rename to core/client-java-core/README.adoc diff --git a/client-java-core/pom.xml b/core/client-java-core/pom.xml similarity index 100% rename from client-java-core/pom.xml rename to core/client-java-core/pom.xml diff --git a/client-java-core/src/main/java/io/featurehub/client/Applied.java b/core/client-java-core/src/main/java/io/featurehub/client/Applied.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/Applied.java rename to core/client-java-core/src/main/java/io/featurehub/client/Applied.java diff --git a/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java b/core/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java rename to core/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java diff --git a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/core/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/ClientContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ClientContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java similarity index 96% rename from client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java index 2ac9c7b..991c3ec 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java @@ -24,7 +24,7 @@ public Future build() { } return this; - }); + }, repository.getExecutor()); } /** diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java similarity index 98% rename from client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java rename to core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 7514013..caf49a2 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -50,7 +50,7 @@ public void cancel() { // feature-key, feature-state private final Map> features = new ConcurrentHashMap<>(); private final Map> featuresById = new ConcurrentHashMap<>(); - private final ExecutorService executor; + @NotNull private ExecutorService executor; private boolean hasReceivedInitialState = false; private Readiness readiness = Readiness.NotReady; private final List> readinessListeners = new ArrayList<>(); @@ -97,7 +97,7 @@ protected ObjectMapper initializeMapper() { } protected static ExecutorService getExecutor(int threadPoolSize) { - return Executors.newFixedThreadPool(threadPoolSize); + return Executors.newFixedThreadPool(Math.max(threadPoolSize, 10)); } public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { @@ -194,7 +194,7 @@ public void execute(@NotNull Runnable command) { } @Override - public Executor getExecutor() { + public ExecutorService getExecutor() { return executor; } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java similarity index 92% rename from client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java rename to core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index 7d432de..a81d0f1 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -41,7 +41,7 @@ public class EdgeFeatureHubConfig implements FeatureHubConfig { @NotNull private final UsageAdapter usageAdapter; - private EdgeType edgeType = EdgeType.REST_TIMEOUT; + private EdgeType edgeType = EdgeType.REST_PASSIVE; private int timeout; public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { @@ -59,7 +59,7 @@ public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull List apiKe // set defaults if (serverEvaluation) { - rest(); + restPassive(); } else { streaming(); } @@ -150,7 +150,7 @@ protected Supplier loadEdgeService(@NotNull InternalFeatureReposit for (FeatureHubClientFactory f : loader) { if (edgeType == EdgeType.STREAMING) { edgeServiceSupplier = f.createSSEEdge(this, repository); - } else if (edgeType == EdgeType.REST_TIMEOUT) { + } else if (edgeType == EdgeType.REST_PASSIVE) { edgeServiceSupplier = f.createRestEdge(this, repository, timeout, false); } else { edgeServiceSupplier = () -> new PollingDelegateEdgeService( @@ -248,34 +248,34 @@ public FeatureHubConfig streaming() { } private enum EdgeType { - STREAMING, REST_TIMEOUT, REST_POLL + STREAMING, REST_PASSIVE, REST_ACTIVE } @Override - public FeatureHubConfig restPoll() { + public FeatureHubConfig restActive() { this.timeout = 180; - edgeType = EdgeType.REST_POLL; + edgeType = EdgeType.REST_ACTIVE; return this; } @Override - public FeatureHubConfig restPoll(int intervalInSeconds) { + public FeatureHubConfig restActive(int intervalInSeconds) { this.timeout = intervalInSeconds; - edgeType = EdgeType.REST_POLL; + edgeType = EdgeType.REST_ACTIVE; return this; } @Override - public FeatureHubConfig rest(int cacheTimeoutInSeconds) { + public FeatureHubConfig restPassive(int cacheTimeoutInSeconds) { this.timeout = cacheTimeoutInSeconds; - edgeType = EdgeType.REST_TIMEOUT; + edgeType = EdgeType.REST_PASSIVE; return this; } @Override - public FeatureHubConfig rest() { + public FeatureHubConfig restPassive() { this.timeout = 180; - edgeType = EdgeType.REST_TIMEOUT; + edgeType = EdgeType.REST_PASSIVE; return this; } } diff --git a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java similarity index 92% rename from client-java-core/src/main/java/io/featurehub/client/EdgeService.java rename to core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java index 5964baf..81640a3 100644 --- a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java @@ -47,4 +47,8 @@ public interface EdgeService { * @return - current interval which can change based on data sent from server. */ long currentInterval(); + + default boolean needsContextChange(String newHeader, String contextSha) { + return false; + } } diff --git a/client-java-android21/src/main/java/io/featurehub/client/Feature.java b/core/client-java-core/src/main/java/io/featurehub/client/Feature.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Feature.java rename to core/client-java-core/src/main/java/io/featurehub/client/Feature.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java similarity index 94% rename from client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 2319c40..2f56f69 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -7,7 +7,6 @@ import java.util.Collection; import java.util.List; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Supplier; @@ -110,14 +109,14 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { /** * interval defaults to 180 seconds */ - FeatureHubConfig restPoll(); - FeatureHubConfig restPoll(int intervalInSeconds); - FeatureHubConfig rest(int cacheTimeoutInSeconds); + FeatureHubConfig restActive(); + FeatureHubConfig restActive(int intervalInSeconds); + FeatureHubConfig restPassive(int cacheTimeoutInSeconds); /** * cache timeout defaults to 180 seconds */ - FeatureHubConfig rest(); + FeatureHubConfig restPassive(); FeatureHubConfig recordUsageEvent(UsageEvent event); } diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureListener.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureState.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureState.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureState.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java similarity index 98% rename from client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java index ecf87d6..6d0c030 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -184,7 +184,7 @@ private Object internalGetValue(@Nullable FeatureValueType passedType, boolean t return triggerUsage ? used(feature.key, feature.fs.getId(), applied.getValue(), type) : applied.getValue(); } } else { - log.trace("not matched using {}", feature.fs.getValue()); + log.trace("feature `{}` has no strategies or there is no context, falling back to default value of {}", getKey(), feature.fs.getValue()); } return triggerUsage ? used(feature.key, feature.fs.getId(), feature.fs.getValue(), type) : diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java similarity index 81% rename from client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java index c8d790d..b4300ec 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java @@ -1,6 +1,7 @@ package io.featurehub.client; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -18,6 +19,6 @@ public static String generateXFeatureHubHeaderFromMap(Map> } return attributes.entrySet().stream().map(e -> String.format("%s=%s", e.getKey(), - URLEncoder.encode(String.join(",", e.getValue())))).sorted().collect(Collectors.joining(",")); + URLEncoder.encode(String.join(",", e.getValue()), StandardCharsets.UTF_8))).sorted().collect(Collectors.joining(",")); } } diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalContext.java b/core/client-java-core/src/main/java/io/featurehub/client/InternalContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/InternalContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/InternalContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java similarity index 97% rename from client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java rename to core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java index 177e474..cfd5fdc 100644 --- a/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; public interface InternalFeatureRepository extends FeatureRepository { @@ -48,7 +49,7 @@ public interface InternalFeatureRepository extends FeatureRepository { @NotNull ClientContext cac); void execute(@NotNull Runnable command); - Executor getExecutor(); + ExecutorService getExecutor(); @NotNull ObjectMapper getJsonObjectMapper(); diff --git a/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java b/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java new file mode 100644 index 0000000..1ff8f99 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java @@ -0,0 +1,132 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public class PollingDelegateEdgeService implements EdgeService { + @NotNull + private final EdgeService edgeService; + @NotNull + private final InternalFeatureRepository repo; + private Timer timer; + private static final Logger log = LoggerFactory.getLogger(PollingDelegateEdgeService.class); + private boolean busy = false; + + /** + * This class has to get the timeout delay from the underlying client because the server can override the timeout delay. + * + * @param edgeService - the Rest client that is polling. It MUST NOT BE an SSE client. + * @param repo - the internal repo API. + */ + + public PollingDelegateEdgeService(@NotNull EdgeService edgeService, @NotNull InternalFeatureRepository repo) { + this.edgeService = edgeService; + this.repo = repo; + timer = newTimer(); + } + + protected Timer newTimer() { + return new Timer(true); + } + + private void loop() { + if (!edgeService.isStopped()) { + busy = false; + + timer = newTimer(); // once its cancelled, you can't reuse it + timer.schedule(new TimerTask() { + @Override + public void run() { + poll(); + } + }, edgeService.currentInterval() * 1000); + } + } + + private void cancelTimer() { + if (timer != null) { + timer.cancel(); + } + } + + @Override + public @NotNull Future contextChange(@Nullable String newHeader, String contextSha) { + if (edgeService.needsContextChange(newHeader, contextSha)) { + log.trace("contextChange"); + cancelTimer(); + + return repo.getExecutor().submit(() -> { + try { + log.trace("context change"); + return edgeService.contextChange(newHeader, contextSha).get(); + } catch (Exception e) { + log.error("failed to context change", e); + return repo.getReadiness(); + } finally { + log.trace("looping again cc"); + loop(); + } + } + ); + } else { + return CompletableFuture.completedFuture(repo.getReadiness()); + } + } + + @Override + public boolean isClientEvaluation() { + return edgeService.isClientEvaluation(); + } + + @Override + public boolean isStopped() { + return edgeService.isStopped(); + } + + @Override + public void close() { + cancelTimer(); + edgeService.close(); + } + + @Override + public @NotNull FeatureHubConfig getConfig() { + return edgeService.getConfig(); + } + + @Override + public Future poll() { + if (!busy) { + busy = true; + cancelTimer(); + + return repo.getExecutor().submit(() -> { + log.trace("calling poll directly"); + try { + return edgeService.poll().get(); + } catch (Exception e) { + log.error("failed to poll", e); + return repo.getReadiness(); + } finally { + log.trace("finished polling"); + loop(); + } + }); + } + + return CompletableFuture.completedFuture(repo.getReadiness()); + } + + @Override + public long currentInterval() { + return edgeService.currentInterval(); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/Readiness.java b/core/client-java-core/src/main/java/io/featurehub/client/Readiness.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/Readiness.java rename to core/client-java-core/src/main/java/io/featurehub/client/Readiness.java diff --git a/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java b/core/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java rename to core/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java diff --git a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/TestApi.java b/core/client-java-core/src/main/java/io/featurehub/client/TestApi.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/TestApi.java rename to core/client-java-core/src/main/java/io/featurehub/client/TestApi.java diff --git a/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java b/core/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/TestApiResult.java rename to core/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java diff --git a/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java similarity index 65% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java index e0350a9..2c6b42f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java @@ -7,9 +7,9 @@ public enum EdgeConnectionState { API_KEY_NOT_FOUND, // [SSE + GET] - // we timed out trying to connect to the server. We should backoff briefly and try and connect again. May + // we timed out trying to read the server. We should backoff briefly and try and connect again. May // require increasing backoff - SERVER_CONNECT_TIMEOUT, // timeout connecting to url, retryable + SERVER_READ_TIMEOUT, // timeout connecting to url, retryable // [SSE Only] this is the normal ping/pong of the server connection disconnecting us, we should delay a random amount // of time an reconnect. @@ -18,6 +18,10 @@ public enum EdgeConnectionState { // [SSE + GET] we never received a response after we did actually connect, we should backoff SERVER_WAS_DISCONNECTED, // we got a disconnect before we received a "bye" + CONNECTION_FAILURE, // e.g. java.net.ConnectionException - such as the host not existing or not being able to be connected to at all + SUCCESS, + // total failure (e.g. 403 or 401 coming from Edge, so stop trying) + FAILURE, } diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java similarity index 92% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java index 09ad0fa..6fe8298 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java @@ -24,6 +24,12 @@ void convertSSEState(@NotNull SSEResultState state, String data, @NotNull Intern ExecutorService getExecutorService(); + int getServerReadTimeoutMs(); + + /** + * connection attempt in ms + * @return + */ int getServerConnectTimeoutMs(); int getServerDisconnectRetryMs(); diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java similarity index 69% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index 99fc79a..c2e1227 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.sse.model.FeatureState; @@ -20,13 +19,32 @@ public class EdgeRetryer implements EdgeRetryService { private static final Logger log = LoggerFactory.getLogger(EdgeRetryer.class); private final ExecutorService executorService; - private final int serverConnectTimeoutMs; + /** + * If we get a server-timeout failure, how long before we try and reconnect. This is the scenario where the server can be connected + * to, but it is exceeding our READ_TIMEOUT setting. Defaults to 5s. + */ + private final int serverReadTimeoutMs; + /** + * If the server disconnects from us (we get a disconnection error without a bye), how long to wait? Normally 0. + */ private final int serverDisconnectRetryMs; + /** + * If the server disconnects from us using a BYE, how long do we wait before a reconnect. Given this is normal behaviour, we normally + * set this to 0. + */ private final int serverByeReconnectMs; + /** + * backoffMultiplier - this is how much to multiply the backoff - the backoff is a random value between 0 and 1, which is multiplied by this + * value. + */ private final int backoffMultiplier; private final int maximumBackoffTimeMs; - // this will change over the lifetime of reconnect attempts + // this will change over the lifetime of reconnect attempts, internal use only private int currentBackoffMultiplier; + /** + * if the connectionk attempt to connect fails, how long do we wait before attempting to reconnect + */ + private final int connectionFailureBackoffTimeMs; private final ObjectMapper mapper = new ObjectMapper(); // if this is set, then we stop recognizing any further requests from the connection, @@ -37,15 +55,16 @@ public class EdgeRetryer implements EdgeRetryService { private final TypeReference> FEATURE_LIST_TYPEDEF = new TypeReference>() {}; - protected EdgeRetryer(int serverConnectTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, - int backoffMultiplier, int maximumBackoffTimeMs) { - this.serverConnectTimeoutMs = serverConnectTimeoutMs; + protected EdgeRetryer(int serverReadTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, + int backoffMultiplier, int maximumBackoffTimeMs, int serverConnectTmeoutMs) { + this.serverReadTimeoutMs = serverReadTimeoutMs; this.serverDisconnectRetryMs = serverDisconnectRetryMs; this.serverByeReconnectMs = serverByeReconnectMs; this.backoffMultiplier = backoffMultiplier; this.maximumBackoffTimeMs = maximumBackoffTimeMs; currentBackoffMultiplier = backoffMultiplier; + this.connectionFailureBackoffTimeMs = serverConnectTmeoutMs; executorService = makeExecutorService(); } @@ -64,21 +83,35 @@ public void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnec log.warn("[featurehub-sdk] terminal failure attempting to connect to Edge, API KEY does not exist."); notFoundState = true; stopped = true; + } else if (state == EdgeConnectionState.FAILURE) { + log.warn("[featurehub-sdk] terminal failure attempting to connect to Edge, no permission."); + notFoundState = true; + stopped = true; } else if (state == EdgeConnectionState.SERVER_WAS_DISCONNECTED) { executorService.submit(() -> { - backoff(serverDisconnectRetryMs, true); + if (serverDisconnectRetryMs > 0) { + backoff(serverDisconnectRetryMs, true); + } reConnector.reconnect(); }); } else if (state == EdgeConnectionState.SERVER_SAID_BYE) { executorService.submit(() -> { -// backoff(serverByeReconnectMs, false); + if (serverByeReconnectMs > 0) { + backoff(serverByeReconnectMs, false); + } + + reConnector.reconnect(); + }); + } else if (state == EdgeConnectionState.SERVER_READ_TIMEOUT) { + executorService.submit(() -> { + backoff(serverReadTimeoutMs, true); reConnector.reconnect(); }); - } else if (state == EdgeConnectionState.SERVER_CONNECT_TIMEOUT) { + } else if (state == EdgeConnectionState.CONNECTION_FAILURE) { executorService.submit(() -> { - backoff(serverConnectTimeoutMs, true); + backoff(connectionFailureBackoffTimeMs, true); reConnector.reconnect(); }); @@ -146,9 +179,14 @@ public ExecutorService getExecutorService() { return executorService; } + @Override + public int getServerReadTimeoutMs() { + return serverReadTimeoutMs; + } + @Override public int getServerConnectTimeoutMs() { - return serverConnectTimeoutMs; + return connectionFailureBackoffTimeMs; } @Override @@ -219,15 +257,29 @@ public int newBackoff(int currentBackoff) { return backoff; } + private static enum EdgeRetryerClientType { + NONE, SSE, REST + } + public static final class EdgeRetryerBuilder { - private int serverConnectTimeoutMs; + private int serverSseReadTimeoutMs; + private int serverRestReadTimeoutMs; private int serverDisconnectRetryMs; private int serverByeReconnectMs; private int backoffMultiplier; private int maximumBackoffTimeMs; + private int serverConnectTimeoutMs; + + private EdgeRetryerClientType clientType = EdgeRetryerClientType.NONE; private EdgeRetryerBuilder() { + // 5s by default, shouldn't be longer than that just to connect serverConnectTimeoutMs = propertyOrEnv("featurehub.edge.server-connect-timeout-ms", "5000"); + // 3m (180 seconds), should be higher if the server is configured for longer by default + serverSseReadTimeoutMs = propertyOrEnv("featurehub.edge.server-sse-read-timeout-ms", "1800000"); + // 15s - should be very fast for a REST request as its a connect, read and disconnect process + serverRestReadTimeoutMs = propertyOrEnv("featurehub.edge.server-rest-read-timeout-ms", "150000"); + serverDisconnectRetryMs = propertyOrEnv("featurehub.edge.server-disconnect-retry-ms", "0"); // immediately try and reconnect if disconnected serverByeReconnectMs = propertyOrEnv("featurehub.edge.server-by-reconnect-ms", @@ -279,9 +331,34 @@ public EdgeRetryerBuilder withMaximumBackoffTimeMs(int maximumBackoffTimeMs) { return this; } + public EdgeRetryerBuilder withSseReadTimeoutTimeMs(int ms) { + this.serverSseReadTimeoutMs = ms; + return this; + } + + public EdgeRetryerBuilder withRestReadTimeoutTimeMs(int ms) { + this.serverRestReadTimeoutMs = ms; + return this; + } + + public EdgeRetryerBuilder sse() { + this.clientType = EdgeRetryerClientType.SSE; + return this; + } + + public EdgeRetryerBuilder rest() { + this.clientType = EdgeRetryerClientType.REST; + return this; + } + public EdgeRetryer build() { - return new EdgeRetryer(serverConnectTimeoutMs, serverDisconnectRetryMs, serverByeReconnectMs, backoffMultiplier - , maximumBackoffTimeMs); + if (clientType == EdgeRetryerClientType.NONE) { + throw new RuntimeException("FeatureHub Retryer does not know what read timeout to use"); + } + + return new EdgeRetryer(clientType == EdgeRetryerClientType.SSE ? serverSseReadTimeoutMs : serverRestReadTimeoutMs, serverDisconnectRetryMs, serverByeReconnectMs, backoffMultiplier + , maximumBackoffTimeMs, serverConnectTimeoutMs + ); } } } diff --git a/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java b/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java rename to core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java diff --git a/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java rename to core/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java diff --git a/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java b/core/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java rename to core/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java diff --git a/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy similarity index 91% rename from client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy index 75cd67e..b844672 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy @@ -69,10 +69,10 @@ class EdgeRetryerSpec extends Specification { def "if the server says 'connect timeout' then we will backoff with the connect timeout and adjust backoff"() { when: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: backoffAdjustBackoff - backoffBaseTime == retryer.serverConnectTimeoutMs + backoffBaseTime == retryer.serverReadTimeoutMs 1 * reconnector.reconnect() 1 * mockExecutor.submit({ Runnable task -> task.run()}) } @@ -87,7 +87,7 @@ class EdgeRetryerSpec extends Specification { def "if the executor service is shut down, no calls are ignored"() { when: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: 1 * mockExecutor.isShutdown() >> true 0 * reconnector.reconnect() @@ -97,7 +97,7 @@ class EdgeRetryerSpec extends Specification { when: "i send the api not found event" retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, reconnector ) and: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: 1 * mockExecutor.isShutdown() >> false 0 * reconnector.reconnect() diff --git a/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy diff --git a/client-java-core/src/test/resources/META-INF/MANIFEST.MF b/core/client-java-core/src/test/resources/META-INF/MANIFEST.MF similarity index 100% rename from client-java-core/src/test/resources/META-INF/MANIFEST.MF rename to core/client-java-core/src/test/resources/META-INF/MANIFEST.MF diff --git a/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/core/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to core/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-core/src/test/resources/log4j2.xml b/core/client-java-core/src/test/resources/log4j2.xml similarity index 100% rename from client-java-core/src/test/resources/log4j2.xml rename to core/client-java-core/src/test/resources/log4j2.xml diff --git a/examples/todo-java/.editorconfig b/examples/.editorconfig similarity index 100% rename from examples/todo-java/.editorconfig rename to examples/.editorconfig diff --git a/examples/pom.xml b/examples/pom.xml index 8a71929..b686634 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - io.featurehub + io.featurehub.java featurehub-sdk-example-reactor 1.1.1 pom @@ -34,7 +34,9 @@ - todo-java + todo-java-shared + todo-java-jersey2 + todo-java-jersey3 migration-check diff --git a/examples/todo-java-jersey2/pom.xml b/examples/todo-java-jersey2/pom.xml new file mode 100644 index 0000000..42b3c51 --- /dev/null +++ b/examples/todo-java-jersey2/pom.xml @@ -0,0 +1,182 @@ + + + 4.0.0 + + io.featurehub.java + todo-java-jersey2 + todo-java-jersey2 + 1.1-SNAPSHOT + + This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). + + It expects environment variables or system property config as follows: + + - feature-service.host = the host where features are stored, e.g. http://localhost:8085 + - feature-service.api-key = the API key issued by the server. + + There are examples in https://github.com/featurehub-io/featurehub/tree/main/adks/e2e-sdk on how to populate your + server automatically via tests to create the features required for this scenario. + + + + ${project.artifactId} + ${project.version} + todo-java-jersey2 + 3.0.1 + 2.0.0 + 2.36 + + + + + io.featurehub.sdk + java-client-jersey2 + [3.1-SNAPSHOT, 4) + + + + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT + + + + io.featurehub.sdk.composites + sdk-composite-jersey2 + [1.1-SNAPSHOT, 2) + + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + ${jersey.version} + + + org.glassfish.grizzly + grizzly-http-server + + + + + + org.glassfish.grizzly + grizzly-http-server + ${grizzly.version} + + + + org.glassfish.grizzly + grizzly-http2 + ${grizzly.version} + + + + org.glassfish.grizzly + grizzly-npn-bootstrap + ${grizzly.npn.version} + + + + org.glassfish.grizzly + grizzly-npn-api + ${grizzly.npn.version} + + + + io.featurehub.sdk.composites + sdk-composite-test + 1.2 + test + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + + + + + + org.openapitools + openapi-generator-maven-plugin + 7.0.1 + + + cd.connect.openapi + connect-openapi-jersey3 + 9.1 + + + + + featurehub-api + + generate + + generate-sources + + ${project.basedir}/target/generated-sources/api + todo.api + todo.model + ${project.basedir}/../todo-java-shared/todo-api.yaml + jersey3-api + jersey3-api + + + server + jersey2 + openApiNullable=false + + + + + useBeanValidation + true + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-generated-source + initialize + + add-source + + + + ${project.build.directory}/generated-sources/api/src/gen + + + + + + + + diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java b/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java new file mode 100644 index 0000000..c916abd --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java @@ -0,0 +1,71 @@ +package todo.backend; + +import cd.connect.app.config.ConfigKey; +import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; +import javax.inject.Singleton; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.server.ResourceConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.backend.resources.FeatureAnalyticsFilter; +import todo.backend.resources.HealthResource; +import todo.backend.resources.LocalExceptionMapper; +import todo.backend.resources.TodoResource; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +public class Application { + private static final Logger log = LoggerFactory.getLogger(Application.class); + @ConfigKey("server.port") + String serverPort = "8099"; + + public Application() { + DeclaredConfigResolver.resolve(this); + } + + public void init() throws Exception { + URI BASE_URI = URI.create(String.format("http://0.0.0.0:%s/", serverPort)); + + log.info("attempting to start on port {} - will wait for features", BASE_URI.toASCIIString()); + + // register our resources, try and tag them as singleton as they are instantiated faster + ResourceConfig config = new ResourceConfig( + TodoResource.class, + HealthResource.class, + LocalExceptionMapper.class, + FeatureAnalyticsFilter.class) + .register(new AbstractBinder() { + @Override + protected void configure() { + bind(FeatureHubSource.class).in(Singleton.class).to(FeatureHub.class); + } + }); + + final HttpServer server = GrizzlyHttpServerFactory.createHttpServer(BASE_URI, config, false); + + server.start(); + + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + server.shutdown(10, TimeUnit.SECONDS); + } + }); + + // tell the App we are ready + ApplicationLifecycleManager.updateStatus(LifecycleStatus.STARTED); + + Thread.currentThread().join(); + } + + public static void main(String[] args) throws Exception { + System.setProperty("jersey.cors.headers", "X-Requested-With,Authorization,Content-type,Accept-Version," + + "Content-MD5,CSRF-Token,x-ijt,cache-control,x-featurehub,baggage"); + + new Application().init(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java similarity index 65% rename from examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java rename to examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java index 3f6ab70..67290ed 100644 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java @@ -5,12 +5,9 @@ import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; import com.segment.analytics.messages.Message; -import io.featurehub.client.*; -import io.featurehub.okhttp.RestClient; -import io.featurehub.client.edge.EdgeRetryer; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; -import io.featurehub.client.jersey.JerseySSEClient; -import io.featurehub.okhttp.SSEClient; import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; @@ -26,12 +23,12 @@ public class FeatureHubSource implements FeatureHub { String sdkKey; @ConfigKey("segment.write-key") String segmentWriteKey = ""; - @ConfigKey("feature-service.sdk") - String clientSdk = "jersey3"; @ConfigKey("feature-service.client") String client = "sse"; // sse, rest, rest-poll - @ConfigKey("feature-service.poll-interval") - Integer pollInterval = 1000; // in milliseconds + @ConfigKey("feature-service.opentelemetry.enabled") + Boolean openTelemetryEnabled = false; + @ConfigKey("feature-service.poll-interval-seconds") + Integer pollInterval = 1; // in seconds @Nullable SegmentAnalyticsSource segmentAnalyticsSource; @@ -51,21 +48,18 @@ public FeatureHubSource() { segmentAnalyticsSource = segmentUsagePlugin; } - // this won't do anything if otel isn't found or configured - config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } // Do this if you wish to force the connection to stay open. - if (clientSdk.equals("jersey3")) { - final JerseySSEClient jerseyClient = new JerseySSEClient(config.getInternalRepository(), - config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - config.setEdgeService(() -> jerseyClient); - } else if (clientSdk.equals("android") || clientSdk.equals("rest")) { - final RestClient client = new RestClient(config, pollInterval); - config.setEdgeService(() -> client); - } else if (clientSdk.equals("sse")) { - final SSEClient client = new SSEClient(config.getInternalRepository(), config, - EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - config.setEdgeService(() -> client); + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-poll")) { + config.restActive(pollInterval); } else { throw new RuntimeException("Unknown featurehub client"); } diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java new file mode 100644 index 0000000..4690e94 --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -0,0 +1,64 @@ +package todo.backend.resources; + +import cd.connect.app.config.DeclaredConfigResolver; +import io.featurehub.client.ClientContext; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import todo.backend.UsageRequestMeasurement; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { + + private final FeatureHub config; + private static final Logger log = LoggerFactory.getLogger(FeatureAnalyticsFilter.class); + + @Inject + public FeatureAnalyticsFilter(FeatureHub config) { + this.config = config; + DeclaredConfigResolver.resolve(this); + } + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + final long currentTime = System.currentTimeMillis(); + requestContext.setProperty("startTime", currentTime); + final List user = requestContext.getUriInfo().getPathParameters().get("user"); + if (user != null && !user.isEmpty()) { + try { + requestContext.setProperty("context", config.getConfig().newContext().build().get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + Long start = (Long) requestContext.getProperty("startTime"); + ClientContext context = (ClientContext) requestContext.getProperty("context"); + + FeatureHubClientContextThreadLocal.clear(); + + if (start != null && context != null) { + long duration = System.currentTimeMillis() - start; + + final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); + + if (!matchedURIs.isEmpty()) { + context.recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + } + } else { + log.error("There was not start time {} and context {}", start, context); + } + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java new file mode 100644 index 0000000..0077f93 --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java @@ -0,0 +1,29 @@ +package todo.backend.resources; + + +import io.featurehub.client.Readiness; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Response; +import todo.backend.FeatureHub; + +@Path("/health") +public class HealthResource { + private final FeatureHub featureHub; + + @Inject + public HealthResource(FeatureHub featureHub) { + this.featureHub = featureHub; + } + + @GET + @Path(("/liveness")) + public Response liveness() { + if (featureHub.getConfig().getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.serverError().build(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java new file mode 100644 index 0000000..cc7bebd --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java @@ -0,0 +1,32 @@ +package todo.backend.resources; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Provider +public class LocalExceptionMapper implements ExceptionMapper { + private static final Logger log = LoggerFactory.getLogger(LocalExceptionMapper.class); + + @Override + public Response toResponse(Exception exception) { + if (exception instanceof WebApplicationException) { + Response response = ((WebApplicationException) exception).getResponse(); + + if (response.getStatus() >= 500) { // special callout to all our 5xx in the house. + log.error("Error HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } else if (!(exception instanceof NotFoundException)) { + log.warn("Failed HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } + + return response; + } + + log.error("Failed jersey request", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java new file mode 100644 index 0000000..cdc16ac --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java @@ -0,0 +1,152 @@ +package todo.backend.resources; + +import com.segment.analytics.messages.IdentifyMessage; +import io.featurehub.client.ClientContext; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.WebApplicationException; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.api.TodoService; +import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import todo.model.Todo; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Singleton +public class TodoResource implements TodoService { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final FeatureHub featureHub; + Map> todos = new ConcurrentHashMap<>(); + + @Inject + public TodoResource(FeatureHub config) { + this.featureHub = config; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = fhClient(user); + + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @NotNull private ClientContext fhClient(String user) { + try { + final ClientContext context = featureHub.getConfig().newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + + FeatureHubClientContextThreadLocal.set(context); + + if (featureHub.segmentAnalytics() != null) { + // this should have the current user's details augmented into it + featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); + } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; + } catch (Exception e) { + log.error("Unable to get context!", e); + throw new WebApplicationException(e); + } + } + + @Override + public List addTodo(@NotNull String user, Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @Override + public List listTodos(@NotNull String user) { + return getTodoList(getTodoMap(user), user); + } + + @Override + public void removeAllTodos(@NotNull String user) { + getTodoMap(user).clear(); + } + + @Override + public List removeTodo(@NotNull String user, @NotNull String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @Override + public List resolveTodo(@NotNull String id, @NotNull String user) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new NotFoundException(); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/examples/todo-java/src/test/java/todo/backend/AppRunner.java b/examples/todo-java-jersey2/src/test/java/todo/backend/AppRunner.java similarity index 100% rename from examples/todo-java/src/test/java/todo/backend/AppRunner.java rename to examples/todo-java-jersey2/src/test/java/todo/backend/AppRunner.java diff --git a/examples/todo-java/pom.xml b/examples/todo-java-jersey3/pom.xml similarity index 64% rename from examples/todo-java/pom.xml rename to examples/todo-java-jersey3/pom.xml index 6d6a18f..d85d101 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java-jersey3/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - cd.connect - todo-backend-java - todo-backend-java + io.featurehub.java + todo-java-jersey3 + todo-java-jersey3 1.1-SNAPSHOT This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). @@ -23,7 +23,7 @@ ${project.artifactId} ${project.version} - connect_todo + todo-java-jersey2 3.0.1 2.0.0 3.1.2 @@ -37,93 +37,32 @@ - io.featurehub.sdk - java-client-okhttp - [3.1-SNAPSHOT, 4) - - - - io.featurehub.sdk.java - segment-usageadapter - [1.1-SNAPSHOT, 2) - - - - io.opentelemetry - opentelemetry-api - 1.40.0 - provided + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT + io.opentelemetry.javaagent.instrumentation opentelemetry-javaagent-jaxrs-3.0-jersey-3.0 2.6.0-alpha + io.opentelemetry.javaagent.instrumentation opentelemetry-javaagent-grizzly-2.3 2.6.0-alpha - - io.featurehub.sdk.java - opentelemetry-usageadapter - [1.1-SNAPSHOT, 2) - - - - io.featurehub.sdk.composites - sdk-composite-jersey3 - [1.1-SNAPSHOT, 2) - - - - - cd.connect.common - connect-app-declare-config - 1.3 - - - net.stickycode.composite - sticky-composite-logging-api - - - - - - - com.bluetrainsoftware.bathe - bathe-booter - [3.1, 4) - - - - - com.bluetrainsoftware.bathe.initializers - system-property-loader - 3.1 - - - * - * - - - - - - io.featurehub.sdk.composites - sdk-composite-logging - [1.1-SNAPSHOT, 2) - - io.featurehub.sdk.composites sdk-composite-jersey3 [1.1-SNAPSHOT, 2) + org.glassfish.jersey.containers jersey-container-grizzly2-http @@ -160,33 +99,6 @@ ${grizzly.npn.version} - - - com.bluetrainsoftware.bathe.initializers - jul-bridge - 2.1 - - - * - * - - - - - - - org.yaml - snakeyaml - 2.0 - - - - - cd.connect.common - connect-app-lifecycle - 1.1 - - io.featurehub.sdk.composites sdk-composite-test @@ -208,7 +120,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false @@ -221,12 +133,12 @@ org.openapitools openapi-generator-maven-plugin - 5.2.1 + 7.0.1 cd.connect.openapi connect-openapi-jersey3 - 7.9 + 9.1 @@ -240,12 +152,13 @@ ${project.basedir}/target/generated-sources/api todo.api todo.model - ${project.basedir}/todo-api.yaml + ${project.basedir}/../todo-java-shared/todo-api.yaml jersey3-api jersey3-api server + openApiNullable=false diff --git a/examples/todo-java/src/main/java/todo/backend/Application.java b/examples/todo-java-jersey3/src/main/java/todo/backend/Application.java similarity index 90% rename from examples/todo-java/src/main/java/todo/backend/Application.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/Application.java index cb0ab59..77c2217 100644 --- a/examples/todo-java/src/main/java/todo/backend/Application.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/Application.java @@ -13,15 +13,16 @@ import org.slf4j.LoggerFactory; import todo.backend.resources.FeatureAnalyticsFilter; import todo.backend.resources.HealthResource; +import todo.backend.resources.LocalExceptionMapper; import todo.backend.resources.TodoResource; import java.net.URI; import java.util.concurrent.TimeUnit; public class Application { - private static final Logger log = LoggerFactory.getLogger(Application.class); - @ConfigKey("server.port") - String serverPort = "8099"; + private static final Logger log = LoggerFactory.getLogger(Application.class); + @ConfigKey("server.port") + String serverPort = "8099"; public Application() { DeclaredConfigResolver.resolve(this); @@ -36,6 +37,7 @@ public void init() throws Exception { ResourceConfig config = new ResourceConfig( TodoResource.class, HealthResource.class, + LocalExceptionMapper.class, FeatureAnalyticsFilter.class) .register(new AbstractBinder() { @Override @@ -65,7 +67,5 @@ public static void main(String[] args) throws Exception { "Content-MD5,CSRF-Token,x-ijt,cache-control,x-featurehub,baggage"); new Application().init(); - } - - + } } diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java new file mode 100644 index 0000000..67290ed --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java @@ -0,0 +1,89 @@ +package todo.backend; + +import cd.connect.app.config.ConfigKey; +import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; +import com.segment.analytics.messages.Message; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class FeatureHubSource implements FeatureHub { + @ConfigKey("feature-service.host") + String featureHubUrl; + @ConfigKey("feature-service.api-key") + String sdkKey; + @ConfigKey("segment.write-key") + String segmentWriteKey = ""; + @ConfigKey("feature-service.client") + String client = "sse"; // sse, rest, rest-poll + @ConfigKey("feature-service.opentelemetry.enabled") + Boolean openTelemetryEnabled = false; + @ConfigKey("feature-service.poll-interval-seconds") + Integer pollInterval = 1; // in seconds + + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + + private final FeatureHubConfig config; + + public FeatureHubSource() { + DeclaredConfigResolver.resolve(this); + + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); + + if (!segmentWriteKey.isEmpty()) { + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, + List.of(new SegmentMessageTransformer(Message.Type.values(), + FeatureHubClientContextThreadLocal::get, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; + } + + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } + + // Do this if you wish to force the connection to stay open. + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-poll")) { + config.restActive(pollInterval); + } else { + throw new RuntimeException("Unknown featurehub client"); + } + + config.init(); + + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + close(); + } + }); + } + + @Override + public FeatureHubConfig getConfig() { + return config; + } + + @Override + public SegmentAnalyticsSource segmentAnalytics() { + return segmentAnalyticsSource; + } + + public void close() { + config.close(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java similarity index 98% rename from examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java index f96a36d..ee6f035 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -2,7 +2,6 @@ import cd.connect.app.config.DeclaredConfigResolver; import io.featurehub.client.ClientContext; -import io.featurehub.client.ThreadLocalContext; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; diff --git a/examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java similarity index 100% rename from examples/todo-java/src/main/java/todo/backend/resources/HealthResource.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java new file mode 100644 index 0000000..2c0bddd --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java @@ -0,0 +1,32 @@ +package todo.backend.resources; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Provider +public class LocalExceptionMapper implements ExceptionMapper { + private static final Logger log = LoggerFactory.getLogger(LocalExceptionMapper.class); + + @Override + public Response toResponse(Exception exception) { + if (exception instanceof WebApplicationException) { + Response response = ((WebApplicationException) exception).getResponse(); + + if (response.getStatus() >= 500) { // special callout to all our 5xx in the house. + log.error("Error HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } else if (!(exception instanceof NotFoundException)) { + log.warn("Failed HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } + + return response; + } + + log.error("Failed jersey request", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java similarity index 92% rename from examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java index 6250db8..147f2fb 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java @@ -1,7 +1,9 @@ package todo.backend.resources; +import com.segment.analytics.messages.IdentifyMessage; import io.featurehub.client.ClientContext; import jakarta.inject.Inject; +import jakarta.inject.Singleton; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.WebApplicationException; import org.jetbrains.annotations.NotNull; @@ -10,7 +12,6 @@ import todo.api.TodoService; import todo.backend.FeatureHub; import todo.backend.FeatureHubClientContextThreadLocal; -import com.segment.analytics.messages.IdentifyMessage; import todo.model.Todo; import java.util.List; @@ -19,7 +20,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; - +@Singleton public class TodoResource implements TodoService { private static final Logger log = LoggerFactory.getLogger(TodoResource.class); private final FeatureHub featureHub; @@ -41,7 +42,8 @@ private Map getTodoMap(String user) { private List getTodoList(Map todos, String user) { ClientContext fhClient = fhClient(user); - return todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; } private String processTitle(ClientContext fhClient, String title) { @@ -102,10 +104,14 @@ private String processTitle(ClientContext fhClient, String title) { @Override public List addTodo(@NotNull String user, Todo body) { - if (body.getId().isEmpty()) { + if (body.getId() == null || body.getId().isEmpty()) { body.id(UUID.randomUUID().toString()); } + if (body.getResolved() == null) { + body.resolved(false); + } + Map userTodo = getTodoMap(user); userTodo.put(body.getId(), body); diff --git a/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java b/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java new file mode 100644 index 0000000..4462eb5 --- /dev/null +++ b/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java @@ -0,0 +1,13 @@ +package todo.backend; + +import bathe.BatheBooter; +import org.junit.Test; + +import java.io.IOException; + +public class AppRunner { + @Test + public void run() throws IOException { + new BatheBooter().run(new String[]{"-R" + Application.class.getName(), "-Pclasspath:/application.properties", "-P${user.home}/.featurehub/example-java.properties"}); + } +} diff --git a/examples/todo-java-shared/.editorconfig b/examples/todo-java-shared/.editorconfig new file mode 100644 index 0000000..5e21e8a --- /dev/null +++ b/examples/todo-java-shared/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yaml] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.adoc] +trim_trailing_whitespace = false diff --git a/examples/todo-java/.gitignore b/examples/todo-java-shared/.gitignore similarity index 100% rename from examples/todo-java/.gitignore rename to examples/todo-java-shared/.gitignore diff --git a/examples/todo-java/README.adoc b/examples/todo-java-shared/README.adoc similarity index 100% rename from examples/todo-java/README.adoc rename to examples/todo-java-shared/README.adoc diff --git a/examples/todo-java-shared/pom.xml b/examples/todo-java-shared/pom.xml new file mode 100644 index 0000000..8f54911 --- /dev/null +++ b/examples/todo-java-shared/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT + todo-java-shared + + This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). + + It expects environment variables or system property config as follows: + + - feature-service.host = the host where features are stored, e.g. http://localhost:8085 + - feature-service.api-key = the API key issued by the server. + + There are examples in https://github.com/featurehub-io/featurehub/tree/main/adks/e2e-sdk on how to populate your + server automatically via tests to create the features required for this scenario. + + + + ${project.artifactId} + ${project.version} + connect_todo + 3.0.1 + 2.0.0 + 3.1.2 + + + + + io.featurehub.sdk + java-client-core + [4.1-SNAPSHOT, 5) + + + + io.featurehub.sdk.java + segment-usageadapter + [1.1-SNAPSHOT, 2) + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + + + + io.featurehub.sdk.java + opentelemetry-usageadapter + [1.1-SNAPSHOT, 2) + + + + + cd.connect.common + connect-app-declare-config + 1.3 + + + net.stickycode.composite + sticky-composite-logging-api + + + + + + + com.bluetrainsoftware.bathe + bathe-booter + [3.1, 4) + + + + + com.bluetrainsoftware.bathe.initializers + system-property-loader + 3.1 + + + * + * + + + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1-SNAPSHOT, 2) + + + + + com.bluetrainsoftware.bathe.initializers + jul-bridge + 2.1 + + + * + * + + + + + + + org.yaml + snakeyaml + 2.0 + + + + + cd.connect.common + connect-app-lifecycle + 1.1 + + + + io.featurehub.sdk.composites + sdk-composite-test + 1.2 + test + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + + + + + + diff --git a/examples/todo-java/src/main/java/todo/Features.java b/examples/todo-java-shared/src/main/java/todo/Features.java similarity index 100% rename from examples/todo-java/src/main/java/todo/Features.java rename to examples/todo-java-shared/src/main/java/todo/Features.java diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHub.java similarity index 100% rename from examples/todo-java/src/main/java/todo/backend/FeatureHub.java rename to examples/todo-java-shared/src/main/java/todo/backend/FeatureHub.java diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java similarity index 100% rename from examples/todo-java/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java rename to examples/todo-java-shared/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java diff --git a/examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java b/examples/todo-java-shared/src/main/java/todo/backend/UsageRequestMeasurement.java similarity index 100% rename from examples/todo-java/src/main/java/todo/backend/UsageRequestMeasurement.java rename to examples/todo-java-shared/src/main/java/todo/backend/UsageRequestMeasurement.java diff --git a/examples/todo-java/src/main/resources/log4j2.xml b/examples/todo-java-shared/src/main/resources/log4j2.xml similarity index 100% rename from examples/todo-java/src/main/resources/log4j2.xml rename to examples/todo-java-shared/src/main/resources/log4j2.xml diff --git a/examples/todo-java-shared/src/test/java/todo/backend/.keep b/examples/todo-java-shared/src/test/java/todo/backend/.keep new file mode 100644 index 0000000..b788975 --- /dev/null +++ b/examples/todo-java-shared/src/test/java/todo/backend/.keep @@ -0,0 +1 @@ +-- keep diff --git a/examples/todo-java/todo-api.yaml b/examples/todo-java-shared/todo-api.yaml similarity index 97% rename from examples/todo-java/todo-api.yaml rename to examples/todo-java-shared/todo-api.yaml index 628d9c5..336f397 100644 --- a/examples/todo-java/todo-api.yaml +++ b/examples/todo-java-shared/todo-api.yaml @@ -116,10 +116,13 @@ components: properties: id: type: string + nullable: true title: type: string resolved: type: boolean + nullable: true when: + nullable: true type: string format: date-time diff --git a/examples/todo-java/todo.txt b/examples/todo-java-shared/todo.txt similarity index 100% rename from examples/todo-java/todo.txt rename to examples/todo-java-shared/todo.txt diff --git a/pom.xml b/pom.xml index 353c48c..9455183 100644 --- a/pom.xml +++ b/pom.xml @@ -34,14 +34,14 @@ - client-java-core - client-java-okhttp - client-java-android21 - client-java-jersey - client-java-jersey3 - client-java-api + core/client-java-core + core/client-java-api + client-implementations/java-client-okhttp + client-implementations/java-client-jersey2 + client-implementations/java-client-jersey3 support examples usage-adapters + unmaintained/client-java-android21 diff --git a/client-java-loadtest/pom.xml b/support/client-java-loadtest/pom.xml similarity index 100% rename from client-java-loadtest/pom.xml rename to support/client-java-loadtest/pom.xml diff --git a/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java b/support/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java similarity index 100% rename from client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java rename to support/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java diff --git a/client-java-loadtest/src/main/resources/log4j2.xml b/support/client-java-loadtest/src/main/resources/log4j2.xml similarity index 100% rename from client-java-loadtest/src/main/resources/log4j2.xml rename to support/client-java-loadtest/src/main/resources/log4j2.xml diff --git a/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java b/support/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java similarity index 100% rename from client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java rename to support/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java diff --git a/support/composite-jersey2/pom.xml b/support/composite-jersey2/pom.xml index 442aceb..3b7faa9 100644 --- a/support/composite-jersey2/pom.xml +++ b/support/composite-jersey2/pom.xml @@ -63,6 +63,12 @@ ${jersey.version} + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + + org.glassfish.jersey.core diff --git a/client-java-android21/CHANGELOG.adoc b/unmaintained/client-java-android21/CHANGELOG.adoc similarity index 100% rename from client-java-android21/CHANGELOG.adoc rename to unmaintained/client-java-android21/CHANGELOG.adoc diff --git a/client-java-android21/README.adoc b/unmaintained/client-java-android21/README.adoc similarity index 100% rename from client-java-android21/README.adoc rename to unmaintained/client-java-android21/README.adoc diff --git a/client-java-android21/pom.xml b/unmaintained/client-java-android21/pom.xml similarity index 100% rename from client-java-android21/pom.xml rename to unmaintained/client-java-android21/pom.xml diff --git a/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java diff --git a/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/Applied.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Applied.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Applied.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Applied.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/EdgeService.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java diff --git a/client-java-core/src/main/java/io/featurehub/client/Feature.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Feature.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/Feature.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Feature.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureState.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/Readyness.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Readyness.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Readyness.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Readyness.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java diff --git a/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java diff --git a/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/unmaintained/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to unmaintained/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy b/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy similarity index 100% rename from client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy rename to unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy diff --git a/client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/unmaintained/client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java similarity index 100% rename from client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java rename to unmaintained/client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java diff --git a/client-java-okhttp/src/test/resources/log4j2.xml b/unmaintained/client-java-android21/src/test/resources/log4j2.xml similarity index 100% rename from client-java-okhttp/src/test/resources/log4j2.xml rename to unmaintained/client-java-android21/src/test/resources/log4j2.xml diff --git a/usage-adapters/featurehub-opentelemetry-adapter/README.adoc b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc index 34c8e42..11007df 100644 --- a/usage-adapters/featurehub-opentelemetry-adapter/README.adoc +++ b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc @@ -1,10 +1,14 @@ == FeatureHub Opentelemetry Usage Plugin for Java -The purpose of this usage plugin is to allow Usage tracking events to be attached as Span events in OpenTelemetry. +The purpose of this usage plugin is to allow Usage tracking events to be attached as Span events in OpenTelemetry. FeatureHub uses https://www.honeycomb.io/[HoneyComb for testing]. + This allows you to view spans in your OpenTelemetry system and see what features were evaluated, and what their values were. -If you record a custom FeatureHub Usage event, it will be translated as long as it implements the `UsageEventName` interface. All attributes are logged with a prefix, which defaults to `featurehub.` - but this can be changed on construction. +The features can be attached as Span Attributes (which usually means they don't have any extra cost, but you cannot see multiple evaluations in a single span) or as events (which +typically cost money but let you see multiple evaluations). This is controlled by the environment variable `FEATUREHUB_OTEL_SPAN_AS_EVENTS` which is `false` by default. Set it to `true` to attach as events. + +If you record a custom FeatureHub Usage event (not to be confused with an OpenTelemetry span event), it will be translated as long as it implements the `UsageEventName` interface. All attributes are logged with a prefix, which defaults to `featurehub.` - but this can be changed on construction. The plugin does not specify a version of the OpenTelemetry libraries to use, it expects your application will include and configure all of these. diff --git a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml index a01881a..1c53cba 100644 --- a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml +++ b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml @@ -14,8 +14,8 @@ io.featurehub.sdk - java-client-okhttp - [3.1-SNAPSHOT, 4) + java-client-core + [4.1-SNAPSHOT, 5) diff --git a/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java index cae8e01..91508ef 100644 --- a/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java +++ b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java @@ -19,6 +19,7 @@ public class OpenTelemetryUsagePlugin extends UsagePlugin { private static final Logger log = LoggerFactory.getLogger(OpenTelemetryUsagePlugin.class); private final String prefix; + private final boolean attachAsSpanEvents = "true".equals(System.getenv("FEATUREHUB_OTEL_SPAN_AS_EVENTS")); public OpenTelemetryUsagePlugin(String prefix) { this.prefix = prefix; @@ -41,10 +42,17 @@ public void send(UsageEvent event) { if (!usageAttributes.isEmpty()) { final AttributesBuilder builder = Attributes.builder(); - defaultEventParams.forEach((k, v) -> putMe(k, v, builder)); - usageAttributes.forEach((k, v) -> putMe(k, v, builder)); + if (attachAsSpanEvents) { + defaultEventParams.forEach((k, v) -> putMe(k, v, builder)); + usageAttributes.forEach((k, v) -> putMe(k, v, builder)); - current.addEvent(prefix(name), builder.build(), Instant.now()); + current.addEvent(prefix(name), builder.build(), Instant.now()); + } else { + defaultEventParams.forEach((k, v) -> putMe(prefix(k), v, builder)); + usageAttributes.forEach((k, v) -> putMe(prefix(k), v, builder)); + + current.setAllAttributes(builder.build()); + } } } } diff --git a/usage-adapters/featurehub-segment-adapter/pom.xml b/usage-adapters/featurehub-segment-adapter/pom.xml index fb9fd45..95284cf 100644 --- a/usage-adapters/featurehub-segment-adapter/pom.xml +++ b/usage-adapters/featurehub-segment-adapter/pom.xml @@ -14,8 +14,8 @@ io.featurehub.sdk - java-client-okhttp - [3.1-SNAPSHOT, 4) + java-client-core + [4.1-SNAPSHOT, 5) From d9155fd97fb8d2a782be5f1142f378f8d5de0f5b Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 22 Aug 2024 19:55:13 +1200 Subject: [PATCH 11/22] tests passing --- build_alL_and_test.sh | 4 +- .../client/jersey/JerseySSEClient.java | 29 ++++++------- .../featurehub/client/jersey/RestClient.java | 1 - .../client/jersey/JerseySSEClientSpec.groovy | 4 +- .../client/jersey/RestClientSpec.groovy | 15 ++++--- .../client/jersey/JerseySSEClient.java | 12 ++++-- .../client/jersey/JerseySSEClientSpec.groovy | 2 +- .../client/jersey/RestClientSpec.groovy | 9 ++-- client-implementations/pom.xml | 41 +++++++++++++++++++ .../featurehub/client/edge/EdgeRetryer.java | 4 +- .../client/EdgeFeatureHubConfigSpec.groovy | 4 ++ .../client/edge/EdgeRetryerSpec.groovy | 2 +- core/pom.xml | 40 ++++++++++++++++++ pom.xml | 9 ++-- .../android/FeatureHubClientSpec.groovy | 2 +- unmaintained/pom.xml | 39 ++++++++++++++++++ 16 files changed, 174 insertions(+), 43 deletions(-) create mode 100644 client-implementations/pom.xml create mode 100644 core/pom.xml create mode 100644 unmaintained/pom.xml diff --git a/build_alL_and_test.sh b/build_alL_and_test.sh index 18e200c..f646bcd 100755 --- a/build_alL_and_test.sh +++ b/build_alL_and_test.sh @@ -1,6 +1,6 @@ #!/bin/sh set -x -export MVN_OPTS="--batch-mode --quiet" -MAVEN_OPTS=${MVN_OPTS:-"-T4C"} +MAVEN_OPTS=${MVN_OPTS} +echo "cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn $MAVEN_OPTS clean install" cd support && mvn -f pom-tiles.xml install && mvn install && cd .. && mvn $MAVEN_OPTS clean install diff --git a/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index 08b67fa..4752ab5 100644 --- a/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -150,7 +150,7 @@ private void initEventSource() { boolean interrupted = false; - while (!eventSource.isClosed() && !interrupted) { + while (!eventSource.isClosed() && !retryer.isStopped() && !interrupted) { if (notify != null) { // this is for testing notify.accept(null); } @@ -207,29 +207,31 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve try { final SSEResultState state = retryer.fromValue(event.getName()); + if (log.isTraceEnabled()) { + log.trace("[featurehub-sdk] decode packet (state {}) {}:{}", state, event.getName(), data); + } + if (state == null) { // unknown state return connectionSaidBye; } - log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); - if (state == SSEResultState.CONFIG) { retryer.edgeConfigInfo(data); } else { retryer.convertSSEState(state, data, repository); - } - // reset the timer - if (state == SSEResultState.FEATURES) { - retryer.edgeResult(EdgeConnectionState.SUCCESS, this); - } + // reset the timer + if (state == SSEResultState.FEATURES) { + retryer.edgeResult(EdgeConnectionState.SUCCESS, this); + } - if (state == SSEResultState.BYE) { - connectionSaidBye = true; - } + if (state == SSEResultState.BYE) { + connectionSaidBye = true; + } - if (state == SSEResultState.FAILURE) { - retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); + if (state == SSEResultState.FAILURE) { + retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); + } } // tell any waiting clients we are now ready @@ -244,7 +246,6 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve } private void notifyWaitingClients() { - log.trace("notifying {} waiting clients", waitingClients.size()); waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); } diff --git a/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java index a253cc0..0c0217b 100644 --- a/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java +++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java @@ -239,7 +239,6 @@ protected void processResponse(ApiResponse> r private void completeReadiness() { List> current = waitingClients; waitingClients = new ArrayList<>(); - log.trace("notifying {} clients", current.size()); current.forEach(c -> { try { c.complete(repository.getReadiness()); diff --git a/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy index 587e153..3bf6d5b 100644 --- a/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy +++ b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -32,7 +32,7 @@ class JerseySSEClientSpec extends Specification { return output }) - edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { + edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build()) { @Override void reconnect() { close(); @@ -44,7 +44,7 @@ class JerseySSEClientSpec extends Specification { def cleanup() { - harness.tearDown() + harness?.tearDown() } def "A basic client connect works as expected"() { diff --git a/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy index e301018..8d08ad5 100644 --- a/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy +++ b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -4,6 +4,7 @@ import cd.connect.openapi.support.ApiResponse import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository import io.featurehub.client.Readiness +import io.featurehub.client.edge.EdgeRetryService import io.featurehub.sse.model.FeatureEnvironmentCollection import io.featurehub.sse.model.SSEResultState import spock.lang.Specification @@ -16,14 +17,16 @@ class RestClientSpec extends Specification { InternalFeatureRepository repo FeatureHubConfig config List apiKeys + EdgeRetryService retryer def setup() { apiKeys = ["123"] featureService = Mock() repo = Mock() config = Mock() + retryer = Mock() config.isServerEvaluation() >> true - client = new RestClient(repo, featureService, config, 0, false) + client = new RestClient(repo, featureService, config, retryer, 0, false) } ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { @@ -132,7 +135,7 @@ class RestClientSpec extends Specification { def "change the polling interval to 180 seconds and a second poll won't poll"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180, false) + client = new RestClient(repo, featureService, config, retryer, 180, false) when: def result = client.poll().get() def result2 = client.poll().get() @@ -144,13 +147,13 @@ class RestClientSpec extends Specification { 0 * _ } - def "change the polling interval to 180 seconds and force cache breaking"() { + def "change polling interval to 180 seconds and force breaking cache on every check"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180, true) + client = new RestClient(repo, featureService, config, retryer, 180, true) when: - def result = client.poll().get() - def result2 = client.poll().get() + client.poll().get() + client.poll().get() then: 2 * repo.updateFeatures([]) 2 * config.apiKeys() >> apiKeys diff --git a/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java index bba87b6..5648ada 100644 --- a/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java @@ -112,6 +112,8 @@ public void close() { } eventSource = null; + + retryer.close(); } } @@ -149,7 +151,7 @@ private void initEventSource() { boolean interrupted = false; - while (!eventSource.isClosed() && !interrupted) { + while (!eventSource.isClosed() && !retryer.isStopped() && !interrupted) { if (notify != null) { // this is for testing notify.accept(null); } @@ -207,17 +209,18 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve try { final SSEResultState state = retryer.fromValue(event.getName()); + if (log.isTraceEnabled()) { + log.trace("[featurehub-sdk] decode packet (state {}) {}:{}", state, event.getName(), data); + } + if (state == null) { // unknown state return connectionSaidBye; } - log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); - if (state == SSEResultState.CONFIG) { retryer.edgeConfigInfo(data); } else { retryer.convertSSEState(state, data, repository); - } // reset the timer if (state == SSEResultState.FEATURES) { @@ -231,6 +234,7 @@ private boolean processResult(boolean connectionSaidBye, String data, InboundEve if (state == SSEResultState.FAILURE) { retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, this); } + } // tell any waiting clients we are now ready if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG)) { diff --git a/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy index c5923a5..3bf6d5b 100644 --- a/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy +++ b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy @@ -32,7 +32,7 @@ class JerseySSEClientSpec extends Specification { return output }) - edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()) { + edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build()) { @Override void reconnect() { close(); diff --git a/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy index 90562f7..d149c97 100644 --- a/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy +++ b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy @@ -4,6 +4,7 @@ import cd.connect.openapi.support.ApiResponse import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository import io.featurehub.client.Readiness +import io.featurehub.client.edge.EdgeRetryService import io.featurehub.sse.model.FeatureEnvironmentCollection import io.featurehub.sse.model.SSEResultState import spock.lang.Specification @@ -16,14 +17,16 @@ class RestClientSpec extends Specification { InternalFeatureRepository repo FeatureHubConfig config List apiKeys + EdgeRetryService retryer def setup() { apiKeys = ["123"] featureService = Mock() repo = Mock() config = Mock() + retryer = Mock() config.isServerEvaluation() >> true - client = new RestClient(repo, featureService, config, 0, false) + client = new RestClient(repo, featureService, config, retryer, 0, false) } ApiResponse> build(int statusCode = 200, List data = [], Map headers = [:]) { @@ -132,7 +135,7 @@ class RestClientSpec extends Specification { def "change the polling interval to 180 seconds and a second poll won't poll"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180, false) + client = new RestClient(repo, featureService, config, retryer, 180, false) when: def result = client.poll().get() def result2 = client.poll().get() @@ -147,7 +150,7 @@ class RestClientSpec extends Specification { def "change polling interval to 180 seconds and force breaking cache on every check"() { given: def response = build() - client = new RestClient(repo, featureService, config, 180, true) + client = new RestClient(repo, featureService, config, retryer, 180, true) when: client.poll().get() client.poll().get() diff --git a/client-implementations/pom.xml b/client-implementations/pom.xml new file mode 100644 index 0000000..dc3d2f1 --- /dev/null +++ b/client-implementations/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + io.featurehub.sdk.java + client-implementations-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + java-client-jersey2 + java-client-jersey3 + java-client-okhttp + + diff --git a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index c2e1227..fd474cd 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -56,7 +56,7 @@ public class EdgeRetryer implements EdgeRetryService { new TypeReference>() {}; protected EdgeRetryer(int serverReadTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, - int backoffMultiplier, int maximumBackoffTimeMs, int serverConnectTmeoutMs) { + int backoffMultiplier, int maximumBackoffTimeMs, int serverConnectTimeoutMs) { this.serverReadTimeoutMs = serverReadTimeoutMs; this.serverDisconnectRetryMs = serverDisconnectRetryMs; this.serverByeReconnectMs = serverByeReconnectMs; @@ -64,7 +64,7 @@ protected EdgeRetryer(int serverReadTimeoutMs, int serverDisconnectRetryMs, int this.maximumBackoffTimeMs = maximumBackoffTimeMs; currentBackoffMultiplier = backoffMultiplier; - this.connectionFailureBackoffTimeMs = serverConnectTmeoutMs; + this.connectionFailureBackoffTimeMs = serverConnectTimeoutMs; executorService = makeExecutorService(); } diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index d74aa4b..7d5b838 100644 --- a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -5,6 +5,7 @@ import io.featurehub.client.usage.UsageProvider import spock.lang.Specification import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService import java.util.function.Consumer class EdgeFeatureHubConfigSpec extends Specification { @@ -116,6 +117,7 @@ class EdgeFeatureHubConfigSpec extends Specification { def "i can pre-replace the repository and edge supplier and the context gets created as expected"() { given: "i have mocked the edge supplier" def mockRepo = Mock(InternalFeatureRepository) + def executor = Mock(ExecutorService) config.setRepository(mockRepo) when: def ctx = config.init().get() as BaseClientContext @@ -123,6 +125,8 @@ class EdgeFeatureHubConfigSpec extends Specification { ctx.edgeService == edgeClient ctx.repository == mockRepo 1 * edgeClient.contextChange(null, '0') >> CompletableFuture.completedFuture(Readiness.Ready) + 1 * mockRepo.getExecutor() >> executor + 1 * executor.execute { Runnable r -> r.run() } 0 * _ } } diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy index b844672..d77bdaf 100644 --- a/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy @@ -14,7 +14,7 @@ class EdgeRetryerSpec extends Specification { def setup() { mockExecutor = Mock(ExecutorService) reconnector = Mock(EdgeReconnector) - retryer = new EdgeRetryer(100, 100, 100, 10, 100) { + retryer = new EdgeRetryer(100, 100, 100, 10, 100, 100) { @Override protected ExecutorService makeExecutorService() { return mockExecutor diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..e33e59d --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.featurehub.sdk.java + core-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + client-java-api + client-java-core + + diff --git a/pom.xml b/pom.xml index 9455183..1ea2845 100644 --- a/pom.xml +++ b/pom.xml @@ -34,14 +34,11 @@ - core/client-java-core - core/client-java-api - client-implementations/java-client-okhttp - client-implementations/java-client-jersey2 - client-implementations/java-client-jersey3 + core + client-implementations support examples usage-adapters - unmaintained/client-java-android21 + unmaintained diff --git a/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy b/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy index 57d69b8..f959953 100644 --- a/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy +++ b/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy @@ -15,7 +15,7 @@ class FeatureHubClientSpec extends Specification { def "a null sdk url will never trigger a call"() { when: "i initialize the client" call = Mock(Call) - def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig)) + def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig), 0) and: "check for updates" fhc.checkForUpdates() then: diff --git a/unmaintained/pom.xml b/unmaintained/pom.xml new file mode 100644 index 0000000..e2790af --- /dev/null +++ b/unmaintained/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + io.featurehub.sdk.java + unmaintained-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + client-java-android21 + + From b73b402ff5a2e5fcd5ebd86cd26a8159bff76a96 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 22 Aug 2024 20:48:14 +1200 Subject: [PATCH 12/22] okhttp client as expected --- .../okhttp/OkHttpFeatureHubFactory.java | 5 +++-- .../java/io/featurehub/okhttp/RestClient.java | 22 ++++++++++++++----- .../java/io/featurehub/okhttp/SSEClient.java | 14 ++++++++++-- .../featurehub/okhttp/RestClientSpec.groovy | 3 ++- .../FeatureHubClientRunner.java | 4 ++-- 5 files changed, 36 insertions(+), 12 deletions(-) rename client-implementations/java-client-okhttp/src/test/java/io/featurehub/{android => okhttp}/FeatureHubClientRunner.java (95%) diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java index b54707c..ad7830b 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java @@ -26,8 +26,9 @@ public Supplier createSSEEdge(@NotNull FeatureHubConfig config) { @Override @NotNull - public Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { - return () -> new RestClient(repository, config, timeoutInSeconds, amPollingDelegate); + public Supplier createRestEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPollingDelegate) { + return () -> new RestClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), timeoutInSeconds, amPollingDelegate); } @Override diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index 7fdd3db..8b606bb 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -6,6 +6,8 @@ import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.Readiness; +import io.featurehub.client.edge.EdgeRetryService; +import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.utils.SdkVersion; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureState; @@ -22,6 +24,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -36,6 +39,7 @@ public class RestClient implements EdgeService { private static final Logger log = LoggerFactory.getLogger(RestClient.class); @NotNull private final InternalFeatureRepository repository; @NotNull private final OkHttpClient client; + private final EdgeRetryService edgeRetryService; private boolean makeRequests; @NotNull private final String url; private final ObjectMapper mapper = new ObjectMapper(); @@ -56,14 +60,22 @@ public class RestClient implements EdgeService { @NotNull private final ExecutorService executorService; public RestClient(@Nullable InternalFeatureRepository repository, - @NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate) { + @NotNull FeatureHubConfig config, @NotNull EdgeRetryService edgeRetryService, int timeoutInSeconds, boolean amPollingDelegate) { + + this.edgeRetryService = edgeRetryService; + if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } this.amPollingDelegate = amPollingDelegate; this.repository = repository; - this.client = new OkHttpClient(); + + this.client = new OkHttpClient.Builder() + .connectTimeout(Duration.ofMillis(edgeRetryService.getServerConnectTimeoutMs())) + .readTimeout(Duration.ofMillis(edgeRetryService.getServerReadTimeoutMs())) + .build(); + this.config = config; this.pollingInterval = timeoutInSeconds; @@ -86,12 +98,12 @@ protected ExecutorService makeExecutorService() { } public RestClient(@NotNull FeatureHubConfig config, - int timeoutInSeconds) { - this(null, config, timeoutInSeconds, false); + @NotNull EdgeRetryService edgeRetryService, int timeoutInSeconds) { + this(null, config, edgeRetryService, timeoutInSeconds, false); } public RestClient(@NotNull FeatureHubConfig config) { - this(null, config, 180, false); + this(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), 180, false); } private final static TypeReference> ref = new TypeReference>(){}; diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java index 965dacd..cc1183f 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java @@ -20,6 +20,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.SocketTimeoutException; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -138,11 +140,18 @@ public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Null @Override public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { log.trace("[featurehub-sdk] failed to connect to {} - {}", config.baseUrl(), response, t); + if (repository.getReadiness() == Readiness.NotReady) { repository.notify(SSEResultState.FAILURE); } - retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); + if (t instanceof java.net.ConnectException) { + retryer.edgeResult(EdgeConnectionState.CONNECTION_FAILURE, connector); + } else if (t instanceof SocketTimeoutException) { // shouldn't happen if long enough + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, connector); + } else { + retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); + } } @Override @@ -156,7 +165,8 @@ protected EventSource makeEventSource(Request request, EventSourceListener liste if (eventSourceFactory == null) { client = new OkHttpClient.Builder() - .readTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(retryer.getServerReadTimeoutMs(), TimeUnit.MILLISECONDS) + .connectTimeout(Duration.ofMillis(retryer.getServerConnectTimeoutMs())) .build(); eventSourceFactory = EventSources.createFactory(client); diff --git a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy index 11071de..ea11b9f 100644 --- a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy +++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository import io.featurehub.client.Readiness +import io.featurehub.client.edge.EdgeRetryer import io.featurehub.sse.model.FeatureEnvironmentCollection import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -27,7 +28,7 @@ class RestClientSpec extends Specification { config.baseUrl() >> url.substring(0, url.length() - 1) config.apiKeys() >> ["one", "two"] config.serverEvaluation >> true - client = new RestClient(config, 0) + client = new RestClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), 0) mockWebServer.url("/features") } diff --git a/client-implementations/java-client-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java similarity index 95% rename from client-implementations/java-client-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java rename to client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java index dcfe332..eaed284 100644 --- a/client-implementations/java-client-okhttp/src/test/java/io/featurehub/android/FeatureHubClientRunner.java +++ b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java @@ -1,4 +1,4 @@ -package io.featurehub.android; +package io.featurehub.okhttp; import io.featurehub.client.ClientContext; import io.featurehub.client.EdgeFeatureHubConfig; @@ -16,7 +16,7 @@ public static void main(String[] args) throws Exception { FeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8064", "default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); - final ClientContext ctx = config.newContext(); + final ClientContext ctx = config.newContext().build().get(); ctx.getRepository().addReadinessListener(rl -> System.out.println("readyness " + rl.toString())); final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); From 98babf560f7897433fbaa4149ef34ed8869e47e1 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 29 Aug 2024 19:29:41 +1200 Subject: [PATCH 13/22] quarkus and okhttp example --- .../java-client-okhttp/README.adoc | 28 -- .../java-client-okhttp/pom.xml | 11 + .../java/io/featurehub/okhttp/SSEClient.java | 255 +++++++++++------- .../okhttp/FeatureHubClientRunner.java | 4 +- .../client/ClientFeatureRepository.java | 10 +- .../featurehub/client/FeatureHubConfig.java | 2 +- .../client/FeatureHubThreadFactory.java | 38 +++ examples/pom.xml | 1 + .../backend/resources/HealthResource.java | 2 + examples/todo-java-quarkus/.gitignore | 33 +++ examples/todo-java-quarkus/README.adoc | 45 ++++ examples/todo-java-quarkus/pom.xml | 171 ++++++++++++ .../examples/quarkus/AuthFilter.java | 57 ++++ .../examples/quarkus/FeatureHubSource.java | 113 ++++++++ .../examples/quarkus/HealthResource.java | 32 +++ .../examples/quarkus/TodoResource.java | 127 +++++++++ .../examples/quarkus/TodoSource.java | 16 ++ .../src/main/resources/application.properties | 8 + 18 files changed, 813 insertions(+), 140 deletions(-) create mode 100644 core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java create mode 100644 examples/todo-java-quarkus/.gitignore create mode 100644 examples/todo-java-quarkus/README.adoc create mode 100644 examples/todo-java-quarkus/pom.xml create mode 100644 examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java create mode 100644 examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java create mode 100644 examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java create mode 100644 examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java create mode 100644 examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java create mode 100644 examples/todo-java-quarkus/src/main/resources/application.properties diff --git a/client-implementations/java-client-okhttp/README.adoc b/client-implementations/java-client-okhttp/README.adoc index d59187a..e440b9e 100644 --- a/client-implementations/java-client-okhttp/README.adoc +++ b/client-implementations/java-client-okhttp/README.adoc @@ -15,16 +15,6 @@ the app is evaluating features, it will re-request the feature state. == Considerations -=== Library Dependencies - -The REST SDK *does not poll*. It allows the user of the SDK to create a polling mechanism which suites their application. - -The SDK provides a standard interface to make HTTP GET requests for the data but sets a period for which it will hold off making new requests (a timeout, which you can set to 0 if you have your own timer). - -This will allow you to keep requesting updates but they will not actually issue REST calls to the FeatureHub Edge server unless the Client Context changes or the timeout has occurred. - -Visit our official web page for more information about the platform https://www.featurehub.io/[here] - === Dependencies This library uses: @@ -32,30 +22,12 @@ This library uses: - Jackson 2.x (for json) - SLF4j (for logging) -=== Using - -When using the REST client, simply including it and following the standard pattern works and is generally -recommended when you are happy with the OKHttpClient defaults. This pattern is as follows: - -[source,java] ----- -String edgeUrl = "http://localhost:8085/"; -String apiKey = "71ed3c04-122b-4312-9ea8-06b2b8d6ceac/fsTmCrcZZoGyl56kPHxfKAkbHrJ7xZMKO3dlBiab5IqUXjgKvqpjxYdI8zdXiJqYCpv92Jrki0jY5taE"; - -FeatureHubConfig fhConfig = new EdgeFeatureHubConfig(edgeUrl, apiKey); -fhConfig.init(); ----- - -The `init` method creates the `FeatureHubClient` via the default method using Java services, and calls -the initial `poll` method on it. - ==== Using directly It is recommended that developers use this SDK directly if they wish to have full control. Using it directly allows you complete control over the OKHttpConfig object that is passed, being able to set connect and request timeouts, interceptors for adding any extra parameters and so forth. - [source,java] ---- // create the central config diff --git a/client-implementations/java-client-okhttp/pom.xml b/client-implementations/java-client-okhttp/pom.xml index e19e2d3..4fe797c 100644 --- a/client-implementations/java-client-okhttp/pom.xml +++ b/client-implementations/java-client-okhttp/pom.xml @@ -45,6 +45,7 @@ 4.12.0 + 3.6.0 @@ -55,16 +56,26 @@ [4, 5) + com.squareup.okhttp3 okhttp ${ok.http.version} + provided + + + + com.squareup.okio + okio + ${ok.io.version} + provided com.squareup.okhttp3 okhttp-sse ${ok.http.version} + provided diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java index cc1183f..7ebbfa5 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java @@ -9,9 +9,7 @@ import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.utils.SdkVersion; import io.featurehub.sse.model.SSEResultState; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; +import okhttp3.*; import okhttp3.sse.EventSource; import okhttp3.sse.EventSourceListener; import okhttp3.sse.EventSources; @@ -20,6 +18,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.net.SocketTimeoutException; import java.time.Duration; import java.util.ArrayList; @@ -39,16 +40,17 @@ public class SSEClient implements EdgeService, EdgeReconnector { private final EdgeRetryService retryer; private final List> waitingClients = new ArrayList<>(); - - public SSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, - @NotNull EdgeRetryService retryer) { - this.repository = repository == null ? (InternalFeatureRepository) config.getRepository() : repository; + public SSEClient( + @Nullable InternalFeatureRepository repository, + @NotNull FeatureHubConfig config, + @NotNull EdgeRetryService retryer) { + this.repository = + repository == null ? (InternalFeatureRepository) config.getRepository() : repository; this.config = config; this.retryer = retryer; } - public SSEClient(@NotNull FeatureHubConfig config, - @NotNull EdgeRetryService retryer) { + public SSEClient(@NotNull FeatureHubConfig config, @NotNull EdgeRetryService retryer) { this(null, config, retryer); } @@ -69,104 +71,151 @@ public long currentInterval() { private boolean connectionSaidBye; private void initEventSource() { - Request.Builder reqBuilder = new Request.Builder().url(this.config.getRealtimeUrl()); - - if (xFeaturehubHeader != null) { - reqBuilder = reqBuilder.addHeader("x-featurehub", xFeaturehubHeader); - } - - reqBuilder.addHeader("X-SDK", SdkVersion.sdkVersionHeader("Java-OKHTTP-SSE")); - - Request request = reqBuilder.build(); - - // we need to know if the connection already said "bye" so as to pass the right reconnection event - connectionSaidBye = false; - final EdgeReconnector connector = this; - - eventSource = makeEventSource(request, new EventSourceListener() { - @Override - public void onClosed(@NotNull EventSource eventSource) { - log.trace("[featurehub-sdk] closed"); - - if (repository.getReadiness() == Readiness.NotReady) { - repository.notify(SSEResultState.FAILURE); - } + try { + Request.Builder reqBuilder = new Request.Builder().url(this.config.getRealtimeUrl()); - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); + if (xFeaturehubHeader != null) { + reqBuilder = reqBuilder.addHeader("x-featurehub", xFeaturehubHeader); } - @Override - public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, - @Nullable String data) { - try { - final SSEResultState state = retryer.fromValue(type); - - if (state == null) { // unknown state - return; - } - - log.trace("[featurehub-sdk] decode packet {}:{}", type, data); - - if (state == SSEResultState.CONFIG) { - retryer.edgeConfigInfo(data); - } else if (data != null) { - retryer.convertSSEState(state, data, repository); - } - - // reset the timer - if (state == SSEResultState.FEATURES) { - retryer.edgeResult(EdgeConnectionState.SUCCESS, connector); - } - - if (state == SSEResultState.BYE) { - connectionSaidBye = true; - } - - if (state == SSEResultState.FAILURE) { - retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, connector); - } - - // tell any waiting clients we are now ready - if (!waitingClients.isEmpty() && (state != SSEResultState.ACK && state != SSEResultState.CONFIG) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); - } - } catch (Exception e) { - log.error("[featurehub-sdk] failed to decode packet {}:{}", type, data, e); - } - } - - @Override - public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { - log.trace("[featurehub-sdk] failed to connect to {} - {}", config.baseUrl(), response, t); - - if (repository.getReadiness() == Readiness.NotReady) { - repository.notify(SSEResultState.FAILURE); - } - - if (t instanceof java.net.ConnectException) { - retryer.edgeResult(EdgeConnectionState.CONNECTION_FAILURE, connector); - } else if (t instanceof SocketTimeoutException) { // shouldn't happen if long enough - retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, connector); - } else { - retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); - } - } + reqBuilder.addHeader("X-SDK", SdkVersion.sdkVersionHeader("Java-OKHTTP-SSE")); + + Request request = reqBuilder.build(); + + // we need to know if the connection already said "bye" so as to pass the right reconnection + // event + connectionSaidBye = false; + final EdgeReconnector connector = this; + + eventSource = + makeEventSource( + request, + new EventSourceListener() { + @Override + public void onClosed(@NotNull EventSource eventSource) { + log.trace("[featurehub-sdk] closed"); + + if (repository.getReadiness() == Readiness.NotReady) { + repository.notify(SSEResultState.FAILURE); + } + + // send this once we are actually disconnected and not before + retryer.edgeResult( + connectionSaidBye + ? EdgeConnectionState.SERVER_SAID_BYE + : EdgeConnectionState.SERVER_WAS_DISCONNECTED, + connector); + } + + @Override + public void onEvent( + @NotNull EventSource eventSource, + @Nullable String id, + @Nullable String type, + @Nullable String data) { + try { + final SSEResultState state = retryer.fromValue(type); + + if (state == null) { // unknown state + return; + } + + log.trace("[featurehub-sdk] decode packet {}:{}", type, data); + + if (state == SSEResultState.CONFIG) { + retryer.edgeConfigInfo(data); + } else if (data != null) { + retryer.convertSSEState(state, data, repository); + } + + // reset the timer + if (state == SSEResultState.FEATURES) { + retryer.edgeResult(EdgeConnectionState.SUCCESS, connector); + } + + if (state == SSEResultState.BYE) { + connectionSaidBye = true; + } + + if (state == SSEResultState.FAILURE) { + retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, connector); + } + + // tell any waiting clients we are now ready + if (!waitingClients.isEmpty() + && (state != SSEResultState.ACK && state != SSEResultState.CONFIG)) { + waitingClients.forEach(wc -> wc.complete(repository.getReadiness())); + } + } catch (Exception e) { + log.error("[featurehub-sdk] failed to decode packet {}:{}", type, data, e); + } + } + + @Override + public void onFailure( + @NotNull EventSource eventSource, + @Nullable Throwable t, + @Nullable Response response) { + if (repository.getReadiness() == Readiness.NotReady) { + log.trace( + "[featurehub-sdk] failed to connect to {} - {}", + config.baseUrl(), + response, + t); + repository.notify(SSEResultState.FAILURE); + } + + if (t instanceof java.net.ConnectException) { + retryer.edgeResult(EdgeConnectionState.CONNECTION_FAILURE, connector); + } else if (repository.getReadiness() == Readiness.Failed && t + instanceof SocketTimeoutException) { + // if it connects yet times out while still failed, lets back off + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, connector); + } else { + retryer.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, connector); + } + } + + @Override + public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { + log.trace("[featurehub-sdk] connected to {}", config.baseUrl()); + } + }); + + } catch (NoClassDefFoundError|NoSuchFieldError noClassDefFoundError) { + log.error( + "You appear to have the wrong version of OKHttp in your classpath or a conflicting version and FeatureHub cannot start", + noClassDefFoundError); + } catch (Throwable e) { + log.error("failed", e); + } - @Override - public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { - log.trace("[featurehub-sdk] connected to {}", config.baseUrl()); - } - }); + if (eventSource == null) { + log.error("Unable to connect to {}", this.config.getRealtimeUrl()); + } } protected EventSource makeEventSource(Request request, EventSourceListener listener) { if (eventSourceFactory == null) { client = new OkHttpClient.Builder() - .readTimeout(retryer.getServerReadTimeoutMs(), TimeUnit.MILLISECONDS) - .connectTimeout(Duration.ofMillis(retryer.getServerConnectTimeoutMs())) + .eventListener( + new EventListener() { + @Override + public void connectFailed( + @NotNull Call call, + @NotNull InetSocketAddress inetSocketAddress, + @NotNull Proxy proxy, + @Nullable Protocol protocol, + @NotNull IOException ioe) { + super.connectFailed(call, inetSocketAddress, proxy, protocol, ioe); + + log.error("connected failed"); + } + }) + // .readTimeout(retryer.getServerReadTimeoutMs(), TimeUnit.MILLISECONDS) + // + // .connectTimeout(Duration.ofMillis(retryer.getServerConnectTimeoutMs())) .build(); eventSourceFactory = EventSources.createFactory(client); @@ -175,18 +224,16 @@ protected EventSource makeEventSource(Request request, EventSourceListener liste return eventSourceFactory.newEventSource(request, listener); } - @Override public @NotNull Future contextChange(String newHeader, String contextSha) { final CompletableFuture change = new CompletableFuture<>(); - if (config.isServerEvaluation() && - ( - (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || - (xFeaturehubHeader != null && !xFeaturehubHeader.equals(newHeader)) - ) ) { + if (config.isServerEvaluation() + && ((newHeader != null && !newHeader.equals(xFeaturehubHeader)) + || (xFeaturehubHeader != null && !xFeaturehubHeader.equals(newHeader)))) { - log.warn("[featurehub-sdk] please only use server evaluated keys with SSE with one repository per SSE client."); + log.warn( + "[featurehub-sdk] please only use server evaluated keys with SSE with one repository per SSE client."); xFeaturehubHeader = newHeader; diff --git a/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java index eaed284..6f6c16f 100644 --- a/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java +++ b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java @@ -14,8 +14,8 @@ public class FeatureHubClientRunner { public static void main(String[] args) throws Exception { FeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8064", - "default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); - + "82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); + config.streaming(); final ClientContext ctx = config.newContext().build().get(); ctx.getRepository().addReadinessListener(rl -> System.out.println("readyness " + rl.toString())); diff --git a/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index caf49a2..728baa2 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -23,10 +23,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.*; import java.util.function.Consumer; public class ClientFeatureRepository implements InternalFeatureRepository { @@ -97,7 +94,10 @@ protected ObjectMapper initializeMapper() { } protected static ExecutorService getExecutor(int threadPoolSize) { - return Executors.newFixedThreadPool(Math.max(threadPoolSize, 10)); + int maxThreads = Math.max(threadPoolSize, 10); + return new ThreadPoolExecutor(3, maxThreads, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), new FeatureHubThreadFactory()); } public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 2f56f69..541d481 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -59,7 +59,7 @@ default FeatureHubConfig systemPropertyConfig() { @NotNull ClientContext newContext(); static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { - return sdkKey.stream().anyMatch(key -> key.contains("*")); + return sdkKey.stream().anyMatch(key -> key != null && key.contains("*")); } FeatureHubConfig setRepository(FeatureRepository repository); diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java new file mode 100644 index 0000000..d37b7fb --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java @@ -0,0 +1,38 @@ +package io.featurehub.client; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * We just want to have our threads named "FeatureHub" so folks can find them. + */ +public class FeatureHubThreadFactory implements ThreadFactory { + + // Note: The source code for this class was based entirely on + // Executors.DefaultThreadFactory class from the JDK8 source. + // The only change made is the ability to configure the thread + // name prefix. + + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String namePrefix; + + public FeatureHubThreadFactory() { + SecurityManager s = System.getSecurityManager(); + group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); + namePrefix = "featurehub-" + poolNumber.getAndIncrement() + "-thread-"; + } + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); + if (t.isDaemon()) { + t.setDaemon(false); + } + if (t.getPriority() != Thread.NORM_PRIORITY) { + t.setPriority(Thread.NORM_PRIORITY); + } + return t; + } +} diff --git a/examples/pom.xml b/examples/pom.xml index b686634..3f5b607 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -37,6 +37,7 @@ todo-java-shared todo-java-jersey2 todo-java-jersey3 + todo-java-quarkus migration-check diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java index 2ab99cc..b6118be 100644 --- a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java @@ -3,11 +3,13 @@ import io.featurehub.client.Readiness; import jakarta.inject.Inject; +import jakarta.inject.Singleton; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.core.Response; import todo.backend.FeatureHub; +@Singleton @Path("/health") public class HealthResource { private final FeatureHub featureHub; diff --git a/examples/todo-java-quarkus/.gitignore b/examples/todo-java-quarkus/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/examples/todo-java-quarkus/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/examples/todo-java-quarkus/README.adoc b/examples/todo-java-quarkus/README.adoc new file mode 100644 index 0000000..bdfcc6d --- /dev/null +++ b/examples/todo-java-quarkus/README.adoc @@ -0,0 +1,45 @@ += The Simple Quarkus Example + +This example simply follows the basics of how a Quarkus +application could be wired for in Java. Java server applications +recommend that you expose a health check endpoint, and if you wish +to have your server not get traffic routed to it by your Application +Load Balancer (or whatever your Cloud provider uses), then simply +fail the health check when FeatureHub is not "ready". + +NOTE: With Quarkus, the version of OkHttp conflicts as FeatureHub is more modern. This +isn't a problem but does require some additional dependency additions to ensure the +right version is forced in. See the `pom.xml` for more details. + +This example is primarily here to provide documentation for the SDK, +but it operates on its own. You must modify the application.properties file for your instance +of FeatureHub + +[source,properties] +---- +feature-hub.api-key=default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN +feature-hub.url=http://localhost:8903 +---- + +It recognizes a "Authorization" header (via the AuthFilter) which contains the value it will +directly put into the userKey for simplicity to allow you to try out +percentage rollouts and tagging feature values to users. + +The urls are: + +- / - it print Hello World and the value of the SUBMIT_COLOR_BUTTON +- /health/liveness - whether the application is ready to receive traffic + +---- +curl -H 'Authorization: richard' http://localhost:8080 +Hello World green1 + +curl -H 'Authorization: irina' http://localhost:8080 +Hello World blue +---- + +You can start the app with: + + $ mvn compile quarkus:dev + +You m diff --git a/examples/todo-java-quarkus/pom.xml b/examples/todo-java-quarkus/pom.xml new file mode 100644 index 0000000..2d8dfb7 --- /dev/null +++ b/examples/todo-java-quarkus/pom.xml @@ -0,0 +1,171 @@ + + + 4.0.0 + io.featurehub.sdk.examples + todo-java-quarkus + 0.0.1-SNAPSHOT + + 3.13.0 + true + 11 + 11 + UTF-8 + UTF-8 + + 3.6.4 + quarkus-universe-bom + io.quarkus + 3.6.4 + 3.0.0-M5 + 4.12.0 + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-resteasy-jackson + + + + io.quarkus + quarkus-arc + + + + + com.squareup.okhttp3 + okhttp + [${ok.http.version}] + + + com.squareup.okio + okio + 3.6.0 + + + + com.squareup.okhttp3 + okhttp-sse + [${ok.http.version}] + + + io.featurehub.sdk + java-client-okhttp + 3.1-SNAPSHOT + + + + + io.featurehub.sdk.java + segment-usageadapter + [1.1-SNAPSHOT, 2) + + + + com.squareup.okhttp3 + okhttp + + + com.squareup.okhttp3 + logging-interceptor + + + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + + + + io.featurehub.sdk.java + opentelemetry-usageadapter + [1.1-SNAPSHOT, 2) + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus-plugin.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler-plugin.version} + + + org.openapitools + openapi-generator-maven-plugin + 7.0.1 + + + featurehub-api + + generate + + generate-sources + + ${project.basedir}/target/generated-sources/api + todo.api + todo.model + ${project.basedir}/../todo-java-shared/todo-api.yaml + jaxrs-spec + + interfaceOnly=true,useSwaggerAnnotations=false,openApiNullable=false + + + true + true + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-generated-source + initialize + + add-source + + + + ${project.build.directory}/generated-sources/api/src/gen/java + + + + + + + + diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java new file mode 100644 index 0000000..053b7fa --- /dev/null +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java @@ -0,0 +1,57 @@ +package io.featurehub.examples.quarkus; + +import com.segment.analytics.messages.IdentifyMessage; +import io.featurehub.client.ClientContext; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.ext.Provider; + +/** + * This filter checks if there is an Authorization header and if so, will add it to the user context + * (which is mutable) allowing downstream resources to correctly calculate their features. + */ +@Provider +@PreMatching +public class AuthFilter implements ContainerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(AuthFilter.class); + + private final SegmentAnalyticsSource segmentAnalyticsSource; + + private final jakarta.inject.Provider contextProvider; + + @Inject + public AuthFilter(SegmentAnalyticsSource segmentAnalyticsSource, jakarta.inject.Provider contextProvider) { + this.segmentAnalyticsSource = segmentAnalyticsSource; + this.contextProvider = contextProvider; + } + + @Override + public void filter(ContainerRequestContext req) { + if (req.getHeaders().containsKey("Authorization")) { + String user = req.getHeaderString("Authorization"); + + log.info("incoming request from user {}", user); + + try { + contextProvider.get().userKey(user).build().get(); + + if (segmentAnalyticsSource != null) { + segmentAnalyticsSource + .getAnalytics() + .enqueue(IdentifyMessage.builder().userId(user)); + } + + } catch (Exception e) { + log.error("Unable to set user key on user"); + } + } else { + log.info("request {} has no user", req.getUriInfo().getAbsolutePath().toASCIIString()); + } + } +} diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java new file mode 100644 index 0000000..39872fb --- /dev/null +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java @@ -0,0 +1,113 @@ +package io.featurehub.examples.quarkus; + +import com.segment.analytics.messages.Message; +import io.featurehub.client.ClientContext; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.quarkus.runtime.Startup; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.enterprise.inject.spi.CDI; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +@Startup +public class FeatureHubSource { + private static final Logger log = LoggerFactory.getLogger(FeatureHubSource.class); + + @ConfigProperty(name = "feature-service.host") + String featureHubUrl; + @ConfigProperty(name = "feature-service.api-key") + String sdkKey; + @ConfigProperty(name = "segment.write-key") + Optional segmentWriteKey; + @ConfigProperty(name = "feature-service.client", defaultValue = "sse") + String client; // sse, rest, rest-poll + @ConfigProperty(name = "feature-service.opentelemetry.enabled", defaultValue = "false") + Boolean openTelemetryEnabled; + @ConfigProperty(name = "feature-service.poll-interval-seconds", defaultValue = "1") + Integer pollInterval; // in seconds + + @Produces + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + + private FeatureHubConfig config; + + @Produces + @ApplicationScoped + public FeatureHubConfig getConfig() { + if (featureHubUrl == null || sdkKey == null) { + throw new RuntimeException("URL and Key must not be null"); + } + log.info("Initializing FeatureHub"); + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); + + if (segmentWriteKey.isPresent()) { + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey.get(), + List.of(new SegmentMessageTransformer(Message.Type.values(), () -> { + final Set> beans = CDI.current().getBeanManager().getBeans(ClientContext.class); + return beans.isEmpty() ? null : (ClientContext) beans.iterator().next(); + }, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; + } + + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } + + // Do this if you wish to force the connection to stay open. + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-poll")) { + config.restActive(pollInterval); + } else { + throw new RuntimeException("Unknown featurehub client"); + } + + config.init(); + return config; + } + + /** + * This lets us create the ClientContext, which will always be empty, or the AuthFilter will add the user if it + * discovers it. + * + * @param config - the FeatureHub Config + * @return - a blank context usable by any resource. + */ + @Produces + @RequestScoped + public ClientContext fhClient(FeatureHubConfig config) { + try { + return config.newContext().build().get(); + } catch (Exception e) { + log.error("Cannot create context!", e); + throw new RuntimeException(e); + } + } + + @PreDestroy + public void close() { + config.close(); + } +} diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java new file mode 100644 index 0000000..422f319 --- /dev/null +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java @@ -0,0 +1,32 @@ +package io.featurehub.examples.quarkus; + +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.Readiness; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +/** + * This follows our Java recommendation patterns to return a 503 until you have a connected repository. If the + * connection to the feature server permanently goes down, this would stop routing traffic to this server. + */ +@Path("/health/liveness") +public class HealthResource { + private final FeatureHubConfig config; + + @Inject + public HealthResource(FeatureHubConfig config) { + this.config = config; + } + + @GET + public Response liveness() { + if (config.getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.status(503).build(); + } +} diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java new file mode 100644 index 0000000..6719b9d --- /dev/null +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java @@ -0,0 +1,127 @@ +package io.featurehub.examples.quarkus; + +import io.featurehub.client.ClientContext; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.api.TodoApi; +import todo.model.Todo; + +public class TodoResource implements TodoApi { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final TodoSource todoSource; + private final Provider contextProvider; + + @Inject + public TodoResource(TodoSource todoSource, jakarta.inject.Provider contextProvider) { + this.todoSource = todoSource; + this.contextProvider = contextProvider; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todoSource.todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = contextProvider.get(); + + final List todoList = + todos.values().stream() + .map(t -> todoSource.copy(t).title(processTitle(fhClient, t.getTitle()))) + .collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @Override + public List addTodo(@NotNull String user, Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @Override + public List listTodos(@NotNull String user) { + return getTodoList(getTodoMap(user), user); + } + + @Override + public void removeAllTodos(@NotNull String user) { + getTodoMap(user).clear(); + } + + @Override + public List removeTodo(@NotNull String user, @NotNull String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @Override + public List resolveTodo(@NotNull String id, @NotNull String user) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new NotFoundException(); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java new file mode 100644 index 0000000..6bd46fc --- /dev/null +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java @@ -0,0 +1,16 @@ +package io.featurehub.examples.quarkus; + +import jakarta.enterprise.context.ApplicationScoped; +import todo.model.Todo; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@ApplicationScoped +public class TodoSource { + public Map> todos = new ConcurrentHashMap<>(); + + public Todo copy(Todo t) { + return new Todo().resolved(t.getResolved()).id(t.getId()).title(t.getTitle()).when(t.getWhen()); + } +} diff --git a/examples/todo-java-quarkus/src/main/resources/application.properties b/examples/todo-java-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..46c1a36 --- /dev/null +++ b/examples/todo-java-quarkus/src/main/resources/application.properties @@ -0,0 +1,8 @@ +feature-service.api-key=9db9f611-f5c9-4b09-bac5-ccf5a5b989c9/ACyrjuIjugghPI2J6XxHybSXKoC28Z*Ld6f2H8drfzP1raWgK8D +feature-service.host=http://localhost:8903 + +quarkus.log.level=INFO +quarkus.log.category."io.featurehub".min-level=TRACE +quarkus.log.category."io.featurehub".level=TRACE + +quarkus.http.port=8099 From 70230a9a5e806445a4128f9f18bbe142b1ecda8c Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 27 Mar 2025 20:02:34 +1300 Subject: [PATCH 14/22] update rest client based on feedback and init method with timeout --- .../java/io/featurehub/okhttp/RestClient.java | 10 ++-- .../client/EdgeFeatureHubConfig.java | 11 +++++ .../featurehub/client/FeatureHubConfig.java | 8 ++++ .../client/EdgeFeatureHubConfigSpec.groovy | 47 +++++++++++++++++++ examples/pom.xml | 1 + .../featurehub/android/FeatureHubClient.java | 4 +- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index 8b606bb..d77447a 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -239,7 +239,6 @@ protected void processResponse(Response response) throws IOException { log.trace("updating feature repository: {}", states); repository.updateFeatures(states); - completeReadiness(); if (response.code() == 236) { this.stopped = true; // prevent any further requests @@ -249,17 +248,18 @@ protected void processResponse(Response response) throws IOException { if (pollingInterval > 0) { whenPollingCacheExpires = now() + (pollingInterval * 1000); } - } else if (response.code() == 400 || response.code() == 404) { + } else if (response.code() == 400 || response.code() == 404 || response.code() == 401 || response.code() == 403) { + // 401 and 403 are possible because of misconfiguration makeRequests = false; log.error("Server indicated an error with our requests making future ones pointless."); repository.notify(SSEResultState.FAILURE); - completeReadiness(); - } else if (response.code() >= 500) { - completeReadiness(); // we haven't changed anything, but we have to unblock clients as we can't just hang } + // could be a 304 or 5xx as expected possible results } catch (Exception e) { log.error("Failed to parse response {}", response.code(), e); } + + completeReadiness(); // under all circumstances, unblock clients } boolean canMakeRequests() { diff --git a/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index a81d0f1..ee6d62f 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.ServiceLoader; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; @@ -116,6 +117,16 @@ public Future init() { return newContext().build(); } + @Override + public void init(long timeout, TimeUnit unit) { + try { + final Future futureContext = newContext().build(); + futureContext.get(timeout, unit); + } catch (Exception e) { + log.warn("Failed to initialize FeatureHub client", e); + } + } + @Override public boolean isServerEvaluation() { return serverEvaluation; diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 541d481..b10690b 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -8,6 +8,7 @@ import java.util.Collection; import java.util.List; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; @@ -39,6 +40,13 @@ default FeatureHubConfig systemPropertyConfig() { @NotNull String baseUrl(); + /** + * If you are using a client evaluated feature context, this will initialise the service and block until + * the provided timeout or until you have received your first set of features. Server Evaluated contexts + * should not use it because it needs to re-request data from the server each time you change your context. + */ + void init(long timeout, TimeUnit unit); + /** * If you are using a client evaluated feature context, this will initialise the service and block until * you have received your first set of features. Server Evaluated contexts should not use it because it needs diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index 7d5b838..53b3285 100644 --- a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -6,6 +6,9 @@ import spock.lang.Specification import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.Future +import java.util.concurrent.TimeoutException import java.util.function.Consumer class EdgeFeatureHubConfigSpec extends Specification { @@ -129,4 +132,48 @@ class EdgeFeatureHubConfigSpec extends Specification { 1 * executor.execute { Runnable r -> r.run() } 0 * _ } + + def "init completes successfully if future resolves within the given time"() { + given: "A mock future that completes successfully" + def futureContext = Mock(Future) + def mockContext = Mock(ClientContext) + and: "I mock the context and future" + def clientContext = Mock(ClientContext) + + and: "A client eval feature config" + def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") { + @Override + ClientContext newContext() { + return clientContext + } + } + when: "init is called with a reasonable timeout" + config.init(100, TimeUnit.MILLISECONDS) + then: "The get method on the future should be called with timeout" + 1 * futureContext.get(100, TimeUnit.MILLISECONDS) >> mockContext + 1 * clientContext.build() >> futureContext + 0 * _ + } + + def "init should timeout if future does not complete within the given time"() { + given: "A mock future that completes successfully" + def futureContext = Mock(Future) + def mockContext = Mock(ClientContext) + and: "I mock the context and future" + def clientContext = Mock(ClientContext) + + and: "A client eval feature config" + def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") { + @Override + ClientContext newContext() { + return clientContext + } + } + when: "init is called with a very short timeout" + config.init(1, TimeUnit.MILLISECONDS) + then: "The get method on the future should be called with timeout" + 1 * futureContext.get(1, TimeUnit.MILLISECONDS) >> { throw new TimeoutException() } + 1 * clientContext.build() >> futureContext + 0 * _ + } } diff --git a/examples/pom.xml b/examples/pom.xml index 3f5b607..238733d 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -38,6 +38,7 @@ todo-java-jersey2 todo-java-jersey3 todo-java-quarkus + migration-check diff --git a/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java index 8e55ca8..9a518ac 100644 --- a/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java +++ b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java @@ -201,7 +201,6 @@ protected void processResponse(Response response) throws IOException { }); repository.notify(states); - completeReadiness(); if (response.code() == 236) { this.stopped = true; // prevent any further requests @@ -215,9 +214,10 @@ protected void processResponse(Response response) throws IOException { makeRequests = false; log.error("Server indicated an error with our requests making future ones pointless."); repository.notify(SSEResultState.FAILURE, null); - completeReadiness(); } } + + completeReadiness(); } boolean canMakeRequests() { From 0eeabbd7d7423ea7af8a7a53f1af4aa94a9d8e08 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 27 Mar 2025 20:21:01 +1300 Subject: [PATCH 15/22] add in ability to override okhttpclient and request builder --- .../java/io/featurehub/okhttp/RestClient.java | 29 ++++++++++++++++--- .../java/io/featurehub/okhttp/SSEClient.java | 25 ++++++++++++++-- .../java/io/featurehub/okhttp/TestClient.java | 22 +++++++++++++- .../io/featurehub/okhttp/SSEClientSpec.groovy | 2 +- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index d77447a..5958146 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -71,10 +71,7 @@ public RestClient(@Nullable InternalFeatureRepository repository, this.amPollingDelegate = amPollingDelegate; this.repository = repository; - this.client = new OkHttpClient.Builder() - .connectTimeout(Duration.ofMillis(edgeRetryService.getServerConnectTimeoutMs())) - .readTimeout(Duration.ofMillis(edgeRetryService.getServerReadTimeoutMs())) - .build(); + this.client = buildOkHttpClient(edgeRetryService); this.config = config; this.pollingInterval = timeoutInSeconds; @@ -93,6 +90,20 @@ public RestClient(@Nullable InternalFeatureRepository repository, } } + /** + * This is overrideable so you can make it do what you wish if you wish. + * + * @param edgeRetryService + * @return + */ + @NotNull + protected OkHttpClient buildOkHttpClient(@NotNull EdgeRetryService edgeRetryService) { + return new OkHttpClient.Builder() + .connectTimeout(Duration.ofMillis(edgeRetryService.getServerConnectTimeoutMs())) + .readTimeout(Duration.ofMillis(edgeRetryService.getServerReadTimeoutMs())) + .build(); + } + protected ExecutorService makeExecutorService() { return Executors.newWorkStealingPool(); } @@ -163,6 +174,16 @@ public void onResponse(@NotNull Call call, @NotNull Response response) throws IO return ask; } + /** + * Override this method if you wish to add extra things + * + * @param requestBuilder + * @return a Request object ready for use + */ + protected Request buildRequest(Request.Builder requestBuilder) { + return requestBuilder.build(); + } + protected @Nullable String getEtag() { return etag; } diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java index 7ebbfa5..1760b4f 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java @@ -80,7 +80,7 @@ private void initEventSource() { reqBuilder.addHeader("X-SDK", SdkVersion.sdkVersionHeader("Java-OKHTTP-SSE")); - Request request = reqBuilder.build(); + Request request = buildRequest(reqBuilder); // we need to know if the connection already said "bye" so as to pass the right reconnection // event @@ -195,10 +195,20 @@ public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) } } + /** + * Override this method if you wish to add extra things + * + * @param requestBuilder + * @return a Request object ready for use + */ + protected Request buildRequest(Request.Builder requestBuilder) { + return requestBuilder.build(); + } + protected EventSource makeEventSource(Request request, EventSourceListener listener) { if (eventSourceFactory == null) { client = - new OkHttpClient.Builder() + buildOkHttpClientBuilder(retryer) .eventListener( new EventListener() { @Override @@ -224,6 +234,17 @@ public void connectFailed( return eventSourceFactory.newEventSource(request, listener); } + /** + * This is overrideable so you can make it do what you wish if you wish. + * + * @param edgeRetryService + * @return - new builder + */ + @NotNull + protected OkHttpClient.Builder buildOkHttpClientBuilder(@NotNull EdgeRetryService edgeRetryService) { + return new OkHttpClient.Builder(); + } + @Override public @NotNull Future contextChange(String newHeader, String contextSha) { final CompletableFuture change = new CompletableFuture<>(); diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java index 8706971..a63cded 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java @@ -5,6 +5,7 @@ import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.TestApi; import io.featurehub.client.TestApiResult; +import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.utils.SdkVersion; import io.featurehub.sse.model.FeatureStateUpdate; import okhttp3.MediaType; @@ -17,14 +18,16 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.time.Duration; public class TestClient implements TestApi { private static final Logger log = LoggerFactory.getLogger(TestClient.class); private final FeatureHubConfig config; - private final OkHttpClient client = new OkHttpClient(); + private final OkHttpClient client; public TestClient(FeatureHubConfig config) { this.config = config; + this.client = buildOkHttpClient(); } @Override @@ -55,6 +58,23 @@ public TestClient(FeatureHubConfig config) { } } + /** + * This is overrideable so you can create your own requestbuilder. + */ + + protected Request.Builder createRequestBuilder() { + return new Request.Builder(); + } + + /** + * This is overrideable so you can create your own okhttpclient. + */ + @NotNull + protected OkHttpClient buildOkHttpClient() { + return new OkHttpClient.Builder() + .build(); + } + @Override public @NotNull TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { return setFeatureState(config.apiKey(), featureKey, featureStateUpdate); diff --git a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy index 55b462b..81c175e 100644 --- a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy +++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy @@ -113,7 +113,7 @@ class SSEClientSpec extends Specification { then: 1 * config.getRealtimeUrl() >> "http://localhost" 1 * config.baseUrl() >> "http://localhost" // used by trace log - 2 * repository.getReadiness() >> Readiness.NotReady + 3 * repository.getReadiness() >> Readiness.NotReady 1 * repository.notify(SSEResultState.FAILURE) 1 * retry.edgeResult(EdgeConnectionState.SERVER_WAS_DISCONNECTED, client) 0 * _ From 151dfd2392877ba263a543eba625833c3a27e052 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sat, 10 Jan 2026 20:48:07 +1300 Subject: [PATCH 16/22] split our jackson support to allow support for jackson3 --- .github/workflows/java.yaml | 8 +- .mvn/jvm.config | 1 + .../java-client-jersey2/pom.xml | 10 +- .../java-client-jersey3/pom.xml | 9 +- .../java-client-okhttp/pom.xml | 10 +- .../java/io/featurehub/okhttp/RestClient.java | 13 +-- .../java/io/featurehub/okhttp/TestClient.java | 15 +-- .../featurehub/okhttp/RestClientSpec.groovy | 5 + .../featurehub/okhttp/TestClientSpec.groovy | 9 +- core/client-java-api/pom.xml | 16 ++- core/client-java-core/pom.xml | 16 ++- .../client/ClientFeatureRepository.java | 24 +--- .../client/EdgeFeatureHubConfig.java | 4 +- .../featurehub/client/FeatureHubConfig.java | 4 +- .../featurehub/client/FeatureRepository.java | 7 +- .../client/InternalFeatureRepository.java | 10 +- .../featurehub/client/edge/EdgeRetryer.java | 23 ++-- .../client/EdgeFeatureHubConfigSpec.groovy | 3 +- .../featurehub/client/RepositorySpec.groovy | 3 +- pom.xml | 4 +- support/common-jackson/pom.xml | 85 ++++++++++++++ .../javascript/JavascriptObjectMapper.java | 25 +++++ ...JavascriptObjectMapperProviderService.java | 5 + .../javascript/JavascriptServiceLoader.java | 18 +++ support/common-jacksonv2/pom.xml | 105 ++++++++++++++++++ .../javascript/Jackson2ObjectMapper.java | 57 ++++++++++ .../Jackson2ObjectMapperProvider.java | 8 ++ ...ript.JavascriptObjectMapperProviderService | 1 + support/composite-jackson/pom.xml | 29 +---- support/composite-jersey2/pom.xml | 8 +- support/composite-jersey3/pom.xml | 8 +- support/composite-logging-api/pom.xml | 2 +- support/composite-logging/pom.xml | 4 +- support/composite-test/pom.xml | 15 ++- support/pom.xml | 3 +- support/tile-java11/pom.xml | 2 +- support/tile-java8/pom.xml | 2 +- support/tile-release/pom.xml | 2 +- support/tile-sdk/pom.xml | 2 +- unmaintained/client-java-android21/pom.xml | 2 +- .../featurehub-opentelemetry-adapter/pom.xml | 3 +- .../featurehub-segment-adapter/pom.xml | 3 +- 42 files changed, 433 insertions(+), 150 deletions(-) create mode 100644 .mvn/jvm.config create mode 100644 support/common-jackson/pom.xml create mode 100644 support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java create mode 100644 support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java create mode 100644 support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java create mode 100644 support/common-jacksonv2/pom.xml create mode 100644 support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java create mode 100644 support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java create mode 100644 support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 4c4daa6..72bd4f0 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -7,11 +7,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '11' + java-version: | + '11' + '21' distribution: 'temurin' cache: maven - name: Install tiles diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..79ecf92 --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1 @@ +--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED diff --git a/client-implementations/java-client-jersey2/pom.xml b/client-implementations/java-client-jersey2/pom.xml index 2806af1..e8913ef 100644 --- a/client-implementations/java-client-jersey2/pom.xml +++ b/client-implementations/java-client-jersey2/pom.xml @@ -75,6 +75,13 @@ test + + io.featurehub.sdk.common + common-jacksonv2 + [1.1-SNAPSHOT, 2] + test + + org.glassfish.jersey.test-framework jersey-test-framework-core @@ -93,7 +100,6 @@ ${jersey.version} test - @@ -156,7 +162,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/client-implementations/java-client-jersey3/pom.xml b/client-implementations/java-client-jersey3/pom.xml index ded33ab..a473f3f 100644 --- a/client-implementations/java-client-jersey3/pom.xml +++ b/client-implementations/java-client-jersey3/pom.xml @@ -73,6 +73,13 @@ [1.1, 2) test + + io.featurehub.sdk.common + common-jacksonv2 + [1.1-SNAPSHOT, 2] + test + + org.glassfish.jersey.test-framework @@ -177,7 +184,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/client-implementations/java-client-okhttp/pom.xml b/client-implementations/java-client-okhttp/pom.xml index 4fe797c..99c731b 100644 --- a/client-implementations/java-client-okhttp/pom.xml +++ b/client-implementations/java-client-okhttp/pom.xml @@ -79,10 +79,10 @@ - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - provided + io.featurehub.sdk.common + common-jacksonv2 + [1.1-SNAPSHOT, 2] + test @@ -111,7 +111,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java index 5958146..0b0530f 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java @@ -1,7 +1,5 @@ package io.featurehub.okhttp; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.EdgeService; import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; @@ -9,6 +7,7 @@ import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.edge.EdgeRetryer; import io.featurehub.client.utils.SdkVersion; +import io.featurehub.javascript.JavascriptObjectMapper; import io.featurehub.sse.model.FeatureEnvironmentCollection; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; @@ -39,10 +38,9 @@ public class RestClient implements EdgeService { private static final Logger log = LoggerFactory.getLogger(RestClient.class); @NotNull private final InternalFeatureRepository repository; @NotNull private final OkHttpClient client; - private final EdgeRetryService edgeRetryService; private boolean makeRequests; @NotNull private final String url; - private final ObjectMapper mapper = new ObjectMapper(); + private final JavascriptObjectMapper mapper; @Nullable private String xFeaturehubHeader; // used for breaking the cache @@ -62,12 +60,12 @@ public class RestClient implements EdgeService { public RestClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config, @NotNull EdgeRetryService edgeRetryService, int timeoutInSeconds, boolean amPollingDelegate) { - this.edgeRetryService = edgeRetryService; - if (repository == null) { repository = (InternalFeatureRepository) config.getRepository(); } + this.mapper = repository.getJsonObjectMapper(); + this.amPollingDelegate = amPollingDelegate; this.repository = repository; @@ -117,7 +115,6 @@ public RestClient(@NotNull FeatureHubConfig config) { this(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), 180, false); } - private final static TypeReference> ref = new TypeReference>(){}; private boolean busy = false; private boolean headerChanged = false; private List> waitingClients = new ArrayList<>(); @@ -242,7 +239,7 @@ protected void processResponse(Response response) throws IOException { List environments; try { - environments = mapper.readValue(body.bytes(), ref); + environments = mapper.readFeatureCollection(new String(body.bytes())); } catch (Exception e) { log.error("Failed to process successful response from FH Edge server", e); processFailure(new IOException(e)); diff --git a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java index a63cded..ef1995b 100644 --- a/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java +++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java @@ -1,13 +1,12 @@ package io.featurehub.okhttp; -import com.fasterxml.jackson.core.JsonProcessingException; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.InternalFeatureRepository; import io.featurehub.client.TestApi; import io.featurehub.client.TestApiResult; -import io.featurehub.client.edge.EdgeRetryService; import io.featurehub.client.utils.SdkVersion; +import io.featurehub.javascript.JavascriptObjectMapper; import io.featurehub.sse.model.FeatureStateUpdate; +import java.io.IOException; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -17,17 +16,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.time.Duration; - public class TestClient implements TestApi { private static final Logger log = LoggerFactory.getLogger(TestClient.class); private final FeatureHubConfig config; private final OkHttpClient client; + private final JavascriptObjectMapper mapper; public TestClient(FeatureHubConfig config) { this.config = config; this.client = buildOkHttpClient(); + this.mapper = config.getInternalRepository().getJsonObjectMapper(); } @Override @@ -35,9 +33,8 @@ public TestClient(FeatureHubConfig config) { String data; try { - data = - ((InternalFeatureRepository)config.getRepository()).getJsonObjectMapper().writeValueAsString(featureStateUpdate); - } catch (JsonProcessingException e) { + data = mapper.featureStateUpdateToString(featureStateUpdate); + } catch (IOException e) { return new TestApiResult(500); } diff --git a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy index ea11b9f..60ebf9c 100644 --- a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy +++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy @@ -5,6 +5,8 @@ import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository import io.featurehub.client.Readiness import io.featurehub.client.edge.EdgeRetryer +import io.featurehub.javascript.Jackson2ObjectMapper +import io.featurehub.javascript.JavascriptObjectMapper import io.featurehub.sse.model.FeatureEnvironmentCollection import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -16,11 +18,14 @@ class RestClientSpec extends Specification { FeatureHubConfig config InternalFeatureRepository repo ObjectMapper mapper + JavascriptObjectMapper fhMapper def setup() { mapper = new ObjectMapper() + fhMapper = new Jackson2ObjectMapper() config = Mock() repo = Mock() + repo.getJsonObjectMapper() >> fhMapper config.repository >> repo mockWebServer = new MockWebServer() diff --git a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy index c0edcb0..6246501 100644 --- a/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy +++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy @@ -3,6 +3,8 @@ package io.featurehub.okhttp import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.FeatureHubConfig import io.featurehub.client.InternalFeatureRepository +import io.featurehub.javascript.Jackson2ObjectMapper +import io.featurehub.javascript.JavascriptObjectMapper import io.featurehub.sse.model.FeatureStateUpdate import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -14,11 +16,11 @@ class TestClientSpec extends Specification { MockWebServer mockWebServer FeatureHubConfig config InternalFeatureRepository repo - ObjectMapper mapper + JavascriptObjectMapper mapper TestClient client def setup() { - mapper = new ObjectMapper() + mapper = new Jackson2ObjectMapper() config = Mock() repo = Mock() repo.getJsonObjectMapper() >> mapper @@ -29,6 +31,7 @@ class TestClientSpec extends Specification { config.baseUrl() >> url.substring(0, url.length()) config.apiKey() >> "one" config.serverEvaluation >> true + config.internalRepository >> repo client = new TestClient(config) } @@ -41,7 +44,7 @@ class TestClientSpec extends Specification { given: def update = new FeatureStateUpdate().value(20).lock(false).updateValue(true) client.setFeatureState('key', update) - def updateAsString = mapper.writeValueAsString(update) + def updateAsString = mapper.featureStateUpdateToString(update) when: def req = mockWebServer.takeRequest(100, TimeUnit.MILLISECONDS) mockWebServer.enqueue(new MockResponse().setResponseCode(200)) diff --git a/core/client-java-api/pom.xml b/core/client-java-api/pom.xml index f7468fd..0de88c2 100644 --- a/core/client-java-api/pom.xml +++ b/core/client-java-api/pom.xml @@ -43,6 +43,10 @@ HEAD + + 2.20 + + @@ -56,13 +60,13 @@ jakarta.annotation-api 2.0.0 - + - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - provided + com.fasterxml.jackson.core + jackson-annotations + [${jackson.annotations.version}] + @@ -123,7 +127,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/core/client-java-core/pom.xml b/core/client-java-core/pom.xml index 9de2c7c..f5ebf5f 100644 --- a/core/client-java-core/pom.xml +++ b/core/client-java-core/pom.xml @@ -54,14 +54,13 @@ org.apache.commons commons-lang3 - 3.7 + 3.18.0 - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - provided + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] @@ -76,6 +75,13 @@ [1.1, 2) test + + + io.featurehub.sdk.common + common-jacksonv2 + [1.1-SNAPSHOT, 2] + test + diff --git a/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java index 728baa2..16aa86c 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -1,12 +1,10 @@ package io.featurehub.client; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.featurehub.client.usage.UsageEvent; import io.featurehub.client.usage.UsageProvider; import io.featurehub.client.usage.FeatureHubUsageValue; +import io.featurehub.javascript.JavascriptObjectMapper; +import io.featurehub.javascript.JavascriptServiceLoader; import io.featurehub.sse.model.FeatureRolloutStrategy; import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.SSEResultState; @@ -57,11 +55,11 @@ public void cancel() { private final List> usageHandlers = new ArrayList<>(); private UsageProvider usageProvider = new UsageProvider.DefaultUsageProvider(); - private ObjectMapper jsonConfigObjectMapper; + private JavascriptObjectMapper jsonConfigObjectMapper; private final ApplyFeature applyFeature; public ClientFeatureRepository(ExecutorService executor, ApplyFeature applyFeature) { - jsonConfigObjectMapper = initializeMapper(); + jsonConfigObjectMapper = JavascriptServiceLoader.load(); this.executor = executor; @@ -83,16 +81,6 @@ public ClientFeatureRepository(ExecutorService executor) { this(executor == null ? getExecutor(1) : executor, null); } - protected ObjectMapper initializeMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); - mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); - - return mapper; - } - protected static ExecutorService getExecutor(int threadPoolSize) { int maxThreads = Math.max(threadPoolSize, 10); return new ThreadPoolExecutor(3, maxThreads, @@ -100,7 +88,7 @@ protected static ExecutorService getExecutor(int threadPoolSize) { new LinkedBlockingQueue<>(), new FeatureHubThreadFactory()); } - public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { + public void setJsonConfigObjectMapper(@NotNull JavascriptObjectMapper jsonConfigObjectMapper) { this.jsonConfigObjectMapper = jsonConfigObjectMapper; } @@ -199,7 +187,7 @@ public ExecutorService getExecutor() { } @Override - public @NotNull ObjectMapper getJsonObjectMapper() { + public @NotNull JavascriptObjectMapper getJsonObjectMapper() { return jsonConfigObjectMapper; } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java index ee6d62f..271fed9 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -1,9 +1,9 @@ package io.featurehub.client; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.usage.UsageAdapter; import io.featurehub.client.usage.UsageEvent; import io.featurehub.client.usage.UsagePlugin; +import io.featurehub.javascript.JavascriptObjectMapper; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -232,7 +232,7 @@ public Readiness getReadiness() { } @Override - public FeatureHubConfig setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) { + public FeatureHubConfig setJsonConfigObjectMapper(@NotNull JavascriptObjectMapper jsonConfigObjectMapper) { getRepository().setJsonConfigObjectMapper(jsonConfigObjectMapper); return this; } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index b10690b..8fbee6c 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -1,8 +1,8 @@ package io.featurehub.client; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.usage.UsageEvent; import io.featurehub.client.usage.UsagePlugin; +import io.featurehub.javascript.JavascriptObjectMapper; import org.jetbrains.annotations.NotNull; import java.util.Collection; @@ -104,7 +104,7 @@ static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { * * @param jsonConfigObjectMapper - a Jackson ObjectMapper */ - FeatureHubConfig setJsonConfigObjectMapper(ObjectMapper jsonConfigObjectMapper); + FeatureHubConfig setJsonConfigObjectMapper(JavascriptObjectMapper jsonConfigObjectMapper); /** * You should use this close if you are using a client evaluated key and wish to close the connection to the remote diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java index eae4550..cd8c88f 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -1,13 +1,12 @@ package io.featurehub.client; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.usage.UsageEvent; import io.featurehub.client.usage.UsageProvider; -import org.jetbrains.annotations.NotNull; - +import io.featurehub.javascript.JavascriptObjectMapper; import java.util.List; import java.util.Set; import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; public interface FeatureRepository { /** @@ -47,7 +46,7 @@ public interface FeatureRepository { * @param jsonConfigObjectMapper - an ObjectMapper configured for client use. This defaults to the same one * used to deserialize */ - void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper); + void setJsonConfigObjectMapper(@NotNull JavascriptObjectMapper jsonConfigObjectMapper); void close(); } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java index cfd5fdc..4d62550 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -1,20 +1,18 @@ package io.featurehub.client; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.usage.UsageEvent; import io.featurehub.client.usage.UsageProvider; +import io.featurehub.javascript.JavascriptObjectMapper; import io.featurehub.sse.model.FeatureRolloutStrategy; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.FeatureValueType; import io.featurehub.sse.model.SSEResultState; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public interface InternalFeatureRepository extends FeatureRepository { @@ -51,7 +49,7 @@ public interface InternalFeatureRepository extends FeatureRepository { void execute(@NotNull Runnable command); ExecutorService getExecutor(); - @NotNull ObjectMapper getJsonObjectMapper(); + @NotNull JavascriptObjectMapper getJsonObjectMapper(); /** * Tell the repository that its features are not in a valid state. Only called by server eval context. diff --git a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index fd474cd..56d2fde 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -1,9 +1,8 @@ package io.featurehub.client.edge; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.javascript.JavascriptObjectMapper; +import io.featurehub.javascript.JavascriptServiceLoader; import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; import org.jetbrains.annotations.NotNull; @@ -11,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -45,16 +45,13 @@ public class EdgeRetryer implements EdgeRetryService { * if the connectionk attempt to connect fails, how long do we wait before attempting to reconnect */ private final int connectionFailureBackoffTimeMs; - private final ObjectMapper mapper = new ObjectMapper(); + private final JavascriptObjectMapper mapper = JavascriptServiceLoader.load(); // if this is set, then we stop recognizing any further requests from the connection, // we can get subsequent disconnect statements. We know we cannot reconnect so we just stop. private boolean notFoundState = false; private boolean stopped = false; - private final TypeReference> FEATURE_LIST_TYPEDEF = - new TypeReference>() {}; - protected EdgeRetryer(int serverReadTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, int backoffMultiplier, int maximumBackoffTimeMs, int serverConnectTimeoutMs) { this.serverReadTimeoutMs = serverReadTimeoutMs; @@ -119,17 +116,15 @@ public void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnec } } - private static final TypeReference> mapConfig = new TypeReference>() {}; - @Override public void edgeConfigInfo(String config) { try { - Map data = mapper.readValue(config, mapConfig); + Map data = mapper.readMapValue(config); if (data.containsKey("edge.stale")) { stopped = true; // force us to stop trying for this connection } - } catch (JsonProcessingException e) { + } catch (IOException e) { // ignored } @@ -151,7 +146,7 @@ public void convertSSEState(@NotNull SSEResultState state, String data, if (data != null) { if (state == SSEResultState.FEATURES) { List features = - repository.getJsonObjectMapper().readValue(data, FEATURE_LIST_TYPEDEF); + repository.getJsonObjectMapper().readFeatureStates(data); repository.updateFeatures(features); } else if (state == SSEResultState.FEATURE) { repository.updateFeature(repository.getJsonObjectMapper().readValue(data, @@ -165,7 +160,7 @@ public void convertSSEState(@NotNull SSEResultState state, String data, if (state == SSEResultState.FAILURE) { repository.notify(state); } - } catch (JsonProcessingException jpe) { + } catch (IOException jpe) { throw new RuntimeException("JSON failed", jpe); } } @@ -257,7 +252,7 @@ public int newBackoff(int currentBackoff) { return backoff; } - private static enum EdgeRetryerClientType { + private enum EdgeRetryerClientType { NONE, SSE, REST } diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy index 53b3285..4743402 100644 --- a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -2,6 +2,7 @@ package io.featurehub.client import com.fasterxml.jackson.databind.ObjectMapper import io.featurehub.client.usage.UsageProvider +import io.featurehub.javascript.JavascriptObjectMapper import spock.lang.Specification import java.util.concurrent.CompletableFuture @@ -50,7 +51,7 @@ class EdgeFeatureHubConfigSpec extends Specification { def repo = Mock(InternalFeatureRepository) config.setRepository(repo) and: "I have some values ready to set" - def om = new ObjectMapper() + def om = Mock(JavascriptObjectMapper) Consumer readynessListener = Mock(Consumer) def featureValueOverride = Mock(FeatureValueInterceptor) def analyticsProvider = Mock(UsageProvider) diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy index a4d723c..f998f5c 100644 --- a/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -1,6 +1,7 @@ package io.featurehub.client import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.javascript.Jackson2ObjectMapper import io.featurehub.sse.model.FeatureState import io.featurehub.sse.model.FeatureValueType import io.featurehub.sse.model.SSEResultState @@ -154,7 +155,7 @@ class RepositorySpec extends Specification { new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value('{"sample":12}').type(FeatureValueType.JSON), ] and: "i register an alternate object mapper" - repo.setJsonConfigObjectMapper(new ObjectMapper()) + repo.setJsonConfigObjectMapper(new Jackson2ObjectMapper()) when: "i notify of features" repo.updateFeatures(features) then: 'the json object is there and deserialises' diff --git a/pom.xml b/pom.xml index 1ea2845..8074d3b 100644 --- a/pom.xml +++ b/pom.xml @@ -37,8 +37,8 @@ core client-implementations support - examples + usage-adapters - unmaintained + diff --git a/support/common-jackson/pom.xml b/support/common-jackson/pom.xml new file mode 100644 index 0000000..474a5c7 --- /dev/null +++ b/support/common-jackson/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jackson + 1.1-SNAPSHOT + common-jackson + + + Holds the APIs required to be implemented by a version of Jackson (2 or 3) + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + + + + org.jetbrains + annotations + 23.0.0 + + + + + + io.featurehub.sdk + java-client-api + [3.2,4) + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java new file mode 100644 index 0000000..f6cfec1 --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java @@ -0,0 +1,25 @@ +package io.featurehub.javascript; + +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * We need to disconnect ourselves from which actual instance type of the Jackson ObjectMapper that + * is being used because it changes from Jackson v2 to Jackson v3 - where it is located and how it is + * configured. So we just provide a subset of services here and discover it using a Java Service API. + */ +public interface JavascriptObjectMapper { + @NotNull T readValue(@NotNull String data, @NotNull Class type) throws IOException; + @NotNull Map readMapValue(@NotNull String data) throws IOException; + + @NotNull List readFeatureStates(@NotNull String data) throws IOException; + @NotNull List readFeatureCollection(@NotNull String data) throws IOException; + + @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException; +} diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java new file mode 100644 index 0000000..7e4de1c --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java @@ -0,0 +1,5 @@ +package io.featurehub.javascript; + +public interface JavascriptObjectMapperProviderService { + JavascriptObjectMapper get(); +} diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java new file mode 100644 index 0000000..2b59588 --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java @@ -0,0 +1,18 @@ +package io.featurehub.javascript; + +import java.util.ServiceLoader; + +/** + * This should be called to get the default system JavascriptObjectMapper + * library. This will be able to be overridden + */ +public class JavascriptServiceLoader { + public static JavascriptObjectMapper load() { + ServiceLoader serviceLoader = ServiceLoader.load(JavascriptObjectMapperProviderService.class); + + JavascriptObjectMapperProviderService svc = serviceLoader.findFirst() + .orElseThrow(() -> new RuntimeException("featurehub-sdk does not have available JavascriptObjectMapper implementation.")); + + return svc.get(); + } +} diff --git a/support/common-jacksonv2/pom.xml b/support/common-jacksonv2/pom.xml new file mode 100644 index 0000000..ef35a2f --- /dev/null +++ b/support/common-jacksonv2/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jacksonv2 + 1.1-SNAPSHOT + common-jacksonv2 + + + Shared core of featurehub client. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 2.20.0 + 2.20.1 + + + + + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] + + + com.fasterxml.jackson.core + jackson-core + [${jackson.version}] + + + + com.fasterxml.jackson.core + jackson-databind + [${jackson.databind.version}] + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + [${jackson.version}] + + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-base + [${jackson.version}] + + + + io.featurehub.sdk.composites + sdk-composite-logging-api + [1.1, 2) + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java new file mode 100644 index 0000000..8a8bdb5 --- /dev/null +++ b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java @@ -0,0 +1,57 @@ +package io.featurehub.javascript; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public class Jackson2ObjectMapper implements JavascriptObjectMapper { + private static ObjectMapper mapper; + + static { + mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + } + + @Override + public T readValue(String data, Class type) throws IOException { + return data == null ? null : mapper.readValue(data, type); + } + + private static final TypeReference> FEATURE_COLLECTION_TYPEREF = new TypeReference>(){}; + private static final TypeReference> mapConfig = new TypeReference>() {}; + private static final TypeReference> FEATURE_LIST_TYPEDEF = + new TypeReference<>() {}; + + @Override + public Map readMapValue(String data) throws IOException { + return mapper.readValue(data, mapConfig); + } + + @Override + public @NotNull List readFeatureStates(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_LIST_TYPEDEF); + } + + @Override + public @NotNull List readFeatureCollection(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_COLLECTION_TYPEREF); + } + + @Override + public @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException { + return mapper.writeValueAsString(data); + } +} diff --git a/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java new file mode 100644 index 0000000..8ba019e --- /dev/null +++ b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java @@ -0,0 +1,8 @@ +package io.featurehub.javascript; + +public class Jackson2ObjectMapperProvider implements JavascriptObjectMapperProviderService { + @Override + public JavascriptObjectMapper get() { + return new Jackson2ObjectMapper(); + } +} diff --git a/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService b/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService new file mode 100644 index 0000000..2ebb5ce --- /dev/null +++ b/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService @@ -0,0 +1 @@ +io.featurehub.javascript.Jackson2ObjectMapperProvider diff --git a/support/composite-jackson/pom.xml b/support/composite-jackson/pom.xml index 6a65e04..a966bf3 100644 --- a/support/composite-jackson/pom.xml +++ b/support/composite-jackson/pom.xml @@ -48,34 +48,7 @@ - - com.fasterxml.jackson.core - jackson-core - [${jackson.version}] - - - com.fasterxml.jackson.core - jackson-databind - [2.13.4.1] - - - - com.fasterxml.jackson.core - jackson-annotations - [${jackson.version}] - - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - [${jackson.version}] - - - com.fasterxml.jackson.jaxrs - jackson-jaxrs-base - [${jackson.version}] - @@ -83,7 +56,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-jersey2/pom.xml b/support/composite-jersey2/pom.xml index 3b7faa9..8a5b97a 100644 --- a/support/composite-jersey2/pom.xml +++ b/support/composite-jersey2/pom.xml @@ -100,19 +100,13 @@ javax.annotation-api 1.3.2 - - - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-jersey3/pom.xml b/support/composite-jersey3/pom.xml index bb2cb47..dd22428 100644 --- a/support/composite-jersey3/pom.xml +++ b/support/composite-jersey3/pom.xml @@ -94,19 +94,13 @@ jersey-media-multipart ${jersey.version} - - - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-logging-api/pom.xml b/support/composite-logging-api/pom.xml index e568925..9471085 100644 --- a/support/composite-logging-api/pom.xml +++ b/support/composite-logging-api/pom.xml @@ -70,7 +70,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-logging/pom.xml b/support/composite-logging/pom.xml index 5051b8e..d929953 100644 --- a/support/composite-logging/pom.xml +++ b/support/composite-logging/pom.xml @@ -43,7 +43,7 @@ - 2.17.1 + 2.25.3 3.4.4 @@ -91,7 +91,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-test/pom.xml b/support/composite-test/pom.xml index 7552c99..3d9b085 100644 --- a/support/composite-test/pom.xml +++ b/support/composite-test/pom.xml @@ -43,7 +43,7 @@ - 3.0.9 + 4.0.26 @@ -56,16 +56,21 @@ org.spockframework spock-core - 2.1-groovy-3.0 + 2.3-groovy-4.0 - org.codehaus.groovy + org.apache.groovy * - org.codehaus.groovy + net.bytebuddy + byte-buddy + 1.14.18 + + + org.apache.groovy groovy-test ${groovy.version} @@ -76,7 +81,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/pom.xml b/support/pom.xml index 53f2c7d..d55749f 100644 --- a/support/pom.xml +++ b/support/pom.xml @@ -34,7 +34,8 @@ - composite-jackson + common-jackson + common-jacksonv2 composite-jersey2 composite-jersey3 composite-logging diff --git a/support/tile-java11/pom.xml b/support/tile-java11/pom.xml index 5055752..abdf85e 100644 --- a/support/tile-java11/pom.xml +++ b/support/tile-java11/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-java8/pom.xml b/support/tile-java8/pom.xml index aa2724d..84b676a 100644 --- a/support/tile-java8/pom.xml +++ b/support/tile-java8/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-release/pom.xml b/support/tile-release/pom.xml index 0385af8..a96575c 100644 --- a/support/tile-release/pom.xml +++ b/support/tile-release/pom.xml @@ -176,7 +176,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-sdk/pom.xml b/support/tile-sdk/pom.xml index 07dba0d..0ea5d07 100644 --- a/support/tile-sdk/pom.xml +++ b/support/tile-sdk/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/unmaintained/client-java-android21/pom.xml b/unmaintained/client-java-android21/pom.xml index 58b0909..d50b544 100644 --- a/unmaintained/client-java-android21/pom.xml +++ b/unmaintained/client-java-android21/pom.xml @@ -98,7 +98,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml index 1c53cba..b9dd950 100644 --- a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml +++ b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml @@ -15,7 +15,7 @@ io.featurehub.sdk java-client-core - [4.1-SNAPSHOT, 5) + [4, 5) @@ -30,6 +30,7 @@ io.featurehub.sdk.composites sdk-composite-logging [1.1, 2) + provided diff --git a/usage-adapters/featurehub-segment-adapter/pom.xml b/usage-adapters/featurehub-segment-adapter/pom.xml index 95284cf..c9c17c5 100644 --- a/usage-adapters/featurehub-segment-adapter/pom.xml +++ b/usage-adapters/featurehub-segment-adapter/pom.xml @@ -15,7 +15,7 @@ io.featurehub.sdk java-client-core - [4.1-SNAPSHOT, 5) + [4, 5) @@ -28,6 +28,7 @@ io.featurehub.sdk.composites sdk-composite-logging [1.1, 2) + provided From 44cc07f8840f16c8f5531286875361ce38939522 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sat, 10 Jan 2026 20:51:29 +1300 Subject: [PATCH 17/22] trying to build with two javas --- .github/workflows/java.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 72bd4f0..78b46a4 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -5,15 +5,15 @@ on: [push] jobs: build: runs-on: ubuntu-latest - + strategy: + matrix: + java: [ '11', '21' ] steps: - uses: actions/checkout@v5 - name: Set up JDK 11 uses: actions/setup-java@v5 with: - java-version: | - '11' - '21' + java-version: ${{ matrix.java }} distribution: 'temurin' cache: maven - name: Install tiles From 94ebcfa8e19a48cdfb0f89956b8c239722814dbd Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 11 Jan 2026 15:07:09 +1300 Subject: [PATCH 18/22] jackson 3 and spring and quarkus examples --- .github/workflows/java.yaml | 29 ++- .../java-client-jersey2/pom.xml | 6 + .../java-client-jersey3/pom.xml | 6 + .../java-client-okhttp/pom.xml | 24 +-- .../featurehub/client/FeatureHubConfig.java | 41 +++-- .../featurehub/client/edge/EdgeRetryer.java | 15 +- .../SystemPropertyValueInterceptor.java | 2 +- examples/migration-check/pom.xml | 12 +- .../io/featurehub/migrationcheck/Main.java | 5 +- .../src/main/resources/log4j2.xml | 3 - examples/pom.xml | 2 +- examples/todo-java-jersey2/pom.xml | 8 +- .../java/todo/backend/FeatureHubSource.java | 24 +-- examples/todo-java-jersey3/README.adoc | 18 ++ examples/todo-java-jersey3/pom.xml | 8 +- .../java/todo/backend/FeatureHubSource.java | 24 +-- examples/todo-java-quarkus/README.adoc | 17 +- examples/todo-java-quarkus/pom.xml | 12 +- .../examples/quarkus/FeatureHubSource.java | 1 + .../examples/quarkus/HealthResource.java | 2 + .../src/main/resources/application.properties | 4 +- .../featurehub/examples/springboot/Todo.java | 173 ++++++++++++++++++ .../examples/springboot/TodoResource.java | 151 +++++++++++++++ pom.xml | 1 - support/common-jacksonv2/pom.xml | 2 +- support/common-jacksonv3/pom.xml | 98 ++++++++++ .../javascript/Jackson3ObjectMapper.java | 62 +++++++ .../Jackson3ObjectMapperProvider.java | 8 + ...ript.JavascriptObjectMapperProviderService | 1 + support/composite-okhttp/pom.xml | 92 ++++++++++ support/featurehub-okhttp3-jackson2/pom.xml | 110 +++++++++++ support/pom-tiles.xml | 1 + support/pom.xml | 3 + support/tile-java11/tile.xml | 8 +- support/tile-java21/.gitignore | 2 + support/tile-java21/pom.xml | 65 +++++++ support/tile-java21/tile.xml | 95 ++++++++++ 37 files changed, 1022 insertions(+), 113 deletions(-) create mode 100644 examples/todo-java-jersey3/README.adoc create mode 100644 examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java create mode 100644 examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java create mode 100644 support/common-jacksonv3/pom.xml create mode 100644 support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java create mode 100644 support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java create mode 100644 support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService create mode 100644 support/composite-okhttp/pom.xml create mode 100644 support/featurehub-okhttp3-jackson2/pom.xml create mode 100644 support/tile-java21/.gitignore create mode 100644 support/tile-java21/pom.xml create mode 100644 support/tile-java21/tile.xml diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 78b46a4..6d94ad8 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -3,22 +3,39 @@ name: Java CI on: [push] jobs: - build: + build-java11: runs-on: ubuntu-latest - strategy: - matrix: - java: [ '11', '21' ] steps: - uses: actions/checkout@v5 - name: Set up JDK 11 uses: actions/setup-java@v5 with: - java-version: ${{ matrix.java }} + java-version: '11' distribution: 'temurin' cache: maven - name: Install tiles run: cd support && mvn -f pom-tiles.xml install - name: Install support composites run: cd support && mvn install - - name: All other things + - name: All other things except examples run: mvn install + build-java21: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Install tiles + run: cd support && mvn -f pom-tiles.xml install + - name: Install support composites + run: cd support && mvn install + - name: All other things except examples + run: mvn install + - name: Examples + working-directory: examples + run: mvn package + diff --git a/client-implementations/java-client-jersey2/pom.xml b/client-implementations/java-client-jersey2/pom.xml index e8913ef..87158d4 100644 --- a/client-implementations/java-client-jersey2/pom.xml +++ b/client-implementations/java-client-jersey2/pom.xml @@ -68,6 +68,12 @@ [1.1, 2) + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + io.featurehub.sdk.composites sdk-composite-test diff --git a/client-implementations/java-client-jersey3/pom.xml b/client-implementations/java-client-jersey3/pom.xml index a473f3f..75a3528 100644 --- a/client-implementations/java-client-jersey3/pom.xml +++ b/client-implementations/java-client-jersey3/pom.xml @@ -67,6 +67,12 @@ [4, 5) + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + io.featurehub.sdk.composites sdk-composite-test diff --git a/client-implementations/java-client-okhttp/pom.xml b/client-implementations/java-client-okhttp/pom.xml index 99c731b..258a781 100644 --- a/client-implementations/java-client-okhttp/pom.xml +++ b/client-implementations/java-client-okhttp/pom.xml @@ -44,11 +44,10 @@ + 4.12.0 - 3.6.0 - io.featurehub.sdk @@ -56,25 +55,10 @@ [4, 5) - - - com.squareup.okhttp3 - okhttp - ${ok.http.version} - provided - - - com.squareup.okio - okio - ${ok.io.version} - provided - - - - com.squareup.okhttp3 - okhttp-sse - ${ok.http.version} + io.featurehub.sdk.composites + composite-okhttp + [1,2) provided diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java index 8fbee6c..fb50d70 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -4,6 +4,7 @@ import io.featurehub.client.usage.UsagePlugin; import io.featurehub.javascript.JavascriptObjectMapper; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.List; @@ -14,20 +15,36 @@ public interface FeatureHubConfig { - /** - * Use environment variables to create a system config. - * @return system config - */ - default FeatureHubConfig envConfig() { - return new EdgeFeatureHubConfig(System.getenv("FEATUREHUB_EDGE_URL"), System.getenv("FEATUREHUB_API_KEY")); + static @Nullable String getConfig(@NotNull String name) { + String val = System.getenv(name); + if (val == null) { + val = System.getProperty(name); + + if (val == null) { + val = System.getenv(name.toUpperCase()); + if (val == null) { + val = System.getenv(name.toUpperCase().replace('.', '_').replace('-', '_')); + } + } + } + + return val; } - /** - * Use system properties to create a system config. - * @return system config - */ - default FeatureHubConfig systemPropertyConfig() { - return new EdgeFeatureHubConfig(System.getProperty("featurehub.edge-url"), System.getProperty("featurehub.api-key")); + static String getRequiredConfig(@NotNull String name) { + String val = getConfig(name); + + if (val == null) { + throw new RuntimeException(String.format("Required configuration `%s` is missing!", name)); + } + + return val; + } + + static @NotNull String getConfig(@NotNull String name, @NotNull String defaultVal) { + String val = getConfig(name); + + return val == null ? defaultVal : val; } /** diff --git a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index 56d2fde..473147c 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -1,5 +1,6 @@ package io.featurehub.client.edge; +import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.InternalFeatureRepository; import io.featurehub.javascript.JavascriptObjectMapper; import io.featurehub.javascript.JavascriptServiceLoader; @@ -283,18 +284,8 @@ private EdgeRetryerBuilder() { maximumBackoffTimeMs = propertyOrEnv("featurehub.edge.maximum-backoff-ms", "30000"); } - private int propertyOrEnv(String name, String defaultVal) { - String val = System.getenv(name); - - if (val == null) { - val = System.getenv(name.replace(".", "_").replace("-", "_")); - } - - if (val == null) { - val = System.getProperty(name, defaultVal); - } - - return Integer.parseInt(val); + private int propertyOrEnv(@NotNull String name, String defaultVal) { + return Integer.parseInt(FeatureHubConfig.getConfig(name, defaultVal)); } public static EdgeRetryerBuilder anEdgeRetrier() { diff --git a/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java b/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java index cbdf7ce..0e1fab1 100644 --- a/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java @@ -19,7 +19,7 @@ public ValueMatch getValue(String key) { if (System.getProperties().containsKey(k)) { matched = true; value = System.getProperty(k); - if (value != null && value.trim().length() == 0) { + if (value != null && value.trim().isEmpty()) { value = null; } } diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 1481d96..1db696e 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -28,14 +28,8 @@ io.featurehub.sdk - java-client-okhttp - [3.1-SNAPSHOT, 4) - - - - io.featurehub.sdk.composites - sdk-composite-logging - [1.1, 2) + featurehub-okhttp3-jackson2 + [3, 4) @@ -57,7 +51,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java21:[1.1,2) diff --git a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java index 0276315..90a07a1 100644 --- a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java +++ b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java @@ -18,8 +18,7 @@ static String env(@NotNull String key, @NotNull String defaultVal) { } public static void main(String[] args) throws ExecutionException, InterruptedException, IOException { String edgeUrl = env("FEATUREHUB_EDGE_URL", "http://localhost:8085"); - String apiKey = env("FEATUREHUB_CLIENT_API_KEY", "ddd28309-7a5d-4e5a-b060-3f02ddd9e771" + - "/iHwJ3Nvmpqgpz7HK9L7KDTzf9RSH9Q*WYArdlfMWHi6PjT57K6K1"); + String apiKey = env("FEATUREHUB_CLIENT_API_KEY", "08c8a5f3-f766-4059-98cf-581424c8a6e3/fYetsNTQlWR7rTq9vPQv6bNd2i6W6o*5aHEEjnyIjNo2QmCnuEj"); // we need to configure the Config that holds this all together and will swap to SSE once we tell it to FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); @@ -31,7 +30,7 @@ public static void main(String[] args) throws ExecutionException, InterruptedExc if (client.poll().get() == Readiness.Ready) { client.close(); // make sure you close it, it has a background thread // once it is ready, we tell the config to use SSE as its connector, and start the config going. - config.setEdgeService(() -> new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build())); + config.setEdgeService(() -> new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build())); config.init(); System.out.println("ready and waiting for updates via SSE"); diff --git a/examples/migration-check/src/main/resources/log4j2.xml b/examples/migration-check/src/main/resources/log4j2.xml index e477ed7..01fb523 100644 --- a/examples/migration-check/src/main/resources/log4j2.xml +++ b/examples/migration-check/src/main/resources/log4j2.xml @@ -7,10 +7,7 @@ - - - diff --git a/examples/pom.xml b/examples/pom.xml index 238733d..0280da2 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -38,7 +38,7 @@ todo-java-jersey2 todo-java-jersey3 todo-java-quarkus - + todo-java-springboot migration-check diff --git a/examples/todo-java-jersey2/pom.xml b/examples/todo-java-jersey2/pom.xml index 42b3c51..c7cb266 100644 --- a/examples/todo-java-jersey2/pom.xml +++ b/examples/todo-java-jersey2/pom.xml @@ -48,6 +48,12 @@ [1.1-SNAPSHOT, 2) + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + org.glassfish.jersey.containers @@ -111,7 +117,7 @@ false - io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-java21:[1.1,2) diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java index 67290ed..cdd5902 100644 --- a/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java @@ -17,30 +17,24 @@ import java.util.List; public class FeatureHubSource implements FeatureHub { - @ConfigKey("feature-service.host") - String featureHubUrl; - @ConfigKey("feature-service.api-key") - String sdkKey; - @ConfigKey("segment.write-key") - String segmentWriteKey = ""; - @ConfigKey("feature-service.client") - String client = "sse"; // sse, rest, rest-poll - @ConfigKey("feature-service.opentelemetry.enabled") - Boolean openTelemetryEnabled = false; - @ConfigKey("feature-service.poll-interval-seconds") - Integer pollInterval = 1; // in seconds + String featureHubUrl = FeatureHubConfig.getRequiredConfig("feature-service.host"); + String sdkKey = FeatureHubConfig.getRequiredConfig("feature-service.api-key"); + String segmentWriteKey = FeatureHubConfig.getConfig("segment.write-key"); + String client = FeatureHubConfig.getConfig("feature-service.client", "sse"); // sse, rest, rest-poll + @ConfigKey() + Boolean openTelemetryEnabled = Boolean.parseBoolean(FeatureHubConfig.getConfig("feature-service.opentelemetry.enabled", "false")); + @ConfigKey() + Integer pollInterval = Integer.parseInt(FeatureHubConfig.getConfig("feature-service.poll-interval-seconds", "1")); // in seconds @Nullable SegmentAnalyticsSource segmentAnalyticsSource; private final FeatureHubConfig config; public FeatureHubSource() { - DeclaredConfigResolver.resolve(this); - config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - if (!segmentWriteKey.isEmpty()) { + if (segmentWriteKey != null) { final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, List.of(new SegmentMessageTransformer(Message.Type.values(), FeatureHubClientContextThreadLocal::get, false, true))); diff --git a/examples/todo-java-jersey3/README.adoc b/examples/todo-java-jersey3/README.adoc new file mode 100644 index 0000000..d0f202b --- /dev/null +++ b/examples/todo-java-jersey3/README.adoc @@ -0,0 +1,18 @@ += ToDo Jersey3 Example + +This is an example of wiring up using Jersey3 instead of Jersey2. The same +settings apply. + +It is expected that this is run in the IDE by default as it is not packaged. The +test will load system properties. + +== System Properties + +The system properties it honours are: + +- `feature-service.host` - the http/https location of your featurehub server minus the `/features` part. +- `feature-service.api-key` - the client eval key, do not use a server eval key for web servers. +- `segment.write-key` - a segment key if you have one +- `feature-service.client`, defaultValue = `sse` - valid values are sse, rest (passive poll, poll only if features are evaluated and polling interval has expired) and rest-poll (continuous poll). +- `feature-service.opentelemetry.enabled`, defaultValue = `false` - you have an otel server and have set the env vars it requires, this turns instrumentation on. +- `feature-service.poll-interval-seconds`, defaultValue = `1` - how many seconds should expire between polls (or poll expiry interval for passive polls). diff --git a/examples/todo-java-jersey3/pom.xml b/examples/todo-java-jersey3/pom.xml index d85d101..6d9440c 100644 --- a/examples/todo-java-jersey3/pom.xml +++ b/examples/todo-java-jersey3/pom.xml @@ -62,6 +62,12 @@ [1.1-SNAPSHOT, 2) + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + org.glassfish.jersey.containers @@ -125,7 +131,7 @@ false - io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-java21:[1.1,2) diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java index 67290ed..cdd5902 100644 --- a/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java @@ -17,30 +17,24 @@ import java.util.List; public class FeatureHubSource implements FeatureHub { - @ConfigKey("feature-service.host") - String featureHubUrl; - @ConfigKey("feature-service.api-key") - String sdkKey; - @ConfigKey("segment.write-key") - String segmentWriteKey = ""; - @ConfigKey("feature-service.client") - String client = "sse"; // sse, rest, rest-poll - @ConfigKey("feature-service.opentelemetry.enabled") - Boolean openTelemetryEnabled = false; - @ConfigKey("feature-service.poll-interval-seconds") - Integer pollInterval = 1; // in seconds + String featureHubUrl = FeatureHubConfig.getRequiredConfig("feature-service.host"); + String sdkKey = FeatureHubConfig.getRequiredConfig("feature-service.api-key"); + String segmentWriteKey = FeatureHubConfig.getConfig("segment.write-key"); + String client = FeatureHubConfig.getConfig("feature-service.client", "sse"); // sse, rest, rest-poll + @ConfigKey() + Boolean openTelemetryEnabled = Boolean.parseBoolean(FeatureHubConfig.getConfig("feature-service.opentelemetry.enabled", "false")); + @ConfigKey() + Integer pollInterval = Integer.parseInt(FeatureHubConfig.getConfig("feature-service.poll-interval-seconds", "1")); // in seconds @Nullable SegmentAnalyticsSource segmentAnalyticsSource; private final FeatureHubConfig config; public FeatureHubSource() { - DeclaredConfigResolver.resolve(this); - config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - if (!segmentWriteKey.isEmpty()) { + if (segmentWriteKey != null) { final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, List.of(new SegmentMessageTransformer(Message.Type.values(), FeatureHubClientContextThreadLocal::get, false, true))); diff --git a/examples/todo-java-quarkus/README.adoc b/examples/todo-java-quarkus/README.adoc index bdfcc6d..9390184 100644 --- a/examples/todo-java-quarkus/README.adoc +++ b/examples/todo-java-quarkus/README.adoc @@ -38,8 +38,23 @@ curl -H 'Authorization: irina' http://localhost:8080 Hello World blue ---- -You can start the app with: +== System Properties + +The system properties it honours are: + +- `feature-service.host` - the http/https location of your featurehub server minus the `/features` part. +- `feature-service.api-key` - the client eval key, do not use a server eval key for web servers. +- `segment.write-key` - a segment key if you have one +- `feature-service.client`, defaultValue = `sse` - valid values are sse, rest (passive poll, poll only if features are evaluated and polling interval has expired) and rest-poll (continuous poll). +- `feature-service.opentelemetry.enabled`, defaultValue = `false` - you have an otel server and have set the env vars it requires, this turns instrumentation on. +- `feature-service.poll-interval-seconds`, defaultValue = `1` - how many seconds should expire between polls (or poll expiry interval for passive polls). + +You can start the app with: $ mvn compile quarkus:dev +If you have a file of these configs elsewhere: + + $ mvn -Dquarkus.config.locations=$HOME/.featurehub/example-java.properties compile quarkus:dev + You m diff --git a/examples/todo-java-quarkus/pom.xml b/examples/todo-java-quarkus/pom.xml index 2d8dfb7..06ef129 100644 --- a/examples/todo-java-quarkus/pom.xml +++ b/examples/todo-java-quarkus/pom.xml @@ -14,10 +14,10 @@ UTF-8 UTF-8 - 3.6.4 + 3.27.1 quarkus-universe-bom io.quarkus - 3.6.4 + 3.27.1 3.0.0-M5 4.12.0 @@ -63,7 +63,13 @@ io.featurehub.sdk java-client-okhttp - 3.1-SNAPSHOT + [3,4) + + + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java index 39872fb..2cf0502 100644 --- a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java @@ -50,6 +50,7 @@ public class FeatureHubSource { @Produces @ApplicationScoped + @Startup public FeatureHubConfig getConfig() { if (featureHubUrl == null || sdkKey == null) { throw new RuntimeException("URL and Key must not be null"); diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java index 422f319..0972303 100644 --- a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java +++ b/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java @@ -3,7 +3,9 @@ import io.featurehub.client.FeatureHubConfig; import io.featurehub.client.Readiness; +import io.quarkus.runtime.Startup; import jakarta.inject.Inject; +import jakarta.inject.Singleton; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.core.Response; diff --git a/examples/todo-java-quarkus/src/main/resources/application.properties b/examples/todo-java-quarkus/src/main/resources/application.properties index 46c1a36..d9d1351 100644 --- a/examples/todo-java-quarkus/src/main/resources/application.properties +++ b/examples/todo-java-quarkus/src/main/resources/application.properties @@ -1,5 +1,5 @@ -feature-service.api-key=9db9f611-f5c9-4b09-bac5-ccf5a5b989c9/ACyrjuIjugghPI2J6XxHybSXKoC28Z*Ld6f2H8drfzP1raWgK8D -feature-service.host=http://localhost:8903 +feature-service.api-key=08c8a5f3-f766-4059-98cf-581424c8a6e3/fYetsNTQlWR7rTq9vPQv6bNd2i6W6o*5aHEEjnyIjNo2QmCnuEj +feature-service.host=http://localhost:8085 quarkus.log.level=INFO quarkus.log.category."io.featurehub".min-level=TRACE diff --git a/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java b/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java new file mode 100644 index 0000000..9b80724 --- /dev/null +++ b/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java @@ -0,0 +1,173 @@ +/* + * Todo + * Sample todo-api + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +package io.featurehub.examples.springboot; + +import com.fasterxml.jackson.annotation.*; +import java.time.OffsetDateTime; +import java.util.Objects; + +/** Todo */ +@JsonIgnoreProperties(ignoreUnknown = true) +@jakarta.annotation.Generated( + value = "cd.connect.openapi.Jersey3ApiGenerator", + date = "2026-01-11T09:21:49.181170+13:00[Pacific/Auckland]") +public class Todo { + @JsonProperty("id") + // required true nullable true default + @org.jetbrains.annotations.Nullable + private String id; + + @JsonProperty("title") + // required false nullable false default + @org.jetbrains.annotations.NotNull + private String title; + + @JsonProperty("resolved") + // required false nullable true default + @org.jetbrains.annotations.Nullable + private Boolean resolved; + + @JsonProperty("when") + // required false nullable true default + @org.jetbrains.annotations.Nullable + private OffsetDateTime when; + + public Todo id(@org.jetbrains.annotations.Nullable String id) { + this.id = id; + return this; + } + + /** + * Get id + * + * @return id + */ + @org.jetbrains.annotations.Nullable + public String getId() { + return id; + } + + public void setId(@org.jetbrains.annotations.Nullable String id) { + this.id = id; + } + + public Todo title(@org.jetbrains.annotations.NotNull String title) { + this.title = title; + return this; + } + + /** + * Get title + * + * @return title + */ + @org.jetbrains.annotations.NotNull + public String getTitle() { + return title; + } + + public void setTitle(@org.jetbrains.annotations.NotNull String title) { + this.title = title; + } + + public Todo resolved(@org.jetbrains.annotations.Nullable Boolean resolved) { + this.resolved = resolved; + return this; + } + + /** + * Get resolved + * + * @return resolved + */ + @org.jetbrains.annotations.Nullable + public Boolean getResolved() { + return resolved; + } + + public void setResolved(@org.jetbrains.annotations.Nullable Boolean resolved) { + this.resolved = resolved; + } + + public Todo when(@org.jetbrains.annotations.Nullable OffsetDateTime when) { + this.when = when; + return this; + } + + /** + * Get when + * + * @return when + */ + @org.jetbrains.annotations.Nullable + public OffsetDateTime getWhen() { + return when; + } + + public void setWhen(@org.jetbrains.annotations.Nullable OffsetDateTime when) { + this.when = when; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Todo todo = (Todo) o; + return Objects.equals(this.id, todo.id) + && Objects.equals(this.title, todo.title) + && Objects.equals(this.resolved, todo.resolved) + && Objects.equals(this.when, todo.when); + } + + @Override + public int hashCode() { + return Objects.hash(id, title, resolved, when); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Todo {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" title: ").append(toIndentedString(title)).append("\n"); + sb.append(" resolved: ").append(toIndentedString(resolved)).append("\n"); + sb.append(" when: ").append(toIndentedString(when)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + public Todo copy() { + Todo copy = new Todo(); + + copy.setId(this.getId()); + copy.setTitle(this.getTitle()); + copy.setResolved(this.getResolved()); + copy.setWhen(this.getWhen()); + + return copy; + } +} diff --git a/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java b/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java new file mode 100644 index 0000000..52493d6 --- /dev/null +++ b/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java @@ -0,0 +1,151 @@ +package io.featurehub.examples.springboot; + + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureHubConfig; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@RestController() +@RequestMapping("/todo") +public class TodoResource { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final FeatureHubConfig featureHub; + Map> todos = new ConcurrentHashMap<>(); + + @Autowired + public TodoResource(FeatureHubConfig config) { + this.featureHub = config; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = fhClient(user); + + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @NotNull + private ClientContext fhClient(String user) { + try { + final ClientContext context = featureHub.newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + +// FeatureHubClientContextThreadLocal.set(context); +// +// if (featureHub.segmentAnalytics() != null) { +// // this should have the current user's details augmented into it +// featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); +// } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; + } catch (Exception e) { + log.error("Unable to get context!", e); + throw new ResponseStatusException(HttpStatusCode.valueOf(503), "Not connected"); + } + } + + @PostMapping(value = "/{user}", consumes = "application/json", produces = "application/json") + public List addTodo(@NotNull @PathVariable("user") String user, @RequestBody Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @GetMapping(value = "/{user}", produces = "application/json") + public List listTodos(@NotNull @PathVariable("user") String user) { + return getTodoList(getTodoMap(user), user); + } + + @DeleteMapping(value = "/{user}", produces = "application/json") + public void removeAllTodos(@NotNull @PathVariable("user") String user) { + getTodoMap(user).clear(); + } + + @GetMapping(value = "/{user}/{id}", produces = "application/json") + public List removeTodo(@NotNull @PathVariable("user") String user, @NotNull @PathVariable("id") String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @PutMapping(value = "/{user}/{id}", produces = "application/json") + public List resolveTodo(@NotNull @PathVariable("user") String user, @NotNull @PathVariable("id") String id) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new ResponseStatusException(HttpStatusCode.valueOf(404), "No such todo"); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/pom.xml b/pom.xml index 8074d3b..06f1173 100644 --- a/pom.xml +++ b/pom.xml @@ -37,7 +37,6 @@ core client-implementations support - usage-adapters diff --git a/support/common-jacksonv2/pom.xml b/support/common-jacksonv2/pom.xml index ef35a2f..d6022ea 100644 --- a/support/common-jacksonv2/pom.xml +++ b/support/common-jacksonv2/pom.xml @@ -8,7 +8,7 @@ common-jacksonv2 - Shared core of featurehub client. + implementation for jackson v2 https://featurehub.io diff --git a/support/common-jacksonv3/pom.xml b/support/common-jacksonv3/pom.xml new file mode 100644 index 0000000..924901f --- /dev/null +++ b/support/common-jacksonv3/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jacksonv3 + 1.1-SNAPSHOT + common-jacksonv3 + + + implementation for jacksonv3 + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 3.0.3 + 3.0.3 + 21 + + + + + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] + + + + tools.jackson.core + jackson-databind + [${jackson.databind.version}] + + + + + tools.jackson.jaxrs + jackson-jaxrs-base + [${jackson.version}] + + + + + io.featurehub.sdk.composites + sdk-composite-logging-api + [1.1, 2) + + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java21:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java b/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java new file mode 100644 index 0000000..029bccd --- /dev/null +++ b/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java @@ -0,0 +1,62 @@ +package io.featurehub.javascript; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +// migration guide: https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md + +public class Jackson3ObjectMapper implements JavascriptObjectMapper { + private static ObjectMapper mapper; + + static { + mapper = JsonMapper.builder() + .enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL)) + .changeDefaultPropertyInclusion(incl -> incl.withContentInclusion(JsonInclude.Include.NON_NULL)) + .build(); + + } + + @Override + public T readValue(String data, Class type) throws IOException { + return data == null ? null : mapper.readValue(data, type); + } + + private static final TypeReference> FEATURE_COLLECTION_TYPEREF = new TypeReference>(){}; + private static final TypeReference> mapConfig = new TypeReference>() {}; + private static final TypeReference> FEATURE_LIST_TYPEDEF = + new TypeReference<>() {}; + + @Override + public Map readMapValue(String data) throws IOException { + return mapper.readValue(data, mapConfig); + } + + @Override + public @NotNull List readFeatureStates(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_LIST_TYPEDEF); + } + + @Override + public @NotNull List readFeatureCollection(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_COLLECTION_TYPEREF); + } + + @Override + public @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException { + return mapper.writeValueAsString(data); + } +} diff --git a/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java b/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java new file mode 100644 index 0000000..e8ce498 --- /dev/null +++ b/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java @@ -0,0 +1,8 @@ +package io.featurehub.javascript; + +public class Jackson3ObjectMapperProvider implements JavascriptObjectMapperProviderService { + @Override + public JavascriptObjectMapper get() { + return new Jackson3ObjectMapper(); + } +} diff --git a/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService b/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService new file mode 100644 index 0000000..6f49c63 --- /dev/null +++ b/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService @@ -0,0 +1 @@ +io.featurehub.javascript.Jackson3ObjectMapperProvider diff --git a/support/composite-okhttp/pom.xml b/support/composite-okhttp/pom.xml new file mode 100644 index 0000000..33fac11 --- /dev/null +++ b/support/composite-okhttp/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + io.featurehub.sdk.composites + composite-okhttp + 1.1-SNAPSHOT + composite-okhttp + + + Provides a complete list of required OKHttp dependencies that can be used while testing or as provided + in SDK. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 4.12.0 + 3.6.0 + + + + + + + + com.squareup.okhttp3 + okhttp + ${ok.http.version} + + + + com.squareup.okio + okio + ${ok.io.version} + + + + com.squareup.okhttp3 + okhttp-sse + ${ok.http.version} + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + diff --git a/support/featurehub-okhttp3-jackson2/pom.xml b/support/featurehub-okhttp3-jackson2/pom.xml new file mode 100644 index 0000000..2241b27 --- /dev/null +++ b/support/featurehub-okhttp3-jackson2/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + io.featurehub.sdk + featurehub-okhttp3-jackson2 + 3.1-SNAPSHOT + featurehub-okhttp3-jackson2 + + + The OKHttp client for Java. Supports all three (streaming, polling, interval). It includes all + necessary dependencies to run the stack which is an unusual choice. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 4.12.0 + 3.6.0 + + + + + io.featurehub.sdk + java-client-okhttp + [3, 4) + + + + + com.squareup.okhttp3 + okhttp + ${ok.http.version} + + + + com.squareup.okio + okio + ${ok.io.version} + + + + com.squareup.okhttp3 + okhttp-sse + ${ok.http.version} + + + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1, 2) + + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + io.featurehub.sdk.tiles:tile-sdk:[1.1-SNAPSHOT,2) + + + + + + diff --git a/support/pom-tiles.xml b/support/pom-tiles.xml index 0496bf7..d7c90f1 100644 --- a/support/pom-tiles.xml +++ b/support/pom-tiles.xml @@ -36,6 +36,7 @@ tile-java8 tile-java11 + tile-java21 tile-sdk tile-release diff --git a/support/pom.xml b/support/pom.xml index d55749f..6caea67 100644 --- a/support/pom.xml +++ b/support/pom.xml @@ -36,10 +36,13 @@ common-jackson common-jacksonv2 + common-jacksonv3 composite-jersey2 composite-jersey3 + composite-okhttp composite-logging composite-logging-api composite-test + featurehub-okhttp3-jackson2 diff --git a/support/tile-java11/tile.xml b/support/tile-java11/tile.xml index 96a8c75..7cb8bd3 100644 --- a/support/tile-java11/tile.xml +++ b/support/tile-java11/tile.xml @@ -28,7 +28,7 @@ maven-compiler-plugin - 3.8.1 + 3.11.0 11 11 @@ -96,17 +96,13 @@ org.codehaus.gmavenplus gmavenplus-plugin - 1.9.0 + 4.1.1 - addSources addTestSources - generateStubs - compile generateTestStubs compileTests - removeStubs removeTestStubs diff --git a/support/tile-java21/.gitignore b/support/tile-java21/.gitignore new file mode 100644 index 0000000..26a9bfe --- /dev/null +++ b/support/tile-java21/.gitignore @@ -0,0 +1,2 @@ +*.iml +target diff --git a/support/tile-java21/pom.xml b/support/tile-java21/pom.xml new file mode 100644 index 0000000..501f34c --- /dev/null +++ b/support/tile-java21/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + io.featurehub.sdk.tiles + tile-java21 + 1.2 + tile + tile-java21 + + + tile java contains plugins required for java application creation. It is focused on Java 21 and is used + primarily for the examples. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + + diff --git a/support/tile-java21/tile.xml b/support/tile-java21/tile.xml new file mode 100644 index 0000000..62e2f8e --- /dev/null +++ b/support/tile-java21/tile.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + true + + + + + + + org.apache.maven.plugins + maven-source-plugin + + 3.0.0 + + + attach-sources + + jar-no-fork + + + + + + maven-compiler-plugin + 3.11.0 + + 21 + false + + + + + default-compile + none + + + + + default-testCompile + none + + + + java-compile + compile + + compile + + + + + java-test-compile + test-compile + + testCompile + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M3 + + + **/*Test.java + **/*Spec.java + + + + + + org.codehaus.mojo + license-maven-plugin + 1.20 + + + licences + + add-third-party + + + + + + + From 2f45a2804a3bd7c3a59ca5de9300ae609b9f0e32 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 11 Jan 2026 16:04:45 +1300 Subject: [PATCH 19/22] move stuff around for more compatible building --- .github/workflows/java.yaml | 12 ++--- examples/pom.xml | 4 +- pom.xml | 1 + support/featurehub-okhttp3-jackson2/pom.xml | 24 ++-------- support/pom.xml | 2 +- v17-and-above/examples/pom.xml | 44 +++++++++++++++++++ .../examples}/todo-java-quarkus/.gitignore | 0 .../examples}/todo-java-quarkus/README.adoc | 0 .../examples}/todo-java-quarkus/pom.xml | 2 +- .../examples/quarkus/AuthFilter.java | 0 .../examples/quarkus/FeatureHubSource.java | 0 .../examples/quarkus/HealthResource.java | 0 .../examples/quarkus/TodoResource.java | 0 .../examples/quarkus/TodoSource.java | 0 .../src/main/resources/application.properties | 0 .../featurehub/examples/springboot/Todo.java | 0 .../examples/springboot/TodoResource.java | 0 v17-and-above/pom.xml | 40 +++++++++++++++++ .../support}/common-jacksonv3/pom.xml | 0 .../javascript/Jackson3ObjectMapper.java | 0 .../Jackson3ObjectMapperProvider.java | 0 ...ript.JavascriptObjectMapperProviderService | 0 v17-and-above/support/pom.xml | 39 ++++++++++++++++ 23 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 v17-and-above/examples/pom.xml rename {examples => v17-and-above/examples}/todo-java-quarkus/.gitignore (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/README.adoc (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/pom.xml (98%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java (100%) rename {examples => v17-and-above/examples}/todo-java-quarkus/src/main/resources/application.properties (100%) rename {examples => v17-and-above/examples}/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java (100%) rename {examples => v17-and-above/examples}/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java (100%) create mode 100644 v17-and-above/pom.xml rename {support => v17-and-above/support}/common-jacksonv3/pom.xml (100%) rename {support => v17-and-above/support}/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java (100%) rename {support => v17-and-above/support}/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java (100%) rename {support => v17-and-above/support}/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService (100%) create mode 100644 v17-and-above/support/pom.xml diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 6d94ad8..3e69ba1 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -16,8 +16,6 @@ jobs: - name: Install tiles run: cd support && mvn -f pom-tiles.xml install - name: Install support composites - run: cd support && mvn install - - name: All other things except examples run: mvn install build-java21: runs-on: ubuntu-latest @@ -31,11 +29,9 @@ jobs: cache: maven - name: Install tiles run: cd support && mvn -f pom-tiles.xml install - - name: Install support composites - run: cd support && mvn install - - name: All other things except examples + - name: All other things + run: mvn install + - name: java17+ only + working-directory: v17-and-above run: mvn install - - name: Examples - working-directory: examples - run: mvn package diff --git a/examples/pom.xml b/examples/pom.xml index 0280da2..827fd0a 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -37,8 +37,8 @@ todo-java-shared todo-java-jersey2 todo-java-jersey3 - todo-java-quarkus - todo-java-springboot + + migration-check diff --git a/pom.xml b/pom.xml index 06f1173..b5c974b 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,7 @@ core + examples client-implementations support usage-adapters diff --git a/support/featurehub-okhttp3-jackson2/pom.xml b/support/featurehub-okhttp3-jackson2/pom.xml index 2241b27..4b1fa6e 100644 --- a/support/featurehub-okhttp3-jackson2/pom.xml +++ b/support/featurehub-okhttp3-jackson2/pom.xml @@ -44,11 +44,6 @@ HEAD - - 4.12.0 - 3.6.0 - - io.featurehub.sdk @@ -56,23 +51,10 @@ [3, 4) - - - com.squareup.okhttp3 - okhttp - ${ok.http.version} - - - com.squareup.okio - okio - ${ok.io.version} - - - - com.squareup.okhttp3 - okhttp-sse - ${ok.http.version} + io.featurehub.sdk.composites + composite-okhttp + [1,2) diff --git a/support/pom.xml b/support/pom.xml index 6caea67..93bc189 100644 --- a/support/pom.xml +++ b/support/pom.xml @@ -36,7 +36,7 @@ common-jackson common-jacksonv2 - common-jacksonv3 + composite-jersey2 composite-jersey3 composite-okhttp diff --git a/v17-and-above/examples/pom.xml b/v17-and-above/examples/pom.xml new file mode 100644 index 0000000..1991010 --- /dev/null +++ b/v17-and-above/examples/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + io.featurehub.java + featurehub-sdk-v17-example-reactor + 1.1.1 + pom + + + Holds examples that require v17+ of Java. We usually use v21 or 23. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + todo-java-quarkus + todo-java-springboot + + diff --git a/examples/todo-java-quarkus/.gitignore b/v17-and-above/examples/todo-java-quarkus/.gitignore similarity index 100% rename from examples/todo-java-quarkus/.gitignore rename to v17-and-above/examples/todo-java-quarkus/.gitignore diff --git a/examples/todo-java-quarkus/README.adoc b/v17-and-above/examples/todo-java-quarkus/README.adoc similarity index 100% rename from examples/todo-java-quarkus/README.adoc rename to v17-and-above/examples/todo-java-quarkus/README.adoc diff --git a/examples/todo-java-quarkus/pom.xml b/v17-and-above/examples/todo-java-quarkus/pom.xml similarity index 98% rename from examples/todo-java-quarkus/pom.xml rename to v17-and-above/examples/todo-java-quarkus/pom.xml index 06ef129..253a254 100644 --- a/examples/todo-java-quarkus/pom.xml +++ b/v17-and-above/examples/todo-java-quarkus/pom.xml @@ -140,7 +140,7 @@ ${project.basedir}/target/generated-sources/api todo.api todo.model - ${project.basedir}/../todo-java-shared/todo-api.yaml + ${project.basedir}/../../../examples/todo-java-shared/todo-api.yaml jaxrs-spec interfaceOnly=true,useSwaggerAnnotations=false,openApiNullable=false diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java similarity index 100% rename from examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java rename to v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java similarity index 100% rename from examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java rename to v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java similarity index 100% rename from examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java rename to v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java similarity index 100% rename from examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java rename to v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java diff --git a/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java similarity index 100% rename from examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java rename to v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java diff --git a/examples/todo-java-quarkus/src/main/resources/application.properties b/v17-and-above/examples/todo-java-quarkus/src/main/resources/application.properties similarity index 100% rename from examples/todo-java-quarkus/src/main/resources/application.properties rename to v17-and-above/examples/todo-java-quarkus/src/main/resources/application.properties diff --git a/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java similarity index 100% rename from examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java rename to v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java diff --git a/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java similarity index 100% rename from examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java rename to v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java diff --git a/v17-and-above/pom.xml b/v17-and-above/pom.xml new file mode 100644 index 0000000..b3a81bd --- /dev/null +++ b/v17-and-above/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.featurehub + featurehub-v17-java-sdk-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + examples + support + + diff --git a/support/common-jacksonv3/pom.xml b/v17-and-above/support/common-jacksonv3/pom.xml similarity index 100% rename from support/common-jacksonv3/pom.xml rename to v17-and-above/support/common-jacksonv3/pom.xml diff --git a/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java similarity index 100% rename from support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java rename to v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java diff --git a/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java similarity index 100% rename from support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java rename to v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java diff --git a/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService b/v17-and-above/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService similarity index 100% rename from support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService rename to v17-and-above/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService diff --git a/v17-and-above/support/pom.xml b/v17-and-above/support/pom.xml new file mode 100644 index 0000000..7e24fc3 --- /dev/null +++ b/v17-and-above/support/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + io.featurehub + featurehub-sdk-v17-support-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + common-jacksonv3 + + From aa1b71ae9df76d6533a7135ea59154ba57f08885 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 11 Jan 2026 16:13:22 +1300 Subject: [PATCH 20/22] fixing build --- examples/todo-java-jersey2/pom.xml | 2 +- examples/todo-java-jersey3/pom.xml | 2 +- .../examples/todo-java-springboot/.gitignore | 35 ++++++++++ .../examples/todo-java-springboot/README.adoc | 37 +++++++++++ .../examples/todo-java-springboot/pom.xml | 65 +++++++++++++++++++ .../examples/springboot/Application.java | 30 +++++++++ .../examples/springboot/HealthResource.java | 34 ++++++++++ .../springboot/UserConfiguration.java | 25 +++++++ .../src/main/resources/application.yaml | 6 ++ .../examples/springboot/ApplicationTests.java | 13 ++++ 10 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 v17-and-above/examples/todo-java-springboot/.gitignore create mode 100644 v17-and-above/examples/todo-java-springboot/README.adoc create mode 100644 v17-and-above/examples/todo-java-springboot/pom.xml create mode 100644 v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java create mode 100644 v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java create mode 100644 v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java create mode 100644 v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml create mode 100644 v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java diff --git a/examples/todo-java-jersey2/pom.xml b/examples/todo-java-jersey2/pom.xml index c7cb266..822cc0d 100644 --- a/examples/todo-java-jersey2/pom.xml +++ b/examples/todo-java-jersey2/pom.xml @@ -117,7 +117,7 @@ false - io.featurehub.sdk.tiles:tile-java21:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) diff --git a/examples/todo-java-jersey3/pom.xml b/examples/todo-java-jersey3/pom.xml index 6d9440c..a2c1002 100644 --- a/examples/todo-java-jersey3/pom.xml +++ b/examples/todo-java-jersey3/pom.xml @@ -131,7 +131,7 @@ false - io.featurehub.sdk.tiles:tile-java21:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) diff --git a/v17-and-above/examples/todo-java-springboot/.gitignore b/v17-and-above/examples/todo-java-springboot/.gitignore new file mode 100644 index 0000000..38cbc8a --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/.gitignore @@ -0,0 +1,35 @@ +HELP.md +target/ +/target +/.idea +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/v17-and-above/examples/todo-java-springboot/README.adoc b/v17-and-above/examples/todo-java-springboot/README.adoc new file mode 100644 index 0000000..f98032e --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/README.adoc @@ -0,0 +1,37 @@ += A FeatureHub SpringBoot Template + +This example simply follows the basics of how a Spring or SpringBoot +application would be provided for in Java. Java server applications +recommend that you expose a health check endpoint, and if you wish +to have your server not get traffic routed to it by your Application +Load Balanccer (or whatever your Cloud provider uses), then simply +fail the health check when FeatureHub is not "ready". + +This example is primarily here to provide documentation for the SDK, +but it operates on its own. You must provide two environment variables +for it to start. + +[source,bash] +---- +export FEATUREHUB_EDGE_URL=http://localhost:8903/ +export FEATUREHUB_API_KEY=default/3f7a1a34-642b-4054-a82f-1ca2d14633ed/aH0l9TDXzauYq6rKQzVUPwbzmzGRqe*oPqyYqhUlVC50RxAzSmx + +mvn spring-boot:run +---- + +It recognizes a "Authorization" header which contains the value it will +directly put into the userKey for simplicity to allow you to try out +percentage rollouts and tagging feature values to users. + +The urls are: + +- / - it print Hello World and the value of the SUBMIT_COLOR_BUTTON +- /health/liveness - whether the application is ready to receive traffic + +---- +curl -H 'Authorization: richard' http://localhost:8080 +Hello World green1 + +curl -H 'Authorization: irina' http://localhost:8080 +Hello World blue +---- diff --git a/v17-and-above/examples/todo-java-springboot/pom.xml b/v17-and-above/examples/todo-java-springboot/pom.xml new file mode 100644 index 0000000..fdf507c --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.1 + + + io.featurehub.sdk.examples + todo-java-springboot + 0.0.1-SNAPSHOT + spring-boot + Demo project for Spring Boot for FeatureHub + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.featurehub.sdk + java-client-okhttp + [3, 4) + + + + io.featurehub.sdk.common + common-jacksonv3 + [1,2) + + + + io.featurehub.sdk.composites + composite-okhttp + [1,2) + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java new file mode 100644 index 0000000..2d16bf2 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java @@ -0,0 +1,30 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class Application { + @Value("${featurehub.url}") + String edgeUrl; + @Value("${featurehub.apiKey}") + String apiKey; + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + + @Bean + public FeatureHubConfig featureHubConfig() { + FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); + config.streaming().init(); + + return config; + } + +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java new file mode 100644 index 0000000..6110a24 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java @@ -0,0 +1,34 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.Readiness; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/health") +public class HealthResource { + private final FeatureHubConfig featureHubConfig; + private static final Logger log = LoggerFactory.getLogger(HealthResource.class); + + @Autowired + public HealthResource(FeatureHubConfig featureHubConfig) { + this.featureHubConfig = featureHubConfig; + } + + @RequestMapping("/liveness") + public String liveness() { + if (featureHubConfig.getReadiness() == Readiness.Ready) { + return "yes"; + } + + log.warn("FeatureHub connection not yet available, reporting not live."); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java new file mode 100644 index 0000000..9ad8265 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java @@ -0,0 +1,25 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureHubConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; + +import jakarta.servlet.http.HttpServletRequest; + +@Configuration +public class UserConfiguration { + @Bean + @Scope("request") + ClientContext featureHubClient(FeatureHubConfig fhConfig, HttpServletRequest request) { + ClientContext fhClient = fhConfig.newContext(); + + if (request.getHeader("Authorization") != null) { + // you would always authenticate some other way, this is just an example + fhClient.userKey(request.getHeader("Authorization")); + } + + return fhClient; + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml b/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml new file mode 100644 index 0000000..b852f0d --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +server: + port: 8099 + +featurehub: + url: ${FEATUREHUB_EDGE_URL:http://localhost:8903} + apiKey: ${FEATUREHUB_CLIENT_API_KEY:9db9f611-f5c9-4b09-bac5-ccf5a5b989c9/ACyrjuIjugghPI2J6XxHybSXKoC28Z*Ld6f2H8drfzP1raWgK8D} diff --git a/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java b/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java new file mode 100644 index 0000000..a6b09d4 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java @@ -0,0 +1,13 @@ +package io.featurehub.examples.springboot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +} From 12230f871ac5690dfdca4f7e264d083aa1129f38 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Sun, 11 Jan 2026 16:26:30 +1300 Subject: [PATCH 21/22] migration check was still java21 should be 11 --- examples/migration-check/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 1db696e..44ffebc 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -51,7 +51,7 @@ false - io.featurehub.sdk.tiles:tile-java21:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) From 9cbba94864a27bb56c6333128de4fbb4014cc911 Mon Sep 17 00:00:00 2001 From: Richard Vowles Date: Thu, 15 Jan 2026 19:40:40 +1300 Subject: [PATCH 22/22] last set of docs before merge --- README.adoc | 409 +++++++++++-- core/client-java-core/README.adoc | 559 ------------------ .../java/todo/backend/FeatureHubSource.java | 4 +- unmaintained/README.adoc | 3 + .../examples/quarkus/FeatureHubSource.java | 6 +- .../examples/springboot/Application.java | 1 - 6 files changed, 357 insertions(+), 625 deletions(-) delete mode 100644 core/client-java-core/README.adoc create mode 100644 unmaintained/README.adoc diff --git a/README.adoc b/README.adoc index 1edc66c..61fe73e 100644 --- a/README.adoc +++ b/README.adoc @@ -1,51 +1,366 @@ -= Java Libraries += FeatureHub SDK for Java (v3) +ifdef::env-github,env-browser[:outfilesuffix: .adoc] -== todo +Welcome to the Java SDK implementation for https://featurehub.io[FeatureHub.io] - Open source Feature flags management, +A/B testing and remote configuration platform. -- ensure that all of the factories are correct and each client has a full test client + rest client + sse client and they are actually working against proper server -- implement polling via a timer in the code repo -- move the remaining examples from featurehub-examples here (quarkus & spring) +This is the version 3 of the SDKs for OKHTTP, Jersey 2, and Jersey 3. It is a departure from the Version 2 libraries +because all possible clients are collected together (SSO, Passive Rest, Active Rest). +The minimum version of Java supported is Java 11. Some libraries support only Java 17+ because of their dependencies. -This is the set of libraries currently supporting the Java programming language and its JDK based cousins. It currently consists -of two libraries: +It is generally recommended that you should use the `OKHttp` version (`io.featurehub.sdk:java-client-okhttp:3+`) of the libary by preference unless you +are already using a Jersey 2 or Jersey 3 stack. -- link:client-java-core/README.adoc[Core] - this reflects the core repository and all listeners that notify about feature changes. +NOTE: We are using Gradle standard for referring to version ranges, so `3+` means +`[3,4)` in Maven. -You primarily use this SDK by choosing a transport mechanism which includes the core library above. The transport -mechanism will automatically configure itself using Java Services, so when you create a new client, you do not have -to worry about the details of each implementation. +== Setting up your dependencies -- link:client-java-android/README.adoc[Android/GET Client] - This is a GET implementation of the client. It does not have inbuilt polling, it is intended for use by clients who do not want or need near realtime updates (which is typical of Mobile -devices - as realtime updates keep the radio on), or if you -were using server side evaluation. It uses OKHttp. To -refresh the client you simply use the "poll" function of your provider as detailed in the Core SDK. It is specific to Java 8+ -and thus supports Android 7+. -- link:client-java-sse/README.adoc[SSE Client] - This allows you to use near realtime events using the -SSE implementation in OKHttp. It is what you would use if you were not using Jersey. -- link:client-java-jersey/README.adoc[Jersey Client] - this reflects the Jersey Eventsource client for Java. It also includes -the Jersey implementation of the Google Analytics provider. It is designed for Jersey 2 and the `javax` annotations. -- link:client-java-jersey3/README.adoc[Jersey Client] - this reflects the Jersey 3 Eventsource client for Java (along with the new Jakarta APIs). It also includes the Jersey implementation of the Google Analytics provider. It is designed for Jersey 3 and the `jakarta` annotations. +You can look at the examples to see what we have done for each stack we have examples for (Quarkus, Spring 7, Jersey 2 and Jersey 3) as a starter. -If you are using Spring or Quarkus on your server, we recommend you use the `client-java-sse` library as it is the lowest -footprint and doesn't bring in another REST framework (i.e. Jersey). +The base libraries do not include dependencies on Jackson (which they need) or a logging +framework (which they also need). A basic OKHttp client will usually require: -This build working depends on featurehub being checked out as a pair directory for the time -being as the API for the Edge is documented there. +- `io.featurehub.sdk:java-client-okhttp:3+` - the basic OKHttp library + shared SDK libraries +- all of the necessary okhttp components - you can find this in `io.featurehub.sdk.composites:composite-okhttp` located in link:support/composite-okhttp/pom.xml +- a _jackson_ adapter depending on which version of Jackson (2 or 3) you are using - so `io.featurehub.sdk.common:common-jacksonv2:1+` or `io.featurehub.sdk.common:common-jacksonv3:1+`. +- an SLF4j implementation - we use `io.featurehub.sdk.composites:sdk-composite-logging:1+` ( link:support/composite-logging/pom.xml ) in this SDK but it is only the API that is a required dependency and we expect you to provide one. + +Jersey 2 and Jersey 3 have equivalent dependencies in `io.featurehub.sdk:java-client-jersey2:3+` and `io.featurehub.sdk:java-client-jersey3:3+` respectively. We also do not include the foundation libraries for these +in our dependencies as we assume you have them in your stack already, which is why you are choosing those +implementations. + +We generally recommend using OKHttp if you do not already have Jersey. + +== Initializing your client + +It is expected that you will first create a FeatureHub config instance. + +[source,java] +---- +import io.featurehub.client.EdgeFeatureHubConfig; + +// typically you would get these from environment variables +String edgeUrl = "http://localhost:8085/"; +String apiKey = "71ed3c04-122b-4312-9ea8-06b2b8d6ceac/fsTmCrcZZoGyl56kPHxfKAkbHrJ7xZMKO3dlBiab5IqUXjgKvqpjxYdI8zdXiJqYCpv92Jrki0jY5taE"; + +FeatureHubConfig fhConfig = new EdgeFeatureHubConfig(edgeUrl, apiKey); +---- + +== Choosing your client type + +There are 3 ways to request for feature updates via this SDK: + +- *Server Sent Events* - these are near realtime events, so the events get pushed to you. The connection to the server lasts usually 1-3 minutes (it can be longer +depending on how your admin has it configured), and the SDK will then disconnect +and reconnect again, ensuring it has received all feature updates in the meantime. This is typically the mode used by Java server based projects. You specify this in code by choosing `fhConfig.streaming().init()`. + +- *Passive REST* - This is where a polling interval is set. There is an initial request for feature state, but until a feature is evaluated and that polling interval has been exceeded, the client will not ask for a fresh set of features or check if any have changed. This is a good choice where there is a low incidence of feature updates, but is usually used on mobile devices (like Android) where you don't want continuous polling if the user isn't doing anything.You specify this in code by choosing `fhConfig.restPassive().init()`. + +- *Active REST* - This is where the client will make a request for updated state every X seconds regardless if anyone is using it. You specify this in code by choosing `fhConfig.restActive().init()`. + +If you are using *Server Evaluated* keys, you do not want to call `init()`. You need to create your first +`ClientContext` (see below) and call `build()` - which will trigger a connection, passing all the requisite +data to the FeatureHub server for evaluation. == Examples -The examples are shifting from the `featurehub-examples` folder into this Java repository. They currently consist of: +Its always good to look at examples on how to do what you want. We have examples for: + +- Spring 7 - using a simple streaming client +- Quarkus, Jersey 2, Jersey 3 - configurable for usage with OpenTelemetry and Segment and offering Streaming, and active or passive REST. + +These are in the `examples` and `v17-and-above/examples` folders. + +== How FeatureHub's Java SDK works + +Every FeatureHub SDK works the same basic way - it needs the URL of your FeatureHub server, and an API key. + +You give those two things to the `FeatureHubConfig` (in Java, its the `EdgeFeatureHubConfig`), then specify +your client type (see above, SSE, Active or Passive REST) and then initialize. + +The SDK takes the responsibility of getting the features from the server, keeping a local copy of them in memory, +and then responding to your requests for feature evaluations. + +Feature evaluations are always done within the scope of a `ClientContext` - which is just a bag of attributes +(a map) you want to keep track of about the current user, request, etc, so that you can use targeting in your feature +evaluation (called strategies). Where those strategies are evaluated depends on the type of key you are using. + +If you use a client evaluated key - as is normal for Java apps - all of the necessary data for decision making +comes to the Java app and it makes decisions there. This is most idealy for any kind of situation where there +will ever be more than one instance of a ClientContext - like a web server for instance. + +If you use a server evaluated key, all those attributes get sent to the server and it evaluates the feature values +and returns them to you. + +If you have confidential information in your features and your client is not confidential, you should use a +server evaluated key, otherwise you should generally use a client evaluated key. + +=== When is it ready to be used? + +Once your SDK has the list of features, it will go into the Ready state, and won't go out again even if it loses the connection or ability to talk to your server. + +We then recommend you consider adding FeatureHub to your heartbeat or liveness check. + +.SpringBoot - liveness +[source,java] +---- + @RequestMapping("/liveness") + public String liveness() { + if (featureHubConfig.getReadyness() == Readyness.Ready) { + return "yes"; + } + + log.warn("FeatureHub connection not yet available, reporting not live."); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); +} +---- + +This will prevent most services like Application Load Balancers or Kubernetes from routing traffic to your +server before it has connected to the feature service and is ready. + +There are other ways to do this - for example not starting your server until you have a readyness success, +but this is the most strongly recommended because it ensures that a system in a properly structured Java service will behave as expected. + +=== The ClientContext + +The next thing you would normally do is to ensure that the `ClientContext` is ready and set up for downstream +systems to get a hold of and use. In Java this is normally done by using a `filter` and providing some +kind of _request level scope_ - a Request Level injectable object. + +In our examples, we simply put the Authorization header into the UserKey of the context, allowing you to just pass the +name of the user to keep it simple. You can see each platform's example to see how this is done in +alternative ways. + +.SpringBoot - creating and using the fhClient +[source,java] +---- +@Configuration +public class UserConfiguration { + @Bean + @Scope("request") + ClientContext createClient(FeatureHubConfig fhConfig, HttpServletRequest request) { + ClientContext fhClient = fhConfig.newContext(); + + if (request.getHeader("Authorization") != null) { + // you would always authenticate some other way, this is just an example + fhClient.userKey(request.getHeader("Authorization")); + } + + return fhClient; + } +} + +@RestController +public class HelloResource { + ... + + @RequestMapping("/") + public String index() { + ClientContext fhClient = clientProvider.get(); + return "Hello World " + fhClient.feature("SUBMIT_COLOR_BUTTON").getString(); + } +} +---- + + +These examples show us how we can wire the FeatureHub functionality into our system in two different cases, the standard CDI +(with extensions) way that Quarkus (and to a degree Jersey) works, and the way that Spring/SpringBoot works. + +**Server side evaluation** + +In the server side evaluation (e.g. an Android Mobile app or a Batch application), the context is created once as you evaluate one user per client. +This config is likely loaded into resources that are baked into your Mobile image and once you load them, you can progress +from there. + +You should not use Server Sent Events for Mobile as they attempt to keep the radio on and will drain battery. For Mobile we recommend `restPassive()` as the mode chosen for this reason. It will only poll if the poll +timeout has occurred and a user is evaluating a feature. + +As such, it is recommended that you create your `ClientContext` as early as sensible and build it. This will trigger +a poll to the server and it will get the feature statuses and you will be ready to go. Each time you need an update, +you can simply .build() your context again and it will force a poll. + +---- +ClientContext fhClient = fhConfig.newContext().build().get(); +---- + +== Rollout Strategies + +Starting from version 1.1.0 FeatureHub supports _server side_ evaluation of complex rollout strategies +that are applied to individual feature values in a specific environment. This includes support of preset rules, e.g. per **_user key_**, **_country_**, **_device type_**, **_platform type_** as well as **_percentage splits_** rules and custom rules that you can create according to your application needs. + +For more details on rollout strategies, targeting rules and feature experiments see the https://docs.featurehub.io/#_rollout_strategies_and_targeting_rules[core documentation]. -- link:examples/todo-java/README.adoc[`todo-java`] - this is a Jersey 3 server that will use any of the Android, Jersey 3 or OKHttp SSE clients depending on configuration. +We are actively working on supporting client side evaluation of +strategies in the future releases as this scales better when you have 10000+ consumers. + +=== Coding for Rollout strategies +There are several preset strategies rules we track specifically: `user key`, `country`, `device` and `platform`. However, if those do not satisfy your requirements you also have an ability to attach a custom rule. Custom rules can be created as following types: `string`, `number`, `boolean`, `date`, `date-time`, `semantic-version`, `ip-address` + +FeatureHub SDK will match your users according to those rules, so you need to provide attributes to match on in the SDK: + +**Sending preset attributes:** + +Provide the following attribute to support `userKey` rule: + +[source,java] +---- +fhClient.userKey("ideally-unique-id"); +---- + +to support `country` rule: + +[source,java] +---- +fhClient.country(StrategyAttributeCountryName.NewZealand); +---- + +to support `device` rule: + +[source,java] +---- +fhClient.device(StrategyAttributeDeviceName.Browser); +---- + +to support `platform` rule: + +[source,java] +---- +fhClient.platform(StrategyAttributePlatformName.Android); +---- + +to support `semantic-version` rule: + +[source,java] +---- +fhClient.version("1.2.0"); +---- + +or if you are using multiple rules, you can combine attributes as follows: + +[source,java] +---- +fhClient.userKey("ideally-unique-id") + .country(StrategyAttributeCountryName.NewZealand) + .device(StrategyAttributeDeviceName.Browser) + .platform(StrategyAttributePlatformName.Android) + .version("1.2.0"); +---- + +If you are using *Server Evaluated API Keys* then you should always run `.build()` which will execute a background +poll. If you wish to ensure the next line of code has the upated statuses, wait for the future to complete with `.get()` + +.Server Evaluated API Key - ensuring the repository is updated +[source,java] +---- + ClientContext fhClient = fhConfig.newContext().userKey("user@mailinator.com").build.get(); +---- + +You do not have to do the build().get() (but you can) for client evaluated keys as the context is mutable and changes are immediate. +As the context is evaluated locally, it will always be ready the very next line of code. + +**Sending custom attributes:** + +To add a custom key/value pair, use `attr(key, value)` + +[source,java] +---- + fhClient.attr("first-language", "russian"); +---- + +Or with array of values (only applicable to custom rules): + +[source,java] +---- +fhClient.attrs(“languages”, Arrays.asList(“russian”, “english”, “german”)); +---- + +You can also use `fhClient.clear()` to empty your context. + +Remember, for *Server Evaluated Keys* you must always call `.build()` to trigger a request to update the feature values +based on the context changes. + +**Coding for percentage splits:** +For percentage rollout you are only required to provide the `userKey` or `sessionKey`. + +[source,java] +---- +fhClient.userKey("ideally-unique-id"); +---- +or + +[source,java] +---- +fhClient.sessionKey("session-id"); +---- + +For more details on percentage splits and feature experiments see https://docs.featurehub.io/#_percentage_split_rule[Percentage Split Rule]. + +== Controlling connectivity and retries + +New in this version is also considerable control over server connection connectivity. The values can be +set using environment variables or system properties. + +- `featurehub.edge.server-connect-timeout-ms` - defaults to 5000 +- `featurehub.edge.server-sse-read-timeout-ms` - defaults to 1800000 - 3m (180 seconds), should be higher if the server is configured for longer by default +- `featurehub.edge.server-rest-read-timeout-ms` - defaults to 150000 - 15s - should be very fast for a REST request as its a connect, read and disconnect process +- `featurehub.edge.server-disconnect-retry-ms` - defaults to 0 - immediately try and reconnect if disconnected +- `featurehub.edge.server-by-reconnect-ms` - defaults to 0 - if the SSE server disconnects using a "bye", how long to wait before reconnecting +- `featurehub.edge.backoff-multiplier` - defaults to 10 +- `featurehub.edge.maximum-backoff-ms` - defaults to 30000 + +This will not be affected by API keys not existing, that will stop connectivity completely. Also, if you are using +the SaaS version and you have exceeded your maximum connects that you have specified, it will also stop after +the first success. + +== Feature Interceptors + +Feature Interceptors are the ability to intercept the request for a feature. They only operate in imperative state. For +an overview check out the https://docs.featurehub.io/#_feature_interceptors[Documentation on them]. + +We currently support two feature interceptors: + +- `io.featurehub.client.interceptor.SystemPropertyValueInterceptor` - link:core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java this will read properties from system properties and if they match the name of a key (case significant) then they will return that value. You need to have specified a system property `featurehub.features.allow-override=true`. If you set a system property `feature-toggles.FEATURE_NAME` then you can override the value of what the value +is for feature flags. This is a further convenience feature and can be useful for an individual developer +working on a new feature, where it is off for everyone else but not for them. + +== Usage Adapters + +A new feature in version 3 of the Core SDK is Usage Adapters. They allow you to hook into the event when +features are being evaluated and capture the result of the evaluation and all ancillary data that is in the +ClientContext. A couple of samples are provided: + + - OpenTelemetry - this will attach the feature, and its value, and any other data in the client context, to either the span as attributes or as an event. This is controlled by the environment variable `FEATUREHUB_OTEL_SPAN_AS_EVENTS`. see link:usage-adapters/featurehub-opentelemetry-adapter/README.adoc for more details on how it works. +- Segment - this integrates with Twilo's Segment tool to push evaluation analytics to that platform. As features are evaluated they are pushed to Segment - there are more detailed instructions in link:usage-adapters/featurehub-segment-adapter/README.adoc + +It is easy to write your own for whatever platform you wish to send evaluation data to, as long as it has an +API you can support. + +== Hacking the SDK + +The SDK gives convenience methods for usage, but you can make it do almost anything you like. + +If you want to specify and deliberately configure it, you can use: + +[source,java] +---- +fhConfig.setEdgeService(() => new EdgeProvider...()); +---- + +Where EdgeProvider is the name of your class that knows how to connect to the Edge and pull feature details. + +There is an example in `examples/migration-check` which does a REST connection initially, and when it has +the features, it will update the repository, allowing the features to be evaluated correctly, but stop the +REST connection and swap to SSE. == Working with the repository -First thing you need to do is run the setup.sh - so it will load the `tiles` into your local repository. -Tiles are a Maven extension that lets you side-load and componentize plugin configuration. +Run `build_only.sh` (or look at it and run the same commands) to install it +on your own system. Once installed you should be able to load it into your IDE. -We use Tiles and Composites (collections of dependencies) to avoid repeating build configuration, each -artifact should include only the things that make it different. +The Java 11 libraries are kept separate from the Java 17+ versions as they +build separately in CI. You can load the link:pom.xml and link:v17-and-above/pom.xml into your IDE separately. === Modules @@ -53,34 +368,8 @@ Java 8 is used in the entire repository, consists of various artifacts: - `core-java-api` - this is a local build of the SSE API from the main FeatureHub repository and will generally track that. It can be behind, but it will never be a breaking change. -- `client-java-core` - holds the basic local cache that is filled in different ways by different clients. It +- `client-java-core` - It holds the definition of all of the core functionality, including the feature repository, its features, listeners, -analytics capability and so forth. It does not connect to the outside world in any way, that is specific to -the HTTP library you have chosen to use. -- `client-java-android` - this is a Java 8 client suitable for Android SDK 24 (Android 7.0) and onwards. It -includes OKHttp 4 and provides a GET only API. It does not poll as that doesn't really make sense in a Mobile -application. It can handle server side or client side evaluation keys equally well. -- `client-java-jersey` - this is a Java 8 Jersey 2.x client for Java, use this if you use Jersey. It has an -SSE client and is capable of both server side and client side evaluation keys. However, it is not recommended you -us server evaluation keys for SSE and they perform badly for SSE in Jersey. -- `client-java-jersey3` - As above, but for Jersey 3.x clients - using Jakarta Java EE 8+ -- `client-java-sse` - This uses OKHttp4 to provide a SSE only client for Java. - -==== The support libraries - -In the support folder are the build tiles (common plugins used for building) and the composites (which are groupings -of dependencies that are common and go together). - -All of these libraries are used in the _provided_ scope in our SDKs, which means you need to include our composites -or provide your own (which is more typical). This means you need to use slf4j but not which version, you need to use -jackson, but not the version we use, etc. You are free to evolve your library version choice separate to ours. - -These are: - -- `composite-jackson` - Jackson shared libraries, shared amongst all of the SDKs -- `composite-jersey2` - Client specific jersey libraries for Jersey 2 -- `composite-jersey3` - Client specific jersey libraries for Jersey 3 -- `composite-logging` - Logging implementation (using log4j2) for the SDKs - they use it in test mode only -- `composite-logging-api` - Logging API (slf4j) -- `composite-test` - Test libraries that we use (Spock, Groovy) +usage and so forth. It does not connect to the outside world in any way, that is specific to + diff --git a/core/client-java-core/README.adoc b/core/client-java-core/README.adoc deleted file mode 100644 index b2ef235..0000000 --- a/core/client-java-core/README.adoc +++ /dev/null @@ -1,559 +0,0 @@ -= Java Client SDK for FeatureHub -ifdef::env-github,env-browser[:outfilesuffix: .adoc] - -Welcome to the Java SDK implementation for https://featurehub.io[FeatureHub.io] - Open source Feature flags management, -A/B testing and remote configuration platform. - -Below explains how you can use the FeatureHub SDK in Java for Java backend applications or Android mobile -applications. - -To control the feature flags from the FeatureHub Admin console, either use our [demo](https://demo.featurehub.io) -version for evaluation or install the app using our guide [here](http://docs.featurehub.io/#_installation) - -There are 2 ways to request for feature updates via this SDK: - -- **SSE (Server Sent Events) realtime updates mechanism** - -In this mode, you will make a connection to the FeatureHub Edge server using an EventSource library which this SDK is based on, and any updates to any features will come through to you in near realtime, automatically updating the feature values in the repository. This is always the recommended method for backend applications, and -we have an implementation in Jersey. - -- **FeatureHub GET client (GET request updates)** - -In this mode, you make a GET request, which you control how often it runs. The SDK provides no timer based -repeat functionality to keep making this request. There is an implementation using OKHttp. We have -deliberately left the timer choice to you as there are many different timer functions, including one built into -the JDK (`java.util.Timer`). - -== SDK Installation - -To install the SDK, choose your method of connection. The Core library will be included transitively. The -Core library uses Java Service Loaders to automatically discover what client library you have chosen, so please -ensure you include only one. - -If you want to specify and deliberately configure it, you can use: - -[source,java] ----- -fhConfig.setEdgeService(() => new EdgeProvider...()); ----- - -Where EdgeProvider is the name of your class that knows how to connect to the Edge and pull feature details. - -- **SSE (Server Sent Events) realtime updates mechanism** - -There are three options for SSE connections - Jersey 2, Jersey 3 and OKHttp SSE. OKHttp is recommended for stacks -that do _not_ already use Jersey as their transport of choice as it is considerably smaller, and less general purpose. We recommend SSE for long lived server or batch processes. - -The dependency includes (Maven style) are below (choose *one*): - -[source,xml] -Jersey 2 ----- - - io.featurehub.sdk - java-client-jersey - [2.1,3) - ----- - -[source,xml] -Jersey 3 ----- - - io.featurehub.sdk - java-client-jersey3 - [2.1,3) - ----- - -[source,xml] -OKHttp SSE ----- - - io.featurehub.sdk - java-client-sse - [1.2,2) - ----- - -If you do not already use Jersey in your code base, you should also include our runtime dependencies for Jersey -and Jackson. - -[source,xml] -set of jersey2 dependencies ----- - - io.featurehub.sdk.composites - sdk-composite-jersey2 - [1.1, 2) - ----- - -- **FeatureHub polling client (GET request updates)** - -This is recommended for Mobile as it will only request updates when you ask for them and not keep the radio on. We also recommend them for short batch jobs, Functions as a Service (such as Knative, Cloud Functions, Lamba or -Azure Cloud Functions or similar frameworks) where you can ensure you get the features up front and then carry on. - -[source,xml] ----- - - io.featurehub.sdk - java-client-android - [2.1,3) - ----- - - -## Quick start - -### Connecting to FeatureHub -There are 3 steps to connecting: - -1) Copy FeatureHub API Key from the FeatureHub Admin Console - -2) Create FeatureHub config - -3) Check FeatureHub Repository readyness and request feature state - -#### 1. Copy API Key from the FeatureHub Admin Console -Find and copy your API Key from the FeatureHub Admin Console on the Service Accounts Keys page - -you will use this in your code to configure feature updates for your environments. -It should look similar to this: `default/71ed3c04-122b-4312-9ea8-06b2b8d6ceac/fsTmCrcZZoGyl56kPHxfKAkbHrJ7xZMKO3dlBiab5IqUXjgKvqpjxYdI8zdXiJqYCpv92Jrki0jY5taE`. - -There are two options - a Server Evaluated API Key and a Client Evaluated API Key. More on this https://docs.featurehub.io/#_client_and_server_api_keys[here] - -Client Side evaluation is intended for use in secure environments (such as microservices) -and is intended for rapid client side evaluation, per request for example. - -Server Side evaluation is more suitable when you are using an _insecure client_. (e.g. Browser or Mobile). -This also means you evaluate one user per client. - -#### 2. Create FeatureHub config: - -Create an instance of `EdgeFeatureHubConfig`. You need to provide the API Key and the URL of the end-point you will be connecting to (the Edge server URL). - -[source,java] ----- -import io.featurehub.client.EdgeFeatureHubConfig; - -// typically you would get these from environment variables -String edgeUrl = "http://localhost:8085/"; -String apiKey = "71ed3c04-122b-4312-9ea8-06b2b8d6ceac/fsTmCrcZZoGyl56kPHxfKAkbHrJ7xZMKO3dlBiab5IqUXjgKvqpjxYdI8zdXiJqYCpv92Jrki0jY5taE"; - -FeatureHubConfig fhConfig = new EdgeFeatureHubConfig(edgeUrl, apiKey); ----- - -#### 3. Check FeatureHub Repository readyness and request feature state - -Feature flag rollout strategies and user targeting are all determined by the active _user context_. If you are not intending to use rollout strategies, you can pass empty context to the SDK. - -**Client Side evaluation** - -What you do next depends on your framework. In many modern frameworks, you don't get to choose when -the server starts, it starts and you just have deal with it. It is recommended that you ensure that your heartbeat -or readyness check is dependent on whether the feature service is connected. - -Remember client side evaluation is used for services, those processing requests (from users or via eventing systems) -or batch processing for example. As such, they are typically wired up using Dependency Injection (DI) frameworks and -we show that approach here as it is what people are most likely to use. - -As you would typically have a dependency injection system (like Spring or CDI) looking after you, you need to inject the -FeatureHubConfig you created above. Our SpringBoot, pure Jersey and Quarkus examples can be found in our -https://github.com/featurehub-io/featurehub-examples[featurehub-examples] repository. - -.SpringBoot - wiring the FeatureHubConfig -[source,java] ----- - @Bean // using environment variables - public FeatureHubConfig featureHubConfig() { - String host = System.getenv("FEATUREHUB_EDGE_URL"); - String apiKey = System.getenv("FEATUREHUB_API_KEY"); - FeatureHubConfig config = new EdgeFeatureHubConfig(host, apiKey); - config.init(); - - return config; - } ----- - -.Quarkus/CDI - wiring the FeatureHubConfig -[source,java] ----- -/** - * We do this at the top level because we need a Produces for the FeatureHub config as we - * specifically want this bean and not have to delegate through, and we need the external config. - */ -@Startup -@ApplicationScoped -public class FeatureSource { - private static final Logger log = LoggerFactory.getLogger(FeatureSource.class); - - @ConfigProperty(name = "feature-hub.url") - String url; - - @ConfigProperty(name = "feature-hub.api-key") - String apiKey; - - /** - * We need a FeatureHubConfig bean available for all sundry uses, the health check and any other - * incoming calls. So we create it at startup and seed it into the CDI Context. - * - * @return FeatureHubConfig - the config ready for use. - */ - @Startup - @Produces - @ApplicationScoped - public FeatureHubConfig fhConfig() { - final EdgeFeatureHubConfig config = new EdgeFeatureHubConfig(url, apiKey); - config.init(); - log.info("FeatureHub started"); - return config; - } -} ----- - -We then recommend you consider adding FeatureHub to your heartbeat or liveness check. - -.SpringBoot - liveness -[source,java] ----- -@RestController -@RequestMapping("/health") -public class HealthResource { - private final FeatureHubConfig featureHubConfig; - private static final Logger log = LoggerFactory.getLogger(HealthResource.class); - - @Inject - public HealthResource(FeatureHubConfig featureHubConfig) { - this.featureHubConfig = featureHubConfig; - } - - @RequestMapping("/liveness") - public String liveness() { - if (featureHubConfig.getReadyness() == Readyness.Ready) { - return "yes"; - } - - log.warn("FeatureHub connection not yet available, reporting not live."); - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); - } -} ----- - -.Quarkus/CDI - liveness -[source,java] ----- -@Path("/health/liveness") -public class HealthResource { - private final FeatureHubConfig config; - - @Inject - public HealthResource(FeatureHubConfig config) { - this.config = config; - } - - @GET - public Response liveness() { - if (config.getReadyness() == Readyness.Ready) { - return Response.ok().build(); - } - - return Response.status(503).build(); - } -} ----- - -This will prevent most services like Application Load Balancers or Kubernetes from routing traffic to your -server before it has connected to the feature service and is ready. - -There are other ways to do this - for example not starting your server until you have a readyness success, -but this is the most strongly recommended because it ensures that a system in a properly structured Java service will behave as expected. - -The next thing you would normally do is to ensure that the `ClientContext` is ready and set up for downstream -systems to get a hold of and use. In Java this is normally done by using a `filter` and providing some -kind of _request level scope_ - a Request Level injectable object. - -In our examples, we simply put the Authorization header into the UserKey of the context, allowing you to just pass the -name of the user to keep it simple. - -.SpringBoot - creating and using the fhClient -[source,java] ----- -@Configuration -public class UserConfiguration { - @Bean - @Scope("request") - ClientContext createClient(FeatureHubConfig fhConfig, HttpServletRequest request) { - ClientContext fhClient = fhConfig.newContext(); - - if (request.getHeader("Authorization") != null) { - // you would always authenticate some other way, this is just an example - fhClient.userKey(request.getHeader("Authorization")); - } - - return fhClient; - } -} - -@RestController -public class HelloResource { - private final Provider clientProvider; - - @Inject - public HelloResource(Provider clientProvider) { - this.clientProvider = clientProvider; - } - - @RequestMapping("/") - public String index() { - ClientContext fhClient = clientProvider.get(); - return "Hello World " + fhClient.feature("SUBMIT_COLOR_BUTTON").getString(); - } -} ----- - -.Quarkus/CDI - creating and using the fhClient -[source,java] ----- - /** - * This lets us create the ClientContext, which will always be empty, or the AuthFilter will add the user if it - * discovers it. (This is part of the FeatureSource class from above) - * - * @param config - the FeatureHub Config - * @return - a blank client context usable by any resource. - */ - @Produces - @RequestScoped - public ClientContext createClient(FeatureHubConfig config) { - try { - return config.newContext().build().get(); - } catch (Exception e) { - log.error("Cannot create context!", e); - throw new RuntimeException(e); - } - } - -/** - * This filter checks if there is an Authorization header and if so, will add it to the user context - * (which is mutable) allowing downstream resources to correctly calculate their features. - * - */ -@Provider -@PreMatching -public class AuthFilter implements ContainerRequestFilter { - private static final Logger log = LoggerFactory.getLogger(AuthFilter.class); - - @Inject - javax.inject.Provider clientProvider; - - @Override - public void filter(ContainerRequestContext req) { - if (req.getHeaders().containsKey("Authorization")) { - String user = req.getHeaderString("Authorization"); - - try { - clientProvider.get().userKey(user).build().get(); - } catch (Exception e) { - log.error("Unable to set user key on user"); - } - } - } -} - -@Path("/") -public class HelloResource { - private final Provider clientProvider; - - @Inject - public HelloResource(Provider clientProvider) { - this.clientProvider = clientProvider; - } - - - @GET - @Produces(MediaType.TEXT_PLAIN) - public String hello() { - return "hello world! " + contextProvider.get().feature("SUBMIT_COLOR_BUTTON").getString(); - } -} ----- - -These examples show us how we can wire the FeatureHub functionality into our system in two different cases, the standard CDI -(with extensions) way that Quarkus (and to a degree Jersey) works, and the way that Spring/SpringBoot works. - -**Server side evaluation** - -In the server side evaluation (e.g. an Android Mobile app), the context is created once as you evaluate one user per client. -This config is likely loaded into resources that are baked into your Mobile image and once you load them, you can progress -from there. - -You should not use Server Sent Events for Mobile as they attempt to keep the radio on and will drain battery. Use the -`java-client-android` artifact and this will be automatically used for you. - -As such, it is recommended that you create your `ClientContext` as early as sensible and build it. This will trigger -a poll to the server and it will get the feature statuses and you will be ready to go. Each time you need an update, -you can simply .build() your context again and it will force a poll. - ----- -ClientContext fhClient = fhConfig.newContext().build().get(); ----- - -==== Local Feature Overrides - -If you set a system property `feature-toggles.FEATURE_NAME` then you can override the value of what the value -is for feature flags. This is a further convenience feature and can be useful for an individual developer -working on a new feature, where it is off for everyone else but not for them. - - -== Analytics - -The Analytics client layer currently only supports directly exporting data to -https://docs.featurehub.io/#_google_analytics_integration[Google Analytics]. It has the capability to add further -adapters but this is not our medium term strategy to do it this way. - -To configure it, you need three things: - -- a Google analytics key - usually in the form UA- -- [optional] a CID - a customer id this is associate with this. We recommend you set on for the server -and override it if you know what you are tracking against for the individual request. -- a client implementation. We provide one for Jersey currently. - -[source,java] ----- -fhConfig.addAnalyticCollector(new GoogleAnalyticsCollector(analyticsKey, analyticsCid, new GoogleAnalyticsJerseyApiClient())); ----- - -When you wish to lodge an event, simply call `logAnalyticsEvent` on the featurehub repository instance. You can -simply pass the event, or you can pass the event plus some extra data, including the overridden CID and a `gaValue` -for the value field in Google Analytics. - -== Rollout Strategies - -Starting from version 1.1.0 FeatureHub supports _server side_ evaluation of complex rollout strategies -that are applied to individual feature values in a specific environment. This includes support of preset rules, e.g. per **_user key_**, **_country_**, **_device type_**, **_platform type_** as well as **_percentage splits_** rules and custom rules that you can create according to your application needs. - -For more details on rollout strategies, targeting rules and feature experiments see the https://docs.featurehub.io/#_rollout_strategies_and_targeting_rules[core documentation]. - -We are actively working on supporting client side evaluation of -strategies in the future releases as this scales better when you have 10000+ consumers. - -=== Coding for Rollout strategies -There are several preset strategies rules we track specifically: `user key`, `country`, `device` and `platform`. However, if those do not satisfy your requirements you also have an ability to attach a custom rule. Custom rules can be created as following types: `string`, `number`, `boolean`, `date`, `date-time`, `semantic-version`, `ip-address` - -FeatureHub SDK will match your users according to those rules, so you need to provide attributes to match on in the SDK: - -**Sending preset attributes:** - -Provide the following attribute to support `userKey` rule: - -[source,java] ----- -fhClient.userKey("ideally-unique-id"); ----- - - -to support `country` rule: - -[source,java] ----- -fhClient.country(StrategyAttributeCountryName.NewZealand); ----- - -to support `device` rule: - -[source,java] ----- -fhClient.device(StrategyAttributeDeviceName.Browser); ----- - -to support `platform` rule: - -[source,java] ----- -fhClient.platform(StrategyAttributePlatformName.Android); ----- - -to support `semantic-version` rule: - -[source,java] ----- -fhClient.version("1.2.0"); ----- - -or if you are using multiple rules, you can combine attributes as follows: - -[source,java] ----- -fhClient.userKey("ideally-unique-id") - .country(StrategyAttributeCountryName.NewZealand) - .device(StrategyAttributeDeviceName.Browser) - .platform(StrategyAttributePlatformName.Android) - .version("1.2.0"); ----- - -If you are using *Server Evaluated API Keys* then you should always run `.build()` which will execute a background -poll. If you wish to ensure the next line of code has the upated statuses, wait for the future to complete with `.get()` - -.Server Evaluated API Key - ensuring the repository is updated -[source,java] ----- - ClientContext fhClient = fhConfig.newContext().userKey("user@mailinator.com").build.get(); ----- - -You do not have to do the build().get() (but you can) for client evaluated keys as the context is mutable and changes are immediate. -As the context is evaluated locally, it will always be ready the very next line of code. - -**Sending custom attributes:** - -To add a custom key/value pair, use `attr(key, value)` - -[source,java] ----- - fhClient.attr("first-language", "russian"); ----- - -Or with array of values (only applicable to custom rules): - -[source,java] ----- -fhClient.attrs(“languages”, Arrays.asList(“russian”, “english”, “german”)); ----- - -You can also use `fhClient.clear()` to empty your context. - -Remember, for *Server Evaluated Keys* you must always call `.build()` to trigger a request to update the feature values -based on the context changes. - -**Coding for percentage splits:** -For percentage rollout you are only required to provide the `userKey` or `sessionKey`. - -[source,java] ----- -fhClient.userKey("ideally-unique-id"); ----- -or - -[source,java] ----- -fhClient.sessionKey("session-id"); ----- - -For more details on percentage splits and feature experiments see https://docs.featurehub.io/#_percentage_split_rule[Percentage Split Rule]. - -== Feature Interceptors - -Feature Interceptors are the ability to intercept the request for a feature. They only operate in imperative state. For -an overview check out the https://docs.featurehub.io/#_feature_interceptors[Documentation on them]. - -We currently support two feature interceptors: - -- `io.featurehub.client.interceptor.SystemPropertyValueInterceptor` - this will read properties from system properties -and if they match the name of a key (case significant) then they will return that value. You need to have specified a -system property `featurehub.features.allow-override=true` - -We have removed support for OpenTracing. - -=== Maintenance - -Please note the `io.featurehub.strategies` package is mirrored from the main repository and is not maintained here. PRs -for it should go to the main FeatureHub repository. - diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java index cdd5902..25850f5 100644 --- a/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java @@ -50,9 +50,9 @@ public FeatureHubSource() { // Do this if you wish to force the connection to stay open. if (client.equals("sse")) { config.streaming(); - } else if (client.equals("rest")) { + } else if (client.equals("rest") || client.equals("rest-passive")) { config.restPassive(pollInterval); - } else if (client.equals("rest-poll")) { + } else if (client.equals("rest-poll") || client.equals("rest-active")) { config.restActive(pollInterval); } else { throw new RuntimeException("Unknown featurehub client"); diff --git a/unmaintained/README.adoc b/unmaintained/README.adoc new file mode 100644 index 0000000..adb5233 --- /dev/null +++ b/unmaintained/README.adoc @@ -0,0 +1,3 @@ += Unmaintained + +This is old code we aren't maintaining any longer or releasing new versions of. diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java index 2cf0502..e6b89d5 100644 --- a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java @@ -37,7 +37,7 @@ public class FeatureHubSource { @ConfigProperty(name = "segment.write-key") Optional segmentWriteKey; @ConfigProperty(name = "feature-service.client", defaultValue = "sse") - String client; // sse, rest, rest-poll + String client; // sse, rest-active, rest-passive @ConfigProperty(name = "feature-service.opentelemetry.enabled", defaultValue = "false") Boolean openTelemetryEnabled; @ConfigProperty(name = "feature-service.poll-interval-seconds", defaultValue = "1") @@ -77,9 +77,9 @@ public FeatureHubConfig getConfig() { // Do this if you wish to force the connection to stay open. if (client.equals("sse")) { config.streaming(); - } else if (client.equals("rest")) { + } else if (client.equals("rest-passive")) { config.restPassive(pollInterval); - } else if (client.equals("rest-poll")) { + } else if (client.equals("rest-active")) { config.restActive(pollInterval); } else { throw new RuntimeException("Unknown featurehub client"); diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java index 2d16bf2..b61956d 100644 --- a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java @@ -18,7 +18,6 @@ public static void main(String[] args) { SpringApplication.run(Application.class, args); } - @Bean public FeatureHubConfig featureHubConfig() { FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey);