< Summary

Information
Class: Elsa.Workflows.Management.Services.WorkflowDefinitionExporter
Assembly: Elsa.Workflows.Management
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionExporter.cs
Line coverage
97%
Covered lines: 82
Uncovered lines: 2
Coverable lines: 84
Total lines: 176
Line coverage: 97.6%
Branch coverage
83%
Covered branches: 20
Total branches: 24
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExportAsync()66.66%6692.3%
ExportManyAsync()75%4490%
ExportDefinitionAsync()100%11100%
ExportDefinitionsAsync()100%11100%
IncludeConsumersAsync()100%22100%
CreateZipArchiveAsync()100%66100%
GetFileName(...)50%22100%
SerializeWorkflowDefinitionAsync()100%44100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionExporter.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Text.Json;
 3using Elsa.Common.Models;
 4using Elsa.Workflows.Management.Entities;
 5using Elsa.Workflows.Management.Mappers;
 6using Elsa.Workflows.Management.Models;
 7using Humanizer;
 8
 9namespace Elsa.Workflows.Management.Services;
 10
 11/// <inheritdoc />
 812public class WorkflowDefinitionExporter(
 813    IWorkflowDefinitionStore store,
 814    IApiSerializer serializer,
 815    WorkflowDefinitionMapper workflowDefinitionMapper,
 816    IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder,
 817    IFileNameSanitizer fileNameSanitizer) : IWorkflowDefinitionExporter
 18{
 19    /// <inheritdoc />
 20    public async Task<WorkflowDefinitionExportResult?> ExportAsync(string definitionId, VersionOptions? versionOptions =
 21    {
 222        var parsedVersionOptions = versionOptions ?? VersionOptions.Latest;
 223        var definition = (await store.FindManyAsync(new()
 224        {
 225            DefinitionId = definitionId,
 226            VersionOptions = parsedVersionOptions
 227        }, cancellationToken)).FirstOrDefault();
 28
 229        if (definition == null)
 030            return null;
 31
 232        if (includeConsumingWorkflows)
 33        {
 134            var definitions = await IncludeConsumersAsync([definition], cancellationToken);
 135            return await ExportDefinitionsAsync(definitions, cancellationToken);
 36        }
 37
 138        return await ExportDefinitionAsync(definition, cancellationToken);
 239    }
 40
 41    /// <inheritdoc />
 42    public async Task<WorkflowDefinitionExportResult?> ExportManyAsync(ICollection<string> ids, bool includeConsumingWor
 43    {
 344        var definitions = (await store.FindManyAsync(new()
 345        {
 346            Ids = ids
 347        }, cancellationToken)).ToList();
 48
 349        if (includeConsumingWorkflows)
 150            definitions = await IncludeConsumersAsync(definitions, cancellationToken);
 51
 352        if (definitions.Count == 0)
 053            return null;
 54
 355        return await ExportDefinitionsAsync(definitions, cancellationToken);
 356    }
 57
 58    /// <inheritdoc />
 59    public async Task<WorkflowDefinitionExportResult> ExportDefinitionAsync(WorkflowDefinition definition, CancellationT
 60    {
 161        var model = await workflowDefinitionMapper.MapAsync(definition, cancellationToken);
 162        var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
 163        var fileName = GetFileName(model);
 164        return new(binaryJson, fileName);
 165    }
 66
 67    /// <inheritdoc />
 68    public async Task<WorkflowDefinitionExportResult> ExportDefinitionsAsync(ICollection<WorkflowDefinition> definitions
 69    {
 470        var zipBytes = await CreateZipArchiveAsync(definitions, cancellationToken);
 471        return new(zipBytes, "workflow-definitions.zip");
 472    }
 73
 74    /// <summary>
 75    /// Recursively discovers all consuming workflow definitions and includes them.
 76    /// Consumers are always resolved at <see cref="VersionOptions.Latest"/>, regardless of the version used for the ini
 77    /// </summary>
 78    private async Task<List<WorkflowDefinition>> IncludeConsumersAsync(List<WorkflowDefinition> definitions, Cancellatio
 79    {
 480        var initialDefinitionIds = definitions.Select(d => d.DefinitionId).ToList();
 281        var graph = await workflowReferenceGraphBuilder.BuildGraphAsync(initialDefinitionIds, cancellationToken);
 82
 83        // Find any consumer definitions not already in our list.
 284        var newDefinitionIds = graph.ConsumerDefinitionIds.Except(initialDefinitionIds).ToList();
 85
 286        if (newDefinitionIds.Count > 0)
 87        {
 288            var consumerDefinitions = await store.FindManyAsync(new()
 289            {
 290                DefinitionIds = newDefinitionIds.ToArray(),
 291                VersionOptions = VersionOptions.Latest
 292            }, cancellationToken);
 93
 294            definitions = definitions.Concat(consumerDefinitions).ToList();
 95        }
 96
 297        return definitions;
 298    }
 99
 100    private async Task<byte[]> CreateZipArchiveAsync(ICollection<WorkflowDefinition> definitions, CancellationToken canc
 101    {
 4102        var zipStream = new MemoryStream();
 12103        var sortedDefinitions = definitions.OrderBy(d => d.DefinitionId).ToList();
 104
 105        // NOTE:
 106        // - ZIP timestamps cannot be earlier than 1980-01-01 (the ZIP format's minimum).
 107        // - We intentionally use a fixed timestamp (instead of DateTimeOffset.UtcNow) to keep exports deterministic.
 108        //   This avoids producing different ZIP bytes for identical exports, which helps tests, caching, and diffing.
 4109        var zipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
 110
 111#if NET10_0_OR_GREATER
 4112        await using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
 113        {
 26114            foreach (var definition in sortedDefinitions)
 115            {
 9116                var model = await workflowDefinitionMapper.MapAsync(definition, cancellationToken);
 9117                var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
 9118                var fileName = GetFileName(model);
 9119                var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
 9120                entry.LastWriteTime = zipEpoch;
 9121                await using var entryStream = await entry.OpenAsync(cancellationToken);
 9122                await entryStream.WriteAsync(binaryJson, cancellationToken);
 9123            }
 124        }
 125#else
 126        using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
 127        {
 128            foreach (var definition in sortedDefinitions)
 129            {
 130                var model = await workflowDefinitionMapper.MapAsync(definition, cancellationToken);
 131                var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
 132                var fileName = GetFileName(model);
 133                var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
 134                entry.LastWriteTime = zipEpoch;
 135                await using var entryStream = entry.Open();
 136                await entryStream.WriteAsync(binaryJson, cancellationToken);
 137            }
 138        }
 139#endif
 140
 4141        zipStream.Position = 0;
 4142        return zipStream.ToArray();
 4143    }
 144
 145    private string GetFileName(WorkflowDefinitionModel definition)
 146    {
 10147        var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name);
 10148        var workflowName = hasWorkflowName ? definition.Name!.Trim() : definition.DefinitionId;
 10149        var workflowSlug = workflowName.Underscore().Dasherize().ToLowerInvariant();
 10150        var dynamicFileNamePart = $"{workflowSlug}-{definition.DefinitionId}-{definition.Id}";
 10151        var sanitizedDynamicFileNamePart = fileNameSanitizer.Sanitize(dynamicFileNamePart);
 152
 10153        return $"workflow-definition-{sanitizedDynamicFileNamePart}.json";
 154    }
 155
 156    private async Task<byte[]> SerializeWorkflowDefinitionAsync(WorkflowDefinitionModel model, CancellationToken cancell
 157    {
 10158        var serializerOptions = serializer.GetOptions();
 10159        using var document = JsonSerializer.SerializeToDocument(model, serializerOptions);
 10160        var rootElement = document.RootElement;
 161
 10162        using var output = new MemoryStream();
 10163        await using var writer = new Utf8JsonWriter(output);
 164
 10165        writer.WriteStartObject();
 10166        writer.WriteString("$schema", "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json");
 167
 366168        foreach (var property in rootElement.EnumerateObject())
 173169            property.WriteTo(writer);
 170
 10171        writer.WriteEndObject();
 172
 10173        await writer.FlushAsync(cancellationToken);
 10174        return output.ToArray();
 10175    }
 176}