Skip to content

Commit a0f0a3c

Browse files
Add workflow export functionality and update changelog (#7357)
* Add `IWorkflowDefinitionExporter` for workflow export functionality Introduced `IWorkflowDefinitionExporter` interface and its implementation to export workflow definitions as JSON or ZIP archives. Simplified `Export` endpoint logic by utilizing the new exporter service. Updated package version to 3.6.1. * Remove duplicate `ExportAsync` method from `IWorkflowDefinitionExporter` and its implementation in `WorkflowDefinitionExporter`. * Remove deprecated test from `WorkflowDefinitionExporterTests`, regression tests are covered in `WorkflowReferenceGraphBuilderTests`. * Update GitHub Actions workflow to support version 3.6.1 deployment * Update src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionExporter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Move `WorkflowDefinitionExporterRegressionTests` to separate test file for clarity * Add `DefaultFileNameSanitizer` for sanitizing file names and update `WorkflowDefinitionExporter` to use it. Implement unit tests for the sanitizer. * Update test assertion for exported workflow file name in `WorkflowDefinitionExporterRegressionTests`. * Simplify file naming in `WorkflowDefinitionExporter` by removing duplicate ID from JSON file names. * Update test/unit/Elsa.Workflows.Management.UnitTests/Services/DefaultFileNameSanitizerTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 291d4a8 commit a0f0a3c

File tree

11 files changed

+413
-177
lines changed

11 files changed

+413
-177
lines changed

.github/workflows/packages.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ jobs:
202202
name: Deploy coverage to GitHub Pages
203203
needs: test
204204
runs-on: ubuntu-latest
205-
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0' || github.ref == 'refs/heads/release/3.6.0'
205+
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.1' || github.ref == 'refs/heads/release/3.6.1'
206206
permissions:
207207
pages: write
208208
id-token: write
Lines changed: 18 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1-
using System.IO.Compression;
2-
using System.Text.Json;
31
using Elsa.Abstractions;
42
using Elsa.Common.Models;
53
using Elsa.Workflows.Management;
6-
using Elsa.Workflows.Management.Entities;
7-
using Elsa.Workflows.Management.Filters;
8-
using Elsa.Workflows.Management.Mappers;
9-
using Elsa.Workflows.Management.Models;
10-
using Humanizer;
114
using JetBrains.Annotations;
125

136
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Export;
@@ -16,26 +9,8 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Export;
169
/// Exports the specified workflow definition as JSON download.
1710
/// </summary>
1811
[UsedImplicitly]
19-
internal class Export : ElsaEndpoint<Request>
12+
internal class Export(IWorkflowDefinitionExporter exporter) : ElsaEndpoint<Request>
2013
{
21-
private readonly IApiSerializer _serializer;
22-
private readonly IWorkflowDefinitionStore _store;
23-
private readonly IWorkflowReferenceGraphBuilder _workflowReferenceGraphBuilder;
24-
private readonly WorkflowDefinitionMapper _workflowDefinitionMapper;
25-
26-
/// <inheritdoc />
27-
public Export(
28-
IWorkflowDefinitionStore store,
29-
IApiSerializer serializer,
30-
WorkflowDefinitionMapper workflowDefinitionMapper,
31-
IWorkflowReferenceGraphBuilder workflowReferenceGraphBuilder)
32-
{
33-
_store = store;
34-
_serializer = serializer;
35-
_workflowDefinitionMapper = workflowDefinitionMapper;
36-
_workflowReferenceGraphBuilder = workflowReferenceGraphBuilder;
37-
}
38-
3914
/// <inheritdoc />
4015
public override void Configure()
4116
{
@@ -48,165 +23,33 @@ public override void Configure()
4823
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
4924
{
5025
if (request.DefinitionId != null)
51-
await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, request.IncludeConsumingWorkflows, cancellationToken);
52-
else if (request.Ids != null)
53-
await DownloadMultipleWorkflowsAsync(request.Ids, request.IncludeConsumingWorkflows, cancellationToken);
54-
else await Send.NoContentAsync(cancellationToken);
55-
}
56-
57-
private async Task DownloadMultipleWorkflowsAsync(ICollection<string> ids, bool includeConsumingWorkflows, CancellationToken cancellationToken)
58-
{
59-
var definitions = (await _store.FindManyAsync(new()
60-
{
61-
Ids = ids
62-
}, cancellationToken)).ToList();
63-
64-
if (includeConsumingWorkflows)
65-
definitions = await IncludeConsumersAsync(definitions, cancellationToken);
66-
67-
if (!definitions.Any())
68-
{
69-
await Send.NoContentAsync(cancellationToken);
70-
return;
71-
}
72-
73-
await WriteZipResponseAsync(definitions, cancellationToken);
74-
}
75-
76-
private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, bool includeConsumingWorkflows, CancellationToken cancellationToken)
77-
{
78-
var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions);
79-
var definition = (await _store.FindManyAsync(new()
80-
{
81-
DefinitionId = definitionId,
82-
VersionOptions = parsedVersionOptions
83-
}, cancellationToken)).FirstOrDefault();
84-
85-
if (definition == null)
8626
{
87-
await Send.NotFoundAsync(cancellationToken);
88-
return;
89-
}
90-
91-
if (includeConsumingWorkflows)
92-
{
93-
var definitions = await IncludeConsumersAsync([definition], cancellationToken);
94-
await WriteZipResponseAsync(definitions, cancellationToken);
95-
return;
96-
}
97-
98-
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
99-
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
100-
var fileName = GetFileName(model);
101-
102-
await Send.BytesAsync(binaryJson, fileName, cancellation: cancellationToken);
103-
}
27+
var versionOptions = string.IsNullOrEmpty(request.VersionOptions) ? VersionOptions.Latest : VersionOptions.FromString(request.VersionOptions);
28+
var result = await exporter.ExportAsync(request.DefinitionId, versionOptions, request.IncludeConsumingWorkflows, cancellationToken);
10429

