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); + } }