From 97eae6cd048d52a3e8697a07c6517c9268ba8827 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 14:19:14 +1000 Subject: [PATCH 01/16] Add ProductMetadata and tests --- .../openfeature/provider/ProductMetadata.java | 56 ++++++++++++++++ .../provider/ProductMetadataTests.java | 66 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/main/java/com/octopus/openfeature/provider/ProductMetadata.java create mode 100644 src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java 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..9ef682e --- /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.isEmpty()) { + throw new IllegalArgumentException("Product name must contain at least one valid token character."); + } + } + + private void validateVersion() { + if (version == null || version.isEmpty()) { + throw new IllegalArgumentException("Product version must contain at least one valid token character."); + } + } +} 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..874ca61 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java @@ -0,0 +1,66 @@ +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_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"); + } +} From ffb9d7c18ff966037b49d722b12bb753a61d8a5e Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 14:34:39 +1000 Subject: [PATCH 02/16] Add ProductMetadata to OctopusConfiguration --- .../octopus/openfeature/provider/OctopusConfiguration.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java index f9ab3b2..14398a8 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java @@ -5,15 +5,18 @@ 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) { + public OctopusConfiguration(String clientIdentifier, ProductMetadata productMetadata) { this.clientIdentifier = clientIdentifier; + this.productMetadata = productMetadata; } public String getClientIdentifier() { return clientIdentifier; } + public ProductMetadata getProductMetadata() { return productMetadata; } public URI getServerUri() { return serverUri; } From 5ac96339ba680a8db901de401f4928121e7642d5 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 14:34:55 +1000 Subject: [PATCH 03/16] Build and set header on client calls --- .../openfeature/provider/OctopusClient.java | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index 96b1963..4ac1979 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -1,6 +1,7 @@ package com.octopus.openfeature.provider; import com.fasterxml.jackson.core.type.TypeReference; + import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -19,19 +20,21 @@ class OctopusClient { private static final System.Logger logger = System.getLogger(OctopusClient.class.getName()); private static final int StatusCodeNotFound = 404; - 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", buildOctopusClientHeader()) .build(); try { HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -43,53 +46,66 @@ 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", buildOctopusClientHeader()) .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 buildOctopusClientHeader() { + var clientHeaderValueBuilder = new StringBuilder(this.config.getProductMetadata().getName()); + + this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s)); + + clientHeaderValueBuilder.append(" openfeature-provider-java/").append("foo"); + + 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; + } } From 3deb1ce318670ce780f1268cc8f41a815a3e6f26 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 14:59:50 +1000 Subject: [PATCH 04/16] Fix tests build --- .../openfeature/provider/OctopusConfigurationTests.java | 4 ++-- .../com/octopus/openfeature/provider/SpecificationTests.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java index aa17d27..4ccfb1b 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java @@ -10,13 +10,13 @@ class OctopusConfigurationTests { @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/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(); From 342bdba753ddc2b7632d0e2d1d283a93bcaf78b4 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:00:24 +1000 Subject: [PATCH 05/16] Set provider version in client header value --- pom.xml | 6 ++ .../openfeature/provider/OctopusClient.java | 17 ++++-- src/main/resources/project.properties | 1 + .../provider/OctopusClientTests.java | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/project.properties create mode 100644 src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java 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 4ac1979..32d53e9 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; +import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; @@ -9,10 +10,7 @@ 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 { @@ -81,7 +79,16 @@ String buildOctopusClientHeader() { this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s)); - clientHeaderValueBuilder.append(" openfeature-provider-java/").append("foo"); + String providerVersion = null; + try { + var projectProperties = new Properties(); + projectProperties.load(this.getClass().getClassLoader().getResourceAsStream("project.properties")); + providerVersion = projectProperties.getProperty("version"); + } catch (IOException e) { + logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); + } + + clientHeaderValueBuilder.append(" openfeature-provider-java/").append(providerVersion); return clientHeaderValueBuilder.toString(); } 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..5f9a93a --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java @@ -0,0 +1,55 @@ +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 props = new Properties(); + props.load(OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")); + + var version = props.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 buildOctopusClientHeader_withNameOnly_headerContainsProductNameAndProviderInformation() { + var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct")); + var client = new OctopusClient(config); + + assertThat(client.buildOctopusClientHeader()) + .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); + } + + @Test + void buildOctopusClientHeader_withNameAndVersion_headerContainsProductAndProviderInformation() { + var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct", "2024.1.0")); + var client = new OctopusClient(config); + + assertThat(client.buildOctopusClientHeader()) + .isEqualTo("MyProduct/2024.1.0 openfeature-provider-java/" + PROVIDER_VERSION); + } + + @Test + void buildOctopusClientHeader_withNameContainingUnsupportedChars_stripsCharsFromHeader() { + // 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.buildOctopusClientHeader()) + .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); + } +} From 759aafdf45439eb0969508646bfc8330dd595cf5 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:38:30 +1000 Subject: [PATCH 06/16] Self review --- .../octopus/openfeature/provider/OctopusClient.java | 9 ++++----- .../openfeature/provider/OctopusConfiguration.java | 2 ++ .../openfeature/provider/ProductMetadata.java | 4 ++-- .../openfeature/provider/OctopusClientTests.java | 12 ++++++------ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index 32d53e9..1fbcadd 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -32,7 +32,7 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash) { .GET() .uri(checkURI) .header("Authorization", String.format("Bearer %s", config.getClientIdentifier())) - .header("X-Octopus-Client", buildOctopusClientHeader()) + .header("X-Octopus-Client", buildOctopusClientHeaderValue()) .build(); try { HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -52,7 +52,7 @@ FeatureToggles getFeatureToggleEvaluationManifest() { .GET() .uri(manifestURI) .header("Authorization", String.format("Bearer %s", config.getClientIdentifier())) - .header("X-Octopus-Client", buildOctopusClientHeader()) + .header("X-Octopus-Client", buildOctopusClientHeaderValue()) .build(); try { HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -65,8 +65,7 @@ FeatureToggles getFeatureToggleEvaluationManifest() { 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); @@ -74,7 +73,7 @@ FeatureToggles getFeatureToggleEvaluationManifest() { } } - String buildOctopusClientHeader() { + String buildOctopusClientHeaderValue() { var clientHeaderValueBuilder = new StringBuilder(this.config.getProductMetadata().getName()); this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s)); diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java index 14398a8..a428180 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java @@ -6,6 +6,7 @@ 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); @@ -16,6 +17,7 @@ public OctopusConfiguration(String clientIdentifier, ProductMetadata productMeta } public String getClientIdentifier() { return clientIdentifier; } + public ProductMetadata getProductMetadata() { return productMetadata; } public URI getServerUri() { return serverUri; } diff --git a/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java b/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java index 9ef682e..7f65b4b 100644 --- a/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java +++ b/src/main/java/com/octopus/openfeature/provider/ProductMetadata.java @@ -43,13 +43,13 @@ private String clean(String value) { } private void validateName() { - if (name == null || name.isEmpty()) { + 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.isEmpty()) { + if (version == null || version.isBlank()) { throw new IllegalArgumentException("Product version must contain at least one valid token character."); } } diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java index 5f9a93a..8dfaead 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java @@ -25,31 +25,31 @@ private static String loadProviderVersion() { } @Test - void buildOctopusClientHeader_withNameOnly_headerContainsProductNameAndProviderInformation() { + void buildOctopusClientHeaderValue_withNameOnly_headerValueContainsProductNameAndProviderInformation() { var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct")); var client = new OctopusClient(config); - assertThat(client.buildOctopusClientHeader()) + assertThat(client.buildOctopusClientHeaderValue()) .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); } @Test - void buildOctopusClientHeader_withNameAndVersion_headerContainsProductAndProviderInformation() { + void buildOctopusClientHeaderValue_withNameAndVersion_headerValueContainsProductAndProviderInformation() { var config = new OctopusConfiguration("test-id", new ProductMetadata("MyProduct", "2024.1.0")); var client = new OctopusClient(config); - assertThat(client.buildOctopusClientHeader()) + assertThat(client.buildOctopusClientHeaderValue()) .isEqualTo("MyProduct/2024.1.0 openfeature-provider-java/" + PROVIDER_VERSION); } @Test - void buildOctopusClientHeader_withNameContainingUnsupportedChars_stripsCharsFromHeader() { + 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.buildOctopusClientHeader()) + assertThat(client.buildOctopusClientHeaderValue()) .isEqualTo("MyProduct openfeature-provider-java/" + PROVIDER_VERSION); } } From 1092857e310fbd5e3f9608e18b2934b8f6290761 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:44:13 +1000 Subject: [PATCH 07/16] Validate OctopusConfiguration constructor arguments --- .../octopus/openfeature/provider/OctopusConfiguration.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java index a428180..2129fd4 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java @@ -2,6 +2,7 @@ import java.net.URI; import java.time.Duration; +import java.util.Objects; public class OctopusConfiguration { private final String clientIdentifier; @@ -12,8 +13,8 @@ public class OctopusConfiguration { private Duration cacheDuration = Duration.ofMinutes(1); public OctopusConfiguration(String clientIdentifier, ProductMetadata productMetadata) { - this.clientIdentifier = clientIdentifier; - this.productMetadata = productMetadata; + this.clientIdentifier = Objects.requireNonNull(clientIdentifier); + this.productMetadata = Objects.requireNonNull(productMetadata); } public String getClientIdentifier() { return clientIdentifier; } From 406f4c77900e93202857a7c63aee0c7640713452 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:44:45 +1000 Subject: [PATCH 08/16] Add null value tests for ProductMetadata constructor --- .../openfeature/provider/ProductMetadataTests.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java b/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java index 874ca61..000efde 100644 --- a/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java +++ b/src/test/java/com/octopus/openfeature/provider/ProductMetadataTests.java @@ -50,6 +50,20 @@ void constructor_withUnsupportedCharsInVersion_stripsThemOut() { 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(" ")) From 2b278c3f30212aa8a612b9dca3e04d198371b9cf Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:46:56 +1000 Subject: [PATCH 09/16] Catch NullPointerExceptions --- .../java/com/octopus/openfeature/provider/OctopusClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index 1fbcadd..eadee58 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -83,7 +83,7 @@ String buildOctopusClientHeaderValue() { var projectProperties = new Properties(); projectProperties.load(this.getClass().getClassLoader().getResourceAsStream("project.properties")); providerVersion = projectProperties.getProperty("version"); - } catch (IOException e) { + } catch (IOException | NullPointerException e) { logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); } From 2c87c474e6d0d395a2d6ea941600365ca5de9b04 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:50:38 +1000 Subject: [PATCH 10/16] Load PROVIDER_VERSION statically --- .../openfeature/provider/OctopusClient.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index eadee58..eae6abc 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -17,6 +17,18 @@ 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(); + projectProperties.load(OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")); + return projectProperties.getProperty("version"); + } catch (IOException | NullPointerException e) { + logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); + return null; + } + } OctopusClient(OctopusConfiguration config) { this.config = config; @@ -78,16 +90,7 @@ String buildOctopusClientHeaderValue() { this.config.getProductMetadata().getVersion().ifPresent(s -> clientHeaderValueBuilder.append("/").append(s)); - String providerVersion = null; - try { - var projectProperties = new Properties(); - projectProperties.load(this.getClass().getClassLoader().getResourceAsStream("project.properties")); - providerVersion = projectProperties.getProperty("version"); - } catch (IOException | NullPointerException e) { - logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); - } - - clientHeaderValueBuilder.append(" openfeature-provider-java/").append(providerVersion); + clientHeaderValueBuilder.append(" openfeature-provider-java/").append(PROVIDER_VERSION); return clientHeaderValueBuilder.toString(); } From 6ac31217b457574e1f4371081c549f1091c28806 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 15:57:03 +1000 Subject: [PATCH 11/16] Add OctopusConfiguration constructor null check tests --- .../provider/OctopusConfigurationTests.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java index 4ccfb1b..8c5d850 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java @@ -5,9 +5,22 @@ 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", new ProductMetadata("TestClient")); From 90102dc0eca8d70439ef00c1d56d4658faa18925 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 16:03:24 +1000 Subject: [PATCH 12/16] try-with-resources for resource stream --- .../octopus/openfeature/provider/OctopusClient.java | 6 +++++- .../openfeature/provider/OctopusClientTests.java | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index eae6abc..72d824a 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -22,7 +22,11 @@ class OctopusClient { private static String loadProviderVersion() { try { var projectProperties = new Properties(); - projectProperties.load(OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")); + try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")) + { + projectProperties.load(resourceStream); + } + return projectProperties.getProperty("version"); } catch (IOException | NullPointerException e) { logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java index 8dfaead..e9d3062 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java @@ -13,13 +13,16 @@ class OctopusClientTests { private static String loadProviderVersion() { try { - var props = new Properties(); - props.load(OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")); + var projectProperties = new Properties(); + try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties")) + { + projectProperties.load(resourceStream); + } - var version = props.getProperty("version"); + var version = projectProperties.getProperty("version"); assertThat(version).matches("\\d+.*"); // Ensure property filtering is working. return version; - } catch (IOException e) { + } catch (IOException | NullPointerException e) { throw new RuntimeException("Could not load project.properties", e); } } From ce2a7fb452197415fa1e72fb5c93b86e39330700 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 16:09:30 +1000 Subject: [PATCH 13/16] More explicit null handling --- .../com/octopus/openfeature/provider/OctopusClient.java | 7 ++++++- src/main/resources/project.properties | 2 +- .../octopus/openfeature/provider/OctopusClientTests.java | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index 72d824a..fecebba 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -24,11 +24,16 @@ private static String loadProviderVersion() { 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 | NullPointerException e) { + } catch (IOException e) { logger.log(System.Logger.Level.WARNING, "Unable to load project properties to determine provider version.", e); return null; } diff --git a/src/main/resources/project.properties b/src/main/resources/project.properties index e5683df..defbd48 100644 --- a/src/main/resources/project.properties +++ b/src/main/resources/project.properties @@ -1 +1 @@ -version=${project.version} \ No newline at end of file +version=${project.version} diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java index e9d3062..a80dfb9 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusClientTests.java @@ -22,8 +22,8 @@ private static String loadProviderVersion() { var version = projectProperties.getProperty("version"); assertThat(version).matches("\\d+.*"); // Ensure property filtering is working. return version; - } catch (IOException | NullPointerException e) { - throw new RuntimeException("Could not load project.properties", e); + } catch (IOException e) { + throw new RuntimeException("Could not load project.properties.", e); } } From 281994ab78b4489525f90d712f08d1374cc42d43 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Mon, 25 May 2026 16:18:38 +1000 Subject: [PATCH 14/16] New line seems to break it --- src/main/resources/project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/project.properties b/src/main/resources/project.properties index defbd48..e5683df 100644 --- a/src/main/resources/project.properties +++ b/src/main/resources/project.properties @@ -1 +1 @@ -version=${project.version} +version=${project.version} \ No newline at end of file From 8ed41d8f118fb7584bf761b0b77cf61ca5565821 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 08:23:12 +1000 Subject: [PATCH 15/16] Align provider name between libraries --- .../java/com/octopus/openfeature/provider/OctopusProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 72fab1286d0a103a5d78516173c5d0b3832b81cd Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 09:53:23 +1000 Subject: [PATCH 16/16] Update README.md --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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); } }