diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5126c4c..be0a865 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,9 @@
-name: Build and Test
+name: Build, Test, and Package
on:
push:
branches: [ "main" ]
pull_request:
- branches: [ "main" ]
jobs:
build:
@@ -13,12 +12,16 @@ jobs:
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: true
+
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
cache: maven
- - name: Build with Maven
+
+ - name: Build, test and package
run: mvn -B package --file pom.xml
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..4f9a6e1
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "specification"]
+ path = specification
+ url = https://github.com/OctopusDeploy/openfeature-provider-specification.git
diff --git a/pom.xml b/pom.xml
index e1aa131..1c58b21 100644
--- a/pom.xml
+++ b/pom.xml
@@ -119,6 +119,7 @@
org.junit.jupiter
junit-jupiter
5.11.1
+ test
org.assertj
@@ -126,6 +127,12 @@
3.27.3
test
+
+ org.wiremock
+ wiremock
+ 3.5.4
+ test
+
\ No newline at end of file
diff --git a/specification b/specification
new file mode 160000
index 0000000..57495a9
--- /dev/null
+++ b/specification
@@ -0,0 +1 @@
+Subproject commit 57495a9dc1155e4c079aba1b91663a4ee501dca7
diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
index 9cab049..f9ab3b2 100644
--- a/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
+++ b/src/main/java/com/octopus/openfeature/provider/OctopusConfiguration.java
@@ -5,21 +5,25 @@
public class OctopusConfiguration {
private final String clientIdentifier;
- private static final String DEFAULT_SERVER_URI = "https://features.octopus.com";
- private Duration cacheDuration = Duration.ofMinutes(1);
+ 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 String getClientIdentifier() { return clientIdentifier; }
-
- public URI getServerUri() { return URI.create(DEFAULT_SERVER_URI); }
+
+ public URI getServerUri() { return serverUri; }
+
+ // Note: package-private by default. Visible to tests in same package, but not to library consumers.
+ void setServerUri(URI serverUri) { this.serverUri = serverUri; }
public Duration getCacheDuration() {
return cacheDuration;
}
-
+
public Duration setCacheDuration(Duration cacheDuration) {
this.cacheDuration = cacheDuration;
return this.cacheDuration;
diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java
new file mode 100644
index 0000000..aa17d27
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/OctopusConfigurationTests.java
@@ -0,0 +1,24 @@
+package com.octopus.openfeature.provider;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OctopusConfigurationTests {
+
+ @Test
+ void defaultServerUriIsOctopusCloud() {
+ var config = new OctopusConfiguration("test-client");
+ assertThat(config.getServerUri()).isEqualTo(URI.create("https://features.octopus.com"));
+ }
+
+ @Test
+ void serverUriCanBeOverridden() {
+ var config = new OctopusConfiguration("test-client");
+ 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/Server.java b/src/test/java/com/octopus/openfeature/provider/Server.java
new file mode 100644
index 0000000..49f3c77
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/Server.java
@@ -0,0 +1,62 @@
+package com.octopus.openfeature.provider;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+
+import java.util.Base64;
+import java.util.UUID;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+
+/**
+ * Fake HTTP server for specification tests.
+ *
+ * Each call to {@link #configure(String)} registers a stub for a unique Bearer token
+ * and returns that token as the client identifier. Stubs accumulate over the server's
+ * lifetime (one per test case), which is harmless since each token is unique.
+ *
+ * Note: parallel test execution is not supported because SpecificationTests uses
+ * the OpenFeatureAPI singleton.
+ */
+class Server {
+
+ // A fixed hash is safe here because each test shuts down the provider via OpenFeatureAPI.shutdown()
+ // before the background refresh thread can poll the check endpoint and compare hashes.
+ private static final String CONTENT_HASH = Base64.getEncoder().encodeToString(new byte[]{0x01});
+ private final WireMockServer wireMock;
+
+ Server() {
+ wireMock = new WireMockServer(wireMockConfig().dynamicPort());
+ wireMock.start();
+ // Fallback: return 401 for any request that does not match a registered token.
+ wireMock.stubFor(any(anyUrl())
+ .atPriority(100)
+ .willReturn(aResponse().withStatus(401)));
+ }
+
+ /**
+ * Registers the given JSON as the response body for a new unique client token.
+ *
+ * @param responseJson the JSON array that the toggle API would return
+ * @return the client identifier (Bearer token) to use in OctopusConfiguration
+ */
+ String configure(String responseJson) {
+ String token = UUID.randomUUID().toString();
+ wireMock.stubFor(get(urlPathEqualTo("/api/featuretoggles/v3/"))
+ .withHeader("Authorization", equalTo("Bearer " + token))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withHeader("ContentHash", CONTENT_HASH)
+ .withBody(responseJson)));
+ return token;
+ }
+
+ String baseUrl() {
+ return wireMock.baseUrl();
+ }
+
+ void stop() {
+ wireMock.stop();
+ }
+}
diff --git a/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
new file mode 100644
index 0000000..e6fe337
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/SpecificationTests.java
@@ -0,0 +1,172 @@
+package com.octopus.openfeature.provider;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import dev.openfeature.sdk.*;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SpecificationTests {
+
+ private static Server server;
+
+ @BeforeAll
+ static void startServer() {
+ server = new Server();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.stop();
+ }
+
+ @AfterEach
+ void shutdownApi() {
+ OpenFeatureAPI.getInstance().shutdown();
+ }
+
+ @ParameterizedTest(name = "[{0}] {1}")
+ @MethodSource("fixtureTestCases")
+ void evaluate(String fileName, String description, String responseJson, FixtureCase testCase) {
+ String token = server.configure(responseJson);
+ OctopusConfiguration config = new OctopusConfiguration(token);
+ config.setServerUri(URI.create(server.baseUrl()));
+
+ OpenFeatureAPI api = OpenFeatureAPI.getInstance();
+ api.setProviderAndWait(new OctopusProvider(config));
+ Client client = api.getClient();
+
+ EvaluationContext ctx = buildContext(testCase.configuration.context);
+ FlagEvaluationDetails result = client.getBooleanDetails(
+ testCase.configuration.slug,
+ testCase.configuration.defaultValue,
+ ctx
+ );
+
+ assertThat(result.getValue())
+ .as("[%s] %s → value", fileName, description)
+ .isEqualTo(testCase.expected.value);
+ assertThat(result.getErrorCode())
+ .as("[%s] %s → errorCode", fileName, description)
+ .isEqualTo(mapErrorCode(testCase.expected.errorCode));
+ }
+
+ static Stream fixtureTestCases() throws IOException {
+ ObjectMapper mapper = JsonMapper.builder()
+ .enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .build();
+
+ List jsonFiles;
+ try (Stream files = Files.list(Path.of("specification", "Fixtures"))) {
+ jsonFiles = files
+ .filter(p -> p.getFileName().toString().endsWith(".json"))
+ .collect(Collectors.toList());
+ }
+ if (jsonFiles.isEmpty()) {
+ throw new IllegalStateException(
+ "No fixture files found under 'specification/Fixtures/'. " +
+ "Ensure the git submodule is initialised: git submodule update --init");
+ }
+ return jsonFiles.stream().flatMap(path -> {
+ try {
+ String fileContent = Files.readString(path);
+ Fixture fixture = mapper.readValue(fileContent, Fixture.class);
+ String fileName = path.getFileName().toString();
+ return Stream.of(fixture.cases)
+ .map(c -> Arguments.of(fileName, c.description, fixture.response, c));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
+
+ private static EvaluationContext buildContext(Map context) {
+ MutableContext ctx = new MutableContext();
+ if (context != null) {
+ context.forEach(ctx::add);
+ }
+ return ctx;
+ }
+
+ private static ErrorCode mapErrorCode(String code) {
+ if (code == null) return null;
+ switch (code) {
+ case "FLAG_NOT_FOUND":
+ return ErrorCode.FLAG_NOT_FOUND;
+ case "PARSE_ERROR":
+ return ErrorCode.PARSE_ERROR;
+ case "TYPE_MISMATCH":
+ return ErrorCode.TYPE_MISMATCH;
+ case "TARGETING_KEY_MISSING":
+ return ErrorCode.TARGETING_KEY_MISSING;
+ case "PROVIDER_NOT_READY":
+ return ErrorCode.PROVIDER_NOT_READY;
+ case "INVALID_CONTEXT":
+ return ErrorCode.INVALID_CONTEXT;
+ case "PROVIDER_FATAL":
+ return ErrorCode.PROVIDER_FATAL;
+ case "GENERAL":
+ return ErrorCode.GENERAL;
+ default:
+ throw new IllegalArgumentException("Unknown error code in fixture: " + code);
+ }
+ }
+
+ static class Fixture {
+ @JsonDeserialize(using = RawJsonDeserializer.class)
+ public String response;
+ public FixtureCase[] cases;
+ }
+
+ static class FixtureCase {
+ public String description;
+ public FixtureConfiguration configuration;
+ public FixtureExpected expected;
+ }
+
+ static class FixtureConfiguration {
+ public String slug;
+ public boolean defaultValue;
+ public Map context;
+ }
+
+ static class FixtureExpected {
+ public boolean value;
+ public String errorCode;
+ }
+
+ static class RawJsonDeserializer extends JsonDeserializer {
+ @Override
+ public String deserialize(JsonParser jp, DeserializationContext dc) throws IOException {
+ long begin = jp.currentLocation().getCharOffset();
+ jp.skipChildren();
+ long end = jp.currentLocation().getCharOffset();
+ String json = jp.currentLocation().contentReference().getRawContent().toString();
+ return json.substring((int) begin - 1, (int) end);
+ }
+ }
+
+}