Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,27 @@
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.Map;

class FeatureToggleEvaluation {
private final String name;
private final String slug;
private final boolean isEnabled;
private final List<Map.Entry<String, String>> segments;
private final List<Segment> segments;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
FeatureToggleEvaluation(@JsonProperty("name") String name, @JsonProperty("slug")String slug, @JsonProperty("isEnabled") boolean isEnabled,
@JsonProperty("segments") List<Map.Entry<String, String>> segments) {
FeatureToggleEvaluation(
@JsonProperty("name") String name,
@JsonProperty("slug") String slug,
@JsonProperty("isEnabled") boolean isEnabled,
@JsonProperty("segments") List<Segment> segments
) {
this.name = name;
this.slug = slug;
this.isEnabled = isEnabled;

this.segments = new ArrayList<>();
if (segments != null) {
this.segments.addAll(segments);
Expand All @@ -38,7 +42,7 @@ public boolean isEnabled() {
return isEnabled;
}

public List<Map.Entry<String, String>> getSegments() {
return segments;
public List<Segment> getSegments() {
return Collections.unmodifiableList(segments);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash)
.build();
try {
HttpResponse<String> httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString());
FeatureToggleCheckResponse checkResponse = new ObjectMapper().readValue(httpResponse.body(), FeatureToggleCheckResponse.class);
FeatureToggleCheckResponse checkResponse = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), FeatureToggleCheckResponse.class);
return !Arrays.equals(checkResponse.contentHash, contentHash);
} catch (Exception e) {
logger.log(System.Logger.Level.WARNING, String.format("Unable to query Octopus Feature Toggle service. URI: %s", checkURI.toString()), e);
Expand Down Expand Up @@ -65,7 +65,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 = new ObjectMapper().readValue(httpResponse.body(), new TypeReference<List<FeatureToggleEvaluation>>(){});
List<FeatureToggleEvaluation> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ ProviderEvaluation<Boolean> evaluate(String slug, Boolean defaultValue, Evaluati
.build();
}

private Boolean MatchesSegment(EvaluationContext evaluationContext, List<Map.Entry<String, String>> segments) {
private Boolean MatchesSegment(EvaluationContext evaluationContext, List<Segment> segments) {
if (evaluationContext == null) {
return false;
}

var contextEntries = evaluationContext.asMap();
var groupedByKey = segments.stream().collect(groupingBy(Map.Entry::getKey));
var groupedByKey = segments.stream().collect(groupingBy(Segment::getKey));
return groupedByKey.keySet().stream().allMatch(k -> {
var values = groupedByKey.get(k);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;

class OctopusObjectMapper {
static final ObjectMapper INSTANCE = JsonMapper.builder()
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
Comment on lines +8 to +12
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

OctopusObjectMapper.INSTANCE is a mutable ObjectMapper that can be reconfigured by any code in the package, which can lead to surprising cross-test or runtime behavior. Consider making the class final with a private constructor and exposing the mapper via a method that returns the shared instance (or a copy) while preventing external reconfiguration.

Suggested change
class OctopusObjectMapper {
static final ObjectMapper INSTANCE = JsonMapper.builder()
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
final class OctopusObjectMapper {
private static final ObjectMapper INSTANCE = JsonMapper.builder()
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private OctopusObjectMapper() {
// Utility class; prevent instantiation.
}
static ObjectMapper getInstance() {
// Return a defensive copy to avoid shared mutable configuration.
return INSTANCE.copy();
}

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.

Hmm...the whole point was to reuse the same instance. Making the wrapper final wouldn't hurt, but seems unnecessary?

}
26 changes: 26 additions & 0 deletions src/main/java/com/octopus/openfeature/provider/Segment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

class Segment {
private final String key;
private final String value;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public Segment(
@JsonProperty(value = "key", required = true) String key,
@JsonProperty(value = "value", required = true) String value
) {
this.key = key;
this.value = value;
}

public String getKey() {
return key;
}

public String getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import org.junit.jupiter.api.Test;

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

Copilot AI Mar 26, 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.Map can be removed to keep the test focused and avoid IDE/linter noise.

Suggested change
import java.util.Map;

Copilot uses AI. Check for mistakes.

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

class FeatureToggleEvaluationDeserializationTests {

private final ObjectMapper objectMapper = OctopusObjectMapper.INSTANCE;

private InputStream resource(String name) {
return getClass().getResourceAsStream(name);
}

private void assertSegmentsContain(List<Segment> segments, Segment... expected) {
assertThat(segments).usingRecursiveFieldByFieldElementComparator().contains(expected);
}

@Test
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();
}

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

assertThat(result.isEnabled()).isFalse();
}

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

assertThat(result.getSegments()).isNotNull().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(),
new Segment("license-type", "free"),
new Segment("country", "au")
);
}

@Test
void shouldDeserializeListOfToggles() throws Exception {
List<FeatureToggleEvaluation> result = objectMapper.readValue(
resource("toggle-list.json"),
new TypeReference<>() {
}
);

assertThat(result).hasSize(2);
assertThat(result.get(0).getSlug()).isEqualTo("feature-a");
assertThat(result.get(0).isEnabled()).isTrue();
assertThat(result.get(1).getSlug()).isEqualTo("feature-b");
assertThat(result.get(1).isEnabled()).isFalse();
}

@Test
void shouldDeserializeListOfTogglesWithVariousFieldCasings() throws Exception {
List<FeatureToggleEvaluation> result = objectMapper.readValue(
resource("toggles-with-different-field-capitalisation.json"),
new TypeReference<>() {
}
);

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"));
assertThat(result.get(1).getSlug()).isEqualTo("feature-b");
assertThat(result.get(1).isEnabled()).isTrue();
assertSegmentsContain(result.get(1).getSegments(), 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"));
}

@Test
void shouldFailDeserializationWhenSegmentKeyIsMissing() {
assertThatThrownBy(() -> objectMapper.readValue(
resource("toggle-segment-missing-key.json"), FeatureToggleEvaluation.class))
.isInstanceOf(MismatchedInputException.class);
}

@Test
void shouldFailDeserializationWhenSegmentValueIsMissing() {
assertThatThrownBy(() -> objectMapper.readValue(
resource("toggle-segment-missing-value.json"), FeatureToggleEvaluation.class))
.isInstanceOf(MismatchedInputException.class);
}

@Test
void shouldFailDeserializationWhenSegmentKeyAndValueAreMissing() {
assertThatThrownBy(() -> objectMapper.readValue(
resource("toggle-segment-missing-key-and-value.json"), FeatureToggleEvaluation.class))
.isInstanceOf(MismatchedInputException.class);
}

@Test
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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class OctopusContextTests {
Arrays.asList(
new FeatureToggleEvaluation("Enabled Feature", "enabled-feature", true, null),
new FeatureToggleEvaluation("Disabled Feature", "disabled-feature", false, null),
new FeatureToggleEvaluation("Feature With Segments", "feature-with-segments", true, Arrays.asList(Map.entry("license-type", "free"), Map.entry("country", "au")) )
new FeatureToggleEvaluation("Feature With Segments", "feature-with-segments", true, Arrays.asList(new Segment("license-type", "free"), new Segment("country", "au")) )
),
new byte[0]
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": false,
"segments": null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"name": "Feature A",
"slug": "feature-a",
"isEnabled": true,
"segments": null
},
{
"name": "Feature B",
"slug": "feature-b",
"isEnabled": false,
"segments": null
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": [
{}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": [
{
"value": "free"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": [
{
"key": "license-type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": [
{
"key": "license-type",
"value": "free",
"more": "data"
}
],
"foo": "bar",
"qux": 123,
"wux": {
"nested": "value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "My Feature",
"slug": "my-feature",
"isEnabled": true,
"segments": [
{
"key": "license-type",
"value": "free"
},
{
"key": "country",
"value": "au"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
{
"NAME": "Feature A",
"SLUG": "feature-a",
"ISENABLED": true,
"SEGMENTS": [
{
"KEY": "license-type",
"VALUE": "free"
}
]
},
{
"Name": "Feature B",
"Slug": "feature-b",
"IsEnabled": true,
"Segments": [
{
"Key": "plan",
"Value": "enterprise"
}
]
},
{
"nAmE": "Feature C",
"sLuG": "feature-c",
"iSeNaBlEd": true,
"sEgMeNtS": [
{
"kEy": "country",
"vAlUe": "au"
}
]
}
]
Loading