105-
/// <summary>
106-
/// Recursively discovers all consuming workflow definitions and includes them.
107-
/// Consumers are always resolved at <see cref="VersionOptions.Latest"/>, regardless of the version used for the initial definitions.
108-
/// </summary>
109-
private async Task<List<WorkflowDefinition>> IncludeConsumersAsync(List<WorkflowDefinition> definitions, CancellationToken cancellationToken)
110-
{
111-
var initialDefinitionIds = definitions.Select(d => d.DefinitionId).ToList();
112-
var graph = await _workflowReferenceGraphBuilder.BuildGraphAsync(initialDefinitionIds, cancellationToken);
113-
114-
// Find any consumer definitions not already in our list.
115-
var newDefinitionIds = graph.ConsumerDefinitionIds.Except(initialDefinitionIds).ToList();
116-
117-
if (newDefinitionIds.Count > 0)
118-
{
119-
var consumerDefinitions = await _store.FindManyAsync(new WorkflowDefinitionFilter
30+
if (result == null)
12031
{
121-
DefinitionIds = newDefinitionIds.ToArray(),
122-
VersionOptions = VersionOptions.Latest
123-
}, cancellationToken);
32+
await Send.NotFoundAsync(cancellationToken);
33+
return;
34+
}
12435

125-
definitions = definitions.Concat(consumerDefinitions).ToList();
36+
await Send.BytesAsync(result.Data, result.FileName, cancellation: cancellationToken);
12637
}
127-
128-
return definitions;
129-
}
130-
131-
private async Task WriteZipResponseAsync(List<WorkflowDefinition> definitions, CancellationToken cancellationToken)
132-
{
133-
var zipStream = new MemoryStream();
134-
var sortedDefinitions = definitions.OrderBy(d => d.DefinitionId).ToList();
135-
136-
// NOTE:
137-
// - ZIP timestamps cannot be earlier than 1980-01-01 (the ZIP format's minimum).
138-
// - We intentionally use a fixed timestamp (instead of DateTimeOffset.UtcNow) to keep exports deterministic.
139-
// This avoids producing different ZIP bytes for identical exports, which helps tests, caching, and diffing.
140-
var zipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
141-
142-
#if NET10_0_OR_GREATER
143-
await using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
38+
else if (request.Ids != null)
14439
{
145-
// Create a JSON file for each workflow definition:
146-
foreach (var definition in sortedDefinitions)
40+
var result = await exporter.ExportManyAsync(request.Ids, request.IncludeConsumingWorkflows, cancellationToken);
41+
42+
if (result == null)
14743
{
148-
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
149-
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
150-
var fileName = GetFileName(model);
151-
var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
152-
entry.LastWriteTime = zipEpoch;
153-
await using var entryStream = await entry.OpenAsync(cancellationToken);
154-
await entryStream.WriteAsync(binaryJson, cancellationToken);
44+
await Send.NoContentAsync(cancellationToken);
45+
return;
15546
}
47+
48+
await Send.BytesAsync(result.Data, result.FileName, cancellation: cancellationToken);
15649
}
157-
#else
158-
using (var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
50+
else
15951
{
160-
// Create a JSON file for each workflow definition:
161-
foreach (var definition in sortedDefinitions)
162-
{
163-
var model = await CreateWorkflowModelAsync(definition, cancellationToken);
164-
var binaryJson = await SerializeWorkflowDefinitionAsync(model, cancellationToken);
165-
var fileName = GetFileName(model);
166-
var entry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
167-
entry.LastWriteTime = zipEpoch;
168-
await using var entryStream = entry.Open();
169-
await entryStream.WriteAsync(binaryJson, cancellationToken);
170-
}
52+
await Send.NoContentAsync(cancellationToken);
17153
}
172-
#endif
173-
174-
// Send the zip file to the client:
175-
zipStream.Position = 0;
176-
await Send.BytesAsync(zipStream.ToArray(), "workflow-definitions.zip", cancellation: cancellationToken);
177-
}
178-
179-
private string GetFileName(WorkflowDefinitionModel definition)
180-
{
181-
var hasWorkflowName = !string.IsNullOrWhiteSpace(definition.Name);
182-
var workflowName = hasWorkflowName ? definition.Name!.Trim() : definition.DefinitionId;
183-
var fileName = $"workflow-definition-{workflowName.Underscore().Dasherize().ToLowerInvariant()}-{definition.DefinitionId}.json";
184-
return fileName;
185-
}
186-
187-
private async Task<byte[]> SerializeWorkflowDefinitionAsync(WorkflowDefinitionModel model, CancellationToken cancellationToken)
188-
{
189-
var serializerOptions = _serializer.GetOptions();
190-
var document = JsonSerializer.SerializeToDocument(model, serializerOptions);
191-
var rootElement = document.RootElement;
192-
193-
using var output = new MemoryStream();
194-
await using var writer = new Utf8JsonWriter(output);
195-
196-
writer.WriteStartObject();
197-
writer.WriteString("$schema", "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json");
198-
199-
foreach (var property in rootElement.EnumerateObject())
200-
property.WriteTo(writer);
201-
202-
writer.WriteEndObject();
203-
204-
await writer.FlushAsync(cancellationToken);
205-
return output.ToArray();
206-
}
207-
208-
private async Task<WorkflowDefinitionModel> CreateWorkflowModelAsync(WorkflowDefinition definition, CancellationToken cancellationToken)
209-
{
210-
return await _workflowDefinitionMapper.MapAsync(definition, cancellationToken);
21154
}
21255
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Elsa.Workflows.Management;
2+
3+
/// <summary>
4+
/// Sanitizes file names by replacing invalid characters with safe alternatives.
5+
/// </summary>
6+
public interface IFileNameSanitizer
7+
{
8+
/// <summary>
9+
/// Replaces invalid file name characters in the specified value.
10+
/// </summary>
11+
string Sanitize(string value);
12+
}
13+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Elsa.Common.Models;
2+
using Elsa.Workflows.Management.Entities;
3+
using Elsa.Workflows.Management.Models;
4+
5+
namespace Elsa.Workflows.Management;
6+
7+
/// <summary>
8+
/// Exports workflow definitions as serialized JSON or ZIP archives.
9+
/// </summary>
10+
public interface IWorkflowDefinitionExporter
11+
{
12+
/// <summary>
13+
/// Exports a single workflow definition as a JSON byte array, optionally including consuming workflows as a ZIP archive.
14+
/// </summary>
15+
/// <param name="definitionId">The definition ID.</param>
16+
/// <param name="versionOptions">The version options. Defaults to <see cref="VersionOptions.Latest"/>.</param>
17+
/// <param name="includeConsumingWorkflows">When true, includes all consuming workflow definitions in the export as a ZIP archive.</param>
18+
/// <param name="cancellationToken">The cancellation token.</param>
19+
/// <returns>An export result containing the binary content and a suggested file name, or null if the definition was not found.</returns>
20+
Task<WorkflowDefinitionExportResult?> ExportAsync(string definitionId, VersionOptions? versionOptions = null, bool includeConsumingWorkflows = false, CancellationToken cancellationToken = default);
21+
22+
/// <summary>
23+
/// Exports multiple workflow definitions as a ZIP archive.
24+
/// </summary>
25+
/// <param name="ids">A list of workflow definition version IDs.</param>
26+
/// <param name="includeConsumingWorkflows">When true, includes all consuming workflow definitions in the export.</param>
27+
/// <param name="cancellationToken">The cancellation token.</param>
28+
/// <returns>An export result containing the ZIP binary content and a suggested file name, or null if no definitions were found.</returns>
29+
Task<WorkflowDefinitionExportResult?> ExportManyAsync(ICollection<string> ids, bool includeConsumingWorkflows = false, CancellationToken cancellationToken = default);
30+
31+
/// <summary>
32+
/// Serializes a single workflow definition entity to a JSON byte array (with $schema header).
33+
/// </summary>
34+
/// <param name="definition">The workflow definition entity.</param>
35+
/// <param name="cancellationToken">The cancellation token.</param>
36+
/// <returns>The serialized JSON bytes and suggested file name.</returns>
37+
Task<WorkflowDefinitionExportResult> ExportDefinitionAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default);
38+
39+
/// <summary>
40+
/// Exports multiple workflow definition entities as a ZIP archive.
41+
/// </summary>
42+
/// <param name="definitions">The workflow definitions to export.</param>
43+
/// <param name="cancellationToken">The cancellation token.</param>
44+
/// <returns>The ZIP archive bytes and suggested file name.</returns>
45+
Task<WorkflowDefinitionExportResult> ExportDefinitionsAsync(ICollection<WorkflowDefinition> definitions, CancellationToken cancellationToken = default);
46+
}

