workflow integrations
This commit is contained in:
parent
9d601791ae
commit
9de67df65f
102
Controllers/NodesController.cs
Normal file
102
Controllers/NodesController.cs
Normal 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
190
Models/Nodes/FlowItem.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Models/Nodes/NodeBlueprint.cs
Normal file
245
Models/Nodes/NodeBlueprint.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
142
Services/Nodes/Catalog/core-http-request.json
Normal file
142
Services/Nodes/Catalog/core-http-request.json
Normal 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"
|
||||||
|
}
|
||||||
60
Services/Nodes/Catalog/core-if.json
Normal file
60
Services/Nodes/Catalog/core-if.json
Normal 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"
|
||||||
|
}
|
||||||
14
Services/Nodes/Catalog/core-noop.json
Normal file
14
Services/Nodes/Catalog/core-noop.json
Normal 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"
|
||||||
|
}
|
||||||
57
Services/Nodes/Catalog/core-set.json
Normal file
57
Services/Nodes/Catalog/core-set.json
Normal 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"
|
||||||
|
}
|
||||||
96
Services/Nodes/INodeExecutor.cs
Normal file
96
Services/Nodes/INodeExecutor.cs
Normal 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);
|
||||||
339
Services/Nodes/Interpolation/NodeParameterInterpolator.cs
Normal file
339
Services/Nodes/Interpolation/NodeParameterInterpolator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
Services/Nodes/NodeBlueprintCatalog.cs
Normal file
178
Services/Nodes/NodeBlueprintCatalog.cs
Normal 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;
|
||||||
|
}
|
||||||
37
Services/Nodes/NodeExecutorRegistry.cs
Normal file
37
Services/Nodes/NodeExecutorRegistry.cs
Normal 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);
|
||||||
|
}
|
||||||
121
w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs
Normal file
121
w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
165
w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs
Normal file
165
w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs
Normal 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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
65
w4c-workflows-api.Tests/NodesControllerTests.cs
Normal file
65
w4c-workflows-api.Tests/NodesControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue