From 1b48827b998f252e68cd778d45aa1cc085f32b4d Mon Sep 17 00:00:00 2001 From: Adham Kiwan Date: Sat, 18 Jul 2026 02:57:34 +0300 Subject: [PATCH] Add multipart/form-data HTTP content factory Elsa.Http provided IHttpContentFactory implementations for JSON, XML, text and application/x-www-form-urlencoded, but there was no writer for multipart/form-data. Add MultipartFormDataHttpContentFactory following the same pattern as FormUrlEncodedHttpContentFactory, register it in the Features and ShellFeatures HttpFeature so it surfaces in the content-type dropdown, and cover it with unit tests. Signed-off-by: Adham Kiwan --- .../MultipartFormDataHttpContentFactory.cs | 36 +++++++++ src/modules/Elsa.Http/Features/HttpFeature.cs | 1 + .../Elsa.Http/ShellFeatures/HttpFeature.cs | 1 + .../ContentWriters/ContentFactoryTests.cs | 74 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 src/modules/Elsa.Http/ContentWriters/MultipartFormDataHttpContentFactory.cs diff --git a/src/modules/Elsa.Http/ContentWriters/MultipartFormDataHttpContentFactory.cs b/src/modules/Elsa.Http/ContentWriters/MultipartFormDataHttpContentFactory.cs new file mode 100644 index 0000000000..2bbaad9c6a --- /dev/null +++ b/src/modules/Elsa.Http/ContentWriters/MultipartFormDataHttpContentFactory.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Elsa.Http.ContentWriters; + +/// +/// A content writer that writes content in the multipart/form-data format. +/// +public class MultipartFormDataHttpContentFactory : IHttpContentFactory +{ + /// + public IEnumerable SupportedContentTypes => ["multipart/form-data"]; + + /// + public HttpContent CreateHttpContent(object content, string? contentType = null) + { + var multipartContent = new MultipartFormDataContent(); + + foreach (var (key, value) in GetContentAsDictionary(content)) + multipartContent.Add(new StringContent(value), key); + + return multipartContent; + } + + private static IDictionary GetContentAsDictionary(object content) + { + if (content is IDictionary dictionary) + return dictionary.ToDictionary(x => x.Key, x => x.Value.ToString() ?? string.Empty); + + if (content is string or JsonObject) + return JsonSerializer.Deserialize>(JsonSerializer.Serialize(content))!; + + var jsonElement = JsonSerializer.SerializeToElement(content); + return jsonElement.EnumerateObject().ToDictionary(x => x.Name, x => x.Value.ToString()); + } +} diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 6e406665ae..440b912ac4 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -194,6 +194,7 @@ public override void Apply() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // Activity property options providers. .AddScoped() diff --git a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs index 7f333e1916..a46e51acf3 100644 --- a/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs +++ b/src/modules/Elsa.Http/ShellFeatures/HttpFeature.cs @@ -164,6 +164,7 @@ public void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() // Activity property options providers. .AddScoped() diff --git a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs index f14b02175a..8610e08d25 100644 --- a/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs +++ b/test/unit/Elsa.Http.UnitTests/ContentWriters/ContentFactoryTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json.Nodes; using Elsa.Http.ContentWriters; using Xunit; @@ -112,4 +113,77 @@ public async Task JsonContentFactory_ShouldHandleMultiByteCharacters() Assert.Equal(Encoding.UTF8.GetByteCount(content), bytes.Length); Assert.Equal(httpContent.Headers.ContentLength, bytes.Length); } + + /// + /// Tests that reports support for the multipart/form-data content type. + /// + [Fact] + public void MultipartFormDataHttpContentFactory_ShouldSupportMultipartFormData() + { + // Arrange + var factory = new MultipartFormDataHttpContentFactory(); + + // Assert + Assert.Contains("multipart/form-data", factory.SupportedContentTypes); + } + + /// + /// Tests that produces multipart/form-data content with a boundary. + /// + [Fact] + public void MultipartFormDataHttpContentFactory_ShouldSetMultipartContentType() + { + // Arrange + var content = new Dictionary { ["field1"] = "value1", ["field2"] = "value2" }; + var factory = new MultipartFormDataHttpContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, "multipart/form-data"); + + // Assert + Assert.IsType(httpContent); + Assert.Equal("multipart/form-data", httpContent.Headers.ContentType?.MediaType); + // A boundary parameter is required for multipart content. + Assert.Contains(httpContent.Headers.ContentType!.Parameters, p => p.Name == "boundary"); + } + + /// + /// Tests that writes one part per dictionary entry. + /// + [Fact] + public async Task MultipartFormDataHttpContentFactory_ShouldWriteEachFieldAsPart() + { + // Arrange + var content = new Dictionary { ["name"] = "Alice", ["role"] = "admin" }; + var factory = new MultipartFormDataHttpContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, "multipart/form-data"); + var body = await httpContent.ReadAsStringAsync(); + + // Assert + // Each entry becomes a form-data section carrying its value. + Assert.Contains("form-data", body); + Assert.Contains("Alice", body); + Assert.Contains("admin", body); + } + + /// + /// Tests that accepts a payload. + /// + [Fact] + public async Task MultipartFormDataHttpContentFactory_ShouldSupportJsonObjectContent() + { + // Arrange + var content = new JsonObject { ["field1"] = "value1", ["field2"] = "value2" }; + var factory = new MultipartFormDataHttpContentFactory(); + + // Act + var httpContent = factory.CreateHttpContent(content, "multipart/form-data"); + var body = await httpContent.ReadAsStringAsync(); + + // Assert + Assert.Contains("value1", body); + Assert.Contains("value2", body); + } } \ No newline at end of file