src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ public override void Apply()
272272
.AddScoped<IWorkflowReferenceGraphBuilder, WorkflowReferenceGraphBuilder>()
273273
.AddScoped(_workflowDefinitionPublisher)
274274
.AddScoped<IWorkflowDefinitionImporter, WorkflowDefinitionImporter>()
275+
.AddScoped<IWorkflowDefinitionExporter, WorkflowDefinitionExporter>()
276+
.AddSingleton<IFileNameSanitizer, DefaultFileNameSanitizer>()
275277
.AddScoped<IWorkflowDefinitionManager, WorkflowDefinitionManager>()
276278
.AddScoped<IWorkflowInstanceManager, WorkflowInstanceManager>()
277279
.AddScoped<IWorkflowReferenceUpdater, WorkflowReferenceUpdater>()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Elsa.Workflows.Management.Models;
2+
3+
/// <summary>
4+
/// Represents the result of exporting one or more workflow definitions.
5+
/// </summary>
6+
/// <param name="Data">The binary content (JSON or ZIP).</param>
7+
/// <param name="FileName">The suggested file name.</param>
8+
public record WorkflowDefinitionExportResult(byte[] Data, string FileName);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace Elsa.Workflows.Management.Services;
2+
3+
/// <inheritdoc />
4+
public class DefaultFileNameSanitizer : IFileNameSanitizer
5+
{
6+
private static readonly char[] InvalidFileNameCharacters = Path.GetInvalidFileNameChars();
7+
8+
/// <inheritdoc />
9+
public string Sanitize(string value)
10+
{
11+
for (var i = 0; i < value.Length; i++)
12+
{
13+
if (!IsInvalidFileNameCharacter(value[i]))
14+
continue;
15+
16+
return string.Create(value.Length, (value, i), static (buffer, state) =>
17+
{
18+
state.value.AsSpan(0, state.i).CopyTo(buffer);
19+
20+
for (var j = state.i; j < state.value.Length; j++)
21+
{
22+
var character = state.value[j];
23+
buffer[j] = IsInvalidFileNameCharacter(character) ? '-' : character;
24+
}
25+
});
26+
}
27+
28+
return value;
29+
}
30+
31+
private static bool IsInvalidFileNameCharacter(char character) => character is '/' or '\\' || Array.IndexOf(InvalidFileNameCharacters, character) >= 0;
32+
}
33+

0 commit comments

Comments
 (0)