diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java new file mode 100644 index 0000000..473a377 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java @@ -0,0 +1,33 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Base type for a client-side rule condition, selected from the camelCase {@code type} discriminator + * when deserializing a v4 evaluation response. These types model the wire shape only. + * + *
A discriminator this version of the provider does not recognise — or an absent one — + * deserializes to {@link UnknownCondition} rather than failing, so a condition type + * introduced by a newer server degrades safely on an older client. + * + *
The conditions sit alongside the rest of the v4 types rather than in a {@code conditions}
+ * sub-package as the .NET provider has them. Java package access is not hierarchical, so a
+ * package-private condition there would be invisible to {@link ClientSideRule} in this package, and
+ * following that layout would mean making every condition public — part of the library's supported
+ * API, which is what keeping these types package-private is meant to avoid.
+ */
+@JsonTypeInfo(
+ use = JsonTypeInfo.Id.NAME,
+ include = JsonTypeInfo.As.PROPERTY,
+ property = "type",
+ visible = true,
+ defaultImpl = UnknownCondition.class
+)
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = PercentageByContextCondition.class, name = ConditionTypeNames.PERCENTAGE_BY_CONTEXT),
+ @JsonSubTypes.Type(value = ContextAttributeIsOneOfCondition.class, name = ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_ONE_OF),
+ @JsonSubTypes.Type(value = ContextAttributeIsNotOneOfCondition.class, name = ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_NOT_ONE_OF)
+})
+abstract class ClientSideCondition {
+}
diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java
new file mode 100644
index 0000000..4e2537d
--- /dev/null
+++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java
@@ -0,0 +1,32 @@
+package com.octopus.openfeature.provider.v4;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * A named rule the provider library still has to evaluate on the client side. The rule matches when
+ * every one of its conditions matches.
+ */
+final class ClientSideRule {
+ private final String name;
+ private final List A flag is returned in one of two shapes:
+ * The raw payload of an unknown condition is not retained, only its discriminator.
+ */
+final class UnknownCondition extends ClientSideCondition {
+ private final String type;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ UnknownCondition(
+ @JsonProperty("type") String type
+ ) {
+ this.type = type;
+ }
+
+ /**
+ * The unrecognised discriminator value, or empty if none was present.
+ */
+ public Optional Test-only: this lives in the test sources and is never published.
+ */
+public final class TestObjectMapper {
+ public static final ObjectMapper INSTANCE = OctopusObjectMapper.INSTANCE;
+
+ private TestObjectMapper() {
+ }
+}
diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java
new file mode 100644
index 0000000..b93caba
--- /dev/null
+++ b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java
@@ -0,0 +1,201 @@
+package com.octopus.openfeature.provider.v4;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import com.octopus.openfeature.provider.TestObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Exercises polymorphic JSON deserialization of the v4 evaluation response. Everything is
+ * deserialized with the provider's own {@code OctopusObjectMapper} — the same mapper the client
+ * uses in production — so discriminator matching, property binding and the absent-property
+ * behaviour are all covered end to end.
+ */
+class ServerSideEvaluationDeserializationTests {
+
+ private final ObjectMapper objectMapper = TestObjectMapper.INSTANCE;
+
+ private InputStream resource(String name) {
+ return getClass().getResourceAsStream(name);
+ }
+
+ @Test
+ void shouldDeserializePercentageByContextConditionToConcreteType() throws Exception {
+ var condition = objectMapper.readValue(
+ resource("condition-percentage-by-context.json"), ClientSideCondition.class);
+
+ assertThat(condition)
+ .isInstanceOfSatisfying(PercentageByContextCondition.class,
+ percentage -> assertThat(percentage.getPercentage()).isEqualTo(50));
+ }
+
+ @Test
+ void shouldDeserializeContextAttributeIsOneOfConditionToConcreteType() throws Exception {
+ var condition = objectMapper.readValue(
+ resource("condition-context-attribute-is-one-of.json"), ClientSideCondition.class);
+
+ assertThat(condition)
+ .isInstanceOfSatisfying(ContextAttributeIsOneOfCondition.class, isOneOf -> {
+ assertThat(isOneOf.getKey()).isEqualTo("user-id");
+ assertThat(isOneOf.getValues()).containsExactly("1234", "5678");
+ });
+ }
+
+ @Test
+ void shouldDeserializeContextAttributeIsNotOneOfConditionToConcreteType() throws Exception {
+ var condition = objectMapper.readValue(
+ resource("condition-context-attribute-is-not-one-of.json"), ClientSideCondition.class);
+
+ assertThat(condition)
+ .isInstanceOfSatisfying(ContextAttributeIsNotOneOfCondition.class, isNotOneOf -> {
+ assertThat(isNotOneOf.getKey()).isEqualTo("region");
+ assertThat(isNotOneOf.getValues()).containsExactly("us", "eu");
+ });
+ }
+
+ @Test
+ void shouldDeserializeMixedConditionListToConcreteTypes() throws Exception {
+ var conditions = objectMapper.readValue(
+ resource("condition-list-mixed.json"),
+ new TypeReference
+ *
+ * Properties that do not apply to the returned shape are omitted from the JSON.
+ */
+final class ServerSideEvaluation {
+ private final String slug;
+ private final Boolean value;
+ private final String reason;
+ private final String evaluationKey;
+ private final List> getRules() {
+ return Optional.ofNullable(rules);
+ }
+}
diff --git a/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java
new file mode 100644
index 0000000..a62aab4
--- /dev/null
+++ b/src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java
@@ -0,0 +1,33 @@
+package com.octopus.openfeature.provider.v4;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Optional;
+
+/**
+ * A client-side condition whose {@code type} discriminator this version of the provider does not
+ * recognise, or which carried no discriminator at all. Rather than failing the whole evaluation
+ * response, an unrecognised condition is preserved as this type. It always evaluates to
+ * {@code false}, so a rule containing an unknown condition can never match — a newer server
+ * capability is safely treated as "not met" by an older client.
+ *
+ *
>() {}
+ );
+
+ assertThat(conditions).hasExactlyElementsOfTypes(
+ PercentageByContextCondition.class,
+ ContextAttributeIsOneOfCondition.class,
+ ContextAttributeIsNotOneOfCondition.class
+ );
+ }
+
+ @Test
+ void shouldDeserializeUnknownConditionTypeToUnknownConditionInsteadOfThrowing() throws Exception {
+ var condition = objectMapper.readValue(
+ resource("condition-unknown-type.json"), ClientSideCondition.class);
+
+ assertThat(condition)
+ .isInstanceOfSatisfying(UnknownCondition.class,
+ unknown -> assertThat(unknown.getType()).hasValue("not-a-real-condition"));
+ }
+
+ @Test
+ void shouldDeserializeConditionWithoutTypeDiscriminatorToUnknownCondition() throws Exception {
+ var condition = objectMapper.readValue(
+ resource("condition-missing-type.json"), ClientSideCondition.class);
+
+ assertThat(condition)
+ .isInstanceOfSatisfying(UnknownCondition.class,
+ unknown -> assertThat(unknown.getType()).isEmpty());
+ }
+
+ @Test
+ void shouldPreserveUnknownConditionAlongsideKnownConditionsWithoutFailingTheResponse() throws Exception {
+ var evaluation = objectMapper.readValue(
+ resource("evaluation-with-unknown-condition.json"), ServerSideEvaluation.class);
+
+ var conditions = evaluation.getRules().orElseThrow().get(0).getConditions();
+
+ assertThat(conditions.get(0)).isInstanceOf(PercentageByContextCondition.class);
+ assertThat(conditions.get(1))
+ .isInstanceOfSatisfying(UnknownCondition.class,
+ unknown -> assertThat(unknown.getType()).hasValue("some-future-condition"));
+ }
+
+ @Test
+ void shouldDeserializeServerResolvedEvaluation() throws Exception {
+ var evaluation = objectMapper.readValue(
+ resource("evaluation-server-resolved.json"), ServerSideEvaluation.class);
+
+ assertThat(evaluation.getSlug()).isEqualTo("my-feature");
+ assertThat(evaluation.getValue()).hasValue(true);
+ assertThat(evaluation.getReason()).hasValue("The flag is enabled for this environment.");
+ assertThat(evaluation.getEvaluationKey()).isEmpty();
+ assertThat(evaluation.getRules()).isEmpty();
+ }
+
+ @Test
+ void shouldDeserializeEvaluationDeferredToTheClientWithPolymorphicConditions() throws Exception {
+ var evaluation = objectMapper.readValue(
+ resource("evaluation-deferred-to-client.json"), ServerSideEvaluation.class);
+
+ assertThat(evaluation.getSlug()).isEqualTo("my-feature");
+ assertThat(evaluation.getEvaluationKey()).hasValue("0f8fad5b-d9cb-469f-a165-70867728950e");
+ assertThat(evaluation.getValue()).isEmpty();
+ assertThat(evaluation.getReason()).isEmpty();
+
+ var rules = evaluation.getRules().orElseThrow();
+ assertThat(rules).hasSize(1);
+
+ var rule = rules.get(0);
+ assertThat(rule.getName()).isEqualTo("Rule 1");
+ assertThat(rule.getConditions()).hasSize(2);
+
+ assertThat(rule.getConditions().get(0))
+ .isInstanceOfSatisfying(PercentageByContextCondition.class,
+ percentage -> assertThat(percentage.getPercentage()).isEqualTo(50));
+ assertThat(rule.getConditions().get(1))
+ .isInstanceOfSatisfying(ContextAttributeIsOneOfCondition.class, isOneOf -> {
+ assertThat(isOneOf.getKey()).isEqualTo("user-id");
+ assertThat(isOneOf.getValues()).containsExactly("1234", "5678");
+ });
+ }
+
+ @Test
+ void shouldDeserializeEvaluationsResponseAsListOfEvaluations() throws Exception {
+ var evaluations = objectMapper.readValue(
+ resource("evaluation-list.json"),
+ new TypeReference
>() {}
+ );
+
+ assertThat(evaluations).hasSize(2);
+
+ assertThat(evaluations.get(0).getSlug()).isEqualTo("resolved-feature");
+ assertThat(evaluations.get(0).getValue()).hasValue(false);
+ assertThat(evaluations.get(0).getRules()).isEmpty();
+
+ assertThat(evaluations.get(1).getSlug()).isEqualTo("deferred-feature");
+ assertThat(evaluations.get(1).getValue()).isEmpty();
+
+ var rules = evaluations.get(1).getRules().orElseThrow();
+ assertThat(rules).hasSize(1);
+ assertThat(rules.get(0).getConditions().get(0)).isInstanceOf(PercentageByContextCondition.class);
+ }
+
+ @Test
+ void shouldFailDeserializationWhenSlugIsMissing() {
+ assertThatThrownBy(() -> objectMapper.readValue(
+ resource("evaluation-missing-slug.json"), ServerSideEvaluation.class))
+ .isInstanceOf(MismatchedInputException.class);
+ }
+
+ @Test
+ void shouldIgnoreExtraneousProperties() throws Exception {
+ var evaluation = objectMapper.readValue(
+ resource("evaluation-with-extraneous-properties.json"), ServerSideEvaluation.class);
+
+ assertThat(evaluation.getSlug()).isEqualTo("my-feature");
+
+ var conditions = evaluation.getRules().orElseThrow().get(0).getConditions();
+ assertThat(conditions.get(0))
+ .isInstanceOfSatisfying(PercentageByContextCondition.class,
+ percentage -> assertThat(percentage.getPercentage()).isEqualTo(50));
+ }
+
+ @Test
+ void shouldExposeConditionsAsImmutableLists() throws Exception {
+ var evaluation = objectMapper.readValue(
+ resource("evaluation-deferred-to-client.json"), ServerSideEvaluation.class);
+
+ var rules = evaluation.getRules().orElseThrow();
+
+ assertThatThrownBy(() -> rules.clear()).isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> rules.get(0).getConditions().clear())
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json
new file mode 100644
index 0000000..5e68d06
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json
@@ -0,0 +1,8 @@
+{
+ "type": "context-attribute-is-not-one-of",
+ "key": "region",
+ "values": [
+ "us",
+ "eu"
+ ]
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json
new file mode 100644
index 0000000..92e2020
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json
@@ -0,0 +1,8 @@
+{
+ "type": "context-attribute-is-one-of",
+ "key": "user-id",
+ "values": [
+ "1234",
+ "5678"
+ ]
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json
new file mode 100644
index 0000000..2c04429
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json
@@ -0,0 +1,20 @@
+[
+ {
+ "type": "percentage-by-context",
+ "percentage": 25
+ },
+ {
+ "type": "context-attribute-is-one-of",
+ "key": "user-id",
+ "values": [
+ "1234"
+ ]
+ },
+ {
+ "type": "context-attribute-is-not-one-of",
+ "key": "region",
+ "values": [
+ "au"
+ ]
+ }
+]
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json
new file mode 100644
index 0000000..42d7dfb
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json
@@ -0,0 +1,3 @@
+{
+ "percentage": 50
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json
new file mode 100644
index 0000000..b29f47a
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json
@@ -0,0 +1,4 @@
+{
+ "type": "percentage-by-context",
+ "percentage": 50
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json
new file mode 100644
index 0000000..0ce3b94
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json
@@ -0,0 +1,4 @@
+{
+ "type": "not-a-real-condition",
+ "percentage": 50
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json
new file mode 100644
index 0000000..0adf650
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json
@@ -0,0 +1,23 @@
+{
+ "slug": "my-feature",
+ "evaluationKey": "0f8fad5b-d9cb-469f-a165-70867728950e",
+ "rules": [
+ {
+ "name": "Rule 1",
+ "conditions": [
+ {
+ "type": "percentage-by-context",
+ "percentage": 50
+ },
+ {
+ "type": "context-attribute-is-one-of",
+ "key": "user-id",
+ "values": [
+ "1234",
+ "5678"
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json
new file mode 100644
index 0000000..d097b57
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json
@@ -0,0 +1,22 @@
+[
+ {
+ "slug": "resolved-feature",
+ "value": false,
+ "reason": "The flag is disabled for this environment."
+ },
+ {
+ "slug": "deferred-feature",
+ "evaluationKey": "0f8fad5b-d9cb-469f-a165-70867728950e",
+ "rules": [
+ {
+ "name": "Rule 1",
+ "conditions": [
+ {
+ "type": "percentage-by-context",
+ "percentage": 10
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json
new file mode 100644
index 0000000..4ad3971
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json
@@ -0,0 +1,4 @@
+{
+ "value": true,
+ "reason": "The flag is enabled for this environment."
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json
new file mode 100644
index 0000000..d8bc564
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json
@@ -0,0 +1,5 @@
+{
+ "slug": "my-feature",
+ "value": true,
+ "reason": "The flag is enabled for this environment."
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json
new file mode 100644
index 0000000..93d7d64
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json
@@ -0,0 +1,22 @@
+{
+ "slug": "my-feature",
+ "evaluationKey": "0f8fad5b-d9cb-469f-a165-70867728950e",
+ "rules": [
+ {
+ "name": "Rule 1",
+ "extra": "rule-data",
+ "conditions": [
+ {
+ "type": "percentage-by-context",
+ "percentage": 50,
+ "extra": "condition-data"
+ }
+ ]
+ }
+ ],
+ "foo": "bar",
+ "qux": 123,
+ "wux": {
+ "nested": "value"
+ }
+}
diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json
new file mode 100644
index 0000000..be6a3b9
--- /dev/null
+++ b/src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json
@@ -0,0 +1,19 @@
+{
+ "slug": "my-feature",
+ "evaluationKey": "0f8fad5b-d9cb-469f-a165-70867728950e",
+ "rules": [
+ {
+ "name": "Rule 1",
+ "conditions": [
+ {
+ "type": "percentage-by-context",
+ "percentage": 50
+ },
+ {
+ "type": "some-future-condition",
+ "someField": "someValue"
+ }
+ ]
+ }
+ ]
+}