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>
|
||||
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; }
|
||||
|
||||
/// <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.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, CanonicalYamlSourceConverter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalBundleSourceConverter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, BpmnXmlSourceConverter>());
|
||||
services.TryAddSingleton<IWorkflowSourceFormatRegistry, WorkflowSourceFormatRegistry>();
|
||||
|
||||
// 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([
|
||||
new CanonicalYamlSourceConverter(),
|
||||
new CanonicalJsonSourceConverter(),
|
||||
new BpmnXmlSourceConverter(),
|
||||
new CanonicalBundleSourceConverter(),
|
||||
]);
|
||||
|
||||
var ids = registry.GetAll().Select(c => c.FormatId).ToArray();
|
||||
// Order by DisplayName ordinal-case-insensitive: "BPMN XML", "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
|
||||
ids.Should().Equal("bpmn-xml", "canonical-bundle", "canonical-json", "canonical-yaml");
|
||||
// Order by DisplayName ordinal-case-insensitive: "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
|
||||
ids.Should().Equal("canonical-bundle", "canonical-json", "canonical-yaml");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user