Skip to content
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

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

@JsonIgnoreProperties(ignoreUnknown = true)
class FeatureToggleEvaluation {
private final String name;
private final String slug;
private final boolean isEnabled;
private final List<Map.Entry<String, String>> 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,
@JsonDeserialize(contentUsing = SegmentDeserializer.class) @JsonProperty("segments") List<Map.Entry<String, String>> segments
) {
this.name = name;
this.slug = slug;
this.isEnabled = isEnabled;

this.segments = new ArrayList<>();
if (segments != null) {
this.segments.addAll(segments);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.IOException;
import java.util.AbstractMap;
import java.util.Map;

class SegmentDeserializer extends JsonDeserializer<Map.Entry<String, String>> {
@Override
public Map.Entry<String, String> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
if (node == null || !node.isObject()) {
ctxt.reportInputMismatch(
Map.Entry.class,
"Expected JSON object for Segment but got: %s",
node
);
return null;
}

JsonNode keyNode = node.get("key");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this case-insensitive?

Copy link
Copy Markdown

@dylanlerch dylanlerch Mar 26, 2026

Choose a reason for hiding this comment

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

And can we add a test for it to make sure that it is (I couldn't find one but I also might just be a silly boy that missed it).

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.

To answer your question: none of this JSON deserialisation was case insensitive.

Implementing case insensitivity led me to two major changes:

  1. a shared ObjectMapper which also replaced the previous solution to extraneous fields
  2. introducing a Segment type rather than further complicate the SegmentDeserializer

if (keyNode == null || keyNode.isNull() || !keyNode.isTextual()) {
ctxt.reportInputMismatch(
Map.Entry.class,
"Expected non-null string 'key' field but got: %s",
keyNode
);
return null;
}

JsonNode valueNode = node.get("value");
if (valueNode == null || valueNode.isNull() || !valueNode.isTextual()) {
ctxt.reportInputMismatch(
Map.Entry.class,
"Expected non-null string 'value' field but got: %s",
valueNode
);
return null;
}

String key = keyNode.asText();
String value = valueNode.asText();

return new AbstractMap.SimpleImmutableEntry<>(key, value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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;

class FeatureToggleEvaluationDeserializationTests {

private final ObjectMapper objectMapper = new ObjectMapper();

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

@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);
assertThat(result.getSegments()).contains(
Map.entry("license-type", "free"),
Map.entry("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 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();
assertThat(result.getSegments()).contains(Map.entry("license-type", "free"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.octopus.openfeature.provider;

import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.junit.jupiter.api.Test;

import java.util.Map;

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

class SegmentDeserializerTests {
private final ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new SimpleModule().addDeserializer(
Map.Entry.class,
new SegmentDeserializer()
));

@Test
void shouldDeserializeSegmentWithKeyAndValue() throws Exception {
Map.Entry<?, ?> result = objectMapper.readValue(
"{\"key\":\"license-type\",\"value\":\"free\"}",
Map.Entry.class
);

assertThat(result.getKey()).isEqualTo("license-type");
assertThat(result.getValue()).isEqualTo("free");
}

@Test
void shouldIgnoreExtraFields() throws Exception {
Map.Entry<?, ?> result = objectMapper.readValue(
"{\"key\":\"k\",\"value\":\"v\",\"extra\":\"ignored\"}",
Map.Entry.class
);

assertThat(result.getKey()).isEqualTo("k");
assertThat(result.getValue()).isEqualTo("v");
}

@Test
void shouldRejectMissingKey() {
assertThatThrownBy(() -> objectMapper.readValue("{\"value\":\"v\"}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("key");
}

@Test
void shouldRejectNullKey() {
assertThatThrownBy(() -> objectMapper.readValue("{\"key\":null,\"value\":\"v\"}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("key");
}

@Test
void shouldRejectNonStringKey() {
assertThatThrownBy(() -> objectMapper.readValue("{\"key\":42,\"value\":\"v\"}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("key");
}

@Test
void shouldRejectMissingValue() {
assertThatThrownBy(() -> objectMapper.readValue("{\"key\":\"k\"}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("value");
}

@Test
void shouldRejectNullValue() {
assertThatThrownBy(() -> objectMapper.readValue("{\"key\":\"k\",\"value\":null}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("value");
}

@Test
void shouldRejectNonStringValue() {
assertThatThrownBy(() -> objectMapper.readValue("{\"key\":\"k\",\"value\":42}", Map.Entry.class))
.isInstanceOf(JsonMappingException.class)
.hasMessageContaining("value");
}

@Test
void shouldRejectArrayInput() {
assertThatThrownBy(() -> objectMapper.readValue("[\"k\",\"v\"]", Map.Entry.class))
.isInstanceOf(JsonMappingException.class);
}

@Test
void shouldRejectScalarInput() {
assertThatThrownBy(() -> objectMapper.readValue("\"just-a-string\"", Map.Entry.class))
.isInstanceOf(JsonMappingException.class);
}
}
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,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"
}
]
}
Loading