Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for binding the raw request body #39388

Merged
merged 24 commits into from
Jan 20, 2022
Merged

Conversation

davidfowl
Copy link
Member

@davidfowl davidfowl commented Jan 9, 2022

  • Added support for PipeReader, Stream, ReadOnlySequence, ReadOnlyMemory and byte[]
  • Added test

This will potentially break custom JSON converters for byte[] (maybe base64 JSON encoded content?)

Fixes #38153

TBD: Fix the error messages that talk about JSON binding when these types are being used.

- Added support for Stream, ReadOnlySequence<byte>, ReadOnlyMemory<byte> and byte[]
- Added test
Comment on lines 707 to 709
// REVIEW: Does this need to be a copy? We can tell users to consume the buffer
// immediately in the action
return (buffer, true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is PipeReader.AdvanceTo called to say the body is consumed? Before or after the request delegate is executed?

Copy link
Member Author

@davidfowl davidfowl Jan 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about this, it's actually broken for non Kestrel servers since nobody completes that PipeReader currently. I'll need to add more logic there. Good catch.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm actually no, it seems like we complete the pipe reader in both cases

if (_internalPipeReader == null ||
!ReferenceEquals(_streamInstanceWhenWrapped, _context.Request.Body))
{
_streamInstanceWhenWrapped = _context.Request.Body;
_internalPipeReader = PipeReader.Create(_context.Request.Body);
_context.Response.OnCompleted((self) =>
{
((PipeReader)self).Complete();
return Task.CompletedTask;
}, _internalPipeReader);
}
return _internalPipeReader;

- Advance the PipeReader preemptively in the cases where we copy the buffer.
- Add support for PipeReader as an argument.
- Split the code path for raw body binding with ReadOnlySequence<byte> to avoid boxing.
@davidfowl davidfowl requested a review from a team January 10, 2022 20:51

if (result.IsCompleted)
{
return buffer;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think calling the route handler with the reader in an incomplete/unadvanced state is problematic. Any code accessing the BodyReader, either through the HttpContext or as a separate PipeReader parameter, will be unable to partially examine the body. Any attempt will throw an InvalidOperationException complaining that "The examined position cannot be less than the previously examined position."

I think we're better off just fully consuming the body if you take a ReadOnlySequence<byte> parameter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t want to copy but I appreciate the potential problems. I’d rather leave that with the caveats or cut the whole feature (which would make me sad but I’d get over it).

Copy link
Member

@halter73 halter73 Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this endpoint:

app.MapPost("/read-twice", async (HttpContext httpContext, ReadOnlySequence<byte> body) =>
{
    await httpContext.Request.Body.ReadAsync(new byte[1], 0, 0);
});

you get the following error:

System.InvalidOperationException: Reading is already in progress.
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1ContentLengthMessageBody.ReadAsyncInternal(CancellationToken cancellationToken) in C:\dev\dotnet\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http\Http1ContentLengthMessageBody.cs:line 34
   at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestStream.ReadAsyncInternal(Memory`1 destination, CancellationToken cancellationToken) in C:\dev\dotnet\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http\HttpRequestStream.cs:line 114
   at Program.<>c.<<<Main>$>b__0_7>d.MoveNext() in C:\dev\dotnet\aspnetcore\src\Http\samples\MinimalSample\Program.cs:line 42
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass54_1.<<HandleRequestBodyAndCompileRequestDelegateForRawBody>b__2>d.MoveNext() in C:\dev\dotnet\aspnetcore\src\Http\Http.Extensions\src\RequestDelegateFactory.cs:line 647

I don't think this is acceptable. We should either:

  1. Consume the entire body so this read returns 0 (edit: This would require a copy anyway to avoid referencing memory that's already returned to the pool, so at this point we might as well do option 2)
  2. Buffer the entire body so you can actually read it again
  3. Poison the body so that it throws a better error than System.InvalidOperationException: Reading is already in progress.
  4. Cut the whole feature

Copy link
Member Author

@davidfowl davidfowl Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I pushed another commit with option 3a. I set the body to Stream.Null. This fixes trying to consume the body after it's already been consumed by our binding logic.


// Looping over arrays is faster
var binders = factoryContext.ParameterBinders.ToArray();
var count = binders.Length;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could capture this inside the lamda, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally didn’t do it would only capture these locals only (in case we nulled out the factory context later).


private static Func<object?, HttpContext, Task> HandleRequestBodyAndCompileRequestDelegateForRawBody(Expression responseWritingMethodCall, FactoryContext factoryContext)
{
Debug.Assert(factoryContext.RequestBodyParameter is not null, "factoryContext.RequestBodyParameter is null for a body parameter.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean

Suggested change
Debug.Assert(factoryContext.RequestBodyParameter is not null, "factoryContext.RequestBodyParameter is null for a body parameter.");
Debug.Assert(factoryContext.RequestBodyParameter is null, "factoryContext.RequestBodyParameter is null for a body parameter.");

Copy link
Member Author

@davidfowl davidfowl Jan 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. We're asserting that it isn't null

if (factoryContext.ParameterBinders.Count > 0)
{
// We need to generate the code for reading from the body before calling into the delegate
var continuation = Expression.Lambda<Func<object?, HttpContext, ReadOnlySequence<byte>, object?[], Task>>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noob question - why is this task returning (vs ValueTask returning)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be either but using Task vs ValueTask but there’s no benefit to using ValueTask here. There’s no pooling to be had and synchronous completion won’t allocate regardless

{
var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>();

if (feature?.CanHaveBody == true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the behavior with JSON if there isn't a body to read from? We should try and emulate that behavior.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If allow empty body is true it’ll bind otherwise 400

httpContext.Items.Add("body", body.ToArray());
}

void TestStream(HttpContext httpContext, Stream stream)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat.

public bool AllowEmptyRequestBody { get; set; }
public bool IsRawRequestBody => RequestBodyParameter?.ParameterType == typeof(ReadOnlySequence<byte>);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be interesting to see if people actually start using it. It's a somewhat obtuse type to actually use (particularly when you can bind the PipeReader instead).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s something we’d have to advertise. I had support for ReadOnlyMemory<byte> and byte[] but removed them.

- This prevents code that would otherwise read the body from doing so after consuming it.
Comment on lines 666 to 668
// We're not buffering the body so we want to block consuming code from reading again
// and getting weird errors. Treat further reads as a fully consumed body.
httpContext.Request.Body = Stream.Null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd, what if someone else is buffering the body?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you'd have to check if the body is seekeable, if so seek it. RadFormAsync / MVC doesn't change / reset the Body once it has been read, couldn't we do the same here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think seeking given a seekable stream is fine.

If we're replacing the body anyway, we should advance the reader before returning even if Kestrel will do it anyway.

We would have to do this after invoking the route handler, so memory doesn't get returned too soon. I'm still worried about people keeping a reference to the ReadOnlySequence<byte> after the route handler completes if we don't copy.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're replacing the body anyway, we should advance the reader before returning even if Kestrel will do it anyway.

Just for cleanliness? It's not just Kestrel doing it, it would happen for any properly implemented pipereader (it will complete the pipereader).

I'm still worried about people keeping a reference to the ReadOnlySequence after the route handler completes if we don't copy.

Yea but this feature is about not copying the buffer. That's why it's a ROS<byte>. That's the one thing I'm unwilling to compromise here on as I think it makes the original scenario (posted in the issue) less efficient.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd, what if someone else is buffering the body?

I don't see how that affects this? I can revert and set the the original stream after executing the handler. The body is being buffered for the logic that is consuming the buffer.

Copy link
Member Author

@davidfowl davidfowl Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the reference to the pipe that we read and we can complete it. It's just redundant is all. Only a badly written application would end up never completing the pipe anyways. It would also need to be explicit written to do so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more about generic code that might try to read any unconsumed bytes from the body after the route handler runs but before middleware completes.

Copy link
Member Author

@davidfowl davidfowl Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also @Tratcher I don't think we should check is seekable here. Specifically because we need to poison both the stream and the PipeReader to be unusable 😄. Also our buffering logic doesn't work if you don't read using the Stream AFAIK.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least revert it when you're done then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

- Restore the original stream after the handler executes.
@davidfowl
Copy link
Member Author

This PR is ready now. I removed reading the request body as a raw buffer and am only supporting Stream and PipeReader.

Copy link
Member

@BrennanConroy BrennanConroy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks a lot simpler than before 😃

Do we have an issue to track doc updates for .NET 7? We need to add IFormFileCollection, PipeReader, and Stream to https://docs.microsoft.com/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0#special-types now

@davidfowl
Copy link
Member Author

Do we have an issue to track doc updates for .NET 7? We need to add IFormFileCollection, PipeReader, and Stream to https://docs.microsoft.com/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0#special-types now

We don't have one yet. Good idea.

@davidfowl davidfowl merged commit 08d1b8a into main Jan 20, 2022
@davidfowl davidfowl deleted the davidfowl/bind-raw-body branch January 20, 2022 18:05
@ghost ghost added this to the 7.0-preview1 milestone Jan 20, 2022
@davidfowl davidfowl added old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels and removed area-runtime labels Jan 20, 2022
ShreyasJejurkar pushed a commit to ShreyasJejurkar/aspnetcore that referenced this pull request Jan 22, 2022
- Added support for Stream, and PipeReader
- Added tests
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support binding request body as Stream/ReadOnlySequence<byte> maybe ReadOnlyMemory<byte>/ReadOnlySpan<byte>
7 participants