Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,30 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

class FeatureToggleEvaluation {
private final String name;
private final String slug;
private final boolean isEnabled;
private final String evaluationKey;
private final List<Segment> segments;
private final Integer clientRolloutPercentage;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
FeatureToggleEvaluation(
@JsonProperty("name") String name,
@JsonProperty("slug") String slug,
@JsonProperty("isEnabled") boolean isEnabled,
@JsonProperty("segments") List<Segment> segments
@JsonProperty(value = "slug", required = true) String slug,
@JsonProperty(value = "isEnabled", required = true) boolean isEnabled,
@JsonProperty("evaluationKey") String evaluationKey,
@JsonProperty("segments") List<Segment> segments,
@JsonProperty("clientRolloutPercentage") Integer clientRolloutPercentage
) {
this.name = name;
this.slug = slug;
this.isEnabled = isEnabled;

this.segments = new ArrayList<>();
if (segments != null) {
this.segments.addAll(segments);
}
}

public String getName() {
return name;
this.evaluationKey = evaluationKey;
this.segments = segments == null ? null : List.copyOf(segments);
this.clientRolloutPercentage = clientRolloutPercentage;
}

public String getSlug() {
Expand All @@ -42,7 +37,19 @@ public boolean isEnabled() {
return isEnabled;
}

public List<Segment> getSegments() {
return Collections.unmodifiableList(segments);
public Optional<String> getEvaluationKey() {
return Optional.ofNullable(evaluationKey);
}

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 👍


public boolean hasSegments() {
return segments != null && !segments.isEmpty();
}

public Optional<Integer> getClientRolloutPercentage() {
return Optional.ofNullable(clientRolloutPercentage);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
Expand Down Expand Up @@ -65,7 +64,7 @@ FeatureToggles getFeatureToggleEvaluationManifest()
logger.log(System.Logger.Level.WARNING,String.format("Feature toggle response from %s did not contain expected ContentHash header", manifestURI.toString()));
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<>.

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);
Expand All @@ -83,16 +82,12 @@ private URI getCheckURI() {

private URI getManifestURI() {
try {
return new URL(config.getServerUri().toURL(), "/api/featuretoggles/v3/").toURI();
return new URL(config.getServerUri().toURL(), "/api/toggles/evaluations/v3/").toURI();
} catch (MalformedURLException | URISyntaxException ignored) // we know this URL is well-formed
{ }
return null;
}

private Boolean isSuccessStatusCode(int statusCode) {
return statusCode >= 200 && statusCode < 300;
}

// This class needs to be static to allow deserialization
private static class FeatureToggleCheckResponse {
public byte[] contentHash;
Expand Down
38 changes: 27 additions & 11 deletions src/main/java/com/octopus/openfeature/provider/OctopusContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import dev.openfeature.sdk.*;
import dev.openfeature.sdk.exceptions.FlagNotFoundError;
import dev.openfeature.sdk.exceptions.ParseError;

import java.util.List;
import java.util.Map;

import static java.util.stream.Collectors.groupingBy;

Expand All @@ -20,35 +20,51 @@ class OctopusContext {
static OctopusContext empty() {
return new OctopusContext(new FeatureToggles(List.of(), new byte[0]));
}

byte[] getContentHash() { return featureToggles.getContentHash(); }

byte[] getContentHash() {
return featureToggles.getContentHash();
}

ProviderEvaluation<Boolean> evaluate(String slug, Boolean defaultValue, EvaluationContext evaluationContext) {
// find the feature toggle matching the slug
var toggleValue = featureToggles.getEvaluations().stream().filter(f -> f.getSlug().equalsIgnoreCase(slug)).findFirst().orElse(null);

// 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.

}

if (missingRequiredPropertiesForClientSideEvaluation(toggleValue)) {
throw new ParseError("Feature toggle " + toggleValue.getSlug() + " is missing necessary information for client-side evaluation.");
}
Comment on lines +37 to 39
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.
// if the toggle is disabled, or if it has no segments, then we don't need to evaluate dynamically
if (!toggleValue.isEnabled() || toggleValue.getSegments().isEmpty()) {

// if the toggle is disabled, or if it has no segments, then we don't need to evaluate dynamically
if (!toggleValue.isEnabled() || !toggleValue.hasSegments()) {
return ProviderEvaluation.<Boolean>builder()
.value(toggleValue.isEnabled())
.reason(Reason.DEFAULT.toString())
.build();
}
// If the toggle is enabled and has segments configured, then we need to evaluate dynamically,

// If the toggle is enabled and has segments configured, then we need to evaluate dynamically,
// checking the context matches the segments
return ProviderEvaluation.<Boolean>builder()
.value(MatchesSegment(evaluationContext, toggleValue.getSegments()))
.value(matchesSegment(evaluationContext, toggleValue.getSegments().orElseThrow())) // checked in hasSegments
.reason(Reason.TARGETING_MATCH.toString())
.build();
}

private Boolean MatchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
private boolean missingRequiredPropertiesForClientSideEvaluation(FeatureToggleEvaluation evaluation) {
if (!evaluation.isEnabled()) {
return false;
}

return evaluation.getClientRolloutPercentage().isEmpty()
|| evaluation.getEvaluationKey().isEmpty()
|| evaluation.getSegments().isEmpty();
Comment on lines +58 to +64
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.

}

private Boolean matchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
if (evaluationContext == null) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

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.

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
Expand All @@ -28,33 +28,41 @@ private void assertSegmentsContain(List<Segment> segments, Segment... expected)
void shouldDeserializeEnabledToggle() throws Exception {
FeatureToggleEvaluation result = objectMapper.readValue(resource("toggle-enabled-no-segments.json"), FeatureToggleEvaluation.class);

assertThat(result.getName()).isEqualTo("My Feature");
assertThat(result.getSlug()).isEqualTo("my-feature");
assertThat(result.isEnabled()).isTrue();
assertThat(result.getSegments()).isEmpty();
assertThat(result.getEvaluationKey()).hasValue("eval-key-1");
assertThat(result.getSegments()).isPresent();
assertThat(result.getSegments().orElseThrow()).isEmpty();
assertThat(result.getClientRolloutPercentage()).hasValue(100);
}

@Test
void shouldDeserializeDisabledToggle() throws Exception {
FeatureToggleEvaluation result = objectMapper.readValue(resource("toggle-disabled.json"), FeatureToggleEvaluation.class);

assertThat(result.isEnabled()).isFalse();
assertThat(result.getEvaluationKey()).isEmpty();
assertThat(result.getSegments()).isEmpty();
assertThat(result.getClientRolloutPercentage()).isEmpty();
}

@Test
void shouldDeserializeToggleWithMissingSegmentsField() throws Exception {
FeatureToggleEvaluation result = objectMapper.readValue(resource("toggle-missing-segments.json"), FeatureToggleEvaluation.class);

assertThat(result.getSegments()).isNotNull().isEmpty();
assertThat(result.getSegments()).isEmpty();
}

@Test
void shouldDeserializeToggleWithSegments() throws Exception {
FeatureToggleEvaluation result = objectMapper.readValue(
resource("toggle-with-segments.json"), FeatureToggleEvaluation.class);

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.


assertThat(segments).hasSize(2);
assertSegmentsContain(
segments,
new Segment("license-type", "free"),
new Segment("country", "au")
);
Expand Down Expand Up @@ -86,13 +94,13 @@ void shouldDeserializeListOfTogglesWithVariousFieldCasings() throws Exception {
assertThat(result).hasSize(3);
assertThat(result.get(0).getSlug()).isEqualTo("feature-a");
assertThat(result.get(0).isEnabled()).isTrue();
assertSegmentsContain(result.get(0).getSegments(), new Segment("license-type", "free"));
assertSegmentsContain(result.get(0).getSegments().orElseThrow(), new Segment("license-type", "free"));
assertThat(result.get(1).getSlug()).isEqualTo("feature-b");
assertThat(result.get(1).isEnabled()).isTrue();
assertSegmentsContain(result.get(1).getSegments(), new Segment("plan", "enterprise"));
assertSegmentsContain(result.get(1).getSegments().orElseThrow(), new Segment("plan", "enterprise"));
assertThat(result.get(2).getSlug()).isEqualTo("feature-c");
assertThat(result.get(2).isEnabled()).isTrue();
assertSegmentsContain(result.get(2).getSegments(), new Segment("country", "au"));
assertSegmentsContain(result.get(2).getSegments().orElseThrow(), new Segment("country", "au"));
}

@Test
Expand Down Expand Up @@ -121,9 +129,8 @@ void shouldIgnoreExtraneousProperties() throws Exception {
FeatureToggleEvaluation result = objectMapper.readValue(
resource("toggle-with-extraneous-properties.json"), FeatureToggleEvaluation.class);

assertThat(result.getName()).isEqualTo("My Feature");
assertThat(result.getSlug()).isEqualTo("my-feature");
assertThat(result.isEnabled()).isTrue();
assertSegmentsContain(result.getSegments(), new Segment("license-type", "free"));
assertSegmentsContain(result.getSegments().orElseThrow(), new Segment("license-type", "free"));
}
}
Loading
Loading