Custom Model Binders in ASP.NET Core 6
2022-02-13
This past week I was integrating with a third party service that passes back boolean values in the query string as Yes/No
. The built-in ASP.NET Core 6 model binding can handle true/false
or 1/0
, but not Yes/No
. Let’s look at how to make our own custom model binder for this simple use case and how to unit test it.
Implementing IModelBinder
The official documentation provides a good overview of custom model binding. In this case, I implemented IModelBinder
as follows
/// <summary>
/// Binds "Yes" or "No" (ignoring case), to true and false respectively.
/// Does not bind anything on other values.
/// </summary>
public class YesNoBooleanModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
var value = valueProviderResult.FirstValue;
if (string.Equals(value, "Yes", StringComparison.InvariantCultureIgnoreCase))
{
bindingContext.Result = ModelBindingResult.Success(true);
}
if (string.Equals(value, "No", StringComparison.InvariantCultureIgnoreCase))
{
bindingContext.Result = ModelBindingResult.Success(false);
}
return Task.CompletedTask;
}
}
Even though my current use case is just for query strings, implementations of IModelBinder
aren’t specific to where the data is coming from. The data is gathered from a variety of sources. By the time the custom IModelBinder
executes, the data has already been gathered and added to the ModelBindingContext
, along with the name being bound to.
How It Works
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
First, I pull the data out of the bindingContext
by name and confirm that a match was found. If not, don’t set any result on the bindingContext
and just return Task.CompletedTask
.
var value = valueProviderResult.FirstValue;
if (string.Equals(value, "Yes", StringComparison.InvariantCultureIgnoreCase))
{
bindingContext.Result = ModelBindingResult.Success(true);
}
else if (string.Equals(value, "No", StringComparison.InvariantCultureIgnoreCase))
{
bindingContext.Result = ModelBindingResult.Success(false);
}
return Task.CompletedTask;
Next, pull out the first value in the result. Binding works with collections, but in this case there should only be a single value. Then make case-insensitive comparisons against the bound value looking for Yes/No
and only set a result for Yes/No
values. Anything else will not bind at all.
Usage
If your use case is better handled without an attribute, then you can implement IModelBinderProvider
. This is a good idea if you always want the custom binder to be applied. In this case, I want to opt-in to the binding with an attribute.
[HttpGet]
public IActionResult Index([ModelBinder(BinderType = typeof(YesNoBooleanModelBinder))] bool isValid)
{
...
}
When executing the binder bindingContext.ModelName
will be isValid
.
Unit Testing
The next step was to figure out how to setup ModelBindingContext
for unit tests. I ended up with this. Assertions are handled with FluentAssertions
public class YesNoBooleanModelBinderTests
{
[Theory]
[InlineData("Yes", true)]
[InlineData("yes", true)]
[InlineData("No", false)]
[InlineData("no", false)]
public async Task BindModelAsync_returns_success_with_with_expected_value(
string modelValue, bool expectedResult)
{
// Arrange
var modelBinder = new YesNoBooleanModelBinder();
var bindingContext = BuildBindingContext(modelValue);
// Act
await modelBinder.BindModelAsync(bindingContext);
// Assert
bindingContext.Result.IsModelSet.Should().Be(true);
var model = bindingContext.Result.Model as bool?;
model.Value.Should().Be(expectedResult);
}
[Fact]
public async Task BindModelAsync_does_not_bind_if_model_value_is_not_yes_or_no()
{
// Arrange
var modelBinder = new YesNoBooleanModelBinder();
var bindingContext = BuildBindingContext("invalid");
// Act
await modelBinder.BindModelAsync(bindingContext);
// Assert
bindingContext.Result.IsModelSet.Should().Be(false);
}
private ModelBindingContext BuildBindingContext(string modelValue)
{
const string ModelName = "test";
var bindingContext = new DefaultModelBindingContext
{
ModelName = ModelName
};
var bindingSource = new BindingSource("", "", false, false);
var queryCollection = new QueryCollection(new Dictionary<string, StringValues>
{
{ ModelName, new StringValues(modelValue) }
});
bindingContext.ValueProvider = new QueryStringValueProvider(bindingSource, queryCollection, null);
return bindingContext;
}
}
The important bits are in BuildBindingContext
. ModelBindingContext
is abstract, but the framework provides a DefaultModelBindingContext
that we can instantiate. The BindingSource
is not relevant for our tests, but must be provided. The QueryCollection
will feed into the QueryStringValueProvider
that we pull values out of in the YesNoBooleanModelBinder
implementation. In this case we’re saying the data came from a query string, but you could use another implementation of IValueProvider
, such as RouteValueProvider
. You can see an example of that at https://stackoverflow.com/a/55387164/235145