Skip to content

Commit 11bae8a

Browse files
authored
Identifying if a request has a body #24175 (#24984)
1 parent df37d00 commit 11bae8a

26 files changed

+591
-41
lines changed

src/Hosting/TestHost/src/ClientHandler.cs

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -66,32 +66,35 @@ protected override async Task<HttpResponseMessage> SendAsync(
6666

6767
var contextBuilder = new HttpContextBuilder(_application, AllowSynchronousIO, PreserveExecutionContext);
6868

69-
var requestContent = request.Content ?? new StreamContent(Stream.Null);
69+
var requestContent = request.Content;
7070

71-
// Read content from the request HttpContent into a pipe in a background task. This will allow the request
72-
// delegate to start before the request HttpContent is complete. A background task allows duplex streaming scenarios.
73-
contextBuilder.SendRequestStream(async writer =>
71+
if (requestContent != null)
7472
{
75-
if (requestContent is StreamContent)
73+
// Read content from the request HttpContent into a pipe in a background task. This will allow the request
74+
// delegate to start before the request HttpContent is complete. A background task allows duplex streaming scenarios.
75+
contextBuilder.SendRequestStream(async writer =>
7676
{
77+
if (requestContent is StreamContent)
78+
{
7779
// This is odd but required for backwards compat. If StreamContent is passed in then seek to beginning.
7880
// This is safe because StreamContent.ReadAsStreamAsync doesn't block. It will return the inner stream.
7981
var body = await requestContent.ReadAsStreamAsync();
80-
if (body.CanSeek)
81-
{
82+
if (body.CanSeek)
83+
{
8284
// This body may have been consumed before, rewind it.
8385
body.Seek(0, SeekOrigin.Begin);
84-
}
86+
}
8587

86-
await body.CopyToAsync(writer);
87-
}
88-
else
89-
{
90-
await requestContent.CopyToAsync(writer.AsStream());
91-
}
88+
await body.CopyToAsync(writer);
89+
}
90+
else
91+
{
92+
await requestContent.CopyToAsync(writer.AsStream());
93+
}
9294

93-
await writer.CompleteAsync();
94-
});
95+
await writer.CompleteAsync();
96+
});
97+
}
9598

9699
contextBuilder.Configure((context, reader) =>
97100
{
@@ -110,6 +113,39 @@ protected override async Task<HttpResponseMessage> SendAsync(
110113

111114
req.Scheme = request.RequestUri.Scheme;
112115

116+
var canHaveBody = false;
117+
if (requestContent != null)
118+
{
119+
canHaveBody = true;
120+
// Chunked takes precedence over Content-Length, don't create a request with both Content-Length and chunked.
121+
if (request.Headers.TransferEncodingChunked != true)
122+
{
123+
// Reading the ContentLength will add it to the Headers‼
124+
// https://github.com/dotnet/runtime/blob/874399ab15e47c2b4b7c6533cc37d27d47cb5242/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HttpContentHeaders.cs#L68-L87
125+
var contentLength = requestContent.Headers.ContentLength;
126+
if (!contentLength.HasValue && request.Version == HttpVersion.Version11)
127+
{
128+
// HTTP/1.1 requests with a body require either Content-Length or Transfer-Encoding: chunked.
129+
request.Headers.TransferEncodingChunked = true;
130+
}
131+
else if (contentLength == 0)
132+
{
133+
canHaveBody = false;
134+
}
135+
}
136+
137+
foreach (var header in requestContent.Headers)
138+
{
139+
req.Headers.Append(header.Key, header.Value.ToArray());
140+
}
141+
142+
if (canHaveBody)
143+
{
144+
req.Body = new AsyncStreamWrapper(reader.AsStream(), () => contextBuilder.AllowSynchronousIO);
145+
}
146+
}
147+
context.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(canHaveBody));
148+
113149
foreach (var header in request.Headers)
114150
{
115151
// User-Agent is a space delineated single line header but HttpRequestHeaders parses it as multiple elements.
@@ -141,17 +177,6 @@ protected override async Task<HttpResponseMessage> SendAsync(
141177
req.PathBase = _pathBase;
142178
}
143179
req.QueryString = QueryString.FromUriComponent(request.RequestUri);
144-
145-
// Reading the ContentLength will add it to the Headers‼
146-
// https://github.com/dotnet/runtime/blob/874399ab15e47c2b4b7c6533cc37d27d47cb5242/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HttpContentHeaders.cs#L68-L87
147-
_ = requestContent.Headers.ContentLength;
148-
149-
foreach (var header in requestContent.Headers)
150-
{
151-
req.Headers.Append(header.Key, header.Value.ToArray());
152-
}
153-
154-
req.Body = new AsyncStreamWrapper(reader.AsStream(), () => contextBuilder.AllowSynchronousIO);
155180
});
156181

157182
var response = new HttpResponseMessage();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Http.Features;
5+
6+
namespace Microsoft.AspNetCore.TestHost
7+
{
8+
internal class RequestBodyDetectionFeature : IHttpRequestBodyDetectionFeature
9+
{
10+
public RequestBodyDetectionFeature(bool canHaveBody)
11+
{
12+
CanHaveBody = canHaveBody;
13+
}
14+
15+
public bool CanHaveBody { get; }
16+
}
17+
}

src/Hosting/TestHost/test/ClientHandlerTests.cs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ public Task ContentLengthWithBodyWorks()
111111
var contentBytes = Encoding.UTF8.GetBytes("This is a content!");
112112
var handler = new ClientHandler(new PathString(""), new DummyApplication(context =>
113113
{
114+
Assert.True(context.Request.CanHaveBody());
114115
Assert.Equal(contentBytes.LongLength, context.Request.ContentLength);
116+
Assert.False(context.Request.Headers.ContainsKey(HeaderNames.TransferEncoding));
115117

116118
return Task.CompletedTask;
117119
}));
@@ -122,11 +124,13 @@ public Task ContentLengthWithBodyWorks()
122124
}
123125

