Skip to content

Commit a748c5b

Browse files
authored
Clone HttpRequestMessage so the original reqeuest can be disposed (#317)
* Clone HttpRequestMessage so the copy is asserted and the original request can be disposed properly * Reduce differences between .netstandard and .net by adding more pollyfills. Reduce netframework specific tests, since they are no longer needed because of the cloning.
1 parent f39e63a commit a748c5b

File tree

12 files changed

+132
-54
lines changed

12 files changed

+132
-54
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1111
- 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.
1212
### Added
1313
- Support for .NET 9.0
14-
- support for .NET 10.0
14+
- Support for .NET 10.0
15+
### Changed
16+
- The TestableHttpMessageHandler now makes a clone of the original request, so that the original request can be disposed.
17+
This change also makes it possible to assert the content on .NET Framework.
1518

1619
## [0.11] - 2024-06-15
1720
### Removed

src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
258258
/// <param name="check">The implementation that hold all the request messages.</param>
259259
/// <param name="pattern">The expected content, supports wildcards.</param>
260260
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
261-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
262261
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern) => WithContent(check, pattern, null);
263262

264263
/// <summary>
@@ -268,7 +267,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
268267
/// <param name="pattern">The expected content, supports wildcards.</param>
269268
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
270269
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
271-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
272270
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithContent(check, pattern, (int?)expectedNumberOfRequests);
273271

274272
private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int? expectedNumberOfRequests)
@@ -285,7 +283,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
285283
/// <param name="check">The implementation that hold all the request messages.</param>
286284
/// <param name="jsonObject">The object representation of the expected request content.</param>
287285
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
288-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
289286
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject) => WithJsonContent(check, jsonObject, null, null);
290287

291288
/// <summary>
@@ -295,7 +292,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
295292
/// <param name="jsonObject">The object representation of the expected request content.</param>
296293
/// <param name="jsonSerializerOptions">The serializer options that should be used for serializing te content.</param>
297294
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
298-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
299295
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions) => WithJsonContent(check, jsonObject, jsonSerializerOptions, null);
300296

301297
/// <summary>
@@ -305,7 +301,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
305301
/// <param name="jsonObject">The object representation of the expected request content.</param>
306302
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
307303
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
308-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
309304
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, null, (int?)expectedNumberOfRequests);
310305

311306
/// <summary>
@@ -316,7 +311,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
316311
/// <param name="jsonSerializerOptions">The serializer options that should be used for serializing the content.</param>
317312
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
318313
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
319-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
320314
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, jsonSerializerOptions, (int?)expectedNumberOfRequests);
321315

322316
private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions? jsonSerializerOptions, int? expectedNumberOfRequests)
@@ -334,7 +328,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
334328
/// <param name="check">The implementation that hold all the request messages.</param>
335329
/// <param name="nameValueCollection">The collection of key/value pairs that should be url encoded.</param>
336330
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
337-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
338331
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection) => WithFormUrlEncodedContent(check, nameValueCollection, null);
339332

340333
/// <summary>
@@ -344,7 +337,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
344337
/// <param name="nameValueCollection">The collection of key/value pairs that should be url encoded.</param>
345338
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
346339
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
347-
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
348340
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection, int expectedNumberOfRequests) => WithFormUrlEncodedContent(check, nameValueCollection, (int?)expectedNumberOfRequests);
349341

350342
private static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection, int? expectedNumberOfRequests)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-

1+
override TestableHttpClient.TestableHttpMessageHandler.Dispose(bool disposing) -> void

src/TestableHttpClient/TestableHttpMessageHandler.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,28 @@ public class TestableHttpMessageHandler : HttpMessageHandler
1717
/// </summary>
1818
public IEnumerable<HttpRequestMessage> Requests => httpRequestMessages;
1919

