diff --git a/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java b/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java index d9db7be..c394baa 100644 --- a/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java +++ b/src/main/java/com/octopus/openfeature/provider/FeatureToggleEvaluation.java @@ -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> segments; + private final List segments; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - FeatureToggleEvaluation(@JsonProperty("name") String name, @JsonProperty("slug")String slug, @JsonProperty("isEnabled") boolean isEnabled, - @JsonProperty("segments") List> segments) { + FeatureToggleEvaluation( + @JsonProperty("name") String name, + @JsonProperty("slug") String slug, + @JsonProperty("isEnabled") boolean isEnabled, + @JsonProperty("segments") List segments + ) { this.name = name; this.slug = slug; this.isEnabled = isEnabled; - + this.segments = new ArrayList<>(); if (segments != null) { this.segments.addAll(segments); @@ -38,7 +42,7 @@ public boolean isEnabled() { return isEnabled; } - public List> getSegments() { - return segments; + public List getSegments() { + return Collections.unmodifiableList(segments); } } diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java index c2b2b56..f22e455 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusClient.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusClient.java @@ -36,7 +36,7 @@ Boolean haveFeatureTogglesChanged(byte[] contentHash) .build(); try { HttpResponse 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); @@ -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 evaluations = new ObjectMapper().readValue(httpResponse.body(), new TypeReference>(){}); + List evaluations = OctopusObjectMapper.INSTANCE.readValue(httpResponse.body(), new 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); diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusContext.java b/src/main/java/com/octopus/openfeature/provider/OctopusContext.java index 6521e9c..7ce50d5 100644 --- a/src/main/java/com/octopus/openfeature/provider/OctopusContext.java +++ b/src/main/java/com/octopus/openfeature/provider/OctopusContext.java @@ -48,13 +48,13 @@ ProviderEvaluation evaluate(String slug, Boolean defaultValue, Evaluati .build(); } - private Boolean MatchesSegment(EvaluationContext evaluationContext, List> segments) { + private Boolean MatchesSegment(EvaluationContext evaluationContext, List 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); diff --git a/src/main/java/com/octopus/openfeature/provider/OctopusObjectMapper.java b/src/main/java/com/octopus/openfeature/provider/OctopusObjectMapper.java new file mode 100644 index 0000000..6930dff --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/OctopusObjectMapper.java @@ -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(); +} diff --git a/src/main/java/com/octopus/openfeature/provider/Segment.java b/src/main/java/com/octopus/openfeature/provider/Segment.java new file mode 100644 index 0000000..0ca3fda --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/Segment.java @@ -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; + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/FeatureToggleEvaluationDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/FeatureToggleEvaluationDeserializationTests.java new file mode 100644 index 0000000..4607b62 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/FeatureToggleEvaluationDeserializationTests.java @@ -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; + +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 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 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 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")); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java index 529a4ca..5cdea37 100644 --- a/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java +++ b/src/test/java/com/octopus/openfeature/provider/OctopusContextTests.java @@ -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] ); diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-disabled.json b/src/test/resources/com/octopus/openfeature/provider/toggle-disabled.json new file mode 100644 index 0000000..f09370d --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-disabled.json @@ -0,0 +1,6 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": false, + "segments": null +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-enabled-no-segments.json b/src/test/resources/com/octopus/openfeature/provider/toggle-enabled-no-segments.json new file mode 100644 index 0000000..e9d17e9 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-enabled-no-segments.json @@ -0,0 +1,6 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true, + "segments": null +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-list.json b/src/test/resources/com/octopus/openfeature/provider/toggle-list.json new file mode 100644 index 0000000..ea6d15f --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-list.json @@ -0,0 +1,14 @@ +[ + { + "name": "Feature A", + "slug": "feature-a", + "isEnabled": true, + "segments": null + }, + { + "name": "Feature B", + "slug": "feature-b", + "isEnabled": false, + "segments": null + } +] diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-missing-segments.json b/src/test/resources/com/octopus/openfeature/provider/toggle-missing-segments.json new file mode 100644 index 0000000..285d13e --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-missing-segments.json @@ -0,0 +1,5 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key-and-value.json b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key-and-value.json new file mode 100644 index 0000000..11669cf --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key-and-value.json @@ -0,0 +1,8 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true, + "segments": [ + {} + ] +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key.json b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key.json new file mode 100644 index 0000000..7bf7e21 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-key.json @@ -0,0 +1,10 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true, + "segments": [ + { + "value": "free" + } + ] +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-value.json b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-value.json new file mode 100644 index 0000000..b4f2137 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-segment-missing-value.json @@ -0,0 +1,10 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true, + "segments": [ + { + "key": "license-type" + } + ] +} diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-with-extraneous-properties.json b/src/test/resources/com/octopus/openfeature/provider/toggle-with-extraneous-properties.json new file mode 100644 index 0000000..39b78d1 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-with-extraneous-properties.json @@ -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" + } +} \ No newline at end of file diff --git a/src/test/resources/com/octopus/openfeature/provider/toggle-with-segments.json b/src/test/resources/com/octopus/openfeature/provider/toggle-with-segments.json new file mode 100644 index 0000000..88ae4f0 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggle-with-segments.json @@ -0,0 +1,15 @@ +{ + "name": "My Feature", + "slug": "my-feature", + "isEnabled": true, + "segments": [ + { + "key": "license-type", + "value": "free" + }, + { + "key": "country", + "value": "au" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/com/octopus/openfeature/provider/toggles-with-different-field-capitalisation.json b/src/test/resources/com/octopus/openfeature/provider/toggles-with-different-field-capitalisation.json new file mode 100644 index 0000000..6f0e5d2 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/toggles-with-different-field-capitalisation.json @@ -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" + } + ] + } +]