Search code examples
c#asp.net-coreminimal-apismodelbinders

Minimal API requires TryParse() on input model despite ModelBinder


I'm trying to implement a single ModelBinder for all my DTOs:

public class MyModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext) {
        var queryDto = bindingContext.ModelType.GetConstructors()[0].Invoke([]);
        // fill properties via Reflection
        bindingContext.Result = ModelBindingResult.Success(queryDto);
        return Task.CompletedTask;
    }
}

This is an example of DTO:

public class Dto {
    public int Id { get; set; }
    public string Name { get; set; }
}

Now, if I try to set an endpoint like this:

app.MapGet("/get-dto", ([FromQuery] [ModelBinder(typeof(MyModelBinder))] Dto dto) => {
    return CalculateResultSomehow(dot);
});

The compiler gives me the error:

error ASP0020: Parameter 'dto' of type Dto should define a bool TryParse(string, IFormatProvider, out Dto) method, or implement IParsable

If I remove the [FromQuery] attribute, the lambda has a warning:

ModelBinderAttribute should not be specified for a MapGet Delegate parameter

And the code breaks at runtime with the Exception:

An unhandled exception occurred while processing the request. InvalidOperationException: Body was inferred but the method does not allow inferred body parameters... Did you mean to register the "Body (Inferred)" parameter(s) as a Service or apply the [FromServices] or [FromBody] attribute?

Now, since I'm implementing a parsing logic based on Reflection, I don't want to implement the static TryParse() on every single DTO of my application (I have 100 of them...). And I shouldn't: I already have the ModelBinder.

A controller's action works perfectly using the same system:

[ApiController]
public class MyController
{
    [HttpGet("/get-dto")]
    public Dto GetDto([FromQuery] [ModelBinder(typeof(MyModelBinder))] Dto dto) {
        return dto;
    }
}

I'm lost here. What am I missing? Why isn't this working for Minimal APIs?


Solution

  • Since I already had a dynamically generated set of endpoints based on a few common generic methods, I declared the query string as a simple string parameter and I pushed the conversion "query string => typed dto" after the beginning of the method, like this:

    // Simplified version:
    public static Delegate ConfigureEndpoint<TQueryStringDto>()
    {
        return async ([FromQuery] string query /* other params omitted */) => {
            var dto = ConvertToDto<TQueryStringDto>(query);
            // Do something general, valid for every endpoint,
            // like sending the dto to IMediator
        };
    }
    

    ConvertToDto is the general method I would have used in a early middleware or in the ModelBinder, but that I can also use here, a little down below the chain processing the request.