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

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

/**
* 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.
*
* <p>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.
* when deserializing a v4 evaluation response.
*
* <p>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)
})
@JsonDeserialize(using = ClientSideConditionDeserializer.class)
abstract class ClientSideCondition {

/**
* Whether this condition is met. A condition that did not arrive in a shape its type can evaluate
* throws {@link dev.openfeature.sdk.exceptions.ParseError} rather than reading a value it was not
* sent.
*/
abstract boolean matches(ClientSideEvaluationContext context);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.octopus.openfeature.provider.v4;

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

import java.io.IOException;

/**
* Selects the concrete {@link ClientSideCondition} from the camelCase {@code type} discriminator. An
* unrecognised discriminator deserializes to {@link UnknownCondition} rather than throwing, so a
* condition type introduced by a newer server degrades safely on an older client.
*
* <p>Written by hand rather than driven by {@code @JsonTypeInfo}, which cannot distinguish a
* discriminator that is not a string — Jackson coerces {@code "type": 123} to {@code "123"} and
* treats it as merely unrecognised. Here a non-string (or absent) discriminator yields an
* {@link UnknownCondition} carrying no type, which fails evaluation as the malformed response it is.
*
* <p>The provider only ever reads these conditions, so serialization is left to Jackson's defaults.
*/
final class ClientSideConditionDeserializer extends JsonDeserializer<ClientSideCondition> {

private static final String DISCRIMINATOR = "type";

@Override
public ClientSideCondition deserialize(JsonParser parser, DeserializationContext context) throws IOException {
ObjectCodec codec = parser.getCodec();
JsonNode node = codec.readTree(parser);

// Matched exactly, as the .NET provider's converter does: the server always sends "type".
JsonNode discriminator = node.get(DISCRIMINATOR);
String type = discriminator != null && discriminator.isTextual() ? discriminator.textValue() : null;

// Deserializing the concrete type targets that type directly, so this deserializer — registered
// on the base type only — is not re-entered.
if (type == null) {
return new UnknownCondition(null);
}

switch (type) {
case ConditionTypeNames.PERCENTAGE_BY_CONTEXT:
return codec.treeToValue(node, PercentageByContextCondition.class);
case ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_ONE_OF:
return codec.treeToValue(node, ContextAttributeIsOneOfCondition.class);
case ConditionTypeNames.CONTEXT_ATTRIBUTE_IS_NOT_ONE_OF:
return codec.treeToValue(node, ContextAttributeIsNotOneOfCondition.class);
default:
return new UnknownCondition(type);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.octopus.openfeature.provider.v4;

import dev.openfeature.sdk.EvaluationContext;

/**
* What a flag's rules and conditions are evaluated against.
*/
final class ClientSideEvaluationContext {
private final String evaluationKey;
private final EvaluationContext openFeatureContext;

ClientSideEvaluationContext(String evaluationKey, EvaluationContext openFeatureContext) {
this.evaluationKey = evaluationKey;
this.openFeatureContext = openFeatureContext;
}

/**
* The key {@code percentage-by-context} buckets against.
*/
String getEvaluationKey() {
return evaluationKey;
}

/**
* The caller's context, or {@code null} if they supplied none.
*/
EvaluationContext getOpenFeatureContext() {
return openFeatureContext;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.openfeature.sdk.exceptions.ParseError;

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.
* A named rule the provider library evaluates on the client side. The rule matches when every one of
* its conditions matches.
*/
final class ClientSideRule {
private final String name;
private final List<ClientSideCondition> conditions;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
ClientSideRule(
@JsonProperty(value = "name", required = true) String name,
@JsonProperty(value = "conditions", required = true) List<ClientSideCondition> conditions
@JsonProperty("name") String name,
@JsonProperty("conditions") List<ClientSideCondition> conditions
) {
this.name = name;
this.conditions = List.copyOf(conditions);
this.conditions = WireLists.copyOrNull(conditions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ListUtils?

}

public String getName() {
Expand All @@ -29,4 +30,28 @@ public String getName() {
public List<ClientSideCondition> getConditions() {
return conditions;
}

boolean matches(ClientSideEvaluationContext context) {
// The server only defers a named rule carrying at least one condition, so anything else is a
// response it could not have sent.
if (name == null) {
throw new ParseError("A rule has no name.");
}

if (conditions == null || conditions.isEmpty()) {
throw new ParseError("Rule '" + name + "' has no conditions.");
}

for (ClientSideCondition condition : conditions) {
if (condition == null) {
throw new ParseError("Rule '" + name + "' has a missing condition.");
}

if (!condition.matches(context)) {
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.util.List;

/**
* Matches when the context attribute {@code key} is not one of {@code values}.
* Matches when the context attribute {@code key} is not one of {@code values}. A missing attribute
* matches.
*/
@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it.
@JsonIgnoreProperties("type") // The discriminator selects this type; it is not modelled as a property.
@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer.
final class ContextAttributeIsNotOneOfCondition extends ClientSideCondition {
private final String key;
private final List<String> values;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
ContextAttributeIsNotOneOfCondition(
@JsonProperty(value = "key", required = true) String key,
@JsonProperty(value = "values", required = true) List<String> values
@JsonProperty("key") String key,
@JsonProperty("values") List<String> values
) {
this.key = key;
this.values = List.copyOf(values);
this.values = WireLists.copyOrNull(values);
}

public String getKey() {
Expand All @@ -30,4 +34,9 @@ public String getKey() {
public List<String> getValues() {
return values;
}

@Override
boolean matches(ClientSideEvaluationContext context) {
return !ContextAttributes.isOneOf(context, key, values);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.util.List;

/**
* Matches when the context attribute {@code key} is one of {@code values}.
* Matches when the context attribute {@code key} is one of {@code values}. A missing attribute does
* not match.
*/
@JsonIgnoreProperties("type") // The discriminator is visible to subtypes; this type does not model it.
@JsonIgnoreProperties("type") // The discriminator selects this type; it is not modelled as a property.
@JsonDeserialize(using = JsonDeserializer.None.class) // Resets the base type's deserializer; see ClientSideConditionDeserializer.
final class ContextAttributeIsOneOfCondition extends ClientSideCondition {
private final String key;
private final List<String> values;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
ContextAttributeIsOneOfCondition(
@JsonProperty(value = "key", required = true) String key,
@JsonProperty(value = "values", required = true) List<String> values
@JsonProperty("key") String key,
@JsonProperty("values") List<String> values
) {
this.key = key;
this.values = List.copyOf(values);
this.values = WireLists.copyOrNull(values);
}

public String getKey() {
Expand All @@ -30,4 +34,9 @@ public String getKey() {
public List<String> getValues() {
return values;
}

@Override
boolean matches(ClientSideEvaluationContext context) {
return ContextAttributes.isOneOf(context, key, values);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.octopus.openfeature.provider.v4;

import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.exceptions.ParseError;

import java.util.List;
import java.util.Objects;

/**
* The attribute lookup shared by both attribute conditions.
*/
final class ContextAttributes {

private ContextAttributes() {
}

/**
* Whether the context holds an attribute named {@code key} with one of {@code values}.
*
* <p>Mirrors v3 segment matching: keys and values compare case-insensitively and a non-string
* value counts as absent. Every entry whose key matches is checked, not just the first — a context
* can hold several case variants of one key.
*/
static boolean isOneOf(ClientSideEvaluationContext context, String key, List<String> values) {
if (key == null) {
throw new ParseError("A condition is missing a key.");
}

if (values == null || values.isEmpty()) {
throw new ParseError("A condition is missing values.");
}

if (values.stream().anyMatch(Objects::isNull)) {
throw new ParseError("A condition is missing a value.");
}

EvaluationContext openFeatureContext = context.getOpenFeatureContext();
if (openFeatureContext == null) {
return false;
}

return openFeatureContext.asMap().entrySet().stream().anyMatch(entry -> {
if (!entry.getKey().equalsIgnoreCase(key)) {
return false;
}

String attribute = entry.getValue().asString();
return attribute != null && values.stream().anyMatch(value -> value.equalsIgnoreCase(attribute));
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.octopus.openfeature.provider.v4;

/**
* Reasons returned alongside a client-side evaluation. Both match the strings the Feature Flags service
* produces server-side, so a flag reads the same whichever side resolved it.
*/
final class EvaluationReasons {

private EvaluationReasons() {
}

static String matchedRule(String ruleName) {
return "Matched rule '" + ruleName + "'.";
}

static String didNotMatchAnyRules() {
return "Did not match any rules.";
}
}
Loading