microsoft / dotnet

This repo is the official home of .NET on GitHub. It's a great starting point to find many .NET OSS projects from Microsoft and the community, including many that are part of the .NET Foundation.
https://devblogs.microsoft.com/dotnet/
MIT License
14.38k stars 2.22k forks source link

ModelBinding behavior for class using a TypeConverter appears to be inconsistent between Controller and ControllerBase #1271

Closed JerrettDavis closed 3 years ago

JerrettDavis commented 3 years ago

I have 2 projects, one is a traditional MVC application and the other is a WebAPI project with an Angular frontend. Both projects have near identical packages, and both startup.cs and program.cs are more or less identical. For all intents and purposes, both of the projects are more or less mirror images of each other, with different UI implementations.

For one specific endpoint, both projects expect the same input model (GetJobListQuery) to be passed into the controller action. GetJobListQuery contains a property called SortDirection with a type of SortDirection. The implementations look something like:

public class GetJobListQuery
{
    public SortDirection SortDirection { get; set; } = SortDirection.Ascending;
    // code omitted for brevity.
}

[TypeConverter(typeof(SortDirectionTypeConverter))]
public readonly struct SortDirection
{
    public static SortDirection Ascending => new SortDirection("ASC");
    public static SortDirection Descending => new SortDirection("DESC");

    private SortDirection(string direction)
    {
        Direction = direction;
    }

    public string Direction { get; }
    // code omitted for brevity.
}

And the TypeConverter referenced in SortDirection is defined as such:

public class SortDirectionTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return typeof(string) == sourceType || base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        var strValue = value as string;
        if (string.IsNullOrWhiteSpace(strValue))
            return SortDirection.Ascending;

        strValue = strValue.ToUpperInvariant();

        var sortDirections = new Dictionary<string, SortDirection>
        {
            [SortDirection.Ascending.Direction] = SortDirection.Ascending,
            [SortDirection.Descending.Direction] = SortDirection.Descending 
        };
        return !sortDirections.ContainsKey(strValue) ? 
            SortDirection.Ascending : 
            sortDirections[strValue];
    }
}

In my MVC project, my controller looks something like:

public class JobsController : Controller
{
    private readonly IMapper _mapper;    
    private readonly IJobFilterDataBuilder _filterBuilder;
    private readonly IMediatr _mediatr;

    public JobsController(
        IMapper mapper, 
        IMediatr _mediatr; 
        IJobFilterDataBuilder filterBuilder)
    {
        _mapper = mapper;
        _modelBuilder = modelBuilder;
        _filterBuilder = filterBuilder;
    }

    public async Task<IActionResult> Index(
        GetJobListQuery? model,
        CancellationToken cancellationToken)
    {
        model ??= new GetJobListQuery();
        var result = await _mediatr.Send(model, cancellationToken);
        var vm = _mapper.Map<JobListPageViewModel>(result);

        vm.JobTypesSet = await _filterBuilder.GetJobTypes(cancellationToken);
        vm.Techs = await _filterBuilder.GetTechs(cancellationToken);
        vm.JobStatesSet = await _filterBuilder.GetJobStates(cancellationToken);
        vm.Managers = await _filterBuilder.GetManagers(cancellationToken);

        return View(vm);
    }
}

And the WebAPI implementation looks like the following:

[ApiController]
[Route("Api/[controller]")]
public class JobsController : ControllerBase
{
    private readonly IMediatr _mediatr;
    public JobsController(IMediatr mediatr)
    {
        _mediatr = mediatr;
    }

    [HttpPost]
    public async Task<IActionResult> GetList(
        [FromBody] GetJobListQuery? model,
        CancellationToken cancellationToken)
    {
        model ??= new GetJobListQuery();
        return Ok(await _mediatr.Send(model, cancellationToken));
    }
}

However, with the default System.Text.Json library in use, the WebAPI version throws an exception as soon as the endpoint is called. The TypeConverter is never called by the modelbinder, and instead I receive a 400 Bad Request error, and the body contains:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-bea4fcf1bc2f25499e80d3e3b947237b-fd4f82e81e29f347-00",
  "errors": {
    "$.sortDirection": [
      "The JSON value could not be converted to Application.Common.Models.SortDirection. Path: $.sortDirection | LineNumber: 0 | BytePositionInLine: 33."
    ]
  }
}

If I replace the default System.Text.Json library with Microsoft.AspNetCore.Mvc.NewtonSoftJson, everything behaves as expected. Is there any explanation for this discrepancy?

JerrettDavis commented 3 years ago

Wrong repo. Moved issue to dotnet/core.