Custom Model Binding of DateTime in .NET

In this post, we will be looking at implementing custom model binding, to work with additional formats in the DateTime type in .NET.

Read more: Custom Model Binding of DateTime in .NET

Before sharing some code, let’s first talk about what we are trying to achieve. By default, the DateTime binding mechanism does not work with custom date formats. So in this post, we are going to implement a method of resolving this using a custom binding mechanism, which is easier than you might think.

Resolving binding issues with culture variants

When we talk about custom date formats, we don’t mean formats which are not native to the default US locale. For example, in the UK where I am, we can set our applications culture, with something like this.

var localeOptions = new RequestLocalizationOptions
{
    SupportedCultures = new List<CultureInfo> { new ("en-GB") },
    SupportedUICultures = new List<CultureInfo> { new ("en-GB") },
    DefaultRequestCulture = new RequestCulture("en-GB")
};
app.UseRequestLocalization(localeOptions);

One thing to be aware of there is that this works fine for model binding, but data passed through as a query string, then the default culture is only used for model binding, data coming from the query string is the invariant culture.

Building a custom model binder

In my application, I have a need to look at dates which are in the format of yyyyMMdd, easy enough to parse from this format, or format to this, when working with the DateTime type, but if you are performing model binding, say on a request in a Web API, then you will get some errors.

First off, we need to create a custom DateTimeModelBinder and a DateTimeModelBinderAttribute, here is the code for doing this.

public class DateTimeModelBinder : IModelBinder
{
public static readonly Type[] SupportedTypes = [typeof(DateTime), typeof(DateTime?)];
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
if (!SupportedTypes.Contains(bindingContext.ModelType))
{
return;
}
var modelName = GetModelName(bindingContext);
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
return;
}
bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);
var dateToParse = valueProviderResult.FirstValue;
if (string.IsNullOrEmpty(dateToParse))
{
return;
}
var dateTime = ParseDate(bindingContext, dateToParse);
bindingContext.Result = ModelBindingResult.Success(dateTime);
await Task.CompletedTask;
}
private DateTime? ParseDate(ModelBindingContext bindingContext, string dateToParse)
{
var attribute = GetDateTimeModelBinderAttribute(bindingContext);
var dateFormat = attribute?.DateFormat;
if (string.IsNullOrEmpty(dateFormat))
{
return Helper.ParseDateTime(dateToParse);
}
return Helper.ParseDateTime(dateToParse, new string[] { dateFormat });
}
private DateTimeModelBinderAttribute GetDateTimeModelBinderAttribute(ModelBindingContext bindingContext)
{
var modelName = GetModelName(bindingContext);
var paramDescriptor = bindingContext.ActionContext.ActionDescriptor.Parameters
.Where(x => x.ParameterType == typeof(DateTime?))
.Where((x) =>
{
// See comment in GetModelName() on why we do this.
var paramModelName = x.BindingInfo?.BinderModelName ?? x.Name;
return paramModelName.Equals(modelName);
})
.FirstOrDefault();
if (!(paramDescriptor is ControllerParameterDescriptor ctrlParamDescriptor))
{
return null;
}
var attribute = ctrlParamDescriptor.ParameterInfo
.GetCustomAttributes(typeof(DateTimeModelBinderAttribute), false)
.FirstOrDefault();
return (DateTimeModelBinderAttribute)attribute;
}
private string GetModelName(ModelBindingContext bindingContext)
{
if (!string.IsNullOrEmpty(bindingContext.BinderModelName))
{
return bindingContext.BinderModelName;
}
return bindingContext.ModelName;
}
}
public class DateTimeModelBinderAttribute : ModelBinderAttribute
{
public string DateFormat { get; set; }
public DateTimeModelBinderAttribute()
: base(typeof(DateTimeModelBinder))
{
}
}

For the GetModelName method, the “name” property of the ModelBinder attribute is used to specify the route parameter name when the action parameter name is different from the route parameter name. For example, when the route is /api/{endDate} and the action parameter name is date.

We can add this attribute with a Name property as follows: [DateTimeModelBinder(Name = "endDate")].

Next, we also need a helper method, the code for this is as follows.

public static class Helper
{
private static readonly string[] CUSTOM_DATE_FORMATS = new string[]
{
"yyyyMMddTHHmmssZ",
"yyyyMMddTHHmmZ",
"yyyyMMddTHHmmss",
"yyyyMMddTHHmm",
"yyyyMMddHHmmss",
"yyyyMMddHHmm",
"yyyyMMdd",
"yyyy-MM-ddTHH-mm-ss",
"yyyy-MM-dd-HH-mm-ss",
"yyyy-MM-dd-HH-mm",
"yyyy-MM-dd",
"MM-dd-yyyy",
"dd/MM/yyyy"
};
public static DateTime? ParseDateTime(
string dateToParse,
string[] formats = null,
IFormatProvider provider = null,
DateTimeStyles styles = DateTimeStyles.None)
{
if (formats == null || !formats.Any())
{
formats = CUSTOM_DATE_FORMATS;
}
DateTime validDate;
foreach (var format in formats)
{
if (format.EndsWith("Z"))
{
if (DateTime.TryParseExact(dateToParse, format, provider, DateTimeStyles.AssumeUniversal, out validDate))
{
return validDate;
}
}
if (DateTime.TryParseExact(dateToParse, format, provider, styles, out validDate))
{
return validDate;
}
}
return null;
}
public static bool IsNullableType(Type type)
{
return type.IsGenericType && type.GetGenericTypeDefinition().Equals(typeof(Nullable<>));
}
}

Finally, as I want to bind to model properties, I also need a CustomDateTimeConverter, here is the final piece of code.

public class CustomDateTimeConverter : DateTimeConverterBase
{
private readonly string dateFormat = null;
private readonly DateTimeConverterBase innerConverter = null;
public CustomDateTimeConverter()
: this(dateFormat: null)
{
}
public CustomDateTimeConverter(string dateFormat = null)
: this(dateFormat, innerConverter: new IsoDateTimeConverter())
{
}
public CustomDateTimeConverter(string dateFormat = null, DateTimeConverterBase innerConverter = null)
{
this.dateFormat = dateFormat;
this.innerConverter = innerConverter ?? new IsoDateTimeConverter();
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Date)
{
return reader.Value;
}
var isNullableType = Helper.IsNullableType(objectType);
if (reader.TokenType == JsonToken.Null)
{
if (isNullableType)
{
return null;
}
throw new JsonSerializationException($"Cannot convert null value to {objectType}.");
}
if (reader.TokenType != JsonToken.String)
{
throw new JsonSerializationException($"Unexpected token parsing date. Expected {nameof(String)}, got {reader.TokenType}.");
}
var dateToParse = reader.Value.ToString();
if (isNullableType && string.IsNullOrWhiteSpace(dateToParse))
{
return null;
}
if (string.IsNullOrEmpty(this.dateFormat))
{
return Helper.ParseDateTime(dateToParse);
}
return Helper.ParseDateTime(dateToParse, new string[] { this.dateFormat });
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
innerConverter?.WriteJson(writer, value, serializer);
}
}

We are now able to pull all of this together, so on our model property, we can use the JsonConverter attribute from Newtonsoft.Json, and provide the following.

[JsonConverter(typeof(CustomDateTimeConverter), ["yyyyMMdd"])]
public DateTime UsageDateTime { get; set; }

Summary

There you have it, don’t forget, if your problem is based around the culture your application is running, use that rather than implement something custom, but if you need to work with specific formats, you can now use a custom date time model binder.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.