From 3bee5666570ea465089d879055e154df2314c359 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 12 Sep 2026 00:10:12 -0700 Subject: [PATCH] feat(bpmn): give BPMN interchange refusals stable error codes (#8067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 5 --- doc/wiki/bpmn-workflows.md | 48 +++++++++ .../Elsa.Bpmn.Interchange/BpmnErrorCodes.cs | 70 +++++++++++++ .../Bpmn/BpmnCapabilityErrorFormatter.cs | 19 +++- .../Endpoints/Bpmn/BpmnErrorResponse.cs | 73 ++++++++++++++ .../Bpmn/BpmnExportErrorResponses.cs | 63 ++++++++++++ .../Bpmn/BpmnExportExceptionCascade.cs | 39 -------- .../Bpmn/BpmnImportErrorResponses.cs | 98 +++++++++++++++++++ .../Bpmn/BpmnImportExceptionCascade.cs | 70 ------------- .../Endpoints/Bpmn/Document/Get/Endpoint.cs | 3 +- .../Endpoints/Bpmn/Document/Put/Endpoint.cs | 21 +++- .../Endpoints/Bpmn/Export/Endpoint.cs | 3 +- .../Endpoints/Bpmn/Import/Endpoint.cs | 3 +- .../BpmnExportUnavailableException.cs | 42 +++++++- .../BpmnInterchangeDocumentService.cs | 12 ++- .../Endpoints/BpmnInterchangeEndpointTests.cs | 44 +++++++++ .../BpmnErrorResponseMappingTests.cs | 98 +++++++++++++++++++ 16 files changed, 580 insertions(+), 126 deletions(-) create mode 100644 src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs create mode 100644 src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnErrorResponse.cs create mode 100644 src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportErrorResponses.cs delete mode 100644 src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs create mode 100644 src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportErrorResponses.cs delete mode 100644 src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs create mode 100644 test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnErrorResponseMappingTests.cs diff --git a/doc/wiki/bpmn-workflows.md b/doc/wiki/bpmn-workflows.md index d1c326631..15e6a2fcd 100644 --- a/doc/wiki/bpmn-workflows.md +++ b/doc/wiki/bpmn-workflows.md @@ -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": [""] }, + "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("")` — 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. diff --git a/src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs b/src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs new file mode 100644 index 000000000..98c38b31a --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs @@ -0,0 +1,70 @@ +namespace Elsa.Bpmn.Interchange; + +/// +/// Stable, machine-readable codes carried alongside the human-readable message of every BPMN-specific refusal +/// bpmn/import, bpmn/definitions/{id}/export and the bpmn/definitions/{id}/document GET/PUT +/// endpoints send, in the additive error envelope describes. +/// +/// +/// 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. +/// +public static class BpmnErrorCodes +{ + /// + /// bpmn/import and the document PUT refuse a document that needs a BPMN host capability this + /// deployment does not declare. Carries data.capabilities (the missing capability names) and + /// data.elementIds (the offending element ids, combined across every missing capability). + /// + public const string ImportCapabilityUnsupported = "bpmn.import.capability-unsupported"; + + /// + /// bpmn/import and the document PUT refuse a document whose work binding — an + /// elsa:activityBinding, a timer duration, a call activity — cannot be turned into a runnable Elsa + /// activity. + /// + public const string ImportBindingInvalid = "bpmn.import.binding-invalid"; + + /// + /// bpmn/definitions/{id}/export and the document GET 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. + /// + public const string ExportNotImported = "bpmn.export.not-imported"; + + /// + /// bpmn/definitions/{id}/export and the document GET 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. + /// + public const string ExportSourceStale = "bpmn.export.source-stale"; + + /// + /// bpmn/definitions/{id}/export and the document GET 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 (the source text is present) and + /// (there is no version to compare against yet); not reachable through + /// Import itself, only through custom properties edited or migrated some other way. + /// + public const string ExportSourceVersionUnknown = "bpmn.export.source-version-unknown"; + + /// + /// The document PUT 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. + /// + public const string DocumentNotFound = "bpmn.document.not-found"; + + /// + /// The document PUT requires an If-Match request header carrying the ETag a prior GET + /// returned, and refuses a request that omits it or sends the wildcard *. + /// + public const string DocumentPreconditionRequired = "bpmn.document.precondition-required"; + + /// + /// The document PUT refuses an If-Match header that does not match the workflow definition's + /// current ETag: the definition was written since the caller last read it. + /// + public const string DocumentPreconditionFailed = "bpmn.document.precondition-failed"; +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs index 0cf102f85..3ad6943ab 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnCapabilityErrorFormatter.cs @@ -20,11 +20,28 @@ internal static class BpmnCapabilityErrorFormatter /// The message an endpoint reports for . 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}."; } + + /// + /// The structured data the response carries + /// alongside 's message: the missing capability names and the offending element ids, under + /// the same "combined, not attributable to any one capability" caveat 's remarks explain. + /// + public static object DataFor(BpmnCapabilityException exception) => new + { + Capabilities = MissingCapabilityNames(exception), + ElementIds = exception.DrivingElementIds + }; + + private static IReadOnlyList MissingCapabilityNames(BpmnCapabilityException exception) => + BpmnInterchangeDocumentService.IndividualCapabilities + .Where(capability => exception.Missing.HasFlag(capability)) + .Select(capability => capability.ToString()) + .ToList(); } diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnErrorResponse.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnErrorResponse.cs new file mode 100644 index 000000000..791c4a852 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnErrorResponse.cs @@ -0,0 +1,73 @@ +using FastEndpoints; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// The error envelope every BPMN-specific refusal coded through is sent as. +/// +/// +/// +/// FastEndpoints has no way, in this deployment's configuration, to surface a +/// in the error response it builds by default: +/// that requires either its ProblemDetails response (which this deployment does not use — see +/// Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator) with its IndicateErrorCode flag set, +/// or replacing Config.ErrOpts.ResponseBuilder, 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. +/// +/// +/// So these endpoints write this response themselves, sent through instead of +/// FastEndpoints' Send.ErrorsAsync, keeping the same top-level shape FastEndpoints' default +/// ErrorResponse would have sent — , , and an +/// dictionary with the same generalErrors key AddError(message) groups under — +/// so a caller that only reads message/errors today, such as Elsa Studio's +/// ValidationApiExceptionExtensions.GetValidationErrorsFromContent, keeps seeing exactly what it saw before +/// this envelope's two additive members, and , existed. +/// +/// +internal sealed class BpmnErrorResponse +{ + /// + /// The key FastEndpoints' own AddError(message) groups a message-only failure under — camelCased from + /// its Config.ErrOpts.GeneralErrorsField default of "GeneralErrors", which nothing in this + /// deployment overrides (see Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator). + /// + private const string GeneralErrorsKey = "generalErrors"; + + /// The HTTP status code sent to the client. + public required int StatusCode { get; init; } + + /// The same default message FastEndpoints' own ErrorResponse carries when nothing overrides it. + public string Message { get; init; } = "One or more errors occurred!"; + + /// The same shape FastEndpoints' own ErrorResponse builds from an endpoint's AddError calls. + public required IReadOnlyDictionary> Errors { get; init; } + + /// The stable, machine-readable code identifying this refusal. See . + public required string Code { get; init; } + + /// Structured data specific to (e.g. the missing capability names and element ids), or null when the code carries none. + public object? Data { get; init; } + + /// Builds the response for a single-message refusal, in the same shape AddError(message) would have produced. + public static BpmnErrorResponse Create(string message, string code, int statusCode, object? data = null) => new() + { + StatusCode = statusCode, + Errors = new Dictionary> { [GeneralErrorsKey] = [message] }, + Code = code, + Data = data + }; + + /// Sends with its own . + /// + /// Goes through 's own SendAsync extension rather than an endpoint's + /// Send.ErrorsAsync/Send.ResponseAsync, since those build FastEndpoints' own ErrorResponse + /// (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 code and a data member. + /// HttpResponse.SendAsync still runs through Config.SerOpts.ResponseSerializer — the same + /// IApiSerializer-backed serializer Elsa.FastEndpointConfigurators.ElsaFastEndpointsConfigurator + /// configures for every other response — so this response's JSON casing matches the rest of the API. + /// + public static Task SendAsync(HttpResponse httpResponse, BpmnErrorResponse response, CancellationToken cancellationToken) => + httpResponse.SendAsync(response, response.StatusCode, cancellation: cancellationToken); +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportErrorResponses.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportErrorResponses.cs new file mode 100644 index 000000000..ad0f9b4b4 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportErrorResponses.cs @@ -0,0 +1,63 @@ +using Bpmn.Interchange; +using Elsa.Bpmn.Interchange.Exceptions; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; + +/// +/// The exception-to-response mapping shared by every endpoint that reads a workflow definition's stored BPMN source +/// back out through BpmnInterchangeDocumentServiceExport and the document Get endpoint — +/// refusing the same "missing" and "stale" cases the same way. +/// +internal static class BpmnExportErrorResponses +{ + /// + /// Runs , reporting the shared error response for whichever exception it throws. + /// A goes out through as a + /// coded from its ; the + /// remaining, uncoded case still goes through + /// and , exactly as before. + /// + public static async Task RunAsync( + Func sendResponse, + HttpResponse httpResponse, + Action addError, + Func 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); + } + } + + /// + /// The response for , coded from its , + /// as its own pure, synchronous step so it can be asserted on directly. + /// in particular 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, so an HTTP-level test cannot + /// reach it either. + /// + 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); + } +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs deleted file mode 100644 index ab89bb4d2..000000000 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnExportExceptionCascade.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Bpmn.Interchange; -using Elsa.Bpmn.Interchange.Exceptions; -using Microsoft.AspNetCore.Http; - -namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn; - -/// -/// The exception-to-status-code mapping shared by every endpoint that reads a workflow definition's stored BPMN -/// source back out through BpmnInterchangeDocumentServiceExport and the document Get -/// endpoint — refusing the same "missing" and "stale" cases the same way. -/// -internal static class BpmnExportExceptionCascade -{ - /// - /// Runs , reporting the shared error response through - /// and for whichever exception it throws. - /// - public static async Task RunAsync( - Func sendResponse, - Action addError, - Func 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); - } - } -} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportErrorResponses.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportErrorResponses.cs new file mode 100644 index 000000000..6229e84f8 --- /dev/null +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportErrorResponses.cs @@ -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; + +/// +/// The exception-to-response mapping shared by every endpoint that imports a BPMN document through +/// BpmnInterchangeDocumentServiceImport and the document Put endpoint — plus the identical +/// handling both give a result whose ImportResult did not succeed. +/// +internal static class BpmnImportErrorResponses +{ + /// + /// Runs , reporting the shared error response for whichever exception it throws, or for + /// an unsuccessful . A refusal this type gives a + /// code goes out through as a + /// ; the remaining, uncoded refusals still go through + /// and , exactly as before. Returns null in every case that already sent + /// a response; the caller sends its own success response otherwise. + /// + public static async Task RunAsync( + Func> import, + HttpResponse httpResponse, + Action addError, + Func 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; + } + + /// + /// The response for , as its own pure, + /// synchronous step so it can be asserted on directly: the endpoints that can throw + /// 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. + /// + internal static BpmnErrorResponse NotFoundResponseFor(BpmnDefinitionNotFoundException exception) => + BpmnErrorResponse.Create(exception.Message, BpmnErrorCodes.DocumentNotFound, StatusCodes.Status404NotFound); + + /// The response for . + internal static BpmnErrorResponse BindingInvalidResponseFor(BpmnBindingException exception) => + BpmnErrorResponse.Create(exception.Message, BpmnErrorCodes.ImportBindingInvalid, StatusCodes.Status422UnprocessableEntity); + + /// + /// The response for , as its + /// own pure, synchronous step so it can be asserted on directly: Bpmn.* 0.2.0 declares every capability + /// this deployment's runtime needs, so nothing in Elsa.Bpmn.Interchange.IntegrationTests can currently + /// make a real import throw to exercise this through the endpoint itself. + /// + internal static BpmnErrorResponse CapabilityResponseFor(BpmnCapabilityException exception) => + BpmnErrorResponse.Create( + BpmnCapabilityErrorFormatter.Format(exception), + BpmnErrorCodes.ImportCapabilityUnsupported, + StatusCodes.Status422UnprocessableEntity, + BpmnCapabilityErrorFormatter.DataFor(exception)); +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs deleted file mode 100644 index 40ef20fd0..000000000 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/BpmnImportExceptionCascade.cs +++ /dev/null @@ -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; - -/// -/// The exception-to-status-code mapping shared by every endpoint that imports a BPMN document through -/// BpmnInterchangeDocumentServiceImport and the document Put endpoint — plus the identical -/// handling both give a result whose ImportResult did not succeed. -/// -internal static class BpmnImportExceptionCascade -{ - /// - /// Runs , reporting the shared error response for whichever exception it throws, or for - /// an unsuccessful , through and - /// . Returns null in every case that already sent a response; the caller - /// sends its own success response otherwise. - /// - public static async Task RunAsync( - Func> import, - Action addError, - Func 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; - } -} diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs index 0b3cf843d..f3195a637 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Get/Endpoint.cs @@ -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); diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs index e77bdcc38..204e1df20 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs @@ -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); diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs index fc141c662..fecabaf8a 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Export/Endpoint.cs @@ -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); diff --git a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs index a1272d0df..e7b820a4a 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Import/Endpoint.cs @@ -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); diff --git a/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnExportUnavailableException.cs b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnExportUnavailableException.cs index 49064e5d9..713d4edd6 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnExportUnavailableException.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Exceptions/BpmnExportUnavailableException.cs @@ -5,8 +5,42 @@ namespace Elsa.Bpmn.Interchange.Exceptions; /// need to write back out is missing or no longer trustworthy. /// /// -/// See '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 '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 , which is what +/// Endpoints.Bpmn.BpmnExportErrorResponses maps to the response's code +/// without having to parse the message. /// -public class BpmnExportUnavailableException(string message) : Exception(message); +public class BpmnExportUnavailableException : Exception +{ + /// Creates the exception with as its reason. + public BpmnExportUnavailableException(string message) : this(message, BpmnExportUnavailableReason.NotImported) + { + } + + /// Creates the exception with an explicit . + public BpmnExportUnavailableException(string message, BpmnExportUnavailableReason reason) : base(message) + { + Reason = reason; + } + + /// Which of the situations this type's remarks describe reports. + public BpmnExportUnavailableReason Reason { get; } +} + +/// The distinct situations a reports. +public enum BpmnExportUnavailableReason +{ + /// The definition does not currently carry BPMN source at all. + NotImported, + + /// + /// The definition carries BPMN source but not the definition version it was recorded against, so whether that + /// source is still current cannot be verified. + /// + SourceVersionUnknown, + + /// The definition has changed — by version, or (for an unpublished draft) by activity graph — since the source was recorded. + SourceStale +} diff --git a/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs b/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs index 9e403278b..fe6013a09 100644 --- a/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs +++ b/src/modules/Elsa.Bpmn.Interchange/Services/BpmnInterchangeDocumentService.cs @@ -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(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; diff --git a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs index 1a0428839..6ffd2a056 100644 --- a/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs +++ b/test/integration/Elsa.Bpmn.Interchange.IntegrationTests/Endpoints/BpmnInterchangeEndpointTests.cs @@ -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) : /// The ETag a prior document GET or PUT response carried, for use as the next PUT's If-Match. private static string? ETagOf(HttpResponseMessage response) => response.Headers.ETag?.Tag; + /// The code field of a coded BPMN error response body (see ), or null if it carries none. + private static string? CodeOf(string body) + { + using var document = JsonDocument.Parse(body); + return document.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; + } + /// GETs the document of , asserting it succeeded, and returns its ETag and body. 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)); } diff --git a/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnErrorResponseMappingTests.cs b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnErrorResponseMappingTests.cs new file mode 100644 index 000000000..87dd4d5c8 --- /dev/null +++ b/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnErrorResponseMappingTests.cs @@ -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; + +/// +/// The BPMN endpoints' exception-to-response mapping (, +/// ) turns a thrown exception into a stable code +/// and, for the capability refusal, structured data — exercised here at the mapping itself, the same way +/// exercises capability refusal directly, rather than +/// through an HTTP round trip: Bpmn.* 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 (only reachable through a race the endpoints' own +/// existence checks close off) and (only reachable +/// through custom properties edited outside ImportAsync). See +/// Elsa.Bpmn.Interchange.IntegrationTests.Endpoints.BpmnInterchangeEndpointTests for the codes reachable +/// through a real HTTP request. +/// +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(() => + 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)data.Capabilities); + Assert.Equal(new[] { "each" }, (IReadOnlyList)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]); + } +}