Skip to content

Commit 18aa151

Browse files
bacherfltoddbaert
andauthored
feat: add custom JsonLogic evaluators (#159)
Signed-off-by: Florian Bacher <[email protected]> Co-authored-by: Todd Baert <[email protected]>
1 parent 98028e9 commit 18aa151

File tree

11 files changed

+898
-1
lines changed

11 files changed

+898
-1
lines changed

Diff for: src/OpenFeature.Contrib.Providers.Flagd/OpenFeature.Contrib.Providers.Flagd.csproj

+5
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
<!-- The schema.proto file referenced here will be used to automatically generate the Grpc client when executing 'dotnet build' -->
2424
<!-- The generated files will be placed in ./obj/Debug/netstandard2.0/Protos -->
2525
<PackageReference Include="JsonLogic.Net" Version="1.1.11" />
26+
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
27+
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
28+
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
29+
<PackageReference Include="murmurhash" Version="1.0.3" />
30+
<PackageReference Include="Semver" Version="2.3.0" />
2631
<Protobuf Include="schemas\protobuf\schema\v1\schema.proto" GrpcServices="Client" />
2732
<Protobuf Include="schemas\protobuf\flagd\sync\v1\sync.proto" GrpcServices="Client" />
2833
<PackageReference Include="Google.Protobuf" Version="3.23.4" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Collections.Generic;
2+
3+
namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
4+
{
5+
internal class FlagdProperties
6+
{
7+
8+
internal const string FlagdPropertiesKey = "$flagd";
9+
internal const string FlagKeyKey = "flagKey";
10+
internal const string TimestampKey = "timestamp";
11+
internal const string TargetingKeyKey = "targetingKey";
12+
13+
internal string FlagKey { get; set; }
14+
internal long Timestamp { get; set; }
15+
internal string TargetingKey { get; set; }
16+
17+
internal FlagdProperties(object from)
18+
{
19+
//object value;
20+
if (from is Dictionary<string, object> dict)
21+
{
22+
if (dict.TryGetValue(TargetingKeyKey, out object targetingKeyValue)
23+
&& targetingKeyValue is string targetingKeyString)
24+
{
25+
TargetingKey = targetingKeyString;
26+
}
27+
if (dict.TryGetValue(FlagdPropertiesKey, out object flagdPropertiesObj)
28+
&& flagdPropertiesObj is Dictionary<string, object> flagdProperties)
29+
{
30+
if (flagdProperties.TryGetValue(FlagKeyKey, out object flagKeyObj)
31+
&& flagKeyObj is string flagKey)
32+
{
33+
FlagKey = flagKey;
34+
}
35+
if (flagdProperties.TryGetValue(TimestampKey, out object timestampObj)
36+
&& timestampObj is long timestamp)
37+
{
38+
Timestamp = timestamp;
39+
}
40+
}
41+
}
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using JsonLogic.Net;
6+
using Microsoft.Extensions.Logging;
7+
using Murmur;
8+
using Newtonsoft.Json.Linq;
9+
using Semver;
10+
11+
namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
12+
{
13+
/// <inheritdoc/>
14+
public class FractionalEvaluator
15+
{
16+
17+
internal ILogger Logger { get; set; }
18+
19+
internal FractionalEvaluator()
20+
{
21+
var loggerFactory = LoggerFactory.Create(
22+
builder => builder
23+
// add console as logging target
24+
.AddConsole()
25+
// add debug output as logging target
26+
.AddDebug()
27+
// set minimum level to log
28+
.SetMinimumLevel(LogLevel.Debug)
29+
);
30+
Logger = loggerFactory.CreateLogger<FractionalEvaluator>();
31+
}
32+
33+
class FractionalEvaluationDistribution
34+
{
35+
public string variant;
36+
public int percentage;
37+
}
38+
39+
internal object Evaluate(IProcessJsonLogic p, JToken[] args, object data)
40+
{
41+
// check if we have at least two arguments:
42+
// 1. the property value
43+
// 2. the array containing the buckets
44+
45+
if (args.Length == 0)
46+
{
47+
return null;
48+
}
49+
50+
var flagdProperties = new FlagdProperties(data);
51+
52+
// check if the first argument is a string (i.e. the property to base the distribution on
53+
var propertyValue = flagdProperties.TargetingKey;
54+
var bucketStartIndex = 0;
55+
56+
var arg0 = p.Apply(args[0], data);
57+
58+
if (arg0 is string stringValue)
59+
{
60+
propertyValue = stringValue;
61+
bucketStartIndex = 1;
62+
}
63+
64+
var distributions = new List<FractionalEvaluationDistribution>();
65+
var distributionSum = 0;
66+
67+
for (var i = bucketStartIndex; i < args.Length; i++)
68+
{
69+
var bucket = p.Apply(args[i], data);
70+
71+
if (!bucket.IsEnumerable())
72+
{
73+
continue;
74+
}
75+
76+
var bucketArr = bucket.MakeEnumerable().ToArray();
77+
78+
if (bucketArr.Count() < 2)
79+
{
80+
continue;
81+
}
82+
83+
if (!bucketArr.ElementAt(1).IsNumeric())
84+
{
85+
continue;
86+
}
87+
88+
89+
var percentage = Convert.ToInt32(bucketArr.ElementAt(1));
90+
distributions.Add(new FractionalEvaluationDistribution
91+
{
92+
variant = bucketArr.ElementAt(0).ToString(),
93+
percentage = percentage
94+
});
95+
96+
distributionSum += percentage;
97+
}
98+
99+
if (distributionSum != 100)
100+
{
101+
Logger.LogDebug("Sum of distribution values is not eqyal to 100");
102+
return null;
103+
}
104+
105+
var valueToDistribute = flagdProperties.FlagKey + propertyValue;
106+
var murmur32 = MurmurHash.Create32();
107+
var bytes = Encoding.ASCII.GetBytes(valueToDistribute);
108+
var hashBytes = murmur32.ComputeHash(bytes);
109+
var hash = BitConverter.ToInt32(hashBytes, 0);
110+
111+
var bucketValue = (int)(Math.Abs((float)hash) / Int32.MaxValue * 100);
112+
113+
var rangeEnd = 0;
114+
115+
foreach (var dist in distributions)
116+
{
117+
rangeEnd += dist.percentage;
118+
if (bucketValue < rangeEnd)
119+
{
120+
return dist.variant;
121+
}
122+
}
123+
124+
Logger.LogDebug("No matching bucket found");
125+
return "";
126+
}
127+
}
128+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System;
2+
using JsonLogic.Net;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
using Newtonsoft.Json.Linq;
6+
using Semver;
7+
8+
namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
9+
{
10+
/// <inheritdoc/>
11+
public class SemVerEvaluator
12+
{
13+
internal ILogger Logger { get; set; }
14+
15+
internal SemVerEvaluator()
16+
{
17+
var loggerFactory = LoggerFactory.Create(
18+
builder => builder
19+
// add console as logging target
20+
.AddConsole()
21+
// add debug output as logging target
22+
.AddDebug()
23+
// set minimum level to log
24+
.SetMinimumLevel(LogLevel.Debug)
25+
);
26+
Logger = loggerFactory.CreateLogger<SemVerEvaluator>();
27+
}
28+
29+
30+
const string OperatorEqual = "=";
31+
const string OperatorNotEqual = "!=";
32+
const string OperatorLess = "<";
33+
const string OperatorLessOrEqual = "<=";
34+
const string OperatorGreater = ">";
35+
const string OperatorGreaterOrEqual = ">=";
36+
const string OperatorMatchMajor = "^";
37+
const string OperatorMatchMinor = "~";
38+
39+
internal object Evaluate(IProcessJsonLogic p, JToken[] args, object data)
40+
{
41+
// check if we have at least 3 arguments
42+
if (args.Length < 3)
43+
{
44+
return false;
45+
}
46+
// get the value from the provided evaluation context
47+
var versionString = p.Apply(args[0], data).ToString();
48+
49+
// get the operator
50+
var semVerOperator = p.Apply(args[1], data).ToString();
51+
52+
// get the target version
53+
var targetVersionString = p.Apply(args[2], data).ToString();
54+
55+
//convert to semantic versions
56+
try
57+
{
58+
var version = SemVersion.Parse(versionString, SemVersionStyles.Strict);
59+
var targetVersion = SemVersion.Parse(targetVersionString, SemVersionStyles.Strict);
60+
61+
switch (semVerOperator)
62+
{
63+
case OperatorEqual:
64+
return version.CompareSortOrderTo(targetVersion) == 0;
65+
case OperatorNotEqual:
66+
return version.CompareSortOrderTo(targetVersion) != 0;
67+
case OperatorLess:
68+
return version.CompareSortOrderTo(targetVersion) < 0;
69+
case OperatorLessOrEqual:
70+
return version.CompareSortOrderTo(targetVersion) <= 0;
71+
case OperatorGreater:
72+
return version.CompareSortOrderTo(targetVersion) > 0;
73+
case OperatorGreaterOrEqual:
74+
return version.CompareSortOrderTo(targetVersion) >= 0;
75+
case OperatorMatchMajor:
76+
return version.Major == targetVersion.Major;
77+
case OperatorMatchMinor:
78+
return version.Major == targetVersion.Major && version.Minor == targetVersion.Minor;
79+
default:
80+
return false;
81+
}
82+
}
83+
catch (Exception e)
84+
{
85+
Logger?.LogDebug("Exception during SemVer evaluation: " + e.Message);
86+
return false;
87+
}
88+
}
89+
}
90+
}

Diff for: src/OpenFeature.Contrib.Providers.Flagd/Resolver/InProcess/CustomEvaluators/StringEvaluator.cs

+28
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,46 @@
11
using System;
22
using JsonLogic.Net;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Logging.Abstractions;
35
using Newtonsoft.Json.Linq;
46

57
namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
68
{
79
internal class StringEvaluator
810
{
11+
internal ILogger Logger { get; set; }
12+
13+
internal StringEvaluator()
14+
{
15+
var loggerFactory = LoggerFactory.Create(
16+
builder => builder
17+
// add console as logging target
18+
.AddConsole()
19+
// add debug output as logging target
20+
.AddDebug()
21+
// set minimum level to log
22+
.SetMinimumLevel(LogLevel.Debug)
23+
);
24+
Logger = loggerFactory.CreateLogger<StringEvaluator>();
25+
}
26+
927
internal object StartsWith(IProcessJsonLogic p, JToken[] args, object data)
1028
{
29+
// check if we have at least 2 arguments
30+
if (args.Length < 2)
31+
{
32+
return false;
33+
}
1134
return p.Apply(args[0], data).ToString().StartsWith(p.Apply(args[1], data).ToString());
1235
}
1336

1437
internal object EndsWith(IProcessJsonLogic p, JToken[] args, object data)
1538
{
39+
// check if we have at least 2 arguments
40+
if (args.Length < 2)
41+
{
42+
return false;
43+
}
1644
return p.Apply(args[0], data).ToString().EndsWith(p.Apply(args[1], data).ToString());
1745
}
1846
}

Diff for: src/OpenFeature.Contrib.Providers.Flagd/Resolver/InProcess/JsonEvaluator.cs

+22-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Linq;
55
using System.Threading.Tasks;
66
using JsonLogic.Net;
7+
using Microsoft.Extensions.Logging;
78
using Newtonsoft.Json;
89
using Newtonsoft.Json.Linq;
910
using OpenFeature.Constant;
@@ -56,14 +57,19 @@ internal class JsonEvaluator
5657

5758
private readonly JsonLogicEvaluator _evaluator = new JsonLogicEvaluator(EvaluateOperators.Default);
5859

60+
5961
internal JsonEvaluator(string selector)
6062
{
6163
_selector = selector;
6264

6365
var stringEvaluator = new StringEvaluator();
66+
var semVerEvaluator = new SemVerEvaluator();
67+
var fractionalEvaluator = new FractionalEvaluator();
6468

6569
EvaluateOperators.Default.AddOperator("starts_with", stringEvaluator.StartsWith);
6670
EvaluateOperators.Default.AddOperator("ends_with", stringEvaluator.EndsWith);
71+
EvaluateOperators.Default.AddOperator("sem_ver", semVerEvaluator.Evaluate);
72+
EvaluateOperators.Default.AddOperator("fractional", fractionalEvaluator.Evaluate);
6773
}
6874

6975
internal void Sync(FlagConfigurationUpdateType updateType, string flagConfigurations)
@@ -136,16 +142,31 @@ private ResolutionDetails<T> ResolveValue<T>(string flagKey, T defaultValue, Eva
136142
var variant = flagConfiguration.DefaultVariant;
137143
if (flagConfiguration.Targeting != null && !String.IsNullOrEmpty(flagConfiguration.Targeting.ToString()) && flagConfiguration.Targeting.ToString() != "{}")
138144
{
145+
var flagdProperties = new Dictionary<string, Value>();
146+
flagdProperties.Add(FlagdProperties.FlagKeyKey, new Value(flagKey));
147+
flagdProperties.Add(FlagdProperties.TimestampKey, new Value(DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
148+
149+
if (context == null)
150+
{
151+
context = EvaluationContext.Builder().Build();
152+
}
153+
154+
var targetingContext = context.AsDictionary().Add(
155+
FlagdProperties.FlagdPropertiesKey,
156+
new Value(new Structure(flagdProperties))
157+
);
158+
139159
reason = Reason.TargetingMatch;
140160
var targetingString = flagConfiguration.Targeting.ToString();
141161
// Parse json into hierarchical structure
142162
var rule = JObject.Parse(targetingString);
143163
// the JsonLogic evaluator will return the variant for the value
144164

145165
// convert the EvaluationContext object into something the JsonLogic evaluator can work with
146-
dynamic contextObj = (object)ConvertToDynamicObject(context.AsDictionary());
166+
dynamic contextObj = (object)ConvertToDynamicObject(targetingContext);
147167

148168
variant = (string)_evaluator.Apply(rule, contextObj);
169+
149170
}
150171

151172

0 commit comments

Comments
 (0)