feat(bpmn): give BPMN interchange refusals stable error codes (#8067)

* feat(bpmn): give BPMN import/export/document refusals stable error codes

Studio has to recognise a BPMN import/export refusal, and extract capability
names and element ids, by matching the server's message text, so any rewording
silently degrades it to a generic error. Add BpmnErrorCodes with a stable code
per refusal (capability-unsupported, binding-invalid, export not-imported/
source-stale/source-version-unknown, and the document PUT's not-found and
precondition codes), sent through an additive BpmnErrorResponse envelope that
keeps today's statusCode/message/errors shape unchanged and adds code/data
alongside it, since FastEndpoints' own error response has no way to surface a
ValidationFailure's error code in this deployment's configuration. Rename the
Import/Export exception cascades to *ErrorResponses to say what they now do.

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

* refactor(bpmn): fold BpmnErrorResponseFactory and BpmnErrorResponseSender into BpmnErrorResponse

Both were thin, single-purpose wrappers around BpmnErrorResponse used only by
the import/export error mapping and one endpoint — a Middle Man chain. Create
and SendAsync now live as static members on BpmnErrorResponse itself; call
sites are unchanged otherwise, and the wire output is byte-identical.

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-12 00:10:12 -07:00 committed by GitHub
parent e54ced5662
commit 3bee566657
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 580 additions and 126 deletions

View file

@ -195,6 +195,54 @@ document, in three situations:
A missing `definitionId` returns `404 Not Found`.
### Error codes on refusals
**Every code below is a compatibility surface.** A client (Studio's own BPMN designer among them) matches on the
`code`, and on the `data` fields a code documents, rather than on the message — the message may be reworded without
notice.
`bpmn/import`, `bpmn/definitions/{id}/export`, and the document `GET`/`PUT` endpoints report most of their
BPMN-specific refusals as an additive envelope alongside the usual FastEndpoints error body shape (`statusCode`,
`message`, `errors`):
```json
{
"statusCode": 422,
"message": "One or more errors occurred!",
"errors": { "generalErrors": ["<the human-readable message>"] },
"code": "bpmn.import.capability-unsupported",
"data": { "capabilities": ["ScopeSignalling"], "elementIds": ["Task_1"] }
}
```
`statusCode`, `message` and `errors` are exactly what FastEndpoints' own `ErrorResponse` would have sent for
`AddError("<the human-readable message>")` — a client that only reads those three keys today (e.g. Studio's
`ValidationApiExceptionExtensions.GetValidationErrorsFromContent`) keeps working unchanged. `code` and `data` are
additive. `data` is omitted when a code carries none. This deployment does not use FastEndpoints'
`ProblemDetails` response, and this envelope is written by the endpoint itself rather than by replacing
FastEndpoints' process-wide `Config.ErrOpts.ResponseBuilder`, which would have reshaped every endpoint's error
response, not just these — see `Elsa.Bpmn.Interchange.Endpoints.Bpmn.BpmnErrorResponse`'s remarks.
Not every 4xx these endpoints send carries a code: a plain `404 Not Found` for a `definitionId` that does not exist,
a `400 Bad Request` from malformed JSON or an unparseable `VersionOptions`, and a `400 Bad Request` for a document
that names more than one process without saying which, are unchanged and uncoded.
| Code (`Elsa.Bpmn.Interchange.BpmnErrorCodes`) | Sent by | Status | `data` |
| --- | --- | --- | --- |
| `bpmn.import.capability-unsupported` | `POST bpmn/import`, document `PUT` | 422 | `capabilities: string[]` (missing capability names), `elementIds: string[]` (offending element ids, combined across every missing capability) |
| `bpmn.import.binding-invalid` | `POST bpmn/import`, document `PUT` | 422 | — |
| `bpmn.export.not-imported` | `GET .../export`, document `GET` | 422 | — |
| `bpmn.export.source-stale` | `GET .../export`, document `GET` | 422 | — |
| `bpmn.export.source-version-unknown` | `GET .../export`, document `GET` | 422 | — |
| `bpmn.document.not-found` | document `PUT` | 404 | — |
| `bpmn.document.precondition-required` | document `PUT` | 428 | — |
| `bpmn.document.precondition-failed` | document `PUT` | 412 | — |
See each constant's XML doc in `Elsa.Bpmn.Interchange.BpmnErrorCodes` for exactly which situation it names.
`bpmn.export.source-version-unknown` is not reachable through `Import` or the document `PUT` themselves — both
always record a source version alongside the source text — only through custom properties edited or migrated some
other way; it is kept, and coded, as a defence against that combination arising.
## Execution State Persistence
The BPMN interpreter's execution state (`BpmnExecutionState`) and the scope's `BpmnWorkLedger` are both serialized as JSON strings in `ActivityExecutionContext.Properties` when the workflow suspends. The state is pruned before each persist: consumed tokens are removed so the serialized size stays bounded regardless of how many evaluations a long-running scope has processed.

View file

@ -0,0 +1,70 @@
namespace Elsa.Bpmn.Interchange;
/// <summary>
/// Stable, machine-readable codes carried alongside the human-readable message of every BPMN-specific refusal
/// <c>bpmn/import</c>, <c>bpmn/definitions/{id}/export</c> and the <c>bpmn/definitions/{id}/document</c> GET/PUT
/// endpoints send, in the additive error envelope <see cref="Endpoints.Bpmn.BpmnErrorResponse"/> describes.
/// </summary>
/// <remarks>
/// Studio (and any other caller) matches on these codes rather than on the message text, so a rewording of a
/// message never breaks a caller that only reads the code. Treat every value here as a compatibility surface: once
/// published, a code must not be renamed or reused for a different refusal. See doc/wiki/bpmn-workflows.md's REST
/// endpoints section for the endpoint(s) each code is sent from and what "data" (if any) it carries.
/// </remarks>
public static class BpmnErrorCodes
{
/// <summary>
/// <c>bpmn/import</c> and the document <c>PUT</c> refuse a document that needs a BPMN host capability this
/// deployment does not declare. Carries <c>data.capabilities</c> (the missing capability names) and
/// <c>data.elementIds</c> (the offending element ids, combined across every missing capability).
/// </summary>
public const string ImportCapabilityUnsupported = "bpmn.import.capability-unsupported";
/// <summary>
/// <c>bpmn/import</c> and the document <c>PUT</c> refuse a document whose work binding — an
/// <c>elsa:activityBinding</c>, a timer duration, a call activity — cannot be turned into a runnable Elsa
/// activity.
/// </summary>
public const string ImportBindingInvalid = "bpmn.import.binding-invalid";
/// <summary>
/// <c>bpmn/definitions/{id}/export</c> and the document <c>GET</c> refuse a workflow definition that does not
/// currently carry BPMN source — either it was never imported from BPMN, or a later save replaced its custom
/// properties wholesale.
/// </summary>
public const string ExportNotImported = "bpmn.export.not-imported";
/// <summary>
/// <c>bpmn/definitions/{id}/export</c> and the document <c>GET</c> refuse a workflow definition whose stored
/// BPMN source no longer corresponds to it — its version, or (for an unpublished draft saved in place) its
/// activity graph, has moved on since the source was recorded.
/// </summary>
public const string ExportSourceStale = "bpmn.export.source-stale";
/// <summary>
/// <c>bpmn/definitions/{id}/export</c> and the document <c>GET</c> refuse a workflow definition that carries
/// BPMN source but not the definition version it was recorded against, so whether that source is still current
/// cannot be verified. Distinct from <see cref="ExportNotImported"/> (the source text is present) and
/// <see cref="ExportSourceStale"/> (there is no version to compare against yet); not reachable through
/// <c>Import</c> itself, only through custom properties edited or migrated some other way.
/// </summary>
public const string ExportSourceVersionUnknown = "bpmn.export.source-version-unknown";
/// <summary>
/// The document <c>PUT</c> refuses to edit a workflow definition that no longer exists — e.g. it was deleted
/// between the endpoint's own existence/ETag check and the import that follows it.
/// </summary>
public const string DocumentNotFound = "bpmn.document.not-found";
/// <summary>
/// The document <c>PUT</c> requires an <c>If-Match</c> request header carrying the ETag a prior <c>GET</c>
/// returned, and refuses a request that omits it or sends the wildcard <c>*</c>.
/// </summary>
public const string DocumentPreconditionRequired = "bpmn.document.precondition-required";
/// <summary>
/// The document <c>PUT</c> refuses an <c>If-Match</c> header that does not match the workflow definition's
/// current ETag: the definition was written since the caller last read it.
/// </summary>
public const string DocumentPreconditionFailed = "bpmn.document.precondition-failed";
}

View file

@ -20,11 +20,28 @@ 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 missingCapabilities = string.Join(", ", MissingCapabilityNames(exception));
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}.";
}
/// <summary>
/// The structured <c>data</c> the <see cref="BpmnErrorCodes.ImportCapabilityUnsupported"/> response carries
/// alongside <see cref="Format"/>'s message: the missing capability names and the offending element ids, under
/// the same "combined, not attributable to any one capability" caveat <see cref="Format"/>'s remarks explain.
/// </summary>
public static object DataFor(BpmnCapabilityException exception) => new
{
Capabilities = MissingCapabilityNames(exception),
ElementIds = exception.DrivingElementIds
};
private static IReadOnlyList<string> MissingCapabilityNames(BpmnCapabilityException exception) =>
BpmnInterchangeDocumentService.IndividualCapabilities
.Where(capability => exception.Missing.HasFlag(capability))
.Select(capability => capability.ToString())
.ToList();
}

View file

@ -0,0 +1,73 @@
using FastEndpoints;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// The error envelope every BPMN-specific refusal coded through <see cref="BpmnErrorCodes"/> is sent as.
/// </summary>
/// <remarks>
/// <para>
/// FastEndpoints has no way, in this deployment's configuration, to surface a
/// <see cref="FluentValidation.Results.ValidationFailure.ErrorCode"/> in the error response it builds by default:
/// that requires either its <c>ProblemDetails</c> response (which this deployment does not use — see
/// <c>Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator</c>) with its <c>IndicateErrorCode</c> flag set,
/// or replacing <c>Config.ErrOpts.ResponseBuilder</c>, which is a single, process-wide FastEndpoints setting — doing
/// so here would reshape every endpoint's error response in the process, not just these BPMN endpoints.
/// </para>
/// <para>
/// So these endpoints write this response themselves, sent through <see cref="SendAsync"/> instead of
/// FastEndpoints' <c>Send.ErrorsAsync</c>, keeping the same top-level shape FastEndpoints' default
/// <c>ErrorResponse</c> would have sent — <see cref="StatusCode"/>, <see cref="Message"/>, and an
/// <see cref="Errors"/> dictionary with the same <c>generalErrors</c> key <c>AddError(message)</c> groups under —
/// so a caller that only reads <c>message</c>/<c>errors</c> today, such as Elsa Studio's
/// <c>ValidationApiExceptionExtensions.GetValidationErrorsFromContent</c>, keeps seeing exactly what it saw before
/// this envelope's two additive members, <see cref="Code"/> and <see cref="Data"/>, existed.
/// </para>
/// </remarks>
internal sealed class BpmnErrorResponse
{
/// <summary>
/// The key FastEndpoints' own <c>AddError(message)</c> groups a message-only failure under — camelCased from
/// its <c>Config.ErrOpts.GeneralErrorsField</c> default of <c>"GeneralErrors"</c>, which nothing in this
/// deployment overrides (see <c>Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator</c>).
/// </summary>
private const string GeneralErrorsKey = "generalErrors";
/// <summary>The HTTP status code sent to the client.</summary>
public required int StatusCode { get; init; }
/// <summary>The same default message FastEndpoints' own <c>ErrorResponse</c> carries when nothing overrides it.</summary>
public string Message { get; init; } = "One or more errors occurred!";
/// <summary>The same shape FastEndpoints' own <c>ErrorResponse</c> builds from an endpoint's <c>AddError</c> calls.</summary>
public required IReadOnlyDictionary<string, IReadOnlyList<string>> Errors { get; init; }
/// <summary>The stable, machine-readable code identifying this refusal. See <see cref="BpmnErrorCodes"/>.</summary>
public required string Code { get; init; }
/// <summary>Structured data specific to <see cref="Code"/> (e.g. the missing capability names and element ids), or <c>null</c> when the code carries none.</summary>
public object? Data { get; init; }
/// <summary>Builds the response for a single-message refusal, in the same shape <c>AddError(message)</c> would have produced.</summary>
public static BpmnErrorResponse Create(string message, string code, int statusCode, object? data = null) => new()
{
StatusCode = statusCode,
Errors = new Dictionary<string, IReadOnlyList<string>> { [GeneralErrorsKey] = [message] },
Code = code,
Data = data
};
/// <summary>Sends <paramref name="response"/> with its own <see cref="StatusCode"/>.</summary>
/// <remarks>
/// Goes through <see cref="HttpResponse"/>'s own <c>SendAsync</c> extension rather than an endpoint's
/// <c>Send.ErrorsAsync</c>/<c>Send.ResponseAsync</c>, since those build FastEndpoints' own <c>ErrorResponse</c>
/// (see this type's remarks) or require the response type FastEndpoints generated for the calling endpoint's
/// declared success response, neither of which fits an envelope with a <c>code</c> and a <c>data</c> member.
/// <c>HttpResponse.SendAsync</c> still runs through <c>Config.SerOpts.ResponseSerializer</c> — the same
/// <c>IApiSerializer</c>-backed serializer <c>Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator</c>
/// configures for every other response — so this response's JSON casing matches the rest of the API.
/// </remarks>
public static Task SendAsync(HttpResponse httpResponse, BpmnErrorResponse response, CancellationToken cancellationToken) =>
httpResponse.SendAsync(response, response.StatusCode, cancellation: cancellationToken);
}

View file

@ -0,0 +1,63 @@
using Bpmn.Interchange;
using Elsa.Bpmn.Interchange.Exceptions;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn;
/// <summary>
/// The exception-to-response 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 BpmnExportErrorResponses
{
/// <summary>
/// Runs <paramref name="sendResponse"/>, reporting the shared error response for whichever exception it throws.
/// A <see cref="BpmnExportUnavailableException"/> goes out through <paramref name="httpResponse"/> as a
/// <see cref="BpmnErrorResponse"/> coded from its <see cref="BpmnExportUnavailableException.Reason"/>; the
/// remaining, uncoded <see cref="BpmnInterchangeException"/> case still goes through <paramref name="addError"/>
/// and <paramref name="sendErrorsAsync"/>, exactly as before.
/// </summary>
public static async Task RunAsync(
Func<Task> sendResponse,
HttpResponse httpResponse,
Action<string> addError,
Func<int, CancellationToken, Task> sendErrorsAsync,
CancellationToken cancellationToken)
{
try
{
await sendResponse();
}
catch (BpmnExportUnavailableException exception)
{
await BpmnErrorResponse.SendAsync(httpResponse, ResponseFor(exception), cancellationToken);
}
catch (BpmnInterchangeException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
}
}
/// <summary>
/// The response for <paramref name="exception"/>, coded from its <see cref="BpmnExportUnavailableException.Reason"/>,
/// as its own pure, synchronous step so it can be asserted on directly.
/// <see cref="BpmnExportUnavailableReason.SourceVersionUnknown"/> in particular is not reachable through
/// <c>Import</c> or the document <c>PUT</c> themselves — both always record a source version alongside the
/// source text — only through custom properties edited or migrated some other way, so an HTTP-level test cannot
/// reach it either.
/// </summary>
internal static BpmnErrorResponse ResponseFor(BpmnExportUnavailableException exception)
{
var code = exception.Reason switch
{
BpmnExportUnavailableReason.NotImported => BpmnErrorCodes.ExportNotImported,
BpmnExportUnavailableReason.SourceVersionUnknown => BpmnErrorCodes.ExportSourceVersionUnknown,
BpmnExportUnavailableReason.SourceStale => BpmnErrorCodes.ExportSourceStale,
_ => throw new ArgumentOutOfRangeException(nameof(exception), exception.Reason, "Unknown BpmnExportUnavailableReason.")
};
return BpmnErrorResponse.Create(exception.Message, code, StatusCodes.Status422UnprocessableEntity);
}
}

