< Summary

Information
Class: Elsa.Bpmn.Interchange.Services.BpmnDocumentImportResult
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 285
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ImportResult()100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs

#LineLine coverage
 1using System.Text;
 2using Bpmn.Interchange;
 3using Bpmn.Model;
 4using Bpmn.Semantics;
 5using Elsa.Bpmn.Activities;
 6using Elsa.Bpmn.Hosting;
 7using Elsa.Bpmn.Interchange.Binding;
 8using Elsa.Bpmn.Interchange.Exceptions;
 9using Elsa.Extensions;
 10using Elsa.Workflows.Management;
 11using Elsa.Workflows.Management.Entities;
 12using Elsa.Workflows.Management.Models;
 13
 14namespace 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>
 67public sealed class BpmnInterchangeDocumentService(
 68    BpmnXmlReader reader,
 69    BpmnXmlWriter writer,
 70    BpmnWorkBinder binder,
 71    IWorkflowDefinitionImporter importer,
 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>
 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
 103    public static readonly IReadOnlyList<BpmnHostCapabilities> IndividualCapabilities =
 104    [
 105        BpmnHostCapabilities.SubtreeCancellation,
 106        BpmnHostCapabilities.ScopeSignalling,
 107        BpmnHostCapabilities.IterationScopes,
 108        BpmnHostCapabilities.ScopeVariables
 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>
 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    {
 133        var result = reader.Read(xml, new BpmnImportOptions { ProcessId = processId });
 134        var rootDefinition = ResolveRootDefinition(result.Definitions, processId);
 135
 136        EnsureCapabilitiesSatisfied(rootDefinition, result.Bindings);
 137
 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.
 142        process.IsRootScope = true;
 143
 144        var model = new WorkflowDefinitionModel
 145        {
 146            DefinitionId = definitionId ?? string.Empty,
 147            Name = string.IsNullOrWhiteSpace(name) ? rootDefinition.Name ?? rootDefinition.ProcessId : name,
 148            Root = process
 149        };
 150
 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.
 158        if (importResult.Succeeded)
 159        {
 160            var persisted = importResult.WorkflowDefinition;
 161            persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml;
 162            persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version;
 163            await store.SaveAsync(persisted, cancellationToken);
 164        }
 165
 166        return new BpmnDocumentImportResult(importResult, result.Analysis);
 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    {
 178        var result = reader.Read(xml, new BpmnImportOptions());
 179        var document = writer.Write(result, new BpmnExportOptions());
 180
 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    {
 197        if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOr
 198        {
 199            throw new BpmnExportUnavailableException(
 200                $"Workflow definition '{definition.DefinitionId}' does not currently carry BPMN source, so it cannot be 
 201                + "Either it was never imported from a BPMN document, or a later save replaced its custom properties who
 202                + $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else.");
 203        }
 204
 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.
 213            throw new BpmnExportUnavailableException(
 214                $"Workflow definition '{definition.DefinitionId}' carries BPMN source, but not the definition version it
 215                + "whether that source still matches this definition cannot be verified. It does not mean this definitio
 216                + "BPMN, and it does not mean the source is stale — there is simply no version recorded to compare again
 217                + "record a complete, exportable source.");
 218        }
 219
 220        if (sourceVersion != definition.Version)
 221        {
 222            throw new BpmnExportUnavailableException(
 223                $"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN (imported 
 224                + $"currently at version {definition.Version}). The BPMN source stored on it no longer corresponds to th
 225                + "would silently return a document that is not what this definition currently is.");
 226        }
 227
 228        return Export(xml);
 229    }
 230
 231    private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId)
 232    {
 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.
 237            return definitions.Processes.First(process => string.Equals(process.ProcessId, processId, StringComparison.O
 238        }
 239
 240        if (definitions.Processes.Count == 1)
 241            return definitions.Processes[0];
 242
 243        var declared = string.Join(", ", definitions.Processes.Select(process => process.ProcessId));
 244
 245        throw new BpmnInterchangeException(
 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
 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    {
 273        BpmnCapabilityRequirements.Analyze(definition).ThrowIfUnmet(available, definition.ProcessId);
 274
 275        var ownedNestedProcesses = bindings
 276            .OfType<BpmnWorkBinding.NestedProcess>()
 277            .Where(nested => string.Equals(nested.ProcessId, definition.ProcessId, StringComparison.Ordinal));
 278
 279        foreach (var nested in ownedNestedProcesses)
 280            EnsureCapabilitiesSatisfied(nested.Definition, bindings, available);
 281    }
 282}
 283
 284/// <summary>The outcome of <see cref="BpmnInterchangeDocumentService.ImportAsync"/>: the persisted definition, plus wha
 38285public sealed record BpmnDocumentImportResult(ImportWorkflowResult ImportResult, BpmnImportAnalysis Analysis);

Methods/Properties

get_ImportResult()