chore(workflow): remove BPMN source-format converter
BPMN is not a viable source format for Stella workflow definitions — the installed canonical JSONs are generated from IDeclarativeWorkflow<T> C# specs, not from BPMN, and BPMN cannot carry the typed metadata the canonical needs (startRequest.contractName, schema, businessReference, initializeStateExpression, function-call references). - Delete BpmnXmlSourceConverter + BpmnCanonicalMapping - Drop BpmnXmlSourceConverter DI registration; comment updated - Delete BpmnXmlSourceConverterTests; WorkflowSourceFormatRegistryTests no longer includes the BPMN converter in the ordering fixture - Abstractions comments updated to stop referencing bpmn-xml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ namespace StellaOps.Workflow.Abstractions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWorkflowSourceFormatConverter
|
public interface IWorkflowSourceFormatConverter
|
||||||
{
|
{
|
||||||
/// <summary>Stable, machine-readable identifier (e.g., "canonical-yaml", "canonical-bundle", "bpmn-xml").</summary>
|
/// <summary>Stable, machine-readable identifier (e.g., "canonical-json", "canonical-yaml", "canonical-bundle").</summary>
|
||||||
string FormatId { get; }
|
string FormatId { get; }
|
||||||
|
|
||||||
/// <summary>Human-friendly name shown in the UI format picker.</summary>
|
/// <summary>Human-friendly name shown in the UI format picker.</summary>
|
||||||
|
|||||||
@@ -1,564 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
|
|
||||||
using StellaOps.Workflow.Abstractions;
|
|
||||||
|
|
||||||
namespace StellaOps.Workflow.Engine.Services.Converters;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pure helpers that translate BPMN 2.0 / Camunda XML into canonical JSON objects (and back).
|
|
||||||
/// The functions return anonymous / dictionary structures that serialize directly to the
|
|
||||||
/// canonical <c>schemaVersion = "serdica.workflow.definition/v1"</c> shape.
|
|
||||||
/// </summary>
|
|
||||||
internal static class BpmnCanonicalMapping
|
|
||||||
{
|
|
||||||
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
|
|
||||||
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// BPMN → canonical
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public static object BuildCanonicalFromBpmnProcess(XElement process, List<WorkflowSourceFormatDiagnostic> diagnostics)
|
|
||||||
{
|
|
||||||
var workflowName = (string?)process.Attribute("id") ?? "ImportedWorkflow";
|
|
||||||
var displayName = (string?)process.Attribute("name") ?? workflowName;
|
|
||||||
|
|
||||||
// Index all flow nodes and sequence flows.
|
|
||||||
var flowNodes = process.Elements()
|
|
||||||
.Where(e => e.Name.Namespace == Bpmn && IsFlowNode(e.Name.LocalName))
|
|
||||||
.ToDictionary(e => ((string)e.Attribute("id")!), e => e);
|
|
||||||
|
|
||||||
var flows = process.Elements(Bpmn + "sequenceFlow")
|
|
||||||
.Select(e => new SequenceFlow(
|
|
||||||
(string)e.Attribute("id")!,
|
|
||||||
(string)e.Attribute("sourceRef")!,
|
|
||||||
(string)e.Attribute("targetRef")!,
|
|
||||||
(string?)e.Element(Bpmn + "conditionExpression")))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var outgoingBySource = flows
|
|
||||||
.GroupBy(f => f.SourceRef)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToArray());
|
|
||||||
|
|
||||||
var startEvents = flowNodes.Values.Where(n => n.Name.LocalName == "startEvent").ToArray();
|
|
||||||
if (startEvents.Length != 1)
|
|
||||||
{
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = "WFS040",
|
|
||||||
Message = $"Exactly one <bpmn:startEvent> is required, found {startEvents.Length}.",
|
|
||||||
});
|
|
||||||
return new { };
|
|
||||||
}
|
|
||||||
|
|
||||||
var startId = (string)startEvents[0].Attribute("id")!;
|
|
||||||
|
|
||||||
// Collect userTask declarations separately (they become <canonical>.tasks) — each userTask
|
|
||||||
// produces both a task declaration and an ActivateTask step placed in the initial sequence.
|
|
||||||
var tasksForCanonical = new List<object>();
|
|
||||||
foreach (var userTask in flowNodes.Values.Where(n => n.Name.LocalName == "userTask"))
|
|
||||||
{
|
|
||||||
var taskName = (string)userTask.Attribute("id")!;
|
|
||||||
var roles = ((string?)userTask.Attribute(Camunda + "candidateGroups") ?? string.Empty)
|
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
||||||
var route = (string?)userTask.Attribute(Camunda + "formKey") ?? "default/task";
|
|
||||||
|
|
||||||
tasksForCanonical.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["taskName"] = taskName,
|
|
||||||
["taskType"] = "HumanTask",
|
|
||||||
["routeExpression"] = StringExpr(route),
|
|
||||||
["payloadExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
|
|
||||||
["taskRoles"] = roles,
|
|
||||||
["onComplete"] = new Dictionary<string, object?> { ["steps"] = new object[0] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the initial sequence by walking from startEvent.
|
|
||||||
var initialSequence = BuildSequenceFromNode(startId, flowNodes, outgoingBySource, visited: new HashSet<string>(), diagnostics);
|
|
||||||
|
|
||||||
return new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["schemaVersion"] = "serdica.workflow.definition/v1",
|
|
||||||
["workflowName"] = workflowName,
|
|
||||||
["workflowVersion"] = "1.0.0",
|
|
||||||
["displayName"] = displayName,
|
|
||||||
["workflowRoles"] = Array.Empty<string>(),
|
|
||||||
["start"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["initializeStateExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
|
|
||||||
["initialSequence"] = initialSequence,
|
|
||||||
},
|
|
||||||
["tasks"] = tasksForCanonical,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> BuildSequenceFromNode(
|
|
||||||
string nodeId,
|
|
||||||
Dictionary<string, XElement> flowNodes,
|
|
||||||
Dictionary<string, SequenceFlow[]> outgoingBySource,
|
|
||||||
HashSet<string> visited,
|
|
||||||
List<WorkflowSourceFormatDiagnostic> diagnostics)
|
|
||||||
{
|
|
||||||
var steps = new List<object>();
|
|
||||||
var current = nodeId;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
if (!visited.Add(current))
|
|
||||||
{
|
|
||||||
// Loop detected — stop traversal. Canonical representation doesn't support BPMN-style backward edges directly.
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Warning,
|
|
||||||
Code = "WFS041",
|
|
||||||
Message = $"Sequence loop detected at '{current}'. Canonical conversion truncates at the loop back-edge.",
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!flowNodes.TryGetValue(current, out var node))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var localName = node.Name.LocalName;
|
|
||||||
|
|
||||||
// startEvent: no step emitted, just traverse the outgoing edge.
|
|
||||||
if (localName == "startEvent")
|
|
||||||
{
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
|
|
||||||
current = outs[0].TargetRef;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "endEvent")
|
|
||||||
{
|
|
||||||
steps.Add(new Dictionary<string, object?> { ["$type"] = "complete" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "userTask")
|
|
||||||
{
|
|
||||||
steps.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "activate-task",
|
|
||||||
["taskName"] = (string)node.Attribute("id")!,
|
|
||||||
});
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
|
|
||||||
current = outs[0].TargetRef;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "serviceTask")
|
|
||||||
{
|
|
||||||
var topic = (string?)node.Attribute(Camunda + "topic") ?? (string)node.Attribute("id")!;
|
|
||||||
steps.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "call-transport",
|
|
||||||
["stepName"] = (string)node.Attribute("id")!,
|
|
||||||
["invocation"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["address"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "legacy-rabbit",
|
|
||||||
["command"] = topic,
|
|
||||||
},
|
|
||||||
["payloadExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
|
|
||||||
current = outs[0].TargetRef;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "callActivity")
|
|
||||||
{
|
|
||||||
var targetWorkflow = (string?)node.Attribute("calledElement") ?? (string)node.Attribute("id")!;
|
|
||||||
steps.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "continue-with-workflow",
|
|
||||||
["stepName"] = (string)node.Attribute("id")!,
|
|
||||||
["invocation"] = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["workflowNameExpression"] = StringExpr(targetWorkflow),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
|
|
||||||
current = outs[0].TargetRef;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "exclusiveGateway")
|
|
||||||
{
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length != 2)
|
|
||||||
{
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = "WFS042",
|
|
||||||
Message = $"<bpmn:exclusiveGateway id='{current}'> must have exactly 2 outgoing flows in this MVP (has {(outs?.Length ?? 0)}).",
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var trueFlow = outs.FirstOrDefault(f => !string.IsNullOrWhiteSpace(f.ConditionExpression)) ?? outs[0];
|
|
||||||
var elseFlow = outs.First(f => f != trueFlow);
|
|
||||||
|
|
||||||
var conditionExpr = TranslateJuel(trueFlow.ConditionExpression, diagnostics)
|
|
||||||
?? StringExpr("true"); // fallback — shouldn't happen if JUEL parsing succeeds
|
|
||||||
|
|
||||||
steps.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "decision",
|
|
||||||
["decisionName"] = (string?)node.Attribute("name") ?? (string)node.Attribute("id")!,
|
|
||||||
["conditionExpression"] = conditionExpr,
|
|
||||||
["whenTrue"] = BuildSequenceFromNode(trueFlow.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics),
|
|
||||||
["whenElse"] = BuildSequenceFromNode(elseFlow.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics),
|
|
||||||
});
|
|
||||||
break; // branches close the sequence
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localName == "parallelGateway")
|
|
||||||
{
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length < 2)
|
|
||||||
{
|
|
||||||
// A parallel join gateway (single outgoing) — just continue through it.
|
|
||||||
if (outs?.Length == 1)
|
|
||||||
{
|
|
||||||
current = outs[0].TargetRef;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var branches = outs.Select(o => BuildSequenceFromNode(o.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics)).ToArray();
|
|
||||||
steps.Add(new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "fork",
|
|
||||||
["stepName"] = (string)node.Attribute("id")!,
|
|
||||||
["branches"] = branches,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: unknown recognized-but-unhandled node — skip, keep traversing.
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Warning,
|
|
||||||
Code = "WFS043",
|
|
||||||
Message = $"Skipped <bpmn:{localName} id='{current}'> (not mapped to a canonical step).",
|
|
||||||
});
|
|
||||||
if (!outgoingBySource.TryGetValue(current, out var skipOuts) || skipOuts.Length == 0) break;
|
|
||||||
current = skipOuts[0].TargetRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Dictionary<string, object?> { ["steps"] = steps };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsFlowNode(string localName) => localName is
|
|
||||||
"startEvent" or "endEvent" or "userTask" or "serviceTask" or
|
|
||||||
"exclusiveGateway" or "parallelGateway" or "callActivity" or "task";
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// JUEL → canonical expression (MVP subset)
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static readonly Regex JuelEqualsPattern = new(
|
|
||||||
"""\#\{\s*(?<path>[A-Za-z_][\w.\[\]]*)\s*==\s*(?:'(?<str>[^']*)'|"(?<str2>[^"]*)"|(?<num>-?\d+(?:\.\d+)?)|(?<bool>true|false))\s*\}""",
|
|
||||||
RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly Regex JuelPathPattern = new(
|
|
||||||
"""^\#\{\s*(?<path>[A-Za-z_][\w.\[\]]*)\s*\}$""",
|
|
||||||
RegexOptions.Compiled);
|
|
||||||
|
|
||||||
internal static Dictionary<string, object?>? TranslateJuel(string? juel, List<WorkflowSourceFormatDiagnostic> diagnostics)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(juel)) return null;
|
|
||||||
|
|
||||||
// `#{state.foo == 'bar'}`, `#{payload.x == 42}`, etc.
|
|
||||||
var eqMatch = JuelEqualsPattern.Match(juel);
|
|
||||||
if (eqMatch.Success)
|
|
||||||
{
|
|
||||||
var path = eqMatch.Groups["path"].Value;
|
|
||||||
object? literalExpr;
|
|
||||||
if (eqMatch.Groups["str"].Success) literalExpr = StringExpr(eqMatch.Groups["str"].Value);
|
|
||||||
else if (eqMatch.Groups["str2"].Success) literalExpr = StringExpr(eqMatch.Groups["str2"].Value);
|
|
||||||
else if (eqMatch.Groups["num"].Success && double.TryParse(eqMatch.Groups["num"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var n)) literalExpr = NumberExpr(n);
|
|
||||||
else literalExpr = new Dictionary<string, object?> { ["$type"] = "boolean", ["value"] = bool.Parse(eqMatch.Groups["bool"].Value) };
|
|
||||||
|
|
||||||
return new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["$type"] = "binary",
|
|
||||||
["operator"] = "eq",
|
|
||||||
["left"] = PathExpr(path),
|
|
||||||
["right"] = literalExpr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var pathMatch = JuelPathPattern.Match(juel);
|
|
||||||
if (pathMatch.Success)
|
|
||||||
{
|
|
||||||
return PathExpr(pathMatch.Groups["path"].Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = "WFS044",
|
|
||||||
Message = $"Unsupported JUEL expression: '{juel}'. MVP supports '#{{path.to.value}}' and '#{{path == literal}}'.",
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Canonical expression helpers
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
internal static Dictionary<string, object?> StringExpr(string value) => new()
|
|
||||||
{
|
|
||||||
["$type"] = "string",
|
|
||||||
["value"] = value,
|
|
||||||
};
|
|
||||||
|
|
||||||
internal static Dictionary<string, object?> PathExpr(string path) => new()
|
|
||||||
{
|
|
||||||
["$type"] = "path",
|
|
||||||
["path"] = path,
|
|
||||||
};
|
|
||||||
|
|
||||||
internal static Dictionary<string, object?> NumberExpr(double value) => new()
|
|
||||||
{
|
|
||||||
["$type"] = "number",
|
|
||||||
["value"] = value,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Canonical → BPMN (export)
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public static string BuildBpmnFromCanonical(JsonElement canonical)
|
|
||||||
{
|
|
||||||
var workflowName = canonical.TryGetProperty("workflowName", out var nameEl) ? (nameEl.GetString() ?? "Workflow") : "Workflow";
|
|
||||||
var displayName = canonical.TryGetProperty("displayName", out var displayEl) ? displayEl.GetString() : workflowName;
|
|
||||||
|
|
||||||
var processId = EscapeId(workflowName);
|
|
||||||
var emitter = new BpmnEmitter(processId);
|
|
||||||
|
|
||||||
emitter.AddStart();
|
|
||||||
|
|
||||||
if (canonical.TryGetProperty("start", out var startEl)
|
|
||||||
&& startEl.TryGetProperty("initialSequence", out var seqEl)
|
|
||||||
&& seqEl.TryGetProperty("steps", out var stepsEl))
|
|
||||||
{
|
|
||||||
emitter.AppendSequence(stepsEl, canonical);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitter.AddEnd();
|
|
||||||
|
|
||||||
var processXml = emitter.BuildProcessElement(displayName ?? workflowName);
|
|
||||||
|
|
||||||
var doc = new XDocument(
|
|
||||||
new XDeclaration("1.0", "UTF-8", "false"),
|
|
||||||
new XElement(Bpmn + "definitions",
|
|
||||||
new XAttribute(XNamespace.Xmlns + "bpmn", Bpmn.NamespaceName),
|
|
||||||
new XAttribute(XNamespace.Xmlns + "camunda", Camunda.NamespaceName),
|
|
||||||
new XAttribute("id", $"defs-{processId}"),
|
|
||||||
new XAttribute("targetNamespace", "http://bpmn.io/schema/bpmn"),
|
|
||||||
processXml));
|
|
||||||
|
|
||||||
// XDocument.Save(StringWriter) writes an encoding="utf-16" declaration because StringWriter
|
|
||||||
// is inherently UTF-16. Use a Utf8StringWriter so the declaration matches the byte output.
|
|
||||||
using var sw = new Utf8StringWriter();
|
|
||||||
doc.Save(sw);
|
|
||||||
return sw.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class BpmnEmitter(string processId)
|
|
||||||
{
|
|
||||||
private readonly List<XElement> nodes = [];
|
|
||||||
private readonly List<XElement> flows = [];
|
|
||||||
private string? lastNodeId;
|
|
||||||
private int counter;
|
|
||||||
|
|
||||||
public void AddStart()
|
|
||||||
{
|
|
||||||
var id = $"StartEvent_{processId}";
|
|
||||||
nodes.Add(new XElement(Bpmn + "startEvent", new XAttribute("id", id), new XAttribute("name", "Start")));
|
|
||||||
lastNodeId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddEnd()
|
|
||||||
{
|
|
||||||
var id = $"EndEvent_{processId}";
|
|
||||||
nodes.Add(new XElement(Bpmn + "endEvent", new XAttribute("id", id), new XAttribute("name", "End")));
|
|
||||||
if (lastNodeId is not null)
|
|
||||||
{
|
|
||||||
AddFlow(lastNodeId, id);
|
|
||||||
}
|
|
||||||
lastNodeId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AppendSequence(JsonElement steps, JsonElement canonical)
|
|
||||||
{
|
|
||||||
foreach (var step in steps.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (!step.TryGetProperty("$type", out var typeEl)) continue;
|
|
||||||
var stepType = typeEl.GetString();
|
|
||||||
|
|
||||||
switch (stepType)
|
|
||||||
{
|
|
||||||
case "activate-task":
|
|
||||||
{
|
|
||||||
var taskName = step.TryGetProperty("taskName", out var tn) ? tn.GetString() ?? "Task" : "Task";
|
|
||||||
var roles = LookupTaskRoles(canonical, taskName);
|
|
||||||
var nodeId = EscapeId(taskName);
|
|
||||||
var el = new XElement(Bpmn + "userTask",
|
|
||||||
new XAttribute("id", nodeId),
|
|
||||||
new XAttribute("name", taskName));
|
|
||||||
if (!string.IsNullOrWhiteSpace(roles)) el.Add(new XAttribute(Camunda + "candidateGroups", roles));
|
|
||||||
AppendNode(el);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "call-transport":
|
|
||||||
{
|
|
||||||
var name = step.TryGetProperty("stepName", out var sn) ? sn.GetString() ?? "ServiceTask" : "ServiceTask";
|
|
||||||
var topic = step.TryGetProperty("invocation", out var inv)
|
|
||||||
&& inv.TryGetProperty("address", out var addr)
|
|
||||||
&& addr.TryGetProperty("command", out var cmd) ? cmd.GetString() : null;
|
|
||||||
var el = new XElement(Bpmn + "serviceTask",
|
|
||||||
new XAttribute("id", EscapeId(name)),
|
|
||||||
new XAttribute("name", name),
|
|
||||||
new XAttribute(Camunda + "type", "external"));
|
|
||||||
if (!string.IsNullOrWhiteSpace(topic)) el.Add(new XAttribute(Camunda + "topic", topic));
|
|
||||||
AppendNode(el);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "continue-with-workflow":
|
|
||||||
case "sub-workflow":
|
|
||||||
{
|
|
||||||
var name = step.TryGetProperty("stepName", out var sn) ? sn.GetString() ?? "CallActivity" : "CallActivity";
|
|
||||||
var target = step.TryGetProperty("invocation", out var inv)
|
|
||||||
&& inv.TryGetProperty("workflowNameExpression", out var wne)
|
|
||||||
&& wne.TryGetProperty("value", out var v) ? v.GetString() : null;
|
|
||||||
var el = new XElement(Bpmn + "callActivity",
|
|
||||||
new XAttribute("id", EscapeId(name)),
|
|
||||||
new XAttribute("name", name));
|
|
||||||
if (!string.IsNullOrWhiteSpace(target)) el.Add(new XAttribute("calledElement", target));
|
|
||||||
AppendNode(el);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "complete":
|
|
||||||
{
|
|
||||||
// Sequence terminates here; AddEnd() will provide the endEvent.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "decision":
|
|
||||||
{
|
|
||||||
var gatewayId = $"Gateway_{++counter}";
|
|
||||||
nodes.Add(new XElement(Bpmn + "exclusiveGateway",
|
|
||||||
new XAttribute("id", gatewayId),
|
|
||||||
new XAttribute("name", step.TryGetProperty("decisionName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty)));
|
|
||||||
if (lastNodeId is not null) AddFlow(lastNodeId, gatewayId);
|
|
||||||
// Flatten: walk the true branch only; else-branch is represented as a sibling sequence flow without condition.
|
|
||||||
if (step.TryGetProperty("whenTrue", out var wt) && wt.TryGetProperty("steps", out var wtSteps))
|
|
||||||
{
|
|
||||||
var savedLast = lastNodeId;
|
|
||||||
lastNodeId = gatewayId;
|
|
||||||
AppendSequence(wtSteps, canonical);
|
|
||||||
lastNodeId = savedLast;
|
|
||||||
}
|
|
||||||
lastNodeId = gatewayId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "fork":
|
|
||||||
{
|
|
||||||
var gatewayId = $"Gateway_{++counter}_par";
|
|
||||||
nodes.Add(new XElement(Bpmn + "parallelGateway", new XAttribute("id", gatewayId)));
|
|
||||||
if (lastNodeId is not null) AddFlow(lastNodeId, gatewayId);
|
|
||||||
lastNodeId = gatewayId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// set-state, assign-business-reference, timer, external-signal, repeat — emit informational marker
|
|
||||||
var infoEl = new XElement(Bpmn + "serviceTask",
|
|
||||||
new XAttribute("id", $"Info_{++counter}"),
|
|
||||||
new XAttribute("name", stepType ?? "step"),
|
|
||||||
new XAttribute(Camunda + "type", "external"),
|
|
||||||
new XAttribute(Camunda + "topic", $"__serdica_{stepType}"));
|
|
||||||
AppendNode(infoEl);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AppendNode(XElement el)
|
|
||||||
{
|
|
||||||
nodes.Add(el);
|
|
||||||
var id = (string)el.Attribute("id")!;
|
|
||||||
if (lastNodeId is not null) AddFlow(lastNodeId, id);
|
|
||||||
lastNodeId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddFlow(string source, string target)
|
|
||||||
{
|
|
||||||
flows.Add(new XElement(Bpmn + "sequenceFlow",
|
|
||||||
new XAttribute("id", $"Flow_{++counter}"),
|
|
||||||
new XAttribute("sourceRef", source),
|
|
||||||
new XAttribute("targetRef", target)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public XElement BuildProcessElement(string displayName)
|
|
||||||
{
|
|
||||||
var process = new XElement(Bpmn + "process",
|
|
||||||
new XAttribute("id", processId),
|
|
||||||
new XAttribute("name", displayName),
|
|
||||||
new XAttribute("isExecutable", "true"));
|
|
||||||
foreach (var n in nodes) process.Add(n);
|
|
||||||
foreach (var f in flows) process.Add(f);
|
|
||||||
return process;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? LookupTaskRoles(JsonElement canonical, string taskName)
|
|
||||||
{
|
|
||||||
if (!canonical.TryGetProperty("tasks", out var tasks) || tasks.ValueKind != JsonValueKind.Array) return null;
|
|
||||||
foreach (var t in tasks.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (t.TryGetProperty("taskName", out var tn) && string.Equals(tn.GetString(), taskName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (t.TryGetProperty("taskRoles", out var rolesEl) && rolesEl.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
return string.Join(", ", rolesEl.EnumerateArray().Select(r => r.GetString()).Where(s => !string.IsNullOrEmpty(s)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeId(string input)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder(input.Length);
|
|
||||||
foreach (var ch in input)
|
|
||||||
{
|
|
||||||
sb.Append(char.IsLetterOrDigit(ch) || ch is '_' or '-' ? ch : '_');
|
|
||||||
}
|
|
||||||
return sb.Length == 0 || !char.IsLetter(sb[0]) ? "_" + sb : sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record SequenceFlow(string Id, string SourceRef, string TargetRef, string? ConditionExpression);
|
|
||||||
|
|
||||||
private sealed class Utf8StringWriter : StringWriter
|
|
||||||
{
|
|
||||||
public override Encoding Encoding => Encoding.UTF8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Xml;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
|
|
||||||
using StellaOps.Workflow.Abstractions;
|
|
||||||
|
|
||||||
namespace StellaOps.Workflow.Engine.Services.Converters;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bidirectional BPMN 2.0 XML converter (MVP scope). Import maps the subset of BPMN / Camunda
|
|
||||||
/// constructs used by the existing BPMNs to canonical step declarations. Export emits
|
|
||||||
/// a clean BPMN 2.0 document with Camunda extensions for round-trip use.
|
|
||||||
///
|
|
||||||
/// Supported on import:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>startEvent → anchors the initial sequence.</item>
|
|
||||||
/// <item>endEvent → <c>WorkflowCompleteStepDeclaration</c>.</item>
|
|
||||||
/// <item>userTask (camunda:candidateGroups → taskRoles, id → taskName) → human task + <c>WorkflowActivateTaskStepDeclaration</c>.</item>
|
|
||||||
/// <item>serviceTask (camunda:topic → LegacyRabbitAddress.Command) → <c>WorkflowTransportCallStepDeclaration</c>.</item>
|
|
||||||
/// <item>callActivity (calledElement → workflow name) → <c>WorkflowContinueWithWorkflowStepDeclaration</c>.</item>
|
|
||||||
/// <item>exclusiveGateway with 2 outgoing flows and a single conditionExpression → <c>WorkflowDecisionStepDeclaration</c>.</item>
|
|
||||||
/// <item>parallelGateway with N outgoing flows → <c>WorkflowForkStepDeclaration</c>.</item>
|
|
||||||
/// <item>sequenceFlow → graph edges used for topological traversal.</item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// Rejected (diagnostic + fail, no partial conversion):
|
|
||||||
/// boundary events, event sub-processes, compensation, multi-instance characteristics, complex
|
|
||||||
/// JUEL expressions beyond <c>#{state.X == 'Y'}</c> / <c>#{payload.X == 'Y'}</c> / <c>#{X}</c>,
|
|
||||||
/// signal-triggered start events, timer events inside gateways, embedded sub-processes.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BpmnXmlSourceConverter : IWorkflowSourceFormatConverter
|
|
||||||
{
|
|
||||||
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
|
|
||||||
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
|
|
||||||
|
|
||||||
public string FormatId => "bpmn-xml";
|
|
||||||
public string DisplayName => "BPMN XML";
|
|
||||||
public string ContentType => "application/xml";
|
|
||||||
public string FileExtension => ".bpmn";
|
|
||||||
public bool SupportsImport => true;
|
|
||||||
public bool SupportsExport => true;
|
|
||||||
public bool IsBinary => false;
|
|
||||||
|
|
||||||
public Task<WorkflowSourceImportResult> ImportAsync(byte[] content, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (content is null || content.Length == 0)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Fail("WFS030", "Content is empty."));
|
|
||||||
}
|
|
||||||
|
|
||||||
XDocument doc;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var ms = new MemoryStream(content, writable: false);
|
|
||||||
doc = XDocument.Load(ms, LoadOptions.SetLineInfo);
|
|
||||||
}
|
|
||||||
catch (XmlException ex)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Fail("WFS031", $"Malformed BPMN XML: {ex.Message}", $"line {ex.LineNumber}, col {ex.LinePosition}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var process = doc.Root?.Elements(Bpmn + "process").FirstOrDefault(p =>
|
|
||||||
string.Equals((string?)p.Attribute("isExecutable"), "true", StringComparison.OrdinalIgnoreCase))
|
|
||||||
?? doc.Root?.Elements(Bpmn + "process").FirstOrDefault();
|
|
||||||
|
|
||||||
if (process is null)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Fail("WFS032", "No <bpmn:process> element found."));
|
|
||||||
}
|
|
||||||
|
|
||||||
var diagnostics = new List<WorkflowSourceFormatDiagnostic>();
|
|
||||||
|
|
||||||
// Reject constructs we don't handle safely.
|
|
||||||
foreach (var unsupported in process.Descendants().Where(IsUnsupportedConstruct))
|
|
||||||
{
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = "WFS033",
|
|
||||||
Message = $"Unsupported BPMN construct <{unsupported.Name.LocalName}> (id='{(string?)unsupported.Attribute("id")}'). Supported subset: startEvent, endEvent, userTask, serviceTask, exclusiveGateway, parallelGateway, callActivity, sequenceFlow.",
|
|
||||||
Location = LineInfo(unsupported),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diagnostics.Any(d => d.Severity == WorkflowSourceFormatDiagnosticSeverity.Error))
|
|
||||||
{
|
|
||||||
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var canonical = BpmnCanonicalMapping.BuildCanonicalFromBpmnProcess(process, diagnostics);
|
|
||||||
if (diagnostics.Any(d => d.Severity == WorkflowSourceFormatDiagnosticSeverity.Error))
|
|
||||||
{
|
|
||||||
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(canonical, JsonOptions);
|
|
||||||
return Task.FromResult(new WorkflowSourceImportResult
|
|
||||||
{
|
|
||||||
Succeeded = true,
|
|
||||||
CanonicalDefinitionJson = json,
|
|
||||||
Diagnostics = diagnostics,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
diagnostics.Add(new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = "WFS034",
|
|
||||||
Message = $"BPMN → canonical conversion failed: {ex.Message}",
|
|
||||||
});
|
|
||||||
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<WorkflowSourceExportResult> ExportAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(record);
|
|
||||||
|
|
||||||
var canonical = JsonSerializer.Deserialize<JsonElement>(record.CanonicalDefinitionJson);
|
|
||||||
var xml = BpmnCanonicalMapping.BuildBpmnFromCanonical(canonical);
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(xml);
|
|
||||||
|
|
||||||
return Task.FromResult(new WorkflowSourceExportResult
|
|
||||||
{
|
|
||||||
Content = bytes,
|
|
||||||
ContentType = ContentType,
|
|
||||||
SuggestedFileName = $"{record.WorkflowName}-{record.WorkflowVersion}{FileExtension}",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsUnsupportedConstruct(XElement el)
|
|
||||||
{
|
|
||||||
if (el.Name.Namespace != Bpmn) return false;
|
|
||||||
var name = el.Name.LocalName;
|
|
||||||
return name switch
|
|
||||||
{
|
|
||||||
"boundaryEvent" => true,
|
|
||||||
"intermediateCatchEvent" => true,
|
|
||||||
"intermediateThrowEvent" => true,
|
|
||||||
"compensateEventDefinition" => true,
|
|
||||||
"subProcess" when (string?)el.Attribute("triggeredByEvent") == "true" => true,
|
|
||||||
"multiInstanceLoopCharacteristics" => true,
|
|
||||||
"standardLoopCharacteristics" => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string LineInfo(XElement el)
|
|
||||||
{
|
|
||||||
if (el is IXmlLineInfo info && info.HasLineInfo())
|
|
||||||
{
|
|
||||||
return $"line {info.LineNumber}, col {info.LinePosition}";
|
|
||||||
}
|
|
||||||
return $"<{el.Name.LocalName} id='{(string?)el.Attribute("id")}'>";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static WorkflowSourceImportResult Fail(string code, string message, string? location = null) => new()
|
|
||||||
{
|
|
||||||
Succeeded = false,
|
|
||||||
Diagnostics =
|
|
||||||
[
|
|
||||||
new WorkflowSourceFormatDiagnostic
|
|
||||||
{
|
|
||||||
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
|
|
||||||
Code = code,
|
|
||||||
Message = message,
|
|
||||||
Location = location,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -96,11 +96,10 @@ public static class WorkflowCoreServiceCollectionExtensions
|
|||||||
services.TryAddScoped<IWorkflowDefinitionStore, NullWorkflowDefinitionStore>();
|
services.TryAddScoped<IWorkflowDefinitionStore, NullWorkflowDefinitionStore>();
|
||||||
services.AddScoped<WorkflowDefinitionDeploymentService>();
|
services.AddScoped<WorkflowDefinitionDeploymentService>();
|
||||||
|
|
||||||
// Source-format converters (canonical JSON + YAML + bundle ZIP + BPMN XML) and their registry.
|
// Source-format converters (canonical JSON + YAML + bundle ZIP) and their registry.
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalJsonSourceConverter>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalJsonSourceConverter>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalYamlSourceConverter>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalYamlSourceConverter>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalBundleSourceConverter>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalBundleSourceConverter>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, BpmnXmlSourceConverter>());
|
|
||||||
services.TryAddSingleton<IWorkflowSourceFormatRegistry, WorkflowSourceFormatRegistry>();
|
services.TryAddSingleton<IWorkflowSourceFormatRegistry, WorkflowSourceFormatRegistry>();
|
||||||
|
|
||||||
// Signal pump telemetry
|
// Signal pump telemetry
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
using StellaOps.Workflow.Abstractions;
|
|
||||||
using StellaOps.Workflow.Engine.Services.Converters;
|
|
||||||
|
|
||||||
using FluentAssertions;
|
|
||||||
|
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace StellaOps.Workflow.Engine.Tests.Services.Converters;
|
|
||||||
|
|
||||||
[TestFixture]
|
|
||||||
public class BpmnXmlSourceConverterTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public async Task ImportAsync_StartEventUserTaskServiceTaskEndEvent_ProducesCanonical()
|
|
||||||
{
|
|
||||||
var bpmn = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
|
|
||||||
<bpmn:process id="TestWorkflow" name="Test Workflow" isExecutable="true">
|
|
||||||
<bpmn:startEvent id="Start_1"/>
|
|
||||||
<bpmn:userTask id="ReviewTask" camunda:candidateGroups="UR_AGENT"/>
|
|
||||||
<bpmn:serviceTask id="NotifyTask" camunda:topic="pas_notify"/>
|
|
||||||
<bpmn:endEvent id="End_1"/>
|
|
||||||
<bpmn:sequenceFlow id="Flow1" sourceRef="Start_1" targetRef="ReviewTask"/>
|
|
||||||
<bpmn:sequenceFlow id="Flow2" sourceRef="ReviewTask" targetRef="NotifyTask"/>
|
|
||||||
<bpmn:sequenceFlow id="Flow3" sourceRef="NotifyTask" targetRef="End_1"/>
|
|
||||||
</bpmn:process>
|
|
||||||
</bpmn:definitions>
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
|
|
||||||
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
|
|
||||||
|
|
||||||
result.Succeeded.Should().BeTrue();
|
|
||||||
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
|
|
||||||
doc.RootElement.GetProperty("schemaVersion").GetString().Should().Be("serdica.workflow.definition/v1");
|
|
||||||
doc.RootElement.GetProperty("workflowName").GetString().Should().Be("TestWorkflow");
|
|
||||||
|
|
||||||
var steps = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps");
|
|
||||||
steps.EnumerateArray().Select(s => s.GetProperty("$type").GetString())
|
|
||||||
.Should().Equal("activate-task", "call-transport", "complete");
|
|
||||||
|
|
||||||
var tasks = doc.RootElement.GetProperty("tasks");
|
|
||||||
tasks.GetArrayLength().Should().Be(1);
|
|
||||||
tasks[0].GetProperty("taskName").GetString().Should().Be("ReviewTask");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task ImportAsync_BoundaryEvent_ReturnsDiagnostic_WFS033_WithId()
|
|
||||||
{
|
|
||||||
var bpmn = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
|
|
||||||
<bpmn:process id="Wf" isExecutable="true">
|
|
||||||
<bpmn:startEvent id="Start_1"/>
|
|
||||||
<bpmn:userTask id="T1"/>
|
|
||||||
<bpmn:boundaryEvent id="Boundary_1" attachedToRef="T1"/>
|
|
||||||
<bpmn:endEvent id="End_1"/>
|
|
||||||
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="T1"/>
|
|
||||||
<bpmn:sequenceFlow id="F2" sourceRef="T1" targetRef="End_1"/>
|
|
||||||
</bpmn:process>
|
|
||||||
</bpmn:definitions>
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
|
|
||||||
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
|
|
||||||
|
|
||||||
result.Succeeded.Should().BeFalse();
|
|
||||||
var diag = result.Diagnostics.Single(d => d.Code == "WFS033");
|
|
||||||
diag.Message.Should().Contain("boundaryEvent");
|
|
||||||
diag.Message.Should().Contain("Boundary_1");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task ImportAsync_ExclusiveGatewayWithJuelCondition_MapsToDecision()
|
|
||||||
{
|
|
||||||
var bpmn = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
|
|
||||||
<bpmn:process id="Wf" isExecutable="true">
|
|
||||||
<bpmn:startEvent id="Start_1"/>
|
|
||||||
<bpmn:exclusiveGateway id="Decide" name="Decide"/>
|
|
||||||
<bpmn:endEvent id="YesEnd"/>
|
|
||||||
<bpmn:endEvent id="NoEnd"/>
|
|
||||||
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="Decide"/>
|
|
||||||
<bpmn:sequenceFlow id="F2" sourceRef="Decide" targetRef="YesEnd">
|
|
||||||
<bpmn:conditionExpression>#{state.approved == 'yes'}</bpmn:conditionExpression>
|
|
||||||
</bpmn:sequenceFlow>
|
|
||||||
<bpmn:sequenceFlow id="F3" sourceRef="Decide" targetRef="NoEnd"/>
|
|
||||||
</bpmn:process>
|
|
||||||
</bpmn:definitions>
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
|
|
||||||
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
|
|
||||||
|
|
||||||
result.Succeeded.Should().BeTrue();
|
|
||||||
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
|
|
||||||
var firstStep = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
|
|
||||||
firstStep.GetProperty("$type").GetString().Should().Be("decision");
|
|
||||||
var condition = firstStep.GetProperty("conditionExpression");
|
|
||||||
condition.GetProperty("$type").GetString().Should().Be("binary");
|
|
||||||
condition.GetProperty("operator").GetString().Should().Be("eq");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task ImportAsync_ParallelGateway_MapsToFork()
|
|
||||||
{
|
|
||||||
var bpmn = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
|
|
||||||
<bpmn:process id="Wf" isExecutable="true">
|
|
||||||
<bpmn:startEvent id="Start_1"/>
|
|
||||||
<bpmn:parallelGateway id="Fork1"/>
|
|
||||||
<bpmn:endEvent id="E1"/>
|
|
||||||
<bpmn:endEvent id="E2"/>
|
|
||||||
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="Fork1"/>
|
|
||||||
<bpmn:sequenceFlow id="F2" sourceRef="Fork1" targetRef="E1"/>
|
|
||||||
<bpmn:sequenceFlow id="F3" sourceRef="Fork1" targetRef="E2"/>
|
|
||||||
</bpmn:process>
|
|
||||||
</bpmn:definitions>
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
|
|
||||||
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
|
|
||||||
|
|
||||||
result.Succeeded.Should().BeTrue();
|
|
||||||
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
|
|
||||||
var firstStep = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
|
|
||||||
firstStep.GetProperty("$type").GetString().Should().Be("fork");
|
|
||||||
firstStep.GetProperty("branches").GetArrayLength().Should().Be(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task ImportAsync_CallActivity_MapsToContinueWithWorkflow()
|
|
||||||
{
|
|
||||||
var bpmn = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
|
|
||||||
<bpmn:process id="Wf" isExecutable="true">
|
|
||||||
<bpmn:startEvent id="Start_1"/>
|
|
||||||
<bpmn:callActivity id="InvokeOther" calledElement="OtherWorkflow"/>
|
|
||||||
<bpmn:endEvent id="End_1"/>
|
|
||||||
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="InvokeOther"/>
|
|
||||||
<bpmn:sequenceFlow id="F2" sourceRef="InvokeOther" targetRef="End_1"/>
|
|
||||||
</bpmn:process>
|
|
||||||
</bpmn:definitions>
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
|
|
||||||
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
|
|
||||||
|
|
||||||
result.Succeeded.Should().BeTrue();
|
|
||||||
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
|
|
||||||
var step = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
|
|
||||||
step.GetProperty("$type").GetString().Should().Be("continue-with-workflow");
|
|
||||||
step.GetProperty("invocation").GetProperty("workflowNameExpression").GetProperty("value").GetString().Should().Be("OtherWorkflow");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task ExportAsync_ProducesUtf8BpmnWithCamundaExtensions()
|
|
||||||
{
|
|
||||||
var canonical = """
|
|
||||||
{"schemaVersion":"serdica.workflow.definition/v1","workflowName":"Wf","workflowVersion":"1.0.0","start":{"initialSequence":{"steps":[{"$type":"activate-task","taskName":"T1"},{"$type":"complete"}]}},"tasks":[{"taskName":"T1","taskType":"HumanTask","taskRoles":["UR_AGENT"]}]}
|
|
||||||
""";
|
|
||||||
var converter = new BpmnXmlSourceConverter();
|
|
||||||
var record = new WorkflowDefinitionRecord
|
|
||||||
{
|
|
||||||
WorkflowName = "Wf",
|
|
||||||
WorkflowVersion = "1.0.0",
|
|
||||||
BaseVersion = "1.0.0",
|
|
||||||
BuildIteration = 0,
|
|
||||||
ContentHash = "h",
|
|
||||||
CanonicalDefinitionJson = canonical,
|
|
||||||
IsActive = true,
|
|
||||||
CreatedOnUtc = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await converter.ExportAsync(record);
|
|
||||||
var xml = Encoding.UTF8.GetString(result.Content);
|
|
||||||
|
|
||||||
xml.Should().MatchRegex(@"^<\?xml version=""1\.0"" encoding=""[Uu][Tt][Ff]-8""");
|
|
||||||
xml.Should().Contain("<bpmn:process");
|
|
||||||
xml.Should().Contain("xmlns:camunda=\"http://camunda.org/schema/1.0/bpmn\"");
|
|
||||||
xml.Should().Contain("<bpmn:userTask");
|
|
||||||
xml.Should().Contain("camunda:candidateGroups=\"UR_AGENT\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,12 +58,11 @@ public class WorkflowSourceFormatRegistryTests
|
|||||||
var registry = new WorkflowSourceFormatRegistry([
|
var registry = new WorkflowSourceFormatRegistry([
|
||||||
new CanonicalYamlSourceConverter(),
|
new CanonicalYamlSourceConverter(),
|
||||||
new CanonicalJsonSourceConverter(),
|
new CanonicalJsonSourceConverter(),
|
||||||
new BpmnXmlSourceConverter(),
|
|
||||||
new CanonicalBundleSourceConverter(),
|
new CanonicalBundleSourceConverter(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var ids = registry.GetAll().Select(c => c.FormatId).ToArray();
|
var ids = registry.GetAll().Select(c => c.FormatId).ToArray();
|
||||||
// Order by DisplayName ordinal-case-insensitive: "BPMN XML", "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
|
// Order by DisplayName ordinal-case-insensitive: "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
|
||||||
ids.Should().Equal("bpmn-xml", "canonical-bundle", "canonical-json", "canonical-yaml");
|
ids.Should().Equal("canonical-bundle", "canonical-json", "canonical-yaml");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user