CarterCommunity / Carter

Carter is framework that is a thin layer of extension methods and functionality over ASP.NET Core allowing code to be more explicit and most importantly more enjoyable.
MIT License
2.11k stars 174 forks source link

BindAndValidate<T> is returning null fields #224

Closed ghost closed 4 years ago

ghost commented 4 years ago

With a simple client exercising the handling of parameters and responses

import h from "stage0";

const View = h`
<div>
    <p>#message</p>
    <p>#username</p>
    <p>#password</p>
    <button #refresh>Login</button>
</div>
`;

const {message, username, password, refresh} = View.collect(View);

message.nodeValue = "Message: --";
username.nodeValue = "Username: --";
password.nodeValue = "Password: --";

refresh.onclick = () => {
    message.nodeValue = "Message: --";
    username.nodeValue = "Username: --";
    password.nodeValue = "Password: --";
    fetch("/api/login", {
        credentials: "same-origin",
        method: "POST",
        headers: {
            "Accept": "application/json",
            "Content-Type": "application/json"
        },
        body: JSON.stringify({
            Username: "ALL",
            Password: "GOOD"
        })
    }).then((response) => {
        if (!response.ok) return response.json().then((data) => {
            message.nodeValue = `Message: ${data.Message}`;
        });
        response.json().then((data) => {
            username.nodeValue = `Username: ${data.Username}`;
            password.nodeValue = `Password: ${data.Password}`;
        });
    });
};

document.body.appendChild(View);

Passed through a boring nginx server and fowarded to

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
      <TargetFramework>netcoreapp3.1`</TargetFramework>
      <StartupObject>Testing.Startup</StartupObject>
      <Version>0.0.0</Version>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Carter" Version="5.0.0" />
        <PackageReference Include="FluentValidation" Version="8.6.0" />
    </ItemGroup>
</Project>
namespace Testing
{
    public class Startup
    {
        private static IHostBuilder CreateHostBuilder(string[] args)
        {
            return Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Config>());
        }

        public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); }
    }
}
namespace Testing
{
    public class Config
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<KestrelServerOptions>(options => options.AllowSynchronousIO = true);
            services.AddSingleton<IResponseNegotiator, DefaultJsonResponseNegotiator>();
            services.AddCarter();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseRouting();
            app.UseEndpoints(builder => { builder.MapCarter(); });
        }

        public class DefaultJsonResponseNegotiator : IResponseNegotiator
        {
            private readonly JsonSerializerOptions settings;

            public DefaultJsonResponseNegotiator()
            {
                settings = new JsonSerializerOptions
                {
                    IgnoreNullValues = true,
                    PropertyNamingPolicy = null
                };
            }

            public bool CanHandle(MediaTypeHeaderValue accept)
            {
                return accept.MediaType.ToString().IndexOf("json", StringComparison.OrdinalIgnoreCase) >= 0;
            }

            public async Task Handle(HttpRequest request, HttpResponse response, object o, CancellationToken token)
            {
                response.ContentType = "application/json; charset=utf-8";
                await JsonSerializer.SerializeAsync(
                    response.Body,
                    o,
                    o == null ? typeof(object) : o.GetType(),
                    settings,
                    token
                );
            }
        }
    }
}
namespace Testing.Controllers
{
    public sealed class LoginModule : CarterModule
    {
        public LoginModule()
        {
            Post("/api/login", async (context) =>
            {
                var (test, data) = await context.Request.BindAndValidate<LoginReq>();
                if (!test.IsValid)
                {
                    context.Response.StatusCode = (int) HttpStatusCode.BadRequest;
                    await context.Response.AsJson(new {Message = test.Errors.Select(err => err.ErrorMessage).ToList()});
                    return;
                }
                await context.Response.AsJson(new {data.Username, data.Password});
            });
        }

        public class LoginReq
        {
            public string Username;
            public string Password;
        }

        public class LoginReqValidator : AbstractValidator<LoginReq>
        {
            public LoginReqValidator()
            {
                RuleFor(data => data.Username).NotEmpty().WithMessage("You must enter a username.");
                RuleFor(data => data.Password).NotEmpty().WithMessage("You must enter a password.");
            }
        }
    }
}

The result of BindAndValidate has null fields. The above dance does handle the new JSON serializer for responses, with the client having no issue. However, when breakpointing at the first test past BindAndValidate, the context has no sign of the submitted data and it will fail validation due to the fields being null.

This issue occurs on both dotnet 3.0 and 3.1.

ghost commented 4 years ago

I have attempted to build and use the 3.1 branch of Carter, which appears to have PR #220 's fix also applied. Unfortunately the issue still persists, it's as if the body is never read. It's entirely possible I am doing something wrong here, however this was a working pattern prior to 5.0.0.

ghost commented 4 years ago

If I add the following before the BindAndValidate call

                context.Request.EnableBuffering();
                context.Request.Body.Seek(0L, SeekOrigin.Begin);
                var bodyString = new StreamReader(context.Request.Body).ReadToEndAsync().Result;
                context.Request.Body.Seek(0L, SeekOrigin.Begin);

I can access the contents of bodyString just fine and it is correct.

ghost commented 4 years ago

Issue was specifically

        public class LoginReq
        {
            public string Username;
            public string Password;
        }

It needs to be

        public class LoginReq
        {
            public string Username { get; set; }
            public string Password { get; set; }
        }

Else JsonSerializer.Deserialize won't succeed. Sorry, this was on my end!