workflow integrations

This commit is contained in:
Vitali sharp8n 2026-09-11 19:04:50 +03:00
parent 9d601791ae
commit 9de67df65f
16 changed files with 1825 additions and 0 deletions

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <summary>Lists node types for the palette, with optional search/filter.</summary>
[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));
}
/// <summary>All category labels, for the palette's category rail.</summary>
[HttpGet("categories")]
[RequireScope("read")]
public IActionResult Categories() => Ok(_catalog.Categories);
/// <summary>Full blueprint for one node type (optionally a pinned version).</summary>
[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));
}
/// <summary>Palette entry: blueprint identity plus port shape, without the full form schema.</summary>
public sealed record NodeSummary(
string Type,
double Version,
string DisplayName,
string? Description,
string Kind,
IReadOnlyList<string> Categories,
string? Icon,
string? IconColor,
string RunMode,
bool Hidden,
string Origin,
int Inputs,
int Outputs,
IReadOnlyList<NodePort> InputPorts,
IReadOnlyList<NodePort> OutputPorts,
bool HasErrorBranch,
bool RequiresCredentials,
bool Runnable);
/// <summary>Full blueprint detail plus whether an executor is installed for it.</summary>
public sealed record NodeDetail(NodeBlueprint Blueprint, bool Runnable);

190
Models/Nodes/FlowItem.cs Normal file
View file

@ -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.
// ---------------------------------------------------------------------------
/// <summary>A binary payload stored out-of-band; items only hold a reference.</summary>
public sealed record BinaryAttachment(
string AssetId,
string? FileName = null,
string? MimeType = null,
long? SizeBytes = null);
/// <summary>A pointer back to an item emitted by another node run.</summary>
public sealed record ItemOrigin(
string NodeId,
int OutputIndex = 0,
int RunIndex = 0,
int ItemIndex = 0);
/// <summary>Where a <see cref="FlowItem"/> came from (for tracing and expressions).</summary>
public sealed record FlowItemOrigin(
string NodeId,
int OutputIndex = 0,
int RunIndex = 0,
int ItemIndex = 0,
List<ItemOrigin>? PairedItems = null);
/// <summary>
/// One data item. <see cref="Json"/> is the payload; <see cref="Binary"/> holds
/// named binary references; <see cref="Origin"/> links it to its source.
/// </summary>
public sealed record FlowItem
{
public required JsonObject Json { get; init; }
public Dictionary<string, BinaryAttachment>? Binary { get; init; }
public FlowItemOrigin? Origin { get; init; }
/// <summary>Convenience factory for a plain JSON item.</summary>
public static FlowItem FromJson(JsonObject json) => new() { Json = json };
/// <summary>Wraps a bare JSON value in a single "value" field.</summary>
public static FlowItem Scalar(JsonNode? value) => new()
{
Json = new JsonObject { ["value"] = value?.DeepClone() },
};
}
/// <summary>Helpers for turning sets of items into and out of JSON.</summary>
public static class FlowItemJson
{
/// <summary>Serializes a batch of items into a JSON array (or null when empty).</summary>
public static string? Serialize(IReadOnlyList<FlowItem> 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();
}
/// <summary>
/// Parses a JSON payload into items. Accepts a JSON array of
/// <c>{ "json": … }</c> envelopes, a JSON array of bare objects, or a single
/// bare object (which becomes a one-item batch).
/// </summary>
public static List<FlowItem> Parse(string? payload)
{
var items = new List<FlowItem>();
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>() ?? string.Empty,
origin["outputIndex"]?.GetValue<int>() ?? 0,
origin["runIndex"]?.GetValue<int>() ?? 0,
origin["itemIndex"]?.GetValue<int>() ?? 0);
}
private static Dictionary<string, BinaryAttachment>? ParseBinary(JsonObject? binary)
{
if (binary == null || binary.Count == 0)
return null;
var result = new Dictionary<string, BinaryAttachment>(StringComparer.Ordinal);
foreach (var (name, value) in binary)
{
if (value is not JsonObject entry)
continue;
result[name] = new BinaryAttachment(
entry["assetId"]?.GetValue<string>() ?? string.Empty,
entry["fileName"]?.GetValue<string>(),
entry["mimeType"]?.GetValue<string>(),
entry["sizeBytes"]?.GetValue<long>());
}
return result;
}
}

View file

