w4c-workflows-api/Services/Nodes/NodeGraphCompiler.Validation.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

208 lines
8.1 KiB
C#

using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using w4c_workflows.Models;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Security;
namespace w4c_workflows.Services.Nodes;
// Validation half of the node-graph compiler: per-step id/blueprint/parameter
// and credential-alias checks. Compilation flow and edge handling live in
// NodeGraphCompiler.cs / NodeGraphCompiler.Edges.cs.
public sealed partial class NodeGraphCompiler
{
// ------------------------------------------------------------------ steps
/// <summary>
/// Validates each step's credential aliases: they must be declared by the
/// blueprint, required links must be present, and an alias must not be bound
/// to two different credentials within one workflow (which is what lets the
/// run resolve credentials once into a single alias → data map).
/// </summary>
private static void ValidateCredentialAliases(
IReadOnlyList<NodeGraphNode> nodes,
IReadOnlyDictionary<string, NodeBlueprint> blueprints,
List<string> errors)
{
var seen = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var node in nodes)
{
var blueprint = blueprints[node.Id];
var declared = blueprint.Credentials.Select(c => c.Alias).ToHashSet(StringComparer.Ordinal);
foreach (var (alias, reference) in node.CredentialRefs)
{
if (!declared.Contains(alias))
errors.Add($"task '{node.Id}': node '{blueprint.Type}' has no credential alias '{alias}'");
if (seen.TryGetValue(alias, out var existing) && !string.Equals(existing, reference, StringComparison.Ordinal))
errors.Add($"credential alias '{alias}' is bound to two different credentials ('{existing}' and '{reference}')");
else
seen[alias] = reference;
}
foreach (var link in blueprint.Credentials.Where(c => c.Required))
{
if (!node.CredentialRefs.ContainsKey(link.Alias))
errors.Add($"task '{node.Id}': node '{blueprint.Type}' requires credential '{link.Alias}'");
}
}
}
private bool ValidateStep(TaskDefinition task, string label, List<string> errors)
{
if (string.IsNullOrWhiteSpace(task.Id))
{
errors.Add($"{label}.id is required");
return false;
}
if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{label}.id 'root' is reserved");
return false;
}
if (!StepIdRegex().IsMatch(task.Id) || task.Id.Length > 100)
{
errors.Add($"{label}.id '{task.Id}' is invalid");
return false;
}
if (task.Node == null)
{
errors.Add($"task '{task.Id}': a node workflow step must declare 'node'");
return false;
}
if (string.IsNullOrWhiteSpace(task.Node.Type))
{
errors.Add($"task '{task.Id}'.node.type is required");
return false;
}
if (task.Entry != null || !string.IsNullOrWhiteSpace(task.Language))
{
errors.Add($"task '{task.Id}': 'node' and 'entry'/'language' are mutually exclusive");
return false;
}
return true;
}
private NodeBlueprint? ResolveBlueprint(TaskDefinition task, List<string> errors)
{
var version = task.Node!.Version;
var blueprint = _catalog.Resolve(task.Node.Type!, version);
if (blueprint != null)
return blueprint;
errors.Add(version is null or 0
? $"task '{task.Id}': unknown node type '{task.Node.Type}'"
: $"task '{task.Id}': node type '{task.Node.Type}' has no version {version}");
return null;
}
private static NodeGraphNode BuildNode(TaskDefinition task, NodeBlueprint blueprint, List<string> errors)
{
var parameters = NodeParameterReader.ToJsonObject(task.Parameters);
ValidateParameters(task.Id!, blueprint, parameters, errors);
if (!string.IsNullOrWhiteSpace(task.RunMode) && !RunModes.Contains(task.RunMode))
{
errors.Add(
$"task '{task.Id}'.runMode '{task.RunMode}' is invalid: expected one of {string.Join(", ", RunModes)}");
}
return new NodeGraphNode
{
Id = task.Id!,
Blueprint = blueprint,
Parameters = parameters,
CredentialRefs = task.Credentials != null
? new Dictionary<string, string>(task.Credentials, StringComparer.Ordinal)
: new Dictionary<string, string>(),
RunMode = task.RunMode,
ContinueOnFail = task.ContinueOnFail ?? false,
Retry = task.Retry,
};
}
private static void ValidateParameters(
string taskId, NodeBlueprint blueprint, JsonObject parameters, List<string> errors)
{
foreach (var (name, value) in parameters)
{
if (!blueprint.HasParameter(name))
{
errors.Add($"task '{taskId}': node '{blueprint.Type}' has no parameter '{name}'");
continue;
}
var descriptor = blueprint.FindParameter(name);
if (descriptor != null)
ValidateParameterValue(taskId, descriptor, value, errors);
}
foreach (var descriptor in blueprint.Parameters.Where(p => p.Required))
{
if (!parameters.ContainsKey(descriptor.Name))
errors.Add($"task '{taskId}': node '{blueprint.Type}' requires parameter '{descriptor.Name}'");
}
}
private static void ValidateParameterValue(
string taskId, NodeParameter descriptor, JsonNode? value, List<string> errors)
{
// Expressions are resolved at run time, so their static type is unknown.
if (IsExpression(value))
return;
switch (descriptor.Type)
{
case NodeParameterType.Options:
case NodeParameterType.MultiOptions:
ValidateOptionValue(taskId, descriptor, value, errors);
break;
case NodeParameterType.Boolean when value is not null && !IsBoolean(value):
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a boolean");
break;
case NodeParameterType.Number when value is not null && !IsNumber(value):
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a number");
break;
}
}
private static bool IsExpression(JsonNode? value)
=> value is JsonValue jsonValue &&
jsonValue.TryGetValue<string>(out var text) &&
(text.StartsWith('=') || text.Contains("{{", StringComparison.Ordinal));
private static void ValidateOptionValue(
string taskId, NodeParameter descriptor, JsonNode? value, List<string> errors)
{
if (value is null || descriptor.Options is not { Count: > 0 })
return;
var allowed = descriptor.Options.Select(option => option.Value.GetRawText()).ToHashSet(StringComparer.Ordinal);
if (descriptor.Type == NodeParameterType.MultiOptions && value is JsonArray array)
{
foreach (var element in array)
{
if (element is not null && !allowed.Contains(element.ToJsonString()))
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {element.ToJsonString()}");
}
return;
}
if (!allowed.Contains(value.ToJsonString()))
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {value.ToJsonString()}");
}
private static bool IsBoolean(JsonNode value)
=> value is JsonValue jsonValue && jsonValue.TryGetValue<bool>(out _);
private static bool IsNumber(JsonNode value)
=> value is JsonValue jsonValue &&
(jsonValue.TryGetValue<decimal>(out _) || jsonValue.TryGetValue<double>(out _) || jsonValue.TryGetValue<long>(out _));
}