Implementing a Custom Model Binder In Razor Pages

The default model binder in Razor Pages is sufficient for most use cases, but sometimes you might need to implement your own model binding solution. The following example demonstrates how to devise and register a model binder to bind values from an week input to a DateTime value.

The week input enables the user to select a week of the year in browsers where it is supported:

The format of the values that the week input works with are defined in ISO 8601 and take the form yyyy-Www, where yyyy represents the year, -W is literal and ww represents the week of the year. The existing DateTime model binder is unable to bind a string in this format to a DateTime. You could manually parse the value that comes from the week input and assign it to a DateTime within the OnPost handler. However, creating a custom model binder is a better solution.

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 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 using a ModelBinderProvider 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 DateTime types 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());
    });
}

Model binder 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.

Last updated: 12/11/2020 08:32:06

© 2020 - Mike Brind.
All rights reserved.
Contact me at Outlook.com