From 9de67df65fbc10f8a131cfedd2d9edb6c6583445 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Fri, 11 Sep 2026 19:04:50 +0300 Subject: [PATCH] workflow integrations --- Controllers/NodesController.cs | 102 ++++++ Models/Nodes/FlowItem.cs | 190 ++++++++++ Models/Nodes/NodeBlueprint.cs | 245 +++++++++++++ Program.cs | 7 + Services/Nodes/Catalog/core-http-request.json | 142 ++++++++ Services/Nodes/Catalog/core-if.json | 60 ++++ Services/Nodes/Catalog/core-noop.json | 14 + Services/Nodes/Catalog/core-set.json | 57 +++ Services/Nodes/INodeExecutor.cs | 96 +++++ .../NodeParameterInterpolator.cs | 339 ++++++++++++++++++ Services/Nodes/NodeBlueprintCatalog.cs | 178 +++++++++ Services/Nodes/NodeExecutorRegistry.cs | 37 ++ .../NodeBlueprintCatalogTests.cs | 121 +++++++ .../NodeParameterInterpolatorTests.cs | 165 +++++++++ .../NodesControllerTests.cs | 65 ++++ w4c-workflows-api.csproj | 7 + 16 files changed, 1825 insertions(+) create mode 100644 Controllers/NodesController.cs create mode 100644 Models/Nodes/FlowItem.cs create mode 100644 Models/Nodes/NodeBlueprint.cs create mode 100644 Services/Nodes/Catalog/core-http-request.json create mode 100644 Services/Nodes/Catalog/core-if.json create mode 100644 Services/Nodes/Catalog/core-noop.json create mode 100644 Services/Nodes/Catalog/core-set.json create mode 100644 Services/Nodes/INodeExecutor.cs create mode 100644 Services/Nodes/Interpolation/NodeParameterInterpolator.cs create mode 100644 Services/Nodes/NodeBlueprintCatalog.cs create mode 100644 Services/Nodes/NodeExecutorRegistry.cs create mode 100644 w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs create mode 100644 w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs create mode 100644 w4c-workflows-api.Tests/NodesControllerTests.cs diff --git a/Controllers/NodesController.cs b/Controllers/NodesController.cs new file mode 100644 index 0000000..2686f72 --- /dev/null +++ b/Controllers/NodesController.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Filters; +using w4c_workflows.Models.Nodes; +using w4c_workflows.Services.Nodes; + +namespace w4c_workflows.Controllers; + +/// +/// Node catalog surface. The authoring UI uses this to render its palette and +/// its generic parameter forms; it authenticates with the tenant operator key +/// like the rest of the control plane. +/// +[ApiController] +[Route("api/nodes")] +public class NodesController : ControllerBase +{ + private readonly NodeBlueprintCatalog _catalog; + private readonly NodeExecutorRegistry _executors; + + public NodesController(NodeBlueprintCatalog catalog, NodeExecutorRegistry executors) + { + _catalog = catalog; + _executors = executors; + } + + /// Lists node types for the palette, with optional search/filter. + [HttpGet] + [RequireScope("read")] + public IActionResult List( + [FromQuery] string? search, + [FromQuery] string? kind, + [FromQuery] string? category, + [FromQuery] bool includeHidden = false) + { + var results = _catalog.Search(search, kind, category); + if (!includeHidden) + results = results.Where(b => !b.Hidden).ToList(); + + return Ok(results.Select(Summarize)); + } + + /// All category labels, for the palette's category rail. + [HttpGet("categories")] + [RequireScope("read")] + public IActionResult Categories() => Ok(_catalog.Categories); + + /// Full blueprint for one node type (optionally a pinned version). + [HttpGet("{type}")] + [RequireScope("read")] + public IActionResult Get(string type, [FromQuery] double? version) + { + var blueprint = _catalog.Resolve(type, version); + if (blueprint is null) + return NotFound(new { error = $"unknown node type '{type}'" }); + + return Ok(new NodeDetail(blueprint, _executors.CanRun(blueprint.Type))); + } + + private NodeSummary Summarize(NodeBlueprint blueprint) => new( + blueprint.Type, + blueprint.Version, + blueprint.DisplayName, + blueprint.Description, + blueprint.Kind, + blueprint.Categories, + blueprint.Icon, + blueprint.IconColor, + blueprint.RunMode, + blueprint.Hidden, + blueprint.Origin, + blueprint.Inputs.Count, + blueprint.Outputs.Count, + blueprint.Inputs, + blueprint.Outputs, + blueprint.ProducesErrorBranch, + blueprint.Credentials.Any(c => c.Required), + _executors.CanRun(blueprint.Type)); +} + +/// Palette entry: blueprint identity plus port shape, without the full form schema. +public sealed record NodeSummary( + string Type, + double Version, + string DisplayName, + string? Description, + string Kind, + IReadOnlyList Categories, + string? Icon, + string? IconColor, + string RunMode, + bool Hidden, + string Origin, + int Inputs, + int Outputs, + IReadOnlyList InputPorts, + IReadOnlyList OutputPorts, + bool HasErrorBranch, + bool RequiresCredentials, + bool Runnable); + +/// Full blueprint detail plus whether an executor is installed for it. +public sealed record NodeDetail(NodeBlueprint Blueprint, bool Runnable); diff --git a/Models/Nodes/FlowItem.cs b/Models/Nodes/FlowItem.cs new file mode 100644 index 0000000..fa2f3cc --- /dev/null +++ b/Models/Nodes/FlowItem.cs @@ -0,0 +1,190 @@ +using System.Text.Json.Nodes; + +namespace w4c_workflows.Models.Nodes; + +// --------------------------------------------------------------------------- +// The unit of data that flows between nodes. A node receives one or more +// FlowItems and produces zero or more per output port. Items carry their own +// provenance so expressions like $("Prepare") and the UI's "trace this item" +// can resolve which upstream item produced a given downstream item. +// --------------------------------------------------------------------------- + +/// A binary payload stored out-of-band; items only hold a reference. +public sealed record BinaryAttachment( + string AssetId, + string? FileName = null, + string? MimeType = null, + long? SizeBytes = null); + +/// A pointer back to an item emitted by another node run. +public sealed record ItemOrigin( + string NodeId, + int OutputIndex = 0, + int RunIndex = 0, + int ItemIndex = 0); + +/// Where a came from (for tracing and expressions). +public sealed record FlowItemOrigin( + string NodeId, + int OutputIndex = 0, + int RunIndex = 0, + int ItemIndex = 0, + List? PairedItems = null); + +/// +/// One data item. is the payload; holds +/// named binary references; links it to its source. +/// +public sealed record FlowItem +{ + public required JsonObject Json { get; init; } + + public Dictionary? Binary { get; init; } + + public FlowItemOrigin? Origin { get; init; } + + /// Convenience factory for a plain JSON item. + public static FlowItem FromJson(JsonObject json) => new() { Json = json }; + + /// Wraps a bare JSON value in a single "value" field. + public static FlowItem Scalar(JsonNode? value) => new() + { + Json = new JsonObject { ["value"] = value?.DeepClone() }, + }; +} + +/// Helpers for turning sets of items into and out of JSON. +public static class FlowItemJson +{ + /// Serializes a batch of items into a JSON array (or null when empty). + public static string? Serialize(IReadOnlyList items) + { + if (items.Count == 0) + return null; + + var array = new JsonArray(); + foreach (var item in items) + { + var entry = new JsonObject { ["json"] = item.Json.DeepClone() }; + if (item.Binary is { Count: > 0 }) + { + var binary = new JsonObject(); + foreach (var (name, attachment) in item.Binary) + { + binary[name] = new JsonObject + { + ["assetId"] = attachment.AssetId, + ["fileName"] = attachment.FileName, + ["mimeType"] = attachment.MimeType, + ["sizeBytes"] = attachment.SizeBytes, + }; + } + entry["binary"] = binary; + } + if (item.Origin != null) + { + entry["origin"] = new JsonObject + { + ["nodeId"] = item.Origin.NodeId, + ["outputIndex"] = item.Origin.OutputIndex, + ["runIndex"] = item.Origin.RunIndex, + ["itemIndex"] = item.Origin.ItemIndex, + }; + } + array.Add(entry); + } + + return array.ToJsonString(); + } + + /// + /// Parses a JSON payload into items. Accepts a JSON array of + /// { "json": … } envelopes, a JSON array of bare objects, or a single + /// bare object (which becomes a one-item batch). + /// + public static List Parse(string? payload) + { + var items = new List(); + if (string.IsNullOrWhiteSpace(payload)) + return items; + + JsonNode? node; + try + { + node = JsonNode.Parse(payload); + } + catch + { + return items; + } + + switch (node) + { + case JsonArray array: + foreach (var element in array) + items.Add(FromElement(element)); + break; + case JsonObject obj: + items.Add(FromObject(obj)); + break; + } + + return items; + } + + private static FlowItem FromElement(JsonNode? element) => element switch + { + JsonObject obj => FromObject(obj), + null => new FlowItem { Json = new JsonObject() }, + _ => FlowItem.Scalar(element), + }; + + private static FlowItem FromObject(JsonObject obj) + { + // Envelope form { json, binary, origin }: unwrap; otherwise treat the + // whole object as the payload. + if (obj.ContainsKey("json") && obj["json"] is JsonObject payload) + { + return new FlowItem + { + Json = (JsonObject)payload.DeepClone(), + Origin = ParseOrigin(obj["origin"] as JsonObject), + Binary = ParseBinary(obj["binary"] as JsonObject), + }; + } + + return new FlowItem { Json = (JsonObject)obj.DeepClone() }; + } + + private static FlowItemOrigin? ParseOrigin(JsonObject? origin) + { + if (origin == null) + return null; + + return new FlowItemOrigin( + origin["nodeId"]?.GetValue() ?? string.Empty, + origin["outputIndex"]?.GetValue() ?? 0, + origin["runIndex"]?.GetValue() ?? 0, + origin["itemIndex"]?.GetValue() ?? 0); + } + + private static Dictionary? ParseBinary(JsonObject? binary) + { + if (binary == null || binary.Count == 0) + return null; + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (name, value) in binary) + { + if (value is not JsonObject entry) + continue; + result[name] = new BinaryAttachment( + entry["assetId"]?.GetValue() ?? string.Empty, + entry["fileName"]?.GetValue(), + entry["mimeType"]?.GetValue(), + entry["sizeBytes"]?.GetValue()); + } + + return result; + } +} diff --git a/Models/Nodes/NodeBlueprint.cs b/Models/Nodes/NodeBlueprint.cs new file mode 100644 index 0000000..444ccac --- /dev/null +++ b/Models/Nodes/NodeBlueprint.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace w4c_workflows.Models.Nodes; + +// --------------------------------------------------------------------------- +// The declarative definition of a node TYPE ("blueprint"). This is the catalog +// entry that both the compiler/validator and the authoring UI consume: it says +// what a node is called, which ports it has, which parameters it exposes and +// which credentials it needs. Runtime behaviour lives behind INodeExecutor. +// +// Field names are camelCase on the wire (System.Text.Json Web defaults) so the +// catalog JSON files in Services/Nodes/Catalog read naturally. +// --------------------------------------------------------------------------- + +/// Broad role of a blueprint. Stored as text so new roles need no migration. +public static class NodeKind +{ + public const string Action = "action"; + public const string Trigger = "trigger"; + public const string Transform = "transform"; + public const string Control = "control"; + public const string Ai = "ai"; +} + +/// Connection channel a port belongs to. "main" carries flow items. +public static class PortKind +{ + public const string Main = "main"; + public const string Error = "error"; + public const string Ai = "ai"; +} + +/// How the runtime hands items to a node. +public static class NodeRunMode +{ + /// Invoke once per input item (the default). + public const string EachItem = "eachItem"; + /// Invoke once with the whole input array (aggregates, code nodes). + public const string AllItems = "allItems"; +} + +/// Parameter field kinds the authoring UI knows how to render. +public static class NodeParameterType +{ + public const string String = "string"; + public const string Number = "number"; + public const string Boolean = "boolean"; + public const string Options = "options"; + public const string MultiOptions = "multiOptions"; + public const string Collection = "collection"; + public const string FixedCollection = "fixedCollection"; + public const string ResourceLocator = "resourceLocator"; + public const string Json = "json"; + public const string Notice = "notice"; + public const string DateTime = "dateTime"; +} + +/// Where a blueprint came from — drives provenance badges and filtering. +public static class NodeOrigin +{ + public const string BuiltIn = "builtIn"; + public const string Connector = "connector"; + public const string OpenApi = "openApi"; + public const string Custom = "custom"; +} + +/// +/// A node type definition. Version is a decimal so it can track the same +/// minor-version history the reference integrations use (e.g. 2.7). +/// +public sealed record NodeBlueprint +{ + /// Stable machine id, e.g. "httpRequest" or "core.set". + public required string Type { get; init; } + + public double Version { get; init; } = 1; + + public required string DisplayName { get; init; } + + public string? Description { get; init; } + + /// Optional expression string for the step-card subtitle. + public string? Subtitle { get; init; } + + /// One of . + public string Kind { get; init; } = NodeKind.Action; + + public List Categories { get; init; } = new(); + + public string? Icon { get; init; } + + public string? IconColor { get; init; } + + public List Inputs { get; init; } = new() { NodePort.Main }; + + public List Outputs { get; init; } = new() { NodePort.Main }; + + /// One of . + public string RunMode { get; init; } = NodeRunMode.EachItem; + + public List Parameters { get; init; } = new(); + + public List Credentials { get; init; } = new(); + + /// Names of dynamic methods this node exposes (loadOptions/listSearch/schema). + public List Methods { get; init; } = new(); + + /// One of . + public string Origin { get; init; } = NodeOrigin.BuiltIn; + + public string? DocumentationUrl { get; init; } + + /// True when the node is hidden from the default palette (internal/test). + public bool Hidden { get; init; } + + [JsonIgnore] + public bool ProducesErrorBranch => + Outputs.Any(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal)); + + /// True when the blueprint exposes a parameter with the given name at any depth. + public bool HasParameter(string name) + { + foreach (var parameter in NodeParameterDescender.DescendAll(Parameters)) + { + if (string.Equals(parameter.Name, name, StringComparison.Ordinal)) + return true; + } + return false; + } +} + +/// A named, typed connection point on a node. +public sealed record NodePort +{ + /// One of . + public string Kind { get; init; } = PortKind.Main; + + /// Optional display label ("true", "false", "done", "loop"). + public string? Label { get; init; } + + public static readonly NodePort Main = new() { Kind = PortKind.Main }; + public static readonly NodePort Error = new() { Kind = PortKind.Error, Label = "error" }; +} + +/// A credential a node may need, referenced by alias in task parameters. +public sealed record NodeCredentialLink +{ + public required string Alias { get; init; } + public string? DisplayName { get; init; } + public bool Required { get; init; } +} + +/// +/// One form field in a node's parameter schema. Intentionally close to the +/// reference corpora's property model so migrated integrations map cleanly, but +/// with our own (clearer) names. +/// +public sealed record NodeParameter +{ + /// Machine name used in task parameters and expressions. + public required string Name { get; init; } + + public required string DisplayName { get; init; } + + /// One of . + public required string Type { get; init; } + + /// Default value. means "no default". + public JsonElement Default { get; init; } + + public string? Description { get; init; } + + public string? Placeholder { get; init; } + + public bool Required { get; init; } + + /// When true the field is a configuration value and never interpolated. + public bool NoDataExpression { get; init; } + + /// Value list for options/multiOptions. + public List? Options { get; init; } + + /// Nested fields for collection/fixedCollection. + public List? Fields { get; init; } + + /// Conditional visibility relative to sibling parameter values. + public NodeVisibility? VisibleWhen { get; init; } + + /// Method on the blueprint used to fetch options dynamically. + public string? LoadOptionsMethod { get; init; } + + /// Parameter names that must change before options are re-fetched. + public List? DependsOn { get; init; } + + /// JSON Schema-ish extras (min/max, precision, multi-value, …). + [JsonPropertyName("typeOptions")] + public JsonElement TypeOptions { get; init; } +} + +/// A selectable value for an options/multiOptions parameter. +public sealed record NodeParameterOption +{ + public required string Name { get; init; } + public required JsonElement Value { get; init; } + public string? Description { get; init; } +} + +/// +/// Conditional visibility for a parameter. A field is shown when every entry in +/// matches and no entry in matches; a +/// missing parameter is treated as not matching. +/// +public sealed record NodeVisibility +{ + public Dictionary>? Show { get; init; } + public Dictionary>? Hide { get; init; } +} + +/// Walks a parameter tree (nested collection fields) in depth-first order. +internal static class NodeParameterDescender +{ + internal static IEnumerable DescendAll(IEnumerable parameters) + { + foreach (var parameter in parameters) + { + yield return parameter; + if (parameter.Fields is { Count: > 0 }) + { + foreach (var nested in DescendAll(parameter.Fields)) + yield return nested; + } + } + } +} + +/// Serialization options shared by catalog loading and API responses. +public static class NodeJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; +} diff --git a/Program.cs b/Program.cs index a1bfbe5..b4ca858 100644 --- a/Program.cs +++ b/Program.cs @@ -10,6 +10,7 @@ using w4c_workflows.Middleware; using w4c_workflows.Services; using w4c_workflows.Services.Execution; using w4c_workflows.Services.Messaging; +using w4c_workflows.Services.Nodes; using w4c_workflows.Services.Runs; using w4c_workflows.Services.Triggers; @@ -120,6 +121,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Node catalog: the declarative blueprint registry backing GET /api/nodes and +// the authoring UI. Core blueprints ship embedded; connector packs load from a +// directory. Executors plug in as INodeExecutor implementations. +builder.Services.AddSingleton(_ => new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded())); +builder.Services.AddSingleton(); + // Realtime SSE hub — notifies subscribed clients when a workflow file changes server-side. builder.Services.AddSingleton(); diff --git a/Services/Nodes/Catalog/core-http-request.json b/Services/Nodes/Catalog/core-http-request.json new file mode 100644 index 0000000..04ec877 --- /dev/null +++ b/Services/Nodes/Catalog/core-http-request.json @@ -0,0 +1,142 @@ +{ + "type": "core.httpRequest", + "version": 1, + "displayName": "HTTP Request", + "description": "Calls an HTTP endpoint and returns the response as items", + "kind": "action", + "categories": ["Core", "Development"], + "icon": "node:http-request", + "inputs": [{ "kind": "main" }], + "outputs": [ + { "kind": "main" }, + { "kind": "error", "label": "error" } + ], + "runMode": "eachItem", + "parameters": [ + { + "name": "method", + "displayName": "Method", + "type": "options", + "default": "GET", + "noDataExpression": true, + "options": [ + { "name": "GET", "value": "GET" }, + { "name": "POST", "value": "POST" }, + { "name": "PUT", "value": "PUT" }, + { "name": "PATCH", "value": "PATCH" }, + { "name": "DELETE", "value": "DELETE" }, + { "name": "HEAD", "value": "HEAD" }, + { "name": "OPTIONS", "value": "OPTIONS" } + ] + }, + { + "name": "url", + "displayName": "URL", + "type": "string", + "required": true, + "default": "", + "placeholder": "https://api.example.com/items" + }, + { + "name": "authentication", + "displayName": "Authentication", + "type": "options", + "default": "none", + "noDataExpression": true, + "options": [ + { "name": "None", "value": "none" }, + { "name": "Predefined credential", "value": "credential" }, + { "name": "Generic header/basic", "value": "generic" } + ] + }, + { + "name": "credential", + "displayName": "Credential", + "type": "resourceLocator", + "visibleWhen": { "show": { "authentication": ["credential", "generic"] } }, + "loadOptionsMethod": "listCredentials", + "default": "" + }, + { + "name": "sendHeaders", + "displayName": "Send headers", + "type": "boolean", + "default": false + }, + { + "name": "headers", + "displayName": "Headers", + "type": "fixedCollection", + "visibleWhen": { "show": { "sendHeaders": [true] } }, + "fields": [ + { "name": "name", "displayName": "Name", "type": "string", "default": "" }, + { "name": "value", "displayName": "Value", "type": "string", "default": "" } + ] + }, + { + "name": "sendBody", + "displayName": "Send body", + "type": "boolean", + "default": false + }, + { + "name": "bodyContentType", + "displayName": "Body content type", + "type": "options", + "default": "json", + "visibleWhen": { "show": { "sendBody": [true] } }, + "noDataExpression": true, + "options": [ + { "name": "JSON", "value": "json" }, + { "name": "Form urlencoded", "value": "form" }, + { "name": "Multipart form data", "value": "multipart" }, + { "name": "Raw text", "value": "raw" }, + { "name": "Binary file", "value": "binary" } + ] + }, + { + "name": "body", + "displayName": "Body", + "type": "json", + "visibleWhen": { "show": { "sendBody": [true], "bodyContentType": ["json", "form", "raw"] } }, + "default": {} + }, + { + "name": "binaryField", + "displayName": "Input binary field", + "type": "string", + "default": "data", + "visibleWhen": { "show": { "bodyContentType": ["binary", "multipart"] } } + }, + { + "name": "options", + "displayName": "Options", + "type": "collection", + "fields": [ + { "name": "timeoutMs", "displayName": "Timeout (ms)", "type": "number", "default": 30000 }, + { "name": "followRedirects", "displayName": "Follow redirects", "type": "boolean", "default": true }, + { "name": "maxRedirects", "displayName": "Max redirects", "type": "number", "default": 5, "visibleWhen": { "show": { "followRedirects": [true] } } }, + { "name": "ignoreSslIssues", "displayName": "Ignore SSL issues", "type": "boolean", "default": false }, + { "name": "neverError", "displayName": "Never error on non-2xx", "type": "boolean", "default": false }, + { + "name": "responseFormat", + "displayName": "Response format", + "type": "options", + "default": "autodetect", + "noDataExpression": true, + "options": [ + { "name": "Autodetect", "value": "autodetect" }, + { "name": "JSON", "value": "json" }, + { "name": "Text", "value": "text" }, + { "name": "File", "value": "file" } + ] + }, + { "name": "putOutputInField", "displayName": "Put output in field", "type": "string", "default": "" } + ] + } + ], + "credentials": [ + { "alias": "httpAuth", "displayName": "HTTP credential", "required": false } + ], + "origin": "builtIn" +} diff --git a/Services/Nodes/Catalog/core-if.json b/Services/Nodes/Catalog/core-if.json new file mode 100644 index 0000000..6fb5b83 --- /dev/null +++ b/Services/Nodes/Catalog/core-if.json @@ -0,0 +1,60 @@ +{ + "type": "core.if", + "version": 1, + "displayName": "If", + "description": "Routes each item to the true or false output branch", + "kind": "control", + "categories": ["Core", "Flow"], + "icon": "node:if", + "inputs": [{ "kind": "main" }], + "outputs": [ + { "kind": "main", "label": "true" }, + { "kind": "main", "label": "false" } + ], + "runMode": "eachItem", + "parameters": [ + { + "name": "left", + "displayName": "Left value", + "type": "string", + "default": "", + "description": "Value to test; usually an expression such as ={{ $json.status }}" + }, + { + "name": "operator", + "displayName": "Operator", + "type": "options", + "default": "equals", + "noDataExpression": true, + "options": [ + { "name": "Equals", "value": "equals" }, + { "name": "Not equals", "value": "notEquals" }, + { "name": "Contains", "value": "contains" }, + { "name": "Starts with", "value": "startsWith" }, + { "name": "Ends with", "value": "endsWith" }, + { "name": "Is greater", "value": "greater" }, + { "name": "Is greater or equal", "value": "greaterOrEqual" }, + { "name": "Is less", "value": "less" }, + { "name": "Is less or equal", "value": "lessOrEqual" }, + { "name": "Is empty", "value": "isEmpty" }, + { "name": "Is not empty", "value": "isNotEmpty" }, + { "name": "Matches regex", "value": "regex" } + ] + }, + { + "name": "right", + "displayName": "Right value", + "type": "string", + "default": "", + "visibleWhen": { "hide": { "operator": ["isEmpty", "isNotEmpty"] } } + }, + { + "name": "caseSensitive", + "displayName": "Case sensitive", + "type": "boolean", + "default": false, + "visibleWhen": { "show": { "operator": ["equals", "notEquals", "contains", "startsWith", "endsWith"] } } + } + ], + "origin": "builtIn" +} diff --git a/Services/Nodes/Catalog/core-noop.json b/Services/Nodes/Catalog/core-noop.json new file mode 100644 index 0000000..37b08b5 --- /dev/null +++ b/Services/Nodes/Catalog/core-noop.json @@ -0,0 +1,14 @@ +{ + "type": "core.noop", + "version": 1, + "displayName": "No-op", + "description": "Passes input items through unchanged; useful as a join or anchor point", + "kind": "control", + "categories": ["Core", "Flow"], + "icon": "node:noop", + "inputs": [{ "kind": "main" }], + "outputs": [{ "kind": "main" }], + "runMode": "eachItem", + "parameters": [], + "origin": "builtIn" +} diff --git a/Services/Nodes/Catalog/core-set.json b/Services/Nodes/Catalog/core-set.json new file mode 100644 index 0000000..930651d --- /dev/null +++ b/Services/Nodes/Catalog/core-set.json @@ -0,0 +1,57 @@ +{ + "type": "core.set", + "version": 1, + "displayName": "Edit fields", + "description": "Add, change or remove fields on each item", + "kind": "transform", + "categories": ["Core", "Data"], + "icon": "node:edit-fields", + "inputs": [{ "kind": "main" }], + "outputs": [{ "kind": "main" }], + "runMode": "eachItem", + "parameters": [ + { + "name": "mode", + "displayName": "Mode", + "type": "options", + "default": "manual", + "noDataExpression": true, + "options": [ + { "name": "Manual mapping", "value": "manual", "description": "Set fields one by one" }, + { "name": "JSON", "value": "json", "description": "Build the item from a JSON value" } + ] + }, + { + "name": "fields", + "displayName": "Fields", + "type": "fixedCollection", + "visibleWhen": { "show": { "mode": ["manual"] } }, + "fields": [ + { "name": "name", "displayName": "Name", "type": "string", "default": "" }, + { "name": "value", "displayName": "Value", "type": "string", "default": "" } + ] + }, + { + "name": "jsonOutput", + "displayName": "JSON", + "type": "json", + "default": {}, + "visibleWhen": { "show": { "mode": ["json"] } } + }, + { + "name": "keepOnlySet", + "displayName": "Keep only set fields", + "type": "boolean", + "default": false, + "description": "Discard input fields that are not listed above" + }, + { + "name": "includeBinary", + "displayName": "Include binary", + "type": "boolean", + "default": true, + "description": "Pass binary attachments through to the output item" + } + ], + "origin": "builtIn" +} diff --git a/Services/Nodes/INodeExecutor.cs b/Services/Nodes/INodeExecutor.cs new file mode 100644 index 0000000..a5160fa --- /dev/null +++ b/Services/Nodes/INodeExecutor.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Nodes; +using w4c_workflows.Models.Nodes; + +namespace w4c_workflows.Services.Nodes; + +/// +/// Runs one node type. Implementations are registered in the node catalog and +/// resolved by the executor registry; the run engine feeds them resolved +/// parameters and input items and routes the returned outputs by port index. +/// +public interface INodeExecutor +{ + /// Blueprint type this executor implements, e.g. "core.set". + string Type { get; } + + Task RunAsync(NodeExecutionContext context, CancellationToken ct); +} + +/// +/// Everything a node needs for one invocation. Parameters are already resolved +/// (interpolation applied) for the target item; the raw scope stays available so +/// a node can re-evaluate a parameter per item if it needs to. +/// +public sealed record NodeExecutionContext +{ + public required NodeBlueprint Blueprint { get; init; } + + /// Resolved parameters keyed by parameter name. + public required JsonObject Parameters { get; init; } + + /// Input items per input port index. + public required IReadOnlyList> Inputs { get; init; } + + /// Decrypted credential data keyed by credential alias. + public IReadOnlyDictionary Credentials { get; init; } = + new Dictionary(); + + /// Workflow-level environment variables. + public JsonObject Environment { get; init; } = new(); + + public string TenantId { get; init; } = string.Empty; + public string RunId { get; init; } = string.Empty; + public string TaskId { get; init; } = string.Empty; + public string? NodeName { get; init; } + + /// Index of the item this invocation targets (0 for all-items mode). + public int ItemIndex { get; init; } + + /// Run iteration index for loops (0 on the first pass). + public int RunIndex { get; init; } + + public IServiceProvider? Services { get; init; } + + /// Items from the given input port (empty when the port is absent). + public IReadOnlyList Input(int portIndex = 0) + => portIndex >= 0 && portIndex < Inputs.Count ? Inputs[portIndex] : Array.Empty(); +} + +/// +/// Result of one node invocation. is indexed by output +/// port; a missing port means "no items on that port". +/// +public sealed record NodeExecutionOutcome +{ + public required IReadOnlyList> Outputs { get; init; } + + /// Set when the node failed but the engine may route to the error port. + public NodeFailure? Failure { get; init; } + + public bool Succeeded => Failure == null; + + /// Single-output convenience wrapper. + public static NodeExecutionOutcome Single(IReadOnlyList items) + => new() { Outputs = new[] { items } }; + + /// No items on any port (used by control nodes that consume input). + public static readonly NodeExecutionOutcome Empty = new() + { + Outputs = Array.Empty>(), + }; + + /// Failure on the error port. + public static NodeExecutionOutcome Failed(string message, string? code = null, string? description = null) + => new() + { + Outputs = Array.Empty>(), + Failure = new NodeFailure(message, code, description), + }; +} + +/// A node-level failure, optionally mapped to a user-facing message. +public sealed record NodeFailure( + string Message, + string? Code = null, + string? Description = null, + int? HttpStatus = null); diff --git a/Services/Nodes/Interpolation/NodeParameterInterpolator.cs b/Services/Nodes/Interpolation/NodeParameterInterpolator.cs new file mode 100644 index 0000000..52a0a98 --- /dev/null +++ b/Services/Nodes/Interpolation/NodeParameterInterpolator.cs @@ -0,0 +1,339 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using w4c_workflows.Models.Nodes; + +namespace w4c_workflows.Services.Nodes.Interpolation; + +/// Raised when a parameter expression cannot be resolved. +public sealed class NodeInterpolationException : Exception +{ + public NodeInterpolationException(string expression, string reason) + : base(reason) + { + Expression = expression; + } + + public string Expression { get; } +} + +/// +/// Data a parameter expression can read from. Kept small and explicit so the +/// resolver stays sandbox-safe (no reflection, no I/O, no arbitrary calls). +/// +public sealed record InterpolationScope +{ + /// The item being mapped; exposed as $json / $item. + public JsonObject? Item { get; init; } + + /// The node's own parameters; exposed as $parameter. + public JsonObject? Parameters { get; init; } + + /// Workflow environment; exposed as $env. + public JsonObject? Environment { get; init; } + + public int ItemIndex { get; init; } + + public int RunIndex { get; init; } + + /// Resolves items emitted by another node ($("Name") / $items("Name")). + public Func>? NodeItems { get; init; } +} + +/// +/// Our parameter interpolation engine. +/// +/// Rules: +/// - A string is in expression mode when it starts with '=' or contains a +/// {{ … }} block; otherwise it is a literal. +/// - In expression mode, {{ … }} blocks are evaluated and any text +/// around them stays literal. +/// - When the whole value is a single block, the resolved value keeps its +/// type (number, boolean, object, array). +/// - When a block is embedded in surrounding text, the result is a string and +/// non-string values are rendered (objects/arrays as JSON). +/// - Objects and arrays are resolved recursively, so expressions nested in a +/// collection field are applied per leaf. +/// +/// Grammar supported in increment 1: literals (string/number/bool/null) and +/// $root accessor paths with .name, ["name"] and [index]. +/// +public sealed class NodeParameterInterpolator +{ + private const char ExpressionPrefix = '='; + + public JsonObject ResolveObject(JsonObject parameters, InterpolationScope scope) + { + var result = new JsonObject(); + foreach (var (name, value) in parameters) + result[name] = ResolveNode(value, scope); + return result; + } + + public JsonNode? ResolveNode(JsonNode? value, InterpolationScope scope) => value switch + { + null => null, + JsonObject obj => ResolveObject(obj, scope), + JsonArray array => ResolveArray(array, scope), + JsonValue jsonValue when jsonValue.TryGetValue(out var text) => ResolveString(text, scope), + JsonValue jsonValue => jsonValue.DeepClone(), + _ => value.DeepClone(), + }; + + private JsonArray ResolveArray(JsonArray array, InterpolationScope scope) + { + var result = new JsonArray(); + foreach (var element in array) + result.Add(ResolveNode(element, scope)); + return result; + } + + /// Resolves a single string value according to the rules above. + public JsonNode? ResolveString(string text, InterpolationScope scope) + { + if (text.Length == 0) + return JsonValue.Create(text); + + string body; + if (text[0] == ExpressionPrefix) + { + body = text[1..]; + } + else + { + if (!text.Contains("{{", StringComparison.Ordinal)) + return JsonValue.Create(text); // no delimiters → literal + body = text; + } + + var blocks = ExtractBlocks(body); + + if (blocks.Count == 0) + return JsonValue.Create(body); // '=' with no delimiters → literal text + + if (blocks.Count == 1 && blocks[0].Start == 0 && blocks[0].End == body.Length) + { + // Whole-value expression: preserve the resolved type. + var resolved = Evaluate(blocks[0].Expression, scope); + return resolved?.DeepClone(); + } + + var builder = new StringBuilder(); + var cursor = 0; + foreach (var block in blocks) + { + if (block.Start > cursor) + builder.Append(body, cursor, block.Start - cursor); + builder.Append(Render(Evaluate(block.Expression, scope))); + cursor = block.End; + } + + if (cursor < body.Length) + builder.Append(body, cursor, body.Length - cursor); + + return JsonValue.Create(builder.ToString()); + } + + // ---------------------------------------------------------------- expressions + + private JsonNode? Evaluate(string expression, InterpolationScope scope) + { + var expr = expression.Trim(); + if (expr.Length == 0) + throw new NodeInterpolationException(expression, "empty expression"); + + if (expr[0] == '$') + return EvaluateAccessor(expr, scope); + + return ParseLiteral(expr, expression); + } + + private JsonNode? EvaluateAccessor(string expr, InterpolationScope scope) + { + // Node references: $("Name"), $node("Name"), $items("Name"). + if (TryReadNodeReference(expr, out var nodeName, out var wantsArray, out var tail)) + { + var items = scope.NodeItems?.Invoke(nodeName) ?? Array.Empty(); + JsonNode? node = wantsArray + ? new JsonArray(items.Select(i => (JsonNode?)i.Json.DeepClone()).ToArray()) + : items.Count > 0 + ? items[0].Json.DeepClone() + : null; + return tail.Length == 0 ? node : Navigate(node, tail, expr); + } + + // Root token: $ + identifier. + var end = 1; + while (end < expr.Length && (char.IsLetterOrDigit(expr[end]) || expr[end] == '_')) + end++; + + var root = expr[..end]; + var path = expr[end..]; + + JsonNode? start = root switch + { + "$json" or "$item" => scope.Item?.DeepClone(), + "$parameter" => scope.Parameters?.DeepClone(), + "$env" => scope.Environment?.DeepClone(), + "$itemIndex" => JsonValue.Create(scope.ItemIndex), + "$runIndex" => JsonValue.Create(scope.RunIndex), + "$now" => JsonValue.Create(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)), + "$today" => JsonValue.Create(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + _ => throw new NodeInterpolationException(expr, $"unknown accessor '{root}'"), + }; + + return path.Length == 0 ? start : Navigate(start, path, expr); + } + + private static bool TryReadNodeReference(string expr, out string nodeName, out bool wantsArray, out string tail) + { + nodeName = string.Empty; + wantsArray = false; + tail = string.Empty; + + string? prefix = null; + if (expr.StartsWith("$items(", StringComparison.Ordinal)) + { + prefix = "$items("; + wantsArray = true; + } + else if (expr.StartsWith("$node(", StringComparison.Ordinal)) + { + prefix = "$node("; + } + else if (expr.StartsWith("$(", StringComparison.Ordinal)) + { + prefix = "$("; + } + + if (prefix == null) + return false; + + var close = expr.IndexOf(')', prefix.Length); + if (close < 0) + throw new NodeInterpolationException(expr, "unterminated node reference"); + + var inner = expr[prefix.Length..close].Trim(); + if (inner.Length >= 2 && + ((inner[0] == '"' && inner[^1] == '"') || (inner[0] == '\'' && inner[^1] == '\''))) + { + inner = inner[1..^1]; + } + + nodeName = inner; + tail = expr[(close + 1)..]; + return true; + } + + private static JsonNode? Navigate(JsonNode? start, string path, string expression) + { + var cursor = 0; + var current = start; + + while (cursor < path.Length) + { + var ch = path[cursor]; + + if (ch == '.') + { + cursor++; + var name = ReadIdentifier(path, ref cursor); + if (name.Length == 0) + throw new NodeInterpolationException(expression, $"expected a field name after '.' at {cursor}"); + current = current is JsonObject obj ? obj[name] : null; + continue; + } + + if (ch == '[') + { + cursor++; + var close = path.IndexOf(']', cursor); + if (close < 0) + throw new NodeInterpolationException(expression, "unterminated '[' in path"); + var token = path[cursor..close].Trim(); + cursor = close + 1; + + if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0]) + { + var key = token[1..^1]; + current = current is JsonObject obj ? obj[key] : null; + } + else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) + { + current = current is JsonArray array && index >= 0 && index < array.Count ? array[index] : null; + } + else + { + throw new NodeInterpolationException(expression, $"invalid array index '{token}'"); + } + + continue; + } + + throw new NodeInterpolationException(expression, $"unexpected '{ch}' in path"); + } + + return current; + } + + 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]; + } + + private static JsonNode? ParseLiteral(string literal, string expression) + { + if (literal is "true" or "false") + return JsonValue.Create(literal == "true"); + if (literal is "null") + return null; + + if (literal.Length >= 2 && + ((literal[0] == '"' && literal[^1] == '"') || (literal[0] == '\'' && literal[^1] == '\''))) + { + return JsonValue.Create(literal[1..^1]); + } + + if (decimal.TryParse(literal, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) + return JsonValue.Create(number); + + throw new NodeInterpolationException(expression, $"unsupported expression '{literal}'"); + } + + private static string Render(JsonNode? node) => node switch + { + null => string.Empty, + JsonValue value when value.TryGetValue(out var text) => text, + JsonValue value => value.ToJsonString(), + _ => node.ToJsonString(), + }; + + // ------------------------------------------------------------------- scanning + + private readonly record struct Block(int Start, int End, string Expression); + + private static List ExtractBlocks(string body) + { + var blocks = new List(); + var cursor = 0; + + while (cursor < body.Length) + { + var open = body.IndexOf("{{", cursor, StringComparison.Ordinal); + if (open < 0) + break; + + var close = body.IndexOf("}}", open + 2, StringComparison.Ordinal); + if (close < 0) + break; // unterminated block: treat the remainder as literal + + blocks.Add(new Block(open, close + 2, body[(open + 2)..close])); + cursor = close + 2; + } + + return blocks; + } +} diff --git a/Services/Nodes/NodeBlueprintCatalog.cs b/Services/Nodes/NodeBlueprintCatalog.cs new file mode 100644 index 0000000..9559df5 --- /dev/null +++ b/Services/Nodes/NodeBlueprintCatalog.cs @@ -0,0 +1,178 @@ +using System.Reflection; +using System.Text.Json; +using w4c_workflows.Models.Nodes; + +namespace w4c_workflows.Services.Nodes; + +/// +/// The catalog of known node types. It is the single source of truth for the +/// compiler/validator and the authoring UI (palette + generic parameter forms). +/// +/// Blueprints come from two places: +/// - built-in core nodes, shipped as embedded JSON under Catalog/; +/// - additional packs (connectors, OpenAPI imports) loaded from files. +/// +public class NodeBlueprintCatalog +{ + private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog."; + + private readonly IReadOnlyDictionary> _byType; + + public NodeBlueprintCatalog(IEnumerable blueprints) + { + var byType = new Dictionary>(StringComparer.Ordinal); + + foreach (var blueprint in blueprints) + { + if (string.IsNullOrWhiteSpace(blueprint.Type)) + throw new InvalidOperationException("a node blueprint is missing its type id"); + + if (!byType.TryGetValue(blueprint.Type, out var versions)) + byType[blueprint.Type] = versions = new List(); + + if (versions.Any(existing => SameVersion(existing.Version, blueprint.Version))) + throw new InvalidOperationException( + $"duplicate node blueprint '{blueprint.Type}' version {blueprint.Version}"); + + versions.Add(blueprint); + } + + foreach (var versions in byType.Values) + versions.Sort((a, b) => a.Version.CompareTo(b.Version)); + + _byType = byType; + } + + /// All registered blueprints, newest version first within each type. + public IReadOnlyList All => + _byType.Values.SelectMany(v => v).ToList(); + + /// Distinct category labels, sorted, for the palette's category rail. + public IReadOnlyList Categories => + _byType.Values.SelectMany(v => v) + .SelectMany(b => b.Categories) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) + .ToList(); + + public bool Contains(string type) => _byType.ContainsKey(type); + + /// The default (highest) version of a type, or null when unknown. + public NodeBlueprint? Get(string type) + => _byType.TryGetValue(type, out var versions) ? versions[^1] : null; + + /// + /// Resolves a specific version. A requested version of 0/null means "default" + /// (highest). Otherwise the closest version not newer than requested is used, + /// so a task pinned to 2.5 keeps working after 2.6 ships. + /// + public NodeBlueprint? Resolve(string type, double? version = null) + { + if (!_byType.TryGetValue(type, out var versions)) + return null; + + if (version is null or 0) + return versions[^1]; + + NodeBlueprint? match = null; + foreach (var candidate in versions) + { + if (candidate.Version <= version.Value + 0.0001) + match = candidate; + else + break; + } + + return match; + } + + /// All versions of a type, newest first. + public IReadOnlyList Versions(string type) + => _byType.TryGetValue(type, out var versions) + ? versions.OrderByDescending(v => v.Version).ToList() + : Array.Empty(); + + /// + /// Filters the palette. matches type, display name, + /// description or category; matches ; + /// matches a category label. + /// + public IReadOnlyList Search(string? query = null, string? kind = null, string? category = null) + { + IEnumerable results = _byType.Values.Select(v => v[^1]); + + if (!string.IsNullOrWhiteSpace(kind)) + results = results.Where(b => string.Equals(b.Kind, kind, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(category)) + results = results.Where(b => b.Categories.Any(c => string.Equals(c, category, StringComparison.OrdinalIgnoreCase))); + + if (!string.IsNullOrWhiteSpace(query)) + { + var needle = query.Trim(); + results = results.Where(b => + Contains(b.Type, needle) || + Contains(b.DisplayName, needle) || + Contains(b.Description, needle) || + b.Categories.Any(c => Contains(c, needle))); + } + + return results + .OrderBy(b => b.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// Loads core blueprints embedded in the running assembly. + public static IReadOnlyList LoadEmbedded() + => LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly); + + /// Loads every Catalog/*.json embedded in . + public static IReadOnlyList LoadEmbedded(Assembly assembly) + { + var blueprints = new List(); + + foreach (var resource in assembly.GetManifestResourceNames()) + { + if (!resource.StartsWith(EmbeddedRoot, StringComparison.Ordinal) || + !resource.EndsWith(".json", StringComparison.Ordinal)) + { + continue; + } + + using var stream = assembly.GetManifestResourceStream(resource); + if (stream == null) + continue; + + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options) + ?? throw new InvalidOperationException($"node blueprint '{resource}' is empty"); + blueprints.Add(blueprint); + } + + return blueprints; + } + + /// Loads all *.json blueprints from a directory (connector packs). + public static IReadOnlyList LoadDirectory(string directory) + { + var blueprints = new List(); + if (!Directory.Exists(directory)) + return blueprints; + + foreach (var file in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories)) + { + var json = File.ReadAllText(file); + var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options) + ?? throw new InvalidOperationException($"node blueprint '{file}' is empty"); + blueprints.Add(blueprint); + } + + return blueprints; + } + + private static bool SameVersion(double a, double b) => Math.Abs(a - b) < 0.0001; + + private static bool Contains(string? haystack, string needle) + => haystack?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true; +} diff --git a/Services/Nodes/NodeExecutorRegistry.cs b/Services/Nodes/NodeExecutorRegistry.cs new file mode 100644 index 0000000..1c68cde --- /dev/null +++ b/Services/Nodes/NodeExecutorRegistry.cs @@ -0,0 +1,37 @@ +using w4c_workflows.Models.Nodes; + +namespace w4c_workflows.Services.Nodes; + +/// +/// Maps a blueprint type id to the that can run it. +/// Blueprints may exist without an executor (catalogue-only, e.g. a connector +/// that is listed but not installed); the compiler and API decide how to surface +/// that, the registry only answers whether a runner exists. +/// +public class NodeExecutorRegistry +{ + private readonly IReadOnlyDictionary _executors; + + public NodeExecutorRegistry(IEnumerable executors) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var executor in executors) + { + if (string.IsNullOrWhiteSpace(executor.Type)) + throw new InvalidOperationException("a node executor is missing its type id"); + if (!map.TryAdd(executor.Type, executor)) + throw new InvalidOperationException($"duplicate node executor for type '{executor.Type}'"); + } + + _executors = map; + } + + public IReadOnlyCollection Types => (IReadOnlyCollection)_executors.Keys; + + public INodeExecutor? Resolve(string type) + => _executors.TryGetValue(type, out var executor) ? executor : null; + + public bool CanRun(string type) => _executors.ContainsKey(type); + + public bool CanRun(NodeBlueprint blueprint) => CanRun(blueprint.Type); +} diff --git a/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs b/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs new file mode 100644 index 0000000..385efa0 --- /dev/null +++ b/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs @@ -0,0 +1,121 @@ +using w4c_workflows.Models.Nodes; +using w4c_workflows.Services.Nodes; +using Xunit; + +namespace w4c_workflows.Tests; + +public class NodeBlueprintCatalogTests +{ + private static NodeBlueprint Blueprint(string type, double version, string display, string kind = NodeKind.Action, params string[] categories) => + new() + { + Type = type, + Version = version, + DisplayName = display, + Kind = kind, + Categories = categories.ToList(), + }; + + [Fact] + public void LoadEmbedded_finds_the_core_blueprints() + { + var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()); + + Assert.True(catalog.Contains("core.noop")); + Assert.True(catalog.Contains("core.set")); + Assert.True(catalog.Contains("core.if")); + Assert.True(catalog.Contains("core.httpRequest")); + } + + [Fact] + public void Core_blueprints_expose_expected_ports() + { + var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()); + + var branch = catalog.Get("core.if"); + Assert.NotNull(branch); + Assert.Equal(2, branch!.Outputs.Count); + Assert.Equal("true", branch.Outputs[0].Label); + Assert.Equal("false", branch.Outputs[1].Label); + + var request = catalog.Get("core.httpRequest"); + Assert.NotNull(request); + Assert.True(request!.ProducesErrorBranch); + } + + [Fact] + public void Resolve_without_version_returns_the_highest() + { + var catalog = new NodeBlueprintCatalog(new[] + { + Blueprint("demo", 1, "Demo v1"), + Blueprint("demo", 2, "Demo v2"), + Blueprint("demo", 2.5, "Demo v2.5"), + }); + + Assert.Equal(2.5, catalog.Resolve("demo")!.Version); + } + + [Fact] + public void Resolve_picks_the_closest_version_not_newer_than_requested() + { + var catalog = new NodeBlueprintCatalog(new[] + { + Blueprint("demo", 1, "Demo v1"), + Blueprint("demo", 2, "Demo v2"), + Blueprint("demo", 2.5, "Demo v2.5"), + }); + + Assert.Equal(2, catalog.Resolve("demo", 2.2)!.Version); + Assert.Equal(1, catalog.Resolve("demo", 1.9)!.Version); + Assert.Equal(2, catalog.Resolve("demo", 2)!.Version); + Assert.Null(catalog.Resolve("demo", 0.5)); + } + + [Fact] + public void Duplicate_type_and_version_is_rejected() + { + var catalog = new NodeBlueprintCatalog(new[] { Blueprint("demo", 1, "A") }); + + Assert.Throws(() => + new NodeBlueprintCatalog(new[] { Blueprint("demo", 1, "A"), Blueprint("demo", 1, "B") })); + } + + [Fact] + public void Search_filters_by_query_kind_and_category() + { + var catalog = new NodeBlueprintCatalog(new[] + { + Blueprint("slack.send", 1, "Send message", NodeKind.Action, "Communication"), + Blueprint("if.branch", 1, "If", NodeKind.Control, "Flow"), + }); + + Assert.Single(catalog.Search(query: "slack")); + Assert.Single(catalog.Search(kind: NodeKind.Control)); + Assert.Single(catalog.Search(category: "Communication")); + Assert.Empty(catalog.Search(query: "does-not-exist")); + } + + [Fact] + public void Categories_are_distinct_and_sorted() + { + var catalog = new NodeBlueprintCatalog(new[] + { + Blueprint("a", 1, "A", NodeKind.Action, "Zeta"), + Blueprint("b", 1, "B", NodeKind.Action, "Alpha", "Zeta"), + }); + + Assert.Equal(new[] { "Alpha", "Zeta" }, catalog.Categories); + } + + [Fact] + public void HasParameter_searches_nested_collection_fields() + { + var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()); + var set = catalog.Get("core.set")!; + + Assert.True(set.HasParameter("mode")); + Assert.True(set.HasParameter("name")); // nested inside the fixedCollection + Assert.False(set.HasParameter("nope")); + } +} diff --git a/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs b/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs new file mode 100644 index 0000000..d36ed2d --- /dev/null +++ b/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs @@ -0,0 +1,165 @@ +using System.Text.Json.Nodes; +using w4c_workflows.Models.Nodes; +using w4c_workflows.Services.Nodes.Interpolation; +using Xunit; + +namespace w4c_workflows.Tests; + +public class NodeParameterInterpolatorTests +{ + private readonly NodeParameterInterpolator _interpolator = new(); + + private static InterpolationScope Scope( + string itemJson = "{}", + string parametersJson = "{}", + string environmentJson = "{}", + Func>? nodeItems = null) => + new() + { + Item = JsonNode.Parse(itemJson)!.AsObject(), + Parameters = JsonNode.Parse(parametersJson)!.AsObject(), + Environment = JsonNode.Parse(environmentJson)!.AsObject(), + ItemIndex = 2, + RunIndex = 1, + NodeItems = nodeItems, + }; + + [Fact] + public void Plain_string_is_a_literal() + { + var result = _interpolator.ResolveString("hello", Scope()); + Assert.Equal("hello", result!.GetValue()); + } + + [Fact] + public void Equals_without_braces_is_still_literal_text() + { + var result = _interpolator.ResolveString("=plain text", Scope()); + Assert.Equal("plain text", result!.GetValue()); + } + + [Fact] + public void Whole_value_expression_preserves_type_string() + { + var result = _interpolator.ResolveString("={{ $json.name }}", Scope("""{"name":"Alice"}""")); + Assert.Equal("Alice", result!.GetValue()); + } + + [Fact] + public void Whole_value_expression_preserves_type_number() + { + var result = _interpolator.ResolveString("={{ $json.count }}", Scope("""{"count":3}""")); + Assert.Equal("3", result!.ToJsonString()); + } + + [Fact] + public void Whole_value_expression_preserves_type_boolean() + { + var result = _interpolator.ResolveString("={{ $json.ok }}", Scope("""{"ok":true}""")); + Assert.Equal("true", result!.ToJsonString()); + } + + [Fact] + public void Embedded_expression_produces_an_interpolated_string() + { + var result = _interpolator.ResolveString("=Hi {{ $json.name }}, #{{ $json.count }}", Scope("""{"name":"Alice","count":3}""")); + Assert.Equal("Hi Alice, #3", result!.GetValue()); + } + + [Fact] + public void Nested_paths_and_array_indexes_resolve() + { + var result = _interpolator.ResolveString( + "={{ $json.orders[0].id }}", + Scope("""{"orders":[{"id":42}]}""")); + Assert.Equal("42", result!.ToJsonString()); + } + + [Fact] + public void Bracket_notation_resolves() + { + var result = _interpolator.ResolveString( + """={{ $json["odd name"] }}""", + Scope("""{"odd name":"value"}""")); + Assert.Equal("value", result!.GetValue()); + } + + [Fact] + public void Parameter_and_environment_roots_resolve() + { + var scope = Scope(parametersJson: """{"mode":"manual"}""", environmentJson: """{"REGION":"eu"}"""); + + Assert.Equal("manual", _interpolator.ResolveString("={{ $parameter.mode }}", scope)!.GetValue()); + Assert.Equal("eu", _interpolator.ResolveString("={{ $env.REGION }}", scope)!.GetValue()); + } + + [Fact] + public void Index_roots_resolve() + { + var scope = Scope(); + Assert.Equal("2", _interpolator.ResolveString("={{ $itemIndex }}", scope)!.ToJsonString()); + Assert.Equal("1", _interpolator.ResolveString("={{ $runIndex }}", scope)!.ToJsonString()); + } + + [Fact] + public void Node_reference_returns_the_first_item_json() + { + var scope = Scope(nodeItems: _ => new List + { + FlowItem.FromJson(new JsonObject { ["id"] = 7 }), + }); + + Assert.Equal("7", _interpolator.ResolveString("""={{ $("Prep").id }}""", scope)!.ToJsonString()); + } + + [Fact] + public void Items_reference_returns_an_array() + { + var scope = Scope(nodeItems: _ => new List + { + FlowItem.FromJson(new JsonObject { ["id"] = 7 }), + FlowItem.FromJson(new JsonObject { ["id"] = 8 }), + }); + + Assert.Equal("""[{"id":7},{"id":8}]""", _interpolator.ResolveString("""={{ $items("Prep") }}""", scope)!.ToJsonString()); + } + + [Fact] + public void Object_parameters_are_resolved_recursively() + { + var parameters = JsonNode.Parse(""" + { + "url": "https://x/{{ $json.id }}", + "options": { "flag": "={{ $json.ok }}" }, + "list": ["={{ $json.id }}"] + } + """)!.AsObject(); + + var resolved = _interpolator.ResolveObject(parameters, Scope("""{"id":"abc","ok":true}""")); + + Assert.Equal("https://x/abc", resolved["url"]!.GetValue()); + Assert.Equal("true", resolved["options"]!["flag"]!.ToJsonString()); + Assert.Equal("abc", resolved["list"]![0]!.GetValue()); + } + + [Fact] + public void Unknown_accessor_throws() + { + Assert.Throws(() => + _interpolator.ResolveString("={{ $nope.x }}", Scope())); + } + + [Fact] + public void Missing_path_resolves_to_null_without_throwing() + { + var result = _interpolator.ResolveString("={{ $json.missing.deep }}", Scope("""{"other":1}""")); + Assert.Null(result); + } + + [Fact] + public void Case_sensitive_literal_stays_lowercase() + { + var result = _interpolator.ResolveString("={{ $json.name }}", Scope("""{"name":"aB"}""")); + Assert.Equal("aB", result!.GetValue()); + } +} diff --git a/w4c-workflows-api.Tests/NodesControllerTests.cs b/w4c-workflows-api.Tests/NodesControllerTests.cs new file mode 100644 index 0000000..59188b4 --- /dev/null +++ b/w4c-workflows-api.Tests/NodesControllerTests.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Controllers; +using w4c_workflows.Models.Nodes; +using w4c_workflows.Services.Nodes; +using Xunit; + +namespace w4c_workflows.Tests; + +public class NodesControllerTests +{ + private static NodesController Create() => new( + new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()), + new NodeExecutorRegistry(Array.Empty())); + + [Fact] + public void List_returns_core_nodes_with_port_shape() + { + var result = Create().List(search: null, kind: null, category: null); + + var ok = Assert.IsType(result); + var items = Assert.IsAssignableFrom>(ok.Value).ToList(); + + var branch = items.Single(n => n.Type == "core.if"); + Assert.Equal(2, branch.Outputs); + Assert.False(branch.HasErrorBranch); + + var request = items.Single(n => n.Type == "core.httpRequest"); + Assert.True(request.HasErrorBranch); + } + + [Fact] + public void List_filters_by_kind() + { + var ok = (OkObjectResult)Create().List(search: null, kind: NodeKind.Control, category: null); + var items = ((IEnumerable)ok.Value!).ToList(); + + Assert.Contains(items, n => n.Type == "core.if"); + Assert.DoesNotContain(items, n => n.Type == "core.httpRequest"); + } + + [Fact] + public void Get_returns_blueprint_detail() + { + var ok = Assert.IsType(Create().Get("core.set", null)); + var detail = Assert.IsType(ok.Value); + + Assert.Equal("core.set", detail.Blueprint.Type); + Assert.False(detail.Runnable); // no executors installed yet + } + + [Fact] + public void Get_unknown_type_returns_404() + { + Assert.IsType(Create().Get("does.not.exist", null)); + } + + [Fact] + public void Categories_are_exposed() + { + var ok = (OkObjectResult)Create().Categories(); + var categories = Assert.IsAssignableFrom>(ok.Value); + + Assert.Contains("Core", categories); + } +} diff --git a/w4c-workflows-api.csproj b/w4c-workflows-api.csproj index addfd2e..97ca45d 100644 --- a/w4c-workflows-api.csproj +++ b/w4c-workflows-api.csproj @@ -15,6 +15,13 @@ + + + + +