ASP.NET Core MVC Mixed Route/FromBody Model Binding & Validation
Answer :
Install-Package HybridModelBinding
Add to Statrup:
services.AddMvc() .AddHybridModelBinder();Model:
public class Person { public int Id { get; set; } public string Name { get; set; } public string FavoriteColor { get; set; } }Controller:
[HttpPost] [Route("people/{id}")] public IActionResult Post([FromHybrid]Person model) { }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"Result:
{ "Id": 123, "Name": "William Boga", "FavoriteColor": "Blue" }There are other advanced features.
You can remove the [FromBody] decorator on your input and let MVC binding map the properties:
[HttpPost("/test/{rootId}/echo/{id}")] public IActionResult TestEcho(TestModel data) {     return Json(new     {         data.Id,         data.RootId,         data.Name,         data.Description,         Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)     }); }   More info: Model binding in ASP.NET Core MVC
UPDATE
Testing


UPDATE 2
@heavyd, you are right in that JSON data requires [FromBody] attribute to bind your model. So what I said above will work on form data but not with JSON data.
As alternative, you can create a custom model binder that binds the Id and RootId properties from the url, whilst it binds the rest of the properties from the request body.
public class TestModelBinder : IModelBinder {     private BodyModelBinder defaultBinder;      public TestModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory) // : base(formatters, readerFactory)     {         defaultBinder = new BodyModelBinder(formatters, readerFactory);     }      public async Task BindModelAsync(ModelBindingContext bindingContext)     {         // callinng the default body binder         await defaultBinder.BindModelAsync(bindingContext);          if (bindingContext.Result.IsModelSet)         {             var data = bindingContext.Result.Model as TestModel;             if (data != null)             {                 var value = bindingContext.ValueProvider.GetValue("Id").FirstValue;                 int intValue = 0;                 if (int.TryParse(value, out intValue))                 {                     // Override the Id property                     data.Id = intValue;                 }                 value = bindingContext.ValueProvider.GetValue("RootId").FirstValue;                 if (int.TryParse(value, out intValue))                 {                     // Override the RootId property                     data.RootId = intValue;                 }                 bindingContext.Result = ModelBindingResult.Success(data);             }          }      } }   Create a binder provider:
public class TestModelBinderProvider : IModelBinderProvider {     private readonly IList<IInputFormatter> formatters;     private readonly IHttpRequestStreamReaderFactory readerFactory;      public TestModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory)     {         this.formatters = formatters;         this.readerFactory = readerFactory;     }      public IModelBinder GetBinder(ModelBinderProviderContext context)     {         if (context.Metadata.ModelType == typeof(TestModel))             return new TestModelBinder(formatters, readerFactory);          return null;     } }   And tell MVC to use it:
services.AddMvc()   .AddMvcOptions(options =>   {      IHttpRequestStreamReaderFactory readerFactory = services.BuildServiceProvider().GetRequiredService<IHttpRequestStreamReaderFactory>();      options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(options.InputFormatters, readerFactory));   });   Then your controller has:
[HttpPost("/test/{rootId}/echo/{id}")] public IActionResult TestEcho(TestModel data) {...}   Testing
 
You can add an Id and RootId to your JSON but they will be ignored as we are overwriting them in our model binder.
UPDATE 3
The above allows you to use your data model annotations for validating Id and RootId. But I think it may confuse other developers who would look at your API code. I would suggest to just simplify the API signature to accept a different model to use with [FromBody] and separate the other two properties that come from the uri. 
[HttpPost("/test/{rootId}/echo/{id}")] public IActionResult TestEcho(int id, int rootId, [FromBody]TestModelNameAndAddress testModelNameAndAddress)   And you could just write a validator for all your input, like:
// This would return a list of tuples of property and error message. var errors = validator.Validate(id, rootId, testModelNameAndAddress);  if (errors.Count() > 0) {     foreach (var error in errors)     {         ModelState.AddModelError(error.Property, error.Message);     } }  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);     } }  
Comments
Post a Comment