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