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 createEdgeService(FeatureHubConfig config, FeatureStore repository) { - return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - } -} diff --git a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy b/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy deleted file mode 100644 index 803644e..0000000 --- a/client-java-jersey/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy +++ /dev/null @@ -1,121 +0,0 @@ -package io.featurehub.client.jersey - -import cd.connect.openapi.support.ApiClient -import io.featurehub.client.ClientFeatureRepository -import io.featurehub.client.EdgeFeatureHubConfig -import io.featurehub.client.FeatureHubConfig -import io.featurehub.sse.api.FeatureService -import io.featurehub.sse.model.FeatureStateUpdate -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import spock.lang.Specification - -import javax.ws.rs.client.Client -import javax.ws.rs.client.WebTarget - -class JerseyClientSpec extends Specification { - private static final Logger log = LoggerFactory.getLogger(JerseyClientSpec.class) - def targetUrl - def basePath - FeatureHubConfig sdkPartialUrl - FeatureService mockFeatureService - ClientFeatureRepository mockRepository - WebTarget mockEventSource - - def "basic initialization test works as expect"() { - given: "i have a valid url" - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - when: "i initialize with a valid kind of sdk url" - def client = new JerseyClient(url, new ClientFeatureRepository(1)) { - @Override - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - targetUrl = sdkUrl - return super.makeEventSourceTarget(client, sdkUrl) - } - - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - basePath = apiClient.basePath - sdkPartialUrl = fhConfig - return super.makeFeatureServiceClient(apiClient) - } - } - then: "the urls are correctly initialize" - targetUrl == url.realtimeUrl - basePath == 'http://localhost:80' - sdkPartialUrl.apiKey() == 'sdk-url' - } - - def "test the set feature sdk call"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - and: "I have a client and mock the feature service url" - def client = new JerseyClient(url, false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState("key", update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) - } - - def "test the set feature sdk call using a Feature"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - and: "I have a client and mock the feature service url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState(InternalFeature.FEATURE, update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) - } - - def "a client side evaluation header does not trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader == null - } - - def "a server side evaluation header does not trigger the context header to be set if it is null"() { - given: "i have a client with a server eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - client.neverConnect = true // groovy is groovy - when: "i set attributes" - client.contextChange(null, '0') - then: - client.featurehubContextHeader == null - - } - - def "a server side evaluation header does trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader != null - } - -} diff --git a/client-java-jersey/src/test/resources/log4j2.xml b/client-java-jersey/src/test/resources/log4j2.xml deleted file mode 100644 index fe1b738..0000000 --- a/client-java-jersey/src/test/resources/log4j2.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java deleted file mode 100644 index 3f7a73a..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/FeatureServiceImpl.java +++ /dev/null @@ -1,70 +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 jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; -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; - } - - @Override - public List getFeatureStates(@NotNull List sdkUrl) { - return null; - } - - @Override - public void setFeatureState(String apiKey, @org.jetbrains.annotations.NotNull String featureKey, - @org.jetbrains.annotations.NotNull FeatureStateUpdate featureStateUpdate) { - // verify the required parameter 'apiKey' is set - if (apiKey == null) { - throw new BadRequestException("Missing the required parameter 'apiKey' when calling setFeatureState"); - } - - // 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-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java deleted file mode 100644 index 834135b..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/GoogleAnalyticsJerseyApiClient.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.featurehub.client.jersey; - -import io.featurehub.client.GoogleAnalyticsApiClient; - -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.MediaType; - -public class GoogleAnalyticsJerseyApiClient implements GoogleAnalyticsApiClient { - private final WebTarget target; - - public GoogleAnalyticsJerseyApiClient() { - target = ClientBuilder.newBuilder() - .build().target("https://www.google-analytics.com/batch"); - } - - @Override - public void postBatchUpdate(String batchData) { - target.request().header("Host", "www.google-analytics.com").post(Entity.entity(batchData, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); - } -} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java deleted file mode 100644 index 1e79090..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyClient.java +++ /dev/null @@ -1,334 +0,0 @@ -package io.featurehub.client.jersey; - -import cd.connect.openapi.support.ApiClient; -import io.featurehub.client.EdgeService; -import io.featurehub.client.Feature; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; -import io.featurehub.client.utils.SdkVersion; -import io.featurehub.sse.api.FeatureService; -import io.featurehub.sse.model.FeatureStateUpdate; -import io.featurehub.sse.model.SSEResultState; -import org.glassfish.jersey.jackson.JacksonFeature; -import org.glassfish.jersey.media.sse.EventInput; -import org.glassfish.jersey.media.sse.InboundEvent; -import org.glassfish.jersey.media.sse.SseFeature; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import jakarta.inject.Singleton; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Invocation; -import jakarta.ws.rs.client.WebTarget; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -@Singleton -@Deprecated -public class JerseyClient implements EdgeService { - private static final Logger log = LoggerFactory.getLogger(JerseyClient.class); - private final WebTarget target; - private boolean initialized; - private final Executor executor; - private final FeatureStore repository; - private final FeatureService featuresService; - private boolean shutdown = false; - private boolean shutdownOnServerFailure = true; - private boolean shutdownOnEdgeFailureConnection = false; - private EventInput eventInput; - private String xFeaturehubHeader; - protected final FeatureHubConfig fhConfig; - private List> waitingClients = new ArrayList<>(); - - // only for testing - private boolean neverConnect = false; - - public JerseyClient(FeatureHubConfig config, FeatureStore repository) { - this(config, !config.isServerEvaluation(), repository, null); - } - - public JerseyClient(FeatureHubConfig config, boolean initializeOnConstruction, - FeatureStore repository, ApiClient apiClient) { - this.repository = repository; - this.fhConfig = config; - - log.trace("new jersey client created"); - - repository.setServerEvaluation(config.isServerEvaluation()); - - Client client = ClientBuilder.newBuilder() - .register(JacksonFeature.class) - .register(SseFeature.class).build(); - - target = makeEventSourceTarget(client, config.getRealtimeUrl()); - executor = makeExecutor(); - - if (apiClient == null) { - apiClient = new ApiClient(client, config.baseUrl()); - } - - featuresService = makeFeatureServiceClient(apiClient); - - if (initializeOnConstruction) { - init(); - } - } - - protected ExecutorService makeExecutor() { - // in case they keep changing the context, it will ask the server and cancel and ask and cancel - // if they are in client mode - return Executors.newFixedThreadPool(4); - } - - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - return client.target(sdkUrl); - } - - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return new FeatureServiceImpl(apiClient); - } - - public void setFeatureState(String key, FeatureStateUpdate update) { - featuresService.setFeatureState(fhConfig.apiKey(), key, update); - } - - public void setFeatureState(Feature feature, FeatureStateUpdate update) { - setFeatureState(feature.name(), update); - } - - // backoff algorithm should be configurable - private void avoidServerDdos() { - if (request != null) { - request.active = false; - request = null; - } - - try { - Thread.sleep(10000); // wait 10 seconds - } catch (InterruptedException ignored) { - } - - if (!shutdown) { - executor.execute(this::restartRequest); - } - } - - private CurrentRequest request; - - class CurrentRequest { - public boolean active = true; - - public void listenUntilDead() { - if (neverConnect) return; - - long start = System.currentTimeMillis(); - try { - Invocation.Builder request = target.request(); - - if (xFeaturehubHeader != null) { - request = request.header("x-featurehub", xFeaturehubHeader); - } - - request = request.header("X-SDK", SdkVersion.sdkVersionHeader("Java-Jersey2")); - - eventInput = request - .get(EventInput.class); - - while (!eventInput.isClosed()) { - final InboundEvent inboundEvent = eventInput.read(); - initialized = true; - - // we cannot force close the client input, it hangs around and waits for the server - if (!active) { - return; // ignore all data from this call, it is no longer active or relevant - } - - if (shutdown || inboundEvent == null) { // connection has been closed or is shutdown - break; - } - - log.trace("notifying of {}", inboundEvent.getName()); - - try { - final SSEResultState state = fromValue(inboundEvent.getName()); - - if (state != null && state != SSEResultState.CONFIG) { - repository.notify(state, inboundEvent.readData()); - } else if (state == SSEResultState.CONFIG) { - - } - - if (state == SSEResultState.FAILURE || state == SSEResultState.FEATURES) { - completeReadyness(); - } - - if (state == SSEResultState.FAILURE && shutdownOnServerFailure) { - log.warn("Failed to connect to FeatureHub Edge on {}, shutting down.", fhConfig.getRealtimeUrl()); - shutdown(); - } - } catch (Exception e) { - log.warn("Failed to parse SSE state {}", inboundEvent.getName(), e); - } - } - } catch (Exception e) { - if (shutdownOnEdgeFailureConnection) { - log.warn("Edge connection failed, shutting down"); - repository.notify(SSEResultState.FAILURE, null); - shutdown(); - } - } - - eventInput = null; // so shutdown doesn't get confused - - initialized = false; - - if (!shutdown) { - log.trace("connection closed, reconnecting"); - // timeout should be configurable - if (System.currentTimeMillis() - start < 2000) { - executor.execute(JerseyClient.this::avoidServerDdos); - } else { - // if we have fallen out, try again - executor.execute(this::listenUntilDead); - } - } else { - completeReadyness(); // ensure we clear everyone out who is waiting - - log.trace("featurehub client shut down"); - } - } - } - - protected SSEResultState fromValue(String name) { - try { - return SSEResultState.fromValue(name); - } catch (Exception e) { - return null; // ok to have unrecognized values - } - } - - public boolean isInitialized() { - return initialized; - } - - private void restartRequest() { - log.trace("starting new request"); - if (request != null) { - request.active = false; - } - - initialized = false; - - request = new CurrentRequest(); - request.listenUntilDead(); - } - - void init() { - if (!initialized) { - executor.execute(this::restartRequest); - } - } - - /** - * Tell the client to shutdown when we next fall off. - */ - public void shutdown() { - log.trace("starting shutdown of jersey edge client"); - this.shutdown = true; - - if (request != null) { - request.active = false; - } - - if (eventInput != null) { - eventInput.close(); - } - - if (executor instanceof ExecutorService) { - ((ExecutorService)executor).shutdownNow(); - } - - log.trace("exiting shutdown of jersey edge client"); - } - - public boolean isShutdownOnServerFailure() { - return shutdownOnServerFailure; - } - - public void setShutdownOnServerFailure(boolean shutdownOnServerFailure) { - this.shutdownOnServerFailure = shutdownOnServerFailure; - } - - public boolean isShutdownOnEdgeFailureConnection() { - return shutdownOnEdgeFailureConnection; - } - - public void setShutdownOnEdgeFailureConnection(boolean shutdownOnEdgeFailureConnection) { - this.shutdownOnEdgeFailureConnection = shutdownOnEdgeFailureConnection; - } - - public String getFeaturehubContextHeader() { - return xFeaturehubHeader; - } - - @Override - public @NotNull Future contextChange(String newHeader, String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - - if (fhConfig.isServerEvaluation() && ((newHeader != null && !newHeader.equals(xFeaturehubHeader)) || !initialized)) { - xFeaturehubHeader = newHeader; - - waitingClients.add(change); - executor.execute(this::restartRequest); - } else { - change.complete(repository.getReadyness()); - } - - return change; - } - - private void completeReadyness() { - List> current = waitingClients; - waitingClients = new ArrayList<>(); - current.forEach(c -> { - try { - c.complete(repository.getReadyness()); - } catch (Exception e) { - log.error("Unable to complete future", e); - } - }); - } - - @Override - public boolean isClientEvaluation() { - return !fhConfig.isServerEvaluation(); - } - - @Override - public void close() { - shutdown(); - } - - @Override - public @NotNull FeatureHubConfig getConfig() { - return fhConfig; - } - - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - - @Override - public void poll() { - // do nothing, its SSE - } -} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseyFeatureHubClientFactory.java deleted file mode 100644 index eac5b3d..0000000 --- a/client-java-jersey3/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 createEdgeService(FeatureHubConfig config, FeatureStore repository) { - return () -> new JerseySSEClient(repository, config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - } -} diff --git a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java b/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java deleted file mode 100644 index 609ff45..0000000 --- a/client-java-jersey3/src/main/java/io/featurehub/client/jersey/JerseySSEClient.java +++ /dev/null @@ -1,242 +0,0 @@ -package io.featurehub.client.jersey; - -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; -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.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - -public class JerseySSEClient implements EdgeService, EdgeReconnector { - private static final Logger log = LoggerFactory.getLogger(JerseySSEClient.class); - private final FeatureStore 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<>(); - - public JerseySSEClient(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) { - this.repository = repository; - this.config = config; - this.retryer = retryer; - - 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()); - - target = makeEventSourceTarget(client, config.getRealtimeUrl()); - } - - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - return client.target(sdkUrl); - } - - @Override - public @NotNull Future contextChange(@Nullable String newHeader, @Nullable String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - - if (config.isServerEvaluation() && - ( - (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || - (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) { - waitingClients.add(change); - - poll(); - } else { - change.complete(repository.getReadyness()); - } - - return change; - } - - @Override - public boolean isClientEvaluation() { - return !config.isServerEvaluation(); - } - - @Override - public void close() { - if (eventSource != null) { - if (!eventSource.isClosed()) { - eventSource.close(); - } - - eventSource = null; - } - } - - @Override - public @NotNull FeatureHubConfig getConfig() { - return config; - } - - @Override - public boolean isRequiresReplacementOnHeaderChange() { - return true; - } - - 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() && !interrupted) { - @Nullable String data; - InboundEvent event; - - try { - event = eventSource.read(); - - if (event == null) { - interrupted = true; - continue; - } - data = event.readData(); - } catch (Exception e) { - log.error("failed read", e); - interrupted = true; - continue; - } - - try { - final SSEResultState state = retryer.fromValue(event.getName()); - - if (state == null) { // unknown state - continue; - } - - log.trace("[featurehub-sdk] decode packet {}:{}", event.getName(), data); - - if (state == SSEResultState.CONFIG) { - retryer.edgeConfigInfo(data); - } else { - repository.notify(state, data); - } - - // 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) ) { - waitingClients.forEach(wc -> wc.complete(repository.getReadyness())); - } - } 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); - } - - // send this once we are actually disconnected and not before - retryer.edgeResult(connectionSaidBye ? EdgeConnectionState.SERVER_SAID_BYE : - EdgeConnectionState.SERVER_WAS_DISCONNECTED, this); - } - } - - private void onMakeEventSourceException(Exception e) { - log.info("[featurehub-sdk] failed to connect to {}", config.getRealtimeUrl()); - 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 { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); - } - } else { - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, this); - } - } - - @Override - public void poll() { - if (eventSource == null) { - retryer.getExecutorService().submit(this::initEventSource); - } - } - - @Override - public void reconnect() { - poll(); - } -} diff --git a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy b/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy deleted file mode 100644 index 68f711a..0000000 --- a/client-java-jersey3/src/test/groovy/io/featurehub/client/jersey/JerseyClientSpec.groovy +++ /dev/null @@ -1,121 +0,0 @@ -package io.featurehub.client.jersey - -import cd.connect.openapi.support.ApiClient -import io.featurehub.client.ClientFeatureRepository -import io.featurehub.client.EdgeFeatureHubConfig -import io.featurehub.client.FeatureHubConfig -import io.featurehub.sse.api.FeatureService -import io.featurehub.sse.model.FeatureStateUpdate -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import spock.lang.Specification - -import jakarta.ws.rs.client.Client -import jakarta.ws.rs.client.WebTarget - -class JerseyClientSpec extends Specification { - private static final Logger log = LoggerFactory.getLogger(JerseyClientSpec.class) - def targetUrl - def basePath - FeatureHubConfig sdkPartialUrl - FeatureService mockFeatureService - ClientFeatureRepository mockRepository - WebTarget mockEventSource - - def "basic initialization test works as expect"() { - given: "i have a valid url" - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - when: "i initialize with a valid kind of sdk url" - def client = new JerseyClient(url, new ClientFeatureRepository(1)) { - @Override - protected WebTarget makeEventSourceTarget(Client client, String sdkUrl) { - targetUrl = sdkUrl - return super.makeEventSourceTarget(client, sdkUrl) - } - - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - basePath = apiClient.basePath - sdkPartialUrl = fhConfig - return super.makeFeatureServiceClient(apiClient) - } - } - then: "the urls are correctly initialize" - targetUrl == url.realtimeUrl - basePath == 'http://localhost:80' - sdkPartialUrl.apiKey() == 'sdk-url' - } - - def "test the set feature sdk call"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - def url = new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url") - and: "I have a client and mock the feature service url" - def client = new JerseyClient(url, false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState("key", update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url", "key", update) - } - - def "test the set feature sdk call using a Feature"() { - given: "I have a mock feature service" - mockFeatureService = Mock(FeatureService) - and: "I have a client and mock the feature service url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) { - @Override - protected FeatureService makeFeatureServiceClient(ApiClient apiClient) { - return mockFeatureService - } - } - and: "i have a feature state update" - def update = new FeatureStateUpdate().lock(true) - when: "I call to set a feature" - client.setFeatureState(InternalFeature.FEATURE, update) - then: - mockFeatureService != null - 1 * mockFeatureService.setFeatureState("sdk-url2", "FEATURE", update) - } - - def "a client side evaluation header does not trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk*url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader == null - } - - def "a server side evaluation header does not trigger the context header to be set if it is null"() { - given: "i have a client with a server eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - client.neverConnect = true // groovy is groovy - when: "i set attributes" - client.contextChange(null, '0') - then: - client.featurehubContextHeader == null - - } - - def "a server side evaluation header does trigger the context header to be set"() { - given: "i have a client with a client eval url" - def client = new JerseyClient(new EdgeFeatureHubConfig("http://localhost:80/", "sdk-url2"), - false, new ClientFeatureRepository(1), null) - when: "i set attributes" - client.contextChange("fred=mary,susan", '0') - then: - client.featurehubContextHeader != null - } - -} diff --git a/client-java-sse/CHANGELOG.adoc b/client-java-sse/CHANGELOG.adoc deleted file mode 100644 index f3af608..0000000 --- a/client-java-sse/CHANGELOG.adoc +++ /dev/null @@ -1,4 +0,0 @@ -= CHANGELOG (jersey3) - -- 1.4 - adds functionality for upcoming expired environments -- 1.3 - fully functional client diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java deleted file mode 100644 index 91cbfac..0000000 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClient.java +++ /dev/null @@ -1,230 +0,0 @@ -package io.featurehub.edge.sse; - -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.Readyness; -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.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -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.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 FeatureStore 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(FeatureStore repository, FeatureHubConfig config, EdgeRetryService retryer) { - this.repository = repository; - this.config = config; - this.retryer = retryer; - } - - @Override - public void poll() { - if (eventSource == null) { - initEventSource(); - } - } - - private boolean connectionSaidBye; - - private void initEventSource() { - Request.Builder reqBuilder = new Request.Builder().url(this.config.getRealtimeUrl()); - - if (xFeaturehubHeader != null) { - reqBuilder = reqBuilder.addHeader("x-featurehub", xFeaturehubHeader); - } - - reqBuilder.addHeader("X-SDK", SdkVersion.sdkVersionHeader("Java-OKHTTP-SSE")); - - Request request = reqBuilder.build(); - - // we need to know if the connection already said "bye" so as to pass the right reconnection event - connectionSaidBye = false; - final EdgeReconnector connector = this; - - eventSource = makeEventSource(request, new EventSourceListener() { - @Override - public void onClosed(@NotNull EventSource eventSource) { - log.trace("[featurehub-sdk] closed"); - - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); - } - - // 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, - @NotNull 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 { - repository.notify(state, data); - } - - // 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.getReadyness())); - } - } catch (Exception e) { - log.error("[featurehub-sdk] failed to decode packet {}:{}", type, data, e); - } - } - - @Override - public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) { - log.trace("[featurehub-sdk] failed to connect to {} - {}", config.baseUrl(), response, t); - if (repository.getReadyness() == Readyness.NotReady) { - repository.notify(SSEResultState.FAILURE, null); - } - - 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()); - } - }); - } - - protected EventSource makeEventSource(Request request, EventSourceListener listener) { - if (eventSourceFactory == null) { - client = - new OkHttpClient.Builder() - .readTimeout(0, TimeUnit.MILLISECONDS) - .build(); - - eventSourceFactory = EventSources.createFactory(client); - } - - return eventSourceFactory.newEventSource(request, listener); - } - - - @Override - public @NotNull Future contextChange(String newHeader, String contextSha) { - final CompletableFuture change = new CompletableFuture<>(); - - if (config.isServerEvaluation() && - ( - (newHeader != null && !newHeader.equals(xFeaturehubHeader)) || - (xFeaturehubHeader != null && !xFeaturehubHeader.equals(newHeader)) - ) ) { - - 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.getReadyness()); - } - - return change; - } - - @Override - public boolean isClientEvaluation() { - return !config.isServerEvaluation(); - } - - @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 boolean isRequiresReplacementOnHeaderChange() { - return false; - } - - @Override - public void reconnect() { - initEventSource(); - } -} diff --git a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java b/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java deleted file mode 100644 index 480d576..0000000 --- a/client-java-sse/src/main/java/io/featurehub/edge/sse/SSEClientFactory.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.featurehub.edge.sse; - -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureHubClientFactory; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.FeatureStore; -import io.featurehub.client.edge.EdgeRetryer; - -import java.util.function.Supplier; - -public class SSEClientFactory implements FeatureHubClientFactory { - @Override - public Supplier createEdgeService(FeatureHubConfig url, FeatureStore repository) { - return () -> - new SSEClient(repository, url, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - } -} diff --git a/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory deleted file mode 100644 index c76799d..0000000 --- a/client-java-sse/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory +++ /dev/null @@ -1 +0,0 @@ -io.featurehub.edge.sse.SSEClientFactory diff --git a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java b/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java deleted file mode 100644 index 00a9bed..0000000 --- a/client-java-sse/src/test/java/io/featurehub/edge/sse/SSEClientRunner.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.featurehub.edge.sse; - -import io.featurehub.client.ClientContext; -import io.featurehub.client.ClientFeatureRepository; -import io.featurehub.client.EdgeFeatureHubConfig; -import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.edge.EdgeRetryer; -import io.featurehub.sse.model.StrategyAttributeDeviceName; -import io.featurehub.sse.model.StrategyAttributePlatformName; - -import java.util.function.Supplier; - -public class SSEClientRunner { - public static void main(String[] args) throws Exception { - FeatureHubConfig config = new EdgeFeatureHubConfig("http://localhost:8903", - "default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN"); - - ClientFeatureRepository cfr = new ClientFeatureRepository(); - EdgeRetryer retryer = EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build(); - - final ClientContext ctx = config.newContext(cfr, () -> new SSEClient(cfr, config, retryer)).build().get(); - ctx.getRepository().addReadynessListener(rl -> System.out.println("readyness " + rl.toString())); - - final Supplier val = () -> ctx.feature("FEATURE_TITLE_TO_UPPERCASE").getBoolean(); - - - cfr.addReadynessListener((rl) -> System.out.println("Readyness is " + rl)); - - System.out.println("Wait for readyness or hit enter if server eval key"); - - System.in.read(); - - ctx.userKey("jimbob") - .platform(StrategyAttributePlatformName.MACOS) - .device(StrategyAttributeDeviceName.DESKTOP) - .attr("city", "istanbul").build().get(); - - System.out.println("Istanbul1 is " + val.get()); - - System.out.println("Press a key"); System.in.read(); - - System.out.println("Istanbul2 is " + val.get()); - - ctx.userKey("supine") - .attr("city", "london").build().get(); - - System.out.println("london1 is " + val.get()); - - System.out.println("Press a key"); System.in.read(); - - System.out.println("london2 is " + val.get()); - - System.out.println("Press a key to close"); System.in.read(); - - ctx.close(); - cfr.close(); - } -} diff --git a/client-java-api/README.adoc b/core/client-java-api/README.adoc similarity index 100% rename from client-java-api/README.adoc rename to core/client-java-api/README.adoc diff --git a/client-java-api/pom.xml b/core/client-java-api/pom.xml similarity index 81% rename from client-java-api/pom.xml rename to core/client-java-api/pom.xml index a5dbf2b..0de88c2 100644 --- a/client-java-api/pom.xml +++ b/core/client-java-api/pom.xml @@ -43,6 +43,10 @@ HEAD + + 2.20 + + @@ -56,13 +60,13 @@ jakarta.annotation-api 2.0.0 - + - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - provided + com.fasterxml.jackson.core + jackson-annotations + [${jackson.annotations.version}] + @@ -70,12 +74,12 @@ org.openapitools openapi-generator-maven-plugin - 6.0.1 + 7.0.1 cd.connect.openapi connect-openapi-jersey3 - 8.2 + 9.1 @@ -89,10 +93,11 @@ ${project.basedir}/target/generated-sources/api io.featurehub.sse.api io.featurehub.sse.model - ${project.basedir}/edge-api.yaml + https://api.dev.featurehub.io/edge/1.1.8.yaml jersey3-api false + useBeanValidation=false openApiNullable=false useNullForUnknownEnumValue=true @@ -117,34 +122,17 @@ - - - attach-final-yaml - package - - attach-artifact - - - - - ${project.basedir}/edge-api.yaml - yaml - api - - - - 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) diff --git a/client-java-api/src/main/java/io/featurehub/sse/model/Package.java b/core/client-java-api/src/main/java/io/featurehub/sse/model/Package.java similarity index 100% rename from client-java-api/src/main/java/io/featurehub/sse/model/Package.java rename to core/client-java-api/src/main/java/io/featurehub/sse/model/Package.java diff --git a/client-java-core/.gitignore b/core/client-java-core/.gitignore similarity index 100% rename from client-java-core/.gitignore rename to core/client-java-core/.gitignore diff --git a/client-java-core/CHANGELOG.adoc b/core/client-java-core/CHANGELOG.adoc similarity index 100% rename from client-java-core/CHANGELOG.adoc rename to core/client-java-core/CHANGELOG.adoc diff --git a/client-java-core/pom.xml b/core/client-java-core/pom.xml similarity index 84% rename from client-java-core/pom.xml rename to core/client-java-core/pom.xml index 3d76dd1..f5ebf5f 100644 --- a/client-java-core/pom.xml +++ b/core/client-java-core/pom.xml @@ -4,7 +4,7 @@ io.featurehub.sdk java-client-core - 3.4-SNAPSHOT + 4.1-SNAPSHOT java-client-core @@ -54,14 +54,13 @@ org.apache.commons commons-lang3 - 3.7 + 3.18.0 - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - provided + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] @@ -76,6 +75,13 @@ [1.1, 2) test + + + io.featurehub.sdk.common + common-jacksonv2 + [1.1-SNAPSHOT, 2] + test + @@ -88,7 +94,7 @@ false - io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-java11:[1.1,2) io.featurehub.sdk.tiles:tile-release:[1.1,2) diff --git a/client-java-core/src/main/java/io/featurehub/client/Applied.java b/core/client-java-core/src/main/java/io/featurehub/client/Applied.java similarity index 70% rename from client-java-core/src/main/java/io/featurehub/client/Applied.java rename to core/client-java-core/src/main/java/io/featurehub/client/Applied.java index a1a2695..7acc9c9 100644 --- a/client-java-core/src/main/java/io/featurehub/client/Applied.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/Applied.java @@ -16,4 +16,12 @@ public boolean isMatched() { public Object getValue() { return value; } + + @Override + public String toString() { + return "Applied{" + + "matched=" + matched + + ", value=" + value + + '}'; + } } diff --git a/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java b/core/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java similarity index 96% rename from client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java rename to core/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java index 04c57d3..2d86b23 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ApplyFeature.java @@ -50,10 +50,10 @@ public Applied applyFeature(List strategies, String key, percentage = percentageCalculator.determineClientPercentage(percentageKey, featureValueId); - log.info("percentage for {} on {} calculated at {}", defaultPercentageKey, key, percentage); + log.trace("percentage for {} on {} calculated at {}", defaultPercentageKey, key, percentage); } - log.info("comparing actual {} vs required: {}", percentage, rsi.getPercentage()); + log.trace("comparing actual {} vs required: {}", percentage, rsi.getPercentage()); int useBasePercentage = rsi.getAttributes() == null || rsi.getAttributes().isEmpty() ? basePercentageVal : 0; // if the percentage is lower than the user's key + // id of feature value then apply it diff --git a/core/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java b/core/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java new file mode 100644 index 0000000..1b10589 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/BaseClientContext.java @@ -0,0 +1,291 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventWithFeature; +import io.featurehub.client.usage.UsageFeaturesCollection; +import io.featurehub.client.usage.UsageFeaturesCollectionContext; +import io.featurehub.client.usage.FeatureHubUsageValue; +import io.featurehub.sse.model.FeatureValueType; +import io.featurehub.sse.model.StrategyAttributeCountryName; +import io.featurehub.sse.model.StrategyAttributeDeviceName; +import io.featurehub.sse.model.StrategyAttributePlatformName; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +class BaseClientContext implements InternalContext { + private static final Logger log = LoggerFactory.getLogger(BaseClientContext.class); + protected final EdgeService edgeService; + + public static final String USER_KEY = "userkey"; + public static final String SESSION_KEY = "session"; + public static final String COUNTRY_KEY = "country"; + public static final String DEVICE_KEY = "device"; + public static final String PLATFORM_KEY = "platform"; + public static final String VERSION_KEY = "version"; + protected final Map> attributes = new ConcurrentHashMap<>(); + protected final InternalFeatureRepository repository; + + public BaseClientContext(InternalFeatureRepository repository, EdgeService edgeService) { + this.repository = repository; + this.edgeService = edgeService; + } + + @Override + public EdgeService getEdgeService() { + return edgeService; + } + + @Override + public String get(String key, String defaultValue) { + if (attributes.containsKey(key)) { + final List vals = attributes.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 = attributes.get(key); + return attrs == null ? Arrays.asList(defaultValue) : attrs; + } + + @Override + public ClientContext userKey(String userKey) { + attributes.put(USER_KEY, Collections.singletonList(userKey)); + return this; + } + + @Override + public ClientContext sessionKey(String sessionKey) { + attributes.put(SESSION_KEY, Collections.singletonList(sessionKey)); + return this; + } + + @Override + public ClientContext country(StrategyAttributeCountryName countryName) { + attributes.put(COUNTRY_KEY, Collections.singletonList(countryName.toString())); + return this; + } + + @Override + public ClientContext device(StrategyAttributeDeviceName deviceName) { + attributes.put(DEVICE_KEY, Collections.singletonList(deviceName.toString())); + return this; + } + + @Override + public ClientContext platform(StrategyAttributePlatformName platformName) { + attributes.put(PLATFORM_KEY, Collections.singletonList(platformName.toString())); + return this; + } + + @Override + public ClientContext version(String version) { + attributes.put(VERSION_KEY, Collections.singletonList(version)); + return this; + } + + @Override + public ClientContext attr(String name, String value) { + attributes.put(name, Collections.singletonList(value)); + return this; + } + + @Override + public ClientContext attrs(String name, List values) { + attributes.put(name, values); + return this; + } + + @Override + public ClientContext attrs(Map> values) { + attributes.clear(); + attributes.putAll(values); + + return this; + } + + @Override + public ClientContext attrsMerge(Map> values) { + attributes.putAll(values); + + return this; + } + + @Override + public void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, + @NotNull FeatureValueType valueType) { + final HashMap> attrCopy = new HashMap<>(attributes); + final String userKey = usageUserKey(); + + log.trace("recording usage for key: {}, id: {}, value: {}, valueType: {}, userKey: {}, attributes: {}", + key, id, val, valueType, userKey, attrCopy); + + repository.execute(() -> { + try { + repository.used(key, id, valueType, val, attrCopy, userKey); + + // a feature has been evaluated, so this allows us to trigger to see if the + // time limit has expired on checking for a state update. + edgeService.poll().get(); + } catch (Exception e) { + log.error("Failed to poll", e); + } + }); + } + + /** + * This uniquely identifies the user of this SDK if the SDK user has chosen to do so. It can be completely opaque + * (e.g. sha of a user's email). + * + * @return null or unique identifier + */ + @Nullable String usageUserKey() { + return getAttr("session", getAttr("userkey")); + } + + + protected void recordFeatureChangedForUser(FeatureStateBase feature) { + repository.recordUsageEvent(new UsageEventWithFeature( + new FeatureHubUsageValue(feature.withContext(this)), attributes, + usageUserKey())); + } + + protected void recordRelativeValuesForUser() { + repository.recordUsageEvent(fillUsageCollection(repository.getUsageProvider().createUsageCollectionEvent())); + } + + public @NotNull T fillUsageCollection(@NotNull T event) { + event.setUserKey(usageUserKey()); + + if (event instanceof UsageFeaturesCollection) { + ((UsageFeaturesCollection)event).setFeatureValues( + repository.getFeatureKeys().stream().map((k) -> + new FeatureHubUsageValue(repository.getFeat(k))).collect(Collectors.toList())); + } + + if (event instanceof UsageFeaturesCollectionContext) { + ((UsageFeaturesCollectionContext)event).setAttributes(attributes); + } + + return event; + } + + @Override + public void recordUsageEvent(@NotNull T event) { + repository.recordUsageEvent(fillUsageCollection(event)); + } + + @Override + @Nullable public String getAttr(@NotNull String name, @Nullable String defaultVal) { + String val = getAttr(name); + return val == null ? defaultVal : val; + } + + @Override + @Nullable public String getAttr(@NotNull String name) { + return attributes.containsKey(name) ? attributes.get(name).get(0) : null; + } + + @Override + @Nullable public List getAttrs(@NotNull String name) { + return attributes.getOrDefault(name, null); + } + + @Override + public Future build() { + return CompletableFuture.completedFuture(this); + } + + @Override + public ClientContext clear() { + attributes.clear(); + return this; + } + + @Override + public Map> context() { + return Collections.unmodifiableMap(attributes); + } + + @Override + public String defaultPercentageKey() { + if (attributes.containsKey(SESSION_KEY)) { + return attributes.get(SESSION_KEY).get(0); + } + if (attributes.containsKey(USER_KEY)) { + return attributes.get(USER_KEY).get(0); + } + + return null; + } + + @Override + public @NotNull FeatureState feature(String name) { + return repository.getFeat(name).withContext(this); + } + + @Override + public @NotNull List> allFeatures() { + return repository.getFeatureKeys().stream() + .map(f -> repository.getFeat(f).withContext(this)) + .collect(Collectors.toList()); + } + + @Override + public @NotNull 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 @NotNull FeatureRepository getRepository() { + return repository; + } + + @Override + public boolean exists(String key) { + return feature(key).exists(); + } + + @Override + public void close() { + edgeService.close(); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientContext.java similarity index 54% rename from client-java-core/src/main/java/io/featurehub/client/ClientContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ClientContext.java index 4ac717b..b1fe6ac 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientContext.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientContext.java @@ -1,5 +1,6 @@ package io.featurehub.client; +import io.featurehub.client.usage.UsageEvent; import io.featurehub.sse.model.StrategyAttributeCountryName; import io.featurehub.sse.model.StrategyAttributeDeviceName; import io.featurehub.sse.model.StrategyAttributePlatformName; @@ -13,7 +14,7 @@ public interface ClientContext { String get(String key, String defaultValue); @NotNull List<@NotNull String> getAttrs(String key, @NotNull String defaultValue); - @Nullable List<@NotNull String> getAttrs(String key); + @Nullable List getAttrs(@NotNull String name); ClientContext userKey(String userKey); ClientContext sessionKey(String sessionKey); @@ -24,8 +25,14 @@ public interface ClientContext { ClientContext attr(String name, String value); ClientContext attrs(String name, List values); + ClientContext attrs(Map> values); + ClientContext attrsMerge(Map> values); + ClientContext clear(); + @Nullable String getAttr(@NotNull String name); + @Nullable String getAttr(@NotNull String name, @Nullable String defaultVal); + /** * Triggers the build and setting of this context. * @@ -36,15 +43,12 @@ public interface ClientContext { Map> context(); String defaultPercentageKey(); - FeatureState feature(String name); - FeatureState feature(Feature name); - List allFeatures(); - - FeatureRepository getRepository(); - EdgeService getEdgeService(); + @NotNull FeatureState feature(String name); + @NotNull FeatureState feature(Feature name); + @NotNull List> allFeatures(); - ClientContext logAnalyticsEvent(String action, Map other); - ClientContext logAnalyticsEvent(String action); + @NotNull FeatureRepository getRepository(); + @NotNull EdgeService getEdgeService(); /** * true if it is a boolean feature and is true within this context. @@ -61,5 +65,21 @@ public interface ClientContext { boolean exists(String key); boolean exists(Feature key); + /** + * If you have a custom usage event you wish to record, add it here. It will capture any associated data from + * the current context if possible and add it to the analytics event. + * @param event + */ + void recordUsageEvent(@NotNull T event); + + /** + * Use this method to set all the fields of your UsageEvent. It will add the user key in, + * collect all feature values (if a UsageFeaturesCollection) and add in the context attributes (if a UsageFeaturesCollectionContext) + * @param event - the vent to fill in + * @return the filled in collection + * @param a type that extends UsageEvent + */ + @NotNull T fillUsageCollection(@NotNull T event); + void close(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java similarity index 66% rename from client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java index e6dbcea..991c3ec 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java @@ -8,13 +8,10 @@ * This class is ONLY used when we are doing client side evaluation. So the edge service stays the same. */ class ClientEvalFeatureContext extends BaseClientContext { - private final EdgeService edgeService; - public ClientEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContext repository, + public ClientEvalFeatureContext(FeatureHubConfig config, InternalFeatureRepository repository, EdgeService edgeService) { - super(repository, config); - - this.edgeService = edgeService; + super(repository, edgeService); } // this doesn't matter for client eval @@ -27,17 +24,7 @@ public Future build() { } return this; - }); - } - - @Override - public FeatureState feature(String name) { - return repository.getFeatureState(name).withContext(this); - } - - @Override - public EdgeService getEdgeService() { - return edgeService; + }, repository.getExecutor()); } /** diff --git a/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java new file mode 100644 index 0000000..16aa86c --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/ClientFeatureRepository.java @@ -0,0 +1,363 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; +import io.featurehub.client.usage.FeatureHubUsageValue; +import io.featurehub.javascript.JavascriptObjectMapper; +import io.featurehub.javascript.JavascriptServiceLoader; +import io.featurehub.sse.model.FeatureRolloutStrategy; +import io.featurehub.sse.model.FeatureValueType; +import io.featurehub.sse.model.SSEResultState; +import io.featurehub.strategies.matchers.MatcherRegistry; +import io.featurehub.strategies.percentage.PercentageMumurCalculator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.function.Consumer; + +public class ClientFeatureRepository implements InternalFeatureRepository { + private static class Callback implements RepositoryEventHandler { + private final List> handlers; + public final Consumer callback; + + public Callback(List> handlers, Consumer callback) { + this.handlers = handlers; + this.handlers.add(this); + this.callback = callback; + } + + @Override + public void cancel() { + this.handlers.remove(this); + } + } + + private static final Logger log = LoggerFactory.getLogger(ClientFeatureRepository.class); + // feature-key, feature-state + private final Map> features = new ConcurrentHashMap<>(); + private final Map> featuresById = new ConcurrentHashMap<>(); + @NotNull private ExecutorService executor; + private boolean hasReceivedInitialState = false; + private Readiness readiness = Readiness.NotReady; + private final List> readinessListeners = new ArrayList<>(); + private final List> newStateAvailableHandlers = new ArrayList<>(); + private final List>> featureUpdateHandlers = new ArrayList<>(); + private final List featureValueInterceptors = new ArrayList<>(); + private final List> usageHandlers = new ArrayList<>(); + private UsageProvider usageProvider = new UsageProvider.DefaultUsageProvider(); + + private JavascriptObjectMapper jsonConfigObjectMapper; + private final ApplyFeature applyFeature; + + public ClientFeatureRepository(ExecutorService executor, ApplyFeature applyFeature) { + jsonConfigObjectMapper = JavascriptServiceLoader.load(); + + 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 static ExecutorService getExecutor(int threadPoolSize) { + int maxThreads = Math.max(threadPoolSize, 10); + return new ThreadPoolExecutor(3, maxThreads, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), new FeatureHubThreadFactory()); + } + + public void setJsonConfigObjectMapper(@NotNull JavascriptObjectMapper jsonConfigObjectMapper) { + this.jsonConfigObjectMapper = jsonConfigObjectMapper; + } + + public @NotNull Readiness getReadyness() { + return getReadiness(); + } + + @Override + public @NotNull Readiness getReadiness() { + return readiness; + } + + @Override + public @NotNull FeatureRepository registerValueInterceptor( + boolean allowFeatureOverride, @NotNull FeatureValueInterceptor interceptor) { + featureValueInterceptors.add( + new FeatureValueInterceptorHolder(allowFeatureOverride, interceptor)); + + return this; + } + + @Override + public void registerUsageProvider(@NotNull UsageProvider provider) { + this.usageProvider = provider; + } + + @Override + public @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback) { + return new Callback<>(newStateAvailableHandlers, callback); + } + + @Override + public @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback) { + return new Callback<>(featureUpdateHandlers, callback); + } + + @Override + public @NotNull RepositoryEventHandler registerUsageStream(@NotNull Consumer callback) { + return new Callback<>(usageHandlers, callback); + } + + @Override + public void notify(@NotNull SSEResultState state) { + log.trace("received state {}", state); + try { + switch (state) { + case ACK: + case BYE: + case DELETE_FEATURE: + case FEATURE: + case FEATURES: + break; + case FAILURE: + readiness = Readiness.Failed; + broadcastReadyness(); + break; + } + } catch (Exception e) { + log.error("Unable to process state `{}`", state, e); + } + } + + @Override + public void updateFeatures(@NotNull List features) { + updateFeatures(features, false); + } + + @Override + public void updateFeatures(List states, boolean force) { + states.forEach(s -> updateFeature(s, force)); + + if (!hasReceivedInitialState) { + hasReceivedInitialState = true; + readiness = Readiness.Ready; + broadcastReadyness(); + } else if (readiness != Readiness.Ready) { + readiness = Readiness.Ready; + broadcastReadyness(); + } + } + + @Override + public @NotNull Applied applyFeature( + @NotNull List strategies, @NotNull String key, @NotNull String featureValueId, @NotNull ClientContext cac) { + return applyFeature.applyFeature(strategies, key, featureValueId, cac); + } + + @Override + public void execute(@NotNull Runnable command) { + executor.execute(command); + } + + @Override + public ExecutorService getExecutor() { + return executor; + } + + @Override + public @NotNull JavascriptObjectMapper getJsonObjectMapper() { + return jsonConfigObjectMapper; + } + + @Override + public void repositoryNotReady() { + readiness = Readiness.NotReady; + broadcastReadyness(); + } + + @Override + public void close() { + log.info("featurehub repository closing"); + features.clear(); + + readiness = Readiness.NotReady; + readinessListeners.forEach(rl -> rl.callback.accept(readiness)); + readinessListeners.clear(); + + executor.shutdownNow(); + + log.info("featurehub repository closed"); + } + + @Override + public @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer rl) { + final Callback callback = new Callback<>(readinessListeners, rl); + + if (!executor.isShutdown()) { + // let it know what the current state is + executor.execute(() -> rl.accept(readiness)); + } + + return callback; + } + + private void broadcastReadyness() { + log.trace("broadcasting readiness {} listener count {}", readiness, readinessListeners.size()); + if (!executor.isShutdown()) { + readinessListeners.forEach((rl) -> executor.execute(() -> rl.callback.accept(readiness))); + } + } + + public void deleteFeature(@NotNull io.featurehub.sse.model.FeatureState readValue) { + readValue.setValue(null); + updateFeature(readValue); + } + + @Override + public @NotNull List> getAllFeatures() { + return new ArrayList<>(features.values()); + } + + @Override + public @NotNull Set getFeatureKeys() { + return features.keySet(); + } + + @Override + public @NotNull FeatureState feature(String key) { + return getFeat(key); + } + + @Override + public @NotNull FeatureState feature(String key, Class clazz) { + return getFeat(key, clazz); + } + + public boolean updateFeature(@NotNull io.featurehub.sse.model.FeatureState featureState) { + return updateFeature(featureState, false); + } + + @Override + public boolean updateFeature(@NotNull io.featurehub.sse.model.FeatureState featureState, boolean force) { + FeatureStateBase holder = features.get(featureState.getKey()); + if (holder == null) { + holder = new FeatureStateBase<>(this, featureState.getKey()); + + features.put(featureState.getKey(), holder); + } else if (holder.feature.fs != null && !force) { + long existingVersion = holder.feature.fs.getVersion() == null ? -1 : holder.feature.fs.getVersion(); + long newVersion = featureState.getVersion() == null ? -1 : featureState.getVersion(); + if (existingVersion > newVersion + || (newVersion == existingVersion + && !FeatureStateUtils.changed( + holder.feature.fs.getValue(), featureState.getValue()))) { + // if the old existingVersion is newer, or they are the same existingVersion and the value hasn't changed. + // it can change with server side evaluation based on user data + return false; + } + } + + holder.setFeatureState(featureState); + featuresById.put(featureState.getId(), holder); + + if (hasReceivedInitialState) { + broadcastFeatureUpdatedListeners(holder); + } + + return true; + } + + @NotNull public FeatureStateBase getFeat(@NotNull String key) { + return getFeat(key, Boolean.class); + } + + @Override + @NotNull public FeatureStateBase getFeat(@NotNull Feature key) { + return getFeat(key.name(), Boolean.class); + } + + @Override + @SuppressWarnings("unchecked") // it is all fake anyway + @NotNull public FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz) { + return (FeatureStateBase) features.computeIfAbsent( + key, + key1 -> { + if (hasReceivedInitialState) { + log.warn( + "FeatureHub error: application requesting use of invalid key after initialization: `{}`", + key1); + } + + return new FeatureStateBase(this, key); + }); + } + + private void broadcastFeatureUpdatedListeners(@NotNull FeatureState fs) { + featureUpdateHandlers.forEach((handler) -> execute(() -> handler.callback.accept(fs))); + } + + @Override + public void recordUsageEvent(@NotNull UsageEvent event) { + usageHandlers.forEach(handler -> execute(() -> handler.callback.accept(event))); + } + + @Override + public void repositoryEmpty() { + readiness = Readiness.Ready; + broadcastReadyness(); + } + + @Override + public void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, + @Nullable Object value, @Nullable Map> attributes, + String usageUserKey) { + recordUsageEvent(usageProvider.createUsageFeature(new FeatureHubUsageValue(id.toString(), key, + value, valueType + ), attributes, usageUserKey)); + } + + @Override + public FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull String key) { + return featureValueInterceptors.stream() + .filter(vi -> !locked || vi.allowLockOverride) + .map( + vi -> { + FeatureValueInterceptor.ValueMatch vm = vi.interceptor.getValue(key); + if (vm != null && vm.matched) { + return vm; + } else { + return null; + } + }) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + @Override + public @NotNull UsageProvider getUsageProvider() { + return usageProvider; + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java new file mode 100644 index 0000000..271fed9 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java @@ -0,0 +1,292 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageAdapter; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsagePlugin; +import io.featurehub.javascript.JavascriptObjectMapper; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.ServiceLoader; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +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 List apiKeys; + @NotNull + private InternalFeatureRepository repository = new ClientFeatureRepository(); + @Nullable + private EdgeService edgeService; + @Nullable + private Supplier edgeServiceSupplier; + + @Nullable private ServerEvalFeatureContext serverEvalFeatureContext; + + @Nullable ServiceLoader loader; + + @Nullable TestApi testApi; + + @NotNull private final UsageAdapter usageAdapter; + + private EdgeType edgeType = EdgeType.REST_PASSIVE; + private int timeout; + + public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull String apiKey) { + this(edgeUrl, Collections.singletonList(apiKey)); + } + + public EdgeFeatureHubConfig(@NotNull String edgeUrl, @NotNull List apiKeys) { + this.apiKeys = apiKeys; + + if (this.apiKeys.isEmpty()) { + throw new RuntimeException("Cannot use empty list of sdk keys"); + } + + serverEvaluation = !FeatureHubConfig.sdkKeyIsClientSideEvaluated(apiKeys); + + // set defaults + if (serverEvaluation) { + restPassive(); + } else { + streaming(); + } + + 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); + + realtimeUrl = String.format("%s/features/%s", edgeUrl, apiKeys.get(0)); + + usageAdapter = new UsageAdapter(repository); + } + + @Override + public FeatureHubConfig registerUsagePlugin(@NotNull UsagePlugin plugin) { + usageAdapter.registerPlugin(plugin); + return this; + } + + @Override + @NotNull + public String getRealtimeUrl() { + return realtimeUrl; + } + + @Override + @NotNull + public String apiKey() { + return apiKeys.get(0); + } + + @Override + public @NotNull List apiKeys() { + return apiKeys; + } + + @Override + @NotNull + public String baseUrl() { + return edgeUrl; + } + + /** + * This provides an async wait to trigger off the client. + */ + @Override + public Future init() { + return newContext().build(); + } + + @Override + public void init(long timeout, TimeUnit unit) { + try { + final Future futureContext = newContext().build(); + futureContext.get(timeout, unit); + } catch (Exception e) { + log.warn("Failed to initialize FeatureHub client", e); + } + } + + @Override + public boolean isServerEvaluation() { + return serverEvaluation; + } + + @Override + @NotNull + public ClientContext newContext() { + if (this.edgeService == null) { + this.edgeService = loadEdgeService(repository).get(); + } + + if (isServerEvaluation()) { + if (serverEvalFeatureContext == null) { + serverEvalFeatureContext = new ServerEvalFeatureContext(repository, edgeService); + } + + return serverEvalFeatureContext; + } + + return new ClientEvalFeatureContext(this, repository, edgeService); + } + + /** + * dynamically load an edge service implementation + */ + @NotNull + protected Supplier loadEdgeService(@NotNull InternalFeatureRepository repository) { + if (edgeServiceSupplier == null) { + ServiceLoader loader = ServiceLoader.load(FeatureHubClientFactory.class); + + for (FeatureHubClientFactory f : loader) { + if (edgeType == EdgeType.STREAMING) { + edgeServiceSupplier = f.createSSEEdge(this, repository); + } else if (edgeType == EdgeType.REST_PASSIVE) { + edgeServiceSupplier = f.createRestEdge(this, repository, timeout, false); + } else { + edgeServiceSupplier = () -> new PollingDelegateEdgeService( + f.createRestEdge(this, repository, timeout, true).get(), + repository); + } + } + } + + if (edgeServiceSupplier != null) { + return edgeServiceSupplier; + } + + throw new RuntimeException("Unable to find an edge service for featurehub, please include one on classpath."); + } + + @Override + public FeatureHubConfig setRepository(@NotNull FeatureRepository repository) { + this.repository = (InternalFeatureRepository) repository; + return this; + } + + @Override + @NotNull + public FeatureRepository getRepository() { + return repository; + } + + @Override + public @NotNull InternalFeatureRepository getInternalRepository() { + return repository; + } + + @Override + public FeatureHubConfig setEdgeService(@NotNull Supplier edgeService) { + this.edgeServiceSupplier = edgeService; + return this; + } + + @Override + @NotNull + public Supplier getEdgeService() { + return loadEdgeService(repository); + } + + @Override + public @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener) { + return repository.addReadinessListener(readinessListener); + } + + @Override + public FeatureHubConfig registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor) { + getRepository().registerValueInterceptor(allowLockOverride, interceptor); + return this; + } + + + @Override + public FeatureHubConfig recordUsageEvent(UsageEvent event) { + getInternalRepository().recordUsageEvent(event); + return this; + } + + @Override + @NotNull + public Readiness getReadiness() { + return getRepository().getReadiness(); + } + + @Override + public FeatureHubConfig setJsonConfigObjectMapper(@NotNull JavascriptObjectMapper jsonConfigObjectMapper) { + getRepository().setJsonConfigObjectMapper(jsonConfigObjectMapper); + return this; + } + + @Override + public void close() { + if (edgeService != null) { + log.trace("closing edge connection"); + edgeService.close(); + edgeService = null; + } + if (testApi != null) { + log.trace("closing test api"); + testApi.close(); + testApi = null; + } + } + + @Override + public FeatureHubConfig streaming() { + edgeType = EdgeType.STREAMING; + timeout = 0; + return this; + } + + private enum EdgeType { + STREAMING, REST_PASSIVE, REST_ACTIVE + } + + @Override + public FeatureHubConfig restActive() { + this.timeout = 180; + edgeType = EdgeType.REST_ACTIVE; + return this; + } + + @Override + public FeatureHubConfig restActive(int intervalInSeconds) { + this.timeout = intervalInSeconds; + edgeType = EdgeType.REST_ACTIVE; + return this; + } + + @Override + public FeatureHubConfig restPassive(int cacheTimeoutInSeconds) { + this.timeout = cacheTimeoutInSeconds; + edgeType = EdgeType.REST_PASSIVE; + return this; + } + + @Override + public FeatureHubConfig restPassive() { + this.timeout = 180; + edgeType = EdgeType.REST_PASSIVE; + return this; + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java b/core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java new file mode 100644 index 0000000..81640a3 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/EdgeService.java @@ -0,0 +1,54 @@ +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(); + + /** + * Has been stopped for some reason + * @return true if stopped + */ + boolean isStopped(); + + /** + * Shut down this service + */ + void close(); + + @NotNull + FeatureHubConfig getConfig(); + + /** + * @return a future which will be completed when the poll has finished. for SSE this will be the 1st return, for + * REST it will be the response. + */ + Future poll(); + + /** + * Only used for REST interfacces, 0 otherwise, and 0 for one-shot calls. + * + * @return - current interval which can change based on data sent from server. + */ + long currentInterval(); + + default boolean needsContextChange(String newHeader, String contextSha) { + return false; + } +} diff --git a/client-java-android21/src/main/java/io/featurehub/client/Feature.java b/core/client-java-core/src/main/java/io/featurehub/client/Feature.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Feature.java rename to core/client-java-core/src/main/java/io/featurehub/client/Feature.java diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java new file mode 100644 index 0000000..a3cc87f --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubClientFactory.java @@ -0,0 +1,25 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Supplier; + +/** + * allows the creation of a new edge service without knowing about the underlying implementation. + * depending on which library is included, this will automatically be created. + */ +public interface FeatureHubClientFactory { + + @NotNull Supplier createSSEEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository); + + @NotNull Supplier createSSEEdge(@NotNull FeatureHubConfig config); + + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, + @Nullable InternalFeatureRepository repository, + int timeoutInSeconds, boolean amPollingDelegate); + + @NotNull Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPollingDelegate); + + @NotNull Supplier createTestApi(@NotNull FeatureHubConfig config); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java new file mode 100644 index 0000000..fb50d70 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubConfig.java @@ -0,0 +1,147 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsagePlugin; +import io.featurehub.javascript.JavascriptObjectMapper; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public interface FeatureHubConfig { + + static @Nullable String getConfig(@NotNull String name) { + String val = System.getenv(name); + if (val == null) { + val = System.getProperty(name); + + if (val == null) { + val = System.getenv(name.toUpperCase()); + if (val == null) { + val = System.getenv(name.toUpperCase().replace('.', '_').replace('-', '_')); + } + } + } + + return val; + } + + static String getRequiredConfig(@NotNull String name) { + String val = getConfig(name); + + if (val == null) { + throw new RuntimeException(String.format("Required configuration `%s` is missing!", name)); + } + + return val; + } + + static @NotNull String getConfig(@NotNull String name, @NotNull String defaultVal) { + String val = getConfig(name); + + return val == null ? defaultVal : val; + } + + /** + * What is the fully deconstructed URL for the server? + */ + @NotNull String getRealtimeUrl(); + + @NotNull String apiKey(); + @NotNull List<@NotNull String> apiKeys(); + + @NotNull String baseUrl(); + + /** + * If you are using a client evaluated feature context, this will initialise the service and block until + * the provided timeout or until you have received your first set of features. Server Evaluated contexts + * should not use it because it needs to re-request data from the server each time you change your context. + */ + void init(long timeout, TimeUnit unit); + + /** + * If you are using a client evaluated feature context, this will initialise the service and block until + * you have received your first set of features. Server Evaluated contexts should not use it because it needs + * to re-request data from the server each time you change your context. + */ + Future 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 + */ + @NotNull ClientContext newContext(); + + static boolean sdkKeyIsClientSideEvaluated(Collection sdkKey) { + return sdkKey.stream().anyMatch(key -> key != null && key.contains("*")); + } + + FeatureHubConfig setRepository(FeatureRepository repository); + @NotNull FeatureRepository getRepository(); + @NotNull InternalFeatureRepository getInternalRepository(); + + FeatureHubConfig setEdgeService(Supplier edgeService); + @NotNull Supplier getEdgeService(); + + /** + * Allows you to specify a readyness listener to trigger every time the repository goes from + * being in any way not reaay, to ready. + * @param readinessListener + */ + @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener); + + /** + * Allows you to register a value interceptor + * @param allowLockOverride + * @param interceptor + */ + FeatureHubConfig registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); + + FeatureHubConfig registerUsagePlugin(@NotNull UsagePlugin plugin); + + /** + * Allows you to query the state of the repository's readyness - such as in a heartbeat API + * @return + */ + @NotNull Readiness getReadiness(); + + /** + * Allows you to override how your config will be deserialized when "getJson" is called. + * + * @param jsonConfigObjectMapper - a Jackson ObjectMapper + */ + FeatureHubConfig setJsonConfigObjectMapper(JavascriptObjectMapper jsonConfigObjectMapper); + + /** + * You should use this close if you are using a client evaluated key and wish to close the connection to the remote + * server cleanly + */ + void close(); + + FeatureHubConfig streaming(); + + /** + * interval defaults to 180 seconds + */ + FeatureHubConfig restActive(); + FeatureHubConfig restActive(int intervalInSeconds); + FeatureHubConfig restPassive(int cacheTimeoutInSeconds); + + /** + * cache timeout defaults to 180 seconds + */ + FeatureHubConfig restPassive(); + + FeatureHubConfig recordUsageEvent(UsageEvent event); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java new file mode 100644 index 0000000..d37b7fb --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureHubThreadFactory.java @@ -0,0 +1,38 @@ +package io.featurehub.client; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * We just want to have our threads named "FeatureHub" so folks can find them. + */ +public class FeatureHubThreadFactory implements ThreadFactory { + + // Note: The source code for this class was based entirely on + // Executors.DefaultThreadFactory class from the JDK8 source. + // The only change made is the ability to configure the thread + // name prefix. + + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final String namePrefix; + + public FeatureHubThreadFactory() { + SecurityManager s = System.getSecurityManager(); + group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); + namePrefix = "featurehub-" + poolNumber.getAndIncrement() + "-thread-"; + } + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); + if (t.isDaemon()) { + t.setDaemon(false); + } + if (t.getPriority() != Thread.NORM_PRIORITY) { + t.setPriority(Thread.NORM_PRIORITY); + } + return t; + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java similarity index 59% rename from client-java-core/src/main/java/io/featurehub/client/FeatureListener.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java index b49c9de..2d9953f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureListener.java @@ -1,5 +1,5 @@ package io.featurehub.client; public interface FeatureListener { - void notify(FeatureState featureChanged); + void notify(FeatureState featureChanged); } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java new file mode 100644 index 0000000..cd8c88f --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureRepository.java @@ -0,0 +1,52 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; +import io.featurehub.javascript.JavascriptObjectMapper; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; + +public interface FeatureRepository { + /** + * Changes in readyness for the repository. It can become ready and then fail if subsequent + * calls fail. + * + * @param readinessListener - a callback lambda + * @return - this FeatureRepository + */ + @NotNull RepositoryEventHandler addReadinessListener(@NotNull Consumer readinessListener); + + @NotNull List> getAllFeatures(); + @NotNull Set getFeatureKeys(); + @NotNull FeatureState feature(String key); + @NotNull FeatureState feature(String key, Class clazz); + + /** + * Adds interceptor support for feature values. + * + * @param allowLockOverride - is this interceptor allowed to override the lock? i.e. if the feature is locked, we + * ignore the interceptor + * @param interceptor - the interceptor + * @return the instance of the repo for chaining + */ + @NotNull FeatureRepository registerValueInterceptor(boolean allowLockOverride, @NotNull FeatureValueInterceptor interceptor); + void registerUsageProvider(@NotNull UsageProvider provider); + + @NotNull RepositoryEventHandler registerNewFeatureStateAvailable(@NotNull Consumer callback); + @NotNull RepositoryEventHandler registerFeatureUpdateAvailable(@NotNull Consumer> callback); + @NotNull RepositoryEventHandler registerUsageStream(@NotNull Consumer callback); + + @NotNull Readiness getReadiness(); + + /** + * 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(@NotNull JavascriptObjectMapper jsonConfigObjectMapper); + + void close(); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureState.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureState.java new file mode 100644 index 0000000..6d18457 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureState.java @@ -0,0 +1,71 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigDecimal; +import java.util.Map; + +public interface FeatureState { + /** + * @return - The key, it is always set, even if this is a feature that doesn't exist in the underlying repository + */ + @NotNull String getKey(); + + @Nullable String getString(); + + /** + * @deprecated recommend now using the getFlag() method + */ + @Deprecated() + @Nullable Boolean getBoolean(); + + /** + * use isEnabled() if you want to have true/false regardless + * @return - true if the feature is a flag, has a value and it is true + */ + @Nullable Boolean getFlag(); + + /** + * Gets the value raw and tries to make it appear as the type you request, regardless of + * the underlying type. If it is a boolean and you ask for it as a string, it will still be a bool and + * will cause a compile error. + * + * @param clazz for fake typing + * @return the determined value (can be overridden by feature value interceptors) + */ + @Nullable K getValue(Class clazz); + + @Nullable BigDecimal getNumber(); + + @Nullable String getRawJson(); + + @Nullable T getJson(Class type); + + /** + * true if the flag is boolean and is true + */ + boolean isEnabled(); + + boolean isSet(); + + /** + * @return Are we dealing with a feature that actually exists in the underlying repository? + */ + boolean exists(); + + boolean isLocked(); + + /** + * Adds a listener to a feature. Do *not* add a listener to a context in server mode, where you are creating + * lots of contexts as this will lead to a memory leak. + * + * @param listener + */ + void addListener(@NotNull FeatureListener listener); + + @Nullable FeatureValueType getType(); + + @NotNull Map featureProperties(); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java new file mode 100644 index 0000000..6d0c030 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateBase.java @@ -0,0 +1,329 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.*; + +/** + * This class is just the base class to avoid a lot of duplication effort and to ensure the + * maximum performance for each feature in updating its listeners and knowing what type it is. + */ +public class FeatureStateBase implements FeatureState { + private static final Logger log = LoggerFactory.getLogger(FeatureStateBase.class); + protected final TopFeatureState feature; + protected final FeatureStateBase top; + protected final List listeners; + protected InternalContext context; + protected FeatureStateBase parentHolder; + protected final InternalFeatureRepository repository; + + // any levels of the hierarchy always point to this object + class TopFeatureState { + public io.featurehub.sse.model.FeatureState fs; + public String key; // we always keep this in case the state gets reset to null + + public TopFeatureState(String key) { + this.key = key; + } + } + + // if this is a child + public FeatureStateBase( + @NotNull InternalFeatureRepository repository, + @NotNull FeatureStateBase parentHolder) { + this.repository = repository; + this.parentHolder = parentHolder; + feature = parentHolder.feature; + + top = top(); + listeners = top.listeners; + } + + // this is exclusively for internal analytic copying + protected FeatureStateBase(@NotNull InternalFeatureRepository repository, @NotNull String key, + @Nullable io.featurehub.sse.model.FeatureState featureState) { + this.repository = repository; + this.parentHolder = null; + this.feature = new TopFeatureState(key); + this.feature.fs = featureState; + top = this; + this.listeners = new ArrayList<>(); + } + + // this is for a new FeatureStateBase + public FeatureStateBase(@NotNull InternalFeatureRepository repository, String key) { + this.repository = repository; + this.feature = new TopFeatureState(key); + top = this; + this.listeners = new ArrayList<>(); + } + + public FeatureStateBase withContext(InternalContext context) { + final FeatureStateBase copy = _copy(); + copy.context = context; + return copy; + } + + // should only be used in constructor and is set once + @NotNull protected FeatureStateBase top() { + if (parentHolder == null) { + return this; + } + + return parentHolder.top(); + } + + protected void notifyListeners() { + listeners.forEach((sl) -> repository.execute(() -> sl.notify(this))); + } + + public String getId() { + return (feature.fs == null) ? "" : feature.fs.getId().toString(); + } + + @Override + public @NotNull String getKey() { + return feature.fs == null ? feature.key : feature.fs.getKey(); + } + + @Override + public boolean isLocked() { + return feature.fs != null && feature.fs.getL() == Boolean.TRUE; + } + + @Override + public String getString() { + return getAsString(FeatureValueType.STRING); + } + + @Override + @Deprecated + public Boolean getBoolean() { + return getFlag(); + } + + @Override + public Boolean getFlag() { + 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); + } + + @Nullable + private Object getValue(@Nullable FeatureValueType type) { + return internalGetValue(type, true); + } + + @Override + @Nullable public FeatureValueType getType() { + return (feature.fs == null) ? null : feature.fs.getType(); + } + + @Override + public @NotNull Map featureProperties() { + final TopFeatureState topFeature = top().feature; + + if (topFeature == null || topFeature.fs == null) return new LinkedHashMap<>(); + + return (topFeature.fs.getFeatureProperties() == null) ? new LinkedHashMap<>() : topFeature.fs.getFeatureProperties(); + } + + public Object getUsageFreeValue() { + return internalGetValue(null, false); + } + + @Override + public K getValue(Class clazz) { + return clazz.cast(internalGetValue(null, true)); + } + + private Object internalGetValue(@Nullable FeatureValueType passedType, boolean triggerUsage) { + boolean locked = feature.fs != null && Boolean.TRUE.equals(feature.fs.getL()); + + // the intercetor can trigger even on invalid feature keys, so we need to be able to track it + FeatureValueInterceptor.ValueMatch vm = repository.findIntercept(locked, feature.key); + + final FeatureValueType type = (passedType == null && feature.fs != null) ? feature.fs.getType() : passedType; + + // was there an overridden value? + if (vm != null) { + return triggerUsage && feature.fs != null && feature.fs.getId() != null ? + used(feature.key, feature.fs.getId(), vm.value, type == null ? FeatureValueType.STRING : type) : + vm.value; + } + + if (feature.fs == null || ( passedType == null && feature.fs.getType() == null )) { + return null; + } + + if (feature.fs.getType() != type || type == null) { + return null; + } + + if (context != null && feature.fs.getStrategies() != null && !feature.fs.getStrategies().isEmpty()) { + final Applied applied = + repository.applyFeature( + feature.fs.getStrategies(), feature.key, feature.fs.getId().toString(), context); + + log.trace("feature is {}", applied); + if (applied.isMatched()) { + return triggerUsage ? used(feature.key, feature.fs.getId(), applied.getValue(), type) : applied.getValue(); + } + } else { + log.trace("feature `{}` has no strategies or there is no context, falling back to default value of {}", getKey(), feature.fs.getValue()); + } + + return triggerUsage ? used(feature.key, feature.fs.getId(), feature.fs.getValue(), type) : + feature.fs.getValue(); + } + + Object used(@NotNull String key, @NotNull UUID id, @Nullable Object value, @NotNull FeatureValueType type) { + if (context != null) { + context.used(key, id, value, type); + } else { + log.trace("calling used with {}", value); + repository.used(key, id, type, value, null, null); + } + + return value; + } + + 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 : repository.getJsonObjectMapper().readValue(rawJson, type); + } catch (IOException e) { + log.warn("Failed to parse JSON", e); + return null; + } + } + + @Override + public boolean isEnabled() { + return getFlag() == Boolean.TRUE; + } + + @Override + public boolean isSet() { + return getValue((FeatureValueType) null) != null; + } + + + @Override + public void addListener(final @NotNull 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 notify actually be inside the listener code? given contexts? + public FeatureState setFeatureState(io.featurehub.sse.model.FeatureState featureState) { + if (featureState == null) { + boolean changed = feature.fs != null; + feature.fs = featureState; + if (changed) { + notifyListeners(); + } + return this; + } + + feature.key = featureState.getKey(); + + Object oldValue = feature.fs == null ? null : feature.fs.getValue(); + feature.fs = featureState; + Object value = convertToRespectiveType(featureState); + if (FeatureStateUtils.changed(oldValue, value)) { + notifyListeners(); + } + return this; + } + + @Nullable + private Object convertToRespectiveType(io.featurehub.sse.model.FeatureState featureState) { + if (featureState.getValue() == null || featureState.getType() == null) { + return null; + } + + try { + switch (featureState.getType()) { + case BOOLEAN: + return Boolean.parseBoolean(featureState.getValue().toString()); + case STRING: + case JSON: + return featureState.getValue().toString(); + case NUMBER: + return new BigDecimal(featureState.getValue().toString()); + } + } catch (Exception ignored) { + } + + return null; + } + + protected FeatureState copy() { + return _copy(); + } + + protected FeatureState usageCopy() { + return new FeatureStateBase(repository, feature.key, feature.fs); + } + + protected FeatureStateBase _copy() { + return new FeatureStateBase<>(repository, this); + } + + public boolean exists() { + return feature.fs != null && feature.fs.getVersion() != null && feature.fs.getVersion() != -1; + } + + protected FeatureValueType type() { + return feature.fs == null ? null : feature.fs.getType(); + } + + @Override + public String toString() { + Object value = feature.fs == null ? null : feature.fs.getValue(); + return value == null ? null : value.toString(); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java new file mode 100644 index 0000000..b4300ec --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/FeatureStateUtils.java @@ -0,0 +1,24 @@ +package io.featurehub.client; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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()), StandardCharsets.UTF_8))).sorted().collect(Collectors.joining(",")); + } +} diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java b/core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java rename to core/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java diff --git a/core/client-java-core/src/main/java/io/featurehub/client/InternalContext.java b/core/client-java-core/src/main/java/io/featurehub/client/InternalContext.java new file mode 100644 index 0000000..def73bc --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/InternalContext.java @@ -0,0 +1,13 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +interface InternalContext extends ClientContext { + void used(@NotNull String key, @NotNull UUID id, @Nullable Object val, + @NotNull FeatureValueType valueType); + + } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java new file mode 100644 index 0000000..4d62550 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/InternalFeatureRepository.java @@ -0,0 +1,79 @@ +package io.featurehub.client; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageProvider; +import io.featurehub.javascript.JavascriptObjectMapper; +import io.featurehub.sse.model.FeatureRolloutStrategy; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureValueType; +import io.featurehub.sse.model.SSEResultState; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface InternalFeatureRepository extends FeatureRepository { + + /* + * Any incoming state changes from a multi-varied set of possible data. This comes + * from SSE. + */ + void notify(@NotNull SSEResultState state); + + /** + * Indicate the feature states have updated and if their versions have + * updated or no versions exist, update the repository. + * + * @param features - the features + */ + void updateFeatures(@NotNull List features); + /** + * Update the feature states and force them to be updated, ignoring their version numbers. + * This still may not cause events to be triggered as event triggers are done on actual value changes. + * + * @param features - the list of feature states + * @param force - whether we should force the states to change + */ + void updateFeatures(@NotNull List features, boolean force); + boolean updateFeature(@NotNull FeatureState feature); + boolean updateFeature(@NotNull FeatureState feature, boolean force); + void deleteFeature(@NotNull FeatureState feature); + + @Nullable FeatureValueInterceptor.ValueMatch findIntercept(boolean locked, @NotNull String key); + + @NotNull Applied applyFeature(@NotNull List strategies, @NotNull String key, @NotNull String featureValueId, + @NotNull ClientContext cac); + + void execute(@NotNull Runnable command); + ExecutorService getExecutor(); + + @NotNull JavascriptObjectMapper getJsonObjectMapper(); + + /** + * Tell the repository that its features are not in a valid state. Only called by server eval context. + */ + void repositoryNotReady(); + + void close(); + + @NotNull Readiness getReadiness(); + + @NotNull FeatureStateBase getFeat(@NotNull String key); + @NotNull FeatureStateBase getFeat(@NotNull Feature key); + @NotNull FeatureStateBase getFeat(@NotNull String key, @NotNull Class clazz); + + void recordUsageEvent(@NotNull UsageEvent event); + + /** + * Repository is empty, there are no features but repository is ready. + */ + void repositoryEmpty(); + + void used(@NotNull String key, @NotNull UUID id, @NotNull FeatureValueType valueType, @Nullable Object value, + @Nullable Map> attributes, + @Nullable String usageUserKey); + + @NotNull UsageProvider getUsageProvider(); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java b/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java new file mode 100644 index 0000000..1ff8f99 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/PollingDelegateEdgeService.java @@ -0,0 +1,132 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +public class PollingDelegateEdgeService implements EdgeService { + @NotNull + private final EdgeService edgeService; + @NotNull + private final InternalFeatureRepository repo; + private Timer timer; + private static final Logger log = LoggerFactory.getLogger(PollingDelegateEdgeService.class); + private boolean busy = false; + + /** + * This class has to get the timeout delay from the underlying client because the server can override the timeout delay. + * + * @param edgeService - the Rest client that is polling. It MUST NOT BE an SSE client. + * @param repo - the internal repo API. + */ + + public PollingDelegateEdgeService(@NotNull EdgeService edgeService, @NotNull InternalFeatureRepository repo) { + this.edgeService = edgeService; + this.repo = repo; + timer = newTimer(); + } + + protected Timer newTimer() { + return new Timer(true); + } + + private void loop() { + if (!edgeService.isStopped()) { + busy = false; + + timer = newTimer(); // once its cancelled, you can't reuse it + timer.schedule(new TimerTask() { + @Override + public void run() { + poll(); + } + }, edgeService.currentInterval() * 1000); + } + } + + private void cancelTimer() { + if (timer != null) { + timer.cancel(); + } + } + + @Override + public @NotNull Future contextChange(@Nullable String newHeader, String contextSha) { + if (edgeService.needsContextChange(newHeader, contextSha)) { + log.trace("contextChange"); + cancelTimer(); + + return repo.getExecutor().submit(() -> { + try { + log.trace("context change"); + return edgeService.contextChange(newHeader, contextSha).get(); + } catch (Exception e) { + log.error("failed to context change", e); + return repo.getReadiness(); + } finally { + log.trace("looping again cc"); + loop(); + } + } + ); + } else { + return CompletableFuture.completedFuture(repo.getReadiness()); + } + } + + @Override + public boolean isClientEvaluation() { + return edgeService.isClientEvaluation(); + } + + @Override + public boolean isStopped() { + return edgeService.isStopped(); + } + + @Override + public void close() { + cancelTimer(); + edgeService.close(); + } + + @Override + public @NotNull FeatureHubConfig getConfig() { + return edgeService.getConfig(); + } + + @Override + public Future poll() { + if (!busy) { + busy = true; + cancelTimer(); + + return repo.getExecutor().submit(() -> { + log.trace("calling poll directly"); + try { + return edgeService.poll().get(); + } catch (Exception e) { + log.error("failed to poll", e); + return repo.getReadiness(); + } finally { + log.trace("finished polling"); + loop(); + } + }); + } + + return CompletableFuture.completedFuture(repo.getReadiness()); + } + + @Override + public long currentInterval() { + return edgeService.currentInterval(); + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/Readyness.java b/core/client-java-core/src/main/java/io/featurehub/client/Readiness.java similarity index 71% rename from client-java-core/src/main/java/io/featurehub/client/Readyness.java rename to core/client-java-core/src/main/java/io/featurehub/client/Readiness.java index a3dec49..d8cfd9d 100644 --- a/client-java-core/src/main/java/io/featurehub/client/Readyness.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/Readiness.java @@ -1,5 +1,5 @@ package io.featurehub.client; -public enum Readyness { +public enum Readiness { NotReady, Ready, Failed } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java b/core/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java new file mode 100644 index 0000000..a159e96 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/RepositoryEventHandler.java @@ -0,0 +1,5 @@ +package io.featurehub.client; + +public interface RepositoryEventHandler { + void cancel(); +} diff --git a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java similarity index 56% rename from client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java rename to core/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java index 83472a2..d992a5c 100644 --- a/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java @@ -8,20 +8,18 @@ import java.security.NoSuchAlgorithmException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; -import java.util.function.Supplier; public class ServerEvalFeatureContext extends BaseClientContext { private static final Logger log = LoggerFactory.getLogger(ServerEvalFeatureContext.class); - private final Supplier edgeService; - private EdgeService currentEdgeService; private String xHeader; - private boolean weOwnEdge; - + private final RepositoryEventHandler newFeatureStateHandler; + private final RepositoryEventHandler featureUpdatedHandler; private final MessageDigest shaDigester; - public ServerEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContext repository, - Supplier edgeService) { - super(repository, config); + + public ServerEvalFeatureContext(InternalFeatureRepository repository, + EdgeService edgeService) { + super(repository, edgeService); try { shaDigester = MessageDigest.getInstance("SHA-256"); @@ -29,47 +27,52 @@ public ServerEvalFeatureContext(FeatureHubConfig config, FeatureRepositoryContex throw new RuntimeException(e); } - this.edgeService = edgeService; - this.weOwnEdge = false; + newFeatureStateHandler = repository.registerNewFeatureStateAvailable((fr) -> { + recordRelativeValuesForUser(); + }); + + featureUpdatedHandler = repository.registerFeatureUpdateAvailable((fs) -> { + recordFeatureChangedForUser((FeatureStateBase)fs); + }); + } + + @Override + public void close() { + super.close(); + + newFeatureStateHandler.cancel(); + featureUpdatedHandler.cancel(); } @Override public Future build() { - String newHeader = FeatureStateUtils.generateXFeatureHubHeaderFromMap(clientContext); + String newHeader = FeatureStateUtils.generateXFeatureHubHeaderFromMap(attributes); if (newHeader != null || xHeader != null) { if ((newHeader != null && xHeader == null) || newHeader == null || !newHeader.equals(xHeader)) { xHeader = newHeader; - repository.notReady(); - - if (currentEdgeService != null && currentEdgeService.isRequiresReplacementOnHeaderChange()) { - currentEdgeService.close(); - currentEdgeService = edgeService.get(); - } + repository.repositoryNotReady(); } } - if (currentEdgeService == null) { - currentEdgeService = edgeService.get(); - weOwnEdge = true; - } - - Future change = currentEdgeService.contextChange(newHeader, + Future change = edgeService.contextChange(newHeader, newHeader == null ? "0" : bytesToHex(shaDigester.digest(newHeader.getBytes(StandardCharsets.UTF_8)))); xHeader = newHeader; CompletableFuture future = new CompletableFuture<>(); - try { - change.get(); + repository.execute(() -> { + try { + change.get(); - future.complete(this); - } catch (Exception e) { - log.error("Failed to update", e); - future.completeExceptionally(e); - } + future.complete(this); + } catch (Exception e) { + log.error("Failed to update", e); + future.completeExceptionally(e); + } + }); return future; @@ -87,23 +90,4 @@ private static String bytesToHex(byte[] hash) { } return hexString.toString(); } - - @Override - public EdgeService getEdgeService() { - return currentEdgeService; - } - - public Supplier getEdgeServiceSupplier() { return edgeService; } - - @Override - public boolean exists(String key) { - return false; - } - - @Override - public void close() { - if (weOwnEdge && currentEdgeService != null) { - currentEdgeService.close(); - } - } } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/TestApi.java b/core/client-java-core/src/main/java/io/featurehub/client/TestApi.java new file mode 100644 index 0000000..18f07d9 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/TestApi.java @@ -0,0 +1,13 @@ +package io.featurehub.client; + +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +public interface TestApi { + @NotNull TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate); + @NotNull TestApiResult setFeatureState(@NotNull String featureKey, + @NotNull FeatureStateUpdate featureStateUpdate); + + void close(); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java b/core/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java new file mode 100644 index 0000000..194ac3c --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/TestApiResult.java @@ -0,0 +1,64 @@ +package io.featurehub.client; + +public class TestApiResult { + private final int code; + + public TestApiResult(int code) { + this.code = code; + } + + public int getCode() { + return code; + } + + public boolean isSuccess() { + return code >= 200 && code < 300; + } + + public boolean isFailed() { + return code >= 400; + } + + /** + * update was accepted but not actioned because feature is already in that state + */ + public boolean isNotChanged() { + return code == 200 || code == 202; + } + + /** + * update was accepted and actioned + */ + public boolean isChanged() { + return code == 201; + } + + /** + * you have made a request that doesn't make sense. e.g. it has no data + */ + public boolean isNonsense() { + return code == 400; + } + + /** + * update was not accepted, attempted change is outside the permissions of this user + */ + public boolean isNotPermitted() { + return code == 403; + } + + /** + * something about the presented data isn't right and we couldn't find it, could be the service key, the + * environment or the feature + */ + public boolean isNonExistant() { + return code == 404; + } + + /** + * you have made a request that isn't possible. e.g. changing a value without unlocking it. + */ + public boolean isNotPossible() { + return code == 412; + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java b/core/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java new file mode 100644 index 0000000..b1f9457 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/ThreadLocalContext.java @@ -0,0 +1,39 @@ +package io.featurehub.client; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class ThreadLocalContext { + @NotNull + private static final ThreadLocal<@Nullable ClientContext> contexts = new ThreadLocal<>(); + @Nullable + private static FeatureHubConfig config; + + public static void setConfig(@NotNull FeatureHubConfig config) { + ThreadLocalContext.config = config; + } + + @NotNull public static ClientContext getContext() { + return context(); + } + + @NotNull public static ClientContext context() { + if (config == null) throw new RuntimeException("config not set, unable to use"); + + ClientContext ctx = contexts.get(); + + if (ctx == null) { + ctx = config.newContext(); + } + + return ctx; + } + + public static void close() { + ClientContext ctx = contexts.get(); + if (ctx != null) { + ctx.close(); + contexts.remove(); + } + } +} diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java similarity index 65% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java index e0350a9..2c6b42f 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java @@ -7,9 +7,9 @@ public enum EdgeConnectionState { API_KEY_NOT_FOUND, // [SSE + GET] - // we timed out trying to connect to the server. We should backoff briefly and try and connect again. May + // we timed out trying to read the server. We should backoff briefly and try and connect again. May // require increasing backoff - SERVER_CONNECT_TIMEOUT, // timeout connecting to url, retryable + SERVER_READ_TIMEOUT, // timeout connecting to url, retryable // [SSE Only] this is the normal ping/pong of the server connection disconnecting us, we should delay a random amount // of time an reconnect. @@ -18,6 +18,10 @@ public enum EdgeConnectionState { // [SSE + GET] we never received a response after we did actually connect, we should backoff SERVER_WAS_DISCONNECTED, // we got a disconnect before we received a "bye" + CONNECTION_FAILURE, // e.g. java.net.ConnectionException - such as the host not existing or not being able to be connected to at all + SUCCESS, + // total failure (e.g. 403 or 401 coming from Edge, so stop trying) + FAILURE, } diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java similarity index 61% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java index 8c90445..6fe8298 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryService.java @@ -1,12 +1,14 @@ package io.featurehub.client.edge; +import io.featurehub.client.InternalFeatureRepository; import io.featurehub.sse.model.SSEResultState; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.concurrent.ExecutorService; public interface EdgeRetryService { - void edgeResult(EdgeConnectionState state, EdgeReconnector reconnector); + void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnector reconnector); /** * Edge connected received a "config" set of data, process it @@ -15,11 +17,19 @@ public interface EdgeRetryService { void edgeConfigInfo(String config); @Nullable SSEResultState fromValue(String value); + void convertSSEState(@NotNull SSEResultState state, String data, @NotNull InternalFeatureRepository + repository); void close(); ExecutorService getExecutorService(); + int getServerReadTimeoutMs(); + + /** + * connection attempt in ms + * @return + */ int getServerConnectTimeoutMs(); int getServerDisconnectRetryMs(); @@ -33,4 +43,6 @@ public interface EdgeRetryService { int getBackoffMultiplier(); boolean isNotFoundState(); + + boolean isStopped(); } diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java similarity index 53% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java rename to core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java index 7b84ced..473147c 100644 --- a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/edge/EdgeRetryer.java @@ -1,13 +1,18 @@ package io.featurehub.client.edge; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.InternalFeatureRepository; +import io.featurehub.javascript.JavascriptObjectMapper; +import io.featurehub.javascript.JavascriptServiceLoader; +import io.featurehub.sse.model.FeatureState; import io.featurehub.sse.model.SSEResultState; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -15,29 +20,49 @@ public class EdgeRetryer implements EdgeRetryService { private static final Logger log = LoggerFactory.getLogger(EdgeRetryer.class); private final ExecutorService executorService; - private final int serverConnectTimeoutMs; + /** + * If we get a server-timeout failure, how long before we try and reconnect. This is the scenario where the server can be connected + * to, but it is exceeding our READ_TIMEOUT setting. Defaults to 5s. + */ + private final int serverReadTimeoutMs; + /** + * If the server disconnects from us (we get a disconnection error without a bye), how long to wait? Normally 0. + */ private final int serverDisconnectRetryMs; + /** + * If the server disconnects from us using a BYE, how long do we wait before a reconnect. Given this is normal behaviour, we normally + * set this to 0. + */ private final int serverByeReconnectMs; + /** + * backoffMultiplier - this is how much to multiply the backoff - the backoff is a random value between 0 and 1, which is multiplied by this + * value. + */ private final int backoffMultiplier; private final int maximumBackoffTimeMs; - // this will change over the lifetime of reconnect attempts + // this will change over the lifetime of reconnect attempts, internal use only private int currentBackoffMultiplier; - private ObjectMapper mapper = new ObjectMapper(); + /** + * if the connectionk attempt to connect fails, how long do we wait before attempting to reconnect + */ + private final int connectionFailureBackoffTimeMs; + private final JavascriptObjectMapper mapper = JavascriptServiceLoader.load(); // if this is set, then we stop recognizing any further requests from the connection, // we can get subsequent disconnect statements. We know we cannot reconnect so we just stop. private boolean notFoundState = false; private boolean stopped = false; - protected EdgeRetryer(int serverConnectTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, - int backoffMultiplier, int maximumBackoffTimeMs) { - this.serverConnectTimeoutMs = serverConnectTimeoutMs; + protected EdgeRetryer(int serverReadTimeoutMs, int serverDisconnectRetryMs, int serverByeReconnectMs, + int backoffMultiplier, int maximumBackoffTimeMs, int serverConnectTimeoutMs) { + this.serverReadTimeoutMs = serverReadTimeoutMs; this.serverDisconnectRetryMs = serverDisconnectRetryMs; this.serverByeReconnectMs = serverByeReconnectMs; this.backoffMultiplier = backoffMultiplier; this.maximumBackoffTimeMs = maximumBackoffTimeMs; currentBackoffMultiplier = backoffMultiplier; + this.connectionFailureBackoffTimeMs = serverConnectTimeoutMs; executorService = makeExecutorService(); } @@ -47,7 +72,7 @@ protected ExecutorService makeExecutorService() { return Executors.newFixedThreadPool(1); } - public void edgeResult(EdgeConnectionState state, EdgeReconnector reConnector) { + public void edgeResult(@NotNull EdgeConnectionState state, @NotNull EdgeReconnector reConnector) { log.trace("[featurehub-sdk] retryer triggered {}", state); if (!notFoundState && !stopped && !executorService.isShutdown()) { if (state == EdgeConnectionState.SUCCESS) { @@ -55,21 +80,36 @@ public void edgeResult(EdgeConnectionState state, EdgeReconnector reConnector) { } else if (state == EdgeConnectionState.API_KEY_NOT_FOUND) { log.warn("[featurehub-sdk] terminal failure attempting to connect to Edge, API KEY does not exist."); notFoundState = true; + stopped = true; + } else if (state == EdgeConnectionState.FAILURE) { + log.warn("[featurehub-sdk] terminal failure attempting to connect to Edge, no permission."); + notFoundState = true; + stopped = true; } else if (state == EdgeConnectionState.SERVER_WAS_DISCONNECTED) { executorService.submit(() -> { - backoff(serverDisconnectRetryMs, true); + if (serverDisconnectRetryMs > 0) { + backoff(serverDisconnectRetryMs, true); + } reConnector.reconnect(); }); } else if (state == EdgeConnectionState.SERVER_SAID_BYE) { executorService.submit(() -> { -// backoff(serverByeReconnectMs, false); + if (serverByeReconnectMs > 0) { + backoff(serverByeReconnectMs, false); + } + + reConnector.reconnect(); + }); + } else if (state == EdgeConnectionState.SERVER_READ_TIMEOUT) { + executorService.submit(() -> { + backoff(serverReadTimeoutMs, true); reConnector.reconnect(); }); - } else if (state == EdgeConnectionState.SERVER_CONNECT_TIMEOUT) { + } else if (state == EdgeConnectionState.CONNECTION_FAILURE) { executorService.submit(() -> { - backoff(serverConnectTimeoutMs, true); + backoff(connectionFailureBackoffTimeMs, true); reConnector.reconnect(); }); @@ -77,17 +117,15 @@ public void edgeResult(EdgeConnectionState state, EdgeReconnector reConnector) { } } - private static final TypeReference> mapConfig = new TypeReference>() {}; - @Override public void edgeConfigInfo(String config) { try { - Map data = mapper.readValue(config, mapConfig); + Map data = mapper.readMapValue(config); if (data.containsKey("edge.stale")) { stopped = true; // force us to stop trying for this connection } - } catch (JsonProcessingException e) { + } catch (IOException e) { // ignored } @@ -102,6 +140,32 @@ public void edgeConfigInfo(String config) { } } + @Override + public void convertSSEState(@NotNull SSEResultState state, String data, + @NotNull InternalFeatureRepository repository) { + try { + if (data != null) { + if (state == SSEResultState.FEATURES) { + List features = + repository.getJsonObjectMapper().readFeatureStates(data); + repository.updateFeatures(features); + } else if (state == SSEResultState.FEATURE) { + repository.updateFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } else if (state == SSEResultState.DELETE_FEATURE) { + repository.deleteFeature(repository.getJsonObjectMapper().readValue(data, + io.featurehub.sse.model.FeatureState.class)); + } + } + + if (state == SSEResultState.FAILURE) { + repository.notify(state); + } + } catch (IOException jpe) { + throw new RuntimeException("JSON failed", jpe); + } + } + public void close() { executorService.shutdownNow(); } @@ -111,9 +175,14 @@ public ExecutorService getExecutorService() { return executorService; } + @Override + public int getServerReadTimeoutMs() { + return serverReadTimeoutMs; + } + @Override public int getServerConnectTimeoutMs() { - return serverConnectTimeoutMs; + return connectionFailureBackoffTimeMs; } @Override @@ -146,6 +215,11 @@ public boolean isNotFoundState() { return notFoundState; } + @Override + public boolean isStopped() { + return stopped; + } + // holds the thread for a specific period of time and then returns // while setting the next backoff incase we come back protected void backoff(int baseTime, boolean adjustBackoff) { @@ -179,15 +253,29 @@ public int newBackoff(int currentBackoff) { return backoff; } + private enum EdgeRetryerClientType { + NONE, SSE, REST + } + public static final class EdgeRetryerBuilder { - private int serverConnectTimeoutMs; + private int serverSseReadTimeoutMs; + private int serverRestReadTimeoutMs; private int serverDisconnectRetryMs; private int serverByeReconnectMs; private int backoffMultiplier; private int maximumBackoffTimeMs; + private int serverConnectTimeoutMs; + + private EdgeRetryerClientType clientType = EdgeRetryerClientType.NONE; private EdgeRetryerBuilder() { + // 5s by default, shouldn't be longer than that just to connect serverConnectTimeoutMs = propertyOrEnv("featurehub.edge.server-connect-timeout-ms", "5000"); + // 3m (180 seconds), should be higher if the server is configured for longer by default + serverSseReadTimeoutMs = propertyOrEnv("featurehub.edge.server-sse-read-timeout-ms", "1800000"); + // 15s - should be very fast for a REST request as its a connect, read and disconnect process + serverRestReadTimeoutMs = propertyOrEnv("featurehub.edge.server-rest-read-timeout-ms", "150000"); + serverDisconnectRetryMs = propertyOrEnv("featurehub.edge.server-disconnect-retry-ms", "0"); // immediately try and reconnect if disconnected serverByeReconnectMs = propertyOrEnv("featurehub.edge.server-by-reconnect-ms", @@ -196,18 +284,8 @@ private EdgeRetryerBuilder() { maximumBackoffTimeMs = propertyOrEnv("featurehub.edge.maximum-backoff-ms", "30000"); } - private int propertyOrEnv(String name, String defaultVal) { - String val = System.getenv(name); - - if (val == null) { - val = System.getenv(name.replace(".", "_").replace("-", "_")); - } - - if (val == null) { - val = System.getProperty(name, defaultVal); - } - - return Integer.parseInt(val); + private int propertyOrEnv(@NotNull String name, String defaultVal) { + return Integer.parseInt(FeatureHubConfig.getConfig(name, defaultVal)); } public static EdgeRetryerBuilder anEdgeRetrier() { @@ -239,9 +317,34 @@ public EdgeRetryerBuilder withMaximumBackoffTimeMs(int maximumBackoffTimeMs) { return this; } + public EdgeRetryerBuilder withSseReadTimeoutTimeMs(int ms) { + this.serverSseReadTimeoutMs = ms; + return this; + } + + public EdgeRetryerBuilder withRestReadTimeoutTimeMs(int ms) { + this.serverRestReadTimeoutMs = ms; + return this; + } + + public EdgeRetryerBuilder sse() { + this.clientType = EdgeRetryerClientType.SSE; + return this; + } + + public EdgeRetryerBuilder rest() { + this.clientType = EdgeRetryerClientType.REST; + return this; + } + public EdgeRetryer build() { - return new EdgeRetryer(serverConnectTimeoutMs, serverDisconnectRetryMs, serverByeReconnectMs, backoffMultiplier - , maximumBackoffTimeMs); + if (clientType == EdgeRetryerClientType.NONE) { + throw new RuntimeException("FeatureHub Retryer does not know what read timeout to use"); + } + + return new EdgeRetryer(clientType == EdgeRetryerClientType.SSE ? serverSseReadTimeoutMs : serverRestReadTimeoutMs, serverDisconnectRetryMs, serverByeReconnectMs, backoffMultiplier + , maximumBackoffTimeMs, serverConnectTimeoutMs + ); } } } diff --git a/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java b/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java similarity index 93% rename from client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java rename to core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java index cbdf7ce..0e1fab1 100644 --- a/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java @@ -19,7 +19,7 @@ public ValueMatch getValue(String key) { if (System.getProperties().containsKey(k)) { matched = true; value = System.getProperty(k); - if (value != null && value.trim().length() == 0) { + if (value != null && value.trim().isEmpty()) { value = null; } } diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java new file mode 100644 index 0000000..7260c6c --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/FeatureHubUsageValue.java @@ -0,0 +1,47 @@ +package io.featurehub.client.usage; + +import io.featurehub.client.FeatureStateBase; +import io.featurehub.sse.model.FeatureValueType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class FeatureHubUsageValue { + @NotNull + final String id; + @NotNull + final String key; + @Nullable + final String value; + + @Nullable + static String convert(@Nullable Object value, @Nullable FeatureValueType type) { + if (type == null || value == null) { + return null; + } + + switch (type) { + case BOOLEAN: + return Boolean.TRUE.equals(value) ? "on" : "off"; + case STRING: + case NUMBER: + return value.toString(); + case JSON: + return null; + } + + return null; + } + + public FeatureHubUsageValue(@NotNull String id, @NotNull String key, @Nullable Object value, + @NotNull FeatureValueType type) { + this.id = id; + this.key = key; + this.value = convert(value, type); + } + + public FeatureHubUsageValue(@NotNull FeatureStateBase holder) { + this.id = holder.getId(); + this.key = holder.getKey(); + this.value = convert(holder.getUsageFreeValue(), holder.getType()); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java new file mode 100644 index 0000000..16ac773 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageAdapter.java @@ -0,0 +1,31 @@ +package io.featurehub.client.usage; + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureRepository; +import io.featurehub.client.RepositoryEventHandler; + +import java.util.LinkedList; +import java.util.List; + +public class UsageAdapter { + private final List plugins = new LinkedList<>(); + final FeatureRepository repository; + final RepositoryEventHandler usageHandlerSub; + + public UsageAdapter(FeatureRepository repo) { + this.repository = repo; + usageHandlerSub = repo.registerUsageStream(this::process); + } + + public void close() { + usageHandlerSub.cancel(); + } + + public void process(UsageEvent event) { + plugins.forEach((p) -> p.send(event)); + } + + public void registerPlugin(UsagePlugin plugin) { + plugins.add(plugin); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java new file mode 100644 index 0000000..dab4cf8 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEvent.java @@ -0,0 +1,51 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +public class UsageEvent { + /** + * This is the unique identifying key of the user for this event (if any) + */ + @Nullable + private String userKey; + /** + * This is the set of any additional parameters that a user wishes to collect over and above the context attributes + */ + @NotNull + private Map additionalParams = new HashMap<>(); + + public UsageEvent(@Nullable String userKey) { + this.userKey = userKey; + } + + public UsageEvent() { + } + + public void setUserKey(String userKey) { + this.userKey = userKey; + } + + public void setAdditionalParams(@NotNull Map additionalParams) { + this.additionalParams = additionalParams; + } + + public UsageEvent(@Nullable String userKey, @Nullable Map additionalParams) { + this.userKey = userKey; + if (additionalParams != null) { + this.additionalParams = additionalParams; + } + } + + @NotNull + public Map toMap() { + return additionalParams; + } + + @Nullable public String getUserKey() { + return userKey; + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java new file mode 100644 index 0000000..a421ea4 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventName.java @@ -0,0 +1,7 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; + +public interface UsageEventName { + @NotNull String getEventName(); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java new file mode 100644 index 0000000..6f4b9a0 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageEventWithFeature.java @@ -0,0 +1,49 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class UsageEventWithFeature extends UsageEvent implements UsageEventName { + @Nullable + final Map> attributes; + @NotNull final FeatureHubUsageValue feature; + + public UsageEventWithFeature(@NotNull FeatureHubUsageValue feature, @Nullable Map> attributes, + @Nullable String userKey) { + this.attributes = attributes; + this.feature = feature; + setUserKey(userKey); + } + + @Nullable public Map> getAttributes() { + return attributes; + } + + @NotNull public FeatureHubUsageValue getFeature() { + return feature; + } + + @Override + public @NotNull String getEventName() { + return "feature"; + } + + @Override + @NotNull public Map toMap() { + Map m = new HashMap<>(super.toMap()); + + if (attributes != null) { // may not be from a context + m.putAll(attributes); + } + m.put("feature", feature.key); + m.put("value", feature.value); + m.put("id", feature.id); + + return Collections.unmodifiableMap(m); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java new file mode 100644 index 0000000..6caa998 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollection.java @@ -0,0 +1,34 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class UsageFeaturesCollection extends UsageEvent { + @NotNull List featureValues = new ArrayList<>(); + + public UsageFeaturesCollection(@Nullable String userKey, @Nullable Map additionalParams) { + super(userKey, additionalParams); + } + + public void setFeatureValues(List featureValues) { + this.featureValues = featureValues; + } + + public UsageFeaturesCollection() {} + + void ready() {} + + @Override + @NotNull public Map toMap() { + Map m = new HashMap<>(super.toMap()); + featureValues.forEach((fv) -> m.put(fv.key, fv.value)); + + return Collections.unmodifiableMap(m); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java new file mode 100644 index 0000000..b4c3cad --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageFeaturesCollectionContext.java @@ -0,0 +1,35 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class UsageFeaturesCollectionContext extends UsageFeaturesCollection { + @NotNull + Map> attributes = new HashMap<>(); + + public UsageFeaturesCollectionContext(@Nullable String userKey, @Nullable Map additionalParams) { + super(userKey, additionalParams); + } + + public UsageFeaturesCollectionContext() { + super(); + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + + @Override + @NotNull public Map toMap() { + Map m = new HashMap<>(super.toMap()); + + m.putAll(attributes); + + return Collections.unmodifiableMap(m); + } +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java new file mode 100644 index 0000000..38b17d6 --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsagePlugin.java @@ -0,0 +1,19 @@ +package io.featurehub.client.usage; + +import java.util.HashMap; +import java.util.Map; + +abstract public class UsagePlugin { + protected final Map defaultEventParams = new HashMap<>(); +// protected final boolean unnamedBecomeEventParameters; +// +// public UsagePlugin(boolean unnamedBecomeEventParameters) { +// this.unnamedBecomeEventParameters = unnamedBecomeEventParameters; +// } + + public Map getDefaultEventParams() { + return defaultEventParams; + } + + public abstract void send(UsageEvent event); +} diff --git a/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java new file mode 100644 index 0000000..2f1330f --- /dev/null +++ b/core/client-java-core/src/main/java/io/featurehub/client/usage/UsageProvider.java @@ -0,0 +1,30 @@ +package io.featurehub.client.usage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public interface UsageProvider { + default UsageEventWithFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @NotNull Map> attributes) { + return new UsageEventWithFeature(feature, attributes, null); + } + + default UsageEventWithFeature createUsageFeature(@NotNull FeatureHubUsageValue feature, + @Nullable Map> attributes, + @Nullable String userKey) { + return new UsageEventWithFeature(feature, attributes, userKey); + } + + default UsageFeaturesCollection createUsageCollectionEvent() { + return new UsageFeaturesCollection(); + } + + default UsageFeaturesCollectionContext createUsageContextCollectionEvent() { + return new UsageFeaturesCollectionContext(); + } + + class DefaultUsageProvider implements UsageProvider {} +} diff --git a/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java b/core/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java similarity index 91% rename from client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java rename to core/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java index 9058e52..5d4cb81 100644 --- a/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java +++ b/core/client-java-core/src/main/java/io/featurehub/client/utils/SdkVersion.java @@ -10,7 +10,7 @@ public class SdkVersion { private static String version = null; private static String constructedVariant = null; - public static String SSE_API_VERSION = "1.1.2"; // while we are compiled against 1.1.3, we don't understand it + public static String SSE_API_VERSION = "1.1.3"; public static String sdkVersionHeader(String variant) { if (constructedVariant == null) { diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java b/core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java b/core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java rename to core/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java diff --git a/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/BananaSample.groovy diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy new file mode 100644 index 0000000..fca17f6 --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/BaseClientContextSpec.groovy @@ -0,0 +1,43 @@ +package io.featurehub.client + +import io.featurehub.sse.model.StrategyAttributeCountryName +import io.featurehub.sse.model.StrategyAttributeDeviceName +import io.featurehub.sse.model.StrategyAttributePlatformName +import spock.lang.Specification + +class BaseClientContextSpec extends Specification { + InternalFeatureRepository repo + EdgeService edgeService + BaseClientContext ctx + + def setup() { + edgeService = Mock(EdgeService) + repo = Mock(InternalFeatureRepository) + ctx = new BaseClientContext(repo, edgeService) + } + + def "the client context encodes as expected"() { + when: "i encode the context" + def tc = ctx.userKey("DJElif") + .country(StrategyAttributeCountryName.TURKEY) + .attr("city", "Istanbul") + .attrs("musical styles", Arrays.asList("psychedelic", "deep")) + .device(StrategyAttributeDeviceName.DESKTOP) + .platform(StrategyAttributePlatformName.ANDROID) + .version("2.3.7") + .sessionKey("anjunadeep").build().get() + + and: "i do the same thing again to ensure i can reset everything" + tc.userKey("DJElif") + .country(StrategyAttributeCountryName.TURKEY) + .attr("city", "Istanbul") + .attrs("musical styles", Arrays.asList("psychedelic", "deep")) + .device(StrategyAttributeDeviceName.DESKTOP) + .platform(StrategyAttributePlatformName.ANDROID) + .version("2.3.7") + .sessionKey("anjunadeep").build().get() + then: + FeatureStateUtils.generateXFeatureHubHeaderFromMap(tc.context()) == + 'city=Istanbul,country=turkey,device=desktop,musical styles=psychedelic%2Cdeep,platform=android,session=anjunadeep,userkey=DJElif,version=2.3.7' + } +} diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy new file mode 100644 index 0000000..4743402 --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/EdgeFeatureHubConfigSpec.groovy @@ -0,0 +1,180 @@ +package io.featurehub.client + +import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.client.usage.UsageProvider +import io.featurehub.javascript.JavascriptObjectMapper +import spock.lang.Specification + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.Future +import java.util.concurrent.TimeoutException +import java.util.function.Consumer + +class EdgeFeatureHubConfigSpec extends Specification { + FeatureHubConfig config + EdgeService edgeClient + + def setup() { + config = new EdgeFeatureHubConfig("http://localhost", "123*abc") + edgeClient = Mock(EdgeService) + config.setEdgeService { -> edgeClient } + } + + def "i can create a valid client evaluated config and multiple requests for a a new context will result in a single connection"() { + when: "i ask for a new context" + def ctx1 = config.newContext() + and: "i ask again" + def ctx2 = config.newContext() + then: + 0 * _ + } + + def "if we use a client eval key, closing after a newContext and re-opening will get a new connection"() { + when: "i ask for a new context" + def ctx1 = config.newContext() + config.close() + and: "i ask again" + def ctx2 = config.newContext() + then: + ctx1 != null + ctx2 != null + ctx1 != ctx2 + config.edgeService.get() == edgeClient + 1 * edgeClient.close() + 0 * _ + } + + def "all the passthrough on the repository from the config works as expected"() { + given: "i have mocked the repository and set it" + def repo = Mock(InternalFeatureRepository) + config.setRepository(repo) + and: "I have some values ready to set" + def om = Mock(JavascriptObjectMapper) + Consumer readynessListener = Mock(Consumer) + def featureValueOverride = Mock(FeatureValueInterceptor) + def analyticsProvider = Mock(UsageProvider) + when: "i set all the passthrough settings" + config.setJsonConfigObjectMapper(om) + config.addReadinessListener(readynessListener) + config.registerValueInterceptor(false, featureValueOverride) + then: + 1 * repo.registerValueInterceptor(false, featureValueOverride) + 1 * repo.addReadinessListener(readynessListener) >> Mock(RepositoryEventHandler) + 1 * repo.setJsonConfigObjectMapper(om) + 0 * _ // nothing else + } + + def "when i create a client evaluated feature context it should auto find the provider"() { + given: "i clean up the static provider" + FeatureHubTestClientFactory.fake = null + config = new EdgeFeatureHubConfig("http://localhost", "2*3") + when: "i create a new client" + def context = config.newContext() + then: + context instanceof ClientEvalFeatureContext + context.repository == config.repository + context.edgeService == FeatureHubTestClientFactory.fake + context.edgeService.config == config + 0 * _ + } + + def "when i create a server evaluated feature context it should auto find the provider"() { + given: "i have a client eval feature config" + def config = new EdgeFeatureHubConfig("http://localhost", "123-abc") + when: "i create a new client" + def context = config.newContext() + and: "i create a second client" + def context2 = config.newContext() + then: + context == context2 + 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"() { + when: "i have a client eval feature config" + def ctx = config.newContext() + then: + config.repository instanceof ClientFeatureRepository + config.readiness == Readiness.NotReady + config.edgeService.get() == edgeClient + } + + def "i can pre-replace the repository and edge supplier and the context gets created as expected"() { + given: "i have mocked the edge supplier" + def mockRepo = Mock(InternalFeatureRepository) + def executor = Mock(ExecutorService) + config.setRepository(mockRepo) + when: + def ctx = config.init().get() as BaseClientContext + then: + ctx.edgeService == edgeClient + ctx.repository == mockRepo + 1 * edgeClient.contextChange(null, '0') >> CompletableFuture.completedFuture(Readiness.Ready) + 1 * mockRepo.getExecutor() >> executor + 1 * executor.execute { Runnable r -> r.run() } + 0 * _ + } + + def "init completes successfully if future resolves within the given time"() { + given: "A mock future that completes successfully" + def futureContext = Mock(Future) + def mockContext = Mock(ClientContext) + and: "I mock the context and future" + def clientContext = Mock(ClientContext) + + and: "A client eval feature config" + def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") { + @Override + ClientContext newContext() { + return clientContext + } + } + when: "init is called with a reasonable timeout" + config.init(100, TimeUnit.MILLISECONDS) + then: "The get method on the future should be called with timeout" + 1 * futureContext.get(100, TimeUnit.MILLISECONDS) >> mockContext + 1 * clientContext.build() >> futureContext + 0 * _ + } + + def "init should timeout if future does not complete within the given time"() { + given: "A mock future that completes successfully" + def futureContext = Mock(Future) + def mockContext = Mock(ClientContext) + and: "I mock the context and future" + def clientContext = Mock(ClientContext) + + and: "A client eval feature config" + def config = new EdgeFeatureHubConfig("http://localhost/", "123*abc") { + @Override + ClientContext newContext() { + return clientContext + } + } + when: "init is called with a very short timeout" + config.init(1, TimeUnit.MILLISECONDS) + then: "The get method on the future should be called with timeout" + 1 * futureContext.get(1, TimeUnit.MILLISECONDS) >> { throw new TimeoutException() } + 1 * clientContext.build() >> futureContext + 0 * _ + } +} diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy new file mode 100644 index 0000000..7480b06 --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/FeatureHubTestClientFactory.groovy @@ -0,0 +1,110 @@ +package io.featurehub.client + +import io.featurehub.sse.model.FeatureStateUpdate +import org.jetbrains.annotations.NotNull +import org.jetbrains.annotations.Nullable + +import java.util.concurrent.Future +import java.util.function.Supplier + +class FeatureHubTestClientFactory implements FeatureHubClientFactory { + class FakeEdgeService implements EdgeService { + final InternalFeatureRepository repository + final FeatureHubConfig config + + FakeEdgeService(@Nullable InternalFeatureRepository repository, @NotNull FeatureHubConfig config) { + this.repository = repository ?: config.repository as InternalFeatureRepository + this.config = config + } + + @Override + Future contextChange(@Nullable String newHeader, String contextSha) { + return null + } + + @Override + boolean isClientEvaluation() { + return false + } + + @Override + boolean isStopped() { + return false + } + + @Override + void close() { + } + + @NotNull + @Override + FeatureHubConfig getConfig() { + return config + } + + @Override + Future poll() { + return null + } + + @Override + long currentInterval() { + return 0 + } + } + + class FakeTestApi implements TestApi { + + @Override + TestApiResult setFeatureState(String apiKey, @NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return null + } + + @Override + TestApiResult setFeatureState(@NotNull String featureKey, @NotNull FeatureStateUpdate featureStateUpdate) { + return null + } + + @Override + void close() { + + } + } + + static FakeEdgeService fake + static FakeTestApi fakeTestApi + + @Override + @NotNull + Supplier createSSEEdge(FeatureHubConfig config, InternalFeatureRepository repository) { + fake = new FakeEdgeService(repository, config) + return { -> fake } + } + + @Override + @NotNull + Supplier createSSEEdge(@NotNull FeatureHubConfig config) { + return createSSEEdge(config, null) + } + + @Override + @NotNull + Supplier createRestEdge(@NotNull FeatureHubConfig config, @Nullable InternalFeatureRepository repository, int timeoutInSeconds, boolean amPolling) { + fake = new FakeEdgeService(repository, config) + return { -> fake } + } + + @Override + @NotNull + Supplier createRestEdge(@NotNull FeatureHubConfig config, int timeoutInSeconds, boolean amPolling) { + fake = new FakeEdgeService(config.getInternalRepository(), config) + return { -> fake } + } + + @Override + @NotNull + Supplier createTestApi(@NotNull FeatureHubConfig config) { + fakeTestApi = new FakeTestApi() + return { -> fakeTestApi } + } +} diff --git a/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy similarity index 73% rename from client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy index eb76430..90ba1fa 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/InterceptorSpec.groovy @@ -17,11 +17,11 @@ class InterceptorSpec extends Specification { System.setProperty(name, "true") System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == 'true' - fr.getFeatureState(featureName).number == null - fr.getFeatureState("feature_none").string == null - !fr.getFeatureState("feature_none").boolean + fr.getFeat(featureName).flag + fr.getFeat(featureName).string == 'true' + fr.getFeat(featureName).number == null + fr.getFeat("feature_none").string == null + !fr.getFeat("feature_none").flag } def "we can deserialize json in an override"() { @@ -36,12 +36,12 @@ class InterceptorSpec extends Specification { System.setProperty(name, rawJson) System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - !fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == rawJson - fr.getFeatureState(featureName).rawJson == rawJson - fr.getFeatureState(featureName).getJson(BananaSample) instanceof BananaSample - fr.getFeatureState(featureName).getJson(BananaSample).sample == 18 - fr.getFeatureState("feature_none").getJson(BananaSample) == null + !fr.getFeat(featureName).flag + fr.getFeat(featureName).string == rawJson + fr.getFeat(featureName).rawJson == rawJson + fr.getFeat(featureName).getJson(BananaSample) instanceof BananaSample + fr.getFeat(featureName).getJson(BananaSample).sample == 18 + fr.getFeat("feature_none").getJson(BananaSample) == null } def "we can deserialize a number in an override"() { @@ -56,11 +56,11 @@ class InterceptorSpec extends Specification { System.setProperty(name, numString) System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - !fr.getFeatureState(featureName).boolean - fr.getFeatureState(featureName).string == numString - fr.getFeatureState(featureName).rawJson == numString - fr.getFeatureState(featureName).number == 17.65 - fr.getFeatureState('feature_none').number == null + !fr.getFeat(featureName).flag + fr.getFeat(featureName).string == numString + fr.getFeat(featureName).rawJson == numString + fr.getFeat(featureName).number == 17.65 + fr.getFeat('feature_none').number == null } def "if system property loader is turned off, overrides are ignored"() { @@ -73,10 +73,10 @@ class InterceptorSpec extends Specification { System.setProperty(name, "true") System.clearProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE) then: - !fr.getFeatureState("feature_one").boolean - fr.getFeatureState("feature_one").string == null - fr.getFeatureState("feature_none").string == null - !fr.getFeatureState("feature_none").boolean + !fr.getFeat("feature_one").flag + fr.getFeat("feature_one").string == null + fr.getFeat("feature_none").string == null + !fr.getFeat("feature_none").flag } def "if a feature is locked, we won't call an interceptor that is overridden"() { @@ -84,9 +84,9 @@ class InterceptorSpec extends Specification { def fr = new ClientFeatureRepository(1); fr.registerValueInterceptor(false, Mock(FeatureValueInterceptor)) and: "we register a feature" - fr.notify([new FeatureState().value(true).type(FeatureValueType.BOOLEAN).key("x").id(UUID.randomUUID()).l(true)]) + fr.updateFeatures([new FeatureState().value(true).type(FeatureValueType.BOOLEAN).key("x").id(UUID.randomUUID()).l(true)]) when: "i ask for the feature" - def f = fr.getFeatureState("x").boolean + def f = fr.getFeat("x").flag then: f } @@ -102,7 +102,7 @@ class InterceptorSpec extends Specification { def peachQuantity = new FeatureState().id(UUID.randomUUID()).key('peach-quantity_or').version(1L).value(17).type(FeatureValueType.NUMBER) def peachConfig = new FeatureState().id(UUID.randomUUID()).key('peach-config_or').version(1L).value("{}").type(FeatureValueType.JSON) def features = [banana, orange, peachConfig, peachQuantity] - fr.notify(features) + fr.updateFeatures(features) when: "we set the feature override" System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + banana.key, "true") System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + orange.key, "nectarine") @@ -110,11 +110,11 @@ class InterceptorSpec extends Specification { System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_PREFIX + peachConfig.key, '{"sample":12}') System.setProperty(SystemPropertyValueInterceptor.FEATURE_TOGGLES_ALLOW_OVERRIDE, "true") then: - fr.getFeatureState(banana.key).boolean - fr.getFeatureState(orange.key).string == 'nectarine' - fr.getFeatureState(peachQuantity.key).number == 13 - fr.getFeatureState(peachConfig.key).rawJson == '{"sample":12}' - fr.getFeatureState(peachConfig.key).getJson(BananaSample).sample == 12 + fr.getFeat(banana.key).flag + fr.getFeat(orange.key).string == 'nectarine' + fr.getFeat(peachQuantity.key).number == 13 + fr.getFeat(peachConfig.key).rawJson == '{"sample":12}' + fr.getFeat(peachConfig.key).getJson(BananaSample).sample == 12 } } diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy new file mode 100644 index 0000000..88a46ee --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/ListenerSpec.groovy @@ -0,0 +1,54 @@ +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 java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + +import static io.featurehub.client.BaseClientContext.USER_KEY + +class ListenerSpec extends Specification { + def "When a listener fires, it will always attempt to take the context into account if it was listened to via a context"() { + given: "i have a setup" + def repo = Mock(InternalFeatureRepository) + def edge = Mock(EdgeService) +// def ctx = Mock(InternalContext) + def ctx = new BaseClientContext(repo, edge) + def key = "fred" + def id = UUID.randomUUID() + and: "a feature" + def feat = new FeatureStateBase(repo, key) + def ctxFeat = feat.withContext(ctx) + BigDecimal n1; + BigDecimal n2; + // the listeners will trigger a repo.execute when they are evaluated + feat.addListener({ fs -> + n1 = fs.number + }) + ctxFeat.addListener({ fs -> n2 = fs.number }) + when: "i set the feature state" + feat.setFeatureState(new io.featurehub.sse.model.FeatureState().id(id).key(key).l(false) + .value(16).type(FeatureValueType.NUMBER).addStrategiesItem(new FeatureRolloutStrategy().value(12).addAttributesItem( + new FeatureRolloutStrategyAttribute().conditional(RolloutStrategyAttributeConditional.EQUALS) + .type(RolloutStrategyFieldType.STRING).fieldName(USER_KEY).addValuesItem("fred") + ))) + then: + n1 == 16 + n2 == 12 + 2 * repo.findIntercept(false, key) >> null // one for each listener + 3 * repo.execute({Runnable cmd -> // 2 for listeners, 1 for firing the "used" on the repo via the context + cmd.run() + }) + 1 * repo.applyFeature(_, key, _, ctx) >> new Applied(true, 12) +// 1 * ctx.used(key, id, 12, FeatureValueType.NUMBER) + 1 * repo.used(key, id, FeatureValueType.NUMBER, 16, null, null) + 1 * repo.used(key, id, FeatureValueType.NUMBER, 12, {}, null) + 1 * edge.poll() >> CompletableFuture.completedFuture(Readiness.Ready) + 0 * _ + } +} diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy new file mode 100644 index 0000000..f998f5c --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/RepositorySpec.groovy @@ -0,0 +1,265 @@ +package io.featurehub.client + +import com.fasterxml.jackson.databind.ObjectMapper +import io.featurehub.javascript.Jackson2ObjectMapper +import io.featurehub.sse.model.FeatureState +import io.featurehub.sse.model.FeatureValueType +import io.featurehub.sse.model.SSEResultState +import spock.lang.Specification + +import java.util.concurrent.ExecutorService +import java.util.function.Consumer + +enum Fruit implements Feature { banana, peach, peach_quantity, peach_config, dragonfruit } + +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 == Readiness.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).featureProperties(Map.of("pork", "dumplings")), + new FeatureState().id(UUID.randomUUID()).key('peach_quantity').version(1L).value(17).type(FeatureValueType.NUMBER), + new FeatureState().id(UUID.randomUUID()).key('peach_config').version(1L).value("{}").type(FeatureValueType.JSON), + ] + and: "we have a readyness listener" + Consumer readinessHandler = Mock(Consumer) + when: // have to do this in the when or it isn't tracking the mock + repo.addReadinessListener(readinessHandler) + and: + repo.updateFeatures(features) + then: + 1 * readinessHandler.accept(Readiness.Ready) + 1 * readinessHandler.accept(Readiness.NotReady) + 0 * _ + !repo.getFeat('banana').flag + repo.getFeat('banana').key == 'banana' + repo.getFeat('banana').exists() + repo.getFeat('banana').featureProperties().isEmpty() + repo.getFeat(Fruit.banana).exists() + !repo.getFeat('dragonfruit').exists() + !repo.getFeat(Fruit.dragonfruit).exists() + repo.getFeat('banana').rawJson == null + repo.getFeat('banana').string == null + repo.getFeat('banana').number == null + repo.getFeat('banana').number == null + repo.getFeat('banana').set + !repo.getFeat('banana').enabled + repo.getFeat('peach').string == 'orange' + repo.getFeat('peach').exists() + repo.getFeat('peach').featureProperties() == ['pork': 'dumplings'] + repo.getFeat(Fruit.peach).exists() + repo.getFeat('peach').key == 'peach' + repo.getFeat('peach').number == null + repo.getFeat('peach').rawJson == null + repo.getFeat('peach').flag == null + repo.getFeat('peach_quantity').number == 17 + repo.getFeat('peach_quantity').featureProperties().isEmpty() + repo.getFeat('peach_quantity').rawJson == null + repo.getFeat('peach_quantity').flag == null + repo.getFeat('peach_quantity').string == null + repo.getFeat('peach_quantity').key == 'peach_quantity' + repo.getFeat('peach_config').rawJson == '{}' + repo.getFeat('peach_config').string == null + repo.getFeat('peach_config').number == null + repo.getFeat('peach_config').flag == null + repo.getFeat('peach_config').key == 'peach_config' + repo.getFeat('peach_config').featureProperties().isEmpty() + repo.getAllFeatures().size() == 5 + } + + 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.updateFeatures(features) + def feature = repo.getFeat('banana').flag + and: "i make a change to the state but keep the version the same (ok because this is what rollout strategies do)" + repo.updateFeatures([ + new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN), + ]) + def feature2 = repo.getFeat('banana').flag + and: "then i make the change but up the version" + repo.updateFeatures([ + new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true).type(FeatureValueType.BOOLEAN), + ]) + def feature3 = repo.getFeat('banana').flag + and: "then i make a change but force it even if the version is the same" + repo.updateFeatures([ + new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), + ], true) + def feature4 = repo.getFeat('banana').flag + 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.getFeat('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.deleteFeature(feature) + then: + !repo.getFeat('banana').enabled + } + + def "A feature is deleted and it is now not set"() { + given: "i have a feature" + def feature = new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(true).type(FeatureValueType.BOOLEAN) + and: "i notify repo" + repo.updateFeatures([feature]) + when: "i check the feature state" + def f = repo.getFeat('banana').flag + and: "i delete the feature" + def featureDel = new FeatureState().id(UUID.randomUUID()).key('banana').version(2L).value(true).type(FeatureValueType.BOOLEAN) + repo.deleteFeature(featureDel) + then: + f + !repo.getFeat('banana').enabled + } + + + 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 Jackson2ObjectMapper()) + when: "i notify of features" + repo.updateFeatures(features) + then: 'the json object is there and deserialises' + repo.getFeat('banana').getJson(BananaSample) instanceof BananaSample + repo.getFeat(Fruit.banana).getJson(BananaSample) instanceof BananaSample + repo.getFeat('banana').getJson(BananaSample).sample == 12 + repo.getFeat(Fruit.banana).getJson(BananaSample).sample == 12 + } + + def "failure changes readiness to failure"() { + given: "i have features" + def features = [ + new FeatureState().id(UUID.randomUUID()).key('banana').version(1L).value(false).type(FeatureValueType.BOOLEAN), + ] + and: "i notify the repo" + List statuses = [] + Consumer readynessHandler = { Readiness r -> + print("called $r") + statuses.add(r) + } + repo.addReadinessListener(readynessHandler) + repo.updateFeatures(features) + def readyness = repo.readyness + when: "i indicate failure" + repo.notify(SSEResultState.FAILURE) + then: "we swap to not ready" + repo.readiness == Readiness.Failed + readyness == Readiness.Ready + statuses == [Readiness.NotReady, Readiness.Ready, Readiness.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.updateFeatures(features) + when: "i ack and then bye, nothing happens" + repo.notify(SSEResultState.ACK) + repo.notify(SSEResultState.BYE) + then: + repo.readyness == Readiness.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.getFeat(f.key) + def listener = Mock(FeatureListener) + updateListener.add(listener) + feature.addListener(listener) + emptyFeatures.add(feature.usageCopy()) + } + def featureCountAfterRequestingEmptyFeatures = repo.allFeatures.size() + when: "i fill in the repo" + repo.updateFeatures(features) + then: + featureCountAfterRequestingEmptyFeatures == features.size() + updateListener.each { + 1 * it.notify(_) + } + emptyFeatures.each {f -> + f.key != null + !f.enabled + f.string == null + f.flag == null + f.rawJson == null + f.number == null + } + features.each { it -> + repo.getFeat(it.key).key == it.key + repo.getFeat(it.key).enabled + + if (it.type == FeatureValueType.BOOLEAN) + repo.getFeat(it.key).flag == it.value + else + repo.getFeat(it.key).flag == null + + if (it.type == FeatureValueType.NUMBER) + repo.getFeat(it.key).number == it.value + else + repo.getFeat(it.key).number == null + + if (it.type == FeatureValueType.STRING) + repo.getFeat(it.key).string.equals(it.value) + else + repo.getFeat(it.key).string == null + + if (it.type == FeatureValueType.JSON) + repo.getFeat(it.key).rawJson.equals(it.value) + else + repo.getFeat(it.key).rawJson == null + } + + } + +} diff --git a/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/SdkVersionSpec.groovy diff --git a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy similarity index 60% rename from client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy index 3739811..372cdd6 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/ServerEvalContextSpec.groovy @@ -5,37 +5,34 @@ import spock.lang.Specification import java.util.concurrent.CompletableFuture class ServerEvalContextSpec extends Specification { - def config - def repo - def edge + FeatureHubConfig config + InternalFeatureRepository repo + EdgeService edge def setup() { config = Mock(FeatureHubConfig) - repo = Mock(FeatureRepositoryContext) + repo = Mock(InternalFeatureRepository) edge = Mock(EdgeService) } def "a server eval context should allow a build which should trigger a poll"() { given: "i have the requisite setup" - def scc = new ServerEvalFeatureContext(config, repo, { -> edge}) - edge.isRequiresReplacementOnHeaderChange() >> false + def scc = new ServerEvalFeatureContext(repo, edge) when: "i attempt to build" - scc.build(); - scc.userKey("fred").build() - scc.clear().build(); + scc.build().get(); + scc.userKey("fred").build().get() + scc.clear().build().get() then: "" - 2 * repo.notReady() - 2 * edge.isRequiresReplacementOnHeaderChange() + 2 * repo.repositoryNotReady() 2 * edge.contextChange(null, '0') >> { def future = new CompletableFuture<>() future.complete(scc) return future } 1 * edge.contextChange("userkey=fred", '6a1d1fa42d1c1917552a255a940792205cb62cc2efd6613ab5a3f75d0038518b') >> { - def future = new CompletableFuture<>() - future.complete(scc) - return future + return CompletableFuture.completedFuture(scc) } + 3 * repo.execute { Runnable cmd -> cmd.run() } 0 * _ } } diff --git a/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy similarity index 77% rename from client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy index 8c924dd..dab003d 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/StrategySpec.groovy @@ -15,6 +15,7 @@ import java.util.concurrent.ExecutorService class StrategySpec extends Specification { ClientFeatureRepository repo + EdgeService edge def setup() { def exec = [ @@ -22,7 +23,10 @@ class StrategySpec extends Specification { shutdownNow: { -> }, isShutdown: { false } ] as ExecutorService + repo = new ClientFeatureRepository(exec) + + edge = Mock(EdgeService) } def "basic boolean strategy"() { @@ -41,18 +45,18 @@ class StrategySpec extends Specification { ] )]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def cc = new TestContext(repo).country(StrategyAttributeCountryName.TURKEY) + def cc = new TestContext(repo, edge).country(StrategyAttributeCountryName.TURKEY) and: "we create a context not matching the strategy" - def ccNot = new TestContext(repo).country(StrategyAttributeCountryName.NEW_ZEALAND) + def ccNot = new TestContext(repo, edge).country(StrategyAttributeCountryName.NEW_ZEALAND) then: "without the context it is true" - repo.getFeatureState("bool1").boolean + repo.getFeat("bool1").flag and: "with the good context it is false" - !cc.feature("bool1").boolean + !cc.feature("bool1").flag !cc.isEnabled("bool1") and: "with the bad context it is true" - ccNot.feature("bool1").boolean + ccNot.feature("bool1").flag } def "number strategy"() { @@ -79,13 +83,13 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27") - def ccNoMatch = new TestContext(repo).attr("age", "18") - def ccSecond = new TestContext(repo).attr("age", "43") + def ccFirst = new TestContext(repo, edge).attr("age", "27") + def ccNoMatch = new TestContext(repo, edge).attr("age", "18") + def ccSecond = new TestContext(repo, edge).attr("age", "43") then: "without the context it is true" - repo.getFeatureState("num1").number == 16 + repo.getFeat("num1").number == 16 ccNoMatch.feature("num1").number == 16 ccSecond.feature("num1").number == 6 ccFirst.feature("num1").number == 10 @@ -115,15 +119,15 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27").platform(StrategyAttributePlatformName.IOS) - def ccNoMatch = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) - def ccSecond = new TestContext(repo).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) - def ccThird = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) - def ccEmpty = new TestContext(repo) + def ccFirst = new TestContext(repo, edge).attr("age", "27").platform(StrategyAttributePlatformName.IOS) + def ccNoMatch = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) + def ccSecond = new TestContext(repo, edge).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) + def ccThird = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) + def ccEmpty = new TestContext(repo, edge) then: "without the context it is true" - repo.getFeatureState("feat1").string == "feature" + repo.getFeat("feat1").string == "feature" ccNoMatch.feature("feat1").string == "feature" ccSecond.feature("feat1").string == "not-mobile" ccFirst.feature("feat1").string == "older-than-twenty" @@ -154,16 +158,16 @@ class StrategySpec extends Specification { ) ]) and: "we have a feature repository with this in it" - repo.notify([f]) + repo.updateFeatures([f]) when: "we create a client context matching the strategy" - def ccFirst = new TestContext(repo).attr("age", "27").platform(StrategyAttributePlatformName.IOS) - def ccNoMatch = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) - def ccSecond = new TestContext(repo).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) - def ccThird = new TestContext(repo).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) - def ccEmpty = new TestContext(repo) + def ccFirst = new TestContext(repo, edge).attr("age", "27").platform(StrategyAttributePlatformName.IOS) + def ccNoMatch = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.ANDROID) + def ccSecond = new TestContext(repo, edge).attr("age", "43").platform(StrategyAttributePlatformName.MACOS) + def ccThird = new TestContext(repo, edge).attr("age", "18").platform(StrategyAttributePlatformName.MACOS) + def ccEmpty = new TestContext(repo, edge) then: "without the context it is true" - repo.getFeatureState("feat1").rawJson == "feature" - repo.getFeatureState("feat1").string == null + repo.getFeat("feat1").rawJson == "feature" + repo.getFeat("feat1").string == null ccNoMatch.feature("feat1").rawJson == "feature" ccNoMatch.feature("feat1").string == null ccSecond.feature("feat1").rawJson == "not-mobile" diff --git a/core/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy new file mode 100644 index 0000000..4cfb509 --- /dev/null +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/TestContext.groovy @@ -0,0 +1,29 @@ +package io.featurehub.client + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + +class TestContext extends BaseClientContext { + TestContext(InternalFeatureRepository repo, EdgeService edgeService) { + super(repo, edgeService) + } + + @Override + Future build() { + return CompletableFuture.completedFuture(this) + } + + @Override + EdgeService getEdgeService() { + return null + } + + @Override + void close() { + } + + @Override + boolean exists(String key) { + return feature(key).exists() + } +} diff --git a/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy similarity index 89% rename from client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy index 75cd67e..d77bdaf 100644 --- a/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy +++ b/core/client-java-core/src/test/groovy/io/featurehub/client/edge/EdgeRetryerSpec.groovy @@ -14,7 +14,7 @@ class EdgeRetryerSpec extends Specification { def setup() { mockExecutor = Mock(ExecutorService) reconnector = Mock(EdgeReconnector) - retryer = new EdgeRetryer(100, 100, 100, 10, 100) { + retryer = new EdgeRetryer(100, 100, 100, 10, 100, 100) { @Override protected ExecutorService makeExecutorService() { return mockExecutor @@ -69,10 +69,10 @@ class EdgeRetryerSpec extends Specification { def "if the server says 'connect timeout' then we will backoff with the connect timeout and adjust backoff"() { when: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: backoffAdjustBackoff - backoffBaseTime == retryer.serverConnectTimeoutMs + backoffBaseTime == retryer.serverReadTimeoutMs 1 * reconnector.reconnect() 1 * mockExecutor.submit({ Runnable task -> task.run()}) } @@ -87,7 +87,7 @@ class EdgeRetryerSpec extends Specification { def "if the executor service is shut down, no calls are ignored"() { when: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: 1 * mockExecutor.isShutdown() >> true 0 * reconnector.reconnect() @@ -97,7 +97,7 @@ class EdgeRetryerSpec extends Specification { when: "i send the api not found event" retryer.edgeResult(EdgeConnectionState.API_KEY_NOT_FOUND, reconnector ) and: "i send a connect timeout event" - retryer.edgeResult(EdgeConnectionState.SERVER_CONNECT_TIMEOUT, reconnector ) + retryer.edgeResult(EdgeConnectionState.SERVER_READ_TIMEOUT, reconnector ) then: 1 * mockExecutor.isShutdown() >> false 0 * reconnector.reconnect() diff --git a/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy b/core/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy similarity index 100% rename from client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy rename to core/client-java-core/src/test/groovy/io/featurehub/strategies/percentage/PercentageMurmurCalculatorSpec.groovy diff --git a/client-java-core/src/test/resources/META-INF/MANIFEST.MF b/core/client-java-core/src/test/resources/META-INF/MANIFEST.MF similarity index 100% rename from client-java-core/src/test/resources/META-INF/MANIFEST.MF rename to core/client-java-core/src/test/resources/META-INF/MANIFEST.MF diff --git a/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/core/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to core/client-java-core/src/test/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-core/src/test/resources/log4j2.xml b/core/client-java-core/src/test/resources/log4j2.xml similarity index 100% rename from client-java-core/src/test/resources/log4j2.xml rename to core/client-java-core/src/test/resources/log4j2.xml diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..e33e59d --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.featurehub.sdk.java + core-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + client-java-api + client-java-core + + diff --git a/examples/todo-java/.editorconfig b/examples/.editorconfig similarity index 100% rename from examples/todo-java/.editorconfig rename to examples/.editorconfig diff --git a/examples/migration-check/pom.xml b/examples/migration-check/pom.xml index 82f95d6..44ffebc 100644 --- a/examples/migration-check/pom.xml +++ b/examples/migration-check/pom.xml @@ -28,22 +28,9 @@ io.featurehub.sdk - java-client-sse - [1.4, 2) + featurehub-okhttp3-jackson2 + [3, 4) - - - io.featurehub.sdk - java-client-android - [2, 3) - - - - io.featurehub.sdk.composites - sdk-composite-logging - [1.1, 2) - - @@ -59,12 +46,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) diff --git a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java index 41957b8..90a07a1 100644 --- a/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java +++ b/examples/migration-check/src/main/java/io/featurehub/migrationcheck/Main.java @@ -1,14 +1,14 @@ package io.featurehub.migrationcheck; -import io.featurehub.android.FeatureHubClient; import io.featurehub.client.EdgeFeatureHubConfig; import io.featurehub.client.FeatureHubConfig; -import io.featurehub.client.Readyness; -import io.featurehub.edge.sse.SSEClientFactory; +import io.featurehub.client.Readiness; +import io.featurehub.client.edge.EdgeRetryer; +import io.featurehub.okhttp.RestClient; +import io.featurehub.okhttp.SSEClient; import org.jetbrains.annotations.NotNull; import java.io.IOException; -import java.util.Collections; import java.util.concurrent.ExecutionException; public class Main { @@ -18,22 +18,19 @@ static String env(@NotNull String key, @NotNull String defaultVal) { } public static void main(String[] args) throws ExecutionException, InterruptedException, IOException { String edgeUrl = env("FEATUREHUB_EDGE_URL", "http://localhost:8085"); - String apiKey = env("FEATUREHUB_CLIENT_API_KEY", "ddd28309-7a5d-4e5a-b060-3f02ddd9e771" + - "/iHwJ3Nvmpqgpz7HK9L7KDTzf9RSH9Q*WYArdlfMWHi6PjT57K6K1"); + String apiKey = env("FEATUREHUB_CLIENT_API_KEY", "08c8a5f3-f766-4059-98cf-581424c8a6e3/fYetsNTQlWR7rTq9vPQv6bNd2i6W6o*5aHEEjnyIjNo2QmCnuEj"); // we need to configure the Config that holds this all together and will swap to SSE once we tell it to FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); // now we _directly_ create the REST based client, pointing it at our config and our repository - FeatureHubClient client = new FeatureHubClient(config.baseUrl(), - Collections.singletonList(config.apiKey()), - config.getRepository(), config); + RestClient client = new RestClient(config); // and now we block, waiting for it to connect and tell us if it is ready or not - if (client.contextChange(null, "0").get() == Readyness.Ready) { + if (client.poll().get() == Readiness.Ready) { client.close(); // make sure you close it, it has a background thread // once it is ready, we tell the config to use SSE as its connector, and start the config going. - config.setEdgeService(new SSEClientFactory().createEdgeService(config, config.getRepository())); + config.setEdgeService(() -> new SSEClient(config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().sse().build())); config.init(); System.out.println("ready and waiting for updates via SSE"); diff --git a/examples/migration-check/src/main/resources/log4j2.xml b/examples/migration-check/src/main/resources/log4j2.xml index e477ed7..01fb523 100644 --- a/examples/migration-check/src/main/resources/log4j2.xml +++ b/examples/migration-check/src/main/resources/log4j2.xml @@ -7,10 +7,7 @@ - - - diff --git a/examples/pom.xml b/examples/pom.xml index 8a71929..827fd0a 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -4,7 +4,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - io.featurehub + io.featurehub.java featurehub-sdk-example-reactor 1.1.1 pom @@ -34,7 +34,11 @@ - todo-java + todo-java-shared + todo-java-jersey2 + todo-java-jersey3 + + migration-check diff --git a/examples/todo-java-jersey2/pom.xml b/examples/todo-java-jersey2/pom.xml new file mode 100644 index 0000000..822cc0d --- /dev/null +++ b/examples/todo-java-jersey2/pom.xml @@ -0,0 +1,188 @@ + + + 4.0.0 + + io.featurehub.java + todo-java-jersey2 + todo-java-jersey2 + 1.1-SNAPSHOT + + This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). + + It expects environment variables or system property config as follows: + + - feature-service.host = the host where features are stored, e.g. http://localhost:8085 + - feature-service.api-key = the API key issued by the server. + + There are examples in https://github.com/featurehub-io/featurehub/tree/main/adks/e2e-sdk on how to populate your + server automatically via tests to create the features required for this scenario. + + + + ${project.artifactId} + ${project.version} + todo-java-jersey2 + 3.0.1 + 2.0.0 + 2.36 + + + + + io.featurehub.sdk + java-client-jersey2 + [3.1-SNAPSHOT, 4) + + + + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT + + + + io.featurehub.sdk.composites + sdk-composite-jersey2 + [1.1-SNAPSHOT, 2) + + + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + + + + org.glassfish.jersey.containers + jersey-container-grizzly2-http + ${jersey.version} + + + org.glassfish.grizzly + grizzly-http-server + + + + + + org.glassfish.grizzly + grizzly-http-server + ${grizzly.version} + + + + org.glassfish.grizzly + grizzly-http2 + ${grizzly.version} + + + + org.glassfish.grizzly + grizzly-npn-bootstrap + ${grizzly.npn.version} + + + + org.glassfish.grizzly + grizzly-npn-api + ${grizzly.npn.version} + + + + io.featurehub.sdk.composites + sdk-composite-test + 1.2 + test + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + + + + + + org.openapitools + openapi-generator-maven-plugin + 7.0.1 + + + cd.connect.openapi + connect-openapi-jersey3 + 9.1 + + + + + featurehub-api + + generate + + generate-sources + + ${project.basedir}/target/generated-sources/api + todo.api + todo.model + ${project.basedir}/../todo-java-shared/todo-api.yaml + jersey3-api + jersey3-api + + + server + jersey2 + openApiNullable=false + + + + + useBeanValidation + true + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-generated-source + initialize + + add-source + + + + ${project.build.directory}/generated-sources/api/src/gen + + + + + + + + diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java b/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java new file mode 100644 index 0000000..c916abd --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/Application.java @@ -0,0 +1,71 @@ +package todo.backend; + +import cd.connect.app.config.ConfigKey; +import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; +import javax.inject.Singleton; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.server.ResourceConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.backend.resources.FeatureAnalyticsFilter; +import todo.backend.resources.HealthResource; +import todo.backend.resources.LocalExceptionMapper; +import todo.backend.resources.TodoResource; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +public class Application { + private static final Logger log = LoggerFactory.getLogger(Application.class); + @ConfigKey("server.port") + String serverPort = "8099"; + + public Application() { + DeclaredConfigResolver.resolve(this); + } + + public void init() throws Exception { + URI BASE_URI = URI.create(String.format("http://0.0.0.0:%s/", serverPort)); + + log.info("attempting to start on port {} - will wait for features", BASE_URI.toASCIIString()); + + // register our resources, try and tag them as singleton as they are instantiated faster + ResourceConfig config = new ResourceConfig( + TodoResource.class, + HealthResource.class, + LocalExceptionMapper.class, + FeatureAnalyticsFilter.class) + .register(new AbstractBinder() { + @Override + protected void configure() { + bind(FeatureHubSource.class).in(Singleton.class).to(FeatureHub.class); + } + }); + + final HttpServer server = GrizzlyHttpServerFactory.createHttpServer(BASE_URI, config, false); + + server.start(); + + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + server.shutdown(10, TimeUnit.SECONDS); + } + }); + + // tell the App we are ready + ApplicationLifecycleManager.updateStatus(LifecycleStatus.STARTED); + + Thread.currentThread().join(); + } + + public static void main(String[] args) throws Exception { + System.setProperty("jersey.cors.headers", "X-Requested-With,Authorization,Content-type,Accept-Version," + + "Content-MD5,CSRF-Token,x-ijt,cache-control,x-featurehub,baggage"); + + new Application().init(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java new file mode 100644 index 0000000..25850f5 --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/FeatureHubSource.java @@ -0,0 +1,83 @@ +package todo.backend; + +import cd.connect.app.config.ConfigKey; +import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; +import com.segment.analytics.messages.Message; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class FeatureHubSource implements FeatureHub { + String featureHubUrl = FeatureHubConfig.getRequiredConfig("feature-service.host"); + String sdkKey = FeatureHubConfig.getRequiredConfig("feature-service.api-key"); + String segmentWriteKey = FeatureHubConfig.getConfig("segment.write-key"); + String client = FeatureHubConfig.getConfig("feature-service.client", "sse"); // sse, rest, rest-poll + @ConfigKey() + Boolean openTelemetryEnabled = Boolean.parseBoolean(FeatureHubConfig.getConfig("feature-service.opentelemetry.enabled", "false")); + @ConfigKey() + Integer pollInterval = Integer.parseInt(FeatureHubConfig.getConfig("feature-service.poll-interval-seconds", "1")); // in seconds + + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + + private final FeatureHubConfig config; + + public FeatureHubSource() { + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); + + if (segmentWriteKey != null) { + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, + List.of(new SegmentMessageTransformer(Message.Type.values(), + FeatureHubClientContextThreadLocal::get, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; + } + + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } + + // Do this if you wish to force the connection to stay open. + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest") || client.equals("rest-passive")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-poll") || client.equals("rest-active")) { + config.restActive(pollInterval); + } else { + throw new RuntimeException("Unknown featurehub client"); + } + + config.init(); + + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + close(); + } + }); + } + + @Override + public FeatureHubConfig getConfig() { + return config; + } + + @Override + public SegmentAnalyticsSource segmentAnalytics() { + return segmentAnalyticsSource; + } + + public void close() { + config.close(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java new file mode 100644 index 0000000..4690e94 --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -0,0 +1,64 @@ +package todo.backend.resources; + +import cd.connect.app.config.DeclaredConfigResolver; +import io.featurehub.client.ClientContext; +import javax.inject.Inject; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import todo.backend.UsageRequestMeasurement; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { + + private final FeatureHub config; + private static final Logger log = LoggerFactory.getLogger(FeatureAnalyticsFilter.class); + + @Inject + public FeatureAnalyticsFilter(FeatureHub config) { + this.config = config; + DeclaredConfigResolver.resolve(this); + } + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + final long currentTime = System.currentTimeMillis(); + requestContext.setProperty("startTime", currentTime); + final List user = requestContext.getUriInfo().getPathParameters().get("user"); + if (user != null && !user.isEmpty()) { + try { + requestContext.setProperty("context", config.getConfig().newContext().build().get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + Long start = (Long) requestContext.getProperty("startTime"); + ClientContext context = (ClientContext) requestContext.getProperty("context"); + + FeatureHubClientContextThreadLocal.clear(); + + if (start != null && context != null) { + long duration = System.currentTimeMillis() - start; + + final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); + + if (!matchedURIs.isEmpty()) { + context.recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + } + } else { + log.error("There was not start time {} and context {}", start, context); + } + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java new file mode 100644 index 0000000..0077f93 --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/HealthResource.java @@ -0,0 +1,29 @@ +package todo.backend.resources; + + +import io.featurehub.client.Readiness; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Response; +import todo.backend.FeatureHub; + +@Path("/health") +public class HealthResource { + private final FeatureHub featureHub; + + @Inject + public HealthResource(FeatureHub featureHub) { + this.featureHub = featureHub; + } + + @GET + @Path(("/liveness")) + public Response liveness() { + if (featureHub.getConfig().getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.serverError().build(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java new file mode 100644 index 0000000..cc7bebd --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/LocalExceptionMapper.java @@ -0,0 +1,32 @@ +package todo.backend.resources; + +import javax.ws.rs.NotFoundException; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Provider +public class LocalExceptionMapper implements ExceptionMapper { + private static final Logger log = LoggerFactory.getLogger(LocalExceptionMapper.class); + + @Override + public Response toResponse(Exception exception) { + if (exception instanceof WebApplicationException) { + Response response = ((WebApplicationException) exception).getResponse(); + + if (response.getStatus() >= 500) { // special callout to all our 5xx in the house. + log.error("Error HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } else if (!(exception instanceof NotFoundException)) { + log.warn("Failed HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } + + return response; + } + + log.error("Failed jersey request", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java new file mode 100644 index 0000000..cdc16ac --- /dev/null +++ b/examples/todo-java-jersey2/src/main/java/todo/backend/resources/TodoResource.java @@ -0,0 +1,152 @@ +package todo.backend.resources; + +import com.segment.analytics.messages.IdentifyMessage; +import io.featurehub.client.ClientContext; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.WebApplicationException; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.api.TodoService; +import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import todo.model.Todo; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Singleton +public class TodoResource implements TodoService { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final FeatureHub featureHub; + Map> todos = new ConcurrentHashMap<>(); + + @Inject + public TodoResource(FeatureHub config) { + this.featureHub = config; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = fhClient(user); + + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @NotNull private ClientContext fhClient(String user) { + try { + final ClientContext context = featureHub.getConfig().newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + + FeatureHubClientContextThreadLocal.set(context); + + if (featureHub.segmentAnalytics() != null) { + // this should have the current user's details augmented into it + featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); + } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; + } catch (Exception e) { + log.error("Unable to get context!", e); + throw new WebApplicationException(e); + } + } + + @Override + public List addTodo(@NotNull String user, Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @Override + public List listTodos(@NotNull String user) { + return getTodoList(getTodoMap(user), user); + } + + @Override + public void removeAllTodos(@NotNull String user) { + getTodoMap(user).clear(); + } + + @Override + public List removeTodo(@NotNull String user, @NotNull String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @Override + public List resolveTodo(@NotNull String id, @NotNull String user) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new NotFoundException(); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/examples/todo-java/src/test/java/todo/backend/AppRunner.java b/examples/todo-java-jersey2/src/test/java/todo/backend/AppRunner.java similarity index 100% rename from examples/todo-java/src/test/java/todo/backend/AppRunner.java rename to examples/todo-java-jersey2/src/test/java/todo/backend/AppRunner.java diff --git a/examples/todo-java-jersey3/README.adoc b/examples/todo-java-jersey3/README.adoc new file mode 100644 index 0000000..d0f202b --- /dev/null +++ b/examples/todo-java-jersey3/README.adoc @@ -0,0 +1,18 @@ += ToDo Jersey3 Example + +This is an example of wiring up using Jersey3 instead of Jersey2. The same +settings apply. + +It is expected that this is run in the IDE by default as it is not packaged. The +test will load system properties. + +== System Properties + +The system properties it honours are: + +- `feature-service.host` - the http/https location of your featurehub server minus the `/features` part. +- `feature-service.api-key` - the client eval key, do not use a server eval key for web servers. +- `segment.write-key` - a segment key if you have one +- `feature-service.client`, defaultValue = `sse` - valid values are sse, rest (passive poll, poll only if features are evaluated and polling interval has expired) and rest-poll (continuous poll). +- `feature-service.opentelemetry.enabled`, defaultValue = `false` - you have an otel server and have set the env vars it requires, this turns instrumentation on. +- `feature-service.poll-interval-seconds`, defaultValue = `1` - how many seconds should expire between polls (or poll expiry interval for passive polls). diff --git a/examples/todo-java/pom.xml b/examples/todo-java-jersey3/pom.xml similarity index 65% rename from examples/todo-java/pom.xml rename to examples/todo-java-jersey3/pom.xml index 18856e7..a2c1002 100644 --- a/examples/todo-java/pom.xml +++ b/examples/todo-java-jersey3/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - cd.connect - todo-backend-java - todo-backend-java + io.featurehub.java + todo-java-jersey3 + todo-java-jersey3 1.1-SNAPSHOT This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). @@ -23,82 +23,52 @@ ${project.artifactId} ${project.version} - connect_todo + todo-java-jersey2 3.0.1 2.0.0 - 3.0.3 + 3.1.2 io.featurehub.sdk java-client-jersey3 - [1.1, 2) + [3.1-SNAPSHOT, 4) - io.featurehub.sdk - java-client-sse - [1.2-SNAPSHOT, 2) - - - - io.featurehub.sdk - java-client-android - [2, 3) - - - - io.featurehub.sdk.composites - sdk-composite-jersey3 - [1.1-SNAPSHOT, 2) + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT - + - cd.connect.common - connect-app-declare-config - 1.3 - - - net.stickycode.composite - sticky-composite-logging-api - - + io.opentelemetry.javaagent.instrumentation + opentelemetry-javaagent-jaxrs-3.0-jersey-3.0 + 2.6.0-alpha - + - com.bluetrainsoftware.bathe - bathe-booter - [3.1, 4) - - - - - com.bluetrainsoftware.bathe.initializers - system-property-loader - 3.1 - - - * - * - - + io.opentelemetry.javaagent.instrumentation + opentelemetry-javaagent-grizzly-2.3 + 2.6.0-alpha io.featurehub.sdk.composites - sdk-composite-logging + sdk-composite-jersey3 [1.1-SNAPSHOT, 2) - io.featurehub.sdk.composites - sdk-composite-jersey3 - [1.1-SNAPSHOT, 2) + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + org.glassfish.jersey.containers jersey-container-grizzly2-http @@ -135,33 +105,6 @@ ${grizzly.npn.version} - - - com.bluetrainsoftware.bathe.initializers - jul-bridge - 2.1 - - - * - * - - - - - - - org.yaml - snakeyaml - 2.0 - - - - - cd.connect.common - connect-app-lifecycle - 1.1 - - io.featurehub.sdk.composites sdk-composite-test @@ -183,12 +126,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) @@ -196,12 +139,12 @@ org.openapitools openapi-generator-maven-plugin - 5.2.1 + 7.0.1 cd.connect.openapi connect-openapi-jersey3 - 7.9 + 9.1 @@ -215,12 +158,13 @@ ${project.basedir}/target/generated-sources/api todo.api todo.model - ${project.basedir}/todo-api.yaml + ${project.basedir}/../todo-java-shared/todo-api.yaml jersey3-api jersey3-api server + openApiNullable=false diff --git a/examples/todo-java/src/main/java/todo/backend/Application.java b/examples/todo-java-jersey3/src/main/java/todo/backend/Application.java similarity index 67% rename from examples/todo-java/src/main/java/todo/backend/Application.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/Application.java index 06c0b94..77c2217 100644 --- a/examples/todo-java/src/main/java/todo/backend/Application.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/Application.java @@ -4,7 +4,7 @@ import cd.connect.app.config.DeclaredConfigResolver; import cd.connect.lifecycle.ApplicationLifecycleManager; import cd.connect.lifecycle.LifecycleStatus; -import io.featurehub.client.Readyness; +import jakarta.inject.Singleton; import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; import org.glassfish.jersey.internal.inject.AbstractBinder; @@ -12,17 +12,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.backend.resources.FeatureAnalyticsFilter; +import todo.backend.resources.HealthResource; +import todo.backend.resources.LocalExceptionMapper; import todo.backend.resources.TodoResource; -import jakarta.inject.Singleton; -import java.io.IOException; import java.net.URI; import java.util.concurrent.TimeUnit; public class Application { - private static final Logger log = LoggerFactory.getLogger(Application.class); - @ConfigKey("server.port") - String serverPort = "8099"; + private static final Logger log = LoggerFactory.getLogger(Application.class); + @ConfigKey("server.port") + String serverPort = "8099"; public Application() { DeclaredConfigResolver.resolve(this); @@ -33,39 +33,22 @@ public void init() throws Exception { log.info("attempting to start on port {} - will wait for features", BASE_URI.toASCIIString()); - FeatureHubSource fhSource = new FeatureHubSource(); - // register our resources, try and tag them as singleton as they are instantiated faster ResourceConfig config = new ResourceConfig( TodoResource.class, + HealthResource.class, + LocalExceptionMapper.class, FeatureAnalyticsFilter.class) .register(new AbstractBinder() { @Override protected void configure() { - bind(fhSource).in(Singleton.class).to(FeatureHub.class); + bind(FeatureHubSource.class).in(Singleton.class).to(FeatureHub.class); } - }) - - ; + }); final HttpServer server = GrizzlyHttpServerFactory.createHttpServer(BASE_URI, config, false); - // call "server.start()" here if you wish to start the application without waiting for features - log.info("Waiting on a complete list of features before starting."); - fhSource.getRepository().addReadynessListener((ready) -> { - if (ready == Readyness.Ready) { - try { - server.start(); - } catch (IOException e) { - log.error("Failed to start", e); - throw new RuntimeException(e); - } - - log.info("Application started. (HTTP/2 enabled!) -> {}", BASE_URI); - } else if (ready == Readyness.Failed) { - log.info("Connection failed, wait for it to come back up."); - } - }); + server.start(); ApplicationLifecycleManager.registerListener(trans -> { if (trans.next == LifecycleStatus.TERMINATING) { @@ -84,7 +67,5 @@ public static void main(String[] args) throws Exception { "Content-MD5,CSRF-Token,x-ijt,cache-control,x-featurehub,baggage"); new Application().init(); - } - - + } } diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java new file mode 100644 index 0000000..cdd5902 --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/FeatureHubSource.java @@ -0,0 +1,83 @@ +package todo.backend; + +import cd.connect.app.config.ConfigKey; +import cd.connect.app.config.DeclaredConfigResolver; +import cd.connect.lifecycle.ApplicationLifecycleManager; +import cd.connect.lifecycle.LifecycleStatus; +import com.segment.analytics.messages.Message; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class FeatureHubSource implements FeatureHub { + String featureHubUrl = FeatureHubConfig.getRequiredConfig("feature-service.host"); + String sdkKey = FeatureHubConfig.getRequiredConfig("feature-service.api-key"); + String segmentWriteKey = FeatureHubConfig.getConfig("segment.write-key"); + String client = FeatureHubConfig.getConfig("feature-service.client", "sse"); // sse, rest, rest-poll + @ConfigKey() + Boolean openTelemetryEnabled = Boolean.parseBoolean(FeatureHubConfig.getConfig("feature-service.opentelemetry.enabled", "false")); + @ConfigKey() + Integer pollInterval = Integer.parseInt(FeatureHubConfig.getConfig("feature-service.poll-interval-seconds", "1")); // in seconds + + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + + private final FeatureHubConfig config; + + public FeatureHubSource() { + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); + + if (segmentWriteKey != null) { + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey, + List.of(new SegmentMessageTransformer(Message.Type.values(), + FeatureHubClientContextThreadLocal::get, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; + } + + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } + + // Do this if you wish to force the connection to stay open. + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-poll")) { + config.restActive(pollInterval); + } else { + throw new RuntimeException("Unknown featurehub client"); + } + + config.init(); + + ApplicationLifecycleManager.registerListener(trans -> { + if (trans.next == LifecycleStatus.TERMINATING) { + close(); + } + }); + } + + @Override + public FeatureHubConfig getConfig() { + return config; + } + + @Override + public SegmentAnalyticsSource segmentAnalytics() { + return segmentAnalyticsSource; + } + + public void close() { + config.close(); + } +} diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java new file mode 100644 index 0000000..ee6f035 --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java @@ -0,0 +1,64 @@ +package todo.backend.resources; + +import cd.connect.app.config.DeclaredConfigResolver; +import io.featurehub.client.ClientContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; +import todo.backend.UsageRequestMeasurement; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { + + private final FeatureHub config; + private static final Logger log = LoggerFactory.getLogger(FeatureAnalyticsFilter.class); + + @Inject + public FeatureAnalyticsFilter(FeatureHub config) { + this.config = config; + DeclaredConfigResolver.resolve(this); + } + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + final long currentTime = System.currentTimeMillis(); + requestContext.setProperty("startTime", currentTime); + final List user = requestContext.getUriInfo().getPathParameters().get("user"); + if (user != null && !user.isEmpty()) { + try { + requestContext.setProperty("context", config.getConfig().newContext().build().get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { + Long start = (Long) requestContext.getProperty("startTime"); + ClientContext context = (ClientContext) requestContext.getProperty("context"); + + FeatureHubClientContextThreadLocal.clear(); + + if (start != null && context != null) { + long duration = System.currentTimeMillis() - start; + + final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); + + if (!matchedURIs.isEmpty()) { + context.recordUsageEvent(new UsageRequestMeasurement(duration, matchedURIs.get(0))); + } + } else { + log.error("There was not start time {} and context {}", start, context); + } + } +} diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java new file mode 100644 index 0000000..b6118be --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/HealthResource.java @@ -0,0 +1,31 @@ +package todo.backend.resources; + + +import io.featurehub.client.Readiness; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import todo.backend.FeatureHub; + +@Singleton +@Path("/health") +public class HealthResource { + private final FeatureHub featureHub; + + @Inject + public HealthResource(FeatureHub featureHub) { + this.featureHub = featureHub; + } + + @GET + @Path(("/liveness")) + public Response liveness() { + if (featureHub.getConfig().getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.serverError().build(); + } +} diff --git a/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java new file mode 100644 index 0000000..2c0bddd --- /dev/null +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/LocalExceptionMapper.java @@ -0,0 +1,32 @@ +package todo.backend.resources; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Provider +public class LocalExceptionMapper implements ExceptionMapper { + private static final Logger log = LoggerFactory.getLogger(LocalExceptionMapper.class); + + @Override + public Response toResponse(Exception exception) { + if (exception instanceof WebApplicationException) { + Response response = ((WebApplicationException) exception).getResponse(); + + if (response.getStatus() >= 500) { // special callout to all our 5xx in the house. + log.error("Error HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } else if (!(exception instanceof NotFoundException)) { + log.warn("Failed HTTP {} for {}", response.getStatus(), response.getLocation(), exception); + } + + return response; + } + + log.error("Failed jersey request", exception); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java similarity index 65% rename from examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java rename to examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java index 59dfc72..147f2fb 100644 --- a/examples/todo-java/src/main/java/todo/backend/resources/TodoResource.java +++ b/examples/todo-java-jersey3/src/main/java/todo/backend/resources/TodoResource.java @@ -1,15 +1,19 @@ package todo.backend.resources; +import com.segment.analytics.messages.IdentifyMessage; import io.featurehub.client.ClientContext; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import todo.api.TodoService; import todo.backend.FeatureHub; +import todo.backend.FeatureHubClientContextThreadLocal; import todo.model.Todo; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.ws.rs.NotFoundException; import java.util.List; import java.util.Map; import java.util.UUID; @@ -19,12 +23,12 @@ @Singleton public class TodoResource implements TodoService { private static final Logger log = LoggerFactory.getLogger(TodoResource.class); - Map> todos = new ConcurrentHashMap<>(); private final FeatureHub featureHub; + Map> todos = new ConcurrentHashMap<>(); @Inject - public TodoResource(FeatureHub featureHub) { - this.featureHub = featureHub; + public TodoResource(FeatureHub config) { + this.featureHub = config; log.info("created"); } @@ -38,7 +42,8 @@ private Map getTodoMap(String user) { private List getTodoList(Map todos, String user) { ClientContext fhClient = fhClient(user); - return todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; } private String processTitle(ClientContext fhClient, String title) { @@ -74,22 +79,39 @@ private String processTitle(ClientContext fhClient, String title) { return title; } - private ClientContext fhClient(String user) { + @NotNull private ClientContext fhClient(String user) { try { - return featureHub.fhClient().userKey(user).build().get(); + final ClientContext context = featureHub.getConfig().newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + + FeatureHubClientContextThreadLocal.set(context); + + if (featureHub.segmentAnalytics() != null) { + // this should have the current user's details augmented into it + featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); + } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; } catch (Exception e) { - log.error("Unable to get context!"); + log.error("Unable to get context!", e); + throw new WebApplicationException(e); } - - return null; } @Override - public List addTodo(String user, Todo body) { - if (body.getId() == null || body.getId().length() == 0) { + public List addTodo(@NotNull String user, Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { body.id(UUID.randomUUID().toString()); } + if (body.getResolved() == null) { + body.resolved(false); + } + Map userTodo = getTodoMap(user); userTodo.put(body.getId(), body); @@ -97,24 +119,24 @@ public List addTodo(String user, Todo body) { } @Override - public List listTodos(String user) { + public List listTodos(@NotNull String user) { return getTodoList(getTodoMap(user), user); } @Override - public void removeAllTodos(String user) { + public void removeAllTodos(@NotNull String user) { getTodoMap(user).clear(); } @Override - public List removeTodo(String user, String id) { + public List removeTodo(@NotNull String user, @NotNull String id) { Map userTodo = getTodoMap(user); userTodo.remove(id); return getTodoList(userTodo, user); } @Override - public List resolveTodo(String id, String user) { + public List resolveTodo(@NotNull String id, @NotNull String user) { Map userTodo = getTodoMap(user); Todo todo = userTodo.get(id); diff --git a/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java b/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java new file mode 100644 index 0000000..4462eb5 --- /dev/null +++ b/examples/todo-java-jersey3/src/test/java/todo/backend/AppRunner.java @@ -0,0 +1,13 @@ +package todo.backend; + +import bathe.BatheBooter; +import org.junit.Test; + +import java.io.IOException; + +public class AppRunner { + @Test + public void run() throws IOException { + new BatheBooter().run(new String[]{"-R" + Application.class.getName(), "-Pclasspath:/application.properties", "-P${user.home}/.featurehub/example-java.properties"}); + } +} diff --git a/examples/todo-java-shared/.editorconfig b/examples/todo-java-shared/.editorconfig new file mode 100644 index 0000000..5e21e8a --- /dev/null +++ b/examples/todo-java-shared/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yaml] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.adoc] +trim_trailing_whitespace = false diff --git a/examples/todo-java/.gitignore b/examples/todo-java-shared/.gitignore similarity index 97% rename from examples/todo-java/.gitignore rename to examples/todo-java-shared/.gitignore index a844e84..93369b2 100644 --- a/examples/todo-java/.gitignore +++ b/examples/todo-java-shared/.gitignore @@ -60,3 +60,4 @@ fabric.properties /swagger-backend/app/app.js.map /swagger-backend/app/generated-interface.js /swagger-backend/app/generated-interface.js.map +/opentelemetry-javaagent.jar diff --git a/examples/todo-java/README.adoc b/examples/todo-java-shared/README.adoc similarity index 100% rename from examples/todo-java/README.adoc rename to examples/todo-java-shared/README.adoc diff --git a/examples/todo-java-shared/pom.xml b/examples/todo-java-shared/pom.xml new file mode 100644 index 0000000..8f54911 --- /dev/null +++ b/examples/todo-java-shared/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + io.featurehub.java + todo-java-shared + 1.1-SNAPSHOT + todo-java-shared + + This is an example of the server side of Jersey 3 using an SSE or GET client (depending on environment variables). + + It expects environment variables or system property config as follows: + + - feature-service.host = the host where features are stored, e.g. http://localhost:8085 + - feature-service.api-key = the API key issued by the server. + + There are examples in https://github.com/featurehub-io/featurehub/tree/main/adks/e2e-sdk on how to populate your + server automatically via tests to create the features required for this scenario. + + + + ${project.artifactId} + ${project.version} + connect_todo + 3.0.1 + 2.0.0 + 3.1.2 + + + + + io.featurehub.sdk + java-client-core + [4.1-SNAPSHOT, 5) + + + + io.featurehub.sdk.java + segment-usageadapter + [1.1-SNAPSHOT, 2) + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + + + + io.featurehub.sdk.java + opentelemetry-usageadapter + [1.1-SNAPSHOT, 2) + + + + + cd.connect.common + connect-app-declare-config + 1.3 + + + net.stickycode.composite + sticky-composite-logging-api + + + + + + + com.bluetrainsoftware.bathe + bathe-booter + [3.1, 4) + + + + + com.bluetrainsoftware.bathe.initializers + system-property-loader + 3.1 + + + * + * + + + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1-SNAPSHOT, 2) + + + + + com.bluetrainsoftware.bathe.initializers + jul-bridge + 2.1 + + + * + * + + + + + + + org.yaml + snakeyaml + 2.0 + + + + + cd.connect.common + connect-app-lifecycle + 1.1 + + + + io.featurehub.sdk.composites + sdk-composite-test + 1.2 + test + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + + + + + + diff --git a/examples/todo-java/src/main/java/todo/Features.java b/examples/todo-java-shared/src/main/java/todo/Features.java similarity index 100% rename from examples/todo-java/src/main/java/todo/Features.java rename to examples/todo-java-shared/src/main/java/todo/Features.java diff --git a/examples/todo-java-shared/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHub.java new file mode 100644 index 0000000..41202b6 --- /dev/null +++ b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHub.java @@ -0,0 +1,12 @@ +package todo.backend; + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; + +import java.util.concurrent.Future; + +public interface FeatureHub { + FeatureHubConfig getConfig(); + SegmentAnalyticsSource segmentAnalytics(); +} diff --git a/examples/todo-java-shared/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java new file mode 100644 index 0000000..e8ebecd --- /dev/null +++ b/examples/todo-java-shared/src/main/java/todo/backend/FeatureHubClientContextThreadLocal.java @@ -0,0 +1,19 @@ +package todo.backend; + +import io.featurehub.client.ClientContext; + +public class FeatureHubClientContextThreadLocal { + private static final ThreadLocal ctx = new ThreadLocal<>(); + + public static void set(ClientContext context) { + ctx.set(context); + } + + public static ClientContext get() { + return ctx.get(); + } + + public static void clear() { + ctx.remove(); + } +} diff --git a/examples/todo-java-shared/src/main/java/todo/backend/UsageRequestMeasurement.java b/examples/todo-java-shared/src/main/java/todo/backend/UsageRequestMeasurement.java new file mode 100644 index 0000000..5435f6c --- /dev/null +++ b/examples/todo-java-shared/src/main/java/todo/backend/UsageRequestMeasurement.java @@ -0,0 +1,33 @@ +package todo.backend; + +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsageFeaturesCollection; +import org.jetbrains.annotations.NotNull; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class UsageRequestMeasurement extends UsageFeaturesCollection implements UsageEventName { + private final long duration; + @NotNull + private final String url; + public UsageRequestMeasurement(long duration, @NotNull String url) { + super(null, null); + + this.duration = duration; + this.url = url; + } + + @Override + public @NotNull Map toMap() { + final LinkedHashMap data = new LinkedHashMap<>(super.toMap()); + data.put("duration", duration); + data.put("url", url); + return data; + } + + @Override + public @NotNull String getEventName() { + return "tracking"; + } +} diff --git a/examples/todo-java/src/main/resources/log4j2.xml b/examples/todo-java-shared/src/main/resources/log4j2.xml similarity index 100% rename from examples/todo-java/src/main/resources/log4j2.xml rename to examples/todo-java-shared/src/main/resources/log4j2.xml diff --git a/examples/todo-java-shared/src/test/java/todo/backend/.keep b/examples/todo-java-shared/src/test/java/todo/backend/.keep new file mode 100644 index 0000000..b788975 --- /dev/null +++ b/examples/todo-java-shared/src/test/java/todo/backend/.keep @@ -0,0 +1 @@ +-- keep diff --git a/examples/todo-java/todo-api.yaml b/examples/todo-java-shared/todo-api.yaml similarity index 97% rename from examples/todo-java/todo-api.yaml rename to examples/todo-java-shared/todo-api.yaml index 628d9c5..336f397 100644 --- a/examples/todo-java/todo-api.yaml +++ b/examples/todo-java-shared/todo-api.yaml @@ -116,10 +116,13 @@ components: properties: id: type: string + nullable: true title: type: string resolved: type: boolean + nullable: true when: + nullable: true type: string format: date-time diff --git a/examples/todo-java/todo.txt b/examples/todo-java-shared/todo.txt similarity index 100% rename from examples/todo-java/todo.txt rename to examples/todo-java-shared/todo.txt diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java b/examples/todo-java/src/main/java/todo/backend/FeatureHub.java deleted file mode 100644 index 452cefd..0000000 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHub.java +++ /dev/null @@ -1,11 +0,0 @@ -package todo.backend; - -import io.featurehub.client.ClientContext; -import io.featurehub.client.EdgeService; -import io.featurehub.client.FeatureRepositoryContext; - -public interface FeatureHub { - ClientContext fhClient(); - FeatureRepositoryContext getRepository(); - void poll(); -} diff --git a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java b/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java deleted file mode 100644 index d944290..0000000 --- a/examples/todo-java/src/main/java/todo/backend/FeatureHubSource.java +++ /dev/null @@ -1,90 +0,0 @@ -package todo.backend; - -import cd.connect.app.config.ConfigKey; -import cd.connect.app.config.DeclaredConfigResolver; -import io.featurehub.android.FeatureHubClient; -import io.featurehub.client.ClientContext; -import io.featurehub.client.ClientFeatureRepository; -import io.featurehub.client.EdgeFeatureHubConfig; -import io.featurehub.client.FeatureRepositoryContext; -import io.featurehub.client.GoogleAnalyticsCollector; -import io.featurehub.client.edge.EdgeRetryer; -import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; -import io.featurehub.client.jersey.GoogleAnalyticsJerseyApiClient; -import io.featurehub.client.jersey.JerseySSEClient; -import io.featurehub.edge.sse.SSEClient; -import org.jetbrains.annotations.Nullable; - -import java.util.Collections; - -public class FeatureHubSource implements FeatureHub { - @ConfigKey("feature-service.host") - String featureHubUrl; - @ConfigKey("feature-service.api-key") - String sdkKey; - @ConfigKey("feature-service.google-analytics-key") - String analyticsKey = ""; - @ConfigKey("feature-service.cid") - String analyticsCid = ""; - @ConfigKey("feature-service.sdk") - String clientSdk = "jersey3"; - - private final FeatureRepositoryContext repository; - private final EdgeFeatureHubConfig config; - @Nullable - private final FeatureHubClient androidClient; - - public FeatureHubSource() { - DeclaredConfigResolver.resolve(this); - - config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey); - - repository = new ClientFeatureRepository(5); - repository.registerValueInterceptor(true, new SystemPropertyValueInterceptor()); - - if (analyticsCid.length() > 0 && analyticsKey.length() > 0) { - repository.addAnalyticCollector(new GoogleAnalyticsCollector(analyticsKey, analyticsCid, - new GoogleAnalyticsJerseyApiClient())); - } - - config.setRepository(repository); - - // Do this if you wish to force the connection to stay open. - if (clientSdk.equals("jersey3")) { - final JerseySSEClient jerseyClient = new JerseySSEClient(repository, - config, EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - config.setEdgeService(() -> jerseyClient); - androidClient = null; - } else if (clientSdk.equals("android")) { - final FeatureHubClient client = new FeatureHubClient(featureHubUrl, Collections.singleton(sdkKey), repository, - config, 1); - config.setEdgeService(() -> client); - androidClient = client; - } else if (clientSdk.equals("sse")) { - final SSEClient client = new SSEClient(repository, config, - EdgeRetryer.EdgeRetryerBuilder.anEdgeRetrier().build()); - config.setEdgeService(() -> client); - androidClient = null; - } else { - throw new RuntimeException("Unknown featurehub client"); - } - - config.init(); - } - - public ClientContext fhClient() { - return config.newContext(); - } - - @Override - public FeatureRepositoryContext getRepository() { - return repository; - } - - @Override - public void poll() { - if (androidClient != null) { - androidClient.poll(); - } - } -} diff --git a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java b/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java deleted file mode 100644 index 6ad9803..0000000 --- a/examples/todo-java/src/main/java/todo/backend/resources/FeatureAnalyticsFilter.java +++ /dev/null @@ -1,60 +0,0 @@ -package todo.backend.resources; - -import cd.connect.app.config.ConfigKey; -import cd.connect.app.config.DeclaredConfigResolver; -import io.featurehub.client.GoogleAnalyticsApiClient; -import todo.backend.FeatureHub; - -import jakarta.inject.Inject; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; -import jakarta.ws.rs.container.ContainerResponseContext; -import jakarta.ws.rs.container.ContainerResponseFilter; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -public class FeatureAnalyticsFilter implements ContainerRequestFilter, ContainerResponseFilter { - private final FeatureHub fh; - private static final AtomicLong timeout = new AtomicLong(0L); - @ConfigKey("feature-service.poll-interval") - Integer pollInterval = 200; // in milliseconds - - @Inject - public FeatureAnalyticsFilter(FeatureHub fh) { - this.fh = fh; - - DeclaredConfigResolver.resolve(this); - } - - @Override - public void filter(ContainerRequestContext requestContext) throws IOException { - final long currentTime = System.currentTimeMillis(); - requestContext.setProperty("startTime", currentTime); - - if (currentTime - timeout.get() > pollInterval) { - fh.poll(); - timeout.set(currentTime); - } - } - - @Override - public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { - Long start = (Long)requestContext.getProperty("startTime"); - long duration = 0; - if (start != null) { - duration = System.currentTimeMillis() - start; - } - - Map other = new HashMap<>(); - other.put(GoogleAnalyticsApiClient.GA_VALUE, Long.toString(duration)); - final List matchedURIs = requestContext.getUriInfo().getMatchedURIs(); - if (matchedURIs.size() > 0) { - fh.getRepository().logAnalyticsEvent(matchedURIs.get(0), other); - } - - } -} diff --git a/pom.xml b/pom.xml index 35d1352..b5c974b 100644 --- a/pom.xml +++ b/pom.xml @@ -34,14 +34,11 @@ - client-java-core - client-java-android - client-java-android21 - client-java-sse - client-java-jersey - client-java-jersey3 - client-java-api - support + core examples + client-implementations + support + usage-adapters + diff --git a/setup.sh b/setup.sh deleted file mode 100755 index 660e2f7..0000000 --- a/setup.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -cd support -mvn -f pom-tiles.xml install -mvn install -cd .. - diff --git a/client-java-loadtest/pom.xml b/support/client-java-loadtest/pom.xml similarity index 100% rename from client-java-loadtest/pom.xml rename to support/client-java-loadtest/pom.xml diff --git a/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java b/support/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java similarity index 100% rename from client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java rename to support/client-java-loadtest/src/main/java/io/featurehub/loadtest/LoadTest.java diff --git a/client-java-loadtest/src/main/resources/log4j2.xml b/support/client-java-loadtest/src/main/resources/log4j2.xml similarity index 100% rename from client-java-loadtest/src/main/resources/log4j2.xml rename to support/client-java-loadtest/src/main/resources/log4j2.xml diff --git a/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java b/support/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java similarity index 100% rename from client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java rename to support/client-java-loadtest/src/test/java/io/featurehub/LoadTestRunner.java diff --git a/support/common-jackson/pom.xml b/support/common-jackson/pom.xml new file mode 100644 index 0000000..474a5c7 --- /dev/null +++ b/support/common-jackson/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jackson + 1.1-SNAPSHOT + common-jackson + + + Holds the APIs required to be implemented by a version of Jackson (2 or 3) + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + + + + org.jetbrains + annotations + 23.0.0 + + + + + + io.featurehub.sdk + java-client-api + [3.2,4) + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java new file mode 100644 index 0000000..f6cfec1 --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapper.java @@ -0,0 +1,25 @@ +package io.featurehub.javascript; + +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * We need to disconnect ourselves from which actual instance type of the Jackson ObjectMapper that + * is being used because it changes from Jackson v2 to Jackson v3 - where it is located and how it is + * configured. So we just provide a subset of services here and discover it using a Java Service API. + */ +public interface JavascriptObjectMapper { + @NotNull T readValue(@NotNull String data, @NotNull Class type) throws IOException; + @NotNull Map readMapValue(@NotNull String data) throws IOException; + + @NotNull List readFeatureStates(@NotNull String data) throws IOException; + @NotNull List readFeatureCollection(@NotNull String data) throws IOException; + + @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException; +} diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java new file mode 100644 index 0000000..7e4de1c --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptObjectMapperProviderService.java @@ -0,0 +1,5 @@ +package io.featurehub.javascript; + +public interface JavascriptObjectMapperProviderService { + JavascriptObjectMapper get(); +} diff --git a/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java new file mode 100644 index 0000000..2b59588 --- /dev/null +++ b/support/common-jackson/src/main/java/io/featurehub/javascript/JavascriptServiceLoader.java @@ -0,0 +1,18 @@ +package io.featurehub.javascript; + +import java.util.ServiceLoader; + +/** + * This should be called to get the default system JavascriptObjectMapper + * library. This will be able to be overridden + */ +public class JavascriptServiceLoader { + public static JavascriptObjectMapper load() { + ServiceLoader serviceLoader = ServiceLoader.load(JavascriptObjectMapperProviderService.class); + + JavascriptObjectMapperProviderService svc = serviceLoader.findFirst() + .orElseThrow(() -> new RuntimeException("featurehub-sdk does not have available JavascriptObjectMapper implementation.")); + + return svc.get(); + } +} diff --git a/support/common-jacksonv2/pom.xml b/support/common-jacksonv2/pom.xml new file mode 100644 index 0000000..d6022ea --- /dev/null +++ b/support/common-jacksonv2/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jacksonv2 + 1.1-SNAPSHOT + common-jacksonv2 + + + implementation for jackson v2 + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 2.20.0 + 2.20.1 + + + + + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] + + + com.fasterxml.jackson.core + jackson-core + [${jackson.version}] + + + + com.fasterxml.jackson.core + jackson-databind + [${jackson.databind.version}] + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + [${jackson.version}] + + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-base + [${jackson.version}] + + + + io.featurehub.sdk.composites + sdk-composite-logging-api + [1.1, 2) + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java new file mode 100644 index 0000000..8a8bdb5 --- /dev/null +++ b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapper.java @@ -0,0 +1,57 @@ +package io.featurehub.javascript; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public class Jackson2ObjectMapper implements JavascriptObjectMapper { + private static ObjectMapper mapper; + + static { + mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + } + + @Override + public T readValue(String data, Class type) throws IOException { + return data == null ? null : mapper.readValue(data, type); + } + + private static final TypeReference> FEATURE_COLLECTION_TYPEREF = new TypeReference>(){}; + private static final TypeReference> mapConfig = new TypeReference>() {}; + private static final TypeReference> FEATURE_LIST_TYPEDEF = + new TypeReference<>() {}; + + @Override + public Map readMapValue(String data) throws IOException { + return mapper.readValue(data, mapConfig); + } + + @Override + public @NotNull List readFeatureStates(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_LIST_TYPEDEF); + } + + @Override + public @NotNull List readFeatureCollection(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_COLLECTION_TYPEREF); + } + + @Override + public @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException { + return mapper.writeValueAsString(data); + } +} diff --git a/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java new file mode 100644 index 0000000..8ba019e --- /dev/null +++ b/support/common-jacksonv2/src/main/java/io/featurehub/javascript/Jackson2ObjectMapperProvider.java @@ -0,0 +1,8 @@ +package io.featurehub.javascript; + +public class Jackson2ObjectMapperProvider implements JavascriptObjectMapperProviderService { + @Override + public JavascriptObjectMapper get() { + return new Jackson2ObjectMapper(); + } +} diff --git a/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService b/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService new file mode 100644 index 0000000..2ebb5ce --- /dev/null +++ b/support/common-jacksonv2/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService @@ -0,0 +1 @@ +io.featurehub.javascript.Jackson2ObjectMapperProvider diff --git a/support/composite-jackson/pom.xml b/support/composite-jackson/pom.xml index 6a65e04..a966bf3 100644 --- a/support/composite-jackson/pom.xml +++ b/support/composite-jackson/pom.xml @@ -48,34 +48,7 @@ - - com.fasterxml.jackson.core - jackson-core - [${jackson.version}] - - - com.fasterxml.jackson.core - jackson-databind - [2.13.4.1] - - - - com.fasterxml.jackson.core - jackson-annotations - [${jackson.version}] - - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - [${jackson.version}] - - - com.fasterxml.jackson.jaxrs - jackson-jaxrs-base - [${jackson.version}] - @@ -83,7 +56,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-jersey2/pom.xml b/support/composite-jersey2/pom.xml index b73b91d..8a5b97a 100644 --- a/support/composite-jersey2/pom.xml +++ b/support/composite-jersey2/pom.xml @@ -53,7 +53,7 @@ cd.connect.openapi.gensupport openapi-generator-support - 1.4 + 1.5 @@ -63,17 +63,16 @@ ${jersey.version} - - org.glassfish.jersey.core - jersey-server + org.glassfish.jersey.inject + jersey-hk2 ${jersey.version} - + - org.glassfish.jersey.ext - jersey-proxy-client + org.glassfish.jersey.core + jersey-server ${jersey.version} @@ -101,19 +100,13 @@ javax.annotation-api 1.3.2 - - - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-jersey3/pom.xml b/support/composite-jersey3/pom.xml index da2b178..dd22428 100644 --- a/support/composite-jersey3/pom.xml +++ b/support/composite-jersey3/pom.xml @@ -45,7 +45,7 @@ - 3.0.5 + 3.1.2 @@ -76,13 +76,6 @@ ${jersey.version} - - - org.glassfish.jersey.ext - jersey-proxy-client - ${jersey.version} - - org.glassfish.jersey.media @@ -101,19 +94,13 @@ jersey-media-multipart ${jersey.version} - - - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) - io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-logging-api/pom.xml b/support/composite-logging-api/pom.xml index e568925..9471085 100644 --- a/support/composite-logging-api/pom.xml +++ b/support/composite-logging-api/pom.xml @@ -70,7 +70,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-logging/pom.xml b/support/composite-logging/pom.xml index 5051b8e..d929953 100644 --- a/support/composite-logging/pom.xml +++ b/support/composite-logging/pom.xml @@ -43,7 +43,7 @@ - 2.17.1 + 2.25.3 3.4.4 @@ -91,7 +91,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/composite-okhttp/pom.xml b/support/composite-okhttp/pom.xml new file mode 100644 index 0000000..33fac11 --- /dev/null +++ b/support/composite-okhttp/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + io.featurehub.sdk.composites + composite-okhttp + 1.1-SNAPSHOT + composite-okhttp + + + Provides a complete list of required OKHttp dependencies that can be used while testing or as provided + in SDK. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 4.12.0 + 3.6.0 + + + + + + + + com.squareup.okhttp3 + okhttp + ${ok.http.version} + + + + com.squareup.okio + okio + ${ok.io.version} + + + + com.squareup.okhttp3 + okhttp-sse + ${ok.http.version} + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java8:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + diff --git a/support/composite-test/pom.xml b/support/composite-test/pom.xml index 7552c99..3d9b085 100644 --- a/support/composite-test/pom.xml +++ b/support/composite-test/pom.xml @@ -43,7 +43,7 @@ - 3.0.9 + 4.0.26 @@ -56,16 +56,21 @@ org.spockframework spock-core - 2.1-groovy-3.0 + 2.3-groovy-4.0 - org.codehaus.groovy + org.apache.groovy * - org.codehaus.groovy + net.bytebuddy + byte-buddy + 1.14.18 + + + org.apache.groovy groovy-test ${groovy.version} @@ -76,7 +81,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/client-java-sse/pom.xml b/support/featurehub-okhttp3-jackson2/pom.xml similarity index 72% rename from client-java-sse/pom.xml rename to support/featurehub-okhttp3-jackson2/pom.xml index 359240d..4b1fa6e 100644 --- a/client-java-sse/pom.xml +++ b/support/featurehub-okhttp3-jackson2/pom.xml @@ -1,14 +1,15 @@ - + 4.0.0 io.featurehub.sdk - java-client-sse - 1.5-SNAPSHOT - java-client-sse + featurehub-okhttp3-jackson2 + 3.1-SNAPSHOT + featurehub-okhttp3-jackson2 - The OKHttp3 SSE client for Java. + The OKHttp client for Java. Supports all three (streaming, polling, interval). It includes all + necessary dependencies to run the stack which is an unusual choice. https://featurehub.io @@ -46,34 +47,28 @@ io.featurehub.sdk - java-client-core + java-client-okhttp [3, 4) - com.squareup.okhttp3 - okhttp - 4.9.3 - - - - com.squareup.okhttp3 - okhttp-sse - 4.9.3 + io.featurehub.sdk.composites + composite-okhttp + [1,2) - io.featurehub.sdk.composites - sdk-composite-jackson - [1.2, 2) + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] io.featurehub.sdk.composites - sdk-composite-test + sdk-composite-logging [1.1, 2) - test + @@ -81,12 +76,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/support/pom-tiles.xml b/support/pom-tiles.xml index 0496bf7..d7c90f1 100644 --- a/support/pom-tiles.xml +++ b/support/pom-tiles.xml @@ -36,6 +36,7 @@ tile-java8 tile-java11 + tile-java21 tile-sdk tile-release diff --git a/support/pom.xml b/support/pom.xml index 53f2c7d..93bc189 100644 --- a/support/pom.xml +++ b/support/pom.xml @@ -34,11 +34,15 @@ - composite-jackson + common-jackson + common-jacksonv2 + composite-jersey2 composite-jersey3 + composite-okhttp composite-logging composite-logging-api composite-test + featurehub-okhttp3-jackson2 diff --git a/support/tile-java11/pom.xml b/support/tile-java11/pom.xml index 5055752..abdf85e 100644 --- a/support/tile-java11/pom.xml +++ b/support/tile-java11/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-java11/tile.xml b/support/tile-java11/tile.xml index 96a8c75..7cb8bd3 100644 --- a/support/tile-java11/tile.xml +++ b/support/tile-java11/tile.xml @@ -28,7 +28,7 @@ maven-compiler-plugin - 3.8.1 + 3.11.0 11 11 @@ -96,17 +96,13 @@ org.codehaus.gmavenplus gmavenplus-plugin - 1.9.0 + 4.1.1 - addSources addTestSources - generateStubs - compile generateTestStubs compileTests - removeStubs removeTestStubs diff --git a/support/tile-java21/.gitignore b/support/tile-java21/.gitignore new file mode 100644 index 0000000..26a9bfe --- /dev/null +++ b/support/tile-java21/.gitignore @@ -0,0 +1,2 @@ +*.iml +target diff --git a/support/tile-java21/pom.xml b/support/tile-java21/pom.xml new file mode 100644 index 0000000..501f34c --- /dev/null +++ b/support/tile-java21/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + io.featurehub.sdk.tiles + tile-java21 + 1.2 + tile + tile-java21 + + + tile java contains plugins required for java application creation. It is focused on Java 21 and is used + primarily for the examples. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + + diff --git a/support/tile-java21/tile.xml b/support/tile-java21/tile.xml new file mode 100644 index 0000000..62e2f8e --- /dev/null +++ b/support/tile-java21/tile.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + true + + + + + + + org.apache.maven.plugins + maven-source-plugin + + 3.0.0 + + + attach-sources + + jar-no-fork + + + + + + maven-compiler-plugin + 3.11.0 + + 21 + false + + + + + default-compile + none + + + + + default-testCompile + none + + + + java-compile + compile + + compile + + + + + java-test-compile + test-compile + + testCompile + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M3 + + + **/*Test.java + **/*Spec.java + + + + + + org.codehaus.mojo + license-maven-plugin + 1.20 + + + licences + + add-third-party + + + + + + + diff --git a/support/tile-java8/pom.xml b/support/tile-java8/pom.xml index aa2724d..84b676a 100644 --- a/support/tile-java8/pom.xml +++ b/support/tile-java8/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-release/pom.xml b/support/tile-release/pom.xml index 0385af8..a96575c 100644 --- a/support/tile-release/pom.xml +++ b/support/tile-release/pom.xml @@ -176,7 +176,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/support/tile-sdk/pom.xml b/support/tile-sdk/pom.xml index 07dba0d..0ea5d07 100644 --- a/support/tile-sdk/pom.xml +++ b/support/tile-sdk/pom.xml @@ -49,7 +49,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/unmaintained/README.adoc b/unmaintained/README.adoc new file mode 100644 index 0000000..adb5233 --- /dev/null +++ b/unmaintained/README.adoc @@ -0,0 +1,3 @@ += Unmaintained + +This is old code we aren't maintaining any longer or releasing new versions of. diff --git a/client-java-android21/CHANGELOG.adoc b/unmaintained/client-java-android21/CHANGELOG.adoc similarity index 100% rename from client-java-android21/CHANGELOG.adoc rename to unmaintained/client-java-android21/CHANGELOG.adoc diff --git a/unmaintained/client-java-android21/README.adoc b/unmaintained/client-java-android21/README.adoc new file mode 100644 index 0000000..1388e4c --- /dev/null +++ b/unmaintained/client-java-android21/README.adoc @@ -0,0 +1,37 @@ += FeatureHub SDK for Android 21/Polling + +== Overview +This SDK is intended for client libraries, particularly for Android as keeping the radio on would drain the battery +quickly. It does this by making GET requests for the data but sets a period for which it will hold off making new requests (a timeout, which you can set to 0 if you have your own timer). This will allow +you to keep requesting updates but they will not actually issue calls unless the context changes +or the timeout has occurred. This is an obsolete library and while it works for versions of FeatureHub currently published, it is no longer being kept up to date with the latest APIs as they use too many features from Java 8. + +This library uses: + +- OKHttp 4 (for http) +- Jackson 2.11 (for json) +- SLF4j (for logging) + +If you need your Android client to use another technology, please let us know or feel free to contribute another version. + +Visit our official web page for more information about the platform https://www.featurehub.io/[here] + +== Using on Android + +As it requires internet access, you will need to add to your `AndroidManifest.xml` the usual: + +`` + +If you are using it locally and not behind https, you will also need to specify an attribute on your `` tag, +which allows clear text traffic. + +`android:usesCleartextTraffic="true"` + +You will need to store your repository in a central location, using a static or via a DI tool like Dagger.Using a static +might look something like this: + +Core uses Java's ServiceLoader capability to automatically discover the JerseyClient implementation. Please +simply follow the instructions in the https://github.com/featurehub-io/featurehub-java-sdk/tree/main/client-java-core[Java Core library]. + +As per that documentation you can manually configure the Edge provider to be the `AndroidFeatureHubClientFactory` if +you wish. diff --git a/client-java-android21/pom.xml b/unmaintained/client-java-android21/pom.xml similarity index 99% rename from client-java-android21/pom.xml rename to unmaintained/client-java-android21/pom.xml index 58b0909..d50b544 100644 --- a/client-java-android21/pom.xml +++ b/unmaintained/client-java-android21/pom.xml @@ -98,7 +98,7 @@ io.repaint.maven tiles-maven-plugin - 2.23 + 2.32 true false diff --git a/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/android/AndroidFeatureHubClientFactory.java diff --git a/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java similarity index 99% rename from client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java index 8e55ca8..9a518ac 100644 --- a/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java +++ b/unmaintained/client-java-android21/src/main/java/io/featurehub/android/FeatureHubClient.java @@ -201,7 +201,6 @@ protected void processResponse(Response response) throws IOException { }); repository.notify(states); - completeReadiness(); if (response.code() == 236) { this.stopped = true; // prevent any further requests @@ -215,9 +214,10 @@ protected void processResponse(Response response) throws IOException { makeRequests = false; log.error("Server indicated an error with our requests making future ones pointless."); repository.notify(SSEResultState.FAILURE, null); - completeReadiness(); } } + + completeReadiness(); } boolean canMakeRequests() { diff --git a/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/AbstractFeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/AnalyticsCollector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/Applied.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Applied.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Applied.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Applied.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ApplyFeature.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/BaseClientContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientEvalFeatureContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ClientFeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeFeatureHubConfig.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/EdgeService.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/EdgeService.java diff --git a/client-java-core/src/main/java/io/featurehub/client/Feature.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Feature.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/Feature.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Feature.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubClientFactory.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureHubConfig.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureListener.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureRepositoryContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureState.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureState.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateBase.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStateUtils.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureStore.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptor.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptor.java diff --git a/client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/FeatureValueInterceptorHolder.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsApiClient.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/GoogleAnalyticsCollector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ObjectSupplier.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/Readyness.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/Readyness.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/Readyness.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/Readyness.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ReadynessListener.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/ServerEvalFeatureContext.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeConnectionState.java diff --git a/client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/client/edge/EdgeReconnector.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeReconnector.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryService.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/edge/EdgeRetryer.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/interceptor/SystemPropertyValueInterceptor.java diff --git a/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/client/utils/SdkVersion.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/BooleanArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/CIDRMatch.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/DateTimeArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/IpAddressArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRegistry.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/MatcherRepository.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/NumberArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionArrayMatcher.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/SemanticVersionComparable.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StrategyMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/matchers/StringArrayMatcher.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/Murmur3_32HashFunction.java diff --git a/client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java similarity index 100% rename from client-java-core/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageCalculator.java diff --git a/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java b/unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java similarity index 100% rename from client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java rename to unmaintained/client-java-android21/src/main/java/io/featurehub/strategies/percentage/PercentageMumurCalculator.java diff --git a/client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory b/unmaintained/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory similarity index 100% rename from client-java-android/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory rename to unmaintained/client-java-android21/src/main/resources/META-INF/services/io.featurehub.client.FeatureHubClientFactory diff --git a/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy b/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy similarity index 97% rename from client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy rename to unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy index 57d69b8..f959953 100644 --- a/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy +++ b/unmaintained/client-java-android21/src/test/groovy/io/featurehub/android/FeatureHubClientSpec.groovy @@ -15,7 +15,7 @@ class FeatureHubClientSpec extends Specification { def "a null sdk url will never trigger a call"() { when: "i initialize the client" call = Mock(Call) - def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig)) + def fhc = new FeatureHubClient(null, null, null, client, Mock(FeatureHubConfig), 0) and: "check for updates" fhc.checkForUpdates() then: diff --git a/client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java b/unmaintained/client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java similarity index 100% rename from client-java-android/src/test/java/io/featurehub/android/FeatureHubClientRunner.java rename to unmaintained/client-java-android21/src/test/java/io/featurehub/android/FeatureHubClientRunner.java diff --git a/client-java-android21/src/test/resources/log4j2.xml b/unmaintained/client-java-android21/src/test/resources/log4j2.xml similarity index 100% rename from client-java-android21/src/test/resources/log4j2.xml rename to unmaintained/client-java-android21/src/test/resources/log4j2.xml diff --git a/unmaintained/pom.xml b/unmaintained/pom.xml new file mode 100644 index 0000000..e2790af --- /dev/null +++ b/unmaintained/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + io.featurehub.sdk.java + unmaintained-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + client-java-android21 + + diff --git a/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig b/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig new file mode 100644 index 0000000..03ed53d --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/.editorconfig @@ -0,0 +1,19 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +indent_style = space + +[*.adoc] +trim_trailing_whitespace = false +indent_style = space + diff --git a/usage-adapters/featurehub-opentelemetry-adapter/README.adoc b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc new file mode 100644 index 0000000..11007df --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/README.adoc @@ -0,0 +1,23 @@ +== FeatureHub Opentelemetry Usage Plugin for Java + +The purpose of this usage plugin is to allow Usage tracking events to be attached as Span events in OpenTelemetry. FeatureHub uses https://www.honeycomb.io/[HoneyComb for testing]. + + +This allows you to view spans in your OpenTelemetry system and see what features were evaluated, and what their values were. + +The features can be attached as Span Attributes (which usually means they don't have any extra cost, but you cannot see multiple evaluations in a single span) or as events (which +typically cost money but let you see multiple evaluations). This is controlled by the environment variable `FEATUREHUB_OTEL_SPAN_AS_EVENTS` which is `false` by default. Set it to `true` to attach as events. + +If you record a custom FeatureHub Usage event (not to be confused with an OpenTelemetry span event), it will be translated as long as it implements the `UsageEventName` interface. All attributes are logged with a prefix, which defaults to `featurehub.` - but this can be changed on construction. + +The plugin does not specify a version of the OpenTelemetry libraries to use, +it expects your application will include and configure all of these. + +A simple configuration, it can be used as: + +[source,java] +---- +config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); +---- + +It is safe to include even if OpenTelemetry is not enabled on your system because it will check it has a valid setup before attempting to log to your OpenTelemetry system. diff --git a/usage-adapters/featurehub-opentelemetry-adapter/pom.xml b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml new file mode 100644 index 0000000..b9dd950 --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + io.featurehub.sdk.java + opentelemetry-usageadapter + opentelemetry-usageadapter + 1.1-SNAPSHOT + + + + + + io.featurehub.sdk + java-client-core + [4, 5) + + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + provided + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1, 2) + provided + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + diff --git a/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java new file mode 100644 index 0000000..91508ef --- /dev/null +++ b/usage-adapters/featurehub-opentelemetry-adapter/src/main/java/io/featurehub/sdk/usageadapter/opentelemetry/OpenTelemetryUsagePlugin.java @@ -0,0 +1,73 @@ +package io.featurehub.sdk.usageadapter.opentelemetry; + +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsagePlugin; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.trace.Span; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class OpenTelemetryUsagePlugin extends UsagePlugin { + private static final Logger log = LoggerFactory.getLogger(OpenTelemetryUsagePlugin.class); + + private final String prefix; + private final boolean attachAsSpanEvents = "true".equals(System.getenv("FEATUREHUB_OTEL_SPAN_AS_EVENTS")); + + public OpenTelemetryUsagePlugin(String prefix) { + this.prefix = prefix; + } + + public OpenTelemetryUsagePlugin() { + this("featurehub."); + } + + @Override + public void send(UsageEvent event) { + final Span current = Span.current(); + if (current != null && event instanceof UsageEventName) { + final String name = ((UsageEventName) event).getEventName(); + + final Map usageAttributes = event.toMap(); + + log.trace("opentelemetry - logging {} with attributes {}", name, usageAttributes); + + if (!usageAttributes.isEmpty()) { + final AttributesBuilder builder = Attributes.builder(); + + if (attachAsSpanEvents) { + defaultEventParams.forEach((k, v) -> putMe(k, v, builder)); + usageAttributes.forEach((k, v) -> putMe(k, v, builder)); + + current.addEvent(prefix(name), builder.build(), Instant.now()); + } else { + defaultEventParams.forEach((k, v) -> putMe(prefix(k), v, builder)); + usageAttributes.forEach((k, v) -> putMe(prefix(k), v, builder)); + + current.setAllAttributes(builder.build()); + } + } + } + } + + private String prefix(String name) { + return prefix + name; + } + + private void putMe(String k, Object v, AttributesBuilder builder) { + if (v instanceof List) { + List list = (List) v; + final String result = list.stream().filter(Objects::nonNull).map(Object::toString).collect(Collectors.joining(",")); + builder.put(prefix(k), result); + } else if (v != null) { + builder.put(prefix(k), v.toString()); + } + } +} diff --git a/usage-adapters/featurehub-segment-adapter/.editorconfig b/usage-adapters/featurehub-segment-adapter/.editorconfig new file mode 100644 index 0000000..03ed53d --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/.editorconfig @@ -0,0 +1,19 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +indent_style = space + +[*.adoc] +trim_trailing_whitespace = false +indent_style = space + diff --git a/usage-adapters/featurehub-segment-adapter/README.adoc b/usage-adapters/featurehub-segment-adapter/README.adoc new file mode 100644 index 0000000..c277640 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/README.adoc @@ -0,0 +1,41 @@ += FeatureHub Segment(TM) Usage Plugin + +https://segment.com[Segment] is as described on their website. This plugin expects the use of the Segment Java SDK and a Segment write key or a preconfigured Analytics object to be provided. + +It can be used in either or both ways: + +- to Augment existing Segment messages but adding Feature information for the current user (`context`) whenever a message is tracked +- to allow specific recording of Feature evaluation events and allow you to record your own Usage events via the FeatureHub Java SDK. + +== Augmenting Segment messages + +This Plugin provides a `SegmentMessageTransformer` class that should be given to your Analytics object when building it. + +[source,java] +---- +public SegmentMessageTransformer(Message.Type[] augmentTypes, + Supplier<@Nullable ClientContext> contextSource, + boolean useAnonymousUser, boolean setUserOnMessage); +---- + +You must specify an array of message types you wish to augment, how the transformer gets the current context when logging, whether or not to always use an anonymous user, and whether to set the user on the message at all. + +NOTE: Unfortunately at the time of writing, there is no way to detect if the Message currently being built already has context or user data and therefore not overwrite it, so this class will overwrite any existing Context information. + +This class is called synchronously by the Segment SDK when building the message in your class, so if you are - for example - in a thread when you construct your message, you could store the `ClientContext` in that thread. The Java `todo` example +does this to make sure the context is available to the message transformer. + +== Tracking Feature usage + +The Usage plgin is `SegmentUsagePlugin` and needs to be registered with the FeatureHub config. e.g. + +[source,java] +---- +config.registerUsagePlugin(new SegmentUsagePlugin(segmentWriteKey)); +---- + +If you have set an environment variable `FEATUREHUB_USAGE_SEGMENT_WRITE_KEY` or a system property `featurehub.usage.segment-write-key`. + +There are several ways to create the SegmentUsagePlugin class, please take a look at the class for the various options. + +NOTE: All copyrights to Segment belong to them. diff --git a/usage-adapters/featurehub-segment-adapter/pom.xml b/usage-adapters/featurehub-segment-adapter/pom.xml new file mode 100644 index 0000000..c9c17c5 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + io.featurehub.sdk.java + segment-usageadapter + segment-usageadapter + 1.1-SNAPSHOT + + + + + + io.featurehub.sdk + java-client-core + [4, 5) + + + + com.segment.analytics.java + analytics + LATEST + + + + io.featurehub.sdk.composites + sdk-composite-logging + [1.1, 2) + provided + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java11:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + + diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java new file mode 100644 index 0000000..9da35f7 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentAnalyticsSource.java @@ -0,0 +1,10 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.Analytics; + +/** + * If you wish to implement your own plugin + */ +public interface SegmentAnalyticsSource { + Analytics getAnalytics(); +} diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java new file mode 100644 index 0000000..bc32220 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentMessageTransformer.java @@ -0,0 +1,71 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.MessageTransformer; +import com.segment.analytics.messages.Message; +import com.segment.analytics.messages.MessageBuilder; +import io.featurehub.client.ClientContext; +import io.featurehub.client.usage.UsageFeaturesCollectionContext; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.function.Supplier; + +/** + * SegmentMessageTransformer is designed to allow an analytics builder to attach the current user's features + * and context information (if any). Segment's MessageBuilder has no way of getting the current context, so being + * able to add information with multiple message transformers isn't possible. + *

+ * Issue: https://github.com/segmentio/analytics-java/issues/486 + */ +public class SegmentMessageTransformer implements MessageTransformer { + private final List augmentTypes; + private final Supplier<@Nullable ClientContext> contextSource; + private final boolean useAnonymousUser; + private final boolean setUserOnMessage; + + /** + * Creates a new Segment message transformer that augments all outgoing messages of the specified type with + * the current user's feature values. + * + * @param augmentTypes - what types of message to augment + * @param contextSource - how to get the current user's context, likely to involve ThreadLocalStorage + * @param useAnonymousUser - always use an anonymous user to prevent user-tracking burn through + * @param setUserOnMessage - should we even set the user in case something else is doing that. + */ + public SegmentMessageTransformer(Message.Type[] augmentTypes, + Supplier<@Nullable ClientContext> contextSource, + boolean useAnonymousUser, boolean setUserOnMessage) { + this.augmentTypes = List.of(augmentTypes); + this.contextSource = contextSource; + this.useAnonymousUser = useAnonymousUser; + this.setUserOnMessage = setUserOnMessage; + } + + @Override + public boolean transform(MessageBuilder builder) { + final ClientContext context = contextSource.get(); + + if (context != null && augmentTypes.contains(builder.type())) { + // create a holder that will collect the user and all the respective data + final UsageFeaturesCollectionContext usage = new UsageFeaturesCollectionContext(); + + context.fillUsageCollection(usage); + + augmentUser(builder, usage); + + builder.context(usage.toMap()); + } + + return true; + } + + private void augmentUser(MessageBuilder builder, UsageFeaturesCollectionContext usage) { + if (setUserOnMessage) { + if (useAnonymousUser) { + builder.userId("anonymous"); + } else { + builder.userId(usage.getUserKey()); + } + } + } +} diff --git a/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java new file mode 100644 index 0000000..3ae8fe4 --- /dev/null +++ b/usage-adapters/featurehub-segment-adapter/src/main/java/io/featurehub/sdk/usageadapter/segment/SegmentUsagePlugin.java @@ -0,0 +1,108 @@ +package io.featurehub.sdk.usageadapter.segment; + +import com.segment.analytics.Analytics; +import com.segment.analytics.MessageTransformer; +import com.segment.analytics.messages.TrackMessage; +import io.featurehub.client.usage.UsageEvent; +import io.featurehub.client.usage.UsageEventName; +import io.featurehub.client.usage.UsagePlugin; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * The Segment Usage Adapter is used when you wish to track "feature" events - so when a feature is evaluated + */ +public class SegmentUsagePlugin extends UsagePlugin implements SegmentAnalyticsSource { + final Analytics analytics; + private static final Logger log = LoggerFactory.getLogger(SegmentUsagePlugin.class); + + public SegmentUsagePlugin(String segmentKey) { + analytics = Analytics.builder(segmentKey).build(); + } + + public SegmentUsagePlugin(@NotNull String segmentKey, @Nullable List segmentMessageTransformer) { + this(segmentKey, null, segmentMessageTransformer); + } + + /** + * Use this constructor if you wish to provide your own OkHttpClient with proxies, timeouts and so forth. + * + * @param segmentKey - the segment write key for a java source + * @param okHttpClient - an okhttp client configured for use + */ + public SegmentUsagePlugin(String segmentKey, @Nullable OkHttpClient okHttpClient, @Nullable List segmentMessageTransformer) { + final Analytics.Builder builder = Analytics.builder(segmentKey); + + if (okHttpClient != null) { + builder.client(okHttpClient); + } + + if (segmentMessageTransformer != null) { + segmentMessageTransformer.forEach(builder::messageTransformer); + } + + analytics = builder.build(); + } + + /** + * Use this constructor if you want/need to create your own Analytics object. + * + * @param analytics - the provided analytics object. + */ + public SegmentUsagePlugin(Analytics analytics) { + this.analytics = analytics; + } + + /** + * This constructor assumes the segment write key is an environment variable `FEATUREHUB_SEGMENT_WRITE_KEY` + * or a system property `featurehub.segment-write-key`. It will construct the analytics object directly with the key + * and all other settings being default. + */ + + public SegmentUsagePlugin() { + this(Analytics.builder(segmentKey()).build()); + } + + /** + * Use this function to get the segment write key if you wish to provide your own OkHttpClient but use the standard + * keys for segment. + * + * @return configured segment key or RuntimeException if not found. + */ + public static String segmentKey() { + String segmentKey = System.getenv("FEATUREHUB_USAGE_SEGMENT_WRITE_KEY"); + + if (segmentKey == null) { + segmentKey = System.getProperty("featurehub.usage.segment-write-key"); + + if (segmentKey == null) { + throw new RuntimeException("You must initialize with an env var `FEATUREHUB_SEGMENT_KEY` or provide one to the constructor"); + } + } + + return segmentKey; + } + + @Override + public void send(UsageEvent event) { + if (event instanceof UsageEventName) { + final String userId = event.getUserKey() == null ? "anonymous" : event.getUserKey(); + + log.trace("segment event {} with key {}", ((UsageEventName) event).getEventName(), userId); + + final TrackMessage.Builder builder = + TrackMessage.builder(((UsageEventName) event).getEventName()).userId(userId).context(event.toMap()); + + analytics.enqueue(builder); + } + } + + public Analytics getAnalytics() { + return analytics; + } +} diff --git a/usage-adapters/pom.xml b/usage-adapters/pom.xml new file mode 100644 index 0000000..e965502 --- /dev/null +++ b/usage-adapters/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.featurehub.sdk.java + usage-adapter-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + featurehub-segment-adapter + featurehub-opentelemetry-adapter + + diff --git a/v17-and-above/examples/pom.xml b/v17-and-above/examples/pom.xml new file mode 100644 index 0000000..1991010 --- /dev/null +++ b/v17-and-above/examples/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + io.featurehub.java + featurehub-sdk-v17-example-reactor + 1.1.1 + pom + + + Holds examples that require v17+ of Java. We usually use v21 or 23. + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + todo-java-quarkus + todo-java-springboot + + diff --git a/v17-and-above/examples/todo-java-quarkus/.gitignore b/v17-and-above/examples/todo-java-quarkus/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/v17-and-above/examples/todo-java-quarkus/README.adoc b/v17-and-above/examples/todo-java-quarkus/README.adoc new file mode 100644 index 0000000..9390184 --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/README.adoc @@ -0,0 +1,60 @@ += The Simple Quarkus Example + +This example simply follows the basics of how a Quarkus +application could be wired for in Java. Java server applications +recommend that you expose a health check endpoint, and if you wish +to have your server not get traffic routed to it by your Application +Load Balancer (or whatever your Cloud provider uses), then simply +fail the health check when FeatureHub is not "ready". + +NOTE: With Quarkus, the version of OkHttp conflicts as FeatureHub is more modern. This +isn't a problem but does require some additional dependency additions to ensure the +right version is forced in. See the `pom.xml` for more details. + +This example is primarily here to provide documentation for the SDK, +but it operates on its own. You must modify the application.properties file for your instance +of FeatureHub + +[source,properties] +---- +feature-hub.api-key=default/82afd7ae-e7de-4567-817b-dd684315adf7/SHxmTA83AJupii4TsIciWvhaQYBIq2*JxIKxiUoswZPmLQAIIWN +feature-hub.url=http://localhost:8903 +---- + +It recognizes a "Authorization" header (via the AuthFilter) which contains the value it will +directly put into the userKey for simplicity to allow you to try out +percentage rollouts and tagging feature values to users. + +The urls are: + +- / - it print Hello World and the value of the SUBMIT_COLOR_BUTTON +- /health/liveness - whether the application is ready to receive traffic + +---- +curl -H 'Authorization: richard' http://localhost:8080 +Hello World green1 + +curl -H 'Authorization: irina' http://localhost:8080 +Hello World blue +---- + +== System Properties + +The system properties it honours are: + +- `feature-service.host` - the http/https location of your featurehub server minus the `/features` part. +- `feature-service.api-key` - the client eval key, do not use a server eval key for web servers. +- `segment.write-key` - a segment key if you have one +- `feature-service.client`, defaultValue = `sse` - valid values are sse, rest (passive poll, poll only if features are evaluated and polling interval has expired) and rest-poll (continuous poll). +- `feature-service.opentelemetry.enabled`, defaultValue = `false` - you have an otel server and have set the env vars it requires, this turns instrumentation on. +- `feature-service.poll-interval-seconds`, defaultValue = `1` - how many seconds should expire between polls (or poll expiry interval for passive polls). + +You can start the app with: + + $ mvn compile quarkus:dev + +If you have a file of these configs elsewhere: + + $ mvn -Dquarkus.config.locations=$HOME/.featurehub/example-java.properties compile quarkus:dev + +You m diff --git a/v17-and-above/examples/todo-java-quarkus/pom.xml b/v17-and-above/examples/todo-java-quarkus/pom.xml new file mode 100644 index 0000000..253a254 --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/pom.xml @@ -0,0 +1,177 @@ + + + 4.0.0 + io.featurehub.sdk.examples + todo-java-quarkus + 0.0.1-SNAPSHOT + + 3.13.0 + true + 11 + 11 + UTF-8 + UTF-8 + + 3.27.1 + quarkus-universe-bom + io.quarkus + 3.27.1 + 3.0.0-M5 + 4.12.0 + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-resteasy-jackson + + + + io.quarkus + quarkus-arc + + + + + com.squareup.okhttp3 + okhttp + [${ok.http.version}] + + + com.squareup.okio + okio + 3.6.0 + + + + com.squareup.okhttp3 + okhttp-sse + [${ok.http.version}] + + + io.featurehub.sdk + java-client-okhttp + [3,4) + + + + io.featurehub.sdk.common + common-jacksonv2 + [1, 2] + + + + + io.featurehub.sdk.java + segment-usageadapter + [1.1-SNAPSHOT, 2) + + + + com.squareup.okhttp3 + okhttp + + + com.squareup.okhttp3 + logging-interceptor + + + + + + io.opentelemetry + opentelemetry-api + 1.40.0 + + + + io.featurehub.sdk.java + opentelemetry-usageadapter + [1.1-SNAPSHOT, 2) + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus-plugin.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler-plugin.version} + + + org.openapitools + openapi-generator-maven-plugin + 7.0.1 + + + featurehub-api + + generate + + generate-sources + + ${project.basedir}/target/generated-sources/api + todo.api + todo.model + ${project.basedir}/../../../examples/todo-java-shared/todo-api.yaml + jaxrs-spec + + interfaceOnly=true,useSwaggerAnnotations=false,openApiNullable=false + + + true + true + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-generated-source + initialize + + add-source + + + + ${project.build.directory}/generated-sources/api/src/gen/java + + + + + + + + diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java new file mode 100644 index 0000000..053b7fa --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/AuthFilter.java @@ -0,0 +1,57 @@ +package io.featurehub.examples.quarkus; + +import com.segment.analytics.messages.IdentifyMessage; +import io.featurehub.client.ClientContext; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.ext.Provider; + +/** + * This filter checks if there is an Authorization header and if so, will add it to the user context + * (which is mutable) allowing downstream resources to correctly calculate their features. + */ +@Provider +@PreMatching +public class AuthFilter implements ContainerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(AuthFilter.class); + + private final SegmentAnalyticsSource segmentAnalyticsSource; + + private final jakarta.inject.Provider contextProvider; + + @Inject + public AuthFilter(SegmentAnalyticsSource segmentAnalyticsSource, jakarta.inject.Provider contextProvider) { + this.segmentAnalyticsSource = segmentAnalyticsSource; + this.contextProvider = contextProvider; + } + + @Override + public void filter(ContainerRequestContext req) { + if (req.getHeaders().containsKey("Authorization")) { + String user = req.getHeaderString("Authorization"); + + log.info("incoming request from user {}", user); + + try { + contextProvider.get().userKey(user).build().get(); + + if (segmentAnalyticsSource != null) { + segmentAnalyticsSource + .getAnalytics() + .enqueue(IdentifyMessage.builder().userId(user)); + } + + } catch (Exception e) { + log.error("Unable to set user key on user"); + } + } else { + log.info("request {} has no user", req.getUriInfo().getAbsolutePath().toASCIIString()); + } + } +} diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java new file mode 100644 index 0000000..e6b89d5 --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/FeatureHubSource.java @@ -0,0 +1,114 @@ +package io.featurehub.examples.quarkus; + +import com.segment.analytics.messages.Message; +import io.featurehub.client.ClientContext; +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.interceptor.SystemPropertyValueInterceptor; +import io.featurehub.sdk.usageadapter.opentelemetry.OpenTelemetryUsagePlugin; +import io.featurehub.sdk.usageadapter.segment.SegmentAnalyticsSource; +import io.featurehub.sdk.usageadapter.segment.SegmentMessageTransformer; +import io.featurehub.sdk.usageadapter.segment.SegmentUsagePlugin; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.quarkus.runtime.Startup; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.enterprise.inject.spi.CDI; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +@Startup +public class FeatureHubSource { + private static final Logger log = LoggerFactory.getLogger(FeatureHubSource.class); + + @ConfigProperty(name = "feature-service.host") + String featureHubUrl; + @ConfigProperty(name = "feature-service.api-key") + String sdkKey; + @ConfigProperty(name = "segment.write-key") + Optional segmentWriteKey; + @ConfigProperty(name = "feature-service.client", defaultValue = "sse") + String client; // sse, rest-active, rest-passive + @ConfigProperty(name = "feature-service.opentelemetry.enabled", defaultValue = "false") + Boolean openTelemetryEnabled; + @ConfigProperty(name = "feature-service.poll-interval-seconds", defaultValue = "1") + Integer pollInterval; // in seconds + + @Produces + @Nullable SegmentAnalyticsSource segmentAnalyticsSource; + + private FeatureHubConfig config; + + @Produces + @ApplicationScoped + @Startup + public FeatureHubConfig getConfig() { + if (featureHubUrl == null || sdkKey == null) { + throw new RuntimeException("URL and Key must not be null"); + } + log.info("Initializing FeatureHub"); + config = new EdgeFeatureHubConfig(featureHubUrl, sdkKey) + .registerValueInterceptor(true, new SystemPropertyValueInterceptor()); + + if (segmentWriteKey.isPresent()) { + final SegmentUsagePlugin segmentUsagePlugin = new SegmentUsagePlugin(segmentWriteKey.get(), + List.of(new SegmentMessageTransformer(Message.Type.values(), () -> { + final Set> beans = CDI.current().getBeanManager().getBeans(ClientContext.class); + return beans.isEmpty() ? null : (ClientContext) beans.iterator().next(); + }, false, true))); + config.registerUsagePlugin(segmentUsagePlugin); + segmentAnalyticsSource = segmentUsagePlugin; + } + + if (openTelemetryEnabled) { + // this won't do anything if otel isn't found or configured + config.registerUsagePlugin(new OpenTelemetryUsagePlugin()); + } + + // Do this if you wish to force the connection to stay open. + if (client.equals("sse")) { + config.streaming(); + } else if (client.equals("rest-passive")) { + config.restPassive(pollInterval); + } else if (client.equals("rest-active")) { + config.restActive(pollInterval); + } else { + throw new RuntimeException("Unknown featurehub client"); + } + + config.init(); + return config; + } + + /** + * This lets us create the ClientContext, which will always be empty, or the AuthFilter will add the user if it + * discovers it. + * + * @param config - the FeatureHub Config + * @return - a blank context usable by any resource. + */ + @Produces + @RequestScoped + public ClientContext fhClient(FeatureHubConfig config) { + try { + return config.newContext().build().get(); + } catch (Exception e) { + log.error("Cannot create context!", e); + throw new RuntimeException(e); + } + } + + @PreDestroy + public void close() { + config.close(); + } +} diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java new file mode 100644 index 0000000..0972303 --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/HealthResource.java @@ -0,0 +1,34 @@ +package io.featurehub.examples.quarkus; + +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.Readiness; + +import io.quarkus.runtime.Startup; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +/** + * This follows our Java recommendation patterns to return a 503 until you have a connected repository. If the + * connection to the feature server permanently goes down, this would stop routing traffic to this server. + */ +@Path("/health/liveness") +public class HealthResource { + private final FeatureHubConfig config; + + @Inject + public HealthResource(FeatureHubConfig config) { + this.config = config; + } + + @GET + public Response liveness() { + if (config.getReadiness() == Readiness.Ready) { + return Response.ok().build(); + } + + return Response.status(503).build(); + } +} diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java new file mode 100644 index 0000000..6719b9d --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoResource.java @@ -0,0 +1,127 @@ +package io.featurehub.examples.quarkus; + +import io.featurehub.client.ClientContext; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import todo.api.TodoApi; +import todo.model.Todo; + +public class TodoResource implements TodoApi { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final TodoSource todoSource; + private final Provider contextProvider; + + @Inject + public TodoResource(TodoSource todoSource, jakarta.inject.Provider contextProvider) { + this.todoSource = todoSource; + this.contextProvider = contextProvider; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todoSource.todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = contextProvider.get(); + + final List todoList = + todos.values().stream() + .map(t -> todoSource.copy(t).title(processTitle(fhClient, t.getTitle()))) + .collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @Override + public List addTodo(@NotNull String user, Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @Override + public List listTodos(@NotNull String user) { + return getTodoList(getTodoMap(user), user); + } + + @Override + public void removeAllTodos(@NotNull String user) { + getTodoMap(user).clear(); + } + + @Override + public List removeTodo(@NotNull String user, @NotNull String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @Override + public List resolveTodo(@NotNull String id, @NotNull String user) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new NotFoundException(); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java new file mode 100644 index 0000000..6bd46fc --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/java/io/featurehub/examples/quarkus/TodoSource.java @@ -0,0 +1,16 @@ +package io.featurehub.examples.quarkus; + +import jakarta.enterprise.context.ApplicationScoped; +import todo.model.Todo; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@ApplicationScoped +public class TodoSource { + public Map> todos = new ConcurrentHashMap<>(); + + public Todo copy(Todo t) { + return new Todo().resolved(t.getResolved()).id(t.getId()).title(t.getTitle()).when(t.getWhen()); + } +} diff --git a/v17-and-above/examples/todo-java-quarkus/src/main/resources/application.properties b/v17-and-above/examples/todo-java-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..d9d1351 --- /dev/null +++ b/v17-and-above/examples/todo-java-quarkus/src/main/resources/application.properties @@ -0,0 +1,8 @@ +feature-service.api-key=08c8a5f3-f766-4059-98cf-581424c8a6e3/fYetsNTQlWR7rTq9vPQv6bNd2i6W6o*5aHEEjnyIjNo2QmCnuEj +feature-service.host=http://localhost:8085 + +quarkus.log.level=INFO +quarkus.log.category."io.featurehub".min-level=TRACE +quarkus.log.category."io.featurehub".level=TRACE + +quarkus.http.port=8099 diff --git a/v17-and-above/examples/todo-java-springboot/.gitignore b/v17-and-above/examples/todo-java-springboot/.gitignore new file mode 100644 index 0000000..38cbc8a --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/.gitignore @@ -0,0 +1,35 @@ +HELP.md +target/ +/target +/.idea +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/v17-and-above/examples/todo-java-springboot/README.adoc b/v17-and-above/examples/todo-java-springboot/README.adoc new file mode 100644 index 0000000..f98032e --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/README.adoc @@ -0,0 +1,37 @@ += A FeatureHub SpringBoot Template + +This example simply follows the basics of how a Spring or SpringBoot +application would be provided for in Java. Java server applications +recommend that you expose a health check endpoint, and if you wish +to have your server not get traffic routed to it by your Application +Load Balanccer (or whatever your Cloud provider uses), then simply +fail the health check when FeatureHub is not "ready". + +This example is primarily here to provide documentation for the SDK, +but it operates on its own. You must provide two environment variables +for it to start. + +[source,bash] +---- +export FEATUREHUB_EDGE_URL=http://localhost:8903/ +export FEATUREHUB_API_KEY=default/3f7a1a34-642b-4054-a82f-1ca2d14633ed/aH0l9TDXzauYq6rKQzVUPwbzmzGRqe*oPqyYqhUlVC50RxAzSmx + +mvn spring-boot:run +---- + +It recognizes a "Authorization" header which contains the value it will +directly put into the userKey for simplicity to allow you to try out +percentage rollouts and tagging feature values to users. + +The urls are: + +- / - it print Hello World and the value of the SUBMIT_COLOR_BUTTON +- /health/liveness - whether the application is ready to receive traffic + +---- +curl -H 'Authorization: richard' http://localhost:8080 +Hello World green1 + +curl -H 'Authorization: irina' http://localhost:8080 +Hello World blue +---- diff --git a/v17-and-above/examples/todo-java-springboot/pom.xml b/v17-and-above/examples/todo-java-springboot/pom.xml new file mode 100644 index 0000000..fdf507c --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.1 + + + io.featurehub.sdk.examples + todo-java-springboot + 0.0.1-SNAPSHOT + spring-boot + Demo project for Spring Boot for FeatureHub + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.featurehub.sdk + java-client-okhttp + [3, 4) + + + + io.featurehub.sdk.common + common-jacksonv3 + [1,2) + + + + io.featurehub.sdk.composites + composite-okhttp + [1,2) + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java new file mode 100644 index 0000000..b61956d --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Application.java @@ -0,0 +1,29 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.EdgeFeatureHubConfig; +import io.featurehub.client.FeatureHubConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class Application { + @Value("${featurehub.url}") + String edgeUrl; + @Value("${featurehub.apiKey}") + String apiKey; + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + @Bean + public FeatureHubConfig featureHubConfig() { + FeatureHubConfig config = new EdgeFeatureHubConfig(edgeUrl, apiKey); + config.streaming().init(); + + return config; + } + +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java new file mode 100644 index 0000000..6110a24 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/HealthResource.java @@ -0,0 +1,34 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.FeatureHubConfig; +import io.featurehub.client.Readiness; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/health") +public class HealthResource { + private final FeatureHubConfig featureHubConfig; + private static final Logger log = LoggerFactory.getLogger(HealthResource.class); + + @Autowired + public HealthResource(FeatureHubConfig featureHubConfig) { + this.featureHubConfig = featureHubConfig; + } + + @RequestMapping("/liveness") + public String liveness() { + if (featureHubConfig.getReadiness() == Readiness.Ready) { + return "yes"; + } + + log.warn("FeatureHub connection not yet available, reporting not live."); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java new file mode 100644 index 0000000..9b80724 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/Todo.java @@ -0,0 +1,173 @@ +/* + * Todo + * Sample todo-api + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +package io.featurehub.examples.springboot; + +import com.fasterxml.jackson.annotation.*; +import java.time.OffsetDateTime; +import java.util.Objects; + +/** Todo */ +@JsonIgnoreProperties(ignoreUnknown = true) +@jakarta.annotation.Generated( + value = "cd.connect.openapi.Jersey3ApiGenerator", + date = "2026-01-11T09:21:49.181170+13:00[Pacific/Auckland]") +public class Todo { + @JsonProperty("id") + // required true nullable true default + @org.jetbrains.annotations.Nullable + private String id; + + @JsonProperty("title") + // required false nullable false default + @org.jetbrains.annotations.NotNull + private String title; + + @JsonProperty("resolved") + // required false nullable true default + @org.jetbrains.annotations.Nullable + private Boolean resolved; + + @JsonProperty("when") + // required false nullable true default + @org.jetbrains.annotations.Nullable + private OffsetDateTime when; + + public Todo id(@org.jetbrains.annotations.Nullable String id) { + this.id = id; + return this; + } + + /** + * Get id + * + * @return id + */ + @org.jetbrains.annotations.Nullable + public String getId() { + return id; + } + + public void setId(@org.jetbrains.annotations.Nullable String id) { + this.id = id; + } + + public Todo title(@org.jetbrains.annotations.NotNull String title) { + this.title = title; + return this; + } + + /** + * Get title + * + * @return title + */ + @org.jetbrains.annotations.NotNull + public String getTitle() { + return title; + } + + public void setTitle(@org.jetbrains.annotations.NotNull String title) { + this.title = title; + } + + public Todo resolved(@org.jetbrains.annotations.Nullable Boolean resolved) { + this.resolved = resolved; + return this; + } + + /** + * Get resolved + * + * @return resolved + */ + @org.jetbrains.annotations.Nullable + public Boolean getResolved() { + return resolved; + } + + public void setResolved(@org.jetbrains.annotations.Nullable Boolean resolved) { + this.resolved = resolved; + } + + public Todo when(@org.jetbrains.annotations.Nullable OffsetDateTime when) { + this.when = when; + return this; + } + + /** + * Get when + * + * @return when + */ + @org.jetbrains.annotations.Nullable + public OffsetDateTime getWhen() { + return when; + } + + public void setWhen(@org.jetbrains.annotations.Nullable OffsetDateTime when) { + this.when = when; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Todo todo = (Todo) o; + return Objects.equals(this.id, todo.id) + && Objects.equals(this.title, todo.title) + && Objects.equals(this.resolved, todo.resolved) + && Objects.equals(this.when, todo.when); + } + + @Override + public int hashCode() { + return Objects.hash(id, title, resolved, when); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Todo {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" title: ").append(toIndentedString(title)).append("\n"); + sb.append(" resolved: ").append(toIndentedString(resolved)).append("\n"); + sb.append(" when: ").append(toIndentedString(when)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + public Todo copy() { + Todo copy = new Todo(); + + copy.setId(this.getId()); + copy.setTitle(this.getTitle()); + copy.setResolved(this.getResolved()); + copy.setWhen(this.getWhen()); + + return copy; + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java new file mode 100644 index 0000000..52493d6 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/TodoResource.java @@ -0,0 +1,151 @@ +package io.featurehub.examples.springboot; + + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureHubConfig; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@RestController() +@RequestMapping("/todo") +public class TodoResource { + private static final Logger log = LoggerFactory.getLogger(TodoResource.class); + private final FeatureHubConfig featureHub; + Map> todos = new ConcurrentHashMap<>(); + + @Autowired + public TodoResource(FeatureHubConfig config) { + this.featureHub = config; + log.info("created"); + } + + private Map getTodoMap(String user) { + return todos.computeIfAbsent(user, (key) -> new ConcurrentHashMap<>()); + } + + // ideally we wouldn't do it this way, but this is the API, the user is in the url + // rather than in the Authorisation token. If it was in the token we would do the context + // creation in a filter and inject the context instead + private List getTodoList(Map todos, String user) { + ClientContext fhClient = fhClient(user); + + final List todoList = todos.values().stream().map(t -> t.copy().title(processTitle(fhClient, t.getTitle()))).collect(Collectors.toList()); + return todoList; + } + + private String processTitle(ClientContext fhClient, String title) { + if (title == null) { + return null; + } + + if (fhClient == null) { + return title; + } + + if (fhClient.isSet("FEATURE_STRING") && "buy".equals(title)) { + title = title + " " + fhClient.feature("FEATURE_STRING").getString(); + log.debug("Processes string feature: {}", title); + } + + if (fhClient.isSet("FEATURE_NUMBER") && title.equals("pay")) { + title = title + " " + fhClient.feature("FEATURE_NUMBER").getNumber().toString(); + log.debug("Processed number feature {}", title); + } + + if (fhClient.isSet("FEATURE_JSON") && title.equals("find")) { + final Map feature_json = fhClient.feature("FEATURE_JSON").getJson(Map.class); + title = title + " " + feature_json.get("foo").toString(); + log.debug("Processed JSON feature {}", title); + } + + if (fhClient.isEnabled("FEATURE_TITLE_TO_UPPERCASE")) { + title = title.toUpperCase(); + log.debug("Processed boolean feature {}", title); + } + + return title; + } + + @NotNull + private ClientContext fhClient(String user) { + try { + final ClientContext context = featureHub.newContext() + .userKey(user) + .attrs("mine", List.of("yours", "his")) + .build().get(); + +// FeatureHubClientContextThreadLocal.set(context); +// +// if (featureHub.segmentAnalytics() != null) { +// // this should have the current user's details augmented into it +// featureHub.segmentAnalytics().getAnalytics().enqueue(IdentifyMessage.builder().userId(user)); +// } + + context.feature("SUBMIT_COLOR_BUTTON").isSet(); + + return context; + } catch (Exception e) { + log.error("Unable to get context!", e); + throw new ResponseStatusException(HttpStatusCode.valueOf(503), "Not connected"); + } + } + + @PostMapping(value = "/{user}", consumes = "application/json", produces = "application/json") + public List addTodo(@NotNull @PathVariable("user") String user, @RequestBody Todo body) { + if (body.getId() == null || body.getId().isEmpty()) { + body.id(UUID.randomUUID().toString()); + } + + if (body.getResolved() == null) { + body.resolved(false); + } + + Map userTodo = getTodoMap(user); + userTodo.put(body.getId(), body); + + return getTodoList(userTodo, user); + } + + @GetMapping(value = "/{user}", produces = "application/json") + public List listTodos(@NotNull @PathVariable("user") String user) { + return getTodoList(getTodoMap(user), user); + } + + @DeleteMapping(value = "/{user}", produces = "application/json") + public void removeAllTodos(@NotNull @PathVariable("user") String user) { + getTodoMap(user).clear(); + } + + @GetMapping(value = "/{user}/{id}", produces = "application/json") + public List removeTodo(@NotNull @PathVariable("user") String user, @NotNull @PathVariable("id") String id) { + Map userTodo = getTodoMap(user); + userTodo.remove(id); + return getTodoList(userTodo, user); + } + + @PutMapping(value = "/{user}/{id}", produces = "application/json") + public List resolveTodo(@NotNull @PathVariable("user") String user, @NotNull @PathVariable("id") String id) { + Map userTodo = getTodoMap(user); + + Todo todo = userTodo.get(id); + + if (todo == null) { + throw new ResponseStatusException(HttpStatusCode.valueOf(404), "No such todo"); + } + + todo.setResolved(true); + + return getTodoList(userTodo, user); + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java new file mode 100644 index 0000000..9ad8265 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/java/io/featurehub/examples/springboot/UserConfiguration.java @@ -0,0 +1,25 @@ +package io.featurehub.examples.springboot; + +import io.featurehub.client.ClientContext; +import io.featurehub.client.FeatureHubConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; + +import jakarta.servlet.http.HttpServletRequest; + +@Configuration +public class UserConfiguration { + @Bean + @Scope("request") + ClientContext featureHubClient(FeatureHubConfig fhConfig, HttpServletRequest request) { + ClientContext fhClient = fhConfig.newContext(); + + if (request.getHeader("Authorization") != null) { + // you would always authenticate some other way, this is just an example + fhClient.userKey(request.getHeader("Authorization")); + } + + return fhClient; + } +} diff --git a/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml b/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml new file mode 100644 index 0000000..b852f0d --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +server: + port: 8099 + +featurehub: + url: ${FEATUREHUB_EDGE_URL:http://localhost:8903} + apiKey: ${FEATUREHUB_CLIENT_API_KEY:9db9f611-f5c9-4b09-bac5-ccf5a5b989c9/ACyrjuIjugghPI2J6XxHybSXKoC28Z*Ld6f2H8drfzP1raWgK8D} diff --git a/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java b/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java new file mode 100644 index 0000000..a6b09d4 --- /dev/null +++ b/v17-and-above/examples/todo-java-springboot/src/test/java/io/featurehub/examples/springboot/ApplicationTests.java @@ -0,0 +1,13 @@ +package io.featurehub.examples.springboot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/v17-and-above/pom.xml b/v17-and-above/pom.xml new file mode 100644 index 0000000..b3a81bd --- /dev/null +++ b/v17-and-above/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.featurehub + featurehub-v17-java-sdk-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + examples + support + + diff --git a/v17-and-above/support/common-jacksonv3/pom.xml b/v17-and-above/support/common-jacksonv3/pom.xml new file mode 100644 index 0000000..924901f --- /dev/null +++ b/v17-and-above/support/common-jacksonv3/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + io.featurehub.sdk.common + common-jacksonv3 + 1.1-SNAPSHOT + common-jacksonv3 + + + implementation for jacksonv3 + + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + MIT + https://opensource.org/licenses/MIT + This code resides in the customer's codebase and therefore has an MIT license. + + + + + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + scm:git:git@github.com:featurehub-io/featurehub-java-sdk.git + git@github.com:featurehub-io/featurehub-java-sdk.git + HEAD + + + + 3.0.3 + 3.0.3 + 21 + + + + + io.featurehub.sdk.common + common-jackson + [1.1-SNAPSHOT, 2] + + + + tools.jackson.core + jackson-databind + [${jackson.databind.version}] + + + + + tools.jackson.jaxrs + jackson-jaxrs-base + [${jackson.version}] + + + + + io.featurehub.sdk.composites + sdk-composite-logging-api + [1.1, 2) + + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.32 + true + + false + + io.featurehub.sdk.tiles:tile-java21:[1.1,2) + io.featurehub.sdk.tiles:tile-release:[1.1,2) + + + + + + diff --git a/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java new file mode 100644 index 0000000..029bccd --- /dev/null +++ b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapper.java @@ -0,0 +1,62 @@ +package io.featurehub.javascript; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import io.featurehub.sse.model.FeatureEnvironmentCollection; +import io.featurehub.sse.model.FeatureState; +import io.featurehub.sse.model.FeatureStateUpdate; +import org.jetbrains.annotations.NotNull; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +// migration guide: https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md + +public class Jackson3ObjectMapper implements JavascriptObjectMapper { + private static ObjectMapper mapper; + + static { + mapper = JsonMapper.builder() + .enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL)) + .changeDefaultPropertyInclusion(incl -> incl.withContentInclusion(JsonInclude.Include.NON_NULL)) + .build(); + + } + + @Override + public T readValue(String data, Class type) throws IOException { + return data == null ? null : mapper.readValue(data, type); + } + + private static final TypeReference> FEATURE_COLLECTION_TYPEREF = new TypeReference>(){}; + private static final TypeReference> mapConfig = new TypeReference>() {}; + private static final TypeReference> FEATURE_LIST_TYPEDEF = + new TypeReference<>() {}; + + @Override + public Map readMapValue(String data) throws IOException { + return mapper.readValue(data, mapConfig); + } + + @Override + public @NotNull List readFeatureStates(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_LIST_TYPEDEF); + } + + @Override + public @NotNull List readFeatureCollection(@NotNull String data) throws IOException { + return mapper.readValue(data, FEATURE_COLLECTION_TYPEREF); + } + + @Override + public @NotNull String featureStateUpdateToString(FeatureStateUpdate data) throws IOException { + return mapper.writeValueAsString(data); + } +} diff --git a/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java new file mode 100644 index 0000000..e8ce498 --- /dev/null +++ b/v17-and-above/support/common-jacksonv3/src/main/java/io/featurehub/javascript/Jackson3ObjectMapperProvider.java @@ -0,0 +1,8 @@ +package io.featurehub.javascript; + +public class Jackson3ObjectMapperProvider implements JavascriptObjectMapperProviderService { + @Override + public JavascriptObjectMapper get() { + return new Jackson3ObjectMapper(); + } +} diff --git a/v17-and-above/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService b/v17-and-above/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService new file mode 100644 index 0000000..6f49c63 --- /dev/null +++ b/v17-and-above/support/common-jacksonv3/src/main/resources/META-INF/services/io.featurehub.javascript.JavascriptObjectMapperProviderService @@ -0,0 +1 @@ +io.featurehub.javascript.Jackson3ObjectMapperProvider diff --git a/v17-and-above/support/pom.xml b/v17-and-above/support/pom.xml new file mode 100644 index 0000000..7e24fc3 --- /dev/null +++ b/v17-and-above/support/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + io.featurehub + featurehub-sdk-v17-support-reactor + 1.1.1 + pom + + https://featurehub.io + + + irina@featurehub.io + isouthwell + Irina Southwell + Anyways Labs Ltd + + + + richard@featurehub.io + rvowles + Richard Vowles + Anyways Labs Ltd + + + + + + Apache 2 with Commons Clause + https://github.com/featurehub-io/featurehub/blob/master/LICENSE.txt + + + + + common-jacksonv3 + +