diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml
index c5b0a02..3e69ba1 100644
--- a/.github/workflows/java.yaml
+++ b/.github/workflows/java.yaml
@@ -3,16 +3,35 @@ name: Java CI
on: [push]
jobs:
- build:
+ build-java11:
runs-on: ubuntu-latest
-
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v5
- name: Set up JDK 11
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v5
with:
java-version: '11'
distribution: 'temurin'
cache: maven
- - name: Build with Maven
- run: sh setup.sh && mvn --batch-mode --quiet package
+ - name: Install tiles
+ run: cd support && mvn -f pom-tiles.xml install
+ - name: Install support composites
+ 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: All other things
+ run: mvn install
+ - name: java17+ only
+ working-directory: v17-and-above
+ run: mvn install
+
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/README.adoc b/README.adoc
index 3a33ebc..61fe73e 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,44 +1,366 @@
-= Java Libraries
+= FeatureHub SDK for Java (v3)
+ifdef::env-github,env-browser[:outfilesuffix: .adoc]
-This is the set of libraries currently supporting the Java programming language and its JDK based cousins. It currently consists
-of two libraries:
+Welcome to the Java SDK implementation for https://featurehub.io[FeatureHub.io] - Open source Feature flags management,
+A/B testing and remote configuration platform.
-- link:client-java-core/README.adoc[Core] - this reflects the core repository and all listeners that notify about feature changes.
+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).
-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.
+The minimum version of Java supported is Java 11. Some libraries support only Java 17+ because of their 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.
+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.
-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).
+NOTE: We are using Gradle standard for referring to version ranges, so `3+` means
+`[3,4)` in Maven.
-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.
+== Setting up your dependencies
+
+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.
+
+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:
+
+- `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.
-- 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.
+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].
+
+== 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
@@ -46,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/build_alL_and_test.sh b/build_alL_and_test.sh
new file mode 100755
index 0000000..f646bcd
--- /dev/null
+++ b/build_alL_and_test.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -x
+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/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 74%
rename from client-java-jersey/pom.xml
rename to client-implementations/java-client-jersey2/pom.xml
index f026330..87158d4 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
- 2.8-SNAPSHOT
- java-client-jersey
+ java-client-jersey2
+ 3.1-SNAPSHOT
+ java-client-jersey2
Jersey client for featurehub
@@ -44,7 +44,7 @@
- 2.28
+ 2.36
@@ -52,13 +52,13 @@
cd.connect.openapi.gensupport
openapi-generator-support
- 1.4
+ 1.5
io.featurehub.sdk
java-client-core
- [3, 4)
+ [4, 5)
@@ -68,54 +68,57 @@
[1.1, 2)
+
+ io.featurehub.sdk.common
+ common-jacksonv2
+ [1, 2]
+
+
io.featurehub.sdk.composites
sdk-composite-test
[1.1, 2)
test
+
+
+ io.featurehub.sdk.common
+ common-jacksonv2
+ [1.1-SNAPSHOT, 2]
+ test
+
+
+
+ org.glassfish.jersey.test-framework
+ jersey-test-framework-core
+ ${jersey.version}
+ test
+
+
+ org.glassfish.jersey.inject
+ jersey-hk2
+ ${jersey.version}
+ test
+
+
+ org.glassfish.jersey.test-framework.providers
+ jersey-test-framework-provider-grizzly2
+ ${jersey.version}
+ test
+
-
-
- 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 +132,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
@@ -165,12 +168,12 @@
io.repaint.maven
tiles-maven-plugin
- 2.23
+ 2.32
true
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-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureService.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureService.java
new file mode 100644
index 0000000..ab01c19
--- /dev/null
+++ b/client-implementations/java-client-jersey2/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-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java
new file mode 100644
index 0000000..f677c72
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java
@@ -0,0 +1,94 @@
+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.model.FeatureEnvironmentCollection;
+import io.featurehub.sse.model.FeatureStateUpdate;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.ws.rs.core.GenericType;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class FeatureServiceImpl implements FeatureService {
+ private final ApiClient apiClient;
+
+ public FeatureServiceImpl(ApiClient apiClient) {
+ this.apiClient = apiClient;
+ }
+
+ public @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey,
+ @Nullable String contextSha,
+ @Nullable Map extraHeaders) {
+ Object localVarPostBody = new Object();
+
+ // create path and map variables /features/
+ String localVarPath = "/features/";
+
+ // 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 = String.format("/features/%s/%s", apiKey, featureKey);
+
+ // query params
+ Map localVarHeaderParams = new HashMap();
+ Map localVarFormParams = new HashMap();
+
+ if (extraHeaders != null) {
+ localVarHeaderParams.putAll(extraHeaders);
+ }
+
+ final String[] localVarAccepts = {
+ "application/json"
+ };
+ final String localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
+
+ final String[] localVarContentTypes = {
+ "application/json"
+ };
+ final String localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
+
+ String[] localVarAuthNames = new String[]{};
+
+ GenericType localVarReturnType = new GenericType() {};
+
+ return apiClient.invokeAPI(localVarPath, "PUT", null, featureStateUpdate, localVarHeaderParams,
+ localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getStatusCode();
+ }
+}
diff --git a/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java
new file mode 100644
index 0000000..d4cb885
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java
@@ -0,0 +1,47 @@
+package io.featurehub.client.jersey;
+
+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 JerseyFeatureHubClientFactory implements FeatureHubClientFactory {
+ @Override
+ @NotNull
+ public Supplier createSSEEdge(@NotNull FeatureHubConfig config,
+ @Nullable InternalFeatureRepository repository) {
+ 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,
+ 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-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 53%
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 9b26859..4752ab5 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
@@ -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;
@@ -25,44 +25,54 @@
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;
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);
- 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<>();
+ private Consumer notify;
- public JerseySSEClient(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) {
- this.repository = repository;
+ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config,
+ @NotNull EdgeRetryService retryer) {
+ this.repository = repository == null ? config.getInternalRepository() : 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();
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());
}
- 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<>();
-
+ public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) {
if (config.isServerEvaluation() &&
(
(newHeader != null && !newHeader.equals(xFeaturehubHeader)) ||
@@ -77,14 +87,10 @@ protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) {
}
if (eventSource == null) {
- waitingClients.add(change);
-
- poll();
- } else {
- change.complete(repository.getReadyness());
+ return poll();
}
- return change;
+ return CompletableFuture.completedFuture(repository.getReadiness());
}
@Override
@@ -92,6 +98,11 @@ public boolean isClientEvaluation() {
return !config.isServerEvaluation();
}
+ @Override
+ public boolean isStopped() {
+ return retryer.isStopped();
+ }
+
@Override
public void close() {
if (eventSource != null) {
@@ -100,6 +111,8 @@ public void close() {
}
eventSource = null;
+
+ retryer.close();
}
}
@@ -108,11 +121,6 @@ public void close() {
return config;
}
- @Override
- public boolean isRequiresReplacementOnHeaderChange() {
- return true;
- }
-
protected EventInput makeEventSource() {
Invocation.Builder request = target.request();
@@ -142,7 +150,11 @@ 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);
+ }
+
@Nullable String data;
InboundEvent event;
@@ -150,30 +162,63 @@ 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;
}
- try {
- final SSEResultState state = retryer.fromValue(event.getName());
+ connectionSaidBye = processResult(connectionSaidBye, data, event);
+ }
- if (state == null) { // unknown state
- continue;
- }
+ if (retryer.isStopped()) {
+ log.trace("[featurehub] event source closed? {} interrupted? {} retryer stopped? {}", eventSource.isClosed(), interrupted, retryer.isStopped());
+ if (!eventSource.isClosed()) {
+ close();
+ }
- log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data);
+ checkForUnsatisfactoryConversation();
- if (state == SSEResultState.CONFIG) {
- retryer.edgeConfigInfo(data);
- } else {
- repository.notify(state, data);
- }
+ notifyWaitingClients();
+ }
+ }
+
+ private void checkForUnsatisfactoryConversation() {
+ // we never received a satisfactory connection
+ if (repository.getReadiness() == Readiness.NotReady) {
+ repository.notify(SSEResultState.FAILURE);
+ }
+ }
+
+ private boolean processResult(boolean connectionSaidBye, String data, InboundEvent event) {
+ 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;
+ }
+
+ if (state == SSEResultState.CONFIG) {
+ retryer.edgeConfigInfo(data);
+ } else {
+ retryer.convertSSEState(state, data, repository);
// reset the timer
if (state == SSEResultState.FEATURES) {
@@ -187,56 +232,70 @@ private void initEventSource() {
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) ) {
- waitingClients.forEach(wc -> wc.complete(repository.getReadyness()));
- }
- } catch (Exception e) {
- log.error("[featurehub-sdk] failed to decode packet {}:{}", event.getName(), data, e);
+ // 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);
}
- if (eventSource.isClosed() || interrupted) {
- close();
-
- log.trace("[featurehub-sdk] closed");
-
- // we never received a satisfactory connection
- if (repository.getReadyness() == Readyness.NotReady) {
- repository.notify(SSEResultState.FAILURE, null);
- }
+ return connectionSaidBye;
+ }
- // send this once we are actually disconnected and not before
- retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE :
- EdgeConnectionState.SERVER_WAS_DISCONNECTED, this);
- }
+ private void notifyWaitingClients() {
+ 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);
}
}
@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
+ public long currentInterval() {
+ return 0;
}
@Override
public void reconnect() {
poll();
}
+
+ public void setNotify(Consumer notify) {
+ this.notify = notify;
+ }
}
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
new file mode 100644
index 0000000..0c0217b
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/main/java/io/featurehub/client/jersey/RestClient.java
@@ -0,0 +1,320 @@
+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.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;
+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;
+ @NotNull private final EdgeRetryService edgeRetryer;
+ @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;
+ private final boolean breakCacheOnEveryCheck;
+ @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 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,
+ @NotNull EdgeRetryService edgeRetryer,
+ int stateTimeoutInSeconds, boolean breakCacheOnEveryCheck) {
+ this.edgeRetryer = edgeRetryer;
+ if (repository == null) {
+ repository = (InternalFeatureRepository) config.getRepository();
+ }
+
+ this.breakCacheOnEveryCheck = breakCacheOnEveryCheck;
+ this.repository = repository;
+ this.client = client == null ? makeClient(config) : client;
+ this.config = config;
+ this.pollingInterval = stateTimeoutInSeconds;
+
+ // ensure the poll has expired the first time we ask for it
+ whenPollingCacheExpires = System.currentTimeMillis() - 100;
+
+ this.clientSideEvaluation = !config.isServerEvaluation();
+ }
+
+ @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()));
+ }
+
+ 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 =
+ 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) {
+ 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 (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;
+ }
+ }
+
+ 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 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<>();
+
+ 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 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;
+ }
+
+ @Override
+ public boolean isClientEvaluation() {
+ return clientSideEvaluation;
+ }
+
+ @Override
+ public void close() {
+ edgeRetryer.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;
+ }
+
+ @Override
+ public long currentInterval() {
+ return pollingInterval;
+ }
+
+ public long getWhenPollingCacheExpires() {
+ return whenPollingCacheExpires;
+ }
+}
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 54%
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 0cea937..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
@@ -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,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-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 73%
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
index 9673446..3c7554b 100644
--- 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
@@ -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/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-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
new file mode 100644
index 0000000..3bf6d5b
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy
@@ -0,0 +1,194 @@
+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()
+ 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 ->
+ output = new EventOutput()
+ return output
+ })
+
+ edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().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-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
new file mode 100644
index 0000000..8d08ad5
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy
@@ -0,0 +1,164 @@
+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.client.edge.EdgeRetryService
+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
+ EdgeRetryService retryer
+
+ def setup() {
+ apiKeys = ["123"]
+ featureService = Mock()
+ repo = Mock()
+ config = Mock()
+ retryer = Mock()
+ config.isServerEvaluation() >> true
+ client = new RestClient(repo, featureService, config, retryer, 0, false)
+ }
+
+ 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, retryer, 180, false)
+ 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 * _
+ }
+
+ def "change polling interval to 180 seconds and force breaking cache on every check"() {
+ given:
+ def response = build()
+ client = new RestClient(repo, featureService, config, retryer, 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-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy
new file mode 100644
index 0000000..6fcf35d
--- /dev/null
+++ b/client-implementations/java-client-jersey2/src/test/groovy/io/featurehub/client/jersey/SSETestHarness.groovy
@@ -0,0 +1,49 @@
+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 javax.inject.Singleton
+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
+
+@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-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 80%
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
index 0a72286..e51b072 100644
--- 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
@@ -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,11 +24,11 @@ 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();
- 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");
@@ -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-sse/src/test/resources/log4j2.xml b/client-implementations/java-client-jersey2/src/test/resources/log4j2.xml
similarity index 81%
rename from client-java-sse/src/test/resources/log4j2.xml
rename to client-implementations/java-client-jersey2/src/test/resources/log4j2.xml
index 40eed7e..5b7d8e8 100644
--- a/client-java-sse/src/test/resources/log4j2.xml
+++ b/client-implementations/java-client-jersey2/src/test/resources/log4j2.xml
@@ -1,7 +1,6 @@
-
+
-
@@ -9,6 +8,7 @@
+
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 83%
rename from client-java-jersey3/pom.xml
rename to client-implementations/java-client-jersey3/pom.xml
index 58701f0..75a3528 100644
--- a/client-java-jersey3/pom.xml
+++ b/client-implementations/java-client-jersey3/pom.xml
@@ -4,7 +4,7 @@
io.featurehub.sdk
java-client-jersey3
- 1.6-SNAPSHOT
+ 3.1-SNAPSHOT
java-client-jersey3
@@ -43,12 +43,16 @@
HEAD
+
+ 3.1.2
+
+
cd.connect.openapi.gensupport
openapi-jersey3-support
- 2.1
+ 2.6
@@ -60,7 +64,13 @@
io.featurehub.sdk
java-client-core
- [3, 4)
+ [4, 5)
+
+
+
+ io.featurehub.sdk.common
+ common-jacksonv2
+ [1, 2]
@@ -69,6 +79,27 @@
[1.1, 2)
test
+
+ io.featurehub.sdk.common
+ common-jacksonv2
+ [1.1-SNAPSHOT, 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
+
+
@@ -159,12 +190,12 @@
io.repaint.maven
tiles-maven-plugin
- 2.23
+ 2.32
true
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-implementations/java-client-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
new file mode 100644
index 0000000..ab01c19
--- /dev/null
+++ b/client-implementations/java-client-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-implementations/java-client-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
new file mode 100644
index 0000000..a032d3d
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java
@@ -0,0 +1,94 @@
+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.model.FeatureEnvironmentCollection;
+import io.featurehub.sse.model.FeatureStateUpdate;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import jakarta.ws.rs.core.GenericType;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class FeatureServiceImpl implements FeatureService {
+ private final ApiClient apiClient;
+
+ public FeatureServiceImpl(ApiClient apiClient) {
+ this.apiClient = apiClient;
+ }
+
+ public @NotNull ApiResponse> getFeatureStates(@NotNull List apiKey,
+ @Nullable String contextSha,
+ @Nullable Map extraHeaders) {
+ Object localVarPostBody = new Object();
+
+ // create path and map variables /features/
+ String localVarPath = "/features/";
+
+ // 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 = String.format("/features/%s/%s", apiKey, featureKey);
+
+ // query params
+ Map localVarHeaderParams = new HashMap();
+ Map localVarFormParams = new HashMap();
+
+ if (extraHeaders != null) {
+ localVarHeaderParams.putAll(extraHeaders);
+ }
+
+ final String[] localVarAccepts = {
+ "application/json"
+ };
+ final String localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
+
+ final String[] localVarContentTypes = {
+ "application/json"
+ };
+ final String localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
+
+ String[] localVarAuthNames = new String[]{};
+
+ GenericType localVarReturnType = new GenericType() {};
+
+ return apiClient.invokeAPI(localVarPath, "PUT", null, featureStateUpdate, localVarHeaderParams,
+ localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getStatusCode();
+ }
+}
diff --git a/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java
new file mode 100644
index 0000000..d4cb885
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java
@@ -0,0 +1,47 @@
+package io.featurehub.client.jersey;
+
+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 JerseyFeatureHubClientFactory implements FeatureHubClientFactory {
+ @Override
+ @NotNull
+ public Supplier createSSEEdge(@NotNull FeatureHubConfig config,
+ @Nullable InternalFeatureRepository repository) {
+ 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,
+ 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-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
new file mode 100644
index 0000000..5648ada
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java
@@ -0,0 +1,303 @@
+package io.featurehub.client.jersey;
+
+import io.featurehub.client.EdgeService;
+import io.featurehub.client.FeatureHubConfig;
+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.SSEResultState;
+import jakarta.ws.rs.WebApplicationException;
+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 jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.client.ClientProperties;
+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.jetbrains.annotations.Nullable;
+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;
+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);
+ 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 Consumer notify;
+
+ public JerseySSEClient(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config,
+ @NotNull EdgeRetryService retryer) {
+ this.repository = repository == null ? config.getInternalRepository() : 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();
+
+ client.property(ClientProperties.CONNECT_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) {
+ return client.target(sdkUrl);
+ }
+
+ @Override
+ public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) {
+ 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.");
+
+ xFeaturehubHeader = newHeader;
+
+ close();
+ }
+
+ if (eventSource == null) {
+ return poll();
+ }
+
+ return CompletableFuture.completedFuture(repository.getReadiness());
+ }
+
+ @Override
+ public boolean isClientEvaluation() {
+ return !config.isServerEvaluation();
+ }
+
+ @Override
+ public boolean isStopped() {
+ return retryer.isStopped();
+ }
+
+ @Override
+ public void close() {
+ if (eventSource != null) {
+ if (!eventSource.isClosed()) {
+ eventSource.close();
+ }
+
+ eventSource = null;
+
+ retryer.close();
+ }
+ }
+
+ @Override
+ public @NotNull FeatureHubConfig getConfig() {
+ return config;
+ }
+
+ protected EventInput makeEventSource() {
+ Invocation.Builder request = target.request();
+
+ if (xFeaturehubHeader != null) {
+ request = request.header("x-featurehub", xFeaturehubHeader);
+ }
+
+ request = request.header("X-SDK", SdkVersion.sdkVersionHeader("Java-Jersey2"));
+
+ log.trace("[featurehub-sdk] connecting to {}", config.getRealtimeUrl());
+
+ return request.get(EventInput.class);
+ }
+
+ private void initEventSource() {
+ try {
+ eventSource = makeEventSource();
+ } catch (Exception e) {
+ onMakeEventSourceException(e);
+ return;
+ }
+
+ log.trace("[featurehub-sdk] connected to {}", config.getRealtimeUrl());
+
+ // we have connected, now what to do?
+ boolean connectionSaidBye = false;
+
+ boolean interrupted = false;
+
+ while (!eventSource.isClosed() && !retryer.isStopped() && !interrupted) {
+ if (notify != null) { // this is for testing
+ notify.accept(null);
+ }
+
+ @Nullable String data;
+ InboundEvent event;
+
+ try {
+ event = eventSource.read();
+
+ if (event == null) {
+ 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;
+ }
+
+ connectionSaidBye = processResult(connectionSaidBye, data, event);
+ }
+
+ if (retryer.isStopped()) {
+ log.trace("[featurehub] event source closed? {} interrupted? {} retryer stopped? {}", eventSource.isClosed(), interrupted, retryer.isStopped());
+ if (!eventSource.isClosed()) {
+ close();
+ }
+
+ checkForUnsatisfactoryConversation();
+
+ notifyWaitingClients();
+ }
+ }
+
+ private void checkForUnsatisfactoryConversation() {
+ // we never received a satisfactory connection
+ if (repository.getReadiness() == Readiness.NotReady) {
+ repository.notify(SSEResultState.FAILURE);
+ }
+ }
+
+ private boolean processResult(boolean connectionSaidBye, String data, InboundEvent event) {
+ 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;
+ }
+
+ 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);
+ }
+
+ if (state == SSEResultState.BYE) {
+ connectionSaidBye = true;
+ }
+
+ 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) {
+ log.info("[featurehub-sdk] failed to connect to {}", config.getRealtimeUrl());
+ 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_READ_TIMEOUT, this);
+ }
+ } else {
+ retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, this);
+ }
+ }
+
+ @Override
+ 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
+ public long currentInterval() {
+ return 0;
+ }
+
+ @Override
+ public void reconnect() {
+ poll();
+ }
+
+ public void setNotify(Consumer notify) {
+ this.notify = notify;
+ }
+}
diff --git a/client-implementations/java-client-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
new file mode 100644
index 0000000..3cfb2f7
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/main/java/io/featurehub/client/jersey/RestClient.java
@@ -0,0 +1,320 @@
+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.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;
+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;
+ @NotNull private final EdgeRetryService edgeRetryer;
+ @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;
+ private final boolean breakCacheOnEveryCheck;
+ @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 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,
+ @NotNull EdgeRetryService edgeRetryer,
+ int stateTimeoutInSeconds, boolean breakCacheOnEveryCheck) {
+ this.edgeRetryer = edgeRetryer;
+ if (repository == null) {
+ repository = (InternalFeatureRepository) config.getRepository();
+ }
+
+ this.breakCacheOnEveryCheck = breakCacheOnEveryCheck;
+ this.repository = repository;
+ this.client = client == null ? makeClient(config) : client;
+ this.config = config;
+ this.pollingInterval = stateTimeoutInSeconds;
+
+ // ensure the poll has expired the first time we ask for it
+ whenPollingCacheExpires = System.currentTimeMillis() - 100;
+
+ this.clientSideEvaluation = !config.isServerEvaluation();
+ }
+
+ @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()));
+ }
+
+ 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 =
+ 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) {
+ 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 (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;
+ }
+ }
+
+ 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 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<>();
+
+ 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 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;
+ }
+
+ @Override
+ public boolean isClientEvaluation() {
+ return clientSideEvaluation;
+ }
+
+ @Override
+ public void close() {
+ edgeRetryer.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;
+ }
+
+ @Override
+ public long currentInterval() {
+ return pollingInterval;
+ }
+
+ public long getWhenPollingCacheExpires() {
+ return whenPollingCacheExpires;
+ }
+}
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 54%
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
index b5755b8..5a64f00 100644
--- 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
@@ -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/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 73%
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
index a3a83e8..fcbe460 100644
--- 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
@@ -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/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-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
new file mode 100644
index 0000000..3bf6d5b
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseySSEClientSpec.groovy
@@ -0,0 +1,194 @@
+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()
+ 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 ->
+ output = new EventOutput()
+ return output
+ })
+
+ edge = new JerseySSEClient(null, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().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-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
new file mode 100644
index 0000000..d149c97
--- /dev/null
+++ b/client-implementations/java-client-jersey3/src/test/groovy/io/featurehub/client/jersey/RestClientSpec.groovy
@@ -0,0 +1,164 @@
+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.client.edge.EdgeRetryService
+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
+ EdgeRetryService retryer
+
+ def setup() {
+ apiKeys = ["123"]
+ featureService = Mock()
+ repo = Mock()
+ config = Mock()
+ retryer = Mock()
+ config.isServerEvaluation() >> true
+ client = new RestClient(repo, featureService, config, retryer, 0, false)
+ }
+
+ 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, retryer, 180, false)
+ 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 * _
+ }
+
+ def "change polling interval to 180 seconds and force breaking cache on every check"() {
+ given:
+ def response = build()
+ client = new RestClient(repo, featureService, config, retryer, 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-implementations/java-client-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
new file mode 100644
index 0000000..b916639
--- /dev/null
+++ b/client-implementations/java-client-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-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 76%
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
index 8925806..5963733 100644
--- 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
@@ -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;
@@ -24,16 +23,14 @@ 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.addReadynessListener((rl) -> System.out.println("Readyness is " + rl));
-
- config.setEdgeService(() -> new JerseySSEClient(config.getRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()));
+ config.setEdgeService(() -> new JerseySSEClient(config.getInternalRepository(), config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()));
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");
@@ -69,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/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-android/CHANGELOG.adoc b/client-implementations/java-client-okhttp/CHANGELOG.adoc
similarity index 100%
rename from client-java-android/CHANGELOG.adoc
rename to client-implementations/java-client-okhttp/CHANGELOG.adoc
diff --git a/client-java-android/README.adoc b/client-implementations/java-client-okhttp/README.adoc
similarity index 54%
rename from client-java-android/README.adoc
rename to client-implementations/java-client-okhttp/README.adoc
index d8fe6c7..e440b9e 100644
--- a/client-java-android/README.adoc
+++ b/client-implementations/java-client-okhttp/README.adoc
@@ -1,51 +1,33 @@
-= 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.
-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]
+== Considerations
=== 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
-
-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-java-android/pom.xml b/client-implementations/java-client-okhttp/pom.xml
similarity index 78%
rename from client-java-android/pom.xml
rename to client-implementations/java-client-okhttp/pom.xml
index 1ce4552..258a781 100644
--- a/client-java-android/pom.xml
+++ b/client-implementations/java-client-okhttp/pom.xml
@@ -3,12 +3,12 @@
4.0.0
io.featurehub.sdk
- java-client-android
- 2.3-SNAPSHOT
- java-client-android
+ java-client-okhttp
+ 3.1-SNAPSHOT
+ java-client-okhttp
- The Android (OKHttp) client for Java.
+ The OKHttp client for Java. Supports all three (streaming, polling, interval)
https://featurehub.io
@@ -43,24 +43,30 @@
HEAD
+
+
+ 4.12.0
+
+
io.featurehub.sdk
java-client-core
- [3, 4)
+ [4, 5)
- com.squareup.okhttp3
- okhttp
- 4.9.3
+ io.featurehub.sdk.composites
+ composite-okhttp
+ [1,2)
+ provided
- io.featurehub.sdk.composites
- sdk-composite-jackson
- [1.2, 2)
- provided
+ io.featurehub.sdk.common
+ common-jacksonv2
+ [1.1-SNAPSHOT, 2]
+ test
@@ -72,7 +78,7 @@
com.squareup.okhttp3
mockwebserver
- 4.9.3
+ ${ok.http.version}
test
@@ -89,12 +95,12 @@
io.repaint.maven
tiles-maven-plugin
- 2.23
+ 2.32
true
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-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
new file mode 100644
index 0000000..ad7830b
--- /dev/null
+++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/OkHttpFeatureHubFactory.java
@@ -0,0 +1,45 @@
+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
+ @NotNull
+ public Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository) {
+ return () -> new SSEClient(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, 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 TestClient(config);
+ }
+}
diff --git a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java
similarity index 54%
rename from client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java
rename to client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java
index 2ae1403..0b0530f 100644
--- a/client-java-android/src/main/java/io/featurehub/android/FeatureHubClient.java
+++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/RestClient.java
@@ -1,12 +1,13 @@
-package io.featurehub.android;
+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.FeatureStore;
-import io.featurehub.client.Readyness;
+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.javascript.JavascriptObjectMapper;
import io.featurehub.sse.model.FeatureEnvironmentCollection;
import io.featurehub.sse.model.FeatureState;
import io.featurehub.sse.model.SSEResultState;
@@ -22,8 +23,8 @@
import org.slf4j.LoggerFactory;
import java.io.IOException;
+import java.time.Duration;
import java.util.ArrayList;
-import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@@ -33,13 +34,13 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
-public class FeatureHubClient implements EdgeService {
- private static final Logger log = LoggerFactory.getLogger(FeatureHubClient.class);
- private final FeatureStore repository;
- private final Call.Factory client;
+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 boolean makeRequests;
- private final String url;
- private final ObjectMapper mapper = new ObjectMapper();
+ @NotNull private final String url;
+ private final JavascriptObjectMapper mapper;
@Nullable
private String xFeaturehubHeader;
// used for breaking the cache
@@ -52,71 +53,93 @@ public class FeatureHubClient implements EdgeService {
private long whenPollingCacheExpires;
private final boolean clientSideEvaluation;
- private final FeatureHubConfig config;
- private final ExecutorService executorService;
+ private final boolean amPollingDelegate;
+ @NotNull private final FeatureHubConfig config;
+ @NotNull private final ExecutorService executorService;
- public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository,
- Call.Factory client, FeatureHubConfig config, int timeoutInSeconds) {
+ public RestClient(@Nullable InternalFeatureRepository repository,
+ @NotNull FeatureHubConfig config, @NotNull EdgeRetryService edgeRetryService, int timeoutInSeconds, boolean amPollingDelegate) {
+
+ if (repository == null) {
+ repository = (InternalFeatureRepository) config.getRepository();
+ }
+
+ this.mapper = repository.getJsonObjectMapper();
+
+ this.amPollingDelegate = amPollingDelegate;
this.repository = repository;
- this.client = client;
+
+ this.client = buildOkHttpClient(edgeRetryService);
+
this.config = config;
this.pollingInterval = timeoutInSeconds;
// 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;
-
- executorService = makeExecutorService();
+ this.clientSideEvaluation = !config.isServerEvaluation();
+ this.makeRequests = true;
+ executorService = makeExecutorService();
- url = host + "/features?" + sdkUrls.stream().map(u -> "apiKey=" + u).collect(Collectors.joining("&"));
+ url = config.baseUrl() + "/features?" + config.apiKeys().stream().map(u -> "apiKey=" + u).collect(Collectors.joining("&"));
- if (clientSideEvaluation) {
- checkForUpdates();
- }
- } else {
- throw new RuntimeException("FeatureHubClient initialized without any sdkUrls");
+ if (clientSideEvaluation) {
+ checkForUpdates(null);
}
}
+ /**
+ * 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();
}
- public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository, FeatureHubConfig config,
- int timeoutInSeconds) {
- this(host, sdkUrls, repository, (Call.Factory) new OkHttpClient(), config, timeoutInSeconds);
+ public RestClient(@NotNull FeatureHubConfig config,
+ @NotNull EdgeRetryService edgeRetryService, int timeoutInSeconds) {
+ this(null, config, edgeRetryService, timeoutInSeconds, false);
}
- public FeatureHubClient(String host, Collection sdkUrls, FeatureStore repository, FeatureHubConfig config) {
- this(host, sdkUrls, repository, (Call.Factory) new OkHttpClient(), config, 180);
+ 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 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() {
- final boolean breakCache = now() > whenPollingCacheExpires || headerChanged;
+ public boolean checkForUpdates(@Nullable CompletableFuture change) {
+ final boolean breakCache =
+ amPollingDelegate || pollingInterval == 0 || (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);
+ log.trace("request url is {}", url);
Request.Builder reqBuilder = new Request.Builder().url(url);
if (xFeaturehubHeader != null) {
@@ -148,11 +171,21 @@ public void onResponse(@NotNull Call call, @NotNull Response response) throws IO
return ask;
}
- protected String getEtag() {
+ /**
+ * 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;
}
- protected void setEtag(String etag) {
+ protected void setEtag(@Nullable String etag) {
this.etag = etag;
}
@@ -167,7 +200,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 +212,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();
}
@@ -187,6 +220,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) {
@@ -201,8 +236,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.readFeatureCollection(new String(body.bytes()));
+ } 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 -> {
@@ -212,8 +254,9 @@ protected void processResponse(Response response) throws IOException {
}
});
- repository.notify(states);
- completeReadiness();
+ log.trace("updating feature repository: {}", states);
+
+ repository.updateFeatures(states);
if (response.code() == 236) {
this.stopped = true; // prevent any further requests
@@ -223,27 +266,32 @@ 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, null);
- completeReadiness();
+ repository.notify(SSEResultState.FAILURE);
}
+ // 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() {
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 {
- c.complete(repository.getReadyness());
+ c.complete(repository.getReadiness());
} catch (Exception e) {
log.error("Unable to complete future", e);
}
@@ -251,18 +299,18 @@ 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 {
- change.complete(repository.getReadyness());
+ } else if (!checkForUpdates(change)) {
+ change.complete(repository.getReadiness());
}
return change;
@@ -281,6 +329,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();
@@ -292,13 +342,22 @@ public void close() {
}
@Override
- public boolean isRequiresReplacementOnHeaderChange() {
- return false;
+ 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;
}
@Override
- public void poll() {
- checkForUpdates();
+ public long currentInterval() {
+ return pollingInterval;
}
public long getWhenPollingCacheExpires() {
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
new file mode 100644
index 0000000..1760b4f
--- /dev/null
+++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/SSEClient.java
@@ -0,0 +1,321 @@
+package io.featurehub.okhttp;
+
+import io.featurehub.client.EdgeService;
+import io.featurehub.client.FeatureHubConfig;
+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.SSEResultState;
+import okhttp3.*;
+import okhttp3.sse.EventSource;
+import okhttp3.sse.EventSourceListener;
+import okhttp3.sse.EventSources;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+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;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public class SSEClient implements EdgeService, EdgeReconnector {
+ private static final Logger log = LoggerFactory.getLogger(SSEClient.class);
+ 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<>();
+
+ 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 Future poll() {
+ if (eventSource == null) {
+ initEventSource();
+ }
+
+ return CompletableFuture.completedFuture(repository.getReadiness());
+ }
+
+ @Override
+ public long currentInterval() {
+ return 0;
+ }
+
+ private boolean connectionSaidBye;
+
+ private void initEventSource() {
+ try {
+ 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 = buildRequest(reqBuilder);
+
+ // 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);
+ }
+
+ if (eventSource == null) {
+ log.error("Unable to connect to {}", this.config.getRealtimeUrl());
+ }
+ }
+
+ /**
+ * 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 =
+ buildOkHttpClientBuilder(retryer)
+ .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);
+ }
+
+ 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<>();
+
+ 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.");
+
+ xFeaturehubHeader = newHeader;
+
+ if (eventSource != null) {
+ eventSource.cancel();
+ eventSource = null;
+ }
+ }
+
+ if (eventSource == null) {
+ waitingClients.add(change);
+
+ poll();
+ } else {
+ change.complete(repository.getReadiness());
+ }
+
+ return change;
+ }
+
+ @Override
+ public boolean isClientEvaluation() {
+ return !config.isServerEvaluation();
+ }
+
+ @Override
+ public boolean isStopped() {
+ return retryer.isStopped();
+ }
+
+ @Override
+ public void close() {
+ // don't let it try connecting again
+ retryer.close();
+
+ // shut down the pool of okhttp connections
+ if (client != null) {
+ client.dispatcher().executorService().shutdownNow();
+ client.connectionPool().evictAll();
+ }
+
+ // cancel the event source
+ if (eventSource != null) {
+ log.info("[featurehub-sdk] closing connection");
+ eventSource.cancel();
+ eventSource = null;
+ }
+
+ // wipe the factory
+ if (eventSourceFactory != null) {
+ eventSourceFactory = null;
+ }
+ }
+
+ @Override
+ public @NotNull FeatureHubConfig getConfig() {
+ return config;
+ }
+
+ @Override
+ public void reconnect() {
+ initEventSource();
+ }
+}
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
new file mode 100644
index 0000000..ef1995b
--- /dev/null
+++ b/client-implementations/java-client-okhttp/src/main/java/io/featurehub/okhttp/TestClient.java
@@ -0,0 +1,84 @@
+package io.featurehub.okhttp;
+
+import io.featurehub.client.FeatureHubConfig;
+import io.featurehub.client.TestApi;
+import io.featurehub.client.TestApiResult;
+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;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+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
+ public @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) {
+ String data;
+
+ try {
+ data = mapper.featureStateUpdateToString(featureStateUpdate);
+ } catch (IOException 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);
+ }
+ }
+
+ /**
+ * 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);
+ }
+
+ @Override
+ public void close() {
+ client.dispatcher().executorService().shutdown();
+ }
+}
diff --git a/client-implementations/java-client-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
new file mode 100644
index 0000000..bf7cd9a
--- /dev/null
+++ b/client-implementations/java-client-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-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy
similarity index 63%
rename from client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy
rename to client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy
index dd7155b..60ebf9c 100644
--- a/client-java-android/src/test/groovy/io/featurehub/android/FeatureHubClientMockSpec.groovy
+++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/RestClientSpec.groovy
@@ -1,28 +1,39 @@
-package io.featurehub.android
+package io.featurehub.okhttp
import com.fasterxml.jackson.databind.ObjectMapper
import io.featurehub.client.FeatureHubConfig
-import io.featurehub.client.FeatureStore
+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
import spock.lang.Specification
-class FeatureHubClientMockSpec extends Specification {
+class RestClientSpec extends Specification {
+ RestClient client
MockWebServer mockWebServer
- FeatureHubClient client
FeatureHubConfig config
- FeatureStore store
+ InternalFeatureRepository repo
ObjectMapper mapper
+ JavascriptObjectMapper fhMapper
def setup() {
mapper = new ObjectMapper()
+ fhMapper = new Jackson2ObjectMapper()
config = Mock()
- store = Mock()
+ repo = Mock()
+ repo.getJsonObjectMapper() >> fhMapper
+ 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 RestClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().rest().build(), 0)
mockWebServer.url("/features")
}
@@ -31,11 +42,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 +49,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 +69,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 +93,9 @@ class FeatureHubClientMockSpec extends Specification {
setResponseCode(400)
})
when:
- poll()
+ def future = client.poll()
+ mockWebServer.takeRequest()
+ future.get()
then:
!client.canMakeRequests()
}
@@ -95,7 +106,9 @@ class FeatureHubClientMockSpec extends Specification {
setResponseCode(404)
})
when:
- poll()
+ def future = client.poll()
+ mockWebServer.takeRequest()
+ future.get()
then:
!client.canMakeRequests()
}
@@ -106,9 +119,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 +145,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-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy
similarity index 72%
rename from client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy
rename to client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy
index ba128f1..81c175e 100644
--- a/client-java-sse/src/test/groovy/io/featurehub/edge/sse/SSEClientSpec.groovy
+++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/SSEClientSpec.groovy
@@ -1,14 +1,13 @@
-package io.featurehub.edge.sse
+package io.featurehub.okhttp
+
-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,13 +24,14 @@ 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"
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
@@ -44,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"() {
@@ -56,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"() {
@@ -71,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 >> Readyness.NotReady
+ 2 * repository.getReadiness() >> Readiness.NotReady
+ 1 * repository.notify(SSEResultState.FAILURE)
+ 0 * _
}
def "success then close with no bye"() {
@@ -87,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 >> Readyness.NotReady
+ 1 * repository.notify(SSEResultState.FAILURE)
+ 2 * repository.getReadiness() >> Readiness.NotReady
1 * retry.fromValue('features') >> SSEResultState.FEATURES
+ 0 * _
}
def "open then immediate failure"() {
@@ -101,9 +111,12 @@ class SSEClientSpec extends Specification {
// esListener.onOpen(mockEventSource, Mock(Response))
esListener.onFailure(mockEventSource, null, null)
then:
- 1 * repository.readyness >> Readyness.NotReady
- 1 * repository.notify(SSEResultState.FAILURE, null)
+ 1 * config.getRealtimeUrl() >> "http://localhost"
+ 1 * config.baseUrl() >> "http://localhost" // used by trace log
+ 3 * 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"() {
@@ -111,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 >> Readyness.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() == Readyness.Failed
+ future.get() == Readiness.Failed
+ 0 * _
}
def "when i context change with a server side key, it creates a request with the header"() {
@@ -148,13 +164,13 @@ class SSEClientSpec extends Specification {
esListener.onEvent(mockEventSource, '1', 'features', 'data')
then:
2 * config.serverEvaluation >> true
- 2 * repository.readyness >> Readyness.Ready
+ 4 * repository.getReadiness() >> 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"() {
@@ -179,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/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
new file mode 100644
index 0000000..6246501
--- /dev/null
+++ b/client-implementations/java-client-okhttp/src/test/groovy/io/featurehub/okhttp/TestClientSpec.groovy
@@ -0,0 +1,56 @@
+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
+import spock.lang.Specification
+
+import java.util.concurrent.TimeUnit
+
+class TestClientSpec extends Specification {
+ MockWebServer mockWebServer
+ FeatureHubConfig config
+ InternalFeatureRepository repo
+ JavascriptObjectMapper mapper
+ TestClient client
+
+ def setup() {
+ mapper = new Jackson2ObjectMapper()
+ 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
+ config.internalRepository >> repo
+ 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.featureStateUpdateToString(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-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java
similarity index 82%
rename from client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java
rename to client-implementations/java-client-okhttp/src/test/java/io/featurehub/okhttp/FeatureHubClientRunner.java
index 553ac78..6f6c16f 100644
--- a/client-java-android21/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;
@@ -14,16 +14,16 @@ 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");
-
- final ClientContext ctx = config.newContext();
- ctx.getRepository().addReadynessListener(rl -> System.out.println("readyness " + rl.toString()));
+ "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()));
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-android/src/test/resources/log4j2.xml b/client-implementations/java-client-okhttp/src/test/resources/log4j2.xml
similarity index 100%
rename from client-java-android/src/test/resources/log4j2.xml
rename to client-implementations/java-client-okhttp/src/test/resources/log4j2.xml
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/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 c5c50bc..0000000
--- a/client-java-android/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java
+++ /dev/null
@@ -1,16 +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.FeatureStore;
-
-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);
- }
-}
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 a7e57b9..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.FeatureStore
-import okhttp3.Call
-import okhttp3.Request
-import spock.lang.Specification
-
-class FeatureHubClientSpec extends Specification {
- Call.Factory client
- Call call;
- FeatureStore 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()
- 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()
- 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
deleted file mode 100644
index 133f654..0000000
--- a/client-java-android21/README.adoc
+++ /dev/null
@@ -1,12 +0,0 @@
-= FeatureHub SDK for Android/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.
-
-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.
-
diff --git a/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory
deleted file mode 100644
index 2bcf7cc..0000000
--- a/client-java-android21/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/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-core/README.adoc b/client-java-core/README.adoc
deleted file mode 100644
index b2ef235..0000000
--- a/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/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
deleted file mode 100644
index 30d1aff..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java
+++ /dev/null
@@ -1,207 +0,0 @@
-package io.featurehub.client;
-
-import io.featurehub.sse.model.StrategyAttributeCountryName;
-import io.featurehub.sse.model.StrategyAttributeDeviceName;
-import io.featurehub.sse.model.StrategyAttributePlatformName;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
-
-public abstract class BaseClientContext implements ClientContext {
- 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 FeatureHubConfig config;
-
- public BaseClientContext(FeatureRepositoryContext repository, FeatureHubConfig config) {
- this.repository = repository;
- this.config = config;
- }
-
- @Override
- public String get(String key, String defaultValue) {
- if (clientContext.containsKey(key)) {
- final List vals = clientContext.get(key);
- return vals.isEmpty() ? defaultValue : vals.get(0);
- }
-
- return defaultValue;
- }
-
- @Override
- public @NotNull List<@NotNull String> getAttrs(String key, @NotNull String defaultValue) {
- final List attrs = clientContext.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));
- return this;
- }
-
- @Override
- public ClientContext sessionKey(String sessionKey) {
- clientContext.put(SESSION_KEY, Collections.singletonList(sessionKey));
- return this;
- }
-
- @Override
- public ClientContext country(StrategyAttributeCountryName countryName) {
- clientContext.put(COUNTRY_KEY, Collections.singletonList(countryName.toString()));
- return this;
- }
-
- @Override
- public ClientContext device(StrategyAttributeDeviceName deviceName) {
- clientContext.put(DEVICE_KEY, Collections.singletonList(deviceName.toString()));
- return this;
- }
-
- @Override
- public ClientContext platform(StrategyAttributePlatformName platformName) {
- clientContext.put(PLATFORM_KEY, Collections.singletonList(platformName.toString()));
- return this;
- }
-
- @Override
- public ClientContext version(String version) {
- clientContext.put(VERSION_KEY, Collections.singletonList(version));
- return this;
- }
-
- @Override
- public ClientContext attr(String name, String value) {
- clientContext.put(name, Collections.singletonList(value));
- return this;
- }
-
- @Override
- public ClientContext attrs(String name, List values) {
- clientContext.put(name, values);
- return this;
- }
-
- @Override
- public ClientContext clear() {
- clientContext.clear();
- return this;
- }
-
- @Override
- public Map> context() {
- return clientContext;
- }
-
- @Override
- public String defaultPercentageKey() {
- if (clientContext.containsKey(SESSION_KEY)) {
- return clientContext.get(SESSION_KEY).get(0);
- }
- if (clientContext.containsKey(USER_KEY)) {
- return clientContext.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);
- }
-
- @Override
- public List allFeatures() {
- boolean isServerEvaluation = getRepository().isServerEvaluation();
- return getRepository().getAllFeatures().stream()
- .map(f -> isServerEvaluation ? f : f.withContext(this))
- .collect(Collectors.toList());
- }
-
- @Override
- public FeatureState feature(Feature name) {
- return feature(name.name());
- }
-
- @Override
- public boolean isEnabled(Feature name) {
- return isEnabled(name.name());
- }
-
- @Override
- public boolean isEnabled(String name) {
- // we use this mechanism as it will return the state within the context (vs repository which might be different)
- return feature(name).isEnabled();
- }
-
- @Override
- public boolean isSet(Feature name) {
- return isSet(name.name());
- }
-
- @Override
- public boolean isSet(String name) {
- // we use this mechanism as it will return the state within the context (vs repository which might be different)
- return feature(name).isSet();
- }
-
- @Override
- public boolean exists(Feature key) {
- return exists(key.name());
- }
-
-
- @Override
- public FeatureRepository getRepository() {
- return repository;
- }
-
- @Override
- public boolean exists(String key) {
- return repository.exists(key);
- }
-
- @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);
- }
-
-}
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
deleted file mode 100644
index 513c429..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java
+++ /dev/null
@@ -1,308 +0,0 @@
-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.sse.model.FeatureRolloutStrategy;
-import io.featurehub.sse.model.SSEResultState;
-import io.featurehub.strategies.matchers.MatcherRegistry;
-import io.featurehub.strategies.percentage.PercentageMumurCalculator;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.stream.Collectors;
-
-public class ClientFeatureRepository extends AbstractFeatureRepository
- implements FeatureRepositoryContext {
- private static final Logger log = LoggerFactory.getLogger(ClientFeatureRepository.class);
- // feature-key, feature-state
- private final Map features = 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 final List featureValueInterceptors = new ArrayList<>();
- private ObjectMapper jsonConfigObjectMapper;
- private final ApplyFeature applyFeature;
- 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;
-
- this.executor = executor;
-
- this.applyFeature =
- applyFeature == null
- ? new ApplyFeature(new PercentageMumurCalculator(), new MatcherRegistry())
- : applyFeature;
- }
-
- public ClientFeatureRepository(int threadPoolSize) {
- this(getExecutor(threadPoolSize), null);
- }
-
- public ClientFeatureRepository() {
- this(1);
- }
-
- 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) {
- return Executors.newFixedThreadPool(threadPoolSize);
- }
-
- public void setJsonConfigObjectMapper(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;
- }
-
- @Override
- public FeatureRepository addAnalyticCollector(AnalyticsCollector collector) {
- analyticsCollectors.add(collector);
- return this;
- }
-
- @Override
- public FeatureRepository registerValueInterceptor(
- boolean allowFeatureOverride, FeatureValueInterceptor interceptor) {
- featureValueInterceptors.add(
- new FeatureValueInterceptorHolder(allowFeatureOverride, interceptor));
-
- return this;
- }
-
- @Override
- public void notify(SSEResultState state, String data) {
- log.trace("received state {} data {}", state, data);
- if (state == null) {
- log.warn("Unexpected null state");
- } else {
- try {
- 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;
- broadcastReadyness();
- break;
- }
- } catch (Exception e) {
- log.error("Unable to process data `{}` for state `{}`", data, state, e);
- }
- }
- }
-
- @Override
- public void notify(List states, boolean force) {
- states.forEach(s -> featureUpdate(s, force));
-
- if (!hasReceivedInitialState) {
- checkForInvalidFeatures();
- hasReceivedInitialState = true;
- readyness = Readyness.Ready;
- broadcastReadyness();
- } else if (readyness != Readyness.Ready) {
- readyness = Readyness.Ready;
- broadcastReadyness();
- }
- }
-
- @Override
- public List getFeatureValueInterceptors() {
- return featureValueInterceptors;
- }
-
- @Override
- public Applied applyFeature(
- List strategies, String key, String featureValueId, ClientContext cac) {
- return applyFeature.applyFeature(strategies, key, featureValueId, cac);
- }
-
- @Override
- public void execute(Runnable command) {
- executor.execute(command);
- }
-
- @Override
- public ObjectMapper getJsonObjectMapper() {
- return jsonConfigObjectMapper;
- }
-
- @Override
- public void setServerEvaluation(boolean val) {
- this.serverEvaluation = val;
- }
-
- @Override
- public void notReady() {
- readyness = Readyness.NotReady;
- broadcastReadyness();
- }
-
- @Override
- public void close() {
- log.info("featurehub repository closing");
- features.clear();
-
- readyness = Readyness.NotReady;
- readynessListeners.stream().forEach(rl -> rl.notify(readyness));
-
- executor.shutdownNow();
-
- log.info("featurehub repository closed");
- }
-
- @Override
- public void notify(List states) {
- notify(states, false);
- }
-
- @Override
- public FeatureRepository addReadynessListener(ReadynessListener rl) {
- this.readynessListeners.add(rl);
-
- if (!executor.isShutdown()) {
- // let it know what the current state is
- executor.execute(() -> rl.notify(readyness));
- }
-
- return this;
- }
-
- private void broadcastReadyness() {
- if (!executor.isShutdown()) {
- readynessListeners.forEach((rl) -> executor.execute(() -> rl.notify(readyness)));
- }
- }
-
- private void deleteFeature(io.featurehub.sse.model.FeatureState readValue) {
- readValue.setValue(null);
- featureUpdate(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 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);
- });
- }
-
- @Override
- public List getAllFeatures() {
- return new ArrayList<>(features.values());
- }
-
- @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;
- }
-
- private void featureUpdate(io.featurehub.sse.model.FeatureState featureState) {
- featureUpdate(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());
-
- features.put(featureState.getKey(), holder);
- } else if (!force && holder._featureState != null) {
- if (holder._featureState.getVersion() > featureState.getVersion()
- || (holder._featureState.getVersion().equals(featureState.getVersion())
- && !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.
- // it can change with server side evaluation based on user data
- return;
- }
- }
-
- holder.setFeatureState(featureState);
- }
-}
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
deleted file mode 100644
index 3205f64..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java
+++ /dev/null
@@ -1,208 +0,0 @@
-package io.featurehub.client;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ServiceLoader;
-import java.util.concurrent.Future;
-import java.util.function.Supplier;
-
-public class EdgeFeatureHubConfig implements FeatureHubConfig {
- private static final Logger log = LoggerFactory.getLogger(EdgeFeatureHubConfig.class);
-
- @NotNull
- private final String realtimeUrl;
- private final boolean serverEvaluation;
- @NotNull
- private final String edgeUrl;
- @NotNull
- private final String apiKey;
- @Nullable
- private FeatureRepositoryContext repository;
- @Nullable
- private Supplier edgeService;
-
- @Nullable
- private EdgeService edgeClient;
-
- public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) {
-
- if (apiKey == null || edgeUrl == null) {
- throw new RuntimeException("Both edge url and sdk key must be set.");
- }
-
- serverEvaluation = !FeatureHubConfig.sdkKeyIsClientSideEvaluated(apiKey);
-
- if (edgeUrl.endsWith("/")) {
- edgeUrl = edgeUrl.substring(0, edgeUrl.length()-1);
- }
-
- if (edgeUrl.endsWith("/features")) {
- edgeUrl = edgeUrl.substring(0, edgeUrl.length() - "/features".length());
- }
-
- this.edgeUrl = String.format("%s", edgeUrl);
- this.apiKey = apiKey;
-
- realtimeUrl = String.format("%s/features/%s", edgeUrl, apiKey);
- }
-
- @Override
- @NotNull
- public String getRealtimeUrl() {
- return realtimeUrl;
- }
-
- @Override
- @NotNull
- public String apiKey() {
- return apiKey;
- }
-
- @Override
- @NotNull
- public String baseUrl() {
- return edgeUrl;
- }
-
- /**
- * 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);
- }
- }
-
- @Override
- public boolean isServerEvaluation() {
- return serverEvaluation;
- }
-
- @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 (isServerEvaluation()) {
- return 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 new ClientEvalFeatureContext(this, repository, edgeClient);
- }
-
- /**
- * 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;
- }
- }
-
- 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;
- }
-
- @Override
- @NotNull
- public FeatureRepositoryContext getRepository() {
- if (repository == null) {
- repository = new ClientFeatureRepository();
- }
-
- return repository;
- }
-
- @Override
- public void setEdgeService(@NotNull Supplier edgeService) {
- this.edgeService = edgeService;
- }
-
- @Override
- @NotNull
- public Supplier getEdgeService() {
- if (edgeService == null) {
- edgeService = loadEdgeService(getRepository());
- }
-
- return edgeService;
- }
-
- @Override
- public void addReadynessListener(@NotNull ReadynessListener readynessListener) {
- getRepository().addReadynessListener(readynessListener);
- }
-
- @Override
- public void addAnalyticCollector(@NotNull AnalyticsCollector collector) {
- getRepository().addAnalyticCollector(collector);
- }
-
- @Override
- public void registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor) {
- getRepository().registerValueInterceptor(allowLockOverride, interceptor);
- }
-
- @Override
- @NotNull
- public Readyness getReadyness() {
- return getRepository().getReadyness();
- }
-
- @Override
- public void setJsonConfigObjectMapper(@NotNull ObjectMapper jsonConfigObjectMapper) {
- getRepository().setJsonConfigObjectMapper(jsonConfigObjectMapper);
- }
-
- @Override
- public void close() {
- if (edgeClient != null) {
- edgeClient.close();
- edgeClient = null;
- }
- }
-}
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
deleted file mode 100644
index 335ad6c..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/EdgeService.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package io.featurehub.client;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.util.concurrent.Future;
-
-public interface EdgeService {
- /**
- * called only when the new attribute header has changed
- *
- * @param newHeader - the header to pass to the server if server evaluated
- * @return a completable future when it has actually changed
- */
- @NotNull
- Future contextChange(@Nullable String newHeader, String contextSha);
-
- /**
- * are we doing client side evaluation?
- * @return
- */
- boolean isClientEvaluation();
-
- /**
- * Shut down this service
- */
- void close();
-
- @NotNull
- FeatureHubConfig getConfig();
-
- boolean isRequiresReplacementOnHeaderChange();
-
- void 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
deleted file mode 100644
index 58880c1..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package io.featurehub.client;
-
-import java.util.function.Supplier;
-
-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
- * @return
- */
- Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository);
-}
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
deleted file mode 100644
index 6ed3633..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package io.featurehub.client;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import java.util.function.Supplier;
-
-public interface FeatureHubConfig {
- /**
- * What is the fully deconstructed URL for the server?
- */
- String getRealtimeUrl();
-
- String apiKey();
-
- String baseUrl();
-
- /**
- * 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
- * to re-request data from the server each time you change your context.
- */
- void init();
-
- /**
- * The API Key indicates this is going to be server based evaluation
- */
- boolean isServerEvaluation();
-
- /**
- * returns a new context using the default edge provider and repository
- *
- * @return a new context
- */
- 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("*");
- }
-
- void setRepository(FeatureRepositoryContext repository);
- FeatureRepositoryContext getRepository();
-
- void setEdgeService(Supplier edgeService);
- 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
- */
- void addAnalyticCollector(AnalyticsCollector collector);
-
- /**
- * Allows you to register a value interceptor
- * @param allowLockOverride
- * @param interceptor
- */
- void registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor);
-
- /**
- * Allows you to query the state of the repository's readyness - such as in a heartbeat API
- * @return
- */
- Readyness getReadyness();
-
- /**
- * Allows you to override how your config will be deserialized when "getJson" is called.
- *
- * @param jsonConfigObjectMapper - a Jackson ObjectMapper
- */
- void 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
- * server cleanly
- */
- void close();
-}
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
deleted file mode 100644
index 33d407b..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package io.featurehub.client;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import java.util.List;
-import java.util.Map;
-
-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
- * @return - this FeatureRepository
- */
- FeatureRepository addReadynessListener(ReadynessListener readynessListener);
-
- /**
- * @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);
-
- /**
- * 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
- * @return the instance of the repo for chaining
- */
- FeatureRepository registerValueInterceptor(boolean allowLockOverride, FeatureValueInterceptor interceptor);
-
- /**
- * Is this repository ready to connect to.
- *
- * @return Readyness status
- */
- Readyness getReadyness();
-
- /**
- * Lets the SDK override the configuration of the JSON mapper in case they have special techniques they use.
- *
- * @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);
-
- boolean isServerEvaluation();
-
- void close();
-}
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
deleted file mode 100644
index 1c282d7..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureState.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package io.featurehub.client;
-
-import java.math.BigDecimal;
-
-public interface FeatureState {
- String getKey();
-
- String getString();
-
- Boolean getBoolean();
-
- BigDecimal getNumber();
-
- String getRawJson();
-
- T getJson(Class type);
-
- /**
- * true if the flag is boolean and is true
- */
- boolean isEnabled();
-
- boolean isSet();
-
- 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);
-
- FeatureState withContext(ClientContext ctx);
-}
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
deleted file mode 100644
index d2a1c94..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java
+++ /dev/null
@@ -1,257 +0,0 @@
-package io.featurehub.client;
-
-import io.featurehub.sse.model.FeatureValueType;
-import org.jetbrains.annotations.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-/**
- * This class is just the base class to avoid a whole 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 {
- 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;
-
- public FeatureStateBase(
- FeatureStateBase oldHolder, FeatureStore featureStore, String key) {
- this(featureStore, key);
-
- if (oldHolder != null) {
- this.listeners = oldHolder.listeners;
- }
- }
-
- public FeatureStateBase(FeatureStore featureStore, String key) {
- this.key = key;
- this.featureStore = featureStore;
- }
-
- public FeatureState withContext(ClientContext context) {
- final FeatureStateBase copy = _copy();
- copy.context = context;
- return copy;
- }
-
- 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;
- }
-
- protected void notifyListeners() {
- listeners.forEach((sl) -> featureStore.execute(() -> sl.notify(this)));
- }
-
- @Override
- public String getKey() {
- return key;
- }
-
- @Override
- public boolean isLocked() {
- return this.featureState() != null && this.featureState().getL() == Boolean.TRUE;
- }
-
- @Override
- public String getString() {
- return getAsString(FeatureValueType.STRING);
- }
-
- @Override
- public Boolean getBoolean() {
- Object val = getValue(FeatureValueType.BOOLEAN);
-
- if (val == null) {
- return null;
- }
-
- if (val instanceof String) {
- return Boolean.TRUE.equals("true".equalsIgnoreCase(val.toString()));
- }
-
- return Boolean.TRUE.equals(val);
- }
-
- private Object getValue(FeatureValueType type) {
- // unlike js, locking is registered on a per interceptor basis
- FeatureValueInterceptor.ValueMatch vm = findIntercept();
-
- if (vm != null) {
- return vm.value;
- }
-
- final io.featurehub.sse.model.FeatureState featureState = featureState();
- if (featureState == null || featureState.getType() != type) {
- return null;
- }
-
- if (context != null) {
- final Applied applied =
- featureStore.applyFeature(
- featureState.getStrategies(), key, featureState.getId().toString(), context);
-
- if (applied.isMatched()) {
- return applied.getValue() == null ? null : applied.getValue();
- }
- }
-
- return featureState.getValue();
- }
-
- private String getAsString(FeatureValueType type) {
- Object value = getValue(type);
- return value == null ? null : value.toString();
- }
-
- @Override
- public BigDecimal getNumber() {
- Object val = getValue(FeatureValueType.NUMBER);
-
- try {
- return (val == null) ? null : (val instanceof BigDecimal ? ((BigDecimal)val) : new BigDecimal(val.toString()));
- } catch (Exception e) {
- log.warn("Attempting to convert {} to BigDecimal fails as is not a number", val);
- return null; // ignore conversion failures
- }
- }
-
- @Override
- public String getRawJson() {
- return getAsString(FeatureValueType.JSON);
- }
-
- @Override
- public T getJson(Class type) {
- String rawJson = getRawJson();
-
- try {
- return rawJson == null ? null : featureStore.getJsonObjectMapper().readValue(rawJson, type);
- } catch (IOException e) {
- log.warn("Failed to parse JSON", e);
- return null;
- }
- }
-
- @Override
- public boolean isEnabled() {
- return getBoolean() == 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);
- }
-
- @Override
- public void addListener(final FeatureListener listener) {
- if (context != null) {
- listeners.add((fs) -> listener.notify(this));
- } else {
- listeners.add(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) {
- if (featureState == null) return this;
- Object oldValue = getValue(type());
- this._featureState = featureState;
- Object value = convertToRespectiveType(featureState);
- if (FeatureStateUtils.changed(oldValue, value)) {
- notifyListeners();
- }
- return this;
- }
-
- @Nullable
- private Object convertToRespectiveType(io.featurehub.sse.model.FeatureState featureState) {
- if (featureState.getValue() == null) {
- return null;
- }
- try {
- switch (featureState.getType()) {
- case BOOLEAN:
- return Boolean.parseBoolean(featureState.getValue().toString());
- case STRING:
- 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() {
- return _copy();
- }
-
- protected FeatureState analyticsCopy() {
- final FeatureStateBase aCopy = _copy();
- aCopy._featureState = featureState();
- return aCopy;
- }
-
- protected FeatureStateBase _copy() {
- final FeatureStateBase copy = new FeatureStateBase(this, featureStore, key);
- copy.parentHolder = this;
- return copy;
- }
-
- protected boolean exists() {
- return featureState() != null;
- }
-
- protected FeatureValueType type() {
- final io.featurehub.sse.model.FeatureState featureState = featureState();
- return featureState == null ? null : featureState.getType();
- }
-
- @Override
- public String toString() {
- Object value = getValue(type());
- return value == null ? null : value.toString();
- }
-}
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
deleted file mode 100644
index 62626e5..0000000
--- a/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package io.featurehub.client;
-
-import java.net.URLEncoder;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-public class FeatureStateUtils {
-
- static boolean changed(Object oldValue, Object newValue) {
- return ((oldValue != null && newValue == null) || (newValue != null && oldValue == null) ||
- (oldValue != null && !oldValue.equals(newValue)) || (newValue != null && !newValue.equals(oldValue)));
- }
-
- public static String generateXFeatureHubHeaderFromMap(Map> attributes) {
- if (attributes == null || attributes.isEmpty()) {
- return null;
- }
-
- 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/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/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy
deleted file mode 100644
index 714f650..0000000
--- a/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy
+++ /dev/null
@@ -1,146 +0,0 @@
-package io.featurehub.client
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import spock.lang.Specification
-
-import java.util.concurrent.Future
-import java.util.function.Supplier
-
-class EdgeFeatureHubConfigSpec extends Specification {
- 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)
- and: "i ask again"
- def ctx2 = config.newContext(null, edgeSupplier)
- 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)
- config.close()
- and: "i ask again"
- def ctx2 = config.newContext(null, edgeSupplier)
- then:
- 2 * edgeSupplier.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)
- config.setRepository(repo)
- and: "I have some values ready to set"
- def om = new ObjectMapper()
- def readynessListener = Mock(ReadynessListener)
- def analyticsCollector = Mock(AnalyticsCollector)
- def featureValueOverride = Mock(FeatureValueInterceptor)
- when: "i set all the passthrough settings"
- config.setJsonConfigObjectMapper(om)
- config.addReadynessListener(readynessListener)
- config.addAnalyticCollector(analyticsCollector)
- config.registerValueInterceptor(false, featureValueOverride)
- then:
- 1 * repo.registerValueInterceptor(false, featureValueOverride)
- 1 * repo.addReadynessListener(readynessListener)
- 1 * repo.addAnalyticCollector(analyticsCollector)
- 1 * repo.setJsonConfigObjectMapper(om)
- 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)
- when: "i create a new client"
- def context = config.newContext()
- then:
- context instanceof ClientEvalFeatureContext
- 1 * FeatureHubTestClientFactory.edgeServiceSupplier.get() >> edgeClient
- ((ClientEvalFeatureContext)context).edgeService == edgeClient
- 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()
- then:
- context instanceof ServerEvalFeatureContext
- ((ServerEvalFeatureContext)context).edgeService == null
- ((ServerEvalFeatureContext)context).edgeServiceSupplier == FeatureHubTestClientFactory.edgeServiceSupplier
- 0 * _
- }
-
- def "initialising gets the urls correct and detects server evaluated context"() {
- when: "i have a client eval feature config"
- def config = new EdgeFeatureHubConfig("http://localhost/", "123-abc")
- then:
- config.apiKey() == '123-abc'
- config.baseUrl() == 'http://localhost'
- config.realtimeUrl == 'http://localhost/features/123-abc'
- config.isServerEvaluation()
- }
-
- def "initialising detects client evaluated context"() {
- when: "i have a client eval feature config"
- def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc")
- then:
- !config.isServerEvaluation()
- }
-
- 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")
- then:
- config.repository instanceof ClientFeatureRepository
- config.readyness == Readyness.NotReady
- config.edgeService == FeatureHubTestClientFactory.edgeServiceSupplier
- }
-
- 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)
- when:
- config.init()
- then:
- 1 * supplier.get() >> client
- 1 * client.contextChange(null, '0') >> mockRequest
- 1 * mockRequest.get() >> Readyness.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
deleted file mode 100644
index 1635b36..0000000
--- a/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy
+++ /dev/null
@@ -1,18 +0,0 @@
-package io.featurehub.client
-
-import java.util.function.Supplier
-
-class FeatureHubTestClientFactory implements FeatureHubClientFactory {
- static Supplier edgeServiceSupplier
- static FeatureHubConfig config
- static FeatureStore repository
-
- @Override
- Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository) {
- // save them for the test to ch eck
- FeatureHubTestClientFactory.config = url
- FeatureHubTestClientFactory.repository = repository
-
- return edgeServiceSupplier
- }
-}
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
deleted file mode 100644
index 8b56f25..0000000
--- a/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy
+++ /dev/null
@@ -1,40 +0,0 @@
-package io.featurehub.client
-
-import io.featurehub.sse.model.FeatureValueType
-import io.featurehub.sse.model.RolloutStrategyAttributeConditional
-import io.featurehub.sse.model.RolloutStrategyFieldType
-import io.featurehub.sse.model.FeatureRolloutStrategy
-import io.featurehub.sse.model.FeatureRolloutStrategyAttribute
-import spock.lang.Specification
-
-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 key = "fred"
- and: "a feature"
- def feat = new FeatureStateBase(fStore, key)
- def ctxFeat = feat.withContext(ctx)
- BigDecimal n1;
- BigDecimal n2;
- 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")
- )))
- then:
- n1 == 16
- n2 == 12
- 2 * fStore.execute({Runnable cmd ->
- cmd.run()
- })
- 1 * fStore.applyFeature(_, key, _, ctx) >> new Applied(true, 12)
- }
-}
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
deleted file mode 100644
index 80099dc..0000000
--- a/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy
+++ /dev/null
@@ -1,318 +0,0 @@
-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.SSEResultState
-import spock.lang.Specification
-
-import java.util.concurrent.ExecutorService
-
-
-enum Fruit implements Feature { banana, peach, peach_quantity, peach_config, dragonfruit }
-
-class RepositorySpec extends Specification {
- ClientFeatureRepository repo
- ExecutorService exec
-
- def setup() {
- exec = [
- execute: { Runnable cmd -> cmd.run() },
- shutdownNow: { -> },
- isShutdown: { false }
- ] as ExecutorService
-
- repo = new ClientFeatureRepository(exec)
- }
-
- def "an empty repository is not ready"() {
- when: "ask for the readyness status"
- def ready = repo.readyness
- then:
- ready == Readyness.NotReady
- }
-
- def "a set of features should trigger readyness and make all features available"() {
- 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_quantity').version(1L).value(17).type(FeatureValueType.NUMBER),
- 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)
- when:
- repo.notify(SSEResultState.FEATURES, new ObjectMapper().writeValueAsString(features))
- then:
- 1 * readynessListener.notify(Readyness.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
- }
-
- def "i can make all features available directly"() {
- given: "we have features"
- def features = [
- 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
- and: "i make a change to the state but keep the version the same (ok because this is what rollout strategies do)"
- repo.notify([
- new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN),
- ])
- def feature2 = repo.getFeatureState('banana').boolean
- and: "then i make the change but up the version"
- repo.notify([
- new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true).type(FeatureValueType.BOOLEAN),
- ])
- def feature3 = repo.getFeatureState('banana').boolean
- and: "then i make a change but force it even if the version is the same"
- repo.notify([
- new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN),
- ], true)
- def feature4 = repo.getFeatureState('banana').boolean
- then:
- !feature
- feature2
- feature3
- !feature4
- }
-
- def "a non existent feature is not set"() {
- when: "we ask for a feature that doesn't exist"
- def feature = repo.getFeatureState('fred')
- then:
- !feature.enabled
- }
-
- def "a feature is deleted that doesn't exist and thats ok"() {
- 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))
- then:
- !repo.getFeatureState('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])
- when: "i check the feature state"
- def f = repo.getFeatureState('banana').boolean
- 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))
- then:
- f
- !repo.getFeatureState('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 = [
- 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())
- when: "i notify of features"
- repo.notify(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
- }
-
- def "failure changes readyness 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(ReadynessListener)
- repo.addReadynessListener(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)
- }
-
- def "ack and bye are ignored"() {
- 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"
- repo.notify(features)
- when: "i ack and then bye, nothing happens"
- repo.notify(SSEResultState.ACK, null)
- repo.notify(SSEResultState.BYE, null)
- then:
- repo.readyness == Readyness.Ready
- }
-
- def "i can attach to a feature before it is added and receive notifications when it is"() {
- given: "i have one of each feature type"
- 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 listen for updates for those features"
- def updateListener = []
- List emptyFeatures = []
- features.each {f ->
- def feature = repo.getFeatureState(f.key)
- def listener = Mock(FeatureListener)
- updateListener.add(listener)
- feature.addListener(listener)
- emptyFeatures.add(feature.analyticsCopy())
- }
- when: "i fill in the repo"
- repo.notify(features)
- then:
- updateListener.each {
- 1 * it.notify(_)
- }
- emptyFeatures.each {f ->
- f.key != null
- !f.enabled
- f.string == null
- f.boolean == null
- f.rawJson == null
- f.number == null
- }
- features.each { it ->
- repo.getFeatureState(it.key).key == it.key
- repo.getFeatureState(it.key).enabled
-
- if (it.type == FeatureValueType.BOOLEAN)
- repo.getFeatureState(it.key).boolean == it.value
- else
- repo.getFeatureState(it.key).boolean == null
-
- if (it.type == FeatureValueType.NUMBER)
- repo.getFeatureState(it.key).number == it.value
- else
- repo.getFeatureState(it.key).number == null
-
- if (it.type == FeatureValueType.STRING)
- repo.getFeatureState(it.key).string.equals(it.value)
- else
- repo.getFeatureState(it.key).string == null
-
- if (it.type == FeatureValueType.JSON)
- repo.getFeatureState(it.key).rawJson.equals(it.value)
- else
- repo.getFeatureState(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/TestContext.groovy b/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy
deleted file mode 100644
index d5168bb..0000000
--- a/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy
+++ /dev/null
@@ -1,41 +0,0 @@
-package io.featurehub.client
-
-import java.util.concurrent.CompletableFuture
-import java.util.concurrent.Future
-
-class TestContext extends BaseClientContext {
- TestContext(FeatureRepositoryContext repo) {
- super(repo, null)
- }
-
- @Override
- Future build() {
- CompletableFuture x = new CompletableFuture()
- x.complete(this)
- return x
- }
-
- @Override
- EdgeService getEdgeService() {
- 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)
- }
-}
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
deleted file mode 100644
index 6209ccf..0000000
--- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package io.featurehub.client.jersey;
-
-import cd.connect.openapi.support.ApiClient;
-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;
-import java.util.List;
-import java.util.Map;
-
-public class FeatureServiceImpl implements FeatureService {
- private final ApiClient apiClient;
-
- public FeatureServiceImpl(ApiClient apiClient) {
- this.apiClient = apiClient;
- }
-
- @Override
- public List getFeatureStates(@NotNull List apiKey, @Nullable String contextSha) {
- return null;
- }
-
- @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");
- }
-
- // verify the required parameter 'featureKey' is set
- if (featureKey == null) {
- throw new BadRequestException("Missing the required parameter 'featureKey' when calling setFeatureState");
- }
-
- // create path and map variables /{apiKey}/{featureKey}
- String localVarPath = "/features/{apiKey}/{featureKey}"
- .replaceAll("\\{" + "apiKey" + "\\}", apiKey.toString())
- .replaceAll("\\{" + "featureKey" + "\\}", featureKey.toString());
-
- // query params
- List localVarQueryParams = new ArrayList();
- Map localVarHeaderParams = new HashMap();
- Map localVarFormParams = new HashMap();
-
-
- final String[] localVarAccepts = {
- "application/json"
- };
- final String localVarAccept = apiClient.selectHeaderAccept(localVarAccepts);
-
- final String[] localVarContentTypes = {
- "application/json"
- };
- final String localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes);
-
- String[] localVarAuthNames = new String[]{};
-
- GenericType localVarReturnType = new GenericType() {};
-
- apiClient.invokeAPI(localVarPath, "PUT", localVarQueryParams, featureStateUpdate, localVarHeaderParams,
- localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType).getData();
- }
-}
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/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
deleted file mode 100644
index eac5b3d..0000000
--- a/client-java-jersey/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package io.featurehub.client.jersey;
-
-import io.featurehub.client.EdgeService;
-import io.featurehub.client.FeatureHubClientFactory;
-import io.featurehub.client.FeatureHubConfig;
-import io.featurehub.client.FeatureStore;
-import io.featurehub.client.edge.EdgeRetryer;
-
-import java.util.function.Supplier;
-
-public class JerseyFeatureHubClientFactory implements FeatureHubClientFactory {
- @Override
- public Supplier