Fix Content-Type header charset auto-appending in HTTP requests (#6676)

* Initial plan for issue

* Implement RawStringContent for fixing charset issue

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
Copilot 2025-05-24 21:39:31 +02:00 committed by GitHub
parent 830a04f325
commit 144edc3a85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 4 deletions

View file

@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34003.232
MinimumVisualStudioVersion = 10.0.40219.1
@ -262,6 +262,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.Python", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.SasTokens", "src\modules\Elsa.SasTokens\Elsa.SasTokens.csproj", "{A7DE02E3-405B-B6BA-7E47-9E27D4BEFB24}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http.UnitTests", "test\unit\Elsa.Http.UnitTests\Elsa.Http.UnitTests.csproj", "{66ACA2B3-DB48-4F68-B26D-62555F3CE69B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -450,6 +452,10 @@ Global
{A7DE02E3-405B-B6BA-7E47-9E27D4BEFB24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A7DE02E3-405B-B6BA-7E47-9E27D4BEFB24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A7DE02E3-405B-B6BA-7E47-9E27D4BEFB24}.Release|Any CPU.Build.0 = Release|Any CPU
{66ACA2B3-DB48-4F68-B26D-62555F3CE69B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66ACA2B3-DB48-4F68-B26D-62555F3CE69B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66ACA2B3-DB48-4F68-B26D-62555F3CE69B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66ACA2B3-DB48-4F68-B26D-62555F3CE69B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -527,6 +533,7 @@ Global
{6AF53651-99F0-1DE0-D37B-4FF6B0348DBB} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76}
{9D8FB664-88B4-10BE-58A2-D9A1644AD2E4} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76}
{A7DE02E3-405B-B6BA-7E47-9E27D4BEFB24} = {5948B0A5-7873-4DBB-BA03-EB283D6EA91B}
{66ACA2B3-DB48-4F68-B26D-62555F3CE69B} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -22,6 +22,6 @@ public class JsonContentFactory : IHttpContentFactory
if (string.IsNullOrWhiteSpace(contentType))
contentType = MediaTypeNames.Application.Json;
return new StringContent(text, Encoding.UTF8, contentType);
return new RawStringContent(text, Encoding.UTF8, contentType);
}
}

View file

@ -0,0 +1,48 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace Elsa.Http.ContentWriters;
/// <summary>
/// A <see cref="HttpContent"/> implementation that allows setting the content type without automatically appending charset information.
/// </summary>
public class RawStringContent : HttpContent
{
private readonly string _content;
private readonly Encoding _encoding;
/// <summary>
/// Creates a new instance of the <see cref="RawStringContent"/> class.
/// </summary>
/// <param name="content">The content to send.</param>
/// <param name="encoding">The encoding to use when sending the content.</param>
/// <param name="mediaType">The media type to use for the content.</param>
public RawStringContent(string content, Encoding encoding, string mediaType)
{
_content = content;
_encoding = encoding;
// Set the media type exactly as provided without appending charset information
Headers.ContentType = new MediaTypeHeaderValue(mediaType);
}
/// <inheritdoc />
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
SerializeToStreamAsync(stream, context, CancellationToken.None);
/// <inheritdoc />
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
{
using var writer = new StreamWriter(stream, _encoding, leaveOpen: true);
await writer.WriteAsync(_content.AsMemory(), cancellationToken);
await writer.FlushAsync();
}
/// <inheritdoc />
protected override bool TryComputeLength(out long length)
{
length = _encoding.GetByteCount(_content);
return true;
}
}

View file

@ -24,6 +24,6 @@ public class TextContentFactory : IHttpContentFactory
if (string.IsNullOrWhiteSpace(contentType))
contentType = MediaTypeNames.Text.Plain;
return new StringContent(text!, Encoding.UTF8, contentType);
return new RawStringContent(text!, Encoding.UTF8, contentType);
}
}

View file

@ -21,7 +21,7 @@ public class XmlContentFactory : IHttpContentFactory
public HttpContent CreateHttpContent(object content, string contentType)
{
var text = content as string ?? Serialize(content);
return new StringContent(text, Encoding.UTF8, contentType);
return new RawStringContent(text, Encoding.UTF8, contentType);
}
private string Serialize(object value)

