Custom DateTime Model Binding in ASP.NET Core Web API

Source on GitHub.

This post shows how to write a custom DateTime model binder in ASP.NET Core Web API. The default DateTime binding mechanism will not work for custom date formats like:

yyyyMMdd
yyyy-MM-dd
MM-dd-yyyy
yyyyMMddTHHmmss
yyyy-MM-ddTHH-mm-ss
  1. Create a custom DateTimeModelBinder and DateTimeModelBinderAttribute.
public class DateTimeModelBinder : IModelBinder
{
    public static readonly Type[] SUPPORTED_TYPES = new Type[] { typeof(DateTime), typeof(DateTime?) };

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        if (!SUPPORTED_TYPES.Contains(bindingContext.ModelType))
        {
            return Task.CompletedTask;
        }

        var modelName = GetModelName(bindingContext);

        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
        if (valueProviderResult == ValueProviderResult.None)
        {
            return Task.CompletedTask;
        }

        bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);

        var dateToParse = valueProviderResult.FirstValue;

        if (string.IsNullOrEmpty(dateToParse))
        {
            return Task.CompletedTask;
        }

        var dateTime = ParseDate(bindingContext, dateToParse);

        bindingContext.Result = ModelBindingResult.Success(dateTime);

        return 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();

        var ctrlParamDescriptor = paramDescriptor as ControllerParameterDescriptor;
        if (ctrlParamDescriptor == null)
        {
            return null;
        }

        var attribute = ctrlParamDescriptor.ParameterInfo
            .GetCustomAttributes(typeof(DateTimeModelBinderAttribute), false)
            .FirstOrDefault();

        return (DateTimeModelBinderAttribute)attribute;
    }

    private string GetModelName(ModelBindingContext bindingContext)
    {
        // The "Name" property of the ModelBinder attribute can be used to specify the
        // route parameter name when the action parameter name is different from the route parameter name.
        // For instance, when the route is /api/{birthDate} and the action parameter name is "date".
        // We can add this attribute with a Name property [DateTimeModelBinder(Name ="birthDate")]
		// Now bindingContext.BinderModelName will be "birthDate" and bindingContext.ModelName will be "date"
        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))
    {
    }
}
  1. A helper class with the ParseDateTime() and other methods...
public class Helper
{
    public static DateTime? ParseDateTime(
        string dateToParse,
        string[] formats = null,
        IFormatProvider provider = null,
        DateTimeStyles styles = DateTimeStyles.None)
    {
        var 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"
            };

        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<>));
    }
}
  1. Create a CustomDateTimeConverter if you need to bind model properties.
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)
    {
        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 });
    }   
}
  1. Register the CustomDateTimeConverter on the model properties using the [JsonConverter] attribute.
public class PostData
{
    [JsonConverter(typeof(CustomDateTimeConverter), new object[] { "MM-dd-yyyy" })]
    public DateTime DateFrom { get; set; }

    [JsonConverter(typeof(CustomDateTimeConverter), new object[] { "MM-dd-yyyy" })]
    public DateTime? DateTo { get; set; }
}
  1. Apply the [DateTimeModelBinder] attribute on the action parameter.
[Route("api/[controller]")]
public class MainController : Controller  
{
    [Route("echo-date/{date}")]
    [HttpGet]
    public DateTime? EchoDate(
        [DateTimeModelBinder]
        DateTime? date)
    {
        return date;
    }

    [Route("echo-custom-date/{date}")]
    [HttpGet]
    public DateTime? EchoCustomDateFormat(
        [DateTimeModelBinder(DateFormat = "yyyyMMdd")]
        DateTime? date)
    {
        return date;
    }

    [Route("echo-model")]
    [HttpPost]
    public PostData EchoModel(PostData model)
    {
        return model;
    }
}

Alternatively, we can create a provider and add it globally during application startup in which case the [DateTimeModelBinder] attribute is not required. All action parameters of type DateTime and DateTime? will be automatically bound.

public class DateTimeModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (DateTimeModelBinder.SUPPORTED_TYPES.Contains(context.Metadata.ModelType))
        {
            return new BinderTypeModelBinder(typeof(DateTimeModelBinder));
        }

        return null;
    }
}
  1. Add the DateTimeModelBinderProvider to the provider collection during startup.
public class Startup
{
    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(option =>
        {
            // add the custom binder at the top of the collection
            option.ModelBinderProviders.Insert(0, new DateTimeModelBinderProvider());
        });
    }
}
  1. Verify the api works as expected...
GET http://localhost/api/echo-date/20150115

GET http://localhost/api/echo-date/2015-01-15

GET http://localhost/api/echo-date/10-05-2015

GET http://localhost/api/echo-date/20150115T142354

GET http://localhost/api/echo-date/2015-01-15T14-23-54

POST http://localhost/api/echo-model
{
    "dateFrom": "12-25-2019",
    "dateTo": "12-31-2019"
}

Related: