Custom DateTime Model Binding in ASP.NET Core Web API

This post shows how to write a custom DateTime model binder in ASP.NET Core Web API. It is based on my post that shows how to bind custom DateTime formats in ASP.NET Web API.

The default binding mechanism will not work for custom URL friendly date formats like:

yyyyMMdd
yyyy-MM-dd
MM-dd-yyyy
yyyyMMddTHHmmss
yyyy-MM-ddTHH-mm-ss

We can easily create a custom DateTimeModelBinder to bind custom DateTime formats. I have posted the code on GitHub Gist.

public class DateTimeModelBinderAttribute : ModelBinderAttribute
{
    public string DateFormat { get; set; }

    public DateTimeModelBinderAttribute()
        : base(typeof(DateTimeModelBinder))
    {
    }
}
	
public class DateTimeModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        if (bindingContext.ModelType != typeof(DateTime?))
        {
            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 ParseDateTime(dateToParse);
        }

        return 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 DateTime? ParseDateTime(
        string dateToParse,
        string[] formats = null,
        IFormatProvider provider = null,
        DateTimeStyles styles = DateTimeStyles.AssumeLocal)
    {
        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 = 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;
    }
}

Now apply the [DateTimeModelBinder] attribute to the method parameter.

[Route("api/[controller]")]
public class MainController : Controller  
{
    [Route("EchoDate/{date}")]
    [HttpGet]
    public DateTime? EchoDate(
        [DateTimeModelBinder]
        DateTime? date)
    {
        return date;
    }

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

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

public class DateTimeModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(DateTime?))
        {
            return new DateTimeModelBinder();
        }

        return null;
    }
}
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 our custom binder to beginning of collection
            option.ModelBinderProviders.Insert(0, new DateTimeModelBinderProvider());
        });
    }
}

We can now verify that all these work as expected.

GET http://localhost/api/EchoDate/20150115

GET http://localhost/api/EchoDate/2015-01-15

GET http://localhost/api/EchoDate/10-05-2015

GET http://localhost/api/EchoDate/20150115T142354

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

Related: