< Summary

Information
Class: Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Put.Put
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs
Line coverage
95%
Covered lines: 58
Uncovered lines: 3
Coverable lines: 61
Total lines: 143
Line coverage: 95%
Branch coverage
94%
Covered branches: 17
Total branches: 18
Branch coverage: 94.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Configure()100%11100%
HandleAsync()94.44%181894.54%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Endpoints/Bpmn/Document/Put/Endpoint.cs

#LineLine coverage
 1using System.Text.Json;
 2using Elsa.Authorization;
 3using Bpmn.Model;
 4using Elsa.Abstractions;
 5using Elsa.Bpmn.Interchange.Endpoints.Bpmn;
 6using Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import;
 7using Elsa.Bpmn.Interchange.Services;
 8using Elsa.Common.Models;
 9using Elsa.Extensions;
 10using Elsa.Workflows.Management;
 11using Elsa.Workflows.Models;
 12using JetBrains.Annotations;
 13using Microsoft.AspNetCore.Http;
 14
 15namespace Elsa.Bpmn.Interchange.Endpoints.Bpmn.Document.Put;
 16
 17/// <summary>
 18/// Accepts the whole <c>bpmnDefinitions</c> JSON document for an existing workflow definition, writes it back out as
 19/// BPMN 2.0 XML, and imports it through the same path <c>Endpoints.Bpmn.Import.Import</c> runs — analyze, capability
 20/// check, bind, persist as a draft, refresh the stored source. A published version is never edited in place: like
 21/// <c>Import</c>, this always produces a new draft.
 22/// </summary>
 23/// <remarks>
 24/// A thin wrapper over <see cref="BpmnInterchangeDocumentService.ImportDocumentAsync"/>. The request body is bound as
 25/// a raw string and deserialized explicitly with <see cref="BpmnDocumentJsonOptions"/>, not through FastEndpoints'
 26/// configured serializer — see that type's remarks for why the two disagree on shape, and
 27/// <c>Endpoints.Bpmn.Document.Get.Get</c> for the read side of this round trip.
 28/// </remarks>
 29[UsedImplicitly]
 7630internal sealed class Put(IWorkflowDefinitionStore store, BpmnInterchangeDocumentService documentService) : ElsaEndpoint
 31{
 32    /// <inheritdoc />
 33    public override void Configure()
 34    {
 4635        Put("bpmn/definitions/{definitionId}/document");
 4636        RequirePermission(Elsa.Bpmn.Interchange.Permissions.BpmnPermissions.Definitions, CoreVerbs.Write);
 4637    }
 38
 39    /// <inheritdoc />
 40    public override async Task HandleAsync(CancellationToken cancellationToken)
 41    {
 3042        var definitionId = Route<string>("definitionId")!;
 3043        var filter = WorkflowDefinitionHandle.ByDefinitionId(definitionId, VersionOptions.Latest).ToFilter();
 3044        var definition = await store.FindAsync(filter, cancellationToken);
 45
 3046        if (definition == null)
 47        {
 148            await Send.NotFoundAsync(cancellationToken);
 149            return;
 50        }
 51
 52        // Optimistic concurrency: a client that GETs the document, then PUTs it back after someone else has written the
 53        // definition in between, must not silently replace that intervening write. The client is required to carry the
 54        // ETag its GET returned as If-Match. A missing header cannot express "I know what I'm overwriting" at all, and
 55        // neither can "*", which matches whatever is stored — so both are refused as 428 rather than honoured. Anything
 56        // else must be exactly the current strong ETag (a weak W/ tag or a list never is), or it proves the client's co
 57        // is no longer current. The header is checked here so a stale client is refused before import work runs, and
 58        // again inside ImportDocumentAsync's compare-and-swap so a write that lands in that window is 412, not a silent
 59        // overwrite. Metadata carried forward is read in that same swap, not from this lookup.
 2960        var ifMatch = HttpContext.Request.Headers.IfMatch.ToString().Trim();
 61
 2962        if (ifMatch is "" or "*")
 63        {
 264            await BpmnErrorResponse.SendAsync(
 265                HttpContext.Response,
 266                BpmnErrorResponse.Create(
 267                    "An If-Match header carrying the ETag from a prior GET of this document is required to PUT it back, 
 268                    BpmnErrorCodes.DocumentPreconditionRequired,
 269                    StatusCodes.Status428PreconditionRequired),
 270                cancellationToken);
 271            return;
 72        }
 73
 2774        if (!string.Equals(ifMatch, BpmnDocumentETag.From(definition), StringComparison.Ordinal))
 75        {
 376            await BpmnErrorResponse.SendAsync(
 377                HttpContext.Response,
 378                BpmnErrorResponse.Create(
 379                    "The workflow definition has been written since the ETag in If-Match was issued. GET the document ag
 380                    BpmnErrorCodes.DocumentPreconditionFailed,
 381                    StatusCodes.Status412PreconditionFailed),
 382                cancellationToken);
 383            return;
 84        }
 85
 86        string body;
 87
 2488        using (var reader = new StreamReader(HttpContext.Request.Body))
 2489            body = await reader.ReadToEndAsync(cancellationToken);
 90
 91        BpmnDefinitions? document;
 92
 93        try
 94        {
 2495            document = JsonSerializer.Deserialize<BpmnDefinitions>(body, BpmnDocumentJsonOptions.Value);
 2396        }
 97        catch (JsonException exception)
 98        {
 199            AddError($"The request body is not a valid BPMN document: {exception.Message}");
 1100            await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
 1101            return;
 102        }
 103
 23104        if (document is null)
 105        {
 0106            AddError("The request body must be a BPMN document, not JSON null.");
 0107            await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
 0108            return;
 109        }
 110
 111        // The process this definition was imported from, when the document that produced it declared more than one:
 112        // reused here so a multi-process document keeps importing the same process on every edit, without asking the
 113        // caller to say so again. See BpmnInterchangeDocumentService.SourceProcessIdCustomPropertyKey.
 23114        var processId = definition.CustomProperties.TryGetValue<string>(BpmnInterchangeDocumentService.SourceProcessIdCu
 23115            ? storedProcessId
 23116            : null;
 117
 23118        var result = await BpmnImportErrorResponses.RunAsync(
 23119            () => documentService.ImportDocumentAsync(document, definitionId, processId, cancellationToken, ifMatch),
 23120            HttpContext.Response,
 1121            message => AddError(message),
 23122            Send.ErrorsAsync,
 23123            cancellationToken);
 124
 23125        if (result is null)
 4126            return;
 127
 19128        var persisted = result.ImportResult.WorkflowDefinition;
 129
 130        // The definition exactly as ImportDocumentAsync's final save wrote it, so the ETag names the state this PUT
 131        // produced. Reloading it from the store instead could pick up a write that landed after that save and hand the
 132        // client a validator for content it never saw, letting its next PUT overwrite that write silently.
 19133        HttpContext.Response.Headers.ETag = BpmnDocumentETag.From(persisted);
 134
 19135        await Send.OkAsync(new Response
 19136        {
 19137            Id = persisted.Id,
 19138            DefinitionId = persisted.DefinitionId,
 19139            Version = persisted.Version,
 19140            Analysis = BpmnImportAnalysisModel.From(result.Analysis)
 19141        }, cancellationToken);
 30142    }
 143}