ASP.NET Core MVC Mixed Route/FromBody Model Binding & Validation

Viewed 26020

I am using ASP.NET Core 1.1 MVC to build an JSON API. Given the following model and action method:

public class TestModel
{
    public int Id { get; set; }

    [Range(100, 999)]
    public int RootId { get; set; }

    [Required, MaxLength(200)]
    public string Name { get; set; }

    public string Description { get; set; }
}

[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho([FromBody] TestModel data)
{
    return Json(new
    {
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    });
}

The [FromBody] on my action method parameter is causing the model to be bound from the JSON payload that is posted to the endpoint, however it also prevents the Id and RootId properties from being bound via the route parameters.

I could break this up into to separate models, one bound from the route and one from the body or I could also force any clients to send the id & rootId as part of the payload, but both of those solutions seem to complicate things more than I'd like and don't allow me to keep the validation logic in a single place. Is there any way to get this situation working where the model can be bound properly and I can keep my model & validation logic together?

5 Answers
  1. Install-Package HybridModelBinding

  2. Add to Statrup:

    services.AddMvc()
        .AddHybridModelBinder();
    
  3. Model:

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string FavoriteColor { get; set; }
    }
    
  4. Controller:

    [HttpPost]
    [Route("people/{id}")]
    public IActionResult Post([FromHybrid]Person model)
    { }
    
  5. Request:

    curl -X POST -H "Accept: application/json" -H "Content-Type:application/json" -d '{
        "id": 999,
        "name": "Bill Boga",
        "favoriteColor": "Blue"
    }' "https://localhost/people/123?name=William%20Boga"
    
  6. Result:

    {
        "Id": 123,
        "Name": "William Boga",
        "FavoriteColor": "Blue"
    }
    
  7. There are other advanced features.

After researching I came up with a solution of creating new model binder + binding source + attribute which combines functionality of BodyModelBinder and ComplexTypeModelBinder. It firstly uses BodyModelBinder to read from body and then ComplexModelBinder fills other fields. Code here:

public class BodyAndRouteBindingSource : BindingSource
{
    public static readonly BindingSource BodyAndRoute = new BodyAndRouteBindingSource(
        "BodyAndRoute",
        "BodyAndRoute",
        true,
        true
        );

    public BodyAndRouteBindingSource(string id, string displayName, bool isGreedy, bool isFromRequest) : base(id, displayName, isGreedy, isFromRequest)
    {
    }

    public override bool CanAcceptDataFrom(BindingSource bindingSource)
    {
        return bindingSource == Body || bindingSource == this;
    }
}

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class FromBodyAndRouteAttribute : Attribute, IBindingSourceMetadata
{
    public BindingSource BindingSource => BodyAndRouteBindingSource.BodyAndRoute;
}

public class BodyAndRouteModelBinder : IModelBinder
{
    private readonly IModelBinder _bodyBinder;
    private readonly IModelBinder _complexBinder;

    public BodyAndRouteModelBinder(IModelBinder bodyBinder, IModelBinder complexBinder)
    {
        _bodyBinder = bodyBinder;
        _complexBinder = complexBinder;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await _bodyBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        {
            bindingContext.Model = bindingContext.Result.Model;
        }

        await _complexBinder.BindModelAsync(bindingContext);
    }
}

public class BodyAndRouteModelBinderProvider : IModelBinderProvider
{
    private BodyModelBinderProvider _bodyModelBinderProvider;
    private ComplexTypeModelBinderProvider _complexTypeModelBinderProvider;

    public BodyAndRouteModelBinderProvider(BodyModelBinderProvider bodyModelBinderProvider, ComplexTypeModelBinderProvider complexTypeModelBinderProvider)
    {
        _bodyModelBinderProvider = bodyModelBinderProvider;
        _complexTypeModelBinderProvider = complexTypeModelBinderProvider;
    }

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var bodyBinder = _bodyModelBinderProvider.GetBinder(context);
        var complexBinder = _complexTypeModelBinderProvider.GetBinder(context);

        if (context.BindingInfo.BindingSource != null
            && context.BindingInfo.BindingSource.CanAcceptDataFrom(BodyAndRouteBindingSource.BodyAndRoute))
        {
            return new BodyAndRouteModelBinder(bodyBinder, complexBinder);
        }
        else
        {
            return null;
        }
    }
}

public static class BodyAndRouteModelBinderProviderSetup
{
    public static void InsertBodyAndRouteBinding(this IList<IModelBinderProvider> providers)
    {
        var bodyProvider = providers.Single(provider => provider.GetType() == typeof(BodyModelBinderProvider)) as BodyModelBinderProvider;
        var complexProvider = providers.Single(provider => provider.GetType() == typeof(ComplexTypeModelBinderProvider)) as ComplexTypeModelBinderProvider;

        var bodyAndRouteProvider = new BodyAndRouteModelBinderProvider(bodyProvider, complexProvider);

        providers.Insert(0, bodyAndRouteProvider);
    }
}

What I ended up doing (translated to your case) was:

  1. Model
public class TestModel
{
    public int Id { get; set; }

    [Range(100, 999)]
    public int RootId { get; set; }

    [Required, MaxLength(200)]
    public string Name { get; set; }

    public string Description { get; set; }
}
  1. Controller
[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho(int rootId, int id, TestModel data)
{
    data.RootId = rootId;
    data.Id = id;
    return Json(new
    {
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    });
}

It might not be the same signature on the controller method. It may not look as elegant as only having the model in the signature. It was - however - easy, as it doesn't require any external packages to be downloaded and only requires small changes to your controller method (one extra line and declared parameter per added route parameter).

Related