diff --git a/Examples/Nodify.Calculator/Execution/ExecutionContext.cs b/Examples/Nodify.Calculator/Execution/ExecutionContext.cs new file mode 100644 index 0000000..f9b9615 --- /dev/null +++ b/Examples/Nodify.Calculator/Execution/ExecutionContext.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Nodify.Calculator.Execution +{ + /// + /// Shared state + callbacks passed to every . + /// Keeps handlers decoupled from the internals. + /// + public sealed class ExecutionContext + { + public ExecutionContext( + Executor executor, + ICollection connections, + Dictionary outputs, + Dictionary variables) + { + Executor = executor; + Connections = connections; + Outputs = outputs; + Variables = variables; + } + + public Executor Executor { get; } + public ICollection Connections { get; } + public Dictionary Outputs { get; } + public Dictionary Variables { get; } + + public void Log(string message, logType level = logType.Information) + => Executor.RaiseLog(message, level); + + public string ReadInput(ConnectorViewModel input) + => Executor.TryReadInputValue_Internal(input, Connections); + } +} diff --git a/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs b/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs new file mode 100644 index 0000000..aa89bba --- /dev/null +++ b/Examples/Nodify.Calculator/Execution/Handlers/NodeHandlers.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Nodify.Calculator.Execution.Handlers +{ + // ───────────────────────────────────────────────────────────────────────────── + // Sentinel / trivial handlers + // ───────────────────────────────────────────────────────────────────────────── + + /// Skips Begin/End sentinel nodes. + internal sealed class BeginEndHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => string.Equals(node.Title, "begin", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Title, "end", StringComparison.OrdinalIgnoreCase); + + public void Execute(OperationViewModel node, ExecutionContext ctx) + => ctx.Log("Begin or End node found. skipping it"); + } + + /// Auth node: configuration is resolved before execution, so this is a no-op log. + internal sealed class AuthHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is AuthOperationViewModel + || string.Equals(node.Title, "auth", StringComparison.OrdinalIgnoreCase); + + public void Execute(OperationViewModel node, ExecutionContext ctx) + => ctx.Log($"Auth node reached: {node.Title}"); + } + + /// Knot/Reroute: forwards the single data input to the node's output. + internal sealed class KnotHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is KnotOperationViewModel; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var inputCon = ctx.Connections.FirstOrDefault( + c => c.Input.Operation == node && c.Input.Shape != ConnectorShape.Triangle); + if (inputCon?.Output?.Operation?.NodeId is string srcId + && ctx.Outputs.TryGetValue(srcId, out var srcVal)) + { + ctx.Outputs[node.NodeId] = srcVal; + } + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // System-op handlers + // ───────────────────────────────────────────────────────────────────────────── + + internal sealed class DebugHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.DEBUG; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var input = node.Input.FirstOrDefault(i => i.Shape != ConnectorShape.Triangle); + var hasConnection = input != null && ctx.Connections.Any(c => c.Input == input); + var val = ctx.ReadInput(input); + + if (!hasConnection) + ctx.Log("[DEBUG] No input connected", logType.Warning); + else if (val != null) + ctx.Log($"[DEBUG] {val}"); + else + ctx.Log("[DEBUG] (no value)", logType.Warning); + } + } + + internal sealed class NewObjectHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is SystemOperationViewModel sys && sys.SystemOperationType == SystemOperations.NEW_OBJECT; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + ctx.Log($"Constructing new object: {node.Title}"); + var jObj = new JObject(); + + foreach (var inp in node.Input) + { + if (inp.Shape == ConnectorShape.Triangle || inp.Shape == ConnectorShape.Square) continue; + + var propName = inp.Title ?? ""; + var parenIdx = propName.IndexOf(" (", StringComparison.Ordinal); + if (parenIdx > 0) propName = propName.Substring(0, parenIdx); + + var val = ctx.ReadInput(inp); + if (val != null) + { + try { jObj[propName] = JToken.Parse(val); } + catch { jObj[propName] = val; } + } + else + { + jObj[propName] = null; + } + } + + var json = jObj.ToString(Formatting.None); + ctx.Outputs[node.NodeId] = json; + ctx.Variables[node.NodeId] = json; + ctx.Log($"Object constructed: {json}"); + } + } + + internal sealed class AssertHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is AssertOperationViewModel; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + ctx.Log($"[ASSERT] Evaluating assertion: {node.Title}"); + string actualVal = "", expectedVal = ""; + + foreach (var inp in node.Input) + { + if (inp.Shape == ConnectorShape.Triangle) continue; + var title = inp.Title?.Trim() ?? ""; + var val = ctx.ReadInput(inp) ?? ""; + + if (title.StartsWith("Actual", StringComparison.OrdinalIgnoreCase)) actualVal = val; + else if (title.StartsWith("Expected", StringComparison.OrdinalIgnoreCase)) expectedVal = val; + } + + var passed = string.Equals(actualVal.Trim(), expectedVal.Trim(), StringComparison.OrdinalIgnoreCase); + ctx.Outputs[node.NodeId] = passed.ToString(); + + if (passed) + ctx.Log($"[ASSERT] ✅ PASSED — Actual: \"{actualVal}\" == Expected: \"{expectedVal}\""); + else + ctx.Log($"[ASSERT] ❌ FAILED — Actual: \"{actualVal}\" != Expected: \"{expectedVal}\"", logType.Error); + } + } + + internal sealed class ForEachHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is ForEachOperationViewModel; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + ctx.Log($"[FOREACH] Starting loop: {node.Title}"); + + var listInput = node.Input.FirstOrDefault(i => i.Shape != ConnectorShape.Triangle); + var listJson = ctx.ReadInput(listInput) ?? ""; + if (string.IsNullOrEmpty(listJson)) + { + ctx.Log("[FOREACH] No list data found. Skipping loop.", logType.Warning); + return; + } + + JArray array; + try + { + var token = JToken.Parse(listJson); + if (token is JArray arr) array = arr; + else + { + ctx.Log("[FOREACH] Input is not an array. Wrapping as single-element array.", logType.Warning); + array = new JArray(token); + } + } + catch + { + ctx.Log("[FOREACH] Failed to parse input as JSON array.", logType.Error); + return; + } + + var loopBodyOutput = node.Output.FirstOrDefault(o => o.Title == "Loop Body"); + var currentItemOutput = node.Output.FirstOrDefault(o => o.Title == "Current Item"); + var indexOutput = node.Output.FirstOrDefault(o => o.Title == "Index"); + var loopBodyConnection = loopBodyOutput != null + ? ctx.Connections.FirstOrDefault(c => c.Output == loopBodyOutput) + : null; + + ctx.Log($"[FOREACH] Iterating over {array.Count} items..."); + for (int i = 0; i < array.Count; i++) + { + var itemStr = array[i].ToString(Formatting.None); + var preview = itemStr.Length > 80 ? itemStr.Substring(0, 80) + "..." : itemStr; + ctx.Log($"[FOREACH] Iteration {i}: {preview}"); + + ctx.Outputs[node.NodeId] = itemStr; + if (currentItemOutput != null) + currentItemOutput.Value = double.TryParse(itemStr, out var dv) ? dv : 0; + if (indexOutput != null) + indexOutput.Value = i; + + var bodyNode = loopBodyConnection?.Input?.Operation; + if (bodyNode != null) + ctx.Executor.TraverseChainPublic(bodyNode, "end", ctx.Connections, new HashSet(), true); + } + ctx.Log($"[FOREACH] Loop completed. {array.Count} iterations executed."); + } + } + + internal sealed class FunctionHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node is FunctionOperationViewModel; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var funcOp = (FunctionOperationViewModel)node; + ctx.Log($"Executing function: {funcOp.FunctionName}"); + ctx.Executor.ExecuteFunctionPublic(funcOp, ctx.Connections); + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Title-matched handlers (legacy string-based behavior preserved) + // ───────────────────────────────────────────────────────────────────────────── + + internal sealed class CreateModelHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node.Title != null + && node.Title.IndexOf("create model", StringComparison.OrdinalIgnoreCase) >= 0; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var url = node.Title ?? ""; + var conByTitle = ctx.Connections + .Where(c => c.Input.Operation.Title != null + && c.Input.Operation.Title.IndexOf("create model", StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + var flowConnection = conByTitle.FirstOrDefault(c => c.Input.Shape != ConnectorShape.Triangle); + if (flowConnection == null) + { + ctx.Log("No input found", logType.Error); + throw new Exception("Input connection missig for : " + url); + } + + if (!ctx.Outputs.TryGetValue(flowConnection.InputNodeId, out var outputValue)) + return; + + var customModelDir = Path.Combine(ProjectManager.ProjectDirectory, "CustomModels"); + Directory.CreateDirectory(customModelDir); + const string className = "Class1"; + var classStruct = Executor.GenerateClassFromJson(outputValue, className); + File.WriteAllText(Path.Combine(customModelDir, $"{className}.cs"), classStruct); + + ctx.Executor.AddNewModelPublic(new OperationInfoViewModel + { + Title = className, + IsModelNode = true, + Type = OperationType.System, + sysOp = SystemOperations.GET_SET, + ClassName = className, + }); + } + } + + /// + /// Handles SET nodes — both "SET myVar (type)" simple variables and + /// legacy "SET ClassName" parse-JSON variants. + /// + internal sealed class SetHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) + => node.Title != null + && node.Title.IndexOf("set", StringComparison.OrdinalIgnoreCase) >= 0; + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var url = node.Title ?? ""; + + // Simple variable SET: "SET myVar (string)" + if (node is SystemOperationViewModel && url.Contains("(") && url.Contains(")")) + { + var parts = url.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) return; + var varName = parts[1]; + + var sourceConn = ctx.Connections.FirstOrDefault( + c => c.Input.Operation == node && c.Input.Shape != ConnectorShape.Triangle); + if (sourceConn != null) + { + var sourceNodeId = sourceConn.Output.Operation.NodeId; + if (ctx.Outputs.TryGetValue(sourceNodeId, out var sourceVal)) + { + ctx.Variables[varName] = sourceVal; + ctx.Log($"Variable '{varName}' SET to: {sourceVal}"); + } + else + { + ctx.Variables[varName] = sourceConn.Output.Value.ToString(); + ctx.Log($"Variable '{varName}' SET to connector value: {sourceConn.Output.Value}"); + } + } + else + { + var valueInput = node.Input.FirstOrDefault(i => i.Title == "Value"); + if (valueInput != null) + { + ctx.Variables[varName] = valueInput.Value.ToString(); + ctx.Log($"Variable '{varName}' SET to default: {valueInput.Value}"); + } + } + ctx.Outputs[node.NodeId] = ctx.Variables.TryGetValue(varName, out var v) + ? v?.ToString() ?? "" : ""; + return; + } + + // Legacy "SET ClassName" parse-json variant + var inputConnection = ctx.Connections.FirstOrDefault( + c => c.Input.Operation.Title == url && c.Input.Shape != ConnectorShape.Triangle); + if (inputConnection == null) + { + ctx.Log("No input found", logType.Error); + throw new Exception("Input connection missig for : " + url); + } + var outPutNode = inputConnection.Output + ?? throw new Exception("Output connection missig for : " + url); + + if (outPutNode.Operation.Title?.IndexOf("parse json", StringComparison.OrdinalIgnoreCase) >= 0) + { + var inputNodeForParseJson = ctx.Connections.FirstOrDefault(c => c.Input.Operation.Title == outPutNode.Title); + if (inputNodeForParseJson == null) + { + ctx.Log("No input found", logType.Error); + throw new Exception("Input connection missig for : " + url); + } + if (ctx.Outputs.TryGetValue(inputNodeForParseJson.InputNodeId, out var inputNodeOutput) + && inputNodeOutput != null) + { + ctx.Variables[node.NodeId] = JsonConvert.DeserializeObject(inputNodeOutput); + } + } + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Default / fallback: API call + // ───────────────────────────────────────────────────────────────────────────── + + internal sealed class ApiRequestHandler : INodeExecutionHandler + { + public bool CanHandle(OperationViewModel node, ExecutionContext ctx) => true; // final fallback + + public void Execute(OperationViewModel node, ExecutionContext ctx) + { + var url = node.Title ?? ""; + ctx.Log($"Starting Execution : {url}"); + + var httpMethod = (node is APIOperationViewModel apiVm) + ? apiVm.OperationType?.ToLower() ?? "get" + : "get"; + + var res = ctx.Executor.GetResponsePublic(url, httpMethod); + if (!string.IsNullOrEmpty(res)) + { + ctx.Outputs[node.NodeId] = res; + if (node is APIOperationViewModel apiOp && !string.IsNullOrEmpty(apiOp.ResponseModelClassName)) + { + ctx.Variables[node.NodeId] = res; + ctx.Log($"Response auto-parsed as {apiOp.ResponseModelClassName}"); + } + } + ctx.Log($"Response Result : {res}"); + } + } +} diff --git a/Examples/Nodify.Calculator/Execution/INodeExecutionHandler.cs b/Examples/Nodify.Calculator/Execution/INodeExecutionHandler.cs new file mode 100644 index 0000000..de38f82 --- /dev/null +++ b/Examples/Nodify.Calculator/Execution/INodeExecutionHandler.cs @@ -0,0 +1,13 @@ +namespace Nodify.Calculator.Execution +{ + /// + /// Strategy contract for executing a single node. The + /// owns an ordered list of handlers and dispatches to the first one whose + /// returns true. + /// + public interface INodeExecutionHandler + { + bool CanHandle(OperationViewModel node, ExecutionContext context); + void Execute(OperationViewModel node, ExecutionContext context); + } +} diff --git a/Examples/Nodify.Calculator/Executor.cs b/Examples/Nodify.Calculator/Executor.cs index 3ed74d1..6963f99 100644 --- a/Examples/Nodify.Calculator/Executor.cs +++ b/Examples/Nodify.Calculator/Executor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; #if NET8_0_OR_GREATER @@ -14,6 +14,8 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using System.IO; using Newtonsoft.Json; +using Nodify.Calculator.Execution; +using Nodify.Calculator.Execution.Handlers; namespace Nodify.Calculator { @@ -30,13 +32,35 @@ namespace Nodify.Calculator private readonly EditorViewModel editorViewModel; public event LogMe OnLogMe; - static bool isAlreadyLogged = false; - static Dictionary outputs = new Dictionary(); - static Dictionary variables = new Dictionary(); + // Per-execution state (instance, not static prevents cross-run leakage) + private bool isAlreadyLogged = false; + private readonly Dictionary outputs = new Dictionary(); + private readonly Dictionary variables = new Dictionary(); + + // Single shared HttpClient (avoids socket exhaustion from per-call creation) +#if NET8_0_OR_GREATER + private static readonly HttpClient _httpClient = new HttpClient(); +#endif // Animation delay between node steps (milliseconds) while executing private const int AnimationDelayMs = 600; + // Ordered dispatch table first match wins. ApiRequestHandler is last (fallback). + private readonly List _handlers = new List + { + new CreateModelHandler(), + new SetHandler(), + new BeginEndHandler(), + new AuthHandler(), + new KnotHandler(), + new DebugHandler(), + new NewObjectHandler(), + new AssertHandler(), + new ForEachHandler(), + new FunctionHandler(), + new ApiRequestHandler(), // default / fallback + }; + // Auth configuration populated from the Auth node private string _authBaseUrl = string.Empty; private string _authType = string.Empty; @@ -75,6 +99,8 @@ namespace Nodify.Calculator OnLogMe?.Invoke("Wish all the best to us :)"); OnLogMe?.Invoke("Perorming chain check, it may take some time depending upon the number of nodes."); outputs.Clear(); + variables.Clear(); + isAlreadyLogged = false; var calcModel = editorViewModel.Calculator; var allnodes = calcModel.Operations; var allConnections = calcModel.Connections; @@ -133,6 +159,33 @@ namespace Nodify.Calculator RunOnUI(() => conn.IsActiveInExecution = active); } + // Helpers to reduce duplication across node-type handlers + private static bool TitleEquals(OperationViewModel op, string text) => + string.Equals(op?.Title, text, StringComparison.OrdinalIgnoreCase); + + private static bool TitleContains(OperationViewModel op, string text) => + 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. + /// 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) + { + 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(); + } + return input.Value.ToString(); + } + public static string GenerateClassFromJson(string json, string className) { // Parse the JSON to understand its structure @@ -336,383 +389,40 @@ namespace Nodify.Calculator private void StartExecution(OperationViewModel op, ICollection connections) { - var url = op.Title ?? ""; - if (url.ToLower().Contains("create model")) + var ctx = new ExecutionContext(this, connections, outputs, variables); + + // Dispatch to the first handler whose CanHandle returns true. + // ApiRequestHandler is the final fallback (its CanHandle always returns true). + foreach (var handler in _handlers) { - var conByTitle = connections.Where(c => c.Input.Operation.Title.ToLower().Contains("create model")).ToList(); - if (conByTitle.Count <= 0) - { - OnLogMe?.Invoke("No input found", logType.Error); - throw new Exception("Input connection missig for : " + url); - } - var flowConnection = conByTitle.Where(c=>c.Input.Shape != ConnectorShape.Triangle).FirstOrDefault(); - if (flowConnection == null) - { - OnLogMe?.Invoke("No input found", logType.Error); - throw new Exception("Input connection missig for : " + url); - } + if (!handler.CanHandle(op, ctx)) continue; - var outputNodeId = flowConnection.InputNodeId; - - string outputValue = string.Empty; - if (outputs.TryGetValue(outputNodeId, out outputValue)) - { - //Convert model here - //CreateModelsFromString(outputValue); - var customModelDir = Path.Combine(ProjectManager.ProjectDirectory, "CustomModels"); - Directory.CreateDirectory(customModelDir); - string className = "Class 1"; - className = className.Replace(" ", ""); - var classStruct = GenerateClassFromJson(outputValue, className); - string flPath = Path.Join(customModelDir, $"{className}.cs"); - File.WriteAllText(flPath, classStruct); - OperationInfoViewModel opv = new OperationInfoViewModel() - { - Title = className, - IsModelNode = true, - Type = OperationType.System, - sysOp = SystemOperations.GET_SET, - ClassName = className - }; - this.editorViewModel.Calculator.OperationsMenu.AddNewModel(opv); - } - } - - if (url.ToLower().Contains("set")) - { - // Handle simple variable SET (e.g., "SET myVar (string)") - if (op is SystemOperationViewModel sysVarOp && url.Contains("(") && url.Contains(")")) - { - var parts = url.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - var varName = parts[1]; - // Find the data input connection (non-triangle) - var inputCons = connections.Where(c => c.Input.Operation == op && c.Input.Shape != ConnectorShape.Triangle).ToList(); - if (inputCons.Any()) - { - var sourceConn = inputCons.First(); - var sourceNodeId = sourceConn.Output.Operation.NodeId; - if (outputs.TryGetValue(sourceNodeId, out var sourceVal)) - { - variables[varName] = sourceVal; - OnLogMe?.Invoke($"Variable '{varName}' SET to: {sourceVal}"); - } - else - { - // Use the connector value directly - variables[varName] = sourceConn.Output.Value.ToString(); - OnLogMe?.Invoke($"Variable '{varName}' SET to connector value: {sourceConn.Output.Value}"); - } - } - else - { - // No connection — use default value from the Value input connector - var valueInput = op.Input.FirstOrDefault(i => i.Title == "Value"); - if (valueInput != null) - { - variables[varName] = valueInput.Value.ToString(); - OnLogMe?.Invoke($"Variable '{varName}' SET to default: {valueInput.Value}"); - } - } - outputs[op.NodeId] = variables.ContainsKey(varName) ? variables[varName]?.ToString() ?? "" : ""; - } - return; - } - - var fullTitle = url; - var classNameToSet = fullTitle.Split(" ", StringSplitOptions.RemoveEmptyEntries)[1]; - var inputConnectionList = connections.Where(c => c.Input.Operation.Title == url).ToList(); - var inputConnection = inputConnectionList.Where(c => c.Input.Shape != ConnectorShape.Triangle).FirstOrDefault(); - if (inputConnection == null) - { - OnLogMe?.Invoke("No input found", logType.Error); - throw new Exception("Input connection missig for : " + url); - } - var outPutNode = inputConnection.Output; - if (outPutNode == null) - { - OnLogMe?.Invoke("No output found", logType.Error); - throw new Exception("Output connection missig for : " + url); - } - if (outPutNode.Operation.Title.ToLower().Contains("parse json")) - { - var inputNodeForParseJson = connections.Where(c => c.Input.Operation.Title == outPutNode.Title).FirstOrDefault(); - if (inputNodeForParseJson == null) - { - OnLogMe?.Invoke("No input found", logType.Error); - throw new Exception("Input connection missig for : " + url); - } - var inputNodeOutput = outputs[inputNodeForParseJson.InputNodeId]; - if (inputNodeOutput != null) - { - var obj = JsonConvert.DeserializeObject(inputNodeOutput); - variables[op.NodeId] = obj; - } - } - } - - OnLogMe?.Invoke($"Execution started : {url}"); - if (url.ToLower() == "begin" || url.ToLower() == "end") - { - OnLogMe?.Invoke($"Being or End node found. skipping it"); + OnLogMe?.Invoke($"Execution started : {op.Title}"); + handler.Execute(op, ctx); return; } - if (url.ToLower() == "auth") - { - ResolveAuthNode(editorViewModel.Calculator.Operations); - OnLogMe?.Invoke($"Auth node resolved. Base URL: {_authBaseUrl}, Auth Type: {_authType}"); - return; - } - // Knot/Reroute node: passthrough - forward input data to output - if (op is KnotOperationViewModel) - { - var knotInputCon = connections.Where(c => c.Input.Operation == op && c.Input.Shape != ConnectorShape.Triangle).FirstOrDefault(); - if (knotInputCon != null) - { - var srcNodeId = knotInputCon.Output?.Operation?.NodeId; - if (srcNodeId != null && outputs.TryGetValue(srcNodeId, out var srcVal)) - { - outputs[op.NodeId] = srcVal; - } - } - return; - } - if (op is SystemOperationViewModel debugSysOp && debugSysOp.SystemOperationType == SystemOperations.DEBUG) - { - // Find the data input connection (non-triangle) - var debugInputCons = connections.Where(c => c.Input.Operation == op && c.Input.Shape != ConnectorShape.Triangle).ToList(); - if (debugInputCons.Any()) - { - var sourceConn = debugInputCons.First(); - var sourceNodeId = sourceConn.Output.Operation.NodeId; - if (outputs.TryGetValue(sourceNodeId, out var debugVal)) - { - OnLogMe?.Invoke($"[DEBUG] {debugVal}"); - } - else if (sourceConn.Output.Value != null) - { - OnLogMe?.Invoke($"[DEBUG] {sourceConn.Output.Value}"); - } - else - { - OnLogMe?.Invoke($"[DEBUG] (no value)", logType.Warning); - } - } - else - { - OnLogMe?.Invoke($"[DEBUG] No input connected", logType.Warning); - } - return; - } - - // Handle New Object nodes — construct JSON from property inputs - if (op is SystemOperationViewModel newObjSysOp && newObjSysOp.SystemOperationType == SystemOperations.NEW_OBJECT) - { - OnLogMe?.Invoke($"Constructing new object: {newObjSysOp.Title}"); - var jObj = new JObject(); - // Iterate property inputs (skip triangle flow + square model connectors) - foreach (var inp in op.Input) - { - if (inp.Shape == ConnectorShape.Triangle || inp.Shape == ConnectorShape.Square) continue; - - // Extract property name from title like "Name (string)" - var propName = inp.Title ?? ""; - var parenIdx = propName.IndexOf(" ("); - if (parenIdx > 0) propName = propName.Substring(0, parenIdx); - - // Find connection feeding this input - var inputCon = connections.FirstOrDefault(cn => cn.Input == inp); - string val = null; - if (inputCon != null) - { - var srcNodeId = inputCon.Output.Operation.NodeId; - if (outputs.TryGetValue(srcNodeId, out var srcVal)) - val = srcVal; - else if (inputCon.Output.Value != null) - val = inputCon.Output.Value.ToString(); - } - - if (val != null) - { - // Try to parse as JSON token, fallback to string - try { jObj[propName] = JToken.Parse(val); } - catch { jObj[propName] = val; } - } - else - { - jObj[propName] = null; - } - } - - var json = jObj.ToString(Formatting.None); - outputs[op.NodeId] = json; - variables[op.NodeId] = json; - OnLogMe?.Invoke($"Object constructed: {json}"); - return; - } - - // Handle Assert nodes - if (op is AssertOperationViewModel assertSysOp) - { - OnLogMe?.Invoke($"[ASSERT] Evaluating assertion: {op.Title}"); - string actualVal = ""; - string expectedVal = ""; - - // Find "Actual" and "Expected" input values - foreach (var inp in op.Input) - { - if (inp.Shape == ConnectorShape.Triangle) continue; - var title = inp.Title?.Trim() ?? ""; - - // Check if there's a connection feeding this input - var inputCon = connections.FirstOrDefault(c => c.Input == inp); - string val = ""; - if (inputCon != null) - { - var srcNodeId = inputCon.Output.Operation.NodeId; - if (outputs.TryGetValue(srcNodeId, out var srcVal)) - val = srcVal; - else if (inputCon.Output.Value != null) - val = inputCon.Output.Value.ToString(); - } - else - { - val = inp.Value.ToString(); - } - - if (title.StartsWith("Actual")) actualVal = val; - else if (title.StartsWith("Expected")) expectedVal = val; - } - - bool passed = string.Equals(actualVal?.Trim(), expectedVal?.Trim(), StringComparison.OrdinalIgnoreCase); - outputs[op.NodeId] = passed.ToString(); - - if (passed) - { - OnLogMe?.Invoke($"[ASSERT] ✅ PASSED — Actual: \"{actualVal}\" == Expected: \"{expectedVal}\""); - } - else - { - OnLogMe?.Invoke($"[ASSERT] ❌ FAILED — Actual: \"{actualVal}\" != Expected: \"{expectedVal}\"", logType.Error); - } - return; - } - - // Handle ForEach loop nodes - if (op is ForEachOperationViewModel forEachSysOp) - { - OnLogMe?.Invoke($"[FOREACH] Starting loop: {op.Title}"); - - // Get the list input value - string listJson = ""; - var listInput = op.Input.FirstOrDefault(i => i.Shape != ConnectorShape.Triangle); - if (listInput != null) - { - var listCon = connections.FirstOrDefault(c => c.Input == listInput); - if (listCon != null) - { - var srcNodeId = listCon.Output.Operation.NodeId; - if (outputs.TryGetValue(srcNodeId, out var srcVal)) - listJson = srcVal; - } - } - - if (string.IsNullOrEmpty(listJson)) - { - OnLogMe?.Invoke($"[FOREACH] No list data found. Skipping loop.", logType.Warning); - return; - } - - // Parse the list - Newtonsoft.Json.Linq.JArray array; - try - { - var token = Newtonsoft.Json.Linq.JToken.Parse(listJson); - if (token is Newtonsoft.Json.Linq.JArray arr) - array = arr; - else - { - OnLogMe?.Invoke($"[FOREACH] Input is not an array. Wrapping as single-element array.", logType.Warning); - array = new Newtonsoft.Json.Linq.JArray(token); - } - } - catch - { - OnLogMe?.Invoke($"[FOREACH] Failed to parse input as JSON array.", logType.Error); - return; - } - - // Find the "Loop Body" flow output connection and "Current Item"/"Index" data outputs - var loopBodyOutput = op.Output.FirstOrDefault(o => o.Title == "Loop Body"); - var currentItemOutput = op.Output.FirstOrDefault(o => o.Title == "Current Item"); - var indexOutput = op.Output.FirstOrDefault(o => o.Title == "Index"); - - var loopBodyConnection = loopBodyOutput != null - ? connections.FirstOrDefault(c => c.Output == loopBodyOutput) - : null; - - OnLogMe?.Invoke($"[FOREACH] Iterating over {array.Count} items..."); - - for (int i = 0; i < array.Count; i++) - { - var item = array[i]; - var itemStr = item.ToString(Newtonsoft.Json.Formatting.None); - - OnLogMe?.Invoke($"[FOREACH] Iteration {i}: {(itemStr.Length > 80 ? itemStr.Substring(0, 80) + "..." : itemStr)}"); - - // Set Current Item and Index output values - outputs[op.NodeId] = itemStr; - if (currentItemOutput != null) - currentItemOutput.Value = double.TryParse(itemStr, out var dv) ? dv : 0; - if (indexOutput != null) - indexOutput.Value = i; - - // Execute the loop body chain if connected - if (loopBodyConnection != null) - { - var bodyNode = loopBodyConnection.Input?.Operation; - if (bodyNode != null) - { - var visited = new HashSet(); - TraverseChain(bodyNode, "end", connections, visited, true); - } - } - } - - OnLogMe?.Invoke($"[FOREACH] Loop completed. {array.Count} iterations executed."); - return; - } - - // Handle Function nodes — execute the inner flow - if (op is FunctionOperationViewModel funcOp) - { - OnLogMe?.Invoke($"Executing function: {funcOp.FunctionName}"); - ExecuteFunction(funcOp, connections); - return; - } - OnLogMe?.Invoke($"Starting Execution : {url}"); - var httpMethod = (op is APIOperationViewModel apiVm) ? apiVm.OperationType?.ToLower() ?? "get" : "get"; - var res = GetResponse(url, httpMethod); - if (!string.IsNullOrEmpty(res)) - { - outputs[op.NodeId] = res; - - // Auto-parse response to model if ResponseModelClassName is specified - if (op is APIOperationViewModel apiOp && !string.IsNullOrEmpty(apiOp.ResponseModelClassName)) - { - var modelClassName = apiOp.ResponseModelClassName; - bool isList = modelClassName.StartsWith("List<") && modelClassName.EndsWith(">"); - var innerClassName = isList ? modelClassName.Substring(5, modelClassName.Length - 6) : modelClassName; - - // Store the parsed JSON as variable keyed by node ID for downstream SET/Split nodes - variables[op.NodeId] = res; - OnLogMe?.Invoke($"Response auto-parsed as {modelClassName}"); - } - } - OnLogMe?.Invoke($"Response Result : {res}"); } + // -- Bridge methods exposed for handlers (assembly-internal access) -- + internal void RaiseLog(string message, logType level) => OnLogMe?.Invoke(message, level); + + internal string TryReadInputValue_Internal(ConnectorViewModel input, ICollection connections) + => TryReadInputValue(input, connections); + + internal void ExecuteFunctionPublic(FunctionOperationViewModel funcOp, ICollection connections) + => ExecuteFunction(funcOp, connections); + + internal string GetResponsePublic(string url, string type) => GetResponse(url, type); + + internal bool TraverseChainPublic(OperationViewModel node, string endNodeTitle, + ICollection connections, + HashSet visited, bool isExecute) + => TraverseChain(node, endNodeTitle, connections, visited, isExecute); + + internal void AddNewModelPublic(OperationInfoViewModel model) + => editorViewModel.Calculator.OperationsMenu.AddNewModel(model); + + private void ResolveAuthNode(ICollection allNodes) { var authNode = allNodes.OfType().FirstOrDefault(); @@ -747,7 +457,7 @@ namespace Nodify.Calculator if (!string.IsNullOrWhiteSpace(authNode.AuthType)) _authType = authNode.AuthType; - OnLogMe?.Invoke($"Auth configured — Base URL: {_authBaseUrl}, Auth Type: {_authType}"); + OnLogMe?.Invoke($"Auth configured Base URL: {_authBaseUrl}, Auth Type: {_authType}"); } private void ResolveGetVariableNodes(ICollection allNodes, ICollection connections) @@ -868,58 +578,62 @@ namespace Nodify.Calculator string responseString = string.Empty; #if NET8_0_OR_GREATER - using (HttpClient client = new HttpClient()) + if (string.Equals(type, "get", StringComparison.OrdinalIgnoreCase)) { - client.BaseAddress = new Uri(baseURL); - - // Apply authentication headers - if (!string.IsNullOrWhiteSpace(_authType)) - { - var authTypeLower = _authType.Trim().ToLower(); - if (authTypeLower.Contains("bearer") && !string.IsNullOrWhiteSpace(_authToken)) - { - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _authToken); - } - else if (authTypeLower.Contains("basic") && - !string.IsNullOrWhiteSpace(_authUsername)) - { - var credentials = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_authUsername}:{_authPassword}")); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); - } - else if (authTypeLower.Contains("api") && !string.IsNullOrWhiteSpace(_authApiKey)) - { - client.DefaultRequestHeaders.Add("X-Api-Key", _authApiKey); - } - } - - if (type == "get") - { - HttpResponseMessage response = client.GetAsync(url).Result; - response.EnsureSuccessStatusCode(); - responseString = response.Content.ReadAsStringAsync().Result; - } + var fullUri = new Uri(new Uri(baseURL.EndsWith("/") ? baseURL : baseURL + "/"), url); + using var request = new HttpRequestMessage(HttpMethod.Get, fullUri); + ApplyAuthHeaders(request); + using var response = _httpClient.Send(request); + response.EnsureSuccessStatusCode(); + using var reader = new StreamReader(response.Content.ReadAsStream()); + responseString = reader.ReadToEnd(); } #endif return responseString; } +#if NET8_0_OR_GREATER + private void ApplyAuthHeaders(HttpRequestMessage request) + { + if (string.IsNullOrWhiteSpace(_authType)) return; + + var authTypeLower = _authType.Trim().ToLowerInvariant(); + if (authTypeLower.Contains("bearer") && !string.IsNullOrWhiteSpace(_authToken)) + { + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _authToken); + } + else if (authTypeLower.Contains("basic") && !string.IsNullOrWhiteSpace(_authUsername)) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_authUsername}:{_authPassword}")); + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); + } + else if (authTypeLower.Contains("api") && !string.IsNullOrWhiteSpace(_authApiKey)) + { + request.Headers.Add("X-Api-Key", _authApiKey); + } + } +#endif + private void PerformChainCheck(ICollection allNodes, ICollection connections, bool isExecute) { string startNodeTitle = "begin"; string endNodeTitle = "end"; // Find the starting node - var startNode = allNodes.FirstOrDefault(node => node.Title?.ToLower() == startNodeTitle); + var startNode = allNodes.FirstOrDefault(node => string.Equals(node.Title, startNodeTitle, StringComparison.OrdinalIgnoreCase)); if (startNode == null) { OnLogMe?.Invoke("No Begin node found", logType.Error); throw new Exception("Begin node not found"); } + // Reset per-phase broken-chain log latch + isAlreadyLogged = false; + // Track visited nodes to prevent infinite loops or revisits HashSet visitedNodes = new HashSet(); @@ -1023,7 +737,7 @@ namespace Nodify.Calculator private string ValidateRequiredNodes(string nodeName, ICollection allnodes) { - var requireNode = allnodes.Where(c => c.Title.ToLower() == nodeName).ToList(); + var requireNode = allnodes.Where(c => string.Equals(c.Title, nodeName, StringComparison.OrdinalIgnoreCase)).ToList(); if (requireNode.Count > 1) { return $"One or more {nodeName} node found. Only one allowed at a time.";