340 lines
12 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|