Skip to content

Commit

Permalink
feat: add custom JsonLogic evaluators (#159)
Browse files Browse the repository at this point in the history
Signed-off-by: Florian Bacher <[email protected]>
Co-authored-by: Todd Baert <[email protected]>
  • Loading branch information
bacherfl and toddbaert authored Mar 14, 2024
1 parent 98028e9 commit 18aa151
Show file tree
Hide file tree
Showing 11 changed files with 898 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
<!-- The schema.proto file referenced here will be used to automatically generate the Grpc client when executing 'dotnet build' -->
<!-- The generated files will be placed in ./obj/Debug/netstandard2.0/Protos -->
<PackageReference Include="JsonLogic.Net" Version="1.1.11" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="murmurhash" Version="1.0.3" />
<PackageReference Include="Semver" Version="2.3.0" />
<Protobuf Include="schemas\protobuf\schema\v1\schema.proto" GrpcServices="Client" />
<Protobuf Include="schemas\protobuf\flagd\sync\v1\sync.proto" GrpcServices="Client" />
<PackageReference Include="Google.Protobuf" Version="3.23.4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;

namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
{
internal class FlagdProperties
{

internal const string FlagdPropertiesKey = "$flagd";
internal const string FlagKeyKey = "flagKey";
internal const string TimestampKey = "timestamp";
internal const string TargetingKeyKey = "targetingKey";

internal string FlagKey { get; set; }
internal long Timestamp { get; set; }
internal string TargetingKey { get; set; }

internal FlagdProperties(object from)
{
//object value;
if (from is Dictionary<string, object> dict)
{
if (dict.TryGetValue(TargetingKeyKey, out object targetingKeyValue)
&& targetingKeyValue is string targetingKeyString)
{
TargetingKey = targetingKeyString;
}
if (dict.TryGetValue(FlagdPropertiesKey, out object flagdPropertiesObj)
&& flagdPropertiesObj is Dictionary<string, object> flagdProperties)
{
if (flagdProperties.TryGetValue(FlagKeyKey, out object flagKeyObj)
&& flagKeyObj is string flagKey)
{
FlagKey = flagKey;
}
if (flagdProperties.TryGetValue(TimestampKey, out object timestampObj)
&& timestampObj is long timestamp)
{
Timestamp = timestamp;
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using JsonLogic.Net;
using Microsoft.Extensions.Logging;
using Murmur;
using Newtonsoft.Json.Linq;
using Semver;

namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
{
/// <inheritdoc/>
public class FractionalEvaluator
{

internal ILogger Logger { get; set; }

internal FractionalEvaluator()
{
var loggerFactory = LoggerFactory.Create(
builder => builder
// add console as logging target
.AddConsole()
// add debug output as logging target
.AddDebug()
// set minimum level to log
.SetMinimumLevel(LogLevel.Debug)
);
Logger = loggerFactory.CreateLogger<FractionalEvaluator>();
}

class FractionalEvaluationDistribution
{
public string variant;
public int percentage;
}

internal object Evaluate(IProcessJsonLogic p, JToken[] args, object data)
{
// check if we have at least two arguments:
// 1. the property value
// 2. the array containing the buckets

if (args.Length == 0)
{
return null;
}

var flagdProperties = new FlagdProperties(data);

// check if the first argument is a string (i.e. the property to base the distribution on
var propertyValue = flagdProperties.TargetingKey;
var bucketStartIndex = 0;

var arg0 = p.Apply(args[0], data);

if (arg0 is string stringValue)
{
propertyValue = stringValue;
bucketStartIndex = 1;
}

var distributions = new List<FractionalEvaluationDistribution>();
var distributionSum = 0;

for (var i = bucketStartIndex; i < args.Length; i++)
{
var bucket = p.Apply(args[i], data);

if (!bucket.IsEnumerable())
{
continue;
}

var bucketArr = bucket.MakeEnumerable().ToArray();

if (bucketArr.Count() < 2)
{
continue;
}

if (!bucketArr.ElementAt(1).IsNumeric())
{
continue;
}


var percentage = Convert.ToInt32(bucketArr.ElementAt(1));
distributions.Add(new FractionalEvaluationDistribution
{
variant = bucketArr.ElementAt(0).ToString(),
percentage = percentage
});

distributionSum += percentage;
}

if (distributionSum != 100)
{
Logger.LogDebug("Sum of distribution values is not eqyal to 100");
return null;
}

var valueToDistribute = flagdProperties.FlagKey + propertyValue;
var murmur32 = MurmurHash.Create32();
var bytes = Encoding.ASCII.GetBytes(valueToDistribute);
var hashBytes = murmur32.ComputeHash(bytes);
var hash = BitConverter.ToInt32(hashBytes, 0);

var bucketValue = (int)(Math.Abs((float)hash) / Int32.MaxValue * 100);

var rangeEnd = 0;

foreach (var dist in distributions)
{
rangeEnd += dist.percentage;
if (bucketValue < rangeEnd)
{
return dist.variant;
}
}

Logger.LogDebug("No matching bucket found");
return "";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using JsonLogic.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Semver;

namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
{
/// <inheritdoc/>
public class SemVerEvaluator
{
internal ILogger Logger { get; set; }

internal SemVerEvaluator()
{
var loggerFactory = LoggerFactory.Create(
builder => builder
// add console as logging target
.AddConsole()
// add debug output as logging target
.AddDebug()
// set minimum level to log
.SetMinimumLevel(LogLevel.Debug)
);
Logger = loggerFactory.CreateLogger<SemVerEvaluator>();
}


const string OperatorEqual = "=";
const string OperatorNotEqual = "!=";
const string OperatorLess = "<";
const string OperatorLessOrEqual = "<=";
const string OperatorGreater = ">";
const string OperatorGreaterOrEqual = ">=";
const string OperatorMatchMajor = "^";
const string OperatorMatchMinor = "~";

internal object Evaluate(IProcessJsonLogic p, JToken[] args, object data)
{
// check if we have at least 3 arguments
if (args.Length < 3)
{
return false;
}
// get the value from the provided evaluation context
var versionString = p.Apply(args[0], data).ToString();

// get the operator
var semVerOperator = p.Apply(args[1], data).ToString();

// get the target version
var targetVersionString = p.Apply(args[2], data).ToString();

//convert to semantic versions
try
{
var version = SemVersion.Parse(versionString, SemVersionStyles.Strict);
var targetVersion = SemVersion.Parse(targetVersionString, SemVersionStyles.Strict);

switch (semVerOperator)
{
case OperatorEqual:
return version.CompareSortOrderTo(targetVersion) == 0;
case OperatorNotEqual:
return version.CompareSortOrderTo(targetVersion) != 0;
case OperatorLess:
return version.CompareSortOrderTo(targetVersion) < 0;
case OperatorLessOrEqual:
return version.CompareSortOrderTo(targetVersion) <= 0;
case OperatorGreater:
return version.CompareSortOrderTo(targetVersion) > 0;
case OperatorGreaterOrEqual:
return version.CompareSortOrderTo(targetVersion) >= 0;
case OperatorMatchMajor:
return version.Major == targetVersion.Major;
case OperatorMatchMinor:
return version.Major == targetVersion.Major && version.Minor == targetVersion.Minor;
default:
return false;
}
}
catch (Exception e)
{
Logger?.LogDebug("Exception during SemVer evaluation: " + e.Message);
return false;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
using System;
using JsonLogic.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;

namespace OpenFeature.Contrib.Providers.Flagd.Resolver.InProcess.CustomEvaluators
{
internal class StringEvaluator
{
internal ILogger Logger { get; set; }

internal StringEvaluator()
{
var loggerFactory = LoggerFactory.Create(
builder => builder
// add console as logging target
.AddConsole()
// add debug output as logging target
.AddDebug()
// set minimum level to log
.SetMinimumLevel(LogLevel.Debug)
);
Logger = loggerFactory.CreateLogger<StringEvaluator>();
}

internal object StartsWith(IProcessJsonLogic p, JToken[] args, object data)
{
// check if we have at least 2 arguments
if (args.Length < 2)
{
return false;
}
return p.Apply(args[0], data).ToString().StartsWith(p.Apply(args[1], data).ToString());
}

internal object EndsWith(IProcessJsonLogic p, JToken[] args, object data)
{
// check if we have at least 2 arguments
if (args.Length < 2)
{
return false;
}
return p.Apply(args[0], data).ToString().EndsWith(p.Apply(args[1], data).ToString());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Linq;
using System.Threading.Tasks;
using JsonLogic.Net;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenFeature.Constant;
Expand Down Expand Up @@ -56,14 +57,19 @@ internal class JsonEvaluator

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


internal JsonEvaluator(string selector)
{
_selector = selector;

var stringEvaluator = new StringEvaluator();
var semVerEvaluator = new SemVerEvaluator();
var fractionalEvaluator = new FractionalEvaluator();

EvaluateOperators.Default.AddOperator("starts_with", stringEvaluator.StartsWith);
EvaluateOperators.Default.AddOperator("ends_with", stringEvaluator.EndsWith);
EvaluateOperators.Default.AddOperator("sem_ver", semVerEvaluator.Evaluate);
EvaluateOperators.Default.AddOperator("fractional", fractionalEvaluator.Evaluate);
}

internal void Sync(FlagConfigurationUpdateType updateType, string flagConfigurations)
Expand Down Expand Up @@ -136,16 +142,31 @@ private ResolutionDetails<T> ResolveValue<T>(string flagKey, T defaultValue, Eva
var variant = flagConfiguration.DefaultVariant;
if (flagConfiguration.Targeting != null && !String.IsNullOrEmpty(flagConfiguration.Targeting.ToString()) && flagConfiguration.Targeting.ToString() != "{}")
{
var flagdProperties = new Dictionary<string, Value>();
flagdProperties.Add(FlagdProperties.FlagKeyKey, new Value(flagKey));
flagdProperties.Add(FlagdProperties.TimestampKey, new Value(DateTimeOffset.UtcNow.ToUnixTimeSeconds()));

if (context == null)
{
context = EvaluationContext.Builder().Build();
}

var targetingContext = context.AsDictionary().Add(
FlagdProperties.FlagdPropertiesKey,
new Value(new Structure(flagdProperties))
);

reason = Reason.TargetingMatch;
var targetingString = flagConfiguration.Targeting.ToString();
// Parse json into hierarchical structure
var rule = JObject.Parse(targetingString);
// the JsonLogic evaluator will return the variant for the value

// convert the EvaluationContext object into something the JsonLogic evaluator can work with
dynamic contextObj = (object)ConvertToDynamicObject(context.AsDictionary());
dynamic contextObj = (object)ConvertToDynamicObject(targetingContext);

variant = (string)_evaluator.Apply(rule, contextObj);

}


Expand Down
Loading

0 comments on commit 18aa151

Please sign in to comment.