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:
parent
e54ced5662
commit
3bee566657
|
|
@ -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.
|
||||
|
|
|
|||
70
src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs
Normal file
70
src/modules/Elsa.Bpmn.Interchange/BpmnErrorCodes.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue