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();