w4c-workflows-api/Models/Nodes/NodeBlueprint.cs

283 lines
11 KiB
C#
Raw Normal View History

2026-09-11 16:04:50 +00:00
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();
2026-09-11 22:02:46 +00:00
/// <summary>
/// Declarative REST connector definition. When present (and
/// <see cref="Origin"/> is <see cref="NodeOrigin.Connector"/>) the shared
/// <c>RestConnectorExecutor</c> runs it, so an integration is catalog data,
/// not a bespoke executor.
/// </summary>
public NodeConnector? Connector { get; init; }
2026-09-11 16:04:50 +00:00
/// <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; }
2026-09-11 22:02:46 +00:00
/// <summary>
/// True when the node can host a loop: an edge back into it is treated as a
/// loop-back edge (excluded from the readiness count) so the loop body can
/// re-run. Only nodes that keep their own iteration state should set this.
/// </summary>
public bool LoopBack { get; init; }
2026-09-11 16:04:50 +00:00
[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>
2026-09-13 16:28:47 +00:00
public bool HasParameter(string name) => ParameterIndex.ContainsKey(name);
/// <summary>
/// Resolves a top-level parameter by name, or null. Precomputed so compile
/// validation does not rescan the parameter list per supplied parameter.
/// </summary>
public NodeParameter? FindParameter(string name)
=> TopLevelParameterIndex.TryGetValue(name, out var parameter) ? parameter : null;
private Dictionary<string, NodeParameter>? _parameterIndex;
private Dictionary<string, NodeParameter>? _topLevelParameterIndex;
private Dictionary<string, NodeParameter> ParameterIndex => _parameterIndex ??=
BuildParameterIndex(NodeParameterDescender.DescendAll(Parameters));
private Dictionary<string, NodeParameter> TopLevelParameterIndex => _topLevelParameterIndex ??=
BuildParameterIndex(Parameters);
/// <summary>First-wins name index; duplicate names at one level are tolerated.</summary>
private static Dictionary<string, NodeParameter> BuildParameterIndex(IEnumerable<NodeParameter> parameters)
2026-09-11 16:04:50 +00:00
{
2026-09-13 16:28:47 +00:00
var index = new Dictionary<string, NodeParameter>(StringComparer.Ordinal);
foreach (var parameter in parameters)
index.TryAdd(parameter.Name, parameter);
return index;
2026-09-11 16:04:50 +00:00
}
}
/// <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; }
2026-09-11 22:02:46 +00:00
/// <summary>
/// Default value, or <c>null</c> when the parameter has none. Nullable is
/// deliberate: a missing default deserializes to <c>null</c>, whereas a
/// <see cref="JsonValueKind.Undefined"/> <see cref="JsonElement"/> cannot be
/// written by System.Text.Json (it throws on serialize).
/// </summary>
public JsonElement? Default { get; init; }
2026-09-11 16:04:50 +00:00
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; }
2026-09-11 22:02:46 +00:00
/// <summary>JSON Schema-ish extras (min/max, precision, multi-value, …); null when none.</summary>
2026-09-11 16:04:50 +00:00
[JsonPropertyName("typeOptions")]
2026-09-11 22:02:46 +00:00
public JsonElement? TypeOptions { get; init; }
2026-09-11 16:04:50 +00:00
}
/// <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,
};
}