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