Skip to content

Use new evaluations endpoint#7

Merged
liamhughes merged 11 commits intomainfrom
liamhughes/devex-136-use-new-evaluations-endpoint
Apr 1, 2026
Merged

Use new evaluations endpoint#7
liamhughes merged 11 commits intomainfrom
liamhughes/devex-136-use-new-evaluations-endpoint

Conversation

@liamhughes
Copy link
Copy Markdown
Contributor

@liamhughes liamhughes commented Mar 30, 2026

Background

The client percentage rollout feature requires ClientRolloutPercentage and EvaluationKey to be included in evaluation responses.

We were unable to add these fields to the existing GET /featuretoggles/v3 evaluations endpoint because the Java OpenFeature provider (in this repo) was previously using Jackson with strict deserialisation (FAIL_ON_UNKNOWN_PROPERTIES = true). Even though we relaxed this in #5, we still can't change the existing endpoint because there are deployed versions of the older serialisation configuration.

This PR updates the provider to use the new endpoint, which has the following changes compared to the old endpoint:

  • The name field was removed
  • evaluationKey and clientRolloutPercentage were added, though are only returned if isEnabled evaluated to true in the feature toggles service
  • segments are also only returned if isEnabled evaluated to true

Changes

  • Update URI called my provider and in test WireMock server
  • Updates to FeatureToggleEvaluation class
    • Removed name property and getter
    • Added evaluationKey and clientRolloutPercentage properties with Optional getters
    • Updated getSegments to Optional getter
    • Added hasSegments convenience method
  • Updates to OctopusContext class
    • Type changes from FeatureToggleEvaluation
    • Added missingRequiredPropertiesForClientSideEvaluation check

Notes for review

  • While Java does not support nullable reference types (i.e. all reference fields could be null) we can use the Optional<T> type to make it explicit which getter values should be checked or defaulted.
  • The new fields are not actually being used yet, but will be in the next PR.

Testing

  • All existing tests are passing, albeit with some change in test data.
  • The majority of specification tests at this commit pass, except those relying on the client rollout functionality. As such, I have disabled the specification tests because there isn't a commit in OctopusDeploy/openfeature-provider-specification that uses the new endpoint response shape that doesn't also expect the client percentage rollout feature to be implemented. This will be implemented and fully tested in the next PR, prior to the next release of this library
Screenshot 2026-03-30 at 15 25 08

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates the Java OpenFeature provider to call the newer evaluations endpoint so the provider can consume updated evaluation payloads (including fields needed for client percentage rollout) without breaking older deployed clients.

Changes:

  • Switched the evaluations URI to /api/toggles/evaluations/v3/ (provider + WireMock test server).
  • Updated FeatureToggleEvaluation to remove name, and add evaluationKey, clientRolloutPercentage, and Optional-based accessors for nullable fields.
  • Updated OctopusContext evaluation flow to account for the new nullable fields and fail fast when required fields for client-side evaluation are missing.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/main/java/com/octopus/openfeature/provider/OctopusClient.java Calls the new evaluations endpoint and updates Jackson TypeReference usage.
src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java Adjusts evaluation model to match the new endpoint (new fields + Optional getters).
src/main/java/com/octopus/openfeature/provider/OctopusContext.java Adds validation for required client-side evaluation properties; updates segment evaluation to use Optional segments.
src/test/java/com/octopus/openfeature/provider/Server.java Updates WireMock stubbed path to the new evaluations endpoint.
src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java Updates test data shape and adds ParseError coverage for missing required evaluation fields.
src/test/java/com/octopus/openfeature/provider/FeatureToggleEvaluationDeserializationTests.java Updates deserialization assertions for the new model shape (Optional segments, removed name).
Comments suppressed due to low confidence (1)