View file

@ -1,39 +0,0 @@
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,98 @@
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-response 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 BpmnImportErrorResponses
{
/// <summary>
/// Runs <paramref name="import"/>, reporting the shared error response for whichever exception it throws, or for
/// an unsuccessful <see cref="BpmnDocumentImportResult.ImportResult"/>. A refusal this type gives a
/// <see cref="BpmnErrorCodes"/> code goes out through <paramref name="httpResponse"/> as a
/// <see cref="BpmnErrorResponse"/>; the remaining, uncoded refusals still go through <paramref name="addError"/>
/// and <paramref name="sendErrorsAsync"/>, exactly as before. 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,
HttpResponse httpResponse,
Action<string> addError,
Func<int, CancellationToken, Task> sendErrorsAsync,
CancellationToken cancellationToken)
{
BpmnDocumentImportResult result;
try
{
result = await import();
}
catch (BpmnDefinitionNotFoundException exception)
{
await BpmnErrorResponse.SendAsync(httpResponse, NotFoundResponseFor(exception), cancellationToken);
return null;
}
catch (BpmnInterchangeException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return null;
}
catch (BpmnBindingException exception)
{
await BpmnErrorResponse.SendAsync(httpResponse, BindingInvalidResponseFor(exception), cancellationToken);
return null;
}
catch (BpmnCapabilityException exception)
{
await BpmnErrorResponse.SendAsync(httpResponse, CapabilityResponseFor(exception), 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;
}
/// <summary>
/// The <see cref="BpmnErrorCodes.DocumentNotFound"/> response for <paramref name="exception"/>, as its own pure,
/// synchronous step so it can be asserted on directly: the endpoints that can throw
/// <see cref="BpmnDefinitionNotFoundException"/> already refuse the ordinary "no such definition" case earlier,
/// through their own existence check, so reaching this from a real request needs the definition to be deleted in
/// the narrow window between that check and the import call this wraps — not something an HTTP-level test can
/// reliably force without a race.
/// </summary>
internal static BpmnErrorResponse NotFoundResponseFor(BpmnDefinitionNotFoundException exception) =>
BpmnErrorResponse.Create(exception.Message, BpmnErrorCodes.DocumentNotFound, StatusCodes.Status404NotFound);
/// <summary>The <see cref="BpmnErrorCodes.ImportBindingInvalid"/> response for <paramref name="exception"/>.</summary>
internal static BpmnErrorResponse BindingInvalidResponseFor(BpmnBindingException exception) =>
BpmnErrorResponse.Create(exception.Message, BpmnErrorCodes.ImportBindingInvalid, StatusCodes.Status422UnprocessableEntity);
/// <summary>
/// The <see cref="BpmnErrorCodes.ImportCapabilityUnsupported"/> response for <paramref name="exception"/>, as its
/// own pure, synchronous step so it can be asserted on directly: <c>Bpmn.*</c> 0.2.0 declares every capability
/// this deployment's runtime needs, so nothing in <c>Elsa.Bpmn.Interchange.IntegrationTests</c> can currently
/// make a real import throw <see cref="BpmnCapabilityException"/> to exercise this through the endpoint itself.
/// </summary>
internal static BpmnErrorResponse CapabilityResponseFor(BpmnCapabilityException exception) =>
BpmnErrorResponse.Create(
BpmnCapabilityErrorFormatter.Format(exception),
BpmnErrorCodes.ImportCapabilityUnsupported,
StatusCodes.Status422UnprocessableEntity,
BpmnCapabilityErrorFormatter.DataFor(exception));
}

View file

@ -1,70 +0,0 @@
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 (BpmnDefinitionNotFoundException exception)
{
addError(exception.Message);
await sendErrorsAsync(StatusCodes.Status404NotFound, cancellationToken);
return null;
}
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

@ -51,7 +51,7 @@ internal sealed class Get(IWorkflowDefinitionStore store, BpmnInterchangeDocumen
return;
}
await BpmnExportExceptionCascade.RunAsync(
await BpmnExportErrorResponses.RunAsync(
async () =>
{
var document = documentService.ReadDocument(definition);
@ -59,6 +59,7 @@ internal sealed class Get(IWorkflowDefinitionStore store, BpmnInterchangeDocumen
HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(definition);
await Send.StringAsync(json, contentType: MediaTypeNames.Application.Json, cancellation: cancellationToken);
},
HttpContext.Response,
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);

View file

@ -59,15 +59,25 @@ internal sealed class Put(IWorkflowDefinitionStore store, BpmnInterchangeDocumen
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);
await BpmnErrorResponse.SendAsync(
HttpContext.Response,
BpmnErrorResponse.Create(
"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.",
BpmnErrorCodes.DocumentPreconditionRequired,
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);
await BpmnErrorResponse.SendAsync(
HttpContext.Response,
BpmnErrorResponse.Create(
"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.",
BpmnErrorCodes.DocumentPreconditionFailed,
StatusCodes.Status412PreconditionFailed),
cancellationToken);
return;
}
@ -103,8 +113,9 @@ internal sealed class Put(IWorkflowDefinitionStore store, BpmnInterchangeDocumen
? storedProcessId
: null;
var result = await BpmnImportExceptionCascade.RunAsync(
var result = await BpmnImportErrorResponses.RunAsync(
() => documentService.ImportDocumentAsync(document, definitionId, processId, cancellationToken),
HttpContext.Response,
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);

View file

@ -63,12 +63,13 @@ internal sealed class Export(IWorkflowDefinitionStore store, BpmnInterchangeDocu
return;
}
await BpmnExportExceptionCascade.RunAsync(
await BpmnExportErrorResponses.RunAsync(
async () =>
{
var bytes = documentService.Export(definition);
await Send.BytesAsync(bytes, $"{request.DefinitionId}.bpmn", "application/xml", cancellation: cancellationToken);
},
HttpContext.Response,
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);

View file

@ -40,8 +40,9 @@ internal sealed class Import(BpmnInterchangeDocumentService documentService) : E
var xml = await BpmnUploadedFileReader.ReadTextAsync(Files[0], cancellationToken);
var result = await BpmnImportExceptionCascade.RunAsync(
var result = await BpmnImportErrorResponses.RunAsync(
() => documentService.ImportAsync(xml, request.DefinitionId, request.Name, request.ProcessId, cancellationToken),
HttpContext.Response,
message => AddError(message),
Send.ErrorsAsync,
cancellationToken);

View file

@ -5,8 +5,42 @@ namespace Elsa.Bpmn.Interchange.Exceptions;
/// need to write back out is missing or no longer trustworthy.
/// </summary>
/// <remarks>
/// See <see cref="Services.BpmnInterchangeDocumentService"/>'s remarks for the two distinct situations this covers —
/// a definition never imported from BPMN (or one whose source a later save dropped), and a definition that has
/// changed since it was imported — and why each gets its own message rather than one generic refusal.
/// See <see cref="Services.BpmnInterchangeDocumentService"/>'s remarks for the distinct situations this covers — a
/// definition never imported from BPMN (or one whose source a later save dropped), one whose source predates the
/// version marker needed to check it is still current, and one that has changed since it was imported — and why
/// each gets its own message and its own <see cref="Reason"/>, which is what
/// <c>Endpoints.Bpmn.BpmnExportErrorResponses</c> maps to the response's <see cref="Elsa.Bpmn.Interchange.BpmnErrorCodes"/> code
/// without having to parse the message.
/// </remarks>
public class BpmnExportUnavailableException(string message) : Exception(message);
public class BpmnExportUnavailableException : Exception
{
/// <summary>Creates the exception with <see cref="BpmnExportUnavailableReason.NotImported"/> as its reason.</summary>
public BpmnExportUnavailableException(string message) : this(message, BpmnExportUnavailableReason.NotImported)
{
}
/// <summary>Creates the exception with an explicit <paramref name="reason"/>.</summary>
public BpmnExportUnavailableException(string message, BpmnExportUnavailableReason reason) : base(message)
{
Reason = reason;
}
/// <summary>Which of the situations this type's remarks describe <see cref="Exception.Message"/> reports.</summary>
public BpmnExportUnavailableReason Reason { get; }
}
/// <summary>The distinct situations a <see cref="BpmnExportUnavailableException"/> reports.</summary>
public enum BpmnExportUnavailableReason
{
/// <summary>The definition does not currently carry BPMN source at all.</summary>
NotImported,
/// <summary>
/// The definition carries BPMN source but not the definition version it was recorded against, so whether that
/// source is still current cannot be verified.
/// </summary>
SourceVersionUnknown,
/// <summary>The definition has changed — by version, or (for an unpublished draft) by activity graph — since the source was recorded.</summary>
SourceStale
}

View file

@ -385,7 +385,8 @@ public sealed class BpmnInterchangeDocumentService(
throw new BpmnExportUnavailableException(
$"Workflow definition '{definition.DefinitionId}' does not currently carry BPMN source, so it cannot be exported as BPMN 2.0 XML. "
+ "Either it was never imported from a BPMN document, or a later save replaced its custom properties wholesale and removed the "
+ $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else.");
+ $"'{SourceXmlCustomPropertyKey}' entry as a side effect of editing something else.",
BpmnExportUnavailableReason.NotImported);
}
if (!definition.CustomProperties.TryGetValue<int>(SourceVersionCustomPropertyKey, out var sourceVersion))
@ -400,7 +401,8 @@ public sealed class BpmnInterchangeDocumentService(
$"Workflow definition '{definition.DefinitionId}' carries BPMN source, but not the definition version it was recorded against, so "
+ "whether that source still matches this definition cannot be verified. It does not mean this definition was never imported from "
+ "BPMN, and it does not mean the source is stale — there is simply no version recorded to compare against. Re-import the document to "
+ "record a complete, exportable source.");
+ "record a complete, exportable source.",
BpmnExportUnavailableReason.SourceVersionUnknown);
}
if (sourceVersion != definition.Version)
@ -408,7 +410,8 @@ public sealed class BpmnInterchangeDocumentService(
throw new BpmnExportUnavailableException(
$"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN (imported at version {sourceVersion}, "
+ $"currently at version {definition.Version}). The BPMN source stored on it no longer corresponds to this definition, so exporting it "
+ "would silently return a document that is not what this definition currently is.");
+ "would silently return a document that is not what this definition currently is.",
BpmnExportUnavailableReason.SourceStale);
}
// The version check above catches a publish or any other save that assigns a new version, but an unpublished
@ -424,7 +427,8 @@ public sealed class BpmnInterchangeDocumentService(
$"Workflow definition '{definition.DefinitionId}' has changed since it was imported from BPMN: its activity graph no longer matches "
+ "the graph the stored source was imported against, even though its version has not changed (an unpublished draft is saved in place). "
+ "The BPMN source stored on it no longer corresponds to this definition, so exporting it would silently return a document that is "
+ "not what this definition currently is.");
+ "not what this definition currently is.",
BpmnExportUnavailableReason.SourceStale);
}
return xml;

View file

@ -143,6 +143,9 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
var response = await PostAuthenticatedAsync("bpmn/import", content, "workflows/definitions:write");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ImportBindingInvalid, CodeOf(body));
Assert.Contains("nothing binds it to an Elsa activity", body);
}
[Fact]
@ -176,9 +179,24 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ExportNotImported, CodeOf(body));
Assert.Contains("does not currently carry BPMN source", body);
}
[Fact]
public async Task Export_OfADefinitionWithAChangedGraphAfterADesignerSave_ReturnsUnprocessableEntityCodedAsStale()
{
var definitionId = await ImportCamundaOrderProcessAsync();
await SaveDraftFromTheDesignerAsync(definitionId);
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/export", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ExportSourceStale, CodeOf(body));
Assert.Contains("has changed since it was imported", body);
}
[Fact]
public async Task Export_WithAMalformedVersion_ReturnsBadRequest()
{
@ -245,9 +263,24 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ExportNotImported, CodeOf(body));
Assert.Contains("does not currently carry BPMN source", body);
}
[Fact]
public async Task DocumentGet_OfADefinitionWithAChangedGraphAfterADesignerSave_ReturnsUnprocessableEntityCodedAsStale()
{
var definitionId = await ImportCamundaOrderProcessAsync();
await SaveDraftFromTheDesignerAsync(definitionId);
var response = await GetAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", "workflows/definitions:view");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ExportSourceStale, CodeOf(body));
Assert.Contains("has changed since it was imported", body);
}
[Fact]
public async Task DocumentGet_OfAFreshlyImportedDefinition_ReturnsOkWithTheLibraryFormatDocument()
{
@ -294,6 +327,7 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
var response = await PutAuthenticatedAsync($"bpmn/definitions/{definitionId}/document", content, null, "workflows/definitions:write");
Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(BpmnErrorCodes.DocumentPreconditionRequired, CodeOf(await response.Content.ReadAsStringAsync()));
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
@ -309,6 +343,7 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
var response = await PutDocumentAsync(definitionId, WithFirstShapeMoved(documentJson), "*");
Assert.Equal(HttpStatusCode.PreconditionRequired, response.StatusCode);
Assert.Equal(BpmnErrorCodes.DocumentPreconditionRequired, CodeOf(await response.Content.ReadAsStringAsync()));
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
}
@ -444,6 +479,7 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
Assert.Equal(HttpStatusCode.UnprocessableEntity, putResponse.StatusCode);
var body = await putResponse.Content.ReadAsStringAsync();
Assert.Equal(BpmnErrorCodes.ImportBindingInvalid, CodeOf(body));
Assert.Contains("nothing binds it to an Elsa activity", body);
Assert.Equal(versionBeforePut, await LatestVersionOfAsync(definitionId));
}
@ -674,6 +710,13 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
/// <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>The <c>code</c> field of a coded BPMN error response body (see <see cref="BpmnErrorCodes"/>), or <c>null</c> if it carries none.</summary>
private static string? CodeOf(string body)
{
using var document = JsonDocument.Parse(body);
return document.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
/// <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)
{
@ -697,6 +740,7 @@ public class BpmnInterchangeEndpointTests(ITestOutputHelper testOutputHelper) :
var response = await PutDocumentAsync(definitionId, documentJson, staleETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode);
Assert.Equal(BpmnErrorCodes.DocumentPreconditionFailed, CodeOf(await response.Content.ReadAsStringAsync()));
Assert.Equal(storedBeforePut, await LatestStoredAsync(definitionId));
}

View file

@ -0,0 +1,98 @@
using Bpmn.Model;
using Bpmn.Semantics;
using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
using Elsa.Bpmn.Interchange.Exceptions;
using Elsa.Bpmn.Interchange.Services;
using Microsoft.AspNetCore.Http;
namespace Elsa.Bpmn.Interchange.UnitTests;
/// <summary>
/// The BPMN endpoints' exception-to-response mapping (<see cref="BpmnImportErrorResponses"/>,
/// <see cref="BpmnExportErrorResponses"/>) turns a thrown exception into a stable <see cref="BpmnErrorCodes"/> code
/// and, for the capability refusal, structured data — exercised here at the mapping itself, the same way
/// <see cref="BpmnInterchangeDocumentServiceCapabilityTests"/> exercises capability refusal directly, rather than
/// through an HTTP round trip: <c>Bpmn.*</c> 0.2.0 declares every capability this deployment's runtime needs, so a
/// document a real import refuses on capability grounds cannot be produced through the public API today. The same is
/// true of <see cref="BpmnDefinitionNotFoundException"/> (only reachable through a race the endpoints' own
/// existence checks close off) and <see cref="BpmnExportUnavailableReason.SourceVersionUnknown"/> (only reachable
/// through custom properties edited outside <c>ImportAsync</c>). See
/// <c>Elsa.Bpmn.Interchange.IntegrationTests.Endpoints.BpmnInterchangeEndpointTests</c> for the codes reachable
/// through a real HTTP request.
/// </summary>
public class BpmnErrorResponseMappingTests
{
[Fact(DisplayName = "A capability refusal is coded bpmn.import.capability-unsupported, carrying the missing capability names and driving element ids as data")]
public void CapabilityResponseFor_CarriesTheCodeAndTheStructuredData()
{
var definition = MultiInstanceDefinition("main", "each");
var exception = Assert.Throws<BpmnCapabilityException>(() =>
BpmnInterchangeDocumentService.EnsureCapabilitiesSatisfied(definition, [], BpmnHostCapabilities.None));
var response = BpmnImportErrorResponses.CapabilityResponseFor(exception);
Assert.Equal(BpmnErrorCodes.ImportCapabilityUnsupported, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
var message = Assert.Single(response.Errors["generalErrors"]);
Assert.Contains("IterationScopes", message);
Assert.Contains("each", message);
dynamic data = response.Data!;
Assert.Equal(new[] { "IterationScopes" }, (IReadOnlyList<string>)data.Capabilities);
Assert.Equal(new[] { "each" }, (IReadOnlyList<string>)data.ElementIds);
}
[Fact(DisplayName = "A binding refusal is coded bpmn.import.binding-invalid, with no structured data, keeping the exception's own message")]
public void BindingInvalidResponseFor_CarriesTheCodeAndTheOriginalMessage()
{
var exception = new BpmnBindingException("BPMN element 'task-1' declares no binding.");
var response = BpmnImportErrorResponses.BindingInvalidResponseFor(exception);
Assert.Equal(BpmnErrorCodes.ImportBindingInvalid, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
[Fact(DisplayName = "A definition-not-found refusal is coded bpmn.document.not-found")]
public void NotFoundResponseFor_CarriesTheCode()
{
var exception = new BpmnDefinitionNotFoundException("Workflow definition 'def-1' does not exist, so its BPMN document cannot be edited.");
var response = BpmnImportErrorResponses.NotFoundResponseFor(exception);
Assert.Equal(BpmnErrorCodes.DocumentNotFound, response.Code);
Assert.Equal(StatusCodes.Status404NotFound, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
[Theory(DisplayName = "Each BpmnExportUnavailableReason maps to its own code")]
[InlineData(BpmnExportUnavailableReason.NotImported, BpmnErrorCodes.ExportNotImported)]
[InlineData(BpmnExportUnavailableReason.SourceVersionUnknown, BpmnErrorCodes.ExportSourceVersionUnknown)]
[InlineData(BpmnExportUnavailableReason.SourceStale, BpmnErrorCodes.ExportSourceStale)]
public void ResponseFor_MapsEachReasonToItsOwnCode(BpmnExportUnavailableReason reason, string expectedCode)
{
var exception = new BpmnExportUnavailableException("Workflow definition 'def-1' cannot be exported.", reason);
var response = BpmnExportErrorResponses.ResponseFor(exception);
Assert.Equal(expectedCode, response.Code);
Assert.Equal(StatusCodes.Status422UnprocessableEntity, response.StatusCode);
Assert.Equal(exception.Message, Assert.Single(response.Errors["generalErrors"]));
Assert.Null(response.Data);
}
private static BpmnProcessDefinition MultiInstanceDefinition(string processId, string elementId)
{
var element = new BpmnElement(
elementId,
BpmnElementTypes.ServiceTask,
bindingRef: $"node-{elementId}",
loopCharacteristics: new BpmnLoopCharacteristics(isSequential: false, cardinality: 3));
return new BpmnProcessDefinition(processId, Elements: [element]);
}
}