124126
[Fact]
125-
public Task ContentLengthWithNoBodyWorks()
127+
public Task ContentLengthNotPresentWithNoBody()
126128
{
127129
var handler = new ClientHandler(new PathString(""), new DummyApplication(context =>
128130
{
129-
Assert.Equal(0, context.Request.ContentLength);
131+
Assert.False(context.Request.CanHaveBody());
132+
Assert.Null(context.Request.ContentLength);
133+
Assert.False(context.Request.Headers.ContainsKey(HeaderNames.TransferEncoding));
130134

131135
return Task.CompletedTask;
132136
}));
@@ -136,11 +140,13 @@ public Task ContentLengthWithNoBodyWorks()
136140
}
137141

138142
[Fact]
139-
public Task ContentLengthWithChunkedTransferEncodingWorks()
143+
public Task ContentLengthWithImplicitChunkedTransferEncodingWorks()
140144
{
141145
var handler = new ClientHandler(new PathString(""), new DummyApplication(context =>
142146
{
147+
Assert.True(context.Request.CanHaveBody());
143148
Assert.Null(context.Request.ContentLength);
149+
Assert.Equal("chunked", context.Request.Headers[HeaderNames.TransferEncoding]);
144150

145151
return Task.CompletedTask;
146152
}));
@@ -150,6 +156,26 @@ public Task ContentLengthWithChunkedTransferEncodingWorks()
150156
return httpClient.PostAsync("http://example.com", new UnlimitedContent());
151157
}
152158

159+
[Fact]
160+
public Task ContentLengthWithExplicitChunkedTransferEncodingWorks()
161+
{
162+
var handler = new ClientHandler(new PathString(""), new DummyApplication(context =>
163+
{
164+
Assert.True(context.Request.CanHaveBody());
165+
Assert.Null(context.Request.ContentLength);
166+
Assert.Equal("chunked", context.Request.Headers[HeaderNames.TransferEncoding]);
167+
168+
return Task.CompletedTask;
169+
}));
170+
171+
var httpClient = new HttpClient(handler);
172+
httpClient.DefaultRequestHeaders.TransferEncodingChunked = true;
173+
var contentBytes = Encoding.UTF8.GetBytes("This is a content!");
174+
var content = new ByteArrayContent(contentBytes);
175+
176+
return httpClient.PostAsync("http://example.com", content);
177+
}
178+
153179
[Fact]
154180
public async Task ServerTrailersSetOnResponseAfterContentRead()
155181
{

src/Hosting/TestHost/test/HttpContextBuilderTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public async Task ExpectedValuesAreAvailable()
4040
Assert.Equal("/A/Path", context.Request.PathBase.Value);
4141
Assert.Equal("/and/file.txt", context.Request.Path.Value);
4242
Assert.Equal("?and=query", context.Request.QueryString.Value);
43+
Assert.Null(context.Request.CanHaveBody());
4344
Assert.NotNull(context.Request.Body);
4445
Assert.NotNull(context.Request.Headers);
4546
Assert.NotNull(context.Response.Headers);

src/Hosting/TestHost/test/TestServerTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ public async Task DispoingTheRequestBodyDoesNotDisposeClientStreams()
228228

229229
var stream = new ThrowOnDisposeStream();
230230
stream.Write(Encoding.ASCII.GetBytes("Hello World"));
231+
stream.Seek(0, SeekOrigin.Begin);
231232
var response = await server.CreateClient().PostAsync("/", new StreamContent(stream));
232233
Assert.True(response.IsSuccessStatusCode);
233234
Assert.Equal("Hello World", await response.Content.ReadAsStringAsync());

src/Hosting/TestHost/test/Utilities.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System;
55
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Http.Features;
68
using Microsoft.AspNetCore.Testing;
79

810
namespace Microsoft.AspNetCore.TestHost
@@ -14,5 +16,10 @@ internal static class Utilities
1416
internal static Task<T> WithTimeout<T>(this Task<T> task) => task.TimeoutAfter(DefaultTimeout);
1517

1618
internal static Task WithTimeout(this Task task) => task.TimeoutAfter(DefaultTimeout);
19+
20+
internal static bool? CanHaveBody(this HttpRequest request)
21+
{
22+
return request.HttpContext.Features.Get<IHttpRequestBodyDetectionFeature>()?.CanHaveBody;
23+
}
1724
}
1825
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Http.Features
5+
{
6+
/// <summary>
7+
/// Used to indicate if the request can have a body.
8+
/// </summary>
9+
public interface IHttpRequestBodyDetectionFeature
10+
{
11+
/// <summary>
12+
/// Indicates if the request can have a body.
13+
/// </summary>
14+
/// <remarks>
15+
/// This returns true when:
16+
/// - It's an HTTP/1.x request with a non-zero Content-Length or a 'Transfer-Encoding: chunked' header.
17+
/// - It's an HTTP/2 request that did not set the END_STREAM flag on the initial headers frame.
18+
/// The final request body length may still be zero for the chunked or HTTP/2 scenarios.
19+
///
20+
/// This returns false when:
21+
/// - It's an HTTP/1.x request with no Content-Length or 'Transfer-Encoding: chunked' header, or the Content-Length is 0.
22+
/// - It's an HTTP/1.x request with Connection: Upgrade (e.g. WebSockets). There is no HTTP request body for these requests and
23+
/// no data should be received until after the upgrade.
24+
/// - It's an HTTP/2 request that set END_STREAM on the initial headers frame.
25+
/// When false, the request body should never return data.
26+
/// </remarks>
27+
bool CanHaveBody { get; }
28+
}
29+
}

src/Servers/HttpSys/src/FeatureContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ namespace Microsoft.AspNetCore.Server.HttpSys
2323
{
2424
internal class FeatureContext :
2525
IHttpRequestFeature,
26+
IHttpRequestBodyDetectionFeature,
2627
IHttpConnectionFeature,
2728
IHttpResponseFeature,
2829
IHttpResponseBodyFeature,
@@ -212,6 +213,8 @@ string IHttpRequestFeature.Scheme
212213
set { _scheme = value; }
213214
}
214215

216+
bool IHttpRequestBodyDetectionFeature.CanHaveBody => Request.HasEntityBody;
217+
215218
IPAddress IHttpConnectionFeature.LocalIpAddress
216219
{
217220
get

src/Servers/HttpSys/src/RequestProcessing/Request.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ public long? ContentLength
135135
{
136136
if (_contentBoundaryType == BoundaryType.None)
137137
{
138+
// Note Http.Sys adds the Transfer-Encoding: chunked header to HTTP/2 requests with bodies for back compat.
138139
string transferEncoding = Headers[HttpKnownHeaderNames.TransferEncoding];
139140
if (string.Equals("chunked", transferEncoding?.Trim(), StringComparison.OrdinalIgnoreCase))
140141
{

src/Servers/HttpSys/src/StandardFeatureCollection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal sealed class StandardFeatureCollection : IFeatureCollection
1717
private static readonly Dictionary<Type, Func<FeatureContext, object>> _featureFuncLookup = new Dictionary<Type, Func<FeatureContext, object>>()
1818
{
1919
{ typeof(IHttpRequestFeature), _identityFunc },
20+
{ typeof(IHttpRequestBodyDetectionFeature), _identityFunc },
2021
{ typeof(IHttpConnectionFeature), _identityFunc },
2122
{ typeof(IHttpResponseFeature), _identityFunc },
2223
{ typeof(IHttpResponseBodyFeature), _identityFunc },

0 commit comments

Comments
 (0)