Custom DateTime Model Binding in ASP.NET Web API

Looking for ASP.NET Core?

ASP.NET Web API binds simple data types like DateTime by reading from the URL by default.

Source: Parameter Binding in ASP.NET Web API

By default, Web API uses the following rules to bind parameters:

  • If the parameter is a “simple” type, Web API tries to get the value from the URI. Simple types include the .NET primitive types (int, bool, double, and so forth), plus TimeSpan, DateTime, Guid, decimal, and string, plus any type with a type converter that can convert from a string. (More about type converters later.)
  • For complex types, Web API tries to read the value from the message body, using a media-type formatter.

This default mechanism does not work for custom URL friendly date formats:

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

Create a custom DateTimeParameterBinding and DateTimeParameterAttribute.

namespace App.Web.Http.Helpers
{
    public class DateTimeParameterBinding : HttpParameterBinding
    {
        public string BinderModelName { get; set; }
        public string DateFormat { get; set; }
        public bool ReadFromQueryString { get; set; }

        public DateTimeParameterBinding(HttpParameterDescriptor descriptor)
            : base(descriptor) { }

        public override Task ExecuteBindingAsync(
            ModelMetadataProvider metadataProvider,
            HttpActionContext actionContext,
            CancellationToken cancellationToken)
        {
            string dateToParse = null;
            var paramName = !string.IsNullOrEmpty(ModelName) ? ModelName : this.Descriptor.ParameterName;
            
            if (ReadFromQueryString)
            {
                // reading from query string
                var nameVal = actionContext.Request.GetQueryNameValuePairs();
                dateToParse = nameVal.Where(q => q.Key.EqualsEx(paramName))
                                     .FirstOrDefault().Value;
            }
            else
            {
                // reading from route
                var routeData = actionContext.Request.GetRouteData();
                if (routeData.Values.TryGetValue(paramName, out var dateObj))
                {
                    dateToParse = Convert.ToString(dateObj);
                }
            }

            DateTime? dateTime = null;
            if (!string.IsNullOrEmpty(dateToParse))
            {
                if (string.IsNullOrEmpty(DateFormat))
                {
                    dateTime = ParseDateTime(dateToParse);
                }
                else
                {
                    dateTime = ParseDateTime(dateToParse, new string[] { DateFormat });
                }
            }

            SetValue(actionContext, dateTime);

            return Task.FromResult<object>(null);
        }        

        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-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;
                    }
                }
                else
                {
                    if (DateTime.TryParseExact(dateToParse, format,
                             provider, styles, out validDate))
                    {
                        return validDate;
                    }
                }
            }

            return null;
        }
    }
}
namespace App.Web.Http.Helpers
{
    public class DateTimeParameterAttribute : ParameterBindingAttribute
    {
        // 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 [DateTimeParameter(Name ="birthDate")]
        public string Name { get; set; }
        public string DateFormat { get; set; }
        public bool ReadFromQueryString { get; set; }

        public override HttpParameterBinding GetBinding(
            HttpParameterDescriptor parameter)
        {
            if (parameter.ParameterType == typeof(DateTime?))
            {
                return new DateTimeParameterBinding(parameter)
                {
                    BinderModelName = Name,
                    DateFormat = DateFormat,
                    ReadFromQueryString = ReadFromQueryString
                };
            }

            return parameter.BindAsError("Expected type DateTime?");
        }
    }
}

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.Date)
        {
            return (DateTime?)reader.Value;
        }

        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)
    {
        var dateTime = value as DateTime?;
        if (dateTime == null || string.IsNullOrWhiteSpace(this.dateFormat))
        {
            return;
        }

        var s = dateTime.Value.ToString(this.dateFormat);
        writer.WriteValue(s);
    }
}

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; }
}

Apply the [DateTimeParameter] attribute on the action parameter.

[RoutePrefix("api")]
public class MainController : ApiController  
{
    [Route("EchoDate/{date}")]
    [HttpGet]
    public DateTime? EchoDate(
        [DateTimeParameter]
        DateTime? date)
    {
        return date;
    }

    [Route("EchoCustomDateFormat/{date}")]
    [HttpGet]
    public DateTime? EchoCustomDateFormat(
        [DateTimeParameter(DateFormat = "dd_MM_yyyy")]
        DateTime? date)
    {
        return date;
    }

    [Route("EchoDateFromUri/{date}")]
    [HttpGet]
    public DateTime? EchoDateFromUri(
        [DateTimeParameter(DateFormat = "yyyyMMdd", FromUri = true)]
        DateTime? date)
    {
        return date;
    }

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

Add it globally to bind all action parameters of type DateTime and DateTime?.

using App.Web.Http.Helpers;

namespace App.Web  
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // add the rule to the collection
            config.ParameterBindingRules
                  .Add(typeof(DateTime?), 
                       des => new DateTimeParameterBinding(des));
        }
    }
}

Verify the api works 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

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

Related:

Looking for ASP.NET Core?

Parameter Binding in ASP.NET Web API