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 DateTime
s 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.