using GB5Shared.DTO.PubSub;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
namespace GB5Shared.RuleEngine
{
///
/// Evaluates simple condition expressions against an
/// using for payload field access.
/// No dynamic compilation at evaluation time — safe for multi-tenant scenarios.
///
/// Supported expression format: "LHS operator RHS"
/// LHS: a property path, either:
/// - Top-level field of OutboxEventMessage: EventTypeId, ObjectTypeId, ObjectId, TenantId, UserId
/// - Payload field: Payload.SomeField (traverses one level of the JSON payload)
/// Operators: == != > < >= <=
/// RHS: a literal (number or string, no quotes)
///
/// Fail-open policy: on any parse/evaluation error the method logs a warning and returns true,
/// so a misconfigured expression never silently suppresses all action delivery.
///
public sealed class JsonPathConditionEvaluator : IConditionEvaluator
{
private readonly ILogger _logger;
public JsonPathConditionEvaluator(ILogger logger)
=> _logger = logger;
///
public bool Evaluate(string? expression, OutboxEventMessage message)
{
if (string.IsNullOrWhiteSpace(expression))
return true; // null/empty = always trigger
try
{
var (lhsPath, op, rhs) = Parse(expression);
var lhsValue = ResolveLeft(lhsPath, message);
if (lhsValue is null)
{
_logger.LogDebug(
"ConditionEvaluator: LHS path '{Path}' resolved to null for EventTypeId={EventTypeId} — treating as false",
lhsPath, message.EventTypeId);
return false;
}
return Compare(lhsValue, op, rhs);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"ConditionEvaluator: failed to evaluate expression '{Expression}' for EventTypeId={EventTypeId} — defaulting to true (fail-open)",
expression, message.EventTypeId);
return true; // fail-open
}
}
// ── Parser ───────────────────────────────────────────────────────────────
private static readonly string[] _operators = [">=", "<=", "!=", "==", ">", "<"];
private static (string lhs, string op, string rhs) Parse(string expression)
{
foreach (var op in _operators)
{
var idx = expression.IndexOf(op, StringComparison.Ordinal);
if (idx < 0) continue;
var lhs = expression[..idx].Trim();
var rhs = expression[(idx + op.Length)..].Trim();
if (string.IsNullOrEmpty(lhs) || string.IsNullOrEmpty(rhs))
continue;
return (lhs, op, rhs);
}
throw new ArgumentException(
$"ConditionEvaluator: cannot parse expression — no supported operator found: '{expression}'");
}
// ── LHS resolver ─────────────────────────────────────────────────────────
private static string? ResolveLeft(string path, OutboxEventMessage msg)
{
// Top-level OutboxEventMessage properties
var resolved = path switch
{
"EventTypeId" => msg.EventTypeId.ToString(),
"ObjectTypeId" => msg.ObjectTypeId.ToString(),
"ObjectId" => msg.ObjectId.ToString(),
"TenantId" => msg.TenantId.ToString(),
"UserId" => msg.UserId.ToString(),
_ => null
};
if (resolved is not null) return resolved;
// Payload.FieldName — one-level JSON traversal
if (path.StartsWith("Payload.", StringComparison.OrdinalIgnoreCase))
{
var fieldName = path["Payload.".Length..];
return ResolvePayloadField(msg.Payload, fieldName);
}
return null; // unknown path
}
private static string? ResolvePayloadField(string? payloadJson, string fieldName)
{
if (string.IsNullOrWhiteSpace(payloadJson)) return null;
try
{
using var doc = JsonDocument.Parse(payloadJson);
var root = doc.RootElement;
// Case-insensitive property search within the payload
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, fieldName, StringComparison.OrdinalIgnoreCase))
continue;
return prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => prop.Value.GetRawText()
};
}
}
catch (JsonException)
{
// Malformed payload — return null; caller will treat as false
}
return null;
}
// ── Comparator ───────────────────────────────────────────────────────────
private static bool Compare(string lhs, string op, string rhs)
{
// Try numeric comparison first; fall back to string comparison
if (decimal.TryParse(lhs, out var lhsNum) &&
decimal.TryParse(rhs, out var rhsNum))
{
return op switch
{
"==" => lhsNum == rhsNum,
"!=" => lhsNum != rhsNum,
">" => lhsNum > rhsNum,
"<" => lhsNum < rhsNum,
">=" => lhsNum >= rhsNum,
"<=" => lhsNum <= rhsNum,
_ => throw new ArgumentException($"Unknown operator: {op}")
};
}
// String comparison (ordinal, case-insensitive)
var cmp = string.Compare(lhs, rhs, StringComparison.OrdinalIgnoreCase);
return op switch
{
"==" => cmp == 0,
"!=" => cmp != 0,
">" => cmp > 0,
"<" => cmp < 0,
">=" => cmp >= 0,
"<=" => cmp <= 0,
_ => throw new ArgumentException($"Unknown operator: {op}")
};
}
}
}