| | | 1 | | using System.Text; |
| | | 2 | | using Bpmn.Interchange; |
| | | 3 | | using Bpmn.Model; |
| | | 4 | | using Bpmn.Semantics; |
| | | 5 | | using Elsa.Bpmn.Activities; |
| | | 6 | | using Elsa.Bpmn.Hosting; |
| | | 7 | | using Elsa.Bpmn.Interchange.Binding; |
| | | 8 | | using Elsa.Bpmn.Interchange.Exceptions; |
| | | 9 | | using Elsa.Extensions; |
| | | 10 | | using Elsa.Workflows.Management; |
| | | 11 | | using Elsa.Workflows.Management.Entities; |
| | | 12 | | using Elsa.Workflows.Management.Models; |
| | | 13 | | |
| | | 14 | | namespace Elsa.Bpmn.Interchange.Services; |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// The one code path the Analyze, Import and Export endpoints all sit on top of. |
| | | 18 | | /// </summary> |
| | | 19 | | /// <remarks> |
| | | 20 | | /// <para> |
| | | 21 | | /// <b>Analyze and Import never disagree.</b> Both call <see cref="BpmnXmlReader"/>, which — per its own contract — |
| | | 22 | | /// runs <see cref="BpmnXmlReader.Analyze"/> and <see cref="BpmnXmlReader.Read"/> through the same code, so a preview |
| | | 23 | | /// can never say something the import that follows contradicts. |
| | | 24 | | /// </para> |
| | | 25 | | /// <para> |
| | | 26 | | /// <b>Export carries the whole library-owned document, not a reduced view of it.</b> The only thing <see cref="ImportAs |
| | | 27 | | /// persists beyond the bound Elsa activity graph is the original BPMN XML text, under <see cref="SourceXmlCustomPropert |
| | | 28 | | /// on the workflow definition's custom properties. <see cref="Export(string)"/> re-reads that same text through the sam |
| | | 29 | | /// reader and hands the resulting <see cref="BpmnImportResult"/> — retained extension elements, foreign attributes, |
| | | 30 | | /// unrecognized children and BPMN DI layout included — straight to <see cref="BpmnXmlWriter"/>. Nothing is |
| | | 31 | | /// reconstructed from the Elsa activity tree, which only carries a bindingRef-to-activityId map and would have to |
| | | 32 | | /// throw away everything the reader retained to get there. |
| | | 33 | | /// </para> |
| | | 34 | | /// <para> |
| | | 35 | | /// <b>Export is only ever the document as imported, and that is a real limitation, not a detail.</b> Until BPMN-aware |
| | | 36 | | /// editing exists (a Studio concern, out of scope for this program), nothing re-serializes edits made through Elsa's |
| | | 37 | | /// own designer back into BPMN — <see cref="Export(WorkflowDefinition)"/> always returns the source text |
| | | 38 | | /// <see cref="ImportAsync"/> stored, never a document reflecting what the definition currently is. That is why it |
| | | 39 | | /// refuses outright, rather than returning something, when it cannot prove that text still matches the definition: |
| | | 40 | | /// see <see cref="SourceVersionCustomPropertyKey"/>. |
| | | 41 | | /// </para> |
| | | 42 | | /// <para> |
| | | 43 | | /// <b>The stored source can go missing or stale after import, and each is refused with its own diagnosis.</b> BPMN |
| | | 44 | | /// source travels on <see cref="SourceXmlCustomPropertyKey"/>, one entry in the same <c>CustomProperties</c> |
| | | 45 | | /// dictionary a workflow edit can — and, through Elsa's own workflow-definition save endpoint, does — replace |
| | | 46 | | /// wholesale. A save that does not carry that key forward removes it as a side effect of editing something else |
| | | 47 | | /// entirely, which is indistinguishable, once it has happened, from a definition that was never imported from BPMN |
| | | 48 | | /// in the first place; <see cref="Export(WorkflowDefinition)"/> says so honestly rather than asserting the document |
| | | 49 | | /// was "never imported", which would be true in one case and false in the other. Separately, a save that DOES carry |
| | | 50 | | /// the key forward can still leave the definition materially changed — the graph, name, or anything else about it — |
| | | 51 | | /// while the stored BPMN text still describes the pre-edit document. <see cref="SourceVersionCustomPropertyKey"/> |
| | | 52 | | /// records the definition's own version at the moment of import for exactly this: if the current version no longer |
| | | 53 | | /// matches, the stored source is stale, and exporting it would silently hand back a document that is not what the |
| | | 54 | | /// caller has, which is worse than refusing. |
| | | 55 | | /// </para> |
| | | 56 | | /// <para> |
| | | 57 | | /// <b>Capability refusal happens here, at import, not at <c>BpmnGraph.Build</c>.</b> <see cref="BpmnCapabilityRequireme |
| | | 58 | | /// is the static half of the same check <c>BpmnGraph.Build</c> performs at first execution: it needs only the |
| | | 59 | | /// definition, not bound work or a host snapshot. Running it at import means an unrunnable diagram is rejected before |
| | | 60 | | /// it is ever persisted, naming the missing capability and the elements that need it, rather than surfacing as an |
| | | 61 | | /// incident the first time the workflow runs. <c>BpmnGraph.Build</c> itself is deliberately not called here: building |
| | | 62 | | /// the graph also validates structural invariants that belong to the runtime module's own execution path |
| | | 63 | | /// (<c>Elsa.Bpmn.Hosting.BpmnScopeHost</c>), and re-running that here would duplicate it outside the module that owns |
| | | 64 | | /// it. |
| | | 65 | | /// </para> |
| | | 66 | | /// </remarks> |
| | 36 | 67 | | public sealed class BpmnInterchangeDocumentService( |
| | 36 | 68 | | BpmnXmlReader reader, |
| | 36 | 69 | | BpmnXmlWriter writer, |
| | 36 | 70 | | BpmnWorkBinder binder, |
| | 36 | 71 | | IWorkflowDefinitionImporter importer, |
| | 36 | 72 | | IWorkflowDefinitionStore store) |
| | | 73 | | { |
| | | 74 | | /// <summary>The workflow definition custom property the original BPMN XML is carried under, for <see cref="Export(W |
| | | 75 | | public const string SourceXmlCustomPropertyKey = "Bpmn:SourceXml"; |
| | | 76 | | |
| | | 77 | | /// <summary> |
| | | 78 | | /// The workflow definition custom property <see cref="ImportAsync"/> records the definition's own version number |
| | | 79 | | /// under, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>. |
| | | 80 | | /// </summary> |
| | | 81 | | /// <remarks> |
| | | 82 | | /// <see cref="Export(WorkflowDefinition)"/> compares this against the definition's current version to tell a |
| | | 83 | | /// still-current source from a stale one. The version number is what <see cref="WorkflowDefinition"/> itself |
| | | 84 | | /// already exposes for "has this definition changed", so this reuses it rather than inventing a second notion of |
| | | 85 | | /// change (a content hash, a timestamp) that could disagree with the versioning the rest of the system already |
| | | 86 | | /// uses. |
| | | 87 | | /// </remarks> |
| | | 88 | | public const string SourceVersionCustomPropertyKey = "Bpmn:SourceVersion"; |
| | | 89 | | |
| | | 90 | | /// <summary> |
| | | 91 | | /// The host capabilities this deployment's BPMN runtime declares. |
| | | 92 | | /// </summary> |
| | | 93 | | /// <remarks> |
| | | 94 | | /// Reads <see cref="BpmnRuntimeCapabilities.Declared"/> straight from <c>Elsa.Bpmn</c> — the runtime module, |
| | | 95 | | /// which already publishes that constant for exactly this reason — rather than restating the flag set here. |
| | | 96 | | /// A restatement could silently drift from what <c>Elsa.Bpmn.Hosting.BpmnScopeHost</c> actually honours at |
| | | 97 | | /// execution time, which would mean import-time refusal and runtime behaviour disagreeing: the worse direction |
| | | 98 | | /// for that drift to go is a document accepted here and only failing the first time it runs. |
| | | 99 | | /// </remarks> |
| | 1 | 100 | | public static readonly BpmnHostCapabilities DeclaredHostCapabilities = BpmnRuntimeCapabilities.Declared; |
| | | 101 | | |
| | | 102 | | /// <summary>Every individually named capability, for turning a <see cref="BpmnHostCapabilities"/> flag set into rea |
| | 1 | 103 | | public static readonly IReadOnlyList<BpmnHostCapabilities> IndividualCapabilities = |
| | 1 | 104 | | [ |
| | 1 | 105 | | BpmnHostCapabilities.SubtreeCancellation, |
| | 1 | 106 | | BpmnHostCapabilities.ScopeSignalling, |
| | 1 | 107 | | BpmnHostCapabilities.IterationScopes, |
| | 1 | 108 | | BpmnHostCapabilities.ScopeVariables |
| | 1 | 109 | | ]; |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// Reports what a document contains and what a read would cost, without persisting anything. |
| | | 113 | | /// </summary> |
| | | 114 | | /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception> |
| | 2 | 115 | | public BpmnImportAnalysis Analyze(string xml) => reader.Analyze(xml, new BpmnImportOptions()); |
| | | 116 | | |
| | | 117 | | /// <summary> |
| | | 118 | | /// Reads a document, refuses it if the host cannot run what it declares, and binds it into the <see cref="BpmnProce |
| | | 119 | | /// scope a workflow definition's root becomes. |
| | | 120 | | /// </summary> |
| | | 121 | | /// <param name="xml">The BPMN 2.0 XML to import.</param> |
| | | 122 | | /// <param name="definitionId">The workflow definition to update, or <c>null</c>/empty to create a new one.</param> |
| | | 123 | | /// <param name="name">The workflow definition's display name, defaulting to the process's own BPMN name or id.</par |
| | | 124 | | /// <param name="processId"> |
| | | 125 | | /// The process to bind when the document declares more than one; not needed when it declares exactly one. |
| | | 126 | | /// </param> |
| | | 127 | | /// <param name="cancellationToken">The cancellation token.</param> |
| | | 128 | | /// <exception cref="BpmnInterchangeException">The document cannot be read, or declares more than one process and <p |
| | | 129 | | /// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare. |
| | | 130 | | /// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</except |
| | | 131 | | public async Task<BpmnDocumentImportResult> ImportAsync(string xml, string? definitionId, string? name, string? proc |
| | | 132 | | { |
| | 13 | 133 | | var result = reader.Read(xml, new BpmnImportOptions { ProcessId = processId }); |
| | 13 | 134 | | var rootDefinition = ResolveRootDefinition(result.Definitions, processId); |
| | | 135 | | |
| | 13 | 136 | | EnsureCapabilitiesSatisfied(rootDefinition, result.Bindings); |
| | | 137 | | |
| | 13 | 138 | | var process = binder.Bind(rootDefinition, result.Bindings); |
| | | 139 | | |
| | | 140 | | // Whoever composes a bound scope into a workflow says explicitly that it is an entry point; an import is |
| | | 141 | | // exactly that, for the process the caller asked to import. |
| | 12 | 142 | | process.IsRootScope = true; |
| | | 143 | | |
| | 12 | 144 | | var model = new WorkflowDefinitionModel |
| | 12 | 145 | | { |
| | 12 | 146 | | DefinitionId = definitionId ?? string.Empty, |
| | 12 | 147 | | Name = string.IsNullOrWhiteSpace(name) ? rootDefinition.Name ?? rootDefinition.ProcessId : name, |
| | 12 | 148 | | Root = process |
| | 12 | 149 | | }; |
| | | 150 | | |
| | 12 | 151 | | var importResult = await importer.ImportAsync(new SaveWorkflowDefinitionRequest { Model = model, Publish = false |
| | | 152 | | |
| | | 153 | | // The definition's final Version is only known once the importer/publisher has assigned and persisted it — |
| | | 154 | | // see SourceVersionCustomPropertyKey's remarks for why that value, specifically, is what staleness is judged |
| | | 155 | | // against. Neither custom property is written until it is known, so both land on this single, explicit save: |
| | | 156 | | // if it fails or is cancelled, the definition carries neither key, which Export(WorkflowDefinition) reports |
| | | 157 | | // honestly as "never imported" rather than as a partial import that cannot be diagnosed. |
| | 12 | 158 | | if (importResult.Succeeded) |
| | | 159 | | { |
| | 12 | 160 | | var persisted = importResult.WorkflowDefinition; |
| | 12 | 161 | | persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml; |
| | 12 | 162 | | persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version; |
| | 12 | 163 | | await store.SaveAsync(persisted, cancellationToken); |
| | | 164 | | } |
| | | 165 | | |
| | 11 | 166 | | return new BpmnDocumentImportResult(importResult, result.Analysis); |
| | 11 | 167 | | } |
| | | 168 | | |
| | | 169 | | /// <summary> |
| | | 170 | | /// Writes the document a workflow definition was imported from back out as BPMN 2.0 XML, through the same |
| | | 171 | | /// reader-then-writer path <see cref="ImportAsync"/> used, so retained extension elements, foreign attributes and |
| | | 172 | | /// BPMN DI layout come back exactly as the reader retained them. |
| | | 173 | | /// </summary> |
| | | 174 | | /// <param name="xml">The BPMN 2.0 XML carried on the workflow definition's <see cref="SourceXmlCustomPropertyKey"/> |
| | | 175 | | /// <exception cref="BpmnInterchangeException">The document cannot be read at all.</exception> |
| | | 176 | | public byte[] Export(string xml) |
| | | 177 | | { |
| | 3 | 178 | | var result = reader.Read(xml, new BpmnImportOptions()); |
| | 3 | 179 | | var document = writer.Write(result, new BpmnExportOptions()); |
| | | 180 | | |
| | 3 | 181 | | return Encoding.UTF8.GetBytes(document); |
| | | 182 | | } |
| | | 183 | | |
| | | 184 | | /// <summary> |
| | | 185 | | /// Resolves the BPMN source a workflow definition was imported from and writes it back out, refusing rather than |
| | | 186 | | /// guessing when that source is missing or no longer trustworthy. See this type's remarks for what "missing" and |
| | | 187 | | /// "stale" mean and why each gets its own message. |
| | | 188 | | /// </summary> |
| | | 189 | | /// <param name="definition">The workflow definition to export, as read from the store.</param> |
| | | 190 | | /// <exception cref="BpmnExportUnavailableException"> |
| | | 191 | | /// The definition does not currently carry BPMN source, or it does but the definition has changed since the |
| | | 192 | | /// source was recorded. |
| | | 193 | | /// </exception> |
| | | 194 | | /// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception> |
| | | 195 | | public byte[] Export(WorkflowDefinition definition) |
| | | 196 | | { |
| | 7 | 197 | | if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOr |
| | | 198 | | { |
| | 3 | 199 | | throw new BpmnExportUnavailableException( |
| | 3 | 200 | | $"Workflow definition '{definition.DefinitionId}' does not currently carry BPMN source, so it cannot be |
| | 3 | 201 | | + "Either it was never imported from a BPMN document, or a later save replaced its custom properties who |
| | 3 | 202 | | + $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else."); |
| | | 203 | | } |
| | | 204 | | |
| | 4 | 205 | | if (!definition.CustomProperties.TryGetValue<int>(SourceVersionCustomPropertyKey, out var sourceVersion)) |
| | | 206 | | { |
| | | 207 | | // Distinct from both other refusals: this is not "never imported" (the source text is right there) and |
| | | 208 | | // not "stale" (there is no version to compare against yet). ImportAsync writes SourceXmlCustomPropertyKey |
| | | 209 | | // and SourceVersionCustomPropertyKey together, in the single save described in its remarks, so this path |
| | | 210 | | // is not reachable through import itself; it is kept as a defence against the same combination arising |
| | | 211 | | // some other way — e.g. custom properties edited or migrated directly, outside ImportAsync — where |
| | | 212 | | // "whether the source still matches" cannot be verified without a version to compare against. |
| | 1 | 213 | | throw new BpmnExportUnavailableException( |
| | 1 | 214 | | $"Workflow definition '{definition.DefinitionId}' carries BPMN source, but not the definition version it |
| | 1 | 215 | | + "whether that source still matches this definition cannot be verified. It does not mean this definitio |
| | 1 | 216 | | + "BPMN, and it does not mean the source is stale — there is simply no version recorded to compare again |
| | 1 | 217 | | + "record a complete, exportable source."); |
| | | 218 | | } |
| | | 219 | | |
| | 3 | 220 | | if (sourceVersion != definition.Version) |
| | | 221 | | { |
| | 1 | 222 | | throw new BpmnExportUnavailableException( |
| | 1 | 223 | | $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN (imported |
| | 1 | 224 | | + $"currently at version {definition.Version}). The BPMN source stored on it no longer corresponds to th |
| | 1 | 225 | | + "would silently return a document that is not what this definition currently is."); |
| | | 226 | | } |
| | | 227 | | |
| | 2 | 228 | | return Export(xml); |
| | | 229 | | } |
| | | 230 | | |
| | | 231 | | private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId) |
| | | 232 | | { |
| | 13 | 233 | | if (!string.IsNullOrWhiteSpace(processId)) |
| | | 234 | | { |
| | | 235 | | // BpmnImportOptions.ProcessId already made the read fail fast if the document does not declare this |
| | | 236 | | // process, so finding it here can only fail if that guarantee itself changes. |
| | 0 | 237 | | return definitions.Processes.First(process => string.Equals(process.ProcessId, processId, StringComparison.O |
| | | 238 | | } |
| | | 239 | | |
| | 13 | 240 | | if (definitions.Processes.Count == 1) |
| | 13 | 241 | | return definitions.Processes[0]; |
| | | 242 | | |
| | 0 | 243 | | var declared = string.Join(", ", definitions.Processes.Select(process => process.ProcessId)); |
| | | 244 | | |
| | 0 | 245 | | throw new BpmnInterchangeException( |
| | 0 | 246 | | $"The document declares {definitions.Processes.Count} processes ({declared}); specify which one to import.") |
| | | 247 | | } |
| | | 248 | | |
| | | 249 | | /// <summary> |
| | | 250 | | /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process |
| | | 251 | | /// nested inside it needs a host capability <see cref="DeclaredHostCapabilities"/> does not cover. |
| | | 252 | | /// </summary> |
| | | 253 | | private static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bin |
| | 13 | 254 | | EnsureCapabilitiesSatisfied(definition, bindings, DeclaredHostCapabilities); |
| | | 255 | | |
| | | 256 | | /// <summary> |
| | | 257 | | /// Refuses the definition, naming the missing capability and the offending element ids, when it or any process |
| | | 258 | | /// nested inside it needs a host capability <paramref name="available"/> does not cover. |
| | | 259 | | /// </summary> |
| | | 260 | | /// <remarks> |
| | | 261 | | /// Takes the available capability set as a parameter, rather than reading <see cref="DeclaredHostCapabilities"/> |
| | | 262 | | /// directly, so a test can prove the refusal — and the walk into nested processes below — without a document that |
| | | 263 | | /// needs a capability this deployment's runtime host has never declared, which the current library version |
| | | 264 | | /// cannot produce because <see cref="DeclaredHostCapabilities"/> already covers every capability it defines. |
| | | 265 | | /// <para> |
| | | 266 | | /// A nested process is a separate scope with its own graph at execution time, so — mirroring that — it is |
| | | 267 | | /// analyzed separately here too, walking every <see cref="BpmnWorkBinding.NestedProcess"/> binding whose owner is |
| | | 268 | | /// the definition just checked. |
| | | 269 | | /// </para> |
| | | 270 | | /// </remarks> |
| | | 271 | | internal static void EnsureCapabilitiesSatisfied(BpmnProcessDefinition definition, IReadOnlyList<BpmnWorkBinding> bi |
| | | 272 | | { |
| | 17 | 273 | | BpmnCapabilityRequirements.Analyze(definition).ThrowIfUnmet(available, definition.ProcessId); |
| | | 274 | | |
| | 15 | 275 | | var ownedNestedProcesses = bindings |
| | 15 | 276 | | .OfType<BpmnWorkBinding.NestedProcess>() |
| | 16 | 277 | | .Where(nested => string.Equals(nested.ProcessId, definition.ProcessId, StringComparison.Ordinal)); |
| | | 278 | | |
| | 31 | 279 | | foreach (var nested in ownedNestedProcesses) |
| | 1 | 280 | | EnsureCapabilitiesSatisfied(nested.Definition, bindings, available); |
| | 14 | 281 | | } |
| | | 282 | | } |
| | | 283 | | |
| | | 284 | | /// <summary>The outcome of <see cref="BpmnInterchangeDocumentService.ImportAsync"/>: the persisted definition, plus wha |
| | | 285 | | public sealed record BpmnDocumentImportResult(ImportWorkflowResult ImportResult, BpmnImportAnalysis Analysis); |