diff --git a/README.md b/README.md
index 9efa465..9383589 100644
--- a/README.md
+++ b/README.md
@@ -22,12 +22,12 @@ The Octopus OpenFeature provider for Java is available as a [Maven package](http
com.octopus.openfeature
octopus-openfeature-provider
- 0.2.0
+ 1.0.0
```
```groovy
-implementation group: 'com.octopus.openfeature', name: 'octopus-openfeature-provider', version: '0.2.0'
+implementation group: 'com.octopus.openfeature', name: 'octopus-openfeature-provider', version: '1.0.0'
// Use current version number
```
@@ -38,12 +38,19 @@ import dev.openfeature.sdk.*;
import com.octopus.openfeature.provider.*;
public class Main {
-
+
public static void main(String[] args) {
var openFeature = OpenFeatureAPI.getInstance();
- openFeature.setProviderAndWait(new OctopusProvider(new OctopusConfiguration("Your Octopus client identifier")));
- var openFeatureClient = openFeature.getClient();
-
+ openFeature.setProviderAndWait(
+ new OctopusProvider(
+ new OctopusConfiguration(
+ "Your Octopus client identifier",
+ new ProductMetadata("YourProductName", "1.0.0")
+ )
+ )
+ );
+ var openFeatureClient = openFeature.getClient();
+
var darkModeIsEnabled = openFeatureClient.getBooleanValue("dark-mode", false);
}
}
diff --git a/pom.xml b/pom.xml
index b501afd..33acdb0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -40,6 +40,12 @@
+
+
+ src/main/resources
+ true
+
+
org.sonatype.central
diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java
index 96b1963..fecebba 100644
--- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java
+++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java
@@ -1,6 +1,8 @@
package com.octopus.openfeature.provider;
import com.fasterxml.jackson.core.type.TypeReference;
+
+import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
@@ -8,30 +10,50 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.Arrays;
-import java.util.Base64;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
class OctopusClient {
private final OctopusConfiguration config;
private static final System.Logger logger = System.getLogger(OctopusClient.class.getName());
private static final int StatusCodeNotFound = 404;
+ private static final String PROVIDER_VERSION = loadProviderVersion();
+
+ private static String loadProviderVersion() {
+ try {
+ var projectProperties = new Properties();
+ try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties"))
+ {
+ if(resourceStream == null) {
+ logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.");
+ return null;
+ }
+
+ projectProperties.load(resourceStream);
+ }
+
+ return projectProperties.getProperty("version");
+ } catch (IOException e) {
+ logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e);
+ return null;
+ }
+ }
- OctopusClient(OctopusConfiguration config){
+ OctopusClient(OctopusConfiguration config) {
this.config = config;
}
-
- Boolean haveFeatureTogglesChanged(byte[] contentHash)
- {
- if (contentHash.length == 0) { return true; }
- URI checkURI = getCheckURI();
+
+ Boolean haveFeatureTogglesChanged(byte[] contentHash) {
+ if (contentHash.length == 0) {
+ return true;
+ }
+ URI checkURI = getCheckURI();
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.GET()
.uri(checkURI)
.header("Authorization", String.format("Bearer %s", config.getClientIdentifier()))
+ .header("X-Octopus-Client", buildOctopusClientHeaderValue())
.build();
try {
HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString());
@@ -43,53 +65,65 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash)
return false;
}
}
-
- FeatureToggles getFeatureToggleEvaluationManifest()
- {
+
+ FeatureToggles getFeatureToggleEvaluationManifest() {
URI manifestURI = getManifestURI();
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.GET()
.uri(manifestURI)
.header("Authorization", String.format("Bearer %s", config.getClientIdentifier()))
+ .header("X-Octopus-Client", buildOctopusClientHeaderValue())
.build();
try {
HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString());
if (httpResponse.statusCode() == StatusCodeNotFound) {
- logger.log(System.Logger.Level.WARNING,String.format("Failed to retrieve feature toggles for client identifier %s from %s", config.getClientIdentifier(), manifestURI.toString()));
- return null;
+ logger.log(System.Logger.Level.WARNING, String.format("Failed to retrieve feature toggles for client identifier %s from %s", config.getClientIdentifier(), manifestURI.toString()));
+ return null;
}
Optional contentHashHeader = httpResponse.headers().firstValue("ContentHash");
if (contentHashHeader.isEmpty()) {
- logger.log(System.Logger.Level.WARNING,String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString()));
+ logger.log(System.Logger.Level.WARNING, String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString()));
return null;
}
- var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference>(){});
+ var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference>() {});
return new FeatureToggles(evaluations, Base64.getDecoder().decode(contentHashHeader.get()));
} catch (Exception e) {
logger.log(System.Logger.Level.WARNING, "Unable to query Octopus Feature Toggle service", e);
return null;
}
}
-
+
+ String buildOctopusClientHeaderValue() {
+ var clientHeaderValueBuilder = new StringBuilder(this.config.getProductMetadata().getName());
+
+ this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s));
+
+ clientHeaderValueBuilder.append(" openfeature-provider-java/").append(PROVIDER_VERSION);
+
+ return clientHeaderValueBuilder.toString();
+ }
+
private URI getCheckURI() {
try {
return new URL(config.getServerUri().toURL(), "/api/featuretoggles/check/v3/").toURI();
} catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed
- { }
+ {
+ }
return null;
}
-
+
private URI getManifestURI() {
try {
return new URL(config.getServerUri().toURL(), "/api/toggles/evaluations/v3/").toURI();
} catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed
- { }
+ {
+ }
return null;
}
-
+
// This class needs to be static to allow deserialization
private static class FeatureToggleCheckResponse {
- public byte[] contentHash;
- }
+ public byte[] contentHash;
+ }
}
diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
index f9ab3b2..2129fd4 100644
--- a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
+++ b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
@@ -2,19 +2,25 @@
import java.net.URI;
import java.time.Duration;
+import java.util.Objects;
public class OctopusConfiguration {
private final String clientIdentifier;
+ private final ProductMetadata productMetadata;
+
private static final URI DEFAULT_SERVER_URI = URI.create("https://features.octopus.com");
private URI serverUri = DEFAULT_SERVER_URI;
private Duration cacheDuration = Duration.ofMinutes(1);
- public OctopusConfiguration(String clientIdentifier) {
- this.clientIdentifier = clientIdentifier;
+ public OctopusConfiguration(String clientIdentifier, ProductMetadata productMetadata) {
+ this.clientIdentifier = Objects.requireNonNull(clientIdentifier);
+ this.productMetadata = Objects.requireNonNull(productMetadata);
}
public String getClientIdentifier() { return clientIdentifier; }
+ public ProductMetadata getProductMetadata() { return productMetadata; }
+
public URI getServerUri() { return serverUri; }
// Note: package-private by default. Visible to tests in same package, but not to library consumers.
diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java b/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java
index 3ff162c..6a28bc3 100644
--- a/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java
+++ b/src/main/java/com/octopus/openfeature/provider/OctopusProvider.java
@@ -3,7 +3,7 @@
import dev.openfeature.sdk.*;
public class OctopusProvider extends EventProvider {
- private static final String PROVIDER_NAME = "octopus";
+ private static final String PROVIDER_NAME = "octopus-java-provider";
private final OctopusConfiguration config;
private final OctopusContextProvider contextProvider;
diff --git a/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java b/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java
new file mode 100644
index 0000000..7f65b4b
--- /dev/null
+++ b/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java
@@ -0,0 +1,56 @@
+package com.octopus.openfeature.provider;
+
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+public class ProductMetadata {
+
+ // https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens
+ private static final Pattern UNSUPPORTED_CHARS = Pattern.compile("[^a-zA-Z0-9!#$%&'*+\\-.^_`|~]");
+
+ private final String name;
+ private final String version;
+
+ public ProductMetadata(String name) {
+ this.name = clean(name);
+ this.version = null;
+
+ validateName();
+ }
+
+ public ProductMetadata(String name, String version) {
+ this.name = clean(name);
+ this.version = clean(version);
+
+ validateName();
+ validateVersion();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Optional getVersion() {
+ return Optional.ofNullable(version);
+ }
+
+ private String clean(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+
+ return UNSUPPORTED_CHARS.matcher(value).replaceAll("");
+ }
+
+ private void validateName() {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Product name must contain at least one valid token character.");
+ }
+ }
+
+ private void validateVersion() {
+ if (version == null || version.isBlank()) {
+ throw new IllegalArgumentException("Product version must contain at least one valid token character.");
+ }
+ }
+}
diff --git a/src/main/resources/project.properties b/src/main/resources/project.properties
new file mode 100644
index 0000000..e5683df
--- /dev/null
+++ b/src/main/resources/project.properties
@@ -0,0 +1 @@
+version=${project.version}
\ No newline at end of file
diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java
new file mode 100644
index 0000000..a80dfb9
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java
@@ -0,0 +1,58 @@
+package com.octopus.openfeature.provider;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OctopusClientTests {
+
+ private static final String PROVIDER_VERSION = loadProviderVersion();
+
+ private static String loadProviderVersion() {
+ try {
+ var projectProperties = new Properties();
+ try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties"))
+ {
+ projectProperties.load(resourceStream);
+ }
+
+ var version = projectProperties.getProperty("version");
+ assertThat(version).matches("\\d+.*"); // Ensure property filtering is working.
+ return version;
+ } catch (IOException e) {
+ throw new RuntimeException("Could not load project.properties.", e);
+ }
+ }
+
+ @Test
+ void buildOctopusClientHeaderValue_withNameOnly_headerValueContainsProductNameAndProviderInformation() {
+ var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct"));
+ var client = new OctopusClient(config);
+
+ assertThat(client.buildOctopusClientHeaderValue())
+ .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION);
+ }
+
+ @Test
+ void buildOctopusClientHeaderValue_withNameAndVersion_headerValueContainsProductAndProviderInformation() {
+ var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct", "2024.1.0"));
+ var client = new OctopusClient(config);
+
+ assertThat(client.buildOctopusClientHeaderValue())
+ .isEqualTo("MyProduct/2024.1.0 openfeature-provider-java/" + PROVIDER_VERSION);
+ }
+
+ @Test
+ void buildOctopusClientHeaderValue_withNameContainingUnsupportedChars_stripsCharsFromHeaderValue() {
+ // Note: More character checking tests are in ProductMetadataTests.java
+
+ var config = new OctopusConfiguration("test-id", new ProductMetadata("My Product"));
+ var client = new OctopusClient(config);
+
+ assertThat(client.buildOctopusClientHeaderValue())
+ .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION);
+ }
+}
diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java
index aa17d27..8c5d850 100644
--- a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java
+++ b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java
@@ -5,18 +5,31 @@
import java.net.URI;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OctopusConfigurationTests {
+ @Test
+ void constructor_whenNullClientIdentifierProvided_throwsNullPointerException() {
+ assertThatThrownBy(() -> new OctopusConfiguration(null, new ProductMetadata("TestClient")))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void constructor_whenNullProductMetadataProvided_throwsNullPointerException() {
+ assertThatThrownBy(() -> new OctopusConfiguration("test-client", null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
@Test
void defaultServerUriIsOctopusCloud() {
- var config = new OctopusConfiguration("test-client");
+ var config = new OctopusConfiguration("test-client", new ProductMetadata("TestClient"));
assertThat(config.getServerUri()).isEqualTo(URI.create("https://features.octopus.com"));
}
@Test
void serverUriCanBeOverridden() {
- var config = new OctopusConfiguration("test-client");
+ var config = new OctopusConfiguration("test-client", new ProductMetadata("TestClient"));
var customUri = URI.create("http://localhost:8080");
config.setServerUri(customUri);
assertThat(config.getServerUri()).isEqualTo(customUri);
diff --git a/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java b/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java
new file mode 100644
index 0000000..000efde
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java
@@ -0,0 +1,80 @@
+package com.octopus.openfeature.provider;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ProductMetadataTests {
+
+ @Test
+ void constructor_withValidNameChars_setsNameUnchanged() {
+ var metadata = new ProductMetadata("OctopusDeploy");
+
+ assertThat(metadata.getName()).isEqualTo("OctopusDeploy");
+ }
+
+ @Test
+ void constructor_withCommonUnsupportedCharsInName_stripsThemOut() {
+ // Characters that may be used but are not RFC 9110 tchars
+ var metadata = new ProductMetadata("My ,Product (v2.0)/release@2024:final");
+
+ assertThat(metadata.getName()).isEqualTo("MyProductv2.0release2024final");
+ }
+
+ @Test
+ void constructor_withHyphenInName_preservesIt() {
+ var metadata = new ProductMetadata("My-Product");
+
+ assertThat(metadata.getName()).isEqualTo("My-Product");
+ }
+
+ @Test
+ void constructor_whenNoVersionProvided_setsEmpty() {
+ var metadata = new ProductMetadata("MyProduct");
+
+ assertThat(metadata.getVersion()).isEmpty();
+ }
+
+ @Test
+ void constructor_withValidCharsInVersion_setsVersionUnchanged() {
+ var metadata = new ProductMetadata("MyProduct", "2024.1.0");
+
+ assertThat(metadata.getVersion()).get().isEqualTo("2024.1.0");
+ }
+
+ @Test
+ void constructor_withUnsupportedCharsInVersion_stripsThemOut() {
+ var metadata = new ProductMetadata("MyProduct", "2024.1 (beta)");
+
+ assertThat(metadata.getVersion()).get().isEqualTo("2024.1beta");
+ }
+
+ @Test
+ void constructor_whenNullNameProvided_throwsIllegalArgumentException() {
+ assertThatThrownBy(() -> new ProductMetadata(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Product name");
+ }
+
+ @Test
+ void constructor_whenNullVersionProvided_throwsIllegalArgumentException() {
+ assertThatThrownBy(() -> new ProductMetadata("MyProduct", null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Product version");
+ }
+
+ @Test
+ void constructor_whenNameBecomesEmptyAfterCleaning_throwsIllegalArgumentException() {
+ assertThatThrownBy(() -> new ProductMetadata(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Product name");
+ }
+
+ @Test
+ void constructor_whenVersionBecomesEmptyAfterCleaning_throwsIllegalArgumentException() {
+ assertThatThrownBy(() -> new ProductMetadata("MyProduct", " "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Product version");
+ }
+}
diff --git a/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
index e6fe337..1316a03 100644
--- a/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
+++ b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
@@ -51,7 +51,7 @@ void shutdownApi() {
@MethodSource("fixtureTestCases")
void evaluate(String fileName, String description, String responseJson, FixtureCase testCase) {
String token = server.configure(responseJson);
- OctopusConfiguration config = new OctopusConfiguration(token);
+ OctopusConfiguration config = new OctopusConfiguration(token, new ProductMetadata("TestClient"));
config.setServerUri(URI.create(server.baseUrl()));
OpenFeatureAPI api = OpenFeatureAPI.getInstance();