Hybrid model binding in ASP.NET Core 1.0 RC2
For those who want the utmost flexibility in model binding.
Imagine you have a REST-ish API and your code follows the OMIOMO/Thunderdome principle. GET
-endpoints are easy to bind–just decorate the action-parameter with [FromRoute]
or [FromQuery]
. But, what to do about POST
or PUT
? Now, there is content coming from the body of the request in addition to properies we want to bind from the URI. The oft-repeated answer is to have two or more parameters:
curl -X POST -H "Accept: application/json" -H "Content-Type: application/json" -d '{
"name": "Bill Boga",
"favoriteColor": "Blue"
}' "https://localhost/people/123/addresses/456"
public class Person
{
public string FavoriteColor { get; set; }
public int Id { get; set; }
public string Name { get; set; }
}
[HttpPost]
[Route("people/{id}/addresses/{addressId}")]
public IActionResult Post(int id, int addressId, [FromBody]Person model)
{ }
But, what if the same method could be re-written as:
[HttpPost]
[Route("people/{id}/addresses/{addressId}")]
public IActionResult Post(Person model)
{ }
This library allows you to bind one-model with multiple model binders (IModelBinder
) and value providers (IValueProvider
). It is designed specifically for the previous example.
At its core is HybridModelBinder
, which takes the collections of binders and providers and loops through them. Binders are handled first followed by providers. The former stops looping once a suitable binder is found and produces a valid model while the latter goes through the full collection and patches applicable model-properties. For example, this means a property submitted with the body will be overwritten if a matching-key is found in the URI.
curl -X POST -H "Accept: application/json" -H "Content-Type: application/json" -d '{
"id": 999,
"name": "Bill Boga",
"favoriteColor": "Blue"
}' "https://localhost/people/123/addresses/456?name=William%20Boga"
will produce this model:
{
"Id": 123,
"Name": "William Boga",
"FavoriteColor": "Blue"
}
The library contains a few helpers to match your preferred coding-style. You can either opt for the hybrid approach app.-wide, or trial with a few endpoints:
PM> Install-Package HybridModelBinding
{
"dependencies": {
"HybridModelBinding": "0.1.0-*"
}
}
using HybridModelBinding;
// Boilerplate...
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc(x =>
{
/**
* App.-wide.
* If you do not want this behavior, do not include this line.
*/
x.Conventions.Add(new HybridModelBinderApplicationModelConvention());
});
services.Configure<MvcOptions>(x =>
{
/**
* This is needed since the provider uses the existing `BodyModelProvider`.
* Ref. https://github.com/aspnet/Mvc/blob/1.0.0-rc2/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/BodyModelBinder.cs
*/
var readerFactory = services.BuildServiceProvider().GetRequiredService<IHttpRequestStreamReaderFactory>();
x.ModelBinderProviders.Insert(0, new DefaultHybridModelBinderProvider(readerFactory));
// Or...
//x.ModelBinderProviders.Add(new DefaultHybridModelBinderProvider(readerFactory));
});
}
[HttpPost]
[Route("people/{id}/addresses/{addressId}")]
///
/// <summary>
/// Decorate the parameter with an attribute
/// if you do not want to go app.-wide.
/// </summary>
///
public IActionResult Post([FromHybrid]Person model)
{ }
HybridModelBinderApplicationModelConvention
scans your project for all controller-actions with one-parameter and applies the hybrid binder. There are additional restrictions such as needing to be a class and containing public properties.
And, you may have noticed DefaultHybridModelBinderProvider
. This is another helper and tells the hybrid binder to bind data in the following order: body => form-values => route-values => querystring-values. You could easily create your own derived class and mix the order so route-values are bound last.
public class DefaultHybridModelBinder : HybridModelBinder
{
public DefaultHybridModelBinder(IHttpRequestStreamReaderFactory readerFactory)
{
ModelBinders.Add(new BodyModelBinder(readerFactory));
ValueProviderFactories.Add(new FormValueProviderFactory());
ValueProviderFactories.Add(new RouteValueProviderFactory());
ValueProviderFactories.Add(new QueryStringValueProviderFactory());
}
}
While the library has gone through rudimentary testing to confirm expected behavior, please do not rush to implement this in your current production apps. The project is very young and may go through some breaking changes. Star/follow the repo.
I plan to write more about how the library works over the next few weeks. Upcoming topics will include: