Implementing a Custom Model Binder In Razor Pages

In Razor Pages, model binding is the process that maps data from an HTTP request to simple PageModel properties or handler parameters. Incoming data can be included in a request as posted form values, query string values or route data. The default collection of model binders cover every simple .NET data type.. But sometimes they are not enough, and you need to add your own implementation.

In my previous article, I explored the features available that simplify the task of working with date and time data in Razor Pages forms. The input tag helper generates values in the format that HTML5 date and time controls work with, and the default DateTimeModelBinder binds the posted values back to .NET DateTime types, delivering perfect 2-way data binding. Mostly.

Although it is unlikely to be used widely, and it's not even supported by some browsers, the week input type does serve a purpose. It enables the user to select a specific week of the year. The week input requires a value in a specific format in order to work: yyyy-Www, where yyyy is the full year -W is literal, and ww represents the ISO 8601 week of the year. Today (Nov 1st, 2020) we are at the end of week 44, which is represented as 2020-W44.

You can configure a DateTime property to correspond to a week input type easily just be adding a custom data type string to the DataType attribute:

[BindProperty, DataType("week")] 
public DateTime Week { get; set; }

The input tag helper renders the correct HTML5 control and formats the value according to the required standard:

However, when the form is posted, the selected value is not bound back to the Week property on the page model.

You can choose to parse the raw posted string value and obtain a week number from it wherever you need to throughout the application, but a better solution would be to create a custom model binder to do that for you in one place.

Model Binder Basics

Model binders implement the IModelBinder interface, which contains one member:

Task BindModelAsync(ModelBindingContext bindingContext)

It is within this method that you attempt to process incoming values and assign them to model properties or parameters. Once you have created your custom model binder, you either apply it to a specific property through the ModelBinder attribute, or you can register it globally using a ModelBinderProvider.

The WeekOfYear ModelBinder

To resolve the issue with binding the week input type value to a DateTime type, the approach using the ModelBinder attribute is simplest. The following code for a custom WeekOfYearModelBinder is based on the source code for the existing DateTimeModelBinder:

public class WeekOfYearModelBinder : 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 modelState = bindingContext.ModelState;
        modelState.SetModelValue(modelName, valueProviderResult);
 
        var metadata = bindingContext.ModelMetadata;
        var type = metadata.UnderlyingOrModelType;
        try
        {
            var value = valueProviderResult.FirstValue;
 
            object model;
            if (string.IsNullOrWhiteSpace(value))
            {
                model = null;
            }
            else if (type == typeof(DateTime))
            {
                var week = value.Split("-W");
                model = ISOWeek.ToDateTime(Convert.ToInt32(week[0]), Convert.ToInt32(week[1]), DayOfWeek.Monday);
            }
            else
            {
                throw new NotSupportedException();
            }
 
            if (model == null && !metadata.IsReferenceOrNullableType)
            {
                modelState.TryAddModelError(
                    modelName,
                    metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
                        valueProviderResult.ToString()));
            }
            else
            {
                bindingContext.Result = ModelBindingResult.Success(model);
            }
        }
        catch (Exception exception)
        {
            // Conversion failed.
            modelState.TryAddModelError(modelName, exception, metadata);
        }
        return Task.CompletedTask;
    }
}

The code might at first glance seem daunting, but the majority of it is fairly boilerplate. The only real differences between this model binder and the original code that it is based on are the omission of logging, and the way that the value is parsed in order to create a valid DateTime value:

var week = value.Split("-W");
model = ISOWeek.ToDateTime(Convert.ToInt32(week[0]), Convert.ToInt32(week[1]), DayOfWeek.Monday);

The code that gets the DateTime from a week number is basically the same as in the previous article. It uses the ISOWeek utility class to generate a DateTime from the year and the week number which is obtained by using the string.Split function on the incoming value.

If model binding is successful - a suitable value was obtained and assigned to the model and the ModelBindingContext.Result is set to a value returned from ModelBindingResult.Success. Otherwise, an entry is added to the Errors collection of ModelState. There is also a check, in the event that the incoming value is null, to see if the model property is required, and if so, an error is logged with ModelState.

The ModelBinder attribute is used to register the custom model binder against the specific property that it should be used for:

[BindProperty, DataType("week"), ModelBinder(BinderType = typeof(WeekOfYearModelBinder))] 
public DateTime Week { get; set; }

Now, when the application runs, this model binder will be used for the Week property in this instance. If you want to use the custom binder on properties elsewhere in the application, you need to apply the attribute there too. Alternatively, you can register the model binder in Startup where it is available to every request.

Model Binder Providers

Model binder providers are used to register model binders globally. They are responsible for creating correctly configured model binders. All of the built in model binders have a related binder provider. But first, you need a binder:

public class WeekOfYearAwareDateTimeModelBinder : IModelBinder
{
    private readonly DateTimeStyles _supportedStyles;
    private readonly ILogger _logger;
 
    public WeekOfYearAwareDateTimeModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
    {
        if (loggerFactory == null)
        {
            throw new ArgumentNullException(nameof(loggerFactory));
        }
 
        _supportedStyles = supportedStyles;
        _logger = loggerFactory.CreateLogger<WeekOfYearAwareDateTimeModelBinder>();
    }
 
    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)
        {
            // no entry
            return Task.CompletedTask;
        }
 
        var modelState = bindingContext.ModelState;
        modelState.SetModelValue(modelName, valueProviderResult);
 
        var metadata = bindingContext.ModelMetadata;
        var type = metadata.UnderlyingOrModelType;
 
        var value = valueProviderResult.FirstValue;
        var culture = valueProviderResult.Culture;
 
        object model;
        if (string.IsNullOrWhiteSpace(value))
        {
            model = null;
        }
        else if (type == typeof(DateTime))
        {
            if (value.Contains("W"))
            {
                var week = value.Split("-W");
                model = ISOWeek.ToDateTime(Convert.ToInt32(week[0]), Convert.ToInt32(week[1]), DayOfWeek.Monday);
            }
            else
            {
                model = DateTime.Parse(value, culture, _supportedStyles);
            }
        }
        else
        {
            // unreachable
            throw new NotSupportedException();
        }
 
        // When converting value, a null model may indicate a failed conversion for an otherwise required
        // model (can't set a ValueType to null). This detects if a null model value is acceptable given the
        // current bindingContext. If not, an error is logged.
        if (model == null && !metadata.IsReferenceOrNullableType)
        {
            modelState.TryAddModelError(
                modelName,
                metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
                    valueProviderResult.ToString()));
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Success(model);
        }
 
        return Task.CompletedTask;
    }
}

This is another modified version of the actual DateTimeModelBinder. The difference this time is the addition of the condition that checks if -W exists in the value being processed. If it does, this value comes from a week input and it is processed using the code from the previous example. Otherwise the value is parsed using the original DateTime model binding algorithm (basically DateTime.Parse). This version retains the logging and DateTimeStyles from the original source that need to be injected into the constructor so that the original behaviour for model binding DateTimes is preserved. Configuration of the constructor parameters is taken care of by the model binder provider:

public class WeekOfYearModelBinderProvider : IModelBinderProvider
{
    internal static readonly DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }
 
        var modelType = context.Metadata.UnderlyingOrModelType;
        if (modelType == typeof(DateTime))
        {
            var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
            return new WeekOfYearAwareDateTimeModelBinder(SupportedStyles, loggerFactory);
        }
 
        return null;
    }
}

This code is again, essentially the same as the built in DateTime model binder provider. The only difference is in the type that the binder returns.

Razor Pages is a layer that sits on top of the MVC framework. Much of what makes Razor Pages "just work" is in the MVC layer. Model binding is one of those features. So the access point to configuring model binders is via MvcOptions in ConfigureServices:

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages().AddMvcOptions(options =>
    {
        options.ModelBinderProviders.Insert(0, new WeekOfYearModelBinderProvider());
    });
}

Modelbinder providers are evaluated in order until one that matches the input model's data type is located. Then that is used to attempt to bind the incoming value to the model. If binding is unsuccessful, one of two things happens - the model value is set to its default value, or a validation error is added to ModelState. Any other model binder providers are ignored. So this new model binder provider is inserted at the beginning of the collection to ensure that it is used for DateTime types instead of the default model binder.

Summary

Custom model binders are not difficult to implement. You can lean on the boiler plate that populates most of the existing framework binders and adjust the algorithm that parses the incoming value according to your needs. You can register them locally using the ModelBinder attribute or globally via MvcOptions.

If you want to bind incoming strings to more complex types, the recommendation is to use a TypeConverter. That will be the subject of my next article.