File: RequestDelegateGenerator\RequestDelegateCreationTests.JsonBody.cs
Web Access
Project: src\src\Http\Http.Extensions\test\Microsoft.AspNetCore.Http.Extensions.Tests.csproj (Microsoft.AspNetCore.Http.Extensions.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
using System.Globalization;
using System.IO.Pipelines;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Http.Generators.Tests;
 
public abstract partial class RequestDelegateCreationTests
{
    public static object[][] MapAction_ExplicitBodyParam_ComplexReturn_Data
    {
        get
        {
            var expectedBody = """{"id":0,"name":"Test Item","isComplete":false}"""{"id":0,"name":"Test Item","isComplete":false}""";
            var todo = new Todo()
            {
                Id = 0,
                Name = "Test Item",
                IsComplete = false
            };
            var withFilter = """
.AddEndpointFilter((c, n) => n(c));
""";
            var fromBodyRequiredSource = """app.MapPost("/", ([FromBody] Todo todo) => TypedResults.Ok(todo));""";
            var fromBodyEmptyBodyBehaviorSource = """app.MapPost("/", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Todo todo) => TypedResults.Ok(todo));""";
            var fromBodyAllowEmptySource = """app.MapPost("/", ([CustomFromBody(AllowEmpty = true)] Todo todo) => TypedResults.Ok(todo));""";
            var fromBodyNullableSource = """app.MapPost("/", ([FromBody] Todo? todo) => TypedResults.Ok(todo));""";
            var fromBodyDefaultValueSource = """
#nullable disable
IResult postTodoWithDefault([FromBody] Todo todo = null) => TypedResults.Ok(todo);
app.MapPost("/", postTodoWithDefault);
#nullable restore
""";
            var fromBodyAsParametersRequiredSource = """app.MapPost("/", ([AsParameters] ParametersListWithExplicitFromBody args) => TypedResults.Ok(args.Todo));""";
            var fromBodyRequiredWithFilterSource = $"""app.MapPost("/", ([FromBody] Todo todo) => TypedResults.Ok(todo)){withFilter}""";
            var fromBodyEmptyBehaviorWithFilterSource = $"""app.MapPost("/", ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] Todo todo) => TypedResults.Ok(todo)){withFilter}""";
            var fromBodyAllowEmptyWithFilterSource = $"""app.MapPost("/", ([CustomFromBody(AllowEmpty = true)] Todo todo) => TypedResults.Ok(todo)){withFilter}""";
            var fromBodyNullableWithFilterSource = $"""app.MapPost("/", ([FromBody] Todo?  todo) => TypedResults.Ok(todo)){withFilter}""";
            var fromBodyDefaultValueWithFilterSource = $"""
#nullable disable
IResult postTodoWithDefault([FromBody] Todo todo = null) => TypedResults.Ok(todo);
app.MapPost("/", postTodoWithDefault){withFilter}
#nullable restore
""";
 
            return new[]
            {
                new object[] { fromBodyRequiredSource, todo, 200, expectedBody },
                new object[] { fromBodyRequiredSource, null, 400, string.Empty },
                new object[] { fromBodyAsParametersRequiredSource, todo, 200, expectedBody },
                new object[] { fromBodyEmptyBodyBehaviorSource, todo, 200, expectedBody },
                new object[] { fromBodyEmptyBodyBehaviorSource, null, 200, string.Empty },
                new object[] { fromBodyAllowEmptySource, todo, 200, expectedBody },
                new object[] { fromBodyAllowEmptySource, null, 200, string.Empty },
                new object[] { fromBodyNullableSource, todo, 200, expectedBody },
                new object[] { fromBodyNullableSource, null, 200, string.Empty },
                new object[] { fromBodyDefaultValueSource, todo, 200, expectedBody },
                new object[] { fromBodyDefaultValueSource, null, 200, string.Empty },
                new object[] { fromBodyRequiredWithFilterSource, todo, 200, expectedBody },
                new object[] { fromBodyRequiredWithFilterSource, null, 400, string.Empty },
                new object[] { fromBodyEmptyBehaviorWithFilterSource, todo, 200, expectedBody },
                new object[] { fromBodyEmptyBehaviorWithFilterSource, null, 200, string.Empty },
                new object[] { fromBodyAllowEmptyWithFilterSource, todo, 200, expectedBody },
                new object[] { fromBodyAllowEmptyWithFilterSource, null, 200, string.Empty },
                new object[] { fromBodyNullableWithFilterSource, todo, 200, expectedBody },
                new object[] { fromBodyNullableWithFilterSource, null, 200, string.Empty },
                new object[] { fromBodyDefaultValueWithFilterSource, todo, 200, expectedBody },
                new object[] { fromBodyDefaultValueSource, null, 200, string.Empty },
            };
        }
    }
 
    [Theory]
    [MemberData(nameof(MapAction_ExplicitBodyParam_ComplexReturn_Data))]
    public async Task MapAction_ExplicitBodyParam_ComplexReturn(string source, Todo requestData, int expectedStatusCode, string expectedBody)
    {
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(requestData is not null));
        httpContext.Request.Headers["Content-Type"] = "application/json";
 
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(requestData);
        var stream = new MemoryStream(requestBodyBytes);
        httpContext.Request.Body = stream;
        httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture);
 
        await endpoint.RequestDelegate(httpContext);
        await VerifyResponseBodyAsync(httpContext, expectedBody, expectedStatusCode);
    }
 
    [Fact]
    public async Task MapAction_ExplicitBodyParam_ComplexReturn_Returns400ForEmptyBody()
    {
        var source = """app.MapPost("/", ([FromBody] Todo todo) => TypedResults.Ok(todo));""";
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(false));
        httpContext.Request.Headers["Content-Type"] = "application/json";
        httpContext.Request.Headers["Content-Length"] = "0";
 
        await endpoint.RequestDelegate(httpContext);
        await VerifyResponseBodyAsync(httpContext, string.Empty, expectedStatusCode: 400);
    }
 
    [Fact]
    public async Task MapAction_ExplicitBodyParam_ComplexReturn_Snapshot()
    {
        var expectedBody = """{"id":0,"name":"Test Item","isComplete":false}"""{"id":0,"name":"Test Item","isComplete":false}""";
        var todo = new Todo()
        {
            Id = 0,
            Name = "Test Item",
            IsComplete = false
        };
        var source = $"""
app.MapPost("/fromBodyRequired", ([FromBody] Todo todo) => TypedResults.Ok(todo));
app.MapPost("/fromBodyOptional", ([FromBody] Todo? todo) => TypedResults.Ok(todo));
""";
        var (_, compilation) = await RunGeneratorAsync(source);
 
        await VerifyAgainstBaselineUsingFile(compilation);
 
        var endpoints = GetEndpointsFromCompilation(compilation);
 
        Assert.Equal(2, endpoints.Length);
 
        // formBodyRequired accepts a provided input
        var httpContext = CreateHttpContextWithBody(todo);
        await endpoints[0].RequestDelegate(httpContext);
        await VerifyResponseBodyAsync(httpContext, expectedBody);
 
        // formBodyRequired throws on null input
        httpContext = CreateHttpContextWithBody(null);
        await endpoints[0].RequestDelegate(httpContext);
        Assert.Equal(400, httpContext.Response.StatusCode);
 
        // formBodyOptional accepts a provided input
        httpContext = CreateHttpContextWithBody(todo);
        await endpoints[1].RequestDelegate(httpContext);
        await VerifyResponseBodyAsync(httpContext, expectedBody);
 
        // formBodyOptional accepts a null input
        httpContext = CreateHttpContextWithBody(null);
        await endpoints[1].RequestDelegate(httpContext);
        await VerifyResponseBodyAsync(httpContext, string.Empty);
    }
 
    public static object[][] ImplicitRawFromBodyActions
    {
        get
        {
            var testStreamSource = """
void TestStream(HttpContext httpContext, System.IO.Stream stream)
{
    var ms = new System.IO.MemoryStream();
    stream.CopyTo(ms);
    httpContext.Items.Add("body", ms.ToArray());
}
app.MapPost("/", TestStream);
""";
            var testPipeReaderSource = """
async Task TestPipeReader(HttpContext httpContext, System.IO.Pipelines.PipeReader reader)
{
    var ms = new System.IO.MemoryStream();
    await reader.CopyToAsync(ms);
    httpContext.Items.Add("body", ms.ToArray());
}
app.MapPost("/", TestPipeReader);
""";
 
            return new[]
            {
                new object[] { testStreamSource },
                new object[] { testPipeReaderSource }
            };
        }
    }
 
    public static object[][] ExplicitRawFromBodyActions
    {
        get
        {
            var explicitTestStreamSource = """
void TestStream(HttpContext httpContext, [FromBody] System.IO.Stream stream)
{
    var ms = new System.IO.MemoryStream();
    stream.CopyTo(ms);
    httpContext.Items.Add("body", ms.ToArray());
}
app.MapPost("/", TestStream);
""";
 
            var explicitTestPipeReaderSource = """
async Task TestPipeReader(HttpContext httpContext, [FromBody] System.IO.Pipelines.PipeReader reader)
{
    var ms = new System.IO.MemoryStream();
    await reader.CopyToAsync(ms);
    httpContext.Items.Add("body", ms.ToArray());
}
app.MapPost("/", TestPipeReader);
""";
 
            return new[]
            {
                new object[] { explicitTestStreamSource },
                new object[] { explicitTestPipeReaderSource }
            };
        }
    }
 
    [Theory]
    [MemberData(nameof(ImplicitRawFromBodyActions))]
    public async Task RequestDelegatePopulatesFromImplicitRawBodyParameter(string source)
    {
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Name = "Write more tests!"
        });
 
        var stream = new MemoryStream(requestBodyBytes);
        httpContext.Request.Body = stream;
        httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture);
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
 
        await endpoint.RequestDelegate(httpContext);
 
        Assert.Same(httpContext.Request.Body, stream);
 
        // Assert that we can read the body from both the pipe reader and Stream after executing
        httpContext.Request.Body.Position = 0;
        byte[] data = new byte[requestBodyBytes.Length];
        int read = await httpContext.Request.Body.ReadAsync(data.AsMemory());
        Assert.Equal(read, data.Length);
        Assert.Equal(requestBodyBytes, data);
 
        httpContext.Request.Body.Position = 0;
        var result = await httpContext.Request.BodyReader.ReadAsync();
        Assert.Equal(requestBodyBytes.Length, result.Buffer.Length);
        Assert.Equal(requestBodyBytes, result.Buffer.ToArray());
        httpContext.Request.BodyReader.AdvanceTo(result.Buffer.End);
 
        var rawRequestBody = httpContext.Items["body"];
        Assert.NotNull(rawRequestBody);
        Assert.Equal(requestBodyBytes, (byte[])rawRequestBody!);
    }
 
    [Theory]
    [MemberData(nameof(ExplicitRawFromBodyActions))]
    public async Task RequestDelegatePopulatesFromExplicitRawBodyParameter(string source)
    {
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
 
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Name = "Write more tests!"
        });
 
        var stream = new MemoryStream(requestBodyBytes);
        httpContext.Request.Body = stream;
        httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture);
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
 
        await endpoint.RequestDelegate(httpContext);
 
        Assert.Same(httpContext.Request.Body, stream);
 
        // Assert that we can read the body from both the pipe reader and Stream after executing
        httpContext.Request.Body.Position = 0;
        byte[] data = new byte[requestBodyBytes.Length];
        int read = await httpContext.Request.Body.ReadAsync(data.AsMemory());
        Assert.Equal(read, data.Length);
        Assert.Equal(requestBodyBytes, data);
 
        httpContext.Request.Body.Position = 0;
        var result = await httpContext.Request.BodyReader.ReadAsync();
        Assert.Equal(requestBodyBytes.Length, result.Buffer.Length);
        Assert.Equal(requestBodyBytes, result.Buffer.ToArray());
        httpContext.Request.BodyReader.AdvanceTo(result.Buffer.End);
 
        var rawRequestBody = httpContext.Items["body"];
        Assert.NotNull(rawRequestBody);
        Assert.Equal(requestBodyBytes, (byte[])rawRequestBody!);
    }
 
    [Theory]
    [MemberData(nameof(ImplicitRawFromBodyActions))]
    public async Task RequestDelegatePopulatesFromImplicitRawBodyParameterPipeReader(string source)
    {
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
 
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Name = "Write more tests!"
        });
 
        var pipeReader = PipeReader.Create(new MemoryStream(requestBodyBytes));
        var stream = pipeReader.AsStream();
        httpContext.Features.Set<IRequestBodyPipeFeature>(new PipeRequestBodyFeature(pipeReader));
        httpContext.Request.Body = stream;
 
        httpContext.Request.Headers["Content-Length"] = requestBodyBytes.Length.ToString(CultureInfo.InvariantCulture);
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
 
        await endpoint.RequestDelegate(httpContext);
 
        Assert.Same(httpContext.Request.Body, stream);
        Assert.Same(httpContext.Request.BodyReader, pipeReader);
 
        // Assert that we can read the body from both the pipe reader and Stream after executing and verify that they are empty (the pipe reader isn't seekable here)
        int read = await httpContext.Request.Body.ReadAsync(new byte[requestBodyBytes.Length].AsMemory());
        Assert.Equal(0, read);
 
        var result = await httpContext.Request.BodyReader.ReadAsync();
        Assert.Equal(0, result.Buffer.Length);
        Assert.True(result.IsCompleted);
        httpContext.Request.BodyReader.AdvanceTo(result.Buffer.End);
 
        var rawRequestBody = httpContext.Items["body"];
        Assert.NotNull(rawRequestBody);
        Assert.Equal(requestBodyBytes, (byte[])rawRequestBody!);
    }
 
    [Theory]
    [MemberData(nameof(ExplicitRawFromBodyActions))]
    public async Task RequestDelegatePopulatesFromExplicitRawBodyParameterPipeReader(string source)
    {
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
 
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Name = "Write more tests!"
        });
 
        var pipeReader = PipeReader.Create(new MemoryStream(requestBodyBytes));
        var stream = pipeReader.AsStream();
        httpContext.Features.Set<IRequestBodyPipeFeature>(new PipeRequestBodyFeature(pipeReader));
        httpContext.Request.Body = stream;
 
        httpContext.Request.Headers["Content-Length"] = requestBodyBytes.Length.ToString(CultureInfo.InvariantCulture);
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
 
        await endpoint.RequestDelegate(httpContext);
 
        Assert.Same(httpContext.Request.Body, stream);
        Assert.Same(httpContext.Request.BodyReader, pipeReader);
 
        // Assert that we can read the body from both the pipe reader and Stream after executing and verify that they are empty (the pipe reader isn't seekable here)
        int read = await httpContext.Request.Body.ReadAsync(new byte[requestBodyBytes.Length].AsMemory());
        Assert.Equal(0, read);
 
        var result = await httpContext.Request.BodyReader.ReadAsync();
        Assert.Equal(0, result.Buffer.Length);
        Assert.True(result.IsCompleted);
        httpContext.Request.BodyReader.AdvanceTo(result.Buffer.End);
 
        var rawRequestBody = httpContext.Items["body"];
        Assert.NotNull(rawRequestBody);
        Assert.Equal(requestBodyBytes, (byte[])rawRequestBody!);
    }
 
    [Fact]
    public async Task RequestDelegateAllowsEmptyBodyStructGivenCorrectlyConfiguredFromBodyParameter()
    {
        var structToBeZeroedKey = "structToBeZeroed";
 
        var source = $$"""
void TestAction(HttpContext httpContext, [CustomFromBody(AllowEmpty = true)] BodyStruct bodyStruct)
{
    httpContext.Items["{{structToBeZeroedKey}}"] = bodyStruct;
}
app.MapPost("/", TestAction);
""";
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
        httpContext.Request.Headers["Content-Type"] = "application/json";
        httpContext.Request.Headers["Content-Length"] = "0";
        httpContext.Items[structToBeZeroedKey] = new BodyStruct { Id = 42 };
 
        await endpoint.RequestDelegate(httpContext);
 
        Assert.Equal(default(BodyStruct), httpContext.Items[structToBeZeroedKey]);
    }
 
    [Fact]
    public async Task RequestDelegateHandlesRequiredBodyStruct()
    {
        var targetStruct = new BodyStruct
        {
            Id = 42
        };
 
        var source = $$"""
void TestAction(HttpContext httpContext, BodyStruct bodyStruct)
{
    httpContext.Items["targetStruct"] = bodyStruct;
}
app.MapPost("/", TestAction);
""";
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContext();
        httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
        httpContext.Request.Headers["Content-Type"] = "application/json";
 
        var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(targetStruct);
        var stream = new MemoryStream(requestBodyBytes);
        httpContext.Request.Body = stream;
        httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture);
 
        await endpoint.RequestDelegate(httpContext);
 
        var resultStruct = Assert.IsType<BodyStruct>(httpContext.Items["targetStruct"]);
        Assert.Equal(42, resultStruct.Id);
    }
 
    public static IEnumerable<object[]> AllowEmptyData
    {
        get
        {
            return new List<object[]>
                {
                    new object[] { $@"string handler([CustomFromBody(AllowEmpty = false)] Todo todo) => todo?.ToString() ?? string.Empty", false },
                    new object[] { $@"string handler([CustomFromBody(AllowEmpty = true)] Todo todo) => todo?.ToString() ?? string.Empty", true },
                    new object[] { $@"string handler([CustomFromBody(AllowEmpty = true)] Todo? todo = null) => todo?.ToString() ?? string.Empty", true },
                    new object[] { $@"string handler([CustomFromBody(AllowEmpty = false)] Todo? todo = null) => todo?.ToString() ?? string.Empty", true }
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(AllowEmptyData))]
    public async Task AllowEmptyOverridesOptionality(string innerSource, bool allowsEmptyRequest)
    {
        var source = $"""
{innerSource};
app.MapPost("/", handler);
""";
        var (_, compilation) = await RunGeneratorAsync(source);
        var endpoint = GetEndpointFromCompilation(compilation);
 
        var httpContext = CreateHttpContextWithBody(null);
 
        await endpoint.RequestDelegate(httpContext);
 
        var logs = TestSink.Writes.ToArray();
 
        if (!allowsEmptyRequest)
        {
            Assert.Equal(400, httpContext.Response.StatusCode);
            var log = Assert.Single(logs);
            Assert.Equal(LogLevel.Debug, log.LogLevel);
            Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId);
            Assert.Equal(@"Required parameter ""Todo todo"" was not provided from body.", log.Message);
        }
        else
        {
            Assert.Equal(200, httpContext.Response.StatusCode);
            Assert.False(httpContext.RequestAborted.IsCancellationRequested);
        }
    }
 
}