From 3be78038136846326a48692453bb6a9f237250e8 Mon Sep 17 00:00:00 2001 From: Lourens de Jager Date: Mon, 10 Aug 2026 08:04:43 +1200 Subject: [PATCH 1/3] feat: add v4 evaluation response types to provider library Adds the OctoToggle v4 evaluation-response contract as package-private types in a v4 sub-package, without hooking them up to anything. The existing v3 evaluation path is untouched. Unrecognised (or absent) client-side condition discriminators deserialize to UnknownCondition rather than failing the whole response, so a condition type introduced by a newer server degrades safely on an older client. Co-Authored-By: Claude Opus 5 (1M context) --- .../provider/v4/ClientSideCondition.java | 27 +++ .../provider/v4/ClientSideRule.java | 32 +++ .../provider/v4/ConditionTypeNames.java | 14 ++ .../ContextAttributeIsNotOneOfCondition.java | 33 +++ .../v4/ContextAttributeIsOneOfCondition.java | 33 +++ .../v4/PercentageByContextCondition.java | 24 ++ .../provider/v4/ServerSideEvaluation.java | 62 +++++ .../provider/v4/UnknownCondition.java | 33 +++ .../provider/TestObjectMapper.java | 17 ++ ...verSideEvaluationDeserializationTests.java | 224 ++++++++++++++++++ ...ition-context-attribute-is-not-one-of.json | 8 + ...condition-context-attribute-is-one-of.json | 8 + ...iscriminator-different-capitalisation.json | 4 + ...inator-value-different-capitalisation.json | 4 + .../provider/v4/condition-list-mixed.json | 20 ++ .../provider/v4/condition-missing-type.json | 3 + .../v4/condition-percentage-by-context.json | 4 + .../provider/v4/condition-unknown-type.json | 4 + .../v4/evaluation-deferred-to-client.json | 23 ++ .../provider/v4/evaluation-list.json | 22 ++ .../provider/v4/evaluation-missing-slug.json | 4 + .../v4/evaluation-server-resolved.json | 5 + ...evaluation-with-extraneous-properties.json | 22 ++ .../v4/evaluation-with-unknown-condition.json | 19 ++ 24 files changed, 649 insertions(+) create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ClientSideRule.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java create mode 100644 src/main/java/com/octopus/openfeature/provider/v4/UnknownCondition.java create mode 100644 src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java create mode 100644 src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-not-one-of.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-context-attribute-is-one-of.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-list-mixed.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-missing-type.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-percentage-by-context.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-unknown-type.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-deferred-to-client.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-list.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-missing-slug.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-server-resolved.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-extraneous-properties.json create mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/evaluation-with-unknown-condition.json 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..e888978 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java @@ -0,0 +1,27 @@ +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. + */ +@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 conditions; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + ClientSideRule( + @JsonProperty(value = "name", required = true) String name, + @JsonProperty(value = "conditions", required = true) List conditions + ) { + this.name = name; + this.conditions = List.copyOf(conditions); + } + + public String getName() { + return name; + } + + public List getConditions() { + return conditions; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java b/src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java new file mode 100644 index 0000000..f8f8cff --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ConditionTypeNames.java @@ -0,0 +1,14 @@ +package com.octopus.openfeature.provider.v4; + +/** + * Discriminator values for the polymorphic v4 client-side conditions. These mirror the values in the + * evaluation response, so they must not drift from the server. + */ +final class ConditionTypeNames { + static final String CONTEXT_ATTRIBUTE_IS_NOT_ONE_OF = "context-attribute-is-not-one-of"; + static final String CONTEXT_ATTRIBUTE_IS_ONE_OF = "context-attribute-is-one-of"; + static final String PERCENTAGE_BY_CONTEXT = "percentage-by-context"; + + private ConditionTypeNames() { + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java new file mode 100644 index 0000000..12f9dae --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsNotOneOfCondition.java @@ -0,0 +1,33 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Matches when the context attribute {@code key} is not one of {@code values}. + */ +@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +final class ContextAttributeIsNotOneOfCondition extends ClientSideCondition { + private final String key; + private final List values; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + ContextAttributeIsNotOneOfCondition( + @JsonProperty(value = "key", required = true) String key, + @JsonProperty(value = "values", required = true) List values + ) { + this.key = key; + this.values = List.copyOf(values); + } + + public String getKey() { + return key; + } + + public List getValues() { + return values; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java new file mode 100644 index 0000000..dbc8e10 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ContextAttributeIsOneOfCondition.java @@ -0,0 +1,33 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Matches when the context attribute {@code key} is one of {@code values}. + */ +@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +final class ContextAttributeIsOneOfCondition extends ClientSideCondition { + private final String key; + private final List values; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + ContextAttributeIsOneOfCondition( + @JsonProperty(value = "key", required = true) String key, + @JsonProperty(value = "values", required = true) List values + ) { + this.key = key; + this.values = List.copyOf(values); + } + + public String getKey() { + return key; + } + + public List getValues() { + return values; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java new file mode 100644 index 0000000..0194643 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/PercentageByContextCondition.java @@ -0,0 +1,24 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Matches when the OpenFeature targeting key falls within the {@code percentage}% rollout. + */ +@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it. +final class PercentageByContextCondition extends ClientSideCondition { + private final int percentage; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + PercentageByContextCondition( + @JsonProperty(value = "percentage", required = true) int percentage + ) { + this.percentage = percentage; + } + + public int getPercentage() { + return percentage; + } +} diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java b/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java new file mode 100644 index 0000000..c9c0028 --- /dev/null +++ b/src/main/java/com/octopus/openfeature/provider/v4/ServerSideEvaluation.java @@ -0,0 +1,62 @@ +package com.octopus.openfeature.provider.v4; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Optional; + +/** + * A single feature flag as returned by the OctoToggle v4 evaluations endpoint. The endpoint returns + * an array of these. + * + *

A flag is returned in one of two shapes: + *

+ * 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 rules; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + ServerSideEvaluation( + @JsonProperty(value = "slug", required = true) String slug, + @JsonProperty("value") Boolean value, + @JsonProperty("reason") String reason, + @JsonProperty("evaluationKey") String evaluationKey, + @JsonProperty("rules") List rules + ) { + this.slug = slug; + this.value = value; + this.reason = reason; + this.evaluationKey = evaluationKey; + this.rules = rules == null ? null : List.copyOf(rules); + } + + public String getSlug() { + return slug; + } + + public Optional getValue() { + return Optional.ofNullable(value); + } + + public Optional getReason() { + return Optional.ofNullable(reason); + } + + public Optional getEvaluationKey() { + return Optional.ofNullable(evaluationKey); + } + + public Optional> 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. + * + *

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 getType() { + return Optional.ofNullable(type); + } +} diff --git a/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java b/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java new file mode 100644 index 0000000..0a34a97 --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/TestObjectMapper.java @@ -0,0 +1,17 @@ +package com.octopus.openfeature.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Exposes the provider's package-private {@link OctopusObjectMapper} to tests in other packages, so + * they can exercise deserialization with the same mapper the client uses in production rather than + * a re-declared copy that could drift from it. + * + *

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..491b56f --- /dev/null +++ b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java @@ -0,0 +1,224 @@ +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>() {} + ); + + 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 shouldDeserializeConditionWhenDiscriminatorPropertyUsesDifferentCapitalisation() throws Exception { + // The mapper accepts case-insensitive property names, and that extends to the discriminator. + var condition = objectMapper.readValue( + resource("condition-discriminator-different-capitalisation.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(PercentageByContextCondition.class, + percentage -> assertThat(percentage.getPercentage()).isEqualTo(50)); + } + + @Test + void shouldTreatDiscriminatorValueWithDifferentCapitalisationAsUnknownCondition() throws Exception { + // Unlike property names, discriminator values are matched exactly — anything else is an + // unrecognised condition, which degrades safely rather than failing the response. + var condition = objectMapper.readValue( + resource("condition-discriminator-value-different-capitalisation.json"), ClientSideCondition.class); + + assertThat(condition) + .isInstanceOfSatisfying(UnknownCondition.class, + unknown -> assertThat(unknown.getType()).hasValue("Percentage-By-Context")); + } + + @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-discriminator-different-capitalisation.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json new file mode 100644 index 0000000..4066324 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json @@ -0,0 +1,4 @@ +{ + "Type": "percentage-by-context", + "Percentage": 50 +} diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json new file mode 100644 index 0000000..4a7ab10 --- /dev/null +++ b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json @@ -0,0 +1,4 @@ +{ + "type": "Percentage-By-Context", + "percentage": 50 +} 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" + } + ] + } + ] +} From 04a81ea8697f45635f2207d082faf5ae1f7ce671 Mon Sep 17 00:00:00 2001 From: Lourens de Jager Date: Tue, 11 Aug 2026 07:37:29 +1200 Subject: [PATCH 2/3] test: drop the JSON property capitalisation tests These pinned Jackson's configured case-insensitive property matching rather than anything the provider does, and committed us to tolerating casing the server never sends. Removed along with their fixtures. Co-Authored-By: Claude Opus 5 (1M context) --- ...verSideEvaluationDeserializationTests.java | 23 ------------------- ...iscriminator-different-capitalisation.json | 4 ---- ...inator-value-different-capitalisation.json | 4 ---- 3 files changed, 31 deletions(-) delete mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json delete mode 100644 src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json diff --git a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java index 491b56f..b93caba 100644 --- a/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java +++ b/src/test/java/com/octopus/openfeature/provider/v4/ServerSideEvaluationDeserializationTests.java @@ -94,29 +94,6 @@ void shouldDeserializeConditionWithoutTypeDiscriminatorToUnknownCondition() thro unknown -> assertThat(unknown.getType()).isEmpty()); } - @Test - void shouldDeserializeConditionWhenDiscriminatorPropertyUsesDifferentCapitalisation() throws Exception { - // The mapper accepts case-insensitive property names, and that extends to the discriminator. - var condition = objectMapper.readValue( - resource("condition-discriminator-different-capitalisation.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(PercentageByContextCondition.class, - percentage -> assertThat(percentage.getPercentage()).isEqualTo(50)); - } - - @Test - void shouldTreatDiscriminatorValueWithDifferentCapitalisationAsUnknownCondition() throws Exception { - // Unlike property names, discriminator values are matched exactly — anything else is an - // unrecognised condition, which degrades safely rather than failing the response. - var condition = objectMapper.readValue( - resource("condition-discriminator-value-different-capitalisation.json"), ClientSideCondition.class); - - assertThat(condition) - .isInstanceOfSatisfying(UnknownCondition.class, - unknown -> assertThat(unknown.getType()).hasValue("Percentage-By-Context")); - } - @Test void shouldPreserveUnknownConditionAlongsideKnownConditionsWithoutFailingTheResponse() throws Exception { var evaluation = objectMapper.readValue( diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json deleted file mode 100644 index 4066324..0000000 --- a/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-different-capitalisation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "Type": "percentage-by-context", - "Percentage": 50 -} diff --git a/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json b/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json deleted file mode 100644 index 4a7ab10..0000000 --- a/src/test/resources/com/octopus/openfeature/provider/v4/condition-discriminator-value-different-capitalisation.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Percentage-By-Context", - "percentage": 50 -} From 7e263557d58a5490a539b81ffb061a0728351a32 Mon Sep 17 00:00:00 2001 From: Lourens de Jager Date: Tue, 11 Aug 2026 07:37:49 +1200 Subject: [PATCH 3/3] docs: note why v4 conditions are not in a sub-package Records the constraint that rules out mirroring the .NET provider's Conditions sub-namespace: package access is not hierarchical, so that layout would force every condition public. Co-Authored-By: Claude Opus 5 (1M context) --- .../openfeature/provider/v4/ClientSideCondition.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java index e888978..473a377 100644 --- a/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java +++ b/src/main/java/com/octopus/openfeature/provider/v4/ClientSideCondition.java @@ -10,6 +10,12 @@ *

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,