Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ The Octopus OpenFeature provider for Java is available as a [Maven package](http
<dependency>
<groupId>com.octopus.openfeature</groupId>
<artifactId>octopus-openfeature-provider</artifactId>
<version>0.2.0</version> <!-- use current version number -->
<version>1.0.0</version> <!-- use current version number -->
</dependency>
```

```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
```

Expand All @@ -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);
}
}
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
</scm>

<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
Comment thread
liamhughes marked this conversation as resolved.
</resources>
<plugins>
<plugin>
<groupId>org.sonatype.central</groupId>
Expand Down
82 changes: 58 additions & 24 deletions src/main/java/com/octopus/openfeature/provider/OctopusClient.java
Original file line number Diff line number Diff line change
@@ -1,37 +1,59 @@
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;
import java.net.URL;
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"))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this try is a try-with-resources block; equivalent to C# using(var ...)

{
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();
Comment thread
liamhughes marked this conversation as resolved.
try {
HttpResponse<String> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString());
Expand All @@ -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<String> 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<String> 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<List<FeatureToggleEvaluation>>(){});
var evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>() {});
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
liamhughes marked this conversation as resolved.

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an aside, I am aligning these between the three libraries.

https://openfeature.dev/specification/sections/providers/#requirement-211

private final OctopusConfiguration config;
private final OctopusContextProvider contextProvider;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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.");
}
}
}
1 change: 1 addition & 0 deletions src/main/resources/project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version=${project.version}
Original file line number Diff line number Diff line change
@@ -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() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought from a background thread in my brain: perhaps I should just load this value directly from the pom.xml file in the tests.

Copy link
Copy Markdown
Contributor Author

@liamhughes liamhughes May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a little research. Sounds like the path to pom.xml may move around depending on how the tests are run (i.e. mvn vs IDE vs CI). Might just leave it for now.

try {
var projectProperties = new Properties();
try (var resourceStream = OctopusClient.class.getClassLoader().getResourceAsStream("project.properties"))
{
Comment thread
liamhughes marked this conversation as resolved.
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading