Repository: Unleash/unleash-client-java
Pull request #145 (2021-11-16 17:19:46)

task: Add OkHttpToggleFetcher implementation
This PR adds a new ToggleFetcher using Square's OkHttp library to perform the HTTP calls, rather than using java.net's internal HttpClient.

Since OkHttp handles caching and redirect logic for use, it reduces logic we have to worry about, at the cost of adding a dependency that does bring with it some additional transitive dependencies.

This PR is not feature complete, because we will have to make sure the OkHttp client can also be convinced to use the proxy logic that has been written for the current java.net implementation. Copying all mocks and tests for the current HttpToggleFetcher and switching to using the OkHttpToggleFetcher did reveal a couple of differences that will need to be verified against our server.

One is that OkHttp apparently suffixes ETags with --gzip if it receives a gzip response; we'll have to make sure that our Unleash server still sees this as the same ETag, and if not, configure OkHttp to add the --gzip suffix.

The other is that apparently HttpUrl's toString() methods suffix toplevel URLs with `/` whereas the old code took the response header raw (without a trailing `/`). Semantically it's the same, and the redirect request does actually happen, so I'm pretty sure it confirms that we still do the same logic for a 308.

Created as Draft for now, open for discussion

chriswk: @checketts - Good question. Yes, you can write your own fetcher and supply that to the config builder.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd1f4e7c..8eae24dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,13 +2,14 @@
 
 ## 6.0.x (unreleased)
 - Remove deprecated `no.finn.unleash` package
-
+- feat: Added support for providing custom ToggleFetcher through a UnleashToggleFetcherFactory (A Function<UnleashConfig, ToggleFetcher>). Two fetchers are now included, one using java.net.http and one using OkHttp, if you want to use the OkHttp one, your project will need to add a dependency on ```<dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp</artifactId><version>4.9+</version></dependency>```
 ## 5.1.0
 - feat: Adds support for more constraint operators (String, Number, Semver, Dates)
   - See https://docs.getunleash.io/advanced/strategy_constraints#constraint-structure
 
 ## 5.0.4
 - fix: Bootstrap no longer fails hard when passed invalid json. It now returns an empty list of features if the bootstrap json could not be parsed.
+
 ## 5.0.0
 - chore: Refactor package names. Moving from `no.finn.unleash` to `io.getunleash` to reflect where development is taking place.
 
