diff --git a/dot-net-sdk/EppoClient.cs b/dot-net-sdk/EppoClient.cs index 6ec518f..6f900c8 100644 --- a/dot-net-sdk/EppoClient.cs +++ b/dot-net-sdk/EppoClient.cs @@ -40,6 +40,12 @@ private EppoClient(ConfigurationStore configurationStore, EppoClientConfig eppoC } + public int? GetIntegerAssignment(string subjectKey, string flagKey, SubjectAttributes? subjectAttributes = null) + { + return GetAssignment(subjectKey, flagKey, subjectAttributes ?? new SubjectAttributes())?.IntegerValue(); + } + + public string? GetStringAssignment(string subjectKey, string flagKey, SubjectAttributes? subjectAttributes = null) { return GetAssignment(subjectKey, flagKey, subjectAttributes ?? new SubjectAttributes())?.StringValue(); @@ -59,7 +65,7 @@ private EppoClient(ConfigurationStore configurationStore, EppoClientConfig eppoC } var subjectVariationOverride = this.GetSubjectVariationOverride(subjectKey, configuration); - if (!subjectVariationOverride.isNull()) + if (!subjectVariationOverride.IsNull()) { return subjectVariationOverride; } diff --git a/dot-net-sdk/dto/EppoValue.cs b/dot-net-sdk/dto/EppoValue.cs index 746c628..ef09ad0 100644 --- a/dot-net-sdk/dto/EppoValue.cs +++ b/dot-net-sdk/dto/EppoValue.cs @@ -6,17 +6,25 @@ namespace eppo_sdk.dto; [JsonConverter(typeof(EppoValueDeserializer))] public class EppoValue { - public string value { get; set; } + public string? value { get; set; } public EppoValueType type { get; set; } = EppoValueType.NULL; - public List array { get; set; } + public List? array { get; set; } + + + public static EppoValue Bool(string? value) => new(value, EppoValueType.BOOLEAN); + public static EppoValue Bool(bool value) => new(value.ToString(), EppoValueType.BOOLEAN); + public static EppoValue Number(string value) => new(value, EppoValueType.NUMBER); + public static EppoValue String(string? value) => new(value, EppoValueType.STRING); + public static EppoValue Integer(string value) => new(value, EppoValueType.INTEGER); + public static EppoValue Null() => new(); public EppoValue() { } - public EppoValue(string value, EppoValueType type) + public EppoValue(string? value, EppoValueType type) { this.value = value; this.type = type; @@ -28,38 +36,25 @@ public EppoValue(List array) this.type = EppoValueType.ARRAY_OF_STRING; } - public EppoValue(EppoValueType type) - { - this.type = type; - } + public EppoValue(EppoValueType type) => this.type = type; - public bool BoolValue() - { - return bool.Parse(value); - } + public bool BoolValue() => bool.Parse(value); - public double DoubleValue() - { - return double.Parse(value, NumberStyles.Number); - } + public double DoubleValue() => double.Parse(value, NumberStyles.Number); - public bool isNumeric() - { - return double.TryParse(value, out _); - } + public int IntegerValue() => int.Parse(value, NumberStyles.Number); - public string StringValue() - { - return value; - } + public bool IsNumeric() => double.TryParse(value, out _); - public List ArrayValue() - { - return array; - } + public string StringValue() => value; - public bool isNull() - { - return EppoValueType.NULL.Equals(type); - } -} \ No newline at end of file + public List ArrayValue() => array; + + public bool IsNull() => EppoValueType.NULL.Equals(type); + + public static bool IsNullValue(EppoValue? value) => + value == null /* null pointer */ || + value.IsNull() /* parsed as a null JSON token type */ || + value.value == null; /* Value type is set but value is null */ + +} diff --git a/dot-net-sdk/dto/EppoValueDeserializer.cs b/dot-net-sdk/dto/EppoValueDeserializer.cs index 7f7e28d..b5d6e3e 100644 --- a/dot-net-sdk/dto/EppoValueDeserializer.cs +++ b/dot-net-sdk/dto/EppoValueDeserializer.cs @@ -18,14 +18,15 @@ public override void WriteJson(JsonWriter writer, EppoValue? value, JsonSerializ switch (reader.TokenType) { case JsonToken.String: - return new EppoValue(value.ToString(), EppoValueType.STRING); + return EppoValue.String(value.ToString()); case JsonToken.Integer: + return EppoValue.Integer(value.ToString()); case JsonToken.Float: - return new EppoValue(value.ToString(), EppoValueType.NUMBER); + return EppoValue.Number(value.ToString()); case JsonToken.Boolean: - return new EppoValue(value.ToString(), EppoValueType.BOOLEAN); + return EppoValue.Bool(value.ToString()); case JsonToken.Null: - return new EppoValue(EppoValueType.NULL); + return EppoValue.Null(); case JsonToken.StartArray: var val = new List(); reader.Read(); @@ -36,7 +37,7 @@ public override void WriteJson(JsonWriter writer, EppoValue? value, JsonSerializ } return new EppoValue(val); default: - throw new UnsupportedEppoValueException("Unsupported Eppo Values"); + throw new UnsupportedEppoValueException("Unsupported Eppo Value Type: " + reader.TokenType.ToString()); } } } \ No newline at end of file diff --git a/dot-net-sdk/dto/EppoValueType.cs b/dot-net-sdk/dto/EppoValueType.cs index 726ce6c..c20b4bc 100644 --- a/dot-net-sdk/dto/EppoValueType.cs +++ b/dot-net-sdk/dto/EppoValueType.cs @@ -3,6 +3,7 @@ namespace eppo_sdk.dto; public enum EppoValueType { NUMBER, + INTEGER, STRING, BOOLEAN, NULL, diff --git a/dot-net-sdk/dto/OperatorType.cs b/dot-net-sdk/dto/OperatorType.cs index 6cf3e43..f7628aa 100644 --- a/dot-net-sdk/dto/OperatorType.cs +++ b/dot-net-sdk/dto/OperatorType.cs @@ -12,5 +12,6 @@ public enum OperatorType LTE, LT, ONE_OF, - NOT_ONE_OF + NOT_ONE_OF, + IS_NULL } diff --git a/dot-net-sdk/http/ExperimentConfigurationRequester.cs b/dot-net-sdk/http/ExperimentConfigurationRequester.cs index 0f76f06..3713e59 100644 --- a/dot-net-sdk/http/ExperimentConfigurationRequester.cs +++ b/dot-net-sdk/http/ExperimentConfigurationRequester.cs @@ -15,20 +15,15 @@ public ExperimentConfigurationRequester(EppoHttpClient eppoHttpClient) { public ExperimentConfigurationResponse? FetchExperimentConfiguration() { - ExperimentConfigurationResponse? config = null; try { return this.eppoHttpClient.Get(Constants.RAC_ENDPOINT); } - catch (UnauthorizedAccessException e) - { - throw e; - } catch (Exception e) { logger.Warn($"Unable to Fetch Experiment Configuration: {e.Message}"); } - return config; + return null; } } \ No newline at end of file diff --git a/dot-net-sdk/validators/RuleValidator.cs b/dot-net-sdk/validators/RuleValidator.cs index 02b6938..54c3170 100644 --- a/dot-net-sdk/validators/RuleValidator.cs +++ b/dot-net-sdk/validators/RuleValidator.cs @@ -28,14 +28,18 @@ private static bool EvaluateCondition(SubjectAttributes subjectAttributes, Condi { try { - if (subjectAttributes.ContainsKey(condition.attribute) && - subjectAttributes.TryGetValue(condition.attribute, out EppoValue outVal)) + // Operators other than `IS_NULL` need to assume non-null + if (condition.operatorType == IS_NULL) { + bool isNull = !subjectAttributes.TryGetValue(condition.attribute, out EppoValue? outVal) || EppoValue.IsNullValue(outVal); + return condition.value.BoolValue() == isNull; + } + else if (subjectAttributes.TryGetValue(condition.attribute, out EppoValue? outVal)) { var value = outVal!; // Assuming non-null for simplicity, handle nulls as necessary if (condition.operatorType == GTE) { - if (value.isNumeric() && condition.value.isNumeric()) + if (value.IsNumeric() && condition.value.IsNumeric()) { return value.DoubleValue() >= condition.value.DoubleValue(); } @@ -50,7 +54,7 @@ private static bool EvaluateCondition(SubjectAttributes subjectAttributes, Condi } else if (condition.operatorType == GT) { - if (value.isNumeric() && condition.value.isNumeric()) + if (value.IsNumeric() && condition.value.IsNumeric()) { return value.DoubleValue() > condition.value.DoubleValue(); } @@ -65,7 +69,7 @@ private static bool EvaluateCondition(SubjectAttributes subjectAttributes, Condi } else if (condition.operatorType == LTE) { - if (value.isNumeric() && condition.value.isNumeric()) + if (value.IsNumeric() && condition.value.IsNumeric()) { return value.DoubleValue() <= condition.value.DoubleValue(); } @@ -80,7 +84,7 @@ private static bool EvaluateCondition(SubjectAttributes subjectAttributes, Condi } else if (condition.operatorType == LT) { - if (value.isNumeric() && condition.value.isNumeric()) + if (value.IsNumeric() && condition.value.IsNumeric()) { return value.DoubleValue() < condition.value.DoubleValue(); } diff --git a/eppo-sdk-test/EppoClientTest.cs b/eppo-sdk-test/EppoClientTest.cs index a6ce2b4..50153a1 100644 --- a/eppo-sdk-test/EppoClientTest.cs +++ b/eppo-sdk-test/EppoClientTest.cs @@ -64,6 +64,11 @@ public void ShouldValidateAssignments(AssignmentTestCase assignmentTestCase) var numericExpectations = assignmentTestCase.expectedAssignments.ConvertAll(x => x.DoubleValue()); Assert.That(GetNumericAssignments(assignmentTestCase), Is.EqualTo(numericExpectations)); + break; + case "integer": + var intExpectations = assignmentTestCase.expectedAssignments.ConvertAll(x => x.IntegerValue()); + Assert.That(GetIntegerAssignments(assignmentTestCase), Is.EqualTo(intExpectations)); + break; case "string": var stringExpectations = assignmentTestCase.expectedAssignments.ConvertAll(x => x.StringValue()); @@ -99,6 +104,19 @@ public void ShouldValidateAssignments(AssignmentTestCase assignmentTestCase) client.GetNumericAssignment(subject, assignmentTestCase.experiment)); } + private static List GetIntegerAssignments(AssignmentTestCase assignmentTestCase) + { + var client = EppoClient.GetInstance(); + if (assignmentTestCase.subjectsWithAttributes != null) + { + return assignmentTestCase.subjectsWithAttributes.ConvertAll(subject => client.GetIntegerAssignment(subject.subjectKey, assignmentTestCase.experiment, + subject.subjectAttributes)); + } + + return assignmentTestCase.subjects.ConvertAll(subject => + client.GetIntegerAssignment(subject, assignmentTestCase.experiment)); + } + private static List GetStringAssignments(AssignmentTestCase assignmentTestCase) { var client = EppoClient.GetInstance(); diff --git a/eppo-sdk-test/validators/RuleValidatorTest.cs b/eppo-sdk-test/validators/RuleValidatorTest.cs index 4103e21..c3c5341 100644 --- a/eppo-sdk-test/validators/RuleValidatorTest.cs +++ b/eppo-sdk-test/validators/RuleValidatorTest.cs @@ -52,8 +52,8 @@ public void ShouldMatchAnyRuleWhenRuleMatches() var subjectAttributes = new SubjectAttributes { - { "price", new EppoValue("15", EppoValueType.NUMBER) }, - { "appVersion", new EppoValue("1.15.0", EppoValueType.STRING) } + { "price", EppoValue.Number("15") }, + { "appVersion", EppoValue.String("1.15.0") } }; Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); @@ -67,7 +67,7 @@ public void ShouldNotMatchAnyRuleWhenThrowInvalidSubjectAttribute() AddNumericConditionToRule(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "price", new EppoValue("abcd", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "price", EppoValue.String("abcd") } }; Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); } @@ -80,7 +80,7 @@ public void ShouldMatchAnyRuleWithRegexCondition() AddRegexConditionToRule(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "match", new EppoValue("abcd", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "match", EppoValue.String("abcd")} }; Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); } @@ -93,7 +93,7 @@ public void ShouldNotMatchAnyRuleWithRegexConditionIsUnmatched() AddRegexConditionToRule(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "match", new EppoValue("123", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "match", EppoValue.String("123") } }; Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); } @@ -106,7 +106,7 @@ public void ShouldMatchAnyRuleWithOneOfRule() AddOneOfCondition(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "oneOf", new EppoValue("value2", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "oneOf", EppoValue.String("value2") } }; Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); } @@ -119,7 +119,7 @@ public void ShouldNotMatchAnyRuleWithOneOfRule() AddOneOfCondition(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "oneOf", new EppoValue("value3", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "oneOf", EppoValue.String("value3")} }; Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); } @@ -132,7 +132,7 @@ public void ShouldMatchAnyRuleWithNotOneOfRule() AddNotOneOfCondition(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "oneOf", new EppoValue("value3", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "oneOf", EppoValue.String("value3") } }; Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); } @@ -145,11 +145,99 @@ public void ShouldNotMatchAnyRuleWithNotOneOfRuleNotPassed() AddNotOneOfCondition(rule); rules.Add(rule); - var subjectAttributes = new SubjectAttributes { { "oneOf", new EppoValue("value1", EppoValueType.STRING) } }; + var subjectAttributes = new SubjectAttributes { { "oneOf", EppoValue.String("value1") } }; Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); } + [Test] + public void ShouldMatchRuleIsNullTrueNullType() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, true); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { { "isnull", new EppoValue() } }; + + Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); + } + + [Test] + public void ShouldMatchRuleIsNullTrue() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, true); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { { "isnull", EppoValue.String(null) } }; + + Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); + } + + + [Test] + public void ShouldMatchRuleIsNullNoAttribute() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, true); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { }; + + Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); + } + + [Test] + public void ShouldMatchRuleIsNullFalse() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, false); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { { "isnull", EppoValue.String("not null") } }; + + Assert.That(rule, Is.EqualTo(RuleValidator.FindMatchingRule(subjectAttributes, rules))); + } + + [Test] + public void ShouldNotMatchRuleIsNullTrue() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, true); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { { "isnull", EppoValue.String("not null") } }; + + Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); + } + + [Test] + public void ShouldNotMatchRuleIsNullFalse() + { + var rules = new List(); + var rule = CreateRule(new List()); + AddIsNullCondition(rule, false); + rules.Add(rule); + + var subjectAttributes = new SubjectAttributes { { "isnull", new EppoValue() } }; + + Assert.That(RuleValidator.FindMatchingRule(subjectAttributes, rules), Is.Null); + } + private static void AddIsNullCondition(Rule rule, Boolean value) + { + rule.conditions.Add(new Condition + { + value = EppoValue.Bool(value), + attribute = "isnull", + operatorType = OperatorType.IS_NULL + }); + } + private static void AddOneOfCondition(Rule rule) { rule.conditions.Add(new Condition @@ -182,7 +270,7 @@ private static void AddRegexConditionToRule(Rule rule) { var condition = new Condition { - value = new EppoValue("[a-z]+", EppoValueType.STRING), + value = EppoValue.String("[a-z]+"), attribute = "match", operatorType = OperatorType.MATCHES }; @@ -191,21 +279,21 @@ private static void AddRegexConditionToRule(Rule rule) private static void AddPriceToSubjectAttribute(SubjectAttributes subjectAttributes) { - subjectAttributes.Add("price", new EppoValue("30", EppoValueType.STRING)); + subjectAttributes.Add("price", EppoValue.String("30")); } private static void AddNumericConditionToRule(Rule rule) { rule.conditions.Add(new Condition { - value = new EppoValue("10", EppoValueType.NUMBER), + value = EppoValue.Number("10"), attribute = "price", operatorType = OperatorType.GTE }); rule.conditions.Add(new Condition { - value = new EppoValue("20", EppoValueType.NUMBER), + value = EppoValue.Number("20"), attribute = "price", operatorType = OperatorType.LTE }); @@ -215,14 +303,14 @@ private static void AddSemVerConditionToRule(Rule rule) { rule.conditions.Add(new Condition { - value = new EppoValue("1.2.3", EppoValueType.STRING), + value = EppoValue.String("1.2.3"), attribute = "appVersion", operatorType = OperatorType.GTE }); rule.conditions.Add(new Condition { - value = new EppoValue("2.2.0", EppoValueType.STRING), + value = EppoValue.String("2.2.0"), attribute = "appVersion", operatorType = OperatorType.LTE }); @@ -230,7 +318,7 @@ private static void AddSemVerConditionToRule(Rule rule) private static void AddNameToSubjectAttribute(SubjectAttributes subjectAttributes) { - subjectAttributes.Add("name", new EppoValue("test", EppoValueType.STRING)); + subjectAttributes.Add("name", EppoValue.String("test")); } private static Rule CreateRule(List conditions)