20+
protected override void Dispose(bool disposing)
21+
{
22+
DisposeRequestMessages();
23+
base.Dispose(disposing);
24+
}
25+
26+
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "It gets disposed in the dispose method")]
2027
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
2128
{
22-
httpRequestMessages.Enqueue(request);
29+
Guard.ThrowIfNull(request);
30+
31+
httpRequestMessages.Enqueue(await HttpRequestMessageCloner.ClonaAsync(request, cancellationToken).ConfigureAwait(false));
2332

2433
HttpResponseMessage responseMessage = new();
2534
HttpResponseContext context = new(request, httpRequestMessages, responseMessage, Options);
2635
await response.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
2736

2837
responseMessage.RequestMessage ??= request;
2938

30-
#if !NET6_0_OR_GREATER
39+
// In .NET Standard, a response message can be null, but we need it to be at least empty like in the newer versions.
40+
// Newer versions will always have a content, even if it's empty.
3141
responseMessage.Content ??= new StringContent("");
32-
#endif
3342

3443
return responseMessage;
3544
}
@@ -55,6 +64,15 @@ public void RespondWith(IResponse response)
5564
/// <remarks>The configuration itself (Options and the configured IResponse) will not be cleared or reset.</remarks>
5665
public void ClearRequests()
5766
{
67+
DisposeRequestMessages();
5868
httpRequestMessages.Clear();
5969
}
70+
71+
private void DisposeRequestMessages()
72+
{
73+
foreach (HttpRequestMessage request in httpRequestMessages)
74+
{
75+
request.Dispose();
76+
}
77+
}
6078
}

src/TestableHttpClient/Utils/Guard.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ internal static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgum
2929
throw new ArgumentException("String should not be empty", paramName);
3030
}
3131
#else
32-
ArgumentNullException.ThrowIfNullOrEmpty(argument, paramName);
32+
ArgumentException.ThrowIfNullOrEmpty(argument, paramName);
3333
#endif
3434
}
3535
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace TestableHttpClient.Utils;
2+
3+
internal static class HttpRequestMessageCloner
4+
{
5+
internal static async Task<HttpRequestMessage> ClonaAsync(HttpRequestMessage request, CancellationToken cancellationToken)
6+
{
7+
HttpRequestMessage clone = new()
8+
{
9+
Method = request.Method,
10+
RequestUri = request.RequestUri,
11+
Version = request.Version,
12+
};
13+
14+
foreach (var item in request.Headers)
15+
{
16+
clone.Headers.TryAddWithoutValidation(item.Key, item.Value);
17+
}
18+
19+
if (request.Content is not null)
20+
{
21+
var bytes = await request.Content
22+
.ReadAsByteArrayAsync(cancellationToken)
23+
.ConfigureAwait(false);
24+
var contentClone = new ByteArrayContent(bytes);
25+
contentClone.Headers.Clear();
26+
// copy content headers
27+
foreach (var header in request.Content.Headers)
28+
{
29+
contentClone.Headers.TryAddWithoutValidation(header.Key, header.Value);
30+
}
31+
32+
clone.Content = contentClone;
33+
}
34+
35+
return clone;
36+
}
37+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#if NETSTANDARD
2+
3+
namespace TestableHttpClient.Utils;
4+
5+
internal static class NetStandardPollyFill
6+
{
7+
public static Task<byte[]> ReadAsByteArrayAsync(this HttpContent content, CancellationToken cancellationToken = default)
8+
{
9+
return content.ReadAsByteArrayAsync();
10+
}
11+
12+
public static string Replace(this string input, string oldValue, string newValue, StringComparison comparisonType)
13+
{
14+
return input.Replace(oldValue, newValue);
15+
}
16+
}
17+
18+
#endif

src/TestableHttpClient/Utils/StringMatcher.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,8 @@ internal static class StringMatcher
77
internal static bool Matches(string value, string pattern, bool ignoreCase = false)
88
{
99
var escapedPattern = Regex.Escape(pattern);
10-
#if NETSTANDARD2_0
11-
var regex = escapedPattern.Replace("\\*", "(.*)");
12-
#else
10+
1311
var regex = escapedPattern.Replace("\\*", "(.*)", StringComparison.InvariantCultureIgnoreCase);
14-
#endif
1512
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
1613
return Regex.IsMatch(value, $"^{regex}$", options);
1714
}

test/TestableHttpClient.IntegrationTests/AssertingRequests.cs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,31 @@ public async Task AssertingContent()
162162
using StringContent content = new("my special content");
163163
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
164164

165-
#if NETFRAMEWORK
166-
// On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
167-
testHandler.ShouldHaveMadeRequests();
168-
#else
169165
testHandler.ShouldHaveMadeRequests().WithContent("my special content");
170166
testHandler.ShouldHaveMadeRequests().WithContent("my*content");
171167
testHandler.ShouldHaveMadeRequests().WithContent("*");
172168

173169
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
174170
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
175-
#endif
171+
}
172+
173+
[Fact]
174+
public async Task AssertingContent_WhenOriginalContentIsDisposed()
175+
{
176+
using TestableHttpMessageHandler testHandler = new();
177+
using HttpClient client = new(testHandler);
178+
179+
using (StringContent content = new("my special content"))
180+
{
181+
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
182+
}
183+
184+
testHandler.ShouldHaveMadeRequests().WithContent("my special content");
185+
testHandler.ShouldHaveMadeRequests().WithContent("my*content");
186+
testHandler.ShouldHaveMadeRequests().WithContent("*");
187+
188+
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
189+
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
176190
}
177191

178192
[Fact]
@@ -184,12 +198,7 @@ public async Task AssertJsonContent()
184198
using StringContent content = new("{}", Encoding.UTF8, "application/json");
185199
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
186200

187-
#if NETFRAMEWORK
188-
// On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
189-
testHandler.ShouldHaveMadeRequests();
190-
#else
191201
testHandler.ShouldHaveMadeRequests().WithJsonContent(new { });
192-
#endif
193202
}
194203

195204
[Fact]

test/TestableHttpClient.IntegrationTests/CustomizeJsonSerialization.cs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ public async Task ByDefault_CamelCasing_is_used_for_json_serialization()
1414
sut.RespondWith(Json(new { Name = "Charlie" }));
1515
using HttpClient client = sut.CreateClient();
1616

17-
#if NETFRAMEWORK
18-
string json = await client.GetStringAsync("http://localhost/myjson");
19-
#else
2017
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
21-
#endif
2218

2319
Assert.Equal("{\"name\":\"Charlie\"}", json);
2420
}
@@ -31,11 +27,7 @@ public async Task But_this_can_be_changed()
3127
sut.RespondWith(Json(new { Name = "Charlie" }));
3228
using HttpClient client = sut.CreateClient();
3329

34-
#if NETFRAMEWORK
35-
string json = await client.GetStringAsync("http://localhost/myjson");
36-
#else
3730
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
38-
#endif
3931

4032
Assert.Equal("{\"Name\":\"Charlie\"}", json);
4133
}
@@ -47,11 +39,7 @@ public async Task But_Also_directly_on_the_response()
4739
sut.RespondWith(Json(new { Name = "Charlie" }, jsonSerializerOptions: new JsonSerializerOptions()));
4840
using HttpClient client = sut.CreateClient();
4941

50-
#if NETFRAMEWORK
51-
string json = await client.GetStringAsync("http://localhost/myjson");
52-
#else
5342
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
54-
#endif
5543

5644
Assert.Equal("{\"Name\":\"Charlie\"}", json);
5745
}
@@ -63,12 +51,7 @@ public async Task Asserting_also_works_this_way()
6351
using HttpClient client = sut.CreateClient();
6452
await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, cancellationToken: TestContext.Current.CancellationToken);
6553

66-
#if NETFRAMEWORK
67-
// Well this doesn't really work on .NET Framework.
68-
sut.ShouldHaveMadeRequests();
69-
#else
7054
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" });
71-
#endif
7255
}
7356

7457
[Fact]
@@ -84,11 +67,6 @@ public async Task And_we_can_go_crazy_with_it()
8467

8568
await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, options, cancellationToken: TestContext.Current.CancellationToken);
8669

87-
#if NETFRAMEWORK
88-
// Well this doesn't really work on .NET Framework.
89-
sut.ShouldHaveMadeRequests();
90-
#else
9170
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" }, options);
92-
#endif
9371
}
9472
}

0 commit comments

Comments
 (0)