src/test/java/com/octopus/openfeature/provider/FeatureToggleEvaluationDeserializationTests.java:34

  • The model now includes evaluationKey and clientRolloutPercentage, but the deserialization tests don't assert their presence/absence for enabled vs disabled toggles. Add assertions (and update fixtures if needed) to ensure these fields are correctly deserialized into the new Optional getters, so regressions in the new endpoint shape are caught.
    @Test
    void shouldDeserializeEnabledToggle() throws Exception {
        FeatureToggleEvaluation result = objectMapper.readValue(resource("toggle-enabled-no-segments.json"), FeatureToggleEvaluation.class);

        assertThat(result.getSlug()).isEqualTo("my-feature");
        assertThat(result.isEnabled()).isTrue();
        assertThat(result.getSegments()).isEmpty();
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

public String getName() {
return name;
this.evaluationKey = evaluationKey;
this.segments = segments;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

segments is assigned directly from the constructor parameter and later wrapped with Collections.unmodifiableList(...) in the getter. This still allows external mutation if the original list reference is retained (e.g., callers constructing FeatureToggleEvaluation directly), which can lead to hard-to-trace behavior changes. Consider making a defensive copy in the constructor (e.g., List.copyOf(segments) when non-null) so the instance is truly immutable.

Suggested change
this.segments = segments;
this.segments = segments == null ? null : List.copyOf(segments);

Copilot uses AI. Check for mistakes.
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.

Low risk, but seems good to me.

@liamhughes liamhughes requested a review from Copilot March 30, 2026 05:14
@liamhughes liamhughes marked this pull request as ready for review March 30, 2026 05:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +58 to +64
if (!evaluation.isEnabled()) {
return false;
}

return evaluation.getClientRolloutPercentage().isEmpty()
|| evaluation.getEvaluationKey().isEmpty()
|| evaluation.getSegments().isEmpty();
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

missingRequiredPropertiesForClientSideEvaluation causes evaluate(...) to throw ParseError for any enabled toggle where segments is missing, even though evaluate can safely treat missing segments as "no targeting" (and you already have a fixture covering a missing segments field). This changes behavior from returning the server-provided isEnabled value to falling back to the caller’s default. Consider treating a missing segments field as an empty list (or only requiring segments when client-side targeting/rollout evaluation is actually performed).

Suggested change
if (!evaluation.isEnabled()) {
return false;
}
return evaluation.getClientRolloutPercentage().isEmpty()
|| evaluation.getEvaluationKey().isEmpty()
|| evaluation.getSegments().isEmpty();
// Client-side evaluation is only applicable when the toggle is enabled and has segments
if (!evaluation.isEnabled() || !evaluation.hasSegments()) {
return false;
}
return evaluation.getClientRolloutPercentage().isEmpty()
|| evaluation.getEvaluationKey().isEmpty();

Copilot uses AI. Check for mistakes.
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.

I think this might be correct. Will give it more thought tomorrow.

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.

This matches what the .NET client is doing and passes the missing-client-validation-fields specification tests.

Comment on lines +37 to 39
if (missingRequiredPropertiesForClientSideEvaluation(toggleValue)) {
throw new ParseError("Feature toggle " + toggleValue.getSlug() + " is missing necessary information for client-side evaluation.");
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The ParseError message is generic and doesn’t indicate which required fields are missing. Including the missing property names (e.g., evaluationKey/segments/clientRolloutPercentage) would make diagnosing malformed responses much easier, especially since the exception causes OpenFeature to fall back to the default value.

Copilot uses AI. Check for mistakes.
Comment on lines 51 to 54
@ParameterizedTest(name = "[{0}] {1}")
@MethodSource("fixtureTestCases")
@Disabled("Requires either old endpoint to be used, or client rollout percentage to be implemented")
void evaluate(String fileName, String description, String responseJson, FixtureCase testCase) {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This test suite is now fully disabled. That removes the only end-to-end coverage against the provider specification fixtures and makes it easier for future changes to regress behavior unnoticed. Consider marking these as a separate tagged/optional suite (e.g., via JUnit @Tag + build profile) or splitting out the cases that still apply so some specification coverage continues to run in CI.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Haven't had these all this time, I guess it's fine to disable them for a bit.

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.

Temporary circumstance as per PR description.

import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Unused import java.util.Optional is present but not referenced anywhere in this test class. Removing it will keep the file clean and avoid warnings in stricter build setups.

Suggested change
import java.util.Optional;

Copilot uses AI. Check for mistakes.
// this exception will be handled by OpenFeature, and the default value will be used
if (toggleValue == null) {
throw new FlagNotFoundError();
throw new FlagNotFoundError();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Interesting. Java implementation throws rather than returning an error response?

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.

The specification test seems to confirm this for the parse error case.

Copy link
Copy Markdown
Contributor Author

@liamhughes liamhughes Mar 31, 2026

Choose a reason for hiding this comment

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

Of note: these exception types also exist in the .NET SDK: https://github.com/open-feature/dotnet-sdk/tree/main/src/OpenFeature/Error

We may want to align the clients in one direction or the other at some point.

@liamhughes liamhughes requested a review from dylanlerch March 31, 2026 03:09
Copy link
Copy Markdown

@caitlynstocker caitlynstocker left a comment

Choose a reason for hiding this comment

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

Have picked through the code and can't see any problems ✅

I'm not sure exactly how to do it but have you also tested it manually?


public Optional<List<Segment>> getSegments() {
return Optional.ofNullable(segments);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is my first time seeing this Optional class, but it sounds right 👍

return null;
}
List<FeatureToggleEvaluation> evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>(){});
List<FeatureToggleEvaluation> evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new TypeReference<>(){});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Have not seen this before but Claude says you're just telling the deserializer to put the response into a list of FeatureToggleEvaluations. Sounds good 👍

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.

Yeah, this is just a bit of syntax clean up. Because the type of the variable List<FeatureToggleEvaluation> is specified (no var in Java 8), it isn't needed to be specified in the TypeReference<>.


assertThat(result.getSegments()).hasSize(2);
assertSegmentsContain(result.getSegments(),
var segments = result.getSegments().orElseThrow();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If we throw an error here, is it possible to include a message about why?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same question below.

Copy link
Copy Markdown
Contributor Author

@liamhughes liamhughes Apr 1, 2026

Choose a reason for hiding this comment

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

I could add an explicit check and throw my own exception, but I think it should be clear if it were ever to throw at this line? You can basically think of .orElseThrow() as the same as C#'s !, except it throws right away rather than when you try to reference the variable.

@liamhughes
Copy link
Copy Markdown
Contributor Author

liamhughes commented Apr 1, 2026

I'm not sure exactly how to do it but have you also tested it manually?

I have tested against my local OctoToggle service:

package com.octopus.openfeature.provider;

import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.OpenFeatureAPI;
import org.junit.jupiter.api.Test;

import java.net.URI;

import static org.assertj.core.api.Assertions.assertThat;

class OpenFeatureIntegrationTest {

    @Test
    void shouldInitialiseOpenFeatureClientWithOctopusProvider() {
        var config = new OctopusConfiguration("eyJh...");
        config.setServerUri(URI.create("http://localhost:7133"));

        var provider = new OctopusProvider(config);

        OpenFeatureAPI api = OpenFeatureAPI.getInstance();
        api.setProviderAndWait(provider);

        Client client = api.getClient();

        assertThat(client).isNotNull();
        assertThat(api.getProviderMetadata().getName()).isEqualTo("octopus");

        assertThat(client.getBooleanValue("alpha-03", false)).isTrue();
    }

}

@liamhughes liamhughes merged commit 7b4839f into main Apr 1, 2026
9 checks passed
@liamhughes liamhughes deleted the liamhughes/devex-136-use-new-evaluations-endpoint branch April 1, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants