feat(bpmn): GET and PUT the BPMN document as library-format JSON (#8060)

* feat(bpmn): add document GET/PUT endpoints for BPMN JSON round-tripping (W21)

Studio holds a BPMN process as the library's own JSON payload, not as .bpmn
XML, so binding a task (W11) or moving a shape (W14) had no write path back
to the server: bpmn/import only takes XML, and saving the activity JSON
through the ordinary definition save leaves Bpmn:SourceXml stale, so export
then refuses with 422.

Adds GET/PUT bpmn/definitions/{definitionId}/document, sharing the same
analyze-then-commit path Import runs (BpmnInterchangeDocumentService.ReadDocument/
ImportDocumentAsync), so a document read by GET and written back unchanged by
PUT can never disagree with what Import or Export would do with the same
bytes. Records which process a document was imported from
(Bpmn:SourceProcessId) so a multi-process document keeps importing the same
process on every edit. Binds and writes the document body with plain
System.Text.Json defaults, not FastEndpoints' configured serializer, since
Bpmn.Model's JSON payload format (integer enums, explicit property names)
disagrees with Elsa's own API-wide JSON conventions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): deduplicate document endpoint response and exception handling

Import and the document Put endpoint shared a byte-identical Response type and
an identical exception-to-status-code cascade; Export and the document Get
endpoint shared an identical cascade too. Extract both into a shared
BpmnImportResponse and two small cascade helpers under Endpoints/Bpmn, used by
all four endpoints with unchanged status codes and error messages. Also add
endpoint coverage for PUT against a definition imported before
Bpmn:SourceProcessId existed, for both the single- and two-process cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): add optimistic concurrency to the document endpoints and restore Import.Response

GET bpmn/definitions/{id}/document now returns a strong ETag derived from the
definition's Version, its Bpmn:SourceVersion custom property, and a new
Bpmn:DocumentRevision counter (needed because an unpublished draft is edited
in place, so Version/SourceVersion alone do not always change on save). PUT
requires a matching If-Match: missing returns 428, stale returns 412 (checked
before any import work), and a successful PUT returns a new, different ETag.

Also restores the public Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import.Response
type the prior dedupe commit renamed to BpmnImportResponse without cause,
which would have broken source and binary consumers of the preview package;
Import and the document Put endpoint now both use Import.Response again.

Also replaces a foreach-with-continue over a JSON array with .Where(...) in
the document Put test helper, per CodeQL, with no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): derive the document ETag from the stored document and graph

The document ETag combined the definition's Version, Bpmn:SourceVersion and a
Bpmn:DocumentRevision counter only the document PUT incremented. An unpublished
draft is edited in place under the same version, and both the workflow
definition importer and the designer's save replace CustomProperties
wholesale, so a POST bpmn/import into the same definition or a designer save
of the draft left all three unchanged. A client holding the pre-write ETag
could then PUT and silently overwrite that write.

The ETag is now a SHA-256 hash over the stored definition's id, version, BPMN
source (Bpmn:SourceXml) and activity graph (StringData), computed in one place
for GET and for PUT's precondition check. Every writer changes at least one
input: a document PUT or an import rewrites the source, a designer save
rewrites the graph, a new draft version changes the id and version. Identical
stored content now yields an identical ETag, so a PUT of unchanged content
returns the ETag GET did.

Bpmn:DocumentRevision and the PUT's pre-import read of it are gone. The ETag a
PUT returns is computed from the definition its import persisted. If-Match
must equal the current strong ETag exactly (weak tags and lists never match,
412), and the wildcard "*" is refused along with a missing header (428),
since it matches whatever is stored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-09-11 21:16:32 -07:00 committed by GitHub
parent 952dfa05ff
commit e28e35635b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1266 additions and 86 deletions

View file

@ -82,16 +82,63 @@ An exported `.bpmn` is self-contained: all binding configuration, including inpu
## REST Endpoints
`Elsa.Bpmn.Interchange` registers three routes, all under `bpmn/`:
`Elsa.Bpmn.Interchange` registers five routes, all under `bpmn/`:
| Method & route | Permission | What it does |
| --- | --- | --- |
| `POST bpmn/analyze` | `read:workflow-definitions` | Uploads a single `.bpmn` file (multipart) and returns the Info/Degraded/Dropped findings a read would produce, without persisting anything. |
| `POST bpmn/import` | `write:workflow-definitions` | Uploads a single `.bpmn` file and persists it as a new or updated workflow definition (as a draft; it is not published). Optional form fields: `DefinitionId` (update an existing definition instead of creating one), `Name`, `ProcessId` (required when the document declares more than one process). |
| `GET bpmn/definitions/{definitionId}/export` | `read:workflow-definitions` | Writes the workflow definition's BPMN source back out as `.bpmn` XML. Optional `VersionOptions` query parameter (`Latest`, `Published`, or a specific version), defaulting to `Latest`. |
| `GET bpmn/definitions/{definitionId}/document` | `read:workflow-definitions` | Reads the workflow definition's stored BPMN source with the `Bpmn.Model`/`Bpmn.Interchange` reader and returns the whole `bpmnDefinitions` document as the library's own JSON (payload format `1.0.0`), rather than as `.bpmn` XML. Same refusals as `Export` when the definition was never imported from BPMN or its stored source is stale. Carries an `ETag` response header for the returned revision — see below. |
| `PUT bpmn/definitions/{definitionId}/document` | `write:workflow-definitions` | Accepts a `bpmnDefinitions` JSON document — the shape `GET` on the same route returns — writes it back out as `.bpmn` XML, and runs it through the same path `Import` runs: analyze, capability check, bind, persist as a new draft, refresh the stored source. Never edits a published version in place, exactly like `Import`. Returns the same `Id`/`DefinitionId`/`Version`/`Analysis` shape `Import` returns, plus the new `ETag`. Requires an `If-Match` request header — see below. |
Both `Analyze` and `Import` require exactly one uploaded file; zero or more than one returns `400 Bad Request`.
### Optimistic concurrency on the document endpoints
`GET` and `PUT` on `bpmn/definitions/{definitionId}/document` exchange a strong `ETag`, so a client that reads the
document, and someone else writes the definition before it writes its own edit back, cannot silently overwrite that
intervening write. The `ETag` is a SHA-256 hash of what the definition stores: its BPMN document (the `Bpmn:SourceXml`
custom property), its activity graph, its version and its id. Any write that changes the stored document or the
graph therefore invalidates it — another document `PUT`, a `POST bpmn/import` with the same `DefinitionId`, a save of
the draft from the workflow designer — including when an unpublished draft is saved in place under the same version,
which is the common case. The value is opaque and must be sent back verbatim.
Because it is derived from content, identical stored content has an identical `ETag`: a `PUT` that writes back
exactly what is stored returns the same `ETag` `GET` did. The first `PUT` after a `.bpmn` upload replaces the
uploaded bytes with the writer's own rendering of the same document, so the stored document, and with it the `ETag`,
changes once even when nothing was edited. A save that changes only the definition's other properties — its name,
description or variables, say — leaves the document and the graph untouched and does not change the `ETag`; a
document `PUT`, like `Import`, resets those properties regardless.
`PUT` requires an `If-Match` request header carrying the `ETag` a prior `GET` (or `PUT`) returned:
- **Missing, or the wildcard `*`**`428 Precondition Required`. Neither says which revision the caller is
replacing (`*` matches whatever is stored), so the endpoint refuses rather than overwrite blindly, before doing any
import work or persisting anything.
- **Anything other than exactly the definition's current `ETag`**`412 Precondition Failed`, checked before any
import work and before anything is persisted. The comparison is exact: a weak (`W/`) tag or a list of tags never
matches. The definition was written since the caller last read it; `GET` the document again and reapply the edit.
- **Exactly the current `ETag`** — the request proceeds exactly as before, and the response carries the `ETag` of
the draft as this `PUT` stored it.
### The document endpoints and the JSON payload format
The `document` GET/PUT pair exists for Studio (W21, part of #7909): Studio holds a BPMN process as the library's own
JSON payload, not as `.bpmn` XML, and editing it — binding a task (W11), moving a shape (W14) — has to write that
JSON back through the same path `Import` uses, or the stored BPMN source drifts out of sync with the definition (see
`BpmnInterchangeDocumentService.SourceVersionCustomPropertyKey`) and `Export` starts refusing with `422`.
The request and response bodies on both routes are the `bpmnDefinitions` document exactly as `Bpmn.Model` serializes
it: property names as `Bpmn.Model`'s own `[JsonPropertyName]` attributes declare them, and any enum as its underlying
integer — **not** Elsa's own API-wide JSON conventions (which add a string-enum converter these bodies must not go
through). A client reading or writing this JSON should use a plain `System.Text.Json` serializer with default
options, not whatever conventions the rest of the Elsa API uses.
A document that declares more than one `<process>` is re-imported against the same `processId` it was originally
imported with — recorded on the workflow definition the first time it is imported, whether from `Import` or from a
`document` `PUT`, so an edit to a multi-process document does not have to name the process again on every save.
### Capability refusal at import
A BPMN document can declare behaviour (e.g. certain multi-instance or event-subprocess shapes) that needs a host
@ -105,9 +152,13 @@ still be refused by `Import` on capability grounds.
`Export` does not reconstruct a `.bpmn` document from the Elsa activity graph a definition runs — that would discard
everything the reader retained on import (foreign extension elements, foreign attributes, unrecognized children, BPMN
DI layout). Instead, it returns exactly the document `Import` stored at import time. This has a real consequence
until BPMN-aware editing exists in Studio: **edits made through Elsa's own designer, after import, are not reflected
in what `Export` returns.**
DI layout). Instead, it returns exactly the document the most recent successful import stored. This has a real
consequence for one kind of edit: **an edit made through Elsa's own designer, without going back through BPMN, is not
reflected in what `Export` returns** — the graph moves on, but the stored source still describes the document as it
stood before that edit.
An edit made through the `document` `PUT` endpoint above is different: it re-imports, so the stored source and the
graph move together, and **`Export` reflects the edit immediately afterward.**
`Export` also refuses outright, with `422 Unprocessable Entity`, rather than silently returning a stale or wrong
document, in two situations:

View file

@ -0,0 +1,30 @@
using Bpmn.Semantics;
using Elsa.Bpmn.Interchange.Services;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// Formats a <see cref="BpmnCapabilityException"/> into the one error message every endpoint that imports a BPMN
/// document — <c>Import</c> and the document <c>Put</c> endpoint — reports it as.
/// </summary>
/// <remarks>
/// <see cref="BpmnCapabilityException"/> carries <see cref="BpmnCapabilityException.DrivingElementIds"/> as a single
/// flat list, already unioned across every missing capability — it does not say which element drove which capability
/// (unlike <c>BpmnCapabilityRequirements.DrivingElementIds</c>, which is per-capability, but that type is gone by the
/// time an endpoint's catch clause sees the exception). Attributing the full, unioned list to each capability
/// individually would put elements next to a capability they may have nothing to do with, so this reports the
/// missing capabilities together with the combined element list once, rather than repeating it.
/// </remarks>
internal static class BpmnCapabilityErrorFormatter
{
/// <summary>The message an endpoint reports for <paramref name="exception"/>.</summary>
public static string Format(BpmnCapabilityException exception)
{
var missingCapabilities = string.Join(", ", BpmnInterchangeDocumentService.IndividualCapabilities.Where(capability => exception.Missing.HasFlag(capability)));
var elementIds = string.Join(", ", exception.DrivingElementIds);
return
$"This deployment does not declare the following BPMN host capabilities the document requires: {missingCapabilities}. "
+ $"Offending elements (combined across all missing capabilities above, not attributable to any one of them): {elementIds}.";
}
}

View file

@ -0,0 +1,25 @@
using System.Text.Json;
using Bpmn.Model;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// The <see cref="JsonSerializerOptions"/> the <c>bpmn/definitions/{definitionId}/document</c> endpoints read and
/// write a <see cref="BpmnDefinitions"/> document with.
/// </summary>
/// <remarks>
/// <c>Bpmn.Model</c> declares every serialized property name explicitly through <c>[JsonPropertyName]</c> — see
/// <see cref="BpmnPayloadFormat"/> — and carries no <c>[JsonConverter]</c> of its own, so its wire shape is
/// plain <see cref="System.Text.Json.JsonSerializer"/> defaults: camelCase names exactly as declared, and any enum
/// as its underlying integer. Elsa's own API-wide serializer (<c>Elsa.Workflows.Serialization.Serializers.ApiSerializer</c>,
/// reached through FastEndpoints' configured <c>IApiSerializer</c>) adds a <c>JsonStringEnumConverter</c> and several
/// other converters of its own, none of which this library's schema expects. Letting the document body go through
/// that serializer, the way an ordinary FastEndpoints request or response DTO does, would silently write a shape
/// <c>Bpmn.Model</c>'s own reader does not agree is the payload format it published — hence the document endpoints
/// binding and writing the body explicitly with this instance instead of a request/response DTO.
/// </remarks>
internal static class BpmnDocumentJsonOptions
{
/// <summary>Plain <see cref="System.Text.Json"/> defaults, scoped to the document endpoints only.</summary>
public static readonly JsonSerializerOptions Value = new();
}

View file

@ -0,0 +1,39 @@
using Bpmn.Interchange;
using Elsa.Bpmn.Interchange.Exceptions;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// The exception-to-status-code mapping shared by every endpoint that reads a workflow definition's stored BPMN
/// source back out through <c>BpmnInterchangeDocumentService</c> — <c>Export</c> and the document <c>Get</c>
/// endpoint — refusing the same "missing" and "stale" cases the same way.
/// </summary>
internal static class BpmnExportExceptionCascade
{
/// <summary>
/// Runs <paramref name="sendResponse"/>, reporting the shared error response through <paramref name="addError"/>
/// and <paramref name="sendErrorsAsync"/> for whichever exception it throws.
/// </summary>
public static async Task RunAsync(
Func<Task> sendResponse,
Action<string> addError,
Func<int, CancellationToken, Task> sendErrorsAsync,
CancellationToken cancellationToken)
{
try
{
await sendResponse();
}
catch (BpmnExportUnavailableException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
}
catch (BpmnInterchangeException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
}
}
}

View file

@ -0,0 +1,64 @@
using Bpmn.Interchange;
using Bpmn.Semantics;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Bpmn.Interchange.Services;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// The exception-to-status-code mapping shared by every endpoint that imports a BPMN document through
/// <c>BpmnInterchangeDocumentService</c> — <c>Import</c> and the document <c>Put</c> endpoint — plus the identical
/// handling both give a result whose <c>ImportResult</c> did not succeed.
/// </summary>
internal static class BpmnImportExceptionCascade
{
/// <summary>
/// Runs <paramref name="import"/>, reporting the shared error response for whichever exception it throws, or for
/// an unsuccessful <see cref="BpmnDocumentImportResult.ImportResult"/>, through <paramref name="addError"/> and
/// <paramref name="sendErrorsAsync"/>. Returns <c>null</c> in every case that already sent a response; the caller
/// sends its own success response otherwise.
/// </summary>
public static async Task<BpmnDocumentImportResult?> RunAsync(
Func<Task<BpmnDocumentImportResult>> import,
Action<string> addError,
Func<int, CancellationToken, Task> sendErrorsAsync,
CancellationToken cancellationToken)
{
BpmnDocumentImportResult result;
try
{
result = await import();
}
catch (BpmnInterchangeException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return null;
}
catch (BpmnBindingException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
return null;
}
catch (BpmnCapabilityException exception)
{
addError(BpmnCapabilityErrorFormatter.Format(exception));
await sendErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
return null;
}
if (!result.ImportResult.Succeeded)
{
foreach (var validationError in result.ImportResult.ValidationErrors)
addError(validationError.Message);
await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return null;
}
return result;
}
}

View file

@ -0,0 +1,69 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Extensions;
using Elsa.Workflows.Management.Entities;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document;
/// <summary>
/// The strong <c>ETag</c> the document <c>Get</c> and <c>Put</c> endpoints exchange for optimistic concurrency: a
/// SHA-256 hash over the stored workflow definition's id, its version, its BPMN source
/// (<see cref="BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey"/>) and its serialized activity graph
/// (<see cref="WorkflowDefinition.StringData"/>).
/// </summary>
/// <remarks>
/// <para>
/// Content-derived rather than a stored counter, because no counter survives every writer. An unpublished draft is
/// edited in place — same row, same version — and both <see cref="Elsa.Workflows.Management.IWorkflowDefinitionImporter"/>
/// (behind <c>Import</c> and the document <c>Put</c>) and the workflow-definition save endpoint the designer uses
/// replace <c>CustomProperties</c> wholesale with the caller's, so a counter kept there is wiped, or carried forward
/// unchanged, by exactly the writes it would have to record. What those writes do change is the content: a document
/// <c>Put</c> or an <c>Import</c> rewrites the stored source, a designer save rewrites the graph, and a save that drops
/// the source hashes differently from one that keeps it. The id and version tie the value to one stored row, so a new
/// draft version of identical content still gets a different one.
/// </para>
/// <para>
/// Identical stored content yields an identical <c>ETag</c>, which is what a strong validator means — it names a
/// representation — so a <c>Put</c> that writes back exactly what is stored returns the value <c>Get</c> did, having
/// overwritten nothing. Every input is length-prefixed and an absent one is marked distinctly from an empty one, so
/// no two different sets of inputs feed the hash the same bytes. The value is opaque to clients, which must send it
/// back verbatim.
/// </para>
/// </remarks>
internal static class BpmnDocumentETag
{
/// <summary>Computes the quoted strong ETag for <paramref name="definition"/> as it is stored.</summary>
public static string From(WorkflowDefinition definition)
{
var sourceXml = definition.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey, out var xml) ? xml : null;
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
Append(hash, definition.Id);
Append(hash, definition.Version.ToString(CultureInfo.InvariantCulture));
Append(hash, sourceXml);
Append(hash, definition.StringData);
return $"\"{Convert.ToHexString(hash.GetHashAndReset())}\"";
}
private static void Append(IncrementalHash hash, string? value)
{
Span<byte> header = stackalloc byte[5];
if (value is null)
{
header[0] = 0;
hash.AppendData(header[..1]);
return;
}
var bytes = Encoding.UTF8.GetBytes(value);
header[0] = 1;
BinaryPrimitives.WriteInt32BigEndian(header[1..], bytes.Length);
hash.AppendData(header);
hash.AppendData(bytes);
}
}

View file

@ -0,0 +1,66 @@
using System.Net.Mime;
using System.Text.Json;
using Elsa.Authorization;
using Bpmn.Model;
using Elsa.Abstractions;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Get;
/// <summary>
/// Reads a stored workflow definition's BPMN source back out as the library's own <c>bpmnDefinitions</c> JSON
/// document — the shape <c>Endpoints.Bpmn.Document.Put.Put</c> accepts back — rather than as <c>.bpmn</c> XML.
/// </summary>
/// <remarks>
/// A thin wrapper over <see cref="BpmnInterchangeDocumentService.ReadDocument"/>. Studio never holds the whole
/// document as JSON; the round trip this endpoint and <c>Put</c> exist for is what lets an edit made there — a
/// binding (W11), a moved shape (W14) — be written back without a client-side reimplementation of
/// <c>BpmnXmlWriter</c>. Same missing/stale-source refusals as <c>Export</c>; see
/// <see cref="BpmnInterchangeDocumentService"/>'s remarks for what each means.
/// <para>
/// The response body is written with <see cref="BpmnDocumentJsonOptions"/>, not through FastEndpoints' configured
/// serializer — see that type's remarks for why the two disagree on shape.
/// </para>
/// </remarks>
[UsedImplicitly]
internal sealed class Get(IWorkflowDefinitionStore store, BpmnInterchangeDocumentService documentService) : ElsaEndpointWithoutRequest<BpmnDefinitions>
{
/// <inheritdoc />
public override void Configure()
{
Get("bpmn/definitions/{definitionId}/document");
RequirePermission(Elsa.Bpmn.Interchange.Permissions.BpmnPermissions.Definitions, CoreVerbs.View);
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var definitionId = Route<string>("definitionId")!;
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await store.FindAsync(filter, cancellationToken);
if (definition == null)
{
await Send.NotFoundAsync(cancellationToken);
return;
}
await BpmnExportExceptionCascade.RunAsync(
async () =>
{
var document = documentService.ReadDocument(definition);
var json = JsonSerializer.Serialize(document, BpmnDocumentJsonOptions.Value);
HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(definition);
await Send.StringAsync(json, contentType: MediaTypeNames.Application.Json, cancellation: cancellationToken);
},
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);
}
}

View file

@ -0,0 +1,130 @@
using System.Text.Json;
using Elsa.Authorization;
using Bpmn.Model;
using Elsa.Abstractions;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Put;
/// <summary>
/// Accepts the whole <c>bpmnDefinitions</c> JSON document for an existing workflow definition, writes it back out as
/// BPMN 2.0 XML, and imports it through the same path <c>Endpoints.Bpmn.Import.Import</c> runs — analyze, capability
/// check, bind, persist as a draft, refresh the stored source. A published version is never edited in place: like
/// <c>Import</c>, this always produces a new draft.
/// </summary>
/// <remarks>
/// A thin wrapper over <see cref="BpmnInterchangeDocumentService.ImportDocumentAsync"/>. The request body is bound as
/// a raw string and deserialized explicitly with <see cref="BpmnDocumentJsonOptions"/>, not through FastEndpoints'
/// configured serializer — see that type's remarks for why the two disagree on shape, and
/// <c>Endpoints.Bpmn.Document.Get.Get</c> for the read side of this round trip.
/// </remarks>
[UsedImplicitly]
internal sealed class Put(IWorkflowDefinitionStore store, BpmnInterchangeDocumentService documentService) : ElsaEndpointWithoutRequest<Response>
{
/// <inheritdoc />
public override void Configure()
{
Put("bpmn/definitions/{definitionId}/document");
RequirePermission(Elsa.Bpmn.Interchange.Permissions.BpmnPermissions.Definitions, CoreVerbs.Write);
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var definitionId = Route<string>("definitionId")!;
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await store.FindAsync(filter, cancellationToken);
if (definition == null)
{
await Send.NotFoundAsync(cancellationToken);
return;
}
// Optimistic concurrency: a client that GETs the document, then PUTs it back after someone else has written the
// definition in between, must not silently replace that intervening write. The client is required to carry the
// ETag its GET returned as If-Match. A missing header cannot express "I know what I'm overwriting" at all, and
// neither can "*", which matches whatever is stored — so both are refused as 428 rather than honoured. Anything
// else must be exactly the current strong ETag (a weak W/ tag or a list never is), or it proves the client's copy
// is no longer current. All of this is checked before any import work runs or anything is persisted.
var ifMatch = HttpContext.Request.Headers.IfMatch.ToString().Trim();
if (ifMatch is "" or "*")
{
AddError("An If-Match header carrying the ETag from a prior GET of this document is required to PUT it back, so an intervening edit is not silently overwritten. The wildcard \"*\" is not accepted.");
await Send.ErrorsAsync(StatusCodes.Status428PreconditionRequired, cancellationToken);
return;
}
if (!string.Equals(ifMatch, BpmnDocumentETag.From(definition), StringComparison.Ordinal))
{
AddError("The workflow definition has been written since the ETag in If-Match was issued. GET the document again, reapply the edit, and PUT it with the new ETag.");
await Send.ErrorsAsync(StatusCodes.Status412PreconditionFailed, cancellationToken);
return;
}
string body;
using (var reader = new StreamReader(HttpContext.Request.Body))
body = await reader.ReadToEndAsync(cancellationToken);
BpmnDefinitions? document;
try
{
document = JsonSerializer.Deserialize<BpmnDefinitions>(body, BpmnDocumentJsonOptions.Value);
}
catch (JsonException exception)
{
AddError($"The request body is not a valid BPMN document: {exception.Message}");
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return;
}
if (document is null)
{
AddError("The request body must be a BPMN document, not JSON null.");
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return;
}
// The process this definition was imported from, when the document that produced it declared more than one:
// reused here so a multi-process document keeps importing the same process on every edit, without asking the
// caller to say so again. See BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey.
var processId = definition.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey, out var storedProcessId)
? storedProcessId
: null;
var result = await BpmnImportExceptionCascade.RunAsync(
() => documentService.ImportDocumentAsync(document, definitionId, processId, cancellationToken),
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);
if (result is null)
return;
var persisted = result.ImportResult.WorkflowDefinition;
// The definition exactly as ImportDocumentAsync's final save wrote it, so the ETag names the state this PUT
// produced. Reloading it from the store instead could pick up a write that landed after that save and hand the
// client a validator for content it never saw, letting its next PUT overwrite that write silently.
HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(persisted);
await Send.OkAsync(new Response
{
Id = persisted.Id,
DefinitionId = persisted.DefinitionId,
Version = persisted.Version,
Analysis = BpmnImportAnalysisModel.From(result.Analysis)
}, cancellationToken);
}
}

View file

@ -1,7 +1,6 @@
using Elsa.Authorization;
using Bpmn.Interchange;
using Elsa.Abstractions;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Workflows.Management;
@ -64,20 +63,14 @@ internal sealed class Export(IWorkflowDefinitionStore store, BpmnInterchangeDocu
return;
}
try
{
var bytes = documentService.Export(definition);
await Send.BytesAsync(bytes, $"{request.DefinitionId}.bpmn", "application/xml", cancellation: cancellationToken);
}
catch (BpmnExportUnavailableException exception)
{
AddError(exception.Message);
await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
}
catch (BpmnInterchangeException exception)
{
AddError(exception.Message);
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
}
await BpmnExportExceptionCascade.RunAsync(
async () =>
{
var bytes = documentService.Export(definition);
await Send.BytesAsync(bytes, $"{request.DefinitionId}.bpmn", "application/xml", cancellation: cancellationToken);
},
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);
}
}

View file

@ -1,8 +1,7 @@
using Elsa.Authorization;
using Bpmn.Interchange;
using Bpmn.Semantics;
using Elsa.Abstractions;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Services;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
@ -41,39 +40,14 @@ internal sealed class Import(BpmnInterchangeDocumentService documentService) : E
var xml = await BpmnUploadedFileReader.ReadTextAsync(Files[0], cancellationToken);
BpmnDocumentImportResult result;
var result = await BpmnImportExceptionCascade.RunAsync(
() => documentService.ImportAsync(xml, request.DefinitionId, request.Name, request.ProcessId, cancellationToken),
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);
try
{
result = await documentService.ImportAsync(xml, request.DefinitionId, request.Name, request.ProcessId, cancellationToken);
}
catch (BpmnInterchangeException exception)
{
AddError(exception.Message);
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
if (result is null)
return;
}
catch (BpmnBindingException exception)
{
AddError(exception.Message);
await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
return;
}
catch (BpmnCapabilityException exception)
{
AddCapabilityErrors(exception);
await Send.ErrorsAsync(StatusCodes.Status422UnprocessableEntity, cancellationToken);
return;
}
if (!result.ImportResult.Succeeded)
{
foreach (var validationError in result.ImportResult.ValidationErrors)
AddError(validationError.Message);
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return;
}
var definition = result.ImportResult.WorkflowDefinition;
@ -85,22 +59,4 @@ internal sealed class Import(BpmnInterchangeDocumentService documentService) : E
Analysis = BpmnImportAnalysisModel.From(result.Analysis)
}, cancellationToken);
}
/// <remarks>
/// <see cref="BpmnCapabilityException"/> carries <see cref="BpmnCapabilityException.DrivingElementIds"/> as a
/// single flat list, already unioned across every missing capability — it does not say which element drove which
/// capability (unlike <c>BpmnCapabilityRequirements.DrivingElementIds</c>, which is per-capability, but that type
/// is gone by the time this catch clause sees the exception). Attributing the full, unioned list to each
/// capability individually would put elements next to a capability they may have nothing to do with, so this
/// reports the missing capabilities together with the combined element list once, rather than repeating it.
/// </remarks>
private void AddCapabilityErrors(BpmnCapabilityException exception)
{
var missingCapabilities = string.Join(", ", BpmnInterchangeDocumentService.IndividualCapabilities.Where(capability => exception.Missing.HasFlag(capability)));
var elementIds = string.Join(", ", exception.DrivingElementIds);
AddError(
$"This deployment does not declare the following BPMN host capabilities the document requires: {missingCapabilities}. "
+ $"Offending elements (combined across all missing capabilities above, not attributable to any one of them): {elementIds}.");
}
}

View file

