Custom Model Binding
By Steve Smith
Model binding allows controller actions to work directly with model types (passed in as method arguments), rather than HTTP requests. Mapping between incoming request data and application models is handled by model binders. Developers can extend the built-in model binding functionality by implementing custom model binders (though typically, you don’t need to write your own provider).
View or download sample from GitHub
Default model binder limitations
The default model binders support most of the common .NET Core data types and should meet most developers` needs. They expect to bind text-based input from the request directly to model types. You might need to transform the input prior to binding it. For example, when you have a key that can be used to look up model data. You can use a custom model binder to fetch data based on the key.
Model binding review
Model binding uses specific definitions for the types it operates on. A simple type is converted from a single string in the input. A complex type is converted from multiple input values. The framework determines the difference based on the existence of a TypeConverter
. We recommended you create a type converter if you have a simple string
-> SomeType
mapping that doesn’t require external resources.
Before creating your own custom model binder, it’s worth reviewing how existing model binders are implemented. Consider the ByteArrayModelBinder which can be used to convert base64-encoded strings into byte arrays. The byte arrays are often stored as files or database BLOB fields.
Working with the ByteArrayModelBinder
Base64-encoded strings can be used to represent binary data. For example, the following image can be encoded as a string.
A small portion of the encoded string is shown in the following image:
Follow the instructions in the sample’s README to convert the base64-encoded string into a file.
ASP.NET Core MVC can take a base64-encoded strings and use a ByteArrayModelBinder
to convert it into a byte array. The ByteArrayModelBinderProvider which implements IModelBinderProvider maps byte[]
arguments to ByteArrayModelBinder
:
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType == typeof(byte[]))
{
return new ByteArrayModelBinder();
}
return null;
}
When creating your own custom model binder, you can implement your own IModelBinderProvider
type, or use the ModelBinderAttribute.
The following example shows how to use ByteArrayModelBinder
to convert a base64-encoded string to a byte[]
and save the result to a file:
[!code-csharpMain]
1: using System.Collections.Generic;
2: using Microsoft.AspNetCore.Mvc;
3: using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
4: using Microsoft.AspNetCore.Hosting;
5: using System.IO;
6:
7: namespace CustomModelBindingSample.Controllers
8: {
9:
10: [Produces("application/json")]
11: [Route("api/Image")]
12: public class ImageController : Controller
13: {
14: private readonly IHostingEnvironment _env;
15: public ImageController(IHostingEnvironment env)
16: {
17: _env = env;
18: }
19:
20: #region post1
21: // POST: api/image
22: [HttpPost]
23: public void Post(byte[] file, string filename)
24: {
25: string filePath = Path.Combine(_env.ContentRootPath, "wwwroot/images/upload", filename);
26: if (System.IO.File.Exists(filePath)) return;
27: System.IO.File.WriteAllBytes(filePath, file);
28: }
29: #endregion
30:
31: #region post2
32: [HttpPost("Profile")]
33: public void SaveProfile(ProfileViewModel model)
34: {
35: string filePath = Path.Combine(_env.ContentRootPath, "wwwroot/images/upload", model.FileName);
36: if (System.IO.File.Exists(model.FileName)) return;
37: System.IO.File.WriteAllBytes(filePath, model.File);
38: }
39:
40: public class ProfileViewModel
41: {
42: public byte[] File { get; set; }
43: public string FileName { get; set; }
44: }
45: #endregion
46: }
47: }
You can POST a base64-encoded string to this api method using a tool like Postman:
As long as the binder can bind request data to appropriately named properties or arguments, model binding will succeed. The following example shows how to use ByteArrayModelBinder
with a view model:
[!code-csharpMain]
1: using System.Collections.Generic;
2: using Microsoft.AspNetCore.Mvc;
3: using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
4: using Microsoft.AspNetCore.Hosting;
5: using System.IO;
6:
7: namespace CustomModelBindingSample.Controllers
8: {
9:
10: [Produces("application/json")]
11: [Route("api/Image")]
12: public class ImageController : Controller
13: {
14: private readonly IHostingEnvironment _env;
15: public ImageController(IHostingEnvironment env)
16: {
17: _env = env;
18: }
19:
20: #region post1
21: // POST: api/image
22: [HttpPost]
23: public void Post(byte[] file, string filename)
24: {
25: string filePath = Path.Combine(_env.ContentRootPath, "wwwroot/images/upload", filename);
26: if (System.IO.File.Exists(filePath)) return;
27: System.IO.File.WriteAllBytes(filePath, file);
28: }
29: #endregion
30:
31: #region post2
32: [HttpPost("Profile")]
33: public void SaveProfile(ProfileViewModel model)
34: {
35: string filePath = Path.Combine(_env.ContentRootPath, "wwwroot/images/upload", model.FileName);
36: if (System.IO.File.Exists(model.FileName)) return;
37: System.IO.File.WriteAllBytes(filePath, model.File);
38: }
39:
40: public class ProfileViewModel
41: {
42: public byte[] File { get; set; }
43: public string FileName { get; set; }
44: }
45: #endregion
46: }
47: }
Custom model binder sample
In this section we’ll implement a custom model binder that:
- Converts incoming request data into strongly typed key arguments.
- Uses Entity Framework Core to fetch the associated entity.
- Passes the associated entity as an argument to the action method.
The following sample uses the ModelBinder
attribute on the Author
model:
[!code-csharpMain]
1: using CustomModelBindingSample.Binders;
2: using Microsoft.AspNetCore.Mvc;
3: using System;
4: using System.Collections.Generic;
5: using System.Linq;
6: using System.Threading.Tasks;
7:
8: namespace CustomModelBindingSample.Data
9: {
10: [ModelBinder(BinderType = typeof(AuthorEntityBinder))]
11: public class Author
12: {
13: public int Id { get; set; }
14: public string Name { get; set; }
15: public string GitHub { get; set; }
16: public string Twitter { get; set; }
17: public string BlogUrl { get; set; }
18: }
19: }
In the preceding code, the ModelBinder
attribute specifies the type of IModelBinder
that should be used to bind Author
action parameters.
The AuthorEntityBinder
is used to bind an Author
parameter by fetching the entity from a data source using Entity Framework Core and an authorId
:
[!code-csharpMain]
1: using CustomModelBindingSample.Data;
2: using Microsoft.AspNetCore.Mvc.Internal;
3: using Microsoft.AspNetCore.Mvc.ModelBinding;
4: using System;
5: using System.Collections.Generic;
6: using System.Linq;
7: using System.Threading.Tasks;
8:
9: namespace CustomModelBindingSample.Binders
10: {
11: #region demo
12: public class AuthorEntityBinder : IModelBinder
13: {
14: private readonly AppDbContext _db;
15: public AuthorEntityBinder(AppDbContext db)
16: {
17: _db = db;
18: }
19:
20: public Task BindModelAsync(ModelBindingContext bindingContext)
21: {
22: if (bindingContext == null)
23: {
24: throw new ArgumentNullException(nameof(bindingContext));
25: }
26:
27: // Specify a default argument name if none is set by ModelBinderAttribute
28: var modelName = bindingContext.BinderModelName;
29: if (string.IsNullOrEmpty(modelName))
30: {
31: modelName = "authorId";
32: }
33:
34: // Try to fetch the value of the argument by name
35: var valueProviderResult =
36: bindingContext.ValueProvider.GetValue(modelName);
37:
38: if (valueProviderResult == ValueProviderResult.None)
39: {
40: return Task.CompletedTask;
41: }
42:
43: bindingContext.ModelState.SetModelValue(modelName,
44: valueProviderResult);
45:
46: var value = valueProviderResult.FirstValue;
47:
48: // Check if the argument value is null or empty
49: if (string.IsNullOrEmpty(value))
50: {
51: return Task.CompletedTask;
52: }
53:
54: int id = 0;
55: if (!int.TryParse(value, out id))
56: {
57: // Non-integer arguments result in model state errors
58: bindingContext.ModelState.TryAddModelError(
59: bindingContext.ModelName,
60: "Author Id must be an integer.");
61: return Task.CompletedTask;
62: }
63:
64: // Model will be null if not found, including for
65: // out of range id values (0, -3, etc.)
66: var model = _db.Authors.Find(id);
67: bindingContext.Result = ModelBindingResult.Success(model);
68: return Task.CompletedTask;
69: }
70: }
71: #endregion
72: }
The following code shows how to use the AuthorEntityBinder
in an action method:
[!code-csharpMain]
1: using Microsoft.AspNetCore.Mvc;
2: using CustomModelBindingSample.Data;
3: using System.Linq;
4:
5: namespace CustomModelBindingSample.Controllers
6: {
7: [Produces("application/json")]
8: [Route("api/[controller]")]
9: public class BoundAuthorsController : Controller
10: {
11: // GET: api/boundauthors/1
12: #region demo1
13: [HttpGet("{id}")]
14: public IActionResult GetById([ModelBinder(Name = "id")]Author author)
15: {
16: if (author == null)
17: {
18: return NotFound();
19: }
20: if (!ModelState.IsValid)
21: {
22: return BadRequest(ModelState);
23: }
24: return Ok(author);
25: }
26: #endregion
27:
28: // GET: api/boundauthors/get/1
29: #region demo2
30: [HttpGet("get/{authorId}")]
31: public IActionResult Get(Author author)
32: {
33: return Ok(author);
34: }
35: #endregion
36: }
37: }
The ModelBinder
attribute can be used to apply the AuthorEntityBinder
to parameters that do not use default conventions:
[!code-csharpMain]
1: using Microsoft.AspNetCore.Mvc;
2: using CustomModelBindingSample.Data;
3: using System.Linq;
4:
5: namespace CustomModelBindingSample.Controllers
6: {
7: [Produces("application/json")]
8: [Route("api/[controller]")]
9: public class BoundAuthorsController : Controller
10: {
11: // GET: api/boundauthors/1
12: #region demo1
13: [HttpGet("{id}")]
14: public IActionResult GetById([ModelBinder(Name = "id")]Author author)
15: {
16: if (author == null)
17: {
18: return NotFound();
19: }
20: if (!ModelState.IsValid)
21: {
22: return BadRequest(ModelState);
23: }
24: return Ok(author);
25: }
26: #endregion
27:
28: // GET: api/boundauthors/get/1
29: #region demo2
30: [HttpGet("get/{authorId}")]
31: public IActionResult Get(Author author)
32: {
33: return Ok(author);
34: }
35: #endregion
36: }
37: }
In this example, since the name of the argument is not the default authorId
, it’s specified on the parameter using ModelBinder
attribute. Note that both the controller and action method are simplified compared to looking up the entity in the action method. The logic to fetch the author using Entity Framework Core is moved to the model binder. This can be considerable simplification when you have several methods that bind to the author model, and can help you to follow the DRY principle.
You can apply the ModelBinder
attribute to individual model properties (such as on a viewmodel) or to action method parameters to specify a certain model binder or model name for just that type or action.
Implementing a ModelBinderProvider
Instead of applying an attribute, you can implement IModelBinderProvider
. This is how the built-in framework binders are implemented. When you specify the type your binder operates on, you specify the type of argument it produces, not the input your binder accepts. The following binder provider works with the AuthorEntityBinder
. When it’s added to MVC’s collection of providers, you don’t need to use the ModelBinder
attribute on Author
or Author
typed parameters.
[!code-csharpMain]
1: using CustomModelBindingSample.Data;
2: using Microsoft.AspNetCore.Mvc.ModelBinding;
3: using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
4: using System;
5:
6: namespace CustomModelBindingSample.Binders
7: {
8: public class AuthorEntityBinderProvider : IModelBinderProvider
9: {
10: public IModelBinder GetBinder(ModelBinderProviderContext context)
11: {
12: if (context == null)
13: {
14: throw new ArgumentNullException(nameof(context));
15: }
16:
17: if (context.Metadata.ModelType == typeof(Author))
18: {
19: return new BinderTypeModelBinder(typeof(AuthorEntityBinder));
20: }
21:
22: return null;
23: }
24: }
25: }
Note: The preceding code returns a
BinderTypeModelBinder
.BinderTypeModelBinder
acts as a factory for model binders and provides dependency injection (DI). TheAuthorEntityBinder
requires DI to access EF Core. UseBinderTypeModelBinder
if your model binder requires services from DI.
To use a custom model binder provider, add it in ConfigureServices
:
[!code-csharpMain]
1: using CustomModelBindingSample.Binders;
2: using CustomModelBindingSample.Data;
3: using Microsoft.AspNetCore.Builder;
4: using Microsoft.EntityFrameworkCore;
5: using Microsoft.Extensions.DependencyInjection;
6:
7: namespace CustomModelBindingSample
8: {
9: public class Startup
10: {
11:
12: #region callout
13: public void ConfigureServices(IServiceCollection services)
14: {
15: services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase());
16:
17: services.AddMvc(options =>
18: {
19: // add custom binder to beginning of collection
20: options.ModelBinderProviders.Insert(0, new AuthorEntityBinderProvider());
21: });
22: }
23: #endregion
24:
25: public void Configure(IApplicationBuilder app, AppDbContext db)
26: {
27: app.UseStaticFiles();
28:
29: app.UseMvc();
30:
31: PopulateTestData(db);
32: }
33:
34: private void PopulateTestData(AppDbContext db)
35: {
36: db.Authors.Add(new Author() { Name = "Steve Smith", Twitter = "ardalis", GitHub = "ardalis", BlogUrl = "ardalis.com" });
37: db.SaveChanges();
38: }
39: }
40: }
When evaluating model binders, the collection of providers is examined in order. The first provider that returns a binder is used.
The following image shows the default model binders from the debugger.
Adding your provider to the end of the collection may result in a built-in model binder being called before your custom binder has a chance. In this example, the custom provider is added to the beginning of the collection to ensure it is used for Author
action arguments.
[!code-csharpMain]
1: using CustomModelBindingSample.Binders;
2: using CustomModelBindingSample.Data;
3: using Microsoft.AspNetCore.Builder;
4: using Microsoft.EntityFrameworkCore;
5: using Microsoft.Extensions.DependencyInjection;
6:
7: namespace CustomModelBindingSample
8: {
9: public class Startup
10: {
11:
12: #region callout
13: public void ConfigureServices(IServiceCollection services)
14: {
15: services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase());
16:
17: services.AddMvc(options =>
18: {
19: // add custom binder to beginning of collection
20: options.ModelBinderProviders.Insert(0, new AuthorEntityBinderProvider());
21: });
22: }
23: #endregion
24:
25: public void Configure(IApplicationBuilder app, AppDbContext db)
26: {
27: app.UseStaticFiles();
28:
29: app.UseMvc();
30:
31: PopulateTestData(db);
32: }
33:
34: private void PopulateTestData(AppDbContext db)
35: {
36: db.Authors.Add(new Author() { Name = "Steve Smith", Twitter = "ardalis", GitHub = "ardalis", BlogUrl = "ardalis.com" });
37: db.SaveChanges();
38: }
39: }
40: }
Recommendations and best practices
Custom model binders: - Should not attempt to set status codes or return results (for example, 404 Not Found). If model binding fails, an (xref:)action filter or logic within the action method itself should handle the failure. - Are most useful for eliminating repetitive code and cross-cutting concerns from action methods. - Typically should not be used to convert a string into a custom type, a TypeConverter
is usually a better option.
|