@ -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.
// ---------------------------------------------------------------------------
/// <summary>Broad role of a blueprint. Stored as text so new roles need no migration.</summary>
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";
}
/// <summary>Connection channel a port belongs to. "main" carries flow items.</summary>
public static class PortKind
{
public const string Main = "main";
public const string Error = "error";
public const string Ai = "ai";
}
/// <summary>How the runtime hands items to a node.</summary>
public static class NodeRunMode
{
/// <summary>Invoke once per input item (the default).</summary>
public const string EachItem = "eachItem";
/// <summary>Invoke once with the whole input array (aggregates, code nodes).</summary>
public const string AllItems = "allItems";
}
/// <summary>Parameter field kinds the authoring UI knows how to render.</summary>
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";
}
/// <summary>Where a blueprint came from — drives provenance badges and filtering.</summary>
public static class NodeOrigin
{
public const string BuiltIn = "builtIn";
public const string Connector = "connector";
public const string OpenApi = "openApi";
public const string Custom = "custom";
}
/// <summary>
/// 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).
/// </summary>
public sealed record NodeBlueprint
{
/// <summary>Stable machine id, e.g. "httpRequest" or "core.set".</summary>
public required string Type { get; init; }
public double Version { get; init; } = 1;
public required string DisplayName { get; init; }
public string? Description { get; init; }
/// <summary>Optional expression string for the step-card subtitle.</summary>
public string? Subtitle { get; init; }
/// <summary>One of <see cref="NodeKind"/>.</summary>
public string Kind { get; init; } = NodeKind.Action;
public List<string> Categories { get; init; } = new();
public string? Icon { get; init; }
public string? IconColor { get; init; }
public List<NodePort> Inputs { get; init; } = new() { NodePort.Main };
public List<NodePort> Outputs { get; init; } = new() { NodePort.Main };
/// <summary>One of <see cref="NodeRunMode"/>.</summary>
public string RunMode { get; init; } = NodeRunMode.EachItem;
public List<NodeParameter> Parameters { get; init; } = new();
public List<NodeCredentialLink> Credentials { get; init; } = new();
/// <summary>Names of dynamic methods this node exposes (loadOptions/listSearch/schema).</summary>
public List<string> Methods { get; init; } = new();
/// <summary>One of <see cref="NodeOrigin"/>.</summary>
public string Origin { get; init; } = NodeOrigin.BuiltIn;
public string? DocumentationUrl { get; init; }
/// <summary>True when the node is hidden from the default palette (internal/test).</summary>
public bool Hidden { get; init; }
[JsonIgnore]
public bool ProducesErrorBranch =>
Outputs.Any(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal));
/// <summary>True when the blueprint exposes a parameter with the given name at any depth.</summary>
public bool HasParameter(string name)
{
foreach (var parameter in NodeParameterDescender.DescendAll(Parameters))
{
if (string.Equals(parameter.Name, name, StringComparison.Ordinal))
return true;
}
return false;
}
}
/// <summary>A named, typed connection point on a node.</summary>
public sealed record NodePort
{
/// <summary>One of <see cref="PortKind"/>.</summary>
public string Kind { get; init; } = PortKind.Main;
/// <summary>Optional display label ("true", "false", "done", "loop").</summary>
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" };
}
/// <summary>A credential a node may need, referenced by alias in task parameters.</summary>
public sealed record NodeCredentialLink
{
public required string Alias { get; init; }
public string? DisplayName { get; init; }
public bool Required { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed record NodeParameter
{
/// <summary>Machine name used in task parameters and expressions.</summary>
public required string Name { get; init; }
public required string DisplayName { get; init; }
/// <summary>One of <see cref="NodeParameterType"/>.</summary>
public required string Type { get; init; }
/// <summary>Default value. <see cref="JsonValueKind.Undefined"/> means "no default".</summary>
public JsonElement Default { get; init; }
public string? Description { get; init; }
public string? Placeholder { get; init; }
public bool Required { get; init; }
/// <summary>When true the field is a configuration value and never interpolated.</summary>
public bool NoDataExpression { get; init; }
/// <summary>Value list for options/multiOptions.</summary>
public List<NodeParameterOption>? Options { get; init; }
/// <summary>Nested fields for collection/fixedCollection.</summary>
public List<NodeParameter>? Fields { get; init; }
/// <summary>Conditional visibility relative to sibling parameter values.</summary>
public NodeVisibility? VisibleWhen { get; init; }
/// <summary>Method on the blueprint used to fetch options dynamically.</summary>
public string? LoadOptionsMethod { get; init; }
/// <summary>Parameter names that must change before options are re-fetched.</summary>
public List<string>? DependsOn { get; init; }
/// <summary>JSON Schema-ish extras (min/max, precision, multi-value, …).</summary>
[JsonPropertyName("typeOptions")]
public JsonElement TypeOptions { get; init; }
}
/// <summary>A selectable value for an options/multiOptions parameter.</summary>
public sealed record NodeParameterOption
{
public required string Name { get; init; }
public required JsonElement Value { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Conditional visibility for a parameter. A field is shown when every entry in
/// <see cref="Show"/> matches and no entry in <see cref="Hide"/> matches; a
/// missing parameter is treated as not matching.
/// </summary>
public sealed record NodeVisibility
{
public Dictionary<string, List<JsonElement>>? Show { get; init; }
public Dictionary<string, List<JsonElement>>? Hide { get; init; }
}
/// <summary>Walks a parameter tree (nested collection fields) in depth-first order.</summary>
internal static class NodeParameterDescender
{
internal static IEnumerable<NodeParameter> DescendAll(IEnumerable<NodeParameter> 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;
}
}
}
}
/// <summary>Serialization options shared by catalog loading and API responses.</summary>
public static class NodeJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
}

View file

@ -10,6 +10,7 @@ using w4c_workflows.Middleware;
using w4c_workflows.Services; using w4c_workflows.Services;
using w4c_workflows.Services.Execution; using w4c_workflows.Services.Execution;
using w4c_workflows.Services.Messaging; using w4c_workflows.Services.Messaging;
using w4c_workflows.Services.Nodes;
using w4c_workflows.Services.Runs; using w4c_workflows.Services.Runs;
using w4c_workflows.Services.Triggers; using w4c_workflows.Services.Triggers;
@ -120,6 +121,12 @@ builder.Services.AddSingleton<LanguageRegistry>();
builder.Services.AddSingleton<WorkflowValidator>(); builder.Services.AddSingleton<WorkflowValidator>();
builder.Services.AddSingleton<WorkflowCompiler>(); builder.Services.AddSingleton<WorkflowCompiler>();
// 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<NodeExecutorRegistry>();
// Realtime SSE hub — notifies subscribed clients when a workflow file changes server-side. // Realtime SSE hub — notifies subscribed clients when a workflow file changes server-side.
builder.Services.AddSingleton<RealtimeEventHub>(); builder.Services.AddSingleton<RealtimeEventHub>();

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -0,0 +1,96 @@
using System.Text.Json.Nodes;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>
/// 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.
/// </summary>
public interface INodeExecutor
{
/// <summary>Blueprint type this executor implements, e.g. "core.set".</summary>
string Type { get; }
Task<NodeExecutionOutcome> RunAsync(NodeExecutionContext context, CancellationToken ct);
}
/// <summary>
/// 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.
/// </summary>
public sealed record NodeExecutionContext
{
public required NodeBlueprint Blueprint { get; init; }
/// <summary>Resolved parameters keyed by parameter name.</summary>
public required JsonObject Parameters { get; init; }
/// <summary>Input items per input port index.</summary>
public required IReadOnlyList<IReadOnlyList<FlowItem>> Inputs { get; init; }
/// <summary>Decrypted credential data keyed by credential alias.</summary>
public IReadOnlyDictionary<string, JsonObject> Credentials { get; init; } =
new Dictionary<string, JsonObject>();
/// <summary>Workflow-level environment variables.</summary>
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; }
/// <summary>Index of the item this invocation targets (0 for all-items mode).</summary>
public int ItemIndex { get; init; }
/// <summary>Run iteration index for loops (0 on the first pass).</summary>
public int RunIndex { get; init; }
public IServiceProvider? Services { get; init; }
/// <summary>Items from the given input port (empty when the port is absent).</summary>
public IReadOnlyList<FlowItem> Input(int portIndex = 0)
=> portIndex >= 0 && portIndex < Inputs.Count ? Inputs[portIndex] : Array.Empty<FlowItem>();
}
/// <summary>
/// Result of one node invocation. <see cref="Outputs"/> is indexed by output
/// port; a missing port means "no items on that port".
/// </summary>
public sealed record NodeExecutionOutcome
{
public required IReadOnlyList<IReadOnlyList<FlowItem>> Outputs { get; init; }
/// <summary>Set when the node failed but the engine may route to the error port.</summary>
public NodeFailure? Failure { get; init; }
public bool Succeeded => Failure == null;
/// <summary>Single-output convenience wrapper.</summary>
public static NodeExecutionOutcome Single(IReadOnlyList<FlowItem> items)
=> new() { Outputs = new[] { items } };
/// <summary>No items on any port (used by control nodes that consume input).</summary>
public static readonly NodeExecutionOutcome Empty = new()
{
Outputs = Array.Empty<IReadOnlyList<FlowItem>>(),
};
/// <summary>Failure on the error port.</summary>
public static NodeExecutionOutcome Failed(string message, string? code = null, string? description = null)
=> new()
{
Outputs = Array.Empty<IReadOnlyList<FlowItem>>(),
Failure = new NodeFailure(message, code, description),
};
}
/// <summary>A node-level failure, optionally mapped to a user-facing message.</summary>
public sealed record NodeFailure(
string Message,
string? Code = null,
string? Description = null,
int? HttpStatus = null);