@ -14,7 +14,8 @@ using Elsa.Workflows.Management.Models;
namespace Elsa.Bpmn.Interchange.Services;
/// <summary>
/// The one code path the Analyze, Import and Export endpoints all sit on top of.
/// The one code path the Analyze, Import, Export and document (<see cref="ReadDocument"/>/<see cref="ImportDocumentAsync"/>)
/// endpoints all sit on top of.
/// </summary>
/// <remarks>
/// <para>
@ -32,12 +33,14 @@ namespace Elsa.Bpmn.Interchange.Services;
/// throw away everything the reader retained to get there.
/// </para>
/// <para>
/// <b>Export is only ever the document as imported, and that is a real limitation, not a detail.</b> Until BPMN-aware
/// editing exists (a Studio concern, out of scope for this program), nothing re-serializes edits made through Elsa's
/// own designer back into BPMN — <see cref="Export(WorkflowDefinition)"/> always returns the source text
/// <see cref="ImportAsync"/> stored, never a document reflecting what the definition currently is. That is why it
/// refuses outright, rather than returning something, when it cannot prove that text still matches the definition:
/// see <see cref="SourceVersionCustomPropertyKey"/>.
/// <b>Export is only ever the document as imported — through whichever path last imported it.</b>
/// <see cref="Export(WorkflowDefinition)"/> never reconstructs a document from the Elsa activity tree; it always
/// returns the source text the most recent successful <see cref="ImportAsync"/> stored. An edit made through Elsa's
/// own designer, without going back through BPMN, is therefore never reflected — the graph moves on but the stored
/// source describes the document as it stood before that edit, which is why staleness has to be provable rather than
/// assumed; see <see cref="SourceVersionCustomPropertyKey"/>. An edit made through <see cref="ImportDocumentAsync"/>
/// is different: it re-imports, so the stored source and the returned graph both move together, and
/// <see cref="Export(WorkflowDefinition)"/> reflects it immediately afterward.
/// </para>
/// <para>
/// <b>The stored source can go missing or stale after import, and each is refused with its own diagnosis.</b> BPMN
@ -87,6 +90,19 @@ public sealed class BpmnInterchangeDocumentService(
/// </remarks>
public const string SourceVersionCustomPropertyKey = "Bpmn:SourceVersion";
/// <summary>
/// The workflow definition custom property <see cref="ImportAsync"/> records the <c>processId</c> it bound the
/// definition's root scope from, at the moment it stores <see cref="SourceXmlCustomPropertyKey"/>.
/// </summary>
/// <remarks>
/// A document that declares more than one <c>&lt;process&gt;</c> needs a <c>processId</c> to disambiguate which
/// one a re-import should bind (see <see cref="ResolveRootDefinition"/>); this is what lets
/// <see cref="ImportDocumentAsync"/> re-import the same process a multi-process document was originally imported
/// from, without asking the caller to say so again on every edit. Recorded unconditionally, including for a
/// single-process document, so this is always derivable the same way rather than only when it happens to matter.
/// </remarks>
public const string SourceProcessIdCustomPropertyKey = "Bpmn:SourceProcessId";
/// <summary>
/// The host capabilities this deployment's BPMN runtime declares.
/// </summary>
@ -160,6 +176,7 @@ public sealed class BpmnInterchangeDocumentService(
var persisted = importResult.WorkflowDefinition;
persisted.CustomProperties[SourceXmlCustomPropertyKey] = xml;
persisted.CustomProperties[SourceVersionCustomPropertyKey] = persisted.Version;
persisted.CustomProperties[SourceProcessIdCustomPropertyKey] = rootDefinition.ProcessId;
await store.SaveAsync(persisted, cancellationToken);
}
@ -192,7 +209,61 @@ public sealed class BpmnInterchangeDocumentService(
/// source was recorded.
/// </exception>
/// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception>
public byte[] Export(WorkflowDefinition definition)
public byte[] Export(WorkflowDefinition definition) => Export(ResolveSourceXml(definition));
/// <summary>
/// Resolves the BPMN source a workflow definition was imported from and reads it back as the neutral
/// <see cref="BpmnDefinitions"/> object model — the same shape <see cref="ImportDocumentAsync"/> accepts back —
/// through the same reader <see cref="ImportAsync"/> and <see cref="Export(string)"/> use, so retained extension
/// elements, foreign attributes and BPMN DI layout are present on the returned document exactly as the reader
/// retained them. Refuses rather than guessing when that source is missing or no longer trustworthy; see this
/// type's remarks for what "missing" and "stale" mean.
/// </summary>
/// <param name="definition">The workflow definition to read, as read from the store.</param>
/// <exception cref="BpmnExportUnavailableException">
/// The definition does not currently carry BPMN source, or it does but the definition has changed since the
/// source was recorded.
/// </exception>
/// <exception cref="BpmnInterchangeException">The stored document cannot be read at all.</exception>
public BpmnDefinitions ReadDocument(WorkflowDefinition definition)
{
var xml = ResolveSourceXml(definition);
return reader.Read(xml, new BpmnImportOptions()).Definitions;
}
/// <summary>
/// Accepts the whole <see cref="BpmnDefinitions"/> document — the shape <see cref="ReadDocument"/> returns —
/// writes it back out as BPMN 2.0 XML with <see cref="BpmnXmlWriter"/>, and imports the result through
/// <see cref="ImportAsync"/>, the same path <c>Import</c> runs. Analyze-then-commit sharing this one code path
/// with the read side is what keeps a preview unable to disagree with what this actually persists.
/// </summary>
/// <param name="document">The edited document, deserialized through the library's own JSON converters.</param>
/// <param name="definitionId">The workflow definition to update.</param>
/// <param name="processId">
/// The process to (re-)bind when the document declares more than one; not needed when it declares exactly one.
/// See <see cref="SourceProcessIdCustomPropertyKey"/> for where a caller re-importing an existing definition
/// finds the value that was used the first time.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <exception cref="BpmnInterchangeException">The document declares more than one process and <paramref name="processId"/> does not pick one.</exception>
/// <exception cref="BpmnCapabilityException">The document needs a host capability this deployment does not declare.</exception>
/// <exception cref="Exceptions.BpmnBindingException">A work binding cannot be turned into an Elsa activity.</exception>
public Task<BpmnDocumentImportResult> ImportDocumentAsync(BpmnDefinitions document, string definitionId, string? processId, CancellationToken cancellationToken)
{
var xml = writer.Write(document);
return ImportAsync(xml, definitionId, name: null, processId, cancellationToken);
}
/// <summary>
/// The BPMN source a workflow definition was imported from, refusing rather than guessing when it is missing or
/// no longer trustworthy. See this type's remarks for what "missing" and "stale" mean and why each gets its own
/// message.
/// </summary>
/// <exception cref="BpmnExportUnavailableException">
/// The definition does not currently carry BPMN source, or it does but the definition has changed since the
/// source was recorded.
/// </exception>
private static string ResolveSourceXml(WorkflowDefinition definition)
{
if (!definition.CustomProperties.TryGetValue<string>(SourceXmlCustomPropertyKey, out var xml) || string.IsNullOrEmpty(xml))
{
@ -225,7 +296,7 @@ public sealed class BpmnInterchangeDocumentService(
+ "would silently return a document that is not what this definition currently is.");
}
return Export(xml);
return xml;
}
private static BpmnProcessDefinition ResolveRootDefinition(BpmnDefinitions definitions, string? processId)

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_two-process"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="first-process" name="First Process" isExecutable="true">
<bpmn:startEvent id="Start_1">
<bpmn:outgoing>Flow_1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:endEvent id="End_1">
<bpmn:incoming>Flow_1</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="End_1" />
</bpmn:process>
<bpmn:process id="second-process" name="Second Process" isExecutable="true">
<bpmn:startEvent id="Start_2">
<bpmn:outgoing>Flow_2</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:endEvent id="End_2">
<bpmn:incoming>Flow_2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_2" sourceRef="Start_2" targetRef="End_2" />
</bpmn:process>
</bpmn:definitions>

View file

@ -3,13 +3,16 @@ using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Elsa;
using Elsa.Bpmn.Activities;
using Elsa.Bpmn.Interchange.Features;
using Elsa.Bpmn.Interchange.IntegrationTests.Support;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
@ -224,6 +227,342 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task DocumentGet_OfAMissingDefinition_ReturnsNotFound()
{
var response = await GetAuthenticatedAsync("bpmn/definitions/does-not-exist/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DocumentGet_OfADefinitionNeverImportedFromBpmn_ReturnsUnprocessableEntity()
{
var definitionId = await CreateNonBpmnDefinitionAsync();
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("does not currently carry BPMN source", body);
}
[Fact]
public async Task DocumentGet_OfAFreshlyImportedDefinition_ReturnsOkWithTheLibraryFormatDocument()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var body = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(body);
Assert.Contains("order-process", document.RootElement.GetProperty("processes").EnumerateArray().Select(process => process.GetProperty("processId").GetString()));
}
[Fact]
public async Task DocumentGet_WhenAuthenticatedWithoutTheRequiredPermission_ReturnsForbidden()
{
var definitionId = await ImportCamundaOrderProcessAsync();
// Authenticated, but only holds the write permission Put needs, not the read permission Get needs.
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:write");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task DocumentPut_OfAMissingDefinition_ReturnsNotFound()
{
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await PutAuthenticatedAsync("bpmn/definitions/does-not-exist/document", content, null, "workflows/definitions:write");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DocumentPut_WithoutIfMatch_ReturnsPreconditionRequired()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var versionBeforePut = await LatestVersionOfAsync(definitionId);
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, null, "workflows/definitions:write");
Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
[Fact]
public async Task DocumentPut_WithAWildcardIfMatch_ReturnsPreconditionRequiredAndOverwritesNothing()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var (_, documentJson) = await GetDocumentAsync(definitionId);
var storedBeforePut = await LatestStoredAsync(definitionId);
// "*" matches whatever is currently stored, so honouring it would be exactly the blind overwrite the required
// If-Match exists to prevent.
var response = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), "*");
Assert.Equal(HttpStatusCode.PreconditionRequired, response.StatusCode);
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
}
[Fact]
public async Task DocumentPut_WithTheCurrentIfMatch_ReturnsOkWithANewETagWhenTheContentChanged()
{
var definitionId = await ImportCamundaOrderProcessWrittenBackAsync();
var (currentETag, documentJson) = await GetDocumentAsync(definitionId);
var putResponse = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), currentETag);
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
var newETag = ETagOf(putResponse);
Assert.NotNull(newETag);
Assert.NotEqual(currentETag, newETag);
// The returned ETag names what was stored, so it is exactly what the next GET hands out.
Assert.Equal(newETag, (await GetDocumentAsync(definitionId)).ETag);
}
[Fact]
public async Task DocumentPut_OfUnchangedContent_ReturnsTheETagTheGetReturned()
{
var definitionId = await ImportCamundaOrderProcessWrittenBackAsync();
var (currentETag, documentJson) = await GetDocumentAsync(definitionId);
var putResponse = await PutDocumentAsync(definitionId, documentJson, currentETag);
// Content-addressed: writing back exactly what is stored overwrites nothing, so the validator stays the same.
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
Assert.Equal(currentETag, ETagOf(putResponse));
}
[Fact]
public async Task DocumentPut_AfterAnInterveningDocumentPut_ReturnsPreconditionFailedAndOverwritesNothing()
{
var definitionId = await ImportCamundaOrderProcessWrittenBackAsync();
var (staleETag, documentJson) = await GetDocumentAsync(definitionId);
var storedBeforeInterveningPut = await LatestStoredAsync(definitionId);
var interveningPut = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), staleETag);
Assert.Equal(HttpStatusCode.OK, interveningPut.StatusCode);
// A layout-only edit to an unpublished draft: same row, same version, same activity graph — only the stored
// document moved, so this is the case that proves the document itself is part of the ETag.
var storedAfterInterveningPut = await LatestStoredAsync(definitionId);
Assert.Equal(storedBeforeInterveningPut with { SourceXml = storedAfterInterveningPut.SourceXml }, storedAfterInterveningPut);
Assert.NotEqual(storedBeforeInterveningPut.SourceXml, storedAfterInterveningPut.SourceXml);
await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag);
}
[Fact]
public async Task DocumentPut_AfterAnInterveningImportIntoTheSameDefinition_ReturnsPreconditionFailedAndOverwritesNothing()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var (staleETag, documentJson) = await GetDocumentAsync(definitionId);
var storedBeforeImport = await LatestStoredAsync(definitionId);
// A different document declaring the same process, so the stale PUT below would bind cleanly and silently
// replace it if the precondition let it through.
using var content = new MultipartFormDataContent();
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn").Replace("Order Handled", "Order Shipped"), "file");
content.Add(new StringContent(definitionId), "DefinitionId");
var importResponse = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
// Imported into the unpublished draft in place: same row, same version.
var storedAfterImport = await LatestStoredAsync(definitionId);
Assert.Equal((storedBeforeImport.Id, storedBeforeImport.Version), (storedAfterImport.Id, storedAfterImport.Version));
await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag);
}
[Fact]
public async Task DocumentPut_AfterAnInterveningDesignerSaveOfTheDraft_ReturnsPreconditionFailedAndOverwritesNothing()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var (staleETag, documentJson) = await GetDocumentAsync(definitionId);
var storedBeforeSave = await LatestStoredAsync(definitionId);
await SaveDraftFromTheDesignerAsync(definitionId);
// Saved in place with every custom property carried forward: same row, same version, same stored document —
// only the activity graph moved, so this is the case that proves the graph itself is part of the ETag.
var storedAfterSave = await LatestStoredAsync(definitionId);
Assert.Equal(storedBeforeSave with { StringData = storedAfterSave.StringData }, storedAfterSave);
Assert.NotEqual(storedBeforeSave.StringData, storedAfterSave.StringData);
await AssertStalePutIsRefusedAsync(definitionId, documentJson, staleETag);
}
[Fact]
public async Task DocumentPut_WithMalformedJson_ReturnsBadRequestAndPersistsNoNewDraft()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var versionBeforePut = await LatestVersionOfAsync(definitionId);
var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
using var content = new StringContent("{ not valid json", Encoding.UTF8, "application/json");
var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, ETagOf(getResponse), "workflows/definitions:write");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
[Fact]
public async Task DocumentPut_ThatRemovesARequiredActivityBinding_ReturnsUnprocessableEntityAndPersistsNoNewDraft()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var versionBeforePut = await LatestVersionOfAsync(definitionId);
var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var documentJson = await getResponse.Content.ReadAsStringAsync();
// Strips the one <elsa:activityBinding> the document carries (on "NotifyWarehouse"), reproducing exactly
// what Import itself refuses for unbound-task-process.bpmn: a task-family element the document describes
// but does not say how to perform.
using var editedDocument = JsonDocument.Parse(documentJson);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteWithoutActivityBindingExtensions(editedDocument.RootElement, writer);
}
using var putContent = new ByteArrayContent(stream.ToArray());
putContent.Headers.ContentType = new("application/json");
var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write");
Assert.Equal(HttpStatusCode.UnprocessableEntity, putResponse.StatusCode);
var body = await putResponse.Content.ReadAsStringAsync();
Assert.Contains("nothing binds it to an Elsa activity", body);
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
[Fact]
public async Task DocumentPut_UnchangedDocument_ReturnsOkAndTheSameFindingsAsImport()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var documentJson = await getResponse.Content.ReadAsStringAsync();
using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json");
var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write");
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
using var putResult = JsonDocument.Parse(await putResponse.Content.ReadAsStringAsync());
Assert.Equal(definitionId, putResult.RootElement.GetProperty("definitionId").GetString());
Assert.Contains(
"order-process",
putResult.RootElement.GetProperty("analysis").GetProperty("processIds").EnumerateArray().Select(processId => processId.GetString()));
var exportResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/export", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, exportResponse.StatusCode);
var exportedXml = await exportResponse.Content.ReadAsStringAsync();
Assert.Contains("order-process", exportedXml);
}
[Fact]
public async Task DocumentPut_WhenAuthenticatedWithoutTheRequiredPermission_ReturnsForbidden()
{
var definitionId = await ImportCamundaOrderProcessAsync();
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
// Authenticated, but only holds the read permission Get needs, not the write permission Put needs.
var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, null, "workflows/definitions:view");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task DocumentPut_ForASingleProcessDefinitionImportedBeforeSourceProcessIdExisted_ReturnsOk()
{
var definitionId = await ImportCamundaOrderProcessAsync();
// Simulates a definition imported before ImportAsync started recording SourceProcessIdCustomPropertyKey.
await RemoveSourceProcessIdCustomPropertyAsync(definitionId);
var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var documentJson = await getResponse.Content.ReadAsStringAsync();
using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json");
var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write");
// A single-process document does not need SourceProcessId to disambiguate anything, so the missing
// property does not stop the edit from succeeding.
Assert.Equal(HttpStatusCode.OK, putResponse.StatusCode);
}
[Fact]
public async Task DocumentPut_ForATwoProcessDefinitionImportedBeforeSourceProcessIdExisted_ReturnsBadRequestAndPersistsNoNewDraft()
{
var definitionId = await ImportTwoProcessDocumentAsync("first-process");
// Simulates a definition imported before ImportAsync started recording SourceProcessIdCustomPropertyKey:
// without it, Put has no way to know which of the document's two processes to re-bind.
await RemoveSourceProcessIdCustomPropertyAsync(definitionId);
var versionBeforePut = await LatestVersionOfAsync(definitionId);
var getResponse = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var documentJson = await getResponse.Content.ReadAsStringAsync();
using var putContent = new StringContent(documentJson, Encoding.UTF8, "application/json");
var putResponse = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", putContent, ETagOf(getResponse), "workflows/definitions:write");
Assert.True((int)putResponse.StatusCode is >= 400 and < 500, $"Expected a 4xx status code, got {(int)putResponse.StatusCode}.");
var body = await putResponse.Content.ReadAsStringAsync();
Assert.Contains("specify which one to import", body);
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
/// <summary>
/// Re-serializes <paramref name="element"/> with every <c>extensionElements</c> array entry named
/// <c>activityBinding</c> (in the <c>elsa:</c> namespace URI) removed, walking the whole document recursively.
/// </summary>
private static void WriteWithoutActivityBindingExtensions(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject())
{
writer.WritePropertyName(property.Name);
WriteWithoutActivityBindingExtensions(property.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray().Where(item => !IsActivityBindingExtensionElement(item)))
WriteWithoutActivityBindingExtensions(item, writer);
writer.WriteEndArray();
break;
default:
element.WriteTo(writer);
break;
}
}
private static bool IsActivityBindingExtensionElement(JsonElement element) =>
element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("name", out var name)
&& name.ValueKind == JsonValueKind.Object
&& name.TryGetProperty("localName", out var localName)
&& localName.GetString() == "activityBinding"
&& name.TryGetProperty("ns", out var ns)
&& ns.GetString() == "https://elsaworkflows.io/schemas/bpmn/v1";
private async Task<string> CreateNonBpmnDefinitionAsync()
{
using var scope = _app!.Services.CreateScope();
@ -267,6 +606,149 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
return await HttpClient.SendAsync(request);
}
private async Task<HttpResponseMessage> PutAuthenticatedAsync(string requestUri, HttpContent content, string? ifMatch, params string[] permissions)
{
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri) { Content = content };
request.Headers.Add(TestAuthenticationHandler.PermissionHeader, string.Join(",", permissions));
if (ifMatch is not null)
request.Headers.Add("If-Match", ifMatch);
return await HttpClient.SendAsync(request);
}
/// <summary>The ETag a prior <c>document</c> GET or PUT response carried, for use as the next PUT's <c>If-Match</c>.</summary>
private static string? ETagOf(HttpResponseMessage response) => response.Headers.ETag?.Tag;
/// <summary>GETs the document of <paramref name="definitionId"/>, asserting it succeeded, and returns its ETag and body.</summary>
private async Task<(string? ETag, string Json)> GetDocumentAsync(string definitionId)
{
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (ETagOf(response), await response.Content.ReadAsStringAsync());
}
private Task<HttpResponseMessage> PutDocumentAsync(string definitionId, string documentJson, string? ifMatch) =>
PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", new StringContent(documentJson, Encoding.UTF8, "application/json"), ifMatch, "workflows/definitions:write");
/// <summary>
/// PUTs <paramref name="documentJson"/> with <paramref name="staleETag"/> and asserts it is refused with 412 and leaves
/// the stored definition exactly as it was. The version alone cannot show that: an unpublished draft is overwritten in
/// place, under the same version, which is precisely the overwrite this is checking did not happen.
/// </summary>
private async Task AssertStalePutIsRefusedAsync(string definitionId, string documentJson, string? staleETag)
{
var storedBeforePut = await LatestStoredAsync(definitionId);
var response = await PutDocumentAsync(definitionId, documentJson, staleETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode);
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
}
/// <summary>Moves the document's first diagram shape to the right — a layout-only edit, the kind W14 makes.</summary>
private static string WithFirstShapeMoved(string documentJson)
{
var document = JsonNode.Parse(documentJson)!;
var bounds = document["diagrams"]![0]!["plane"]!["shapes"]![0]!["bounds"]!;
bounds["x"] = bounds["x"]!.GetValue<double>() + 10;
return document.ToJsonString();
}
/// <summary>
/// Saves the latest draft of <paramref name="definitionId"/> the way the workflow-definition save endpoint Studio's
/// designer calls does — <see cref="IWorkflowDefinitionPublisher.GetDraftAsync"/>, a re-serialized root, then
/// <see cref="IWorkflowDefinitionPublisher.SaveDraftAsync"/> — with the bound activity's text edited and every custom
/// property, the stored BPMN source included, carried forward as Studio sends them back.
/// </summary>
private async Task SaveDraftFromTheDesignerAsync(string definitionId)
{
using var scope = _app!.Services.CreateScope();
var publisher = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
var serializer = scope.ServiceProvider.GetRequiredService<IActivitySerializer>();
var draft = await publisher.GetDraftAsync(definitionId, VersionOptions.Latest);
Assert.NotNull(draft);
var root = Assert.IsType<BpmnProcess>(serializer.Deserialize(draft!.StringData!));
Assert.Single(root.Activities.OfType<WriteLine>()).Text = new("Notifying the warehouse, edited in the designer");
draft.StringData = serializer.Serialize(root);
await publisher.SaveDraftAsync(draft);
}
/// <summary>Imports <c>camunda-order-process.bpmn</c> through the real endpoint and returns the resulting <c>definitionId</c>.</summary>
private async Task<string> ImportCamundaOrderProcessAsync()
{
using var content = new MultipartFormDataContent();
AddBpmnFile(content, ReadAsset("camunda-order-process.bpmn"), "file");
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("definitionId").GetString()!;
}
/// <summary>
/// Imports <c>camunda-order-process.bpmn</c> and writes its document straight back once through the document PUT, so
/// what is stored is the writer's own rendering of it rather than the uploaded bytes. From there only an actual edit
/// changes the stored content, which is what lets a test attribute an ETag change — or its absence — to one write.
/// </summary>
private async Task<string> ImportCamundaOrderProcessWrittenBackAsync()
{
var definitionId = await ImportCamundaOrderProcessAsync();
var (etag, documentJson) = await GetDocumentAsync(definitionId);
var response = await PutDocumentAsync(definitionId, documentJson, etag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return definitionId;
}
/// <summary>Imports <c>two-process.bpmn</c>, picking <paramref name="processId"/>, and returns the resulting <c>definitionId</c>.</summary>
private async Task<string> ImportTwoProcessDocumentAsync(string processId)
{
using var content = new MultipartFormDataContent();
AddBpmnFile(content, ReadAsset("two-process.bpmn"), "file");
content.Add(new StringContent(processId), "ProcessId");
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("definitionId").GetString()!;
}
/// <summary>
/// Removes <see cref="BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey"/> from the latest version of
/// <paramref name="definitionId"/>, simulating a definition imported before <c>ImportAsync</c> started recording it.
/// </summary>
private async Task RemoveSourceProcessIdCustomPropertyAsync(string definitionId)
{
using var scope = _app!.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await store.FindAsync(filter);
Assert.NotNull(definition);
definition!.CustomProperties.Remove(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey);
await store.SaveAsync(definition);
}
private async Task<int> LatestVersionOfAsync(string definitionId) => (await LatestStoredAsync(definitionId)).Version;
/// <summary>
/// A snapshot of the latest version of <paramref name="definitionId"/>: its id, version, activity graph and stored BPMN
/// document — everything a document PUT rewrites that the document ETag covers.
/// </summary>
private async Task<StoredDefinition> LatestStoredAsync(string definitionId)
{
using var scope = _app!.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await store.FindAsync(filter);
Assert.NotNull(definition);
definition!.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceXmlCustomPropertyKey, out var sourceXml);
return new(definition.Id, definition.Version, definition.StringData, sourceXml);
}
private sealed record StoredDefinition(string Id, int Version, string? StringData, string? SourceXml);
private sealed class TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,

View file

@ -0,0 +1,164 @@
using System.Text;
using System.Xml.Linq;
using Bpmn.Model;
using Elsa.Bpmn.Interchange.Binding;
using Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Binding;
using Elsa.Bpmn.Interchange.IntegrationTests.Support;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Interchange;
/// <summary>
/// The round trip W21 exists for: <see cref="BpmnInterchangeDocumentService.ReadDocument"/> and
/// <see cref="BpmnInterchangeDocumentService.ImportDocumentAsync"/> — the document endpoints' shared service path —
/// let a document round-trip through JSON without losing what <c>Export</c> already proves survives XML alone.
/// </summary>
/// <remarks>
/// Derives from <see cref="BpmnBindingTestBase"/>, not <see cref="BpmnInterchangeTestBase"/>, because building the
/// edit in the second test needs <see cref="BpmnActivityBindingFormat"/> to write a fresh <c>elsa:activityBinding</c>,
/// exactly as <see cref="Scenarios.Publishing.BpmnPublishGateTestBase"/> already does for the same reason.
/// </remarks>
public class BpmnDocumentRoundTripTests : BpmnBindingTestBase
{
private static readonly XNamespace Camunda = BpmnXNamespaces.Camunda;
private static readonly XNamespace Elsa = BpmnXNamespaces.Elsa;
private static readonly XNamespace Di = BpmnXNamespaces.Di;
private static readonly XNamespace Bpmn = BpmnXNamespaces.Bpmn;
public BpmnDocumentRoundTripTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
{
DocumentService = Services.GetRequiredService<BpmnInterchangeDocumentService>();
DefinitionStore = Services.GetRequiredService<IWorkflowDefinitionStore>();
}
private BpmnInterchangeDocumentService DocumentService { get; }
private IWorkflowDefinitionStore DefinitionStore { get; }
[Fact(DisplayName = "Reading a document then posting it back unchanged exports content-equal to the original")]
public async Task ReadDocument_ThenImportDocumentAsyncUnchanged_ExportsContentEqualToTheOriginal()
{
var xml = ReadAsset("camunda-order-process.bpmn");
var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message)));
var definitionId = imported.ImportResult.WorkflowDefinition.DefinitionId;
var beforeStored = await FindLatestAsync(definitionId);
var originalExportedXml = Encoding.UTF8.GetString(DocumentService.Export(beforeStored));
var document = DocumentService.ReadDocument(beforeStored);
var processId = ProcessIdOf(beforeStored);
var putResult = await DocumentService.ImportDocumentAsync(document, definitionId, processId, CancellationToken.None);
Assert.True(putResult.ImportResult.Succeeded, string.Join("; ", putResult.ImportResult.ValidationErrors.Select(error => error.Message)));
var afterStored = await FindLatestAsync(definitionId);
var roundTrippedExportedXml = Encoding.UTF8.GetString(DocumentService.Export(afterStored));
AssertContentEquivalent(XDocument.Parse(originalExportedXml), XDocument.Parse(roundTrippedExportedXml));
}
[Fact(DisplayName = "Posting a document back with a new bound task added shows the binding on export and leaves everything else unchanged")]
public async Task ImportDocumentAsync_WithANewBoundTaskAdded_ExportsTheAdditionAndLeavesTheRestUnchanged()
{
var xml = ReadAsset("camunda-order-process.bpmn");
var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message)));
var definitionId = imported.ImportResult.WorkflowDefinition.DefinitionId;
var stored = await FindLatestAsync(definitionId);
var originalExportedXml = Encoding.UTF8.GetString(DocumentService.Export(stored));
var document = DocumentService.ReadDocument(stored);
var process = Assert.Single(document.Processes);
const string newTaskId = "ArchiveOrder";
var binding = Format.Write(new WriteLine("Archiving the order"));
var newTask = new BpmnElement(newTaskId, BpmnElementTypes.ServiceTask, name: "Archive Order", extensions: BpmnActivityBindingFormat.Attach(null, binding));
var editedProcess = process with { Elements = process.Elements.Append(newTask).ToList() };
var editedDocument = document with { Processes = [editedProcess] };
var processId = ProcessIdOf(stored);
var putResult = await DocumentService.ImportDocumentAsync(editedDocument, definitionId, processId, CancellationToken.None);
Assert.True(putResult.ImportResult.Succeeded, string.Join("; ", putResult.ImportResult.ValidationErrors.Select(error => error.Message)));
var updated = await FindLatestAsync(definitionId);
var updatedExportedXml = Encoding.UTF8.GetString(DocumentService.Export(updated));
var originalDocument = XDocument.Parse(originalExportedXml);
var updatedDocument = XDocument.Parse(updatedExportedXml);
// Everything that was there before the edit is still there, unchanged.
AssertContentEquivalent(originalDocument, updatedDocument);
// The addition shows up.
var addedTask = updatedDocument.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == newTaskId);
var addedBinding = addedTask.Descendants(Elsa + "activityBinding").Single();
Assert.Equal("Elsa.WriteLine", addedBinding.Attribute("activityType")?.Value);
Assert.Contains("Archiving the order", addedBinding.Descendants(Elsa + "input").Single().Value);
}
/// <summary>Everything <c>camunda-order-process.bpmn</c> carries that an edit must not disturb: foreign attributes, foreign extension elements, the elsa: binding on the untouched task, and BPMN DI waypoints.</summary>
private static void AssertContentEquivalent(XDocument expected, XDocument actual)
{
var expectedProcess = expected.Descendants(Bpmn + "process").Single();
var actualProcess = actual.Descendants(Bpmn + "process").Single();
Assert.Equal(expectedProcess.Attribute("id")?.Value, actualProcess.Attribute("id")?.Value);
Assert.Equal("order-process", expectedProcess.Attribute("id")?.Value);
Assert.Equal(expectedProcess.Attribute(Camunda + "versionTag")?.Value, actualProcess.Attribute(Camunda + "versionTag")?.Value);
var expectedTask = expected.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == "NotifyWarehouse");
var actualTask = actual.Descendants(Bpmn + "serviceTask").Single(element => element.Attribute("id")?.Value == "NotifyWarehouse");
Assert.Equal("true", expectedTask.Attribute(Camunda + "asyncBefore")?.Value);
Assert.Equal(expectedTask.Attribute(Camunda + "asyncBefore")?.Value, actualTask.Attribute(Camunda + "asyncBefore")?.Value);
Assert.Equal(expectedTask.Descendants(Bpmn + "documentation").Single().Value, actualTask.Descendants(Bpmn + "documentation").Single().Value);
var expectedBinding = expectedTask.Descendants(Elsa + "activityBinding").Single();
var actualBinding = actualTask.Descendants(Elsa + "activityBinding").Single();
Assert.Equal("Elsa.WriteLine", expectedBinding.Attribute("activityType")?.Value);
Assert.Equal(expectedBinding.Attribute("activityType")?.Value, actualBinding.Attribute("activityType")?.Value);
Assert.Equal(expectedBinding.Descendants(Elsa + "input").Single().Value, actualBinding.Descendants(Elsa + "input").Single().Value);
var expectedProperty = expected.Descendants(Camunda + "property").Single();
var actualProperty = actual.Descendants(Camunda + "property").Single();
Assert.Equal("owner", expectedProperty.Attribute("name")?.Value);
Assert.Equal(expectedProperty.Attribute("name")?.Value, actualProperty.Attribute("name")?.Value);
Assert.Equal(expectedProperty.Attribute("value")?.Value, actualProperty.Attribute("value")?.Value);
var expectedInputParameter = expected.Descendants(Camunda + "inputParameter").Single();
var actualInputParameter = actual.Descendants(Camunda + "inputParameter").Single();
Assert.Equal(expectedInputParameter.Value, actualInputParameter.Value);
var expectedWaypoints = expected.Descendants(Di + "waypoint").Select(WaypointOf).ToList();
var actualWaypoints = actual.Descendants(Di + "waypoint").Select(WaypointOf).ToList();
Assert.Equal(4, expectedWaypoints.Count);
Assert.Equal(expectedWaypoints, actualWaypoints);
}
private static (string X, string Y) WaypointOf(XElement element) => (element.Attribute("x")!.Value, element.Attribute("y")!.Value);
/// <summary>The <c>processId</c> a document endpoint would reuse, mirroring how <c>Put</c> resolves it from the stored definition.</summary>
private static string? ProcessIdOf(WorkflowDefinition definition) =>
definition.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey, out var processId) ? processId : null;
private async Task<WorkflowDefinition> FindLatestAsync(string definitionId)
{
var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
var definition = await DefinitionStore.FindAsync(filter);
Assert.NotNull(definition);
return definition!;
}
/// <summary>Reads a fixture from the <c>Assets</c> directory shipped alongside this test project.</summary>
private static string ReadAsset(string fileName) => BpmnAssetReader.Read(fileName);
}

View file

@ -1,5 +1,6 @@
using System.Xml.Linq;
using Bpmn.Interchange;
using Elsa.Bpmn.Interchange.IntegrationTests.Support;
using Elsa.Bpmn.Interchange.Services;
using Elsa.Common.Models;
using Elsa.Extensions;
@ -15,11 +16,11 @@ namespace Elsa.Bpmn.Interchange.IntegrationTests.Scenarios.Interchange;
/// </summary>
public class BpmnInterchangeDocumentServiceTests(ITestOutputHelper testOutputHelper) : BpmnInterchangeTestBase(testOutputHelper)
{
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
private static readonly XNamespace Elsa = "https://elsaworkflows.io/schemas/bpmn/v1";
private static readonly XNamespace Dc = "http://www.omg.org/spec/DD/20100524/DC";
private static readonly XNamespace Di = "http://www.omg.org/spec/DD/20100524/DI";
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
private static readonly XNamespace Camunda = BpmnXNamespaces.Camunda;
private static readonly XNamespace Elsa = BpmnXNamespaces.Elsa;
private static readonly XNamespace Dc = BpmnXNamespaces.Dc;
private static readonly XNamespace Di = BpmnXNamespaces.Di;
private static readonly XNamespace Bpmn = BpmnXNamespaces.Bpmn;
[Fact(DisplayName = "Analyze and Import report the same findings for the same document")]
public async Task Analyze_AndImport_ReportTheSameFindings()

View file

@ -0,0 +1,16 @@
using System.Xml.Linq;
namespace Elsa.Bpmn.Interchange.IntegrationTests.Support;
/// <summary>
/// The XML namespaces every test that parses a BPMN document with <see cref="XDocument"/> needs, kept in one place
/// so <c>camunda-order-process.bpmn</c>'s fixtures agree on them everywhere they are asserted against.
/// </summary>
internal static class BpmnXNamespaces
{
public static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
public static readonly XNamespace Elsa = "https://elsaworkflows.io/schemas/bpmn/v1";
public static readonly XNamespace Dc = "http://www.omg.org/spec/DD/20100524/DC";
public static readonly XNamespace Di = "http://www.omg.org/spec/DD/20100524/DI";
public static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
}