w4c-workflows-api/Services/Nodes/Interpolation/NodeParameterInterpolator.cs
2026-09-11 19:04:50 +03:00

340 lines
12 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes.Interpolation;
/// <summary>Raised when a parameter expression cannot be resolved.</summary>
public sealed class NodeInterpolationException : Exception
{
public NodeInterpolationException(string expression, string reason)
: base(reason)
{
Expression = expression;
}
public string Expression { get; }
}
/// <summary>
/// Data a parameter expression can read from. Kept small and explicit so the
/// resolver stays sandbox-safe (no reflection, no I/O, no arbitrary calls).
/// </summary>
public sealed record InterpolationScope
{
/// <summary>The item being mapped; exposed as <c>$json</c> / <c>$item</c>.</summary>
public JsonObject? Item { get; init; }
/// <summary>The node's own parameters; exposed as <c>$parameter</c>.</summary>
public JsonObject? Parameters { get; init; }
/// <summary>Workflow environment; exposed as <c>$env</c>.</summary>
public JsonObject? Environment { get; init; }
public int ItemIndex { get; init; }
public int RunIndex { get; init; }
/// <summary>Resolves items emitted by another node (<c>$("Name")</c> / <c>$items("Name")</c>).</summary>
public Func<string, IReadOnlyList<FlowItem>>? NodeItems { get; init; }
}
/// <summary>
/// Our parameter interpolation engine.
///
/// Rules:
/// - A string is in expression mode when it starts with '=' or contains a
/// <c>{{ … }}</c> block; otherwise it is a literal.
/// - In expression mode, <c>{{ … }}</c> blocks are evaluated and any text
/// around them stays literal.
/// - When the whole value is a single block, the resolved value keeps its
/// type (number, boolean, object, array).
/// - When a block is embedded in surrounding text, the result is a string and
/// non-string values are rendered (objects/arrays as JSON).
/// - Objects and arrays are resolved recursively, so expressions nested in a
/// collection field are applied per leaf.
///
/// Grammar supported in increment 1: literals (string/number/bool/null) and
/// <c>$root</c> accessor paths with <c>.name</c>, <c>["name"]</c> and <c>[index]</c>.
/// </summary>
public sealed class NodeParameterInterpolator
{
private const char ExpressionPrefix = '=';
public JsonObject ResolveObject(JsonObject parameters, InterpolationScope scope)
{
var result = new JsonObject();
foreach (var (name, value) in parameters)
result[name] = ResolveNode(value, scope);
return result;
}
public JsonNode? ResolveNode(JsonNode? value, InterpolationScope scope) => value switch
{
null => null,
JsonObject obj => ResolveObject(obj, scope),
JsonArray array => ResolveArray(array, scope),
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var text) => ResolveString(text, scope),
JsonValue jsonValue => jsonValue.DeepClone(),
_ => value.DeepClone(),
};
private JsonArray ResolveArray(JsonArray array, InterpolationScope scope)
{
var result = new JsonArray();
foreach (var element in array)
result.Add(ResolveNode(element, scope));
return result;
}
/// <summary>Resolves a single string value according to the rules above.</summary>
public JsonNode? ResolveString(string text, InterpolationScope scope)
{
if (text.Length == 0)
return JsonValue.Create(text);
string body;
if (text[0] == ExpressionPrefix)
{
body = text[1..];
}
else
{
if (!text.Contains("{{", StringComparison.Ordinal))
return JsonValue.Create(text); // no delimiters → literal
body = text;
}
var blocks = ExtractBlocks(body);
if (blocks.Count == 0)
return JsonValue.Create(body); // '=' with no delimiters → literal text
if (blocks.Count == 1 && blocks[0].Start == 0 && blocks[0].End == body.Length)
{
// Whole-value expression: preserve the resolved type.
var resolved = Evaluate(blocks[0].Expression, scope);
return resolved?.DeepClone();
}
var builder = new StringBuilder();
var cursor = 0;
foreach (var block in blocks)
{
if (block.Start > cursor)
builder.Append(body, cursor, block.Start - cursor);
builder.Append(Render(Evaluate(block.Expression, scope)));
cursor = block.End;
}
if (cursor < body.Length)
builder.Append(body, cursor, body.Length - cursor);
return JsonValue.Create(builder.ToString());
}
// ---------------------------------------------------------------- expressions
private JsonNode? Evaluate(string expression, InterpolationScope scope)
{
var expr = expression.Trim();
if (expr.Length == 0)
throw new NodeInterpolationException(expression, "empty expression");
if (expr[0] == '$')
return EvaluateAccessor(expr, scope);
return ParseLiteral(expr, expression);
}
private JsonNode? EvaluateAccessor(string expr, InterpolationScope scope)
{
// Node references: $("Name"), $node("Name"), $items("Name").
if (TryReadNodeReference(expr, out var nodeName, out var wantsArray, out var tail))
{
var items = scope.NodeItems?.Invoke(nodeName) ?? Array.Empty<FlowItem>();
JsonNode? node = wantsArray
? new JsonArray(items.Select(i => (JsonNode?)i.Json.DeepClone()).ToArray())
: items.Count > 0
? items[0].Json.DeepClone()
: null;
return tail.Length == 0 ? node : Navigate(node, tail, expr);
}
// Root token: $ + identifier.
var end = 1;
while (end < expr.Length && (char.IsLetterOrDigit(expr[end]) || expr[end] == '_'))
end++;
var root = expr[..end];
var path = expr[end..];
JsonNode? start = root switch
{
"$json" or "$item" => scope.Item?.DeepClone(),
"$parameter" => scope.Parameters?.DeepClone(),
"$env" => scope.Environment?.DeepClone(),
"$itemIndex" => JsonValue.Create(scope.ItemIndex),
"$runIndex" => JsonValue.Create(scope.RunIndex),
"$now" => JsonValue.Create(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)),
"$today" => JsonValue.Create(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
_ => throw new NodeInterpolationException(expr, $"unknown accessor '{root}'"),
};
return path.Length == 0 ? start : Navigate(start, path, expr);
}
private static bool TryReadNodeReference(string expr, out string nodeName, out bool wantsArray, out string tail)
{
nodeName = string.Empty;
wantsArray = false;
tail = string.Empty;
string? prefix = null;
if (expr.StartsWith("$items(", StringComparison.Ordinal))
{
prefix = "$items(";
wantsArray = true;
}
else if (expr.StartsWith("$node(", StringComparison.Ordinal))
{
prefix = "$node(";
}
else if (expr.StartsWith("$(", StringComparison.Ordinal))
{
prefix = "$(";
}
if (prefix == null)
return false;
var close = expr.IndexOf(')', prefix.Length);
if (close < 0)
throw new NodeInterpolationException(expr, "unterminated node reference");
var inner = expr[prefix.Length..close].Trim();
if (inner.Length >= 2 &&
((inner[0] == '"' && inner[^1] == '"') || (inner[0] == '\'' && inner[^1] == '\'')))
{
inner = inner[1..^1];
}
nodeName = inner;
tail = expr[(close + 1)..];
return true;
}
private static JsonNode? Navigate(JsonNode? start, string path, string expression)
{
var cursor = 0;
var current = start;
while (cursor < path.Length)
{
var ch = path[cursor];
if (ch == '.')
{
cursor++;
var name = ReadIdentifier(path, ref cursor);
if (name.Length == 0)
throw new NodeInterpolationException(expression, $"expected a field name after '.' at {cursor}");
current = current is JsonObject obj ? obj[name] : null;
continue;
}
if (ch == '[')
{
cursor++;
var close = path.IndexOf(']', cursor);
if (close < 0)
throw new NodeInterpolationException(expression, "unterminated '[' in path");
var token = path[cursor..close].Trim();
cursor = close + 1;
if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0])
{
var key = token[1..^1];
current = current is JsonObject obj ? obj[key] : null;
}
else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index))
{
current = current is JsonArray array && index >= 0 && index < array.Count ? array[index] : null;
}
else
{
throw new NodeInterpolationException(expression, $"invalid array index '{token}'");
}
continue;
}
throw new NodeInterpolationException(expression, $"unexpected '{ch}' in path");
}
return current;
}
private static string ReadIdentifier(string path, ref int cursor)
{
var start = cursor;
while (cursor < path.Length && (char.IsLetterOrDigit(path[cursor]) || path[cursor] == '_' || path[cursor] == '-'))
cursor++;
return path[start..cursor];
}
private static JsonNode? ParseLiteral(string literal, string expression)
{
if (literal is "true" or "false")
return JsonValue.Create(literal == "true");
if (literal is "null")
return null;
if (literal.Length >= 2 &&
((literal[0] == '"' && literal[^1] == '"') || (literal[0] == '\'' && literal[^1] == '\'')))
{
return JsonValue.Create(literal[1..^1]);
}
if (decimal.TryParse(literal, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
return JsonValue.Create(number);
throw new NodeInterpolationException(expression, $"unsupported expression '{literal}'");
}
private static string Render(JsonNode? node) => node switch
{
null => string.Empty,
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonValue value => value.ToJsonString(),
_ => node.ToJsonString(),
};
// ------------------------------------------------------------------- scanning
private readonly record struct Block(int Start, int End, string Expression);
private static List<Block> ExtractBlocks(string body)
{
var blocks = new List<Block>();
var cursor = 0;
while (cursor < body.Length)
{
var open = body.IndexOf("{{", cursor, StringComparison.Ordinal);
if (open < 0)
break;
var close = body.IndexOf("}}", open + 2, StringComparison.Ordinal);
if (close < 0)
break; // unterminated block: treat the remainder as literal
blocks.Add(new Block(open, close + 2, body[(open + 2)..close]));
cursor = close + 2;
}
return blocks;
}
}