implemented input node resolver and major refactoring over execution function
This commit is contained in:
@@ -346,11 +346,21 @@ namespace Nodify.Calculator.Execution.Handlers
|
|||||||
|
|
||||||
internal sealed class ApiRequestHandler : INodeExecutionHandler
|
internal sealed class ApiRequestHandler : INodeExecutionHandler
|
||||||
{
|
{
|
||||||
|
// Matches {name} placeholders in URL templates (e.g., /api/Pizza/{id})
|
||||||
|
private static readonly System.Text.RegularExpressions.Regex PlaceholderRegex =
|
||||||
|
new System.Text.RegularExpressions.Regex(@"\{([^{}]+)\}",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||||
|
|
||||||
public bool CanHandle(OperationViewModel node, ExecutionContext ctx) => true; // final fallback
|
public bool CanHandle(OperationViewModel node, ExecutionContext ctx) => true; // final fallback
|
||||||
|
|
||||||
public void Execute(OperationViewModel node, ExecutionContext ctx)
|
public void Execute(OperationViewModel node, ExecutionContext ctx)
|
||||||
{
|
{
|
||||||
var url = node.Title ?? "";
|
var urlTemplate = node.Title ?? "";
|
||||||
|
var url = ResolveUrlPlaceholders(urlTemplate, node, ctx);
|
||||||
|
|
||||||
|
if (!string.Equals(url, urlTemplate, StringComparison.Ordinal))
|
||||||
|
ctx.Log($"URL resolved: {urlTemplate} → {url}");
|
||||||
|
|
||||||
ctx.Log($"Starting Execution : {url}");
|
ctx.Log($"Starting Execution : {url}");
|
||||||
|
|
||||||
var httpMethod = (node is APIOperationViewModel apiVm)
|
var httpMethod = (node is APIOperationViewModel apiVm)
|
||||||
@@ -369,5 +379,42 @@ namespace Nodify.Calculator.Execution.Handlers
|
|||||||
}
|
}
|
||||||
ctx.Log($"Response Result : {res}");
|
ctx.Log($"Response Result : {res}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces {name} tokens in the URL template with values read from the
|
||||||
|
/// matching input connector on the node. Connector titles may include a
|
||||||
|
/// trailing " (type)" suffix (e.g., "id (int)") which is stripped for matching.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveUrlPlaceholders(string template, OperationViewModel node, ExecutionContext ctx)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(template) || template.IndexOf('{') < 0)
|
||||||
|
return template;
|
||||||
|
|
||||||
|
// Build lookup: normalized connector name → value
|
||||||
|
var inputMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var inp in node.Input)
|
||||||
|
{
|
||||||
|
if (inp.Shape == ConnectorShape.Triangle) continue;
|
||||||
|
var name = inp.Title ?? "";
|
||||||
|
var parenIdx = name.IndexOf(" (", StringComparison.Ordinal);
|
||||||
|
if (parenIdx > 0) name = name.Substring(0, parenIdx);
|
||||||
|
name = name.Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
|
||||||
|
var value = ctx.ReadInput(inp);
|
||||||
|
if (value != null)
|
||||||
|
inputMap[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaceholderRegex.Replace(template, m =>
|
||||||
|
{
|
||||||
|
var key = m.Groups[1].Value.Trim();
|
||||||
|
if (inputMap.TryGetValue(key, out var val))
|
||||||
|
return Uri.EscapeDataString(val);
|
||||||
|
|
||||||
|
ctx.Log($"URL placeholder '{{{key}}}' has no matching input value; leaving as-is.", logType.Warning);
|
||||||
|
return m.Value;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
Examples/Nodify.Calculator/Execution/INodeValueResolver.cs
Normal file
43
Examples/Nodify.Calculator/Execution/INodeValueResolver.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Nodify.Calculator.Execution
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy for lazily producing a value from a <em>non-flow</em> (sideband) node when
|
||||||
|
/// downstream code asks for a value on one of its output connectors.
|
||||||
|
///
|
||||||
|
/// The <see cref="Executor"/>'s value-reading pipeline walks this list (first match wins)
|
||||||
|
/// whenever an upstream source has not populated <c>outputs[nodeId]</c>. This lets us
|
||||||
|
/// transparently support chains like:
|
||||||
|
/// ForEach.CurrentItem → Split.Id → API.id
|
||||||
|
/// without ever needing Split (or Copy, Take, Knot, GET-variable, Parse-JSON, …) to be in
|
||||||
|
/// the flow chain.
|
||||||
|
/// </summary>
|
||||||
|
public interface INodeValueResolver
|
||||||
|
{
|
||||||
|
/// <summary>Return true if this resolver knows how to produce a value for the source connector.</summary>
|
||||||
|
bool CanResolve(OperationViewModel sourceNode, ConnectorViewModel sourceConnector);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produce the value. May call <paramref name="readUpstream"/> recursively to resolve
|
||||||
|
/// any of the source node's own inputs (common case for Split/Copy/Take).
|
||||||
|
/// Returns null when no value can be produced.
|
||||||
|
/// </summary>
|
||||||
|
string Resolve(OperationViewModel sourceNode,
|
||||||
|
ConnectorViewModel sourceConnector,
|
||||||
|
IValueResolutionContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Services provided to resolvers so they can recursively evaluate the graph.
|
||||||
|
/// </summary>
|
||||||
|
public interface IValueResolutionContext
|
||||||
|
{
|
||||||
|
System.Collections.Generic.ICollection<ConnectionViewModel> Connections { get; }
|
||||||
|
System.Collections.Generic.Dictionary<string, string> Outputs { get; }
|
||||||
|
System.Collections.Generic.Dictionary<string, dynamic> Variables { get; }
|
||||||
|
|
||||||
|
/// <summary>Recursively read the value feeding any input connector.</summary>
|
||||||
|
string Read(ConnectorViewModel input);
|
||||||
|
|
||||||
|
void Log(string message, logType level = logType.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Nodify.Calculator.Execution.Resolvers
|
||||||
|
{
|
||||||
|
/// <summary>Utilities shared by resolvers.</summary>
|
||||||
|
internal static class ResolverUtils
|
||||||
|
{
|
||||||
|
/// <summary>Strip " (type)" suffix from a connector title. "Id (int)" → "Id".</summary>
|
||||||
|
public static string NormalizeConnectorName(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(title)) return string.Empty;
|
||||||
|
var parenIdx = title.IndexOf(" (", StringComparison.Ordinal);
|
||||||
|
return parenIdx > 0 ? title.Substring(0, parenIdx).Trim() : title.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pick a named property from a JSON string (case-insensitive). Returns null if not parseable or not an object.</summary>
|
||||||
|
public static string ExtractJsonProperty(string json, string propertyName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) || string.IsNullOrEmpty(propertyName)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = JToken.Parse(json);
|
||||||
|
if (token is JObject obj)
|
||||||
|
{
|
||||||
|
var prop = obj.Properties()
|
||||||
|
.FirstOrDefault(p => string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (prop != null)
|
||||||
|
{
|
||||||
|
return prop.Value.Type == JTokenType.String
|
||||||
|
? prop.Value.ToString()
|
||||||
|
: prop.Value.ToString(Formatting.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* not JSON */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Find the single non-flow (non-Triangle) input connector.</summary>
|
||||||
|
public static ConnectorViewModel FirstDataInput(OperationViewModel node)
|
||||||
|
=> node.Input.FirstOrDefault(i => i.Shape != ConnectorShape.Triangle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SPLIT: expose properties of an incoming JSON object as individual outputs.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class SplitNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
=> node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.SPLIT;
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
// Split's model input is the Square-shaped connector
|
||||||
|
var modelInput = node.Input.FirstOrDefault(i => i.Shape == ConnectorShape.Square);
|
||||||
|
if (modelInput == null) return null;
|
||||||
|
|
||||||
|
var modelJson = ctx.Read(modelInput);
|
||||||
|
if (string.IsNullOrWhiteSpace(modelJson)) return null;
|
||||||
|
|
||||||
|
var propName = ResolverUtils.NormalizeConnectorName(outputConnector.Title);
|
||||||
|
return ResolverUtils.ExtractJsonProperty(modelJson, propName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// COPY: N identical outputs = whatever feeds the single input.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class CopyNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
=> node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.COPY;
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
var input = ResolverUtils.FirstDataInput(node);
|
||||||
|
return input == null ? null : ctx.Read(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// KNOT / reroute: passthrough of the single data input.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class KnotNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
=> node is KnotOperationViewModel;
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
var input = ResolverUtils.FirstDataInput(node);
|
||||||
|
return input == null ? null : ctx.Read(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// TAKE: pick the Nth (or random) element from an incoming JSON array.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class TakeNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
=> node is TakeOperationViewModel;
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
var takeOp = (TakeOperationViewModel)node;
|
||||||
|
var listInput = node.Input.FirstOrDefault(i => i.IsTakeListConnector)
|
||||||
|
?? ResolverUtils.FirstDataInput(node);
|
||||||
|
if (listInput == null) return null;
|
||||||
|
|
||||||
|
var json = ctx.Read(listInput);
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = JToken.Parse(json);
|
||||||
|
if (token is JArray arr && arr.Count > 0)
|
||||||
|
{
|
||||||
|
int idx = takeOp.IsRandom
|
||||||
|
? new Random().Next(arr.Count)
|
||||||
|
: Math.Max(0, Math.Min(takeOp.NthIndex, arr.Count - 1));
|
||||||
|
var item = arr[idx];
|
||||||
|
return item.Type == JTokenType.String ? item.ToString() : item.ToString(Formatting.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ctx.Log($"[TAKE] Failed to parse list JSON: {ex.Message}", logType.Warning);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PARSEJSON: passthrough (output = parsed form of input). For value-reading
|
||||||
|
// purposes we just forward the raw JSON downstream.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class ParseJsonNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
=> node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.PARSEJSON;
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
var input = ResolverUtils.FirstDataInput(node);
|
||||||
|
return input == null ? null : ctx.Read(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// GET variable (GET_SET non-flow node): resolve from the variables dictionary.
|
||||||
|
// Titles look like "GET myVar (string)" or "GET ClassName".
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
internal sealed class GetVariableNodeValueResolver : INodeValueResolver
|
||||||
|
{
|
||||||
|
public bool CanResolve(OperationViewModel node, ConnectorViewModel c)
|
||||||
|
{
|
||||||
|
if (!(node is SystemOperationViewModel sys) || sys.SystemOperationType != SystemOperations.GET_SET)
|
||||||
|
return false;
|
||||||
|
var t = node.Title ?? string.Empty;
|
||||||
|
return t.StartsWith("GET ", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Resolve(OperationViewModel node, ConnectorViewModel outputConnector, IValueResolutionContext ctx)
|
||||||
|
{
|
||||||
|
var parts = (node.Title ?? string.Empty)
|
||||||
|
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length < 2) return null;
|
||||||
|
var varName = parts[1];
|
||||||
|
if (ctx.Variables.TryGetValue(varName, out var val))
|
||||||
|
return val?.ToString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using System.IO;
|
|||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Nodify.Calculator.Execution;
|
using Nodify.Calculator.Execution;
|
||||||
using Nodify.Calculator.Execution.Handlers;
|
using Nodify.Calculator.Execution.Handlers;
|
||||||
|
using Nodify.Calculator.Execution.Resolvers;
|
||||||
|
|
||||||
namespace Nodify.Calculator
|
namespace Nodify.Calculator
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,18 @@ namespace Nodify.Calculator
|
|||||||
new ApiRequestHandler(), // default / fallback
|
new ApiRequestHandler(), // default / fallback
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolvers for non-flow (sideband) nodes — consulted when a downstream node
|
||||||
|
// reads a value whose source hasn't produced an entry in `outputs`. First match wins.
|
||||||
|
private readonly List<INodeValueResolver> _valueResolvers = new List<INodeValueResolver>
|
||||||
|
{
|
||||||
|
new SplitNodeValueResolver(),
|
||||||
|
new CopyNodeValueResolver(),
|
||||||
|
new KnotNodeValueResolver(),
|
||||||
|
new TakeNodeValueResolver(),
|
||||||
|
new ParseJsonNodeValueResolver(),
|
||||||
|
new GetVariableNodeValueResolver(),
|
||||||
|
};
|
||||||
|
|
||||||
// Auth configuration populated from the Auth node
|
// Auth configuration populated from the Auth node
|
||||||
private string _authBaseUrl = string.Empty;
|
private string _authBaseUrl = string.Empty;
|
||||||
private string _authType = string.Empty;
|
private string _authType = string.Empty;
|
||||||
@@ -167,25 +180,69 @@ namespace Nodify.Calculator
|
|||||||
op?.Title != null && op.Title.IndexOf(text, StringComparison.OrdinalIgnoreCase) >= 0;
|
op?.Title != null && op.Title.IndexOf(text, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the value feeding the given input connector: upstream node output, then
|
/// Reads the value feeding the given input connector. Resolution order:
|
||||||
/// connected source's connector value, then falls back to the input's own default value.
|
/// 1. If the source node already produced output (outputs[nodeId]), use it.
|
||||||
|
/// 2. Walk registered <see cref="INodeValueResolver"/>s (SPLIT / COPY / KNOT /
|
||||||
|
/// TAKE / PARSEJSON / GET-variable / …) to lazily produce a value from a
|
||||||
|
/// non-flow source node. Resolvers may recurse via the provided context.
|
||||||
|
/// 3. The source connector's own Value.
|
||||||
|
/// 4. The input connector's default Value.
|
||||||
/// Returns null when nothing is available.
|
/// Returns null when nothing is available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string TryReadInputValue(ConnectorViewModel input, ICollection<ConnectionViewModel> connections)
|
private string TryReadInputValue(ConnectorViewModel input, ICollection<ConnectionViewModel> connections)
|
||||||
{
|
{
|
||||||
if (input == null) return null;
|
if (input == null) return null;
|
||||||
|
|
||||||
var inputCon = connections.FirstOrDefault(c => c.Input == input);
|
var inputCon = connections.FirstOrDefault(c => c.Input == input);
|
||||||
if (inputCon != null)
|
if (inputCon == null)
|
||||||
|
return input.Value.ToString();
|
||||||
|
|
||||||
|
var sourceConnector = inputCon.Output;
|
||||||
|
var sourceOp = sourceConnector?.Operation;
|
||||||
|
var srcNodeId = sourceOp?.NodeId;
|
||||||
|
|
||||||
|
// 1. Flow-executed nodes record their result in outputs[nodeId].
|
||||||
|
if (srcNodeId != null && outputs.TryGetValue(srcNodeId, out var producedVal))
|
||||||
|
return producedVal;
|
||||||
|
|
||||||
|
// 2. Generic dispatch for non-flow (sideband) nodes.
|
||||||
|
if (sourceOp != null && sourceConnector != null)
|
||||||
{
|
{
|
||||||
var srcNodeId = inputCon.Output?.Operation?.NodeId;
|
var resCtx = new ValueResolutionContext(this, connections);
|
||||||
if (srcNodeId != null && outputs.TryGetValue(srcNodeId, out var srcVal))
|
foreach (var resolver in _valueResolvers)
|
||||||
return srcVal;
|
{
|
||||||
if (inputCon.Output?.Value != null)
|
if (!resolver.CanResolve(sourceOp, sourceConnector)) continue;
|
||||||
return inputCon.Output.Value.ToString();
|
var resolved = resolver.Resolve(sourceOp, sourceConnector, resCtx);
|
||||||
|
if (!string.IsNullOrEmpty(resolved))
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3/4. Fall back to connector/default values.
|
||||||
|
if (sourceConnector?.Value != null)
|
||||||
|
return sourceConnector.Value.ToString();
|
||||||
return input.Value.ToString();
|
return input.Value.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter passed to <see cref="INodeValueResolver"/>s so they can recursively
|
||||||
|
/// evaluate upstream inputs and log without taking a hard dependency on Executor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ValueResolutionContext : IValueResolutionContext
|
||||||
|
{
|
||||||
|
private readonly Executor _executor;
|
||||||
|
public ValueResolutionContext(Executor executor, ICollection<ConnectionViewModel> connections)
|
||||||
|
{
|
||||||
|
_executor = executor;
|
||||||
|
Connections = connections;
|
||||||
|
}
|
||||||
|
public ICollection<ConnectionViewModel> Connections { get; }
|
||||||
|
public Dictionary<string, string> Outputs => _executor.outputs;
|
||||||
|
public Dictionary<string, dynamic> Variables => _executor.variables;
|
||||||
|
public string Read(ConnectorViewModel input) => _executor.TryReadInputValue(input, Connections);
|
||||||
|
public void Log(string message, logType level) => _executor.OnLogMe?.Invoke(message, level);
|
||||||
|
}
|
||||||
|
|
||||||
public static string GenerateClassFromJson(string json, string className)
|
public static string GenerateClassFromJson(string json, string className)
|
||||||
{
|
{
|
||||||
// Parse the JSON to understand its structure
|
// Parse the JSON to understand its structure
|
||||||
|
|||||||
Reference in New Issue
Block a user