diff --git a/README.md b/README.md
index 1ed3fc6f..f959812d 100644
--- a/README.md
+++ b/README.md
@@ -220,6 +220,31 @@ UnleashConfig config = UnleashConfig.builder()
     .build();
 ```
 
+### Toggle fetcher
+The Unleash Java client now supports using your own toggle fetcher.
+The Config builder has been expanded to accept a `io.getunleash.util.UnleashFeatureFetcherFactory` which should be a `Function<UnleashConfig, FeatureFetcher>`.
+If you want to use OkHttp instead of HttpURLConnection you'll need a dependency on okhttp
+
+```xml
+<dependency>
+    <groupId>com.squareup.okhttp3</groupId>
+    <artifactId>okhttp</artifactId>
+    <version>4.9+</version>
+</dependency>
+```
+
+Then you can change your config to
+```java
+UnleashConfig config = UnleashConfig.builder()
+    .appName("my-app")
+    .unleashAPI("http://unleash.org")
+    .customHttpHeader("Authorization", "API token")
+    .unleashFeatureFetcherFactory(OkHttpFeatureFetcher::new)
+    .build();
+```
+
+This will then start using OkHttp instead of HttpURLConnection.
+
 ## Local backup
 By default unleash-client fetches the feature toggles from unleash-server every 10s, and stores the
 result in `unleash-repo.json` which is located in the `java.io.tmpdir` directory. This means that if
diff --git a/examples/cli-example/build.gradle.kts b/examples/cli-example/build.gradle.kts
index e947d328..8392d449 100644
--- a/examples/cli-example/build.gradle.kts
+++ b/examples/cli-example/build.gradle.kts
@@ -4,6 +4,7 @@ plugins {
 
 repositories {
     mavenCentral()
+    mavenLocal()
 }
 
 dependencies {
diff --git a/pom.xml b/pom.xml
index 63dc78b6..782898ee 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,9 +9,11 @@
         <version.slf4j>1.7.36</version.slf4j>
         <version.log4j2>2.17.2</version.log4j2>
         <version.junit5>5.8.2</version.junit5>
+        <version.okhttp>4.9.3</version.okhttp>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <version.unleash.specification>4.2.0</version.unleash.specification>
         <arguments />
+        <version.jackson>2.13.3</version.jackson>
     </properties>
 
     <name>io.getunleash:unleash-client-java</name>
@@ -58,6 +60,12 @@
             <artifactId>gson</artifactId>
             <version>2.9.0</version>
         </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>${version.okhttp}</version>
+            <optional>true</optional>
+        </dependency>
 
         <dependency>
             <groupId>org.slf4j</groupId>
@@ -96,17 +104,16 @@
         <dependency>
             <groupId>org.assertj</groupId>
             <artifactId>assertj-core</artifactId>
-            <version>3.22.0</version>
+            <version>3.23.1</version>
             <scope>test</scope>
         </dependency>
 
         <dependency>
             <groupId>org.mockito</groupId>
             <artifactId>mockito-core</artifactId>
-            <version>4.5.1</version>
+            <version>4.6.1</version>
             <scope>test</scope>
         </dependency>
-
         <dependency>
             <groupId>com.github.tomakehurst</groupId>
             <artifactId>wiremock</artifactId>
@@ -116,19 +123,19 @@
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-core</artifactId>
-            <version>2.13.3</version>
+            <version>${version.jackson}</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-annotations</artifactId>
-            <version>2.13.3</version>
+            <version>${version.jackson}</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-databind</artifactId>
-            <version>2.13.3</version>
+            <version>${version.jackson}</version>
             <scope>test</scope>
         </dependency>
 
@@ -149,6 +156,12 @@
     </dependencies>
 
     <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
         <plugins>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
@@ -385,8 +398,7 @@
                 <activeByDefault>false</activeByDefault>
             </activation>
             <properties>
-                <sonatypeOssDistMgmtSnapshotsUrl>https://s01.oss.sonatype.org/content/repositories/snapshots
-                </sonatypeOssDistMgmtSnapshotsUrl>
+                <sonatypeOssDistMgmtSnapshotsUrl>https://s01.oss.sonatype.org/content/repositories/snapshots</sonatypeOssDistMgmtSnapshotsUrl>
             </properties>
             <repositories>
                 <repository>
diff --git a/src/main/java/io/getunleash/DefaultUnleash.java b/src/main/java/io/getunleash/DefaultUnleash.java
index ffc65d1f..6b4848ae 100644
--- a/src/main/java/io/getunleash/DefaultUnleash.java
+++ b/src/main/java/io/getunleash/DefaultUnleash.java
@@ -8,7 +8,7 @@ import io.getunleash.event.ToggleEvaluated;
 import io.getunleash.lang.Nullable;
 import io.getunleash.metric.UnleashMetricService;
 import io.getunleash.metric.UnleashMetricServiceImpl;
-import io.getunleash.repository.*;
+import io.getunleash.repository.FeatureRepository;
 import io.getunleash.strategy.*;
 import io.getunleash.util.ConstraintMerger;
 import io.getunleash.util.UnleashConfig;
@@ -35,7 +35,6 @@ public class DefaultUnleash implements Unleash {
     public static final UnknownStrategy UNKNOWN_STRATEGY = new UnknownStrategy();
 
     private final UnleashMetricService metricService;
-
     private final FeatureRepository featureRepository;
     private final Map<String, Strategy> strategyMap;
     private final UnleashContextProvider contextProvider;
diff --git a/src/main/java/io/getunleash/FakeUnleash.java b/src/main/java/io/getunleash/FakeUnleash.java
index b7eb06b2..cbe8e784 100644
--- a/src/main/java/io/getunleash/FakeUnleash.java
+++ b/src/main/java/io/getunleash/FakeUnleash.java
@@ -138,10 +138,11 @@ public class FakeUnleash implements Unleash {
         public List<EvaluatedToggle> evaluateAllToggles(@Nullable UnleashContext context) {
             return getFeatureToggleNames().stream()
                     .map(
-                            toggleName -> {
-                                return new EvaluatedToggle(
-                                        toggleName, isEnabled(toggleName), getVariant(toggleName));
-                            })
+                            toggleName ->
+                                    new EvaluatedToggle(
+                                            toggleName,
+                                            isEnabled(toggleName),
+                                            getVariant(toggleName)))
                     .collect(Collectors.toList());
         }
 
diff --git a/src/main/java/io/getunleash/repository/OkHttpFeatureFetcher.java b/src/main/java/io/getunleash/repository/OkHttpFeatureFetcher.java
new file mode 100644
index 00000000..82ba367d
--- /dev/null
+++ b/src/main/java/io/getunleash/repository/OkHttpFeatureFetcher.java
@@ -0,0 +1,140 @@
+package io.getunleash.repository;
+
+import static io.getunleash.util.UnleashConfig.UNLEASH_APP_NAME_HEADER;
+import static io.getunleash.util.UnleashConfig.UNLEASH_INSTANCE_ID_HEADER;
+
+import com.google.gson.JsonSyntaxException;
+import io.getunleash.UnleashException;
+import io.getunleash.util.UnleashConfig;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import okhttp3.Cache;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class OkHttpFeatureFetcher implements FeatureFetcher {
+
+    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+    private static final Duration CALL_TIMEOUT = Duration.ofSeconds(5);
+
+    private final HttpUrl toggleUrl;
+    private final OkHttpClient client;
+
+    public OkHttpFeatureFetcher(UnleashConfig unleashConfig) {
+        File tempDir = null;
+        try {
+            tempDir = Files.createTempDirectory("http_cache").toFile();
+        } catch (IOException ignored) {
+        }
+        OkHttpClient.Builder builder =
+                new OkHttpClient.Builder()
+                        .connectTimeout(CONNECT_TIMEOUT)
+                        .callTimeout(CALL_TIMEOUT)
+                        .followRedirects(true);
+        if (tempDir != null) {
+            builder = builder.cache(new Cache(tempDir, 1024 * 1024 * 50));
+        }
+        if (unleashConfig.getProxy() != null) {
+            builder = builder.proxy(unleashConfig.getProxy());
+        }
+
+        this.toggleUrl =
+                Objects.requireNonNull(
+                        HttpUrl.get(
+                                unleashConfig
+                                        .getUnleashURLs()
+                                        .getFetchTogglesURL(
+                                                unleashConfig.getProjectName(),
+                                                unleashConfig.getNamePrefix())));
+        this.client = configureInterceptor(unleashConfig, builder.build());
+    }
+
+    public OkHttpFeatureFetcher(UnleashConfig unleashConfig, OkHttpClient client) {
+        this.client = configureInterceptor(unleashConfig, client);
+        this.toggleUrl =
+                Objects.requireNonNull(
+                        HttpUrl.get(
+                                unleashConfig
+                                        .getUnleashURLs()
+                                        .getFetchTogglesURL(
+                                                unleashConfig.getProjectName(),
+                                                unleashConfig.getNamePrefix())));
+    }
+
+    public OkHttpClient configureInterceptor(UnleashConfig config, OkHttpClient client) {
+        return client.newBuilder()
+                .addInterceptor(
+                        (c) -> {
+                            Request.Builder headers =
+                                    c.request()
+                                            .newBuilder()
+                                            .addHeader("Content-Type", "application/json")
+                                            .addHeader("Accept", "application/json")
+                                            .addHeader(UNLEASH_APP_NAME_HEADER, config.getAppName())
+                                            .addHeader(
+                                                    UNLEASH_INSTANCE_ID_HEADER,
+                                                    config.getInstanceId())
+                                            .addHeader("User-Agent", config.getAppName())
+                                            .addHeader(
+                                                    "Unleash-Client-Spec",
+                                                    config.getClientSpecificationVersion());
+                            for (Map.Entry<String, String> headerEntry :
+                                    config.getCustomHttpHeaders().entrySet()) {
+                                headers =
+                                        headers.addHeader(
+                                                headerEntry.getKey(), headerEntry.getValue());
+                            }
+                            for (Map.Entry<String, String> headerEntry :
+                                    config.getCustomHttpHeadersProvider()
+                                            .getCustomHeaders()
+                                            .entrySet()) {
+                                headers =
+                                        headers.addHeader(
+                                                headerEntry.getKey(), headerEntry.getValue());
+                            }
+                            return c.proceed(headers.build());
+                        })
+                .build();
+    }
+
+    @Override
+    public ClientFeaturesResponse fetchFeatures() throws UnleashException {
+        Request request = new Request.Builder().url(toggleUrl).get().build();
+        HttpUrl location = toggleUrl;
+        int code = 200;
+        try (Response response = client.newCall(request).execute()) {
+            if (response.isSuccessful() && response.body() != null) {
+                location = response.request().url();
+                code = response.code();
+                if (response.cacheResponse() != null) {
+                    return new ClientFeaturesResponse(
+                            ClientFeaturesResponse.Status.NOT_CHANGED, 304);
+                } else {
+                    FeatureCollection features =
+                            JsonFeatureParser.fromJson(response.body().charStream());
+                    return new ClientFeaturesResponse(
+                            ClientFeaturesResponse.Status.CHANGED,
+                            features.getToggleCollection(),
+                            features.getSegmentCollection());
+                }
+            } else if (response.code() >= 301 && response.code() <= 304) {
+                return new ClientFeaturesResponse(
+                        ClientFeaturesResponse.Status.NOT_CHANGED, response.code());
+            } else {
+                return new ClientFeaturesResponse(
+                        ClientFeaturesResponse.Status.UNAVAILABLE, response.code());
+            }
+        } catch (IOException ioEx) {
+            throw new UnleashException("Could not fetch toggles", ioEx);
+        } catch (IllegalStateException | JsonSyntaxException ex) {
+            return new ClientFeaturesResponse(
+                    ClientFeaturesResponse.Status.UNAVAILABLE, code, location.toString());
+        }
+    }
+}
diff --git a/src/main/java/io/getunleash/util/ConstraintMerger.java b/src/main/java/io/getunleash/util/ConstraintMerger.java
index 7f3b0493..b1961f8a 100644
--- a/src/main/java/io/getunleash/util/ConstraintMerger.java
+++ b/src/main/java/io/getunleash/util/ConstraintMerger.java
@@ -15,8 +15,7 @@ public class ConstraintMerger {
                         Optional.ofNullable(strategy.getConstraints())
                                 .orElseGet(Collections::emptyList),
                         Optional.ofNullable(strategy.getSegments())
-                                .orElseGet(Collections::emptyList)
-                                .stream()
+                                .orElseGet(Collections::emptyList).stream()
                                 .map(repository::getSegment)
                                 .filter(Objects::nonNull)
                                 .map(Segment::getConstraints)
diff --git a/src/main/java/io/getunleash/util/UnleashConfig.java b/src/main/java/io/getunleash/util/UnleashConfig.java
index 1fa051b3..94f69cdf 100644
--- a/src/main/java/io/getunleash/util/UnleashConfig.java
+++ b/src/main/java/io/getunleash/util/UnleashConfig.java
@@ -8,6 +8,7 @@ import io.getunleash.UnleashContextProvider;
 import io.getunleash.event.NoOpSubscriber;
 import io.getunleash.event.UnleashSubscriber;
 import io.getunleash.lang.Nullable;
+import io.getunleash.repository.HttpFeatureFetcher;
 import io.getunleash.repository.ToggleBootstrapProvider;
 import io.getunleash.strategy.Strategy;
 import java.io.File;
@@ -25,8 +26,8 @@ import java.util.Optional;
 
 public class UnleashConfig {
 
-    static final String UNLEASH_APP_NAME_HEADER = "UNLEASH-APPNAME";
-    static final String UNLEASH_INSTANCE_ID_HEADER = "UNLEASH-INSTANCEID";
+    public static final String UNLEASH_APP_NAME_HEADER = "UNLEASH-APPNAME";
+    public static final String UNLEASH_INSTANCE_ID_HEADER = "UNLEASH-INSTANCEID";
 
     private final URI unleashAPI;
     private final UnleashURLs unleashURLs;
@@ -37,12 +38,15 @@ public class UnleashConfig {
     private final String instanceId;
     private final String sdkVersion;
     private final String backupFile;
+
+    private final String clientSpecificationVersion;
     @Nullable private final String projectName;
     @Nullable private final String namePrefix;
     private final long fetchTogglesInterval;
     private final long sendMetricsInterval;
     private final boolean disableMetrics;
     private final boolean isProxyAuthenticationByJvmProperties;
+    private final UnleashFeatureFetcherFactory unleashFeatureFetcherFactory;
     private final UnleashContextProvider contextProvider;
     private final boolean synchronousFetchOnInitialisation;
     private final UnleashScheduledExecutor unleashScheduledExecutor;
@@ -68,6 +72,7 @@ public class UnleashConfig {
             UnleashContextProvider contextProvider,
             boolean isProxyAuthenticationByJvmProperties,
             boolean synchronousFetchOnInitialisation,
+            UnleashFeatureFetcherFactory unleashFeatureFetcherFactory,
             @Nullable UnleashScheduledExecutor unleashScheduledExecutor,
             @Nullable UnleashSubscriber unleashSubscriber,
             @Nullable Strategy fallbackStrategy,
@@ -128,6 +133,9 @@ public class UnleashConfig {
         this.unleashSubscriber = unleashSubscriber;
         this.toggleBootstrapProvider = unleashBootstrapProvider;
         this.proxy = proxy;
+        this.unleashFeatureFetcherFactory = unleashFeatureFetcherFactory;
+        this.clientSpecificationVersion =
+                UnleashProperties.getProperty("client.specification.version");
     }
 
     public static Builder builder() {
@@ -138,6 +146,8 @@ public class UnleashConfig {
         connection.setRequestProperty(UNLEASH_APP_NAME_HEADER, config.getAppName());
         connection.setRequestProperty(UNLEASH_INSTANCE_ID_HEADER, config.getInstanceId());
         connection.setRequestProperty("User-Agent", config.getAppName());
+        connection.setRequestProperty(
+                "Unleash-Client-Spec", config.getClientSpecificationVersion());
         config.getCustomHttpHeaders().forEach(connection::setRequestProperty);
         config.customHttpHeadersProvider.getCustomHeaders().forEach(connection::setRequestProperty);
     }
@@ -176,6 +186,10 @@ public class UnleashConfig {
         return sdkVersion;
     }
 
+    public String getClientSpecificationVersion() {
+        return clientSpecificationVersion;
+    }
+
     public @Nullable String getProjectName() {
         return projectName;
     }
@@ -240,6 +254,10 @@ public class UnleashConfig {
         return proxy;
     }
 
+    public UnleashFeatureFetcherFactory unleashFeatureFetcherFactory() {
+        return this.unleashFeatureFetcherFactory;
+    }
+
     static class SystemProxyAuthenticator extends Authenticator {
         @Override
         protected @Nullable PasswordAuthentication getPasswordAuthentication() {
@@ -309,6 +327,7 @@ public class UnleashConfig {
         private long fetchTogglesInterval = 10;
         private long sendMetricsInterval = 60;
         private boolean disableMetrics = false;
+        private UnleashFeatureFetcherFactory unleashFeatureFetcherFactory = HttpFeatureFetcher::new;
         private UnleashContextProvider contextProvider =
                 UnleashContextProvider.getDefaultProvider();
         private boolean synchronousFetchOnInitialisation = false;
@@ -380,6 +399,12 @@ public class UnleashConfig {
             return this;
         }
 
+        public Builder unleashFeatureFetcherFactory(
+                UnleashFeatureFetcherFactory unleashFeatureFetcherFactory) {
+            this.unleashFeatureFetcherFactory = unleashFeatureFetcherFactory;
+            return this;
+        }
+
         public Builder fetchTogglesInterval(long fetchTogglesInterval) {
             this.fetchTogglesInterval = fetchTogglesInterval;
             return this;
@@ -479,6 +504,7 @@ public class UnleashConfig {
                     contextProvider,
                     isProxyAuthenticationByJvmProperties,
                     synchronousFetchOnInitialisation,
+                    unleashFeatureFetcherFactory,
                     Optional.ofNullable(scheduledExecutor)
                             .orElseGet(UnleashScheduledExecutorImpl::getInstance),
                     Optional.ofNullable(unleashSubscriber).orElseGet(NoOpSubscriber::new),
diff --git a/src/main/java/io/getunleash/util/UnleashFeatureFetcherFactory.java b/src/main/java/io/getunleash/util/UnleashFeatureFetcherFactory.java
new file mode 100644
index 00000000..b3d74fe4
--- /dev/null
+++ b/src/main/java/io/getunleash/util/UnleashFeatureFetcherFactory.java
@@ -0,0 +1,6 @@
+package io.getunleash.util;
+
+import io.getunleash.repository.FeatureFetcher;
+import java.util.function.Function;
+
+public interface UnleashFeatureFetcherFactory extends Function<UnleashConfig, FeatureFetcher> {}
diff --git a/src/main/java/io/getunleash/util/UnleashProperties.java b/src/main/java/io/getunleash/util/UnleashProperties.java
new file mode 100644
index 00000000..8ed23b98
--- /dev/null
+++ b/src/main/java/io/getunleash/util/UnleashProperties.java
@@ -0,0 +1,24 @@
+package io.getunleash.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+public class UnleashProperties {
+    static Properties appProperties;
+
+    static {
+        try (InputStream is =
+                UnleashProperties.class.getClassLoader().getResourceAsStream("app.properties")) {
+            appProperties = new Properties();
+            appProperties.load(is);
+        } catch (IOException ioException) {
+            appProperties = new Properties();
+            appProperties.setProperty("client.specification.version", "4.2.0");
+        }
+    }
+
+    public static String getProperty(String propName) {
+        return appProperties.getProperty(propName);
+    }
+}
diff --git a/src/main/java/io/getunleash/util/UnleashURLs.java b/src/main/java/io/getunleash/util/UnleashURLs.java
index e8ff0fc4..e59244b6 100644
--- a/src/main/java/io/getunleash/util/UnleashURLs.java
+++ b/src/main/java/io/getunleash/util/UnleashURLs.java
@@ -48,7 +48,9 @@ public class UnleashURLs {
     }
 
     private void appendParam(StringBuilder suffix, String key, @Nullable String value) {
-        if (value == null) return;
+        if (value == null) {
+            return;
+        }
         if (suffix.length() == 0) {
             suffix.append("?");
         } else {
diff --git a/src/main/resources/app.properties b/src/main/resources/app.properties
new file mode 100644
index 00000000..965156a6
--- /dev/null
+++ b/src/main/resources/app.properties
@@ -0,0 +1,1 @@
+client.specification.version=${version.unleash.specification}


Test patch:
diff --git a/src/test/java/io/getunleash/repository/OkHttpFeatureFetcherTest.java b/src/test/java/io/getunleash/repository/OkHttpFeatureFetcherTest.java
new file mode 100644
index 00000000..04358ef9
--- /dev/null
+++ b/src/test/java/io/getunleash/repository/OkHttpFeatureFetcherTest.java
@@ -0,0 +1,365 @@
+package io.getunleash.repository;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.jenspiegsa.wiremockextension.ConfigureWireMock;
+import com.github.jenspiegsa.wiremockextension.InjectServer;
+import com.github.jenspiegsa.wiremockextension.WireMockExtension;
+import com.github.jenspiegsa.wiremockextension.WireMockSettings;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.Options;
+import io.getunleash.FeatureToggle;
+import io.getunleash.util.UnleashConfig;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@ExtendWith(WireMockExtension.class)
+@WireMockSettings(failOnUnmatchedRequests = false)
+public class OkHttpFeatureFetcherTest {
+    @ConfigureWireMock Options options = wireMockConfig().dynamicPort();
+
+    @InjectServer WireMockServer serverMock;
+
+    @Test
+    public void happy_path_test_version0() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v0.json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        ClientFeaturesResponse response = okHttpToggleFetcher.fetchFeatures();
+        FeatureToggle featureX = response.getToggleCollection().getToggle("featureX");
+
+        assertThat(featureX.isEnabled()).isTrue();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void happy_path_test_version1() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v1.json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+        FeatureToggle featureX = response.getToggleCollection().getToggle("featureX");
+
+        assertThat(featureX.isEnabled()).isTrue();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void happy_path_test_version_with_variants() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v1-with-variants.json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+        FeatureToggle featureX = response.getToggleCollection().getToggle("Test.variants");
+
+        assertThat(featureX.isEnabled()).isTrue();
+        assertThat(featureX.getVariants().get(0).getName()).isEqualTo("variant1");
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void should_include_etag_in_second_request() throws URISyntaxException {
+        // First fetch
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withHeader("ETag", "AZ12")
+                                        .withBodyFile("features-v1-with-variants.json")));
+
+        // Second fetch
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("If-None-Match", equalTo("AZ12--gzip"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(304)
+                                        .withHeader("Content-Type", "application/json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+
+        FeatureToggleResponse response1 = okHttpToggleFetcher.fetchFeatures();
+        FeatureToggleResponse response2 = okHttpToggleFetcher.fetchFeatures();
+        verify(
+                1,
+                getRequestedFor(urlEqualTo("/api/client/features")).withoutHeader("If-None-Match"));
+        verify(
+                1,
+                getRequestedFor(urlEqualTo("/api/client/features"))
+                        .withHeader("If-None-Match", equalTo("AZ12--gzip")));
+        assertThat(response1.getStatus()).isEqualTo(FeatureToggleResponse.Status.CHANGED);
+        assertThat(response2.getStatus()).isEqualTo(FeatureToggleResponse.Status.NOT_CHANGED);
+    }
+
+    @Test
+    @ExtendWith(UnleashExceptionExtension.class)
+    public void given_empty_body() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        okHttpToggleFetcher.fetchFeatures();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    @ExtendWith(UnleashExceptionExtension.class)
+    public void given_json_without_feature_field() throws Exception {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBody("{}")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        okHttpToggleFetcher.fetchFeatures();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void should_handle_not_changed() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(304)
+                                        .withHeader("Content-Type", "application/json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+        assertThat(response.getStatus()).isEqualTo(FeatureToggleResponse.Status.NOT_CHANGED);
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            ints = {
+                HttpURLConnection.HTTP_MOVED_PERM,
+                HttpURLConnection.HTTP_MOVED_TEMP,
+                HttpURLConnection.HTTP_SEE_OTHER
+            })
+    public void should_handle_redirect(int responseCode) throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(responseCode)
+                                        .withHeader(
+                                                "Location",
+                                                "http://localhost:"
+                                                        + serverMock.port()
+                                                        + "/api/v2/client/features")));
+        stubFor(
+                get(urlEqualTo("/api/v2/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(HttpURLConnection.HTTP_OK)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v1.json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+        assertThat(response.getStatus()).isEqualTo(FeatureToggleResponse.Status.CHANGED);
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+        verify(
+                getRequestedFor(urlMatching("/api/v2/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {400, 401, 403, 404, 500, 503})
+    public void should_handle_errors(int httpCode) throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(httpCode)
+                                        .withHeader("Content-Type", "application/json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+        assertThat(response.getStatus()).isEqualTo(FeatureToggleResponse.Status.UNAVAILABLE);
+        assertThat(response.getHttpStatusCode()).isEqualTo(httpCode);
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void should_not_set_empty_ifNoneMatchHeader() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v1.json")));
+
+        URI uri = new URI("http://localhost:" + serverMock.port() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher okHttpToggleFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpToggleFetcher.fetchFeatures();
+
+        verify(getRequestedFor(urlMatching("/api/client/features")).withoutHeader("If-None-Match"));
+    }
+
+    @Test
+    public void should_add_project_filter_to_toggles_url_if_config_has_it_set()
+            throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features?project=name"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v1.json")));
+        URI uri = new URI(serverMock.baseUrl() + "/api/");
+        UnleashConfig config =
+                UnleashConfig.builder().appName("test").unleashAPI(uri).projectName("name").build();
+        OkHttpFeatureFetcher okHttpFeatureFetcher = new OkHttpFeatureFetcher(config);
+        FeatureToggleResponse response = okHttpFeatureFetcher.fetchFeatures();
+        verify(getRequestedFor(urlMatching("/api/client/features\\?project=name")));
+    }
+
+    @Test
+    public void happy_path_test_with_variants_and_segments() throws URISyntaxException {
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v2-with-segments.json")));
+        URI uri = new URI(serverMock.baseUrl() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        OkHttpFeatureFetcher fetcher = new OkHttpFeatureFetcher(config);
+        ClientFeaturesResponse response = fetcher.fetchFeatures();
+        FeatureToggle featureX = response.getToggleCollection().getToggle("featureX");
+
+        assertThat(featureX.isEnabled()).isTrue();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+
+    @Test
+    public void should_include_client_specification_version_in_header() throws URISyntaxException {
+        URI uri = new URI(serverMock.baseUrl() + "/api/");
+        UnleashConfig config = UnleashConfig.builder().appName("test").unleashAPI(uri).build();
+        stubFor(
+                get(urlEqualTo("/api/client/features"))
+                        .withHeader("Accept", equalTo("application/json"))
+                        .withHeader(
+                                "Unleash-Client-Spec",
+                                equalTo(config.getClientSpecificationVersion()))
+                        .willReturn(
+                                aResponse()
+                                        .withStatus(200)
+                                        .withHeader("Content-Type", "application/json")
+                                        .withBodyFile("features-v2-with-segments.json")));
+        OkHttpFeatureFetcher fetcher = new OkHttpFeatureFetcher(config);
+        ClientFeaturesResponse response = fetcher.fetchFeatures();
+        FeatureToggle featureX = response.getToggleCollection().getToggle("featureX");
+
+        assertThat(featureX.isEnabled()).isTrue();
+
+        verify(
+                getRequestedFor(urlMatching("/api/client/features"))
+                        .withHeader("Content-Type", matching("application/json")));
+    }
+}
diff --git a/src/test/java/io/getunleash/util/IpAddressMatcherTest.java b/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
index 8b2bf5cb..f9d48f37 100644
--- a/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
+++ b/src/test/java/io/getunleash/util/IpAddressMatcherTest.java
@@ -19,9 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 import org.junit.jupiter.api.Test;
 
-/**
- * @author Luke Taylor
- */
+/** @author Luke Taylor */
 class IpAddressMatcherTest {
     private final IpAddressMatcher v6matcher = new IpAddressMatcher("fe80::21f:5bff:fe33:bd68");
     private final IpAddressMatcher v4matcher = new IpAddressMatcher("192.168.1.104");
diff --git a/src/test/resources/log4j2.xml b/src/test/resources/log4j2.xml
index f40400e4..9b9061b3 100644
--- a/src/test/resources/log4j2.xml
+++ b/src/test/resources/log4j2.xml
@@ -10,4 +10,4 @@
             <AppenderRef ref="Console"/>
         </Root>
     </Loggers>
-</Configuration>
\ No newline at end of file
+</Configuration>