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); + } + } + +}