View file

@ -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;
/// <summary>Raised when a parameter expression cannot be resolved.</summary>
public sealed class NodeInterpolationException : Exception
{
public NodeInterpolationException(string expression, string reason)
: base(reason)
{
Expression = expression;
}
public string Expression { get; }
}
/// <summary>
/// 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).
/// </summary>
public sealed record InterpolationScope
{
/// <summary>The item being mapped; exposed as <c>$json</c> / <c>$item</c>.</summary>
public JsonObject? Item { get; init; }
/// <summary>The node's own parameters; exposed as <c>$parameter</c>.</summary>
public JsonObject? Parameters { get; init; }
/// <summary>Workflow environment; exposed as <c>$env</c>.</summary>
public JsonObject? Environment { get; init; }
public int ItemIndex { get; init; }
public int RunIndex { get; init; }
/// <summary>Resolves items emitted by another node (<c>$("Name")</c> / <c>$items("Name")</c>).</summary>
public Func<string, IReadOnlyList<FlowItem>>? NodeItems { get; init; }
}
/// <summary>
/// Our parameter interpolation engine.
///
/// Rules:
/// - A string is in expression mode when it starts with '=' or contains a
/// <c>{{ … }}</c> block; otherwise it is a literal.
/// - In expression mode, <c>{{ … }}</c> 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
/// <c>$root</c> accessor paths with <c>.name</c>, <c>["name"]</c> and <c>[index]</c>.
/// </summary>
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<string>(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;
}
/// <summary>Resolves a single string value according to the rules above.</summary>
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<FlowItem>();
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<string>(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<Block> ExtractBlocks(string body)
{
var blocks = new List<Block>();
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;
}
}

View file

@ -0,0 +1,178 @@
using System.Reflection;
using System.Text.Json;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>
/// 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 <c>Catalog/</c>;
/// - additional packs (connectors, OpenAPI imports) loaded from files.
/// </summary>
public class NodeBlueprintCatalog
{
private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog.";
private readonly IReadOnlyDictionary<string, List<NodeBlueprint>> _byType;
public NodeBlueprintCatalog(IEnumerable<NodeBlueprint> blueprints)
{
var byType = new Dictionary<string, List<NodeBlueprint>>(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<NodeBlueprint>();
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;
}
/// <summary>All registered blueprints, newest version first within each type.</summary>
public IReadOnlyList<NodeBlueprint> All =>
_byType.Values.SelectMany(v => v).ToList();
/// <summary>Distinct category labels, sorted, for the palette's category rail.</summary>
public IReadOnlyList<string> 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);
/// <summary>The default (highest) version of a type, or null when unknown.</summary>
public NodeBlueprint? Get(string type)
=> _byType.TryGetValue(type, out var versions) ? versions[^1] : null;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>All versions of a type, newest first.</summary>
public IReadOnlyList<NodeBlueprint> Versions(string type)
=> _byType.TryGetValue(type, out var versions)
? versions.OrderByDescending(v => v.Version).ToList()
: Array.Empty<NodeBlueprint>();
/// <summary>
/// Filters the palette. <paramref name="query"/> matches type, display name,
/// description or category; <paramref name="kind"/> matches <see cref="NodeKind"/>;
/// <paramref name="category"/> matches a category label.
/// </summary>
public IReadOnlyList<NodeBlueprint> Search(string? query = null, string? kind = null, string? category = null)
{
IEnumerable<NodeBlueprint> 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();
}
/// <summary>Loads core blueprints embedded in the running assembly.</summary>
public static IReadOnlyList<NodeBlueprint> LoadEmbedded()
=> LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly);
/// <summary>Loads every <c>Catalog/*.json</c> embedded in <paramref name="assembly"/>.</summary>
public static IReadOnlyList<NodeBlueprint> LoadEmbedded(Assembly assembly)
{
var blueprints = new List<NodeBlueprint>();
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<NodeBlueprint>(json, NodeJson.Options)
?? throw new InvalidOperationException($"node blueprint '{resource}' is empty");
blueprints.Add(blueprint);
}
return blueprints;
}
/// <summary>Loads all <c>*.json</c> blueprints from a directory (connector packs).</summary>
public static IReadOnlyList<NodeBlueprint> LoadDirectory(string directory)
{
var blueprints = new List<NodeBlueprint>();
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<NodeBlueprint>(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;
}

View file

@ -0,0 +1,37 @@
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>
/// Maps a blueprint type id to the <see cref="INodeExecutor"/> 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.
/// </summary>
public class NodeExecutorRegistry
{
private readonly IReadOnlyDictionary<string, INodeExecutor> _executors;
public NodeExecutorRegistry(IEnumerable<INodeExecutor> executors)
{
var map = new Dictionary<string, INodeExecutor>(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<string> Types => (IReadOnlyCollection<string>)_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);
}

View file

@ -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<InvalidOperationException>(() =>
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"));
}
}

View file

@ -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<string, IReadOnlyList<FlowItem>>? 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<string>());
}
[Fact]
public void Equals_without_braces_is_still_literal_text()
{
var result = _interpolator.ResolveString("=plain text", Scope());
Assert.Equal("plain text", result!.GetValue<string>());
}
[Fact]
public void Whole_value_expression_preserves_type_string()
{
var result = _interpolator.ResolveString("={{ $json.name }}", Scope("""{"name":"Alice"}"""));
Assert.Equal("Alice", result!.GetValue<string>());
}
[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<string>());
}
[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<string>());
}
[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<string>());
Assert.Equal("eu", _interpolator.ResolveString("={{ $env.REGION }}", scope)!.GetValue<string>());
}
[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>
{
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>
{
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<string>());
Assert.Equal("true", resolved["options"]!["flag"]!.ToJsonString());
Assert.Equal("abc", resolved["list"]![0]!.GetValue<string>());
}
[Fact]
public void Unknown_accessor_throws()
{
Assert.Throws<NodeInterpolationException>(() =>
_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<string>());
}
}