View file

@ -0,0 +1,67 @@
using Elsa.Http.ContentWriters;
using Xunit;
namespace Elsa.Http.UnitTests.ContentWriters;
/// <summary>
/// Tests for the <see cref="IHttpContentFactory"/> implementations.
/// </summary>
public class ContentFactoryTests
{
/// <summary>
/// Tests that <see cref="JsonContentFactory"/> doesn't append charset to content type.
/// </summary>
[Fact]
public void JsonContentFactory_ShouldNotAppendCharset()
{
// Arrange
const string contentType = "application/json";
const string content = "{\"test\": \"value\"}";
var factory = new JsonContentFactory();
// Act
var httpContent = factory.CreateHttpContent(content, contentType);
// Assert
Assert.Equal(contentType, httpContent.Headers.ContentType?.MediaType);
Assert.Null(httpContent.Headers.ContentType?.CharSet);
}
/// <summary>
/// Tests that <see cref="XmlContentFactory"/> doesn't append charset to content type.
/// </summary>
[Fact]
public void XmlContentFactory_ShouldNotAppendCharset()
{
// Arrange
const string contentType = "text/xml";
const string content = "<root>test</root>";
var factory = new XmlContentFactory();
// Act
var httpContent = factory.CreateHttpContent(content, contentType);
// Assert
Assert.Equal(contentType, httpContent.Headers.ContentType?.MediaType);
Assert.Null(httpContent.Headers.ContentType?.CharSet);
}
/// <summary>
/// Tests that <see cref="TextContentFactory"/> doesn't append charset to content type.
/// </summary>
[Fact]
public void TextContentFactory_ShouldNotAppendCharset()
{
// Arrange
const string contentType = "text/html";
const string content = "<html><body>test</body></html>";
var factory = new TextContentFactory();
// Act
var httpContent = factory.CreateHttpContent(content, contentType);
// Assert
Assert.Equal(contentType, httpContent.Headers.ContentType?.MediaType);
Assert.Null(httpContent.Headers.ContentType?.CharSet);
}
}

View file

@ -0,0 +1,55 @@
using System.Net.Http.Headers;
using System.Text;
using Elsa.Http.ContentWriters;
using Xunit;
namespace Elsa.Http.UnitTests.ContentWriters;
/// <summary>
/// Tests for the <see cref="RawStringContent"/> class.
/// </summary>
public class RawStringContentTests
{
/// <summary>
/// Tests that the content type is set exactly as provided without appending charset information.
/// </summary>
[Fact]
public void ContentType_ShouldNotAppendCharset()
{
// Arrange
const string contentType = "text/xml";
const string content = "<root>test</root>";
// Act
var rawContent = new RawStringContent(content, Encoding.UTF8, contentType);
// Assert
Assert.Equal(contentType, rawContent.Headers.ContentType?.MediaType);
Assert.Null(rawContent.Headers.ContentType?.CharSet);
}
/// <summary>
/// Tests that the content type with parameters is preserved exactly as provided.
/// </summary>
[Fact]
public void ContentType_WithParameters_ShouldPreserveParameters()
{
// Arrange
const string contentType = "application/json; custom-param=value";
var expectedMediaType = new MediaTypeHeaderValue(contentType);
const string content = "{\"test\": \"value\"}";
// Act
var rawContent = new RawStringContent(content, Encoding.UTF8, contentType);
// Assert
Assert.Equal(expectedMediaType.MediaType, rawContent.Headers.ContentType?.MediaType);
Assert.Equal(expectedMediaType.Parameters.Count(), rawContent.Headers.ContentType?.Parameters.Count());
var expectedParam = expectedMediaType.Parameters.First();
var actualParam = rawContent.Headers.ContentType?.Parameters.First();
Assert.Equal(expectedParam.Name, actualParam?.Name);
Assert.Equal(expectedParam.Value, actualParam?.Value);
}
}

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
</ItemGroup>
</Project>