diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a36a1a..a51fa2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- automatic nuget updates by dependabot, since we want to test against the lowest supported nuget version and most of the time dependabot does not choose the right package.
### Added
- Support for .NET 9.0
-- support for .NET 10.0
+- Support for .NET 10.0
+### Changed
+- The TestableHttpMessageHandler now makes a clone of the original request, so that the original request can be disposed.
+ This change also makes it possible to assert the content on .NET Framework.
## [0.11] - 2024-06-15
### Removed
diff --git a/src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs b/src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs
index e6188a1..fd55d7f 100644
--- a/src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs
+++ b/src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs
@@ -258,7 +258,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
/// The implementation that hold all the request messages.
/// The expected content, supports wildcards.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern) => WithContent(check, pattern, null);
///
@@ -268,7 +267,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
/// The expected content, supports wildcards.
/// The expected number of requests.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithContent(check, pattern, (int?)expectedNumberOfRequests);
private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int? expectedNumberOfRequests)
@@ -285,7 +283,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// The implementation that hold all the request messages.
/// The object representation of the expected request content.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject) => WithJsonContent(check, jsonObject, null, null);
///
@@ -295,7 +292,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// The object representation of the expected request content.
/// The serializer options that should be used for serializing te content.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions) => WithJsonContent(check, jsonObject, jsonSerializerOptions, null);
///
@@ -305,7 +301,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// The object representation of the expected request content.
/// The expected number of requests.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, null, (int?)expectedNumberOfRequests);
///
@@ -316,7 +311,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// The serializer options that should be used for serializing the content.
/// The expected number of requests.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, jsonSerializerOptions, (int?)expectedNumberOfRequests);
private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions? jsonSerializerOptions, int? expectedNumberOfRequests)
@@ -334,7 +328,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
/// The implementation that hold all the request messages.
/// The collection of key/value pairs that should be url encoded.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable> nameValueCollection) => WithFormUrlEncodedContent(check, nameValueCollection, null);
///
@@ -344,7 +337,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
/// The collection of key/value pairs that should be url encoded.
/// The expected number of requests.
/// The for further assertions.
- /// Note that on .NET Framework, the HttpClient might dispose the content after sending the request.
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable> nameValueCollection, int expectedNumberOfRequests) => WithFormUrlEncodedContent(check, nameValueCollection, (int?)expectedNumberOfRequests);
private static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable> nameValueCollection, int? expectedNumberOfRequests)
diff --git a/src/TestableHttpClient/PublicAPI.Unshipped.txt b/src/TestableHttpClient/PublicAPI.Unshipped.txt
index 5f28270..b7208c0 100644
--- a/src/TestableHttpClient/PublicAPI.Unshipped.txt
+++ b/src/TestableHttpClient/PublicAPI.Unshipped.txt
@@ -1 +1 @@
-
\ No newline at end of file
+override TestableHttpClient.TestableHttpMessageHandler.Dispose(bool disposing) -> void
\ No newline at end of file
diff --git a/src/TestableHttpClient/TestableHttpMessageHandler.cs b/src/TestableHttpClient/TestableHttpMessageHandler.cs
index 416920a..beb91aa 100644
--- a/src/TestableHttpClient/TestableHttpMessageHandler.cs
+++ b/src/TestableHttpClient/TestableHttpMessageHandler.cs
@@ -17,9 +17,18 @@ public class TestableHttpMessageHandler : HttpMessageHandler
///
public IEnumerable Requests => httpRequestMessages;
+ protected override void Dispose(bool disposing)
+ {
+ DisposeRequestMessages();
+ base.Dispose(disposing);
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "It gets disposed in the dispose method")]
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
- httpRequestMessages.Enqueue(request);
+ Guard.ThrowIfNull(request);
+
+ httpRequestMessages.Enqueue(await HttpRequestMessageCloner.ClonaAsync(request, cancellationToken).ConfigureAwait(false));
HttpResponseMessage responseMessage = new();
HttpResponseContext context = new(request, httpRequestMessages, responseMessage, Options);
@@ -27,9 +36,9 @@ protected override async Task SendAsync(HttpRequestMessage
responseMessage.RequestMessage ??= request;
-#if !NET6_0_OR_GREATER
+ // In .NET Standard, a response message can be null, but we need it to be at least empty like in the newer versions.
+ // Newer versions will always have a content, even if it's empty.
responseMessage.Content ??= new StringContent("");
-#endif
return responseMessage;
}
@@ -55,6 +64,15 @@ public void RespondWith(IResponse response)
/// The configuration itself (Options and the configured IResponse) will not be cleared or reset.
public void ClearRequests()
{
+ DisposeRequestMessages();
httpRequestMessages.Clear();
}
+
+ private void DisposeRequestMessages()
+ {
+ foreach (HttpRequestMessage request in httpRequestMessages)
+ {
+ request.Dispose();
+ }
+ }
}
diff --git a/src/TestableHttpClient/Utils/Guard.cs b/src/TestableHttpClient/Utils/Guard.cs
index e07c677..780832e 100644
--- a/src/TestableHttpClient/Utils/Guard.cs
+++ b/src/TestableHttpClient/Utils/Guard.cs
@@ -29,7 +29,7 @@ internal static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgum
throw new ArgumentException("String should not be empty", paramName);
}
#else
- ArgumentNullException.ThrowIfNullOrEmpty(argument, paramName);
+ ArgumentException.ThrowIfNullOrEmpty(argument, paramName);
#endif
}
}
diff --git a/src/TestableHttpClient/Utils/HttpRequestMessageCloner.cs b/src/TestableHttpClient/Utils/HttpRequestMessageCloner.cs
new file mode 100644
index 0000000..46c23cb
--- /dev/null
+++ b/src/TestableHttpClient/Utils/HttpRequestMessageCloner.cs
@@ -0,0 +1,37 @@
+namespace TestableHttpClient.Utils;
+
+internal static class HttpRequestMessageCloner
+{
+ internal static async Task ClonaAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ HttpRequestMessage clone = new()
+ {
+ Method = request.Method,
+ RequestUri = request.RequestUri,
+ Version = request.Version,
+ };
+
+ foreach (var item in request.Headers)
+ {
+ clone.Headers.TryAddWithoutValidation(item.Key, item.Value);
+ }
+
+ if (request.Content is not null)
+ {
+ var bytes = await request.Content
+ .ReadAsByteArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+ var contentClone = new ByteArrayContent(bytes);
+ contentClone.Headers.Clear();
+ // copy content headers
+ foreach (var header in request.Content.Headers)
+ {
+ contentClone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ clone.Content = contentClone;
+ }
+
+ return clone;
+ }
+}
diff --git a/src/TestableHttpClient/Utils/NetStandardPollyFill.cs b/src/TestableHttpClient/Utils/NetStandardPollyFill.cs
new file mode 100644
index 0000000..f3d1acc
--- /dev/null
+++ b/src/TestableHttpClient/Utils/NetStandardPollyFill.cs
@@ -0,0 +1,18 @@
+#if NETSTANDARD
+
+namespace TestableHttpClient.Utils;
+
+internal static class NetStandardPollyFill
+{
+ public static Task ReadAsByteArrayAsync(this HttpContent content, CancellationToken cancellationToken = default)
+ {
+ return content.ReadAsByteArrayAsync();
+ }
+
+ public static string Replace(this string input, string oldValue, string newValue, StringComparison comparisonType)
+ {
+ return input.Replace(oldValue, newValue);
+ }
+}
+
+#endif
diff --git a/src/TestableHttpClient/Utils/StringMatcher.cs b/src/TestableHttpClient/Utils/StringMatcher.cs
index 1055bf6..bbcb2e2 100644
--- a/src/TestableHttpClient/Utils/StringMatcher.cs
+++ b/src/TestableHttpClient/Utils/StringMatcher.cs
@@ -7,11 +7,8 @@ internal static class StringMatcher
internal static bool Matches(string value, string pattern, bool ignoreCase = false)
{
var escapedPattern = Regex.Escape(pattern);
-#if NETSTANDARD2_0
- var regex = escapedPattern.Replace("\\*", "(.*)");
-#else
+
var regex = escapedPattern.Replace("\\*", "(.*)", StringComparison.InvariantCultureIgnoreCase);
-#endif
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
return Regex.IsMatch(value, $"^{regex}$", options);
}
diff --git a/test/TestableHttpClient.IntegrationTests/AssertingRequests.cs b/test/TestableHttpClient.IntegrationTests/AssertingRequests.cs
index 26875f8..5f1f317 100644
--- a/test/TestableHttpClient.IntegrationTests/AssertingRequests.cs
+++ b/test/TestableHttpClient.IntegrationTests/AssertingRequests.cs
@@ -162,17 +162,31 @@ public async Task AssertingContent()
using StringContent content = new("my special content");
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
-#if NETFRAMEWORK
- // On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
- testHandler.ShouldHaveMadeRequests();
-#else
testHandler.ShouldHaveMadeRequests().WithContent("my special content");
testHandler.ShouldHaveMadeRequests().WithContent("my*content");
testHandler.ShouldHaveMadeRequests().WithContent("*");
Assert.Throws(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
Assert.Throws(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
-#endif
+ }
+
+ [Fact]
+ public async Task AssertingContent_WhenOriginalContentIsDisposed()
+ {
+ using TestableHttpMessageHandler testHandler = new();
+ using HttpClient client = new(testHandler);
+
+ using (StringContent content = new("my special content"))
+ {
+ _ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
+ }
+
+ testHandler.ShouldHaveMadeRequests().WithContent("my special content");
+ testHandler.ShouldHaveMadeRequests().WithContent("my*content");
+ testHandler.ShouldHaveMadeRequests().WithContent("*");
+
+ Assert.Throws(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
+ Assert.Throws(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
}
[Fact]
@@ -184,12 +198,7 @@ public async Task AssertJsonContent()
using StringContent content = new("{}", Encoding.UTF8, "application/json");
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
-#if NETFRAMEWORK
- // On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
- testHandler.ShouldHaveMadeRequests();
-#else
testHandler.ShouldHaveMadeRequests().WithJsonContent(new { });
-#endif
}
[Fact]
diff --git a/test/TestableHttpClient.IntegrationTests/CustomizeJsonSerialization.cs b/test/TestableHttpClient.IntegrationTests/CustomizeJsonSerialization.cs
index 40d13bd..a47b6c8 100644
--- a/test/TestableHttpClient.IntegrationTests/CustomizeJsonSerialization.cs
+++ b/test/TestableHttpClient.IntegrationTests/CustomizeJsonSerialization.cs
@@ -14,11 +14,7 @@ public async Task ByDefault_CamelCasing_is_used_for_json_serialization()
sut.RespondWith(Json(new { Name = "Charlie" }));
using HttpClient client = sut.CreateClient();
-#if NETFRAMEWORK
- string json = await client.GetStringAsync("http://localhost/myjson");
-#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
-#endif
Assert.Equal("{\"name\":\"Charlie\"}", json);
}
@@ -31,11 +27,7 @@ public async Task But_this_can_be_changed()
sut.RespondWith(Json(new { Name = "Charlie" }));
using HttpClient client = sut.CreateClient();
-#if NETFRAMEWORK
- string json = await client.GetStringAsync("http://localhost/myjson");
-#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
-#endif
Assert.Equal("{\"Name\":\"Charlie\"}", json);
}
@@ -47,11 +39,7 @@ public async Task But_Also_directly_on_the_response()
sut.RespondWith(Json(new { Name = "Charlie" }, jsonSerializerOptions: new JsonSerializerOptions()));
using HttpClient client = sut.CreateClient();
-#if NETFRAMEWORK
- string json = await client.GetStringAsync("http://localhost/myjson");
-#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
-#endif
Assert.Equal("{\"Name\":\"Charlie\"}", json);
}
@@ -63,12 +51,7 @@ public async Task Asserting_also_works_this_way()
using HttpClient client = sut.CreateClient();
await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, cancellationToken: TestContext.Current.CancellationToken);
-#if NETFRAMEWORK
- // Well this doesn't really work on .NET Framework.
- sut.ShouldHaveMadeRequests();
-#else
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" });
-#endif
}
[Fact]
@@ -84,11 +67,6 @@ public async Task And_we_can_go_crazy_with_it()
await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, options, cancellationToken: TestContext.Current.CancellationToken);
-#if NETFRAMEWORK
- // Well this doesn't really work on .NET Framework.
- sut.ShouldHaveMadeRequests();
-#else
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" }, options);
-#endif
}
}
diff --git a/test/TestableHttpClient.IntegrationTests/NetFrameworkPollyFill.cs b/test/TestableHttpClient.IntegrationTests/NetFrameworkPollyFill.cs
index 2d036e7..64a09c1 100644
--- a/test/TestableHttpClient.IntegrationTests/NetFrameworkPollyFill.cs
+++ b/test/TestableHttpClient.IntegrationTests/NetFrameworkPollyFill.cs
@@ -1,8 +1,9 @@
-using System.Threading;
+#if NETFRAMEWORK
+
+using System.Threading;
namespace TestableHttpClient.IntegrationTests;
-#if NETFRAMEWORK
internal static class NetFrameworkPollyFill
{
@@ -10,6 +11,11 @@ public static Task ReadAsStringAsync(this HttpContent content, Cancellat
{
return content.ReadAsStringAsync();
}
+
+ public static Task GetStringAsync(this HttpClient client, string requestUri, CancellationToken cancellationToken = default)
+ {
+ return client.GetStringAsync(requestUri);
+ }
}
#endif
diff --git a/test/TestableHttpClient.Tests/TestableHttpMessageHandlerTests.cs b/test/TestableHttpClient.Tests/TestableHttpMessageHandlerTests.cs
index feb462d..9539673 100644
--- a/test/TestableHttpClient.Tests/TestableHttpMessageHandlerTests.cs
+++ b/test/TestableHttpClient.Tests/TestableHttpMessageHandlerTests.cs
@@ -16,7 +16,7 @@ public async Task SendAsync_WhenRequestsAreMade_LogsRequests()
_ = await client.SendAsync(request, TestContext.Current.CancellationToken);
- Assert.Contains(request, sut.Requests);
+ Assert.Contains(request, sut.Requests, new SimpleHttpRequestMessageComparer());
}
[Fact]
@@ -34,7 +34,7 @@ public async Task SendAsync_WhenMultipleRequestsAreMade_AllRequestsAreLogged()
_ = await client.SendAsync(request3, TestContext.Current.CancellationToken);
_ = await client.SendAsync(request4, TestContext.Current.CancellationToken);
- Assert.Equal([request1, request2, request3, request4], sut.Requests);
+ Assert.Equal([request1, request2, request3, request4], sut.Requests, new SimpleHttpRequestMessageComparer());
}
[Fact]
@@ -210,4 +210,24 @@ public void Dispose()
GC.SuppressFinalize(this);
}
}
+
+ private sealed class SimpleHttpRequestMessageComparer : IEqualityComparer
+ {
+ public bool Equals(HttpRequestMessage? x, HttpRequestMessage? y)
+ {
+ if (x is null && y is null)
+ {
+ return true;
+ }
+
+ if (x is null || y is null)
+ {
+ return false;
+ }
+
+ return x.Method == y.Method && x.RequestUri == y.RequestUri && x.Version == y.Version;
+ }
+
+ public int GetHashCode([DisallowNull] HttpRequestMessage obj) => HashCode.Combine(obj.Method, obj.RequestUri, obj.Version);
+ }
}