View file

@ -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<INodeExecutor>()));
[Fact]
public void List_returns_core_nodes_with_port_shape()
{
var result = Create().List(search: null, kind: null, category: null);
var ok = Assert.IsType<OkObjectResult>(result);
var items = Assert.IsAssignableFrom<IEnumerable<NodeSummary>>(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<NodeSummary>)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<OkObjectResult>(Create().Get("core.set", null));
var detail = Assert.IsType<NodeDetail>(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<NotFoundObjectResult>(Create().Get("does.not.exist", null));
}
[Fact]
public void Categories_are_exposed()
{
var ok = (OkObjectResult)Create().Categories();
var categories = Assert.IsAssignableFrom<IReadOnlyList<string>>(ok.Value);
Assert.Contains("Core", categories);
}
}

View file

@ -15,6 +15,13 @@
<None Remove="w4c-workflows-api.Tests/**" /> <None Remove="w4c-workflows-api.Tests/**" />
</ItemGroup> </ItemGroup>
<!-- Built-in node blueprints ship embedded so the catalog is available without
relying on files being copied next to the binary. Connector packs loaded
at runtime come from a directory instead. -->
<ItemGroup>
<EmbeddedResource Include="Services/Nodes/Catalog/*.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="11.0.0-preview.6.26359.118" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="11.0.0-preview.6.26359.118" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="11.0.0-preview.6.26359.118" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="11.0.0-preview.6.26359.118" />