w4c-workflows-api/Services/WorkflowValidator.cs
2026-09-12 01:02:46 +03:00

249 lines
9.7 KiB
C#

using System.Text.RegularExpressions;
using w4c_workflows.Models;
namespace w4c_workflows.Services;
/// <summary>
/// Validates a parsed <see cref="WorkflowDefinition"/>. Pure and side-effect
/// free: it returns a list of human-readable errors (empty = valid). The
/// compiler calls this before building the EF entities.
/// </summary>
public partial class WorkflowValidator
{
private static readonly string[] Modes = { WorkflowMode.Function, WorkflowMode.Durable, WorkflowMode.Handler };
private static readonly string[] TriggerTypes = { TriggerType.Cron, TriggerType.Interval, TriggerType.Webhook, TriggerType.Event, TriggerType.Queue };
[GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)]
private static partial Regex IdentifierRegex();
private readonly LanguageRegistry _languages;
public WorkflowValidator(LanguageRegistry languages)
{
_languages = languages;
}
public IReadOnlyList<string> Validate(WorkflowDefinition def)
{
var errors = new List<string>();
ValidateHeader(def, errors);
ValidateTrigger(def, errors);
var tasks = def.Tasks ?? new List<TaskDefinition>();
ValidateTasks(def, tasks, errors);
ValidateFlow(tasks, errors);
return errors;
}
/// <summary>
/// Validates the header + trigger of a node-mode workflow. Script-specific
/// requirements (header <c>language</c>/<c>entry</c>, per-task <c>entry</c>)
/// do not apply here: the node graph compiler validates the steps, their
/// parameters and the graph shape instead. <c>mode</c> is optional and
/// defaults to <c>function</c>.
/// </summary>
public IReadOnlyList<string> ValidateNodeWorkflow(WorkflowDefinition def)
{
var errors = new List<string>();
ValidateName(def, errors);
if (!string.IsNullOrWhiteSpace(def.Mode) && !Modes.Contains(def.Mode))
errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
ValidateTrigger(def, errors);
return errors;
}
private void ValidateHeader(WorkflowDefinition def, List<string> errors)
{
ValidateName(def, errors);
if (string.IsNullOrWhiteSpace(def.Mode))
errors.Add("mode is required (function | durable | handler)");
else if (!Modes.Contains(def.Mode))
errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
if (string.IsNullOrWhiteSpace(def.Language))
errors.Add("language is required (shell | javascript | typescript | csharp | python)");
else if (!_languages.IsKnown(def.Language))
errors.Add($"language '{def.Language}' is not supported");
if (def.Entry == null || string.IsNullOrWhiteSpace(def.Entry.File))
errors.Add("entry.file is required");
}
private static void ValidateName(WorkflowDefinition def, List<string> errors)
{
if (string.IsNullOrWhiteSpace(def.Name))
{
errors.Add("name is required");
}
else if (def.Name.Length > 200 || !IdentifierRegex().IsMatch(def.Name))
{
errors.Add($"name '{def.Name}' is invalid: use 1-200 chars of letters, digits, '.', '_', '-'");
}
}
private void ValidateTrigger(WorkflowDefinition def, List<string> errors)
{
var trigger = def.Trigger;
if (trigger == null)
return; // no auto-trigger → manual (event) only; valid
if (string.IsNullOrWhiteSpace(trigger.Type) || !TriggerTypes.Contains(trigger.Type))
{
errors.Add($"trigger.type is invalid: expected one of {string.Join(", ", TriggerTypes)}");
return;
}
if (def.Mode == WorkflowMode.Handler)
{
// Handler mode is a stream subscription — its trigger must be a queue.
if (trigger.Type != TriggerType.Queue)
errors.Add("handler mode requires trigger.type = 'queue'");
return;
}
switch (trigger.Type)
{
case TriggerType.Cron:
if (string.IsNullOrWhiteSpace(trigger.Cron) || trigger.Cron.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length != 5)
errors.Add("trigger.cron is required and must be a 5-field cron expression");
break;
case TriggerType.Interval:
if (!DurationParser.TryParse(trigger.Interval, out _))
errors.Add("trigger.interval is required and must be a duration like '30m' or '2h'");
break;
case TriggerType.Webhook:
if (string.IsNullOrWhiteSpace(trigger.WebhookPath) || !trigger.WebhookPath.StartsWith('/'))
errors.Add("trigger.webhookPath is required and must start with '/'");
break;
case TriggerType.Queue:
errors.Add("queue trigger is only valid for handler mode");
break;
}
}
private void ValidateTasks(WorkflowDefinition def, IReadOnlyList<TaskDefinition> tasks, List<string> errors)
{
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < tasks.Count; i++)
{
var task = tasks[i];
var label = $"tasks[{i}]";
if (string.IsNullOrWhiteSpace(task.Id))
{
errors.Add($"{label}.id is required");
continue;
}
if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{label}.id 'root' is reserved");
continue;
}
if (!IdentifierRegex().IsMatch(task.Id) || task.Id.Length > 100)
{
errors.Add($"{label}.id '{task.Id}' is invalid");
continue;
}
if (!ids.Add(task.Id))
{
errors.Add($"{label}.id '{task.Id}' is duplicated");
continue;
}
if (task.Language != null && !_languages.IsKnown(task.Language))
errors.Add($"{label}.language '{task.Language}' is not supported");
if (task.Mode != null && !Modes.Contains(task.Mode))
errors.Add($"{label}.mode '{task.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
if (task.Entry == null || string.IsNullOrWhiteSpace(task.Entry.File))
errors.Add($"{label}.entry.file is required");
}
// Reference resolution: parent/next/onError must point at an existing
// task (or "root" for parent).
var knownIds = ids;
foreach (var task in tasks)
{
if (task.Id == null) continue;
var label = $"task '{task.Id}'";
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
if (!string.Equals(parent, "root", StringComparison.Ordinal) && !knownIds.Contains(parent))
errors.Add($"{label}.parent '{parent}' does not reference a known task or 'root'");
if (!string.IsNullOrEmpty(task.Next))
{
if (!knownIds.Contains(task.Next))
errors.Add($"{label}.next '{task.Next}' does not reference a known task");
else if (string.Equals(task.Next, task.Id, StringComparison.Ordinal))
errors.Add($"{label}.next must not reference itself");
}
if (!string.IsNullOrEmpty(task.OnError))
{
if (!knownIds.Contains(task.OnError))
errors.Add($"{label}.onError '{task.OnError}' does not reference a known task");
else if (string.Equals(task.OnError, task.Id, StringComparison.Ordinal))
errors.Add($"{label}.onError must not reference itself");
}
}
}
private void ValidateFlow(IReadOnlyList<TaskDefinition> tasks, List<string> errors)
{
if (tasks.Count == 0)
return; // root-only workflow
// Skip flow validation if the reference/structural checks already failed —
// the graph would be incomplete and produce misleading errors.
if (errors.Count > 0)
return;
var graph = WorkflowGraph.Compute(new WorkflowDefinition { Tasks = tasks.ToList() });
// Linear chain: each task is the `next` target of at most one other task.
foreach (var (taskId, incoming) in graph.IncomingNext)
{
if (incoming > 1)
errors.Add($"task '{taskId}' has {incoming} incoming 'next' edges; the success chain must be linear");
}
if (graph.HeadId == null)
{
errors.Add("no chain head: expected a top-level task (parent: root) that starts the success chain");
return;
}
// Cycle detection on the success chain.
var seen = new HashSet<string>(StringComparer.Ordinal);
var cursor = graph.HeadId;
while (cursor != null)
{
if (!seen.Add(cursor))
{
errors.Add($"cycle detected in the success chain at task '{cursor}'");
return;
}
graph.ById.TryGetValue(cursor, out var task);
cursor = string.IsNullOrEmpty(task?.Next) ? null : task.Next;
}
// Every task must be either on the success chain or compensation-only.
var chained = new HashSet<string>(graph.Chain, StringComparer.Ordinal);
foreach (var task in tasks)
{
if (task.Id == null) continue;
if (chained.Contains(task.Id) || graph.CompensationOnly.Contains(task.Id)) continue;
errors.Add($"task '{task.Id}' is unreachable: it is not on the success chain and not an onError target");
}
}
}