w4c-workflows-api/Services/Nodes/NodePath.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

158 lines
5.6 KiB
C#

using System.Globalization;
using System.Text.Json.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>One step in a resolved item path: a property name or an array index.</summary>
internal readonly record struct NodePathSegment(string? Name, int Index)
{
public bool IsIndex => Name is null;
public static NodePathSegment Property(string name) => new(name, 0);
public static NodePathSegment ElementAt(int index) => new(null, index);
}
/// <summary>
/// The single resolver for "@item" style paths, shared by the expression
/// interpolator and the executor field readers (sort / dedup / aggregate /
/// HTTP output paths). Supports <c>.name</c>, <c>["name"]</c> and <c>[index]</c>,
/// so <c>items[0].id</c> resolves everywhere; before the merge the executors'
/// resolver silently failed on array indices.
///
/// Parsing and navigation are separated so the interpolator can turn a parse
/// error into a typed exception while the executor readers treat it as a miss.
/// </summary>
internal static class NodePath
{
/// <summary>
/// Resolves a path against <paramref name="root"/> and returns the node, or
/// null when the path is empty, malformed or absent. Never throws.
/// <paramref name="dotNotation"/> false treats the whole path as one literal
/// property name (a field name that may itself contain dots).
/// </summary>
public static JsonNode? Read(JsonNode? root, string? path, bool dotNotation)
{
if (string.IsNullOrEmpty(path))
return null;
if (!dotNotation)
return root is JsonObject literal ? Property(literal, path) : null;
return TryParse(path, out var segments, out _) ? Navigate(root, segments) : null;
}
/// <summary>
/// Parses a path into segments. Returns false with a human-readable
/// <paramref name="error"/> when the syntax is invalid.
/// </summary>
public static bool TryParse(string path, out IReadOnlyList<NodePathSegment> segments, out string? error)
{
var parsed = new List<NodePathSegment>();
var cursor = 0;
var first = true;
error = null;
while (cursor < path.Length)
{
var ch = path[cursor];
if (ch == '.')
{
cursor++;
var name = ReadIdentifier(path, ref cursor);
if (name.Length == 0)
{
error = $"expected a field name after '.' at {cursor}";
segments = Array.Empty<NodePathSegment>();
return false;
}
parsed.Add(NodePathSegment.Property(name));
}
else if (ch == '[')
{
cursor++;
var close = path.IndexOf(']', cursor);
if (close < 0)
{
error = "unterminated '[' in path";
segments = Array.Empty<NodePathSegment>();
return false;
}
var token = path[cursor..close].Trim();
cursor = close + 1;
if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0])
{
parsed.Add(NodePathSegment.Property(token[1..^1]));
}
else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) && index >= 0)
{
parsed.Add(NodePathSegment.ElementAt(index));
}
else
{
error = $"invalid array index '{token}'";
segments = Array.Empty<NodePathSegment>();
return false;
}
}
else if (first)
{
// A leading bare identifier: executor paths look like "user.name",
// not ".user.name" as the interpolator's tail does.
var name = ReadIdentifier(path, ref cursor);
if (name.Length == 0)
{
error = $"unexpected '{ch}' in path";
segments = Array.Empty<NodePathSegment>();
return false;
}
parsed.Add(NodePathSegment.Property(name));
}
else
{
error = $"unexpected '{ch}' in path";
segments = Array.Empty<NodePathSegment>();
return false;
}
first = false;
}
segments = parsed;
return true;
}
/// <summary>Walks parsed segments; a missing property or out-of-range index yields null.</summary>
public static JsonNode? Navigate(JsonNode? root, IReadOnlyList<NodePathSegment> segments)
{
var current = root;
foreach (var segment in segments)
{
if (current == null)
return null;
current = segment.IsIndex
? current is JsonArray array && segment.Index < array.Count ? array[segment.Index] : null
: current is JsonObject obj ? Property(obj, segment.Name!) : null;
}
return current;
}
private static JsonNode? Property(JsonObject obj, string name)
=> obj.TryGetPropertyValue(name, out var value) ? value : null;
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];
}
}