diff --git a/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs b/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs
index aa89bba..b480708 100644
--- a/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs
+++ b/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs
@@ -346,11 +346,21 @@ namespace Nodify.Calculator.Execution.Handlers
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 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}");
var httpMethod = (node is APIOperationViewModel apiVm)
@@ -369,5 +379,42 @@ namespace Nodify.Calculator.Execution.Handlers
}
ctx.Log($"Response Result : {res}");
}
+
+ ///
+ /// 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.
+ ///
+ 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(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;
+ });
+ }
}
}
diff --git a/Examples/Nodify.Calculator/Execution/INodeValueResolver.cs b/Examples/Nodify.Calculator/Execution/INodeValueResolver.cs
new file mode 100644
index 0000000..6ea6dd9
--- /dev/null
+++ b/Examples/Nodify.Calculator/Execution/INodeValueResolver.cs
@@ -0,0 +1,43 @@
+namespace Nodify.Calculator.Execution
+{
+ ///
+ /// Strategy for lazily producing a value from a non-flow (sideband) node when
+ /// downstream code asks for a value on one of its output connectors.
+ ///
+ /// The 's value-reading pipeline walks this list (first match wins)
+ /// whenever an upstream source has not populated outputs[nodeId]. 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.
+ ///
+ public interface INodeValueResolver
+ {
+ /// Return true if this resolver knows how to produce a value for the source connector.
+ bool CanResolve(OperationViewModel sourceNode, ConnectorViewModel sourceConnector);
+
+ ///
+ /// Produce the value. May call 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.
+ ///
+ string Resolve(OperationViewModel sourceNode,
+ ConnectorViewModel sourceConnector,
+ IValueResolutionContext context);
+ }
+
+ ///
+ /// Services provided to resolvers so they can recursively evaluate the graph.
+ ///
+ public interface IValueResolutionContext
+ {
+ System.Collections.Generic.ICollection Connections { get; }
+ System.Collections.Generic.Dictionary Outputs { get; }
+ System.Collections.Generic.Dictionary Variables { get; }
+
+ /// Recursively read the value feeding any input connector.
+ string Read(ConnectorViewModel input);
+
+ void Log(string message, logType level = logType.Information);
+ }
+}
diff --git a/Examples/Nodify.Calculator/Execution/Resolvers/NodeValueResolvers.cs b/Examples/Nodify.Calculator/Execution/Resolvers/NodeValueResolvers.cs
new file mode 100644
index 0000000..95859b2
--- /dev/null
+++ b/Examples/Nodify.Calculator/Execution/Resolvers/NodeValueResolvers.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Nodify.Calculator.Execution.Resolvers
+{
+ /// Utilities shared by resolvers.
+ internal static class ResolverUtils
+ {
+ /// Strip " (type)" suffix from a connector title. "Id (int)" → "Id".
+ 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();
+ }
+
+ /// Pick a named property from a JSON string (case-insensitive). Returns null if not parseable or not an object.
+ 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;
+ }
+
+ /// Find the single non-flow (non-Triangle) input connector.
+ 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;
+ }
+ }
+}
diff --git a/Examples/Nodify.Calculator/Executor.cs b/Examples/Nodify.Calculator/Executor.cs
index 6963f99..0402bdf 100644
--- a/Examples/Nodify.Calculator/Executor.cs
+++ b/Examples/Nodify.Calculator/Executor.cs
@@ -16,6 +16,7 @@ using System.IO;
using Newtonsoft.Json;
using Nodify.Calculator.Execution;
using Nodify.Calculator.Execution.Handlers;
+using Nodify.Calculator.Execution.Resolvers;
namespace Nodify.Calculator
{
@@ -61,6 +62,18 @@ namespace Nodify.Calculator
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 _valueResolvers = new List
+ {
+ new SplitNodeValueResolver(),
+ new CopyNodeValueResolver(),
+ new KnotNodeValueResolver(),
+ new TakeNodeValueResolver(),
+ new ParseJsonNodeValueResolver(),
+ new GetVariableNodeValueResolver(),
+ };
+
// Auth configuration populated from the Auth node
private string _authBaseUrl = string.Empty;
private string _authType = string.Empty;
@@ -167,25 +180,69 @@ namespace Nodify.Calculator
op?.Title != null && op.Title.IndexOf(text, StringComparison.OrdinalIgnoreCase) >= 0;
///
- /// Reads the value feeding the given input connector: upstream node output, then
- /// connected source's connector value, then falls back to the input's own default value.
+ /// Reads the value feeding the given input connector. Resolution order:
+ /// 1. If the source node already produced output (outputs[nodeId]), use it.
+ /// 2. Walk registered 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.
///
private string TryReadInputValue(ConnectorViewModel input, ICollection connections)
{
if (input == null) return null;
+
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;
- if (srcNodeId != null && outputs.TryGetValue(srcNodeId, out var srcVal))
- return srcVal;
- if (inputCon.Output?.Value != null)
- return inputCon.Output.Value.ToString();
+ var resCtx = new ValueResolutionContext(this, connections);
+ foreach (var resolver in _valueResolvers)
+ {
+ if (!resolver.CanResolve(sourceOp, sourceConnector)) continue;
+ 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();
}
+ ///
+ /// Adapter passed to s so they can recursively
+ /// evaluate upstream inputs and log without taking a hard dependency on Executor.
+ ///
+ private sealed class ValueResolutionContext : IValueResolutionContext
+ {
+ private readonly Executor _executor;
+ public ValueResolutionContext(Executor executor, ICollection connections)
+ {
+ _executor = executor;
+ Connections = connections;
+ }
+ public ICollection Connections { get; }
+ public Dictionary Outputs => _executor.outputs;
+ public Dictionary 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)
{
// Parse the JSON to understand its structure