31

I'm posting an object to an MVC controller. The object contains a field called StartDt and on the client it is a javascript Date object in local time.

When I call JSON.stringify on the object and POST it to the server using jQuery's ajax method I can see in Firebug that what's being sent to the server is an ISO string like "1900-12-31T13:00:00.000Z" which I believe should be the local time in UTC format.

When I look at the DateTime field in my controller though, it looks like its back to local time and not UTC. How can I fix this?

I want to store the UTC version of the Date that came from the client.

Ian Kemp
  • 28,293
  • 19
  • 112
  • 138
C.J.
  • 6,789
  • 7
  • 36
  • 45

7 Answers7

23

This problem persists in ASP.NET Core 2.0. The following code will resolve it, supporting ISO 8601 basic and extended formats, properly preserving the value and setting DateTimeKind correctly. This aligns with the default behavior of JSON.Net's parsing, so it keeps your model binding behavior aligned with the rest of the system.

First, add the following model binder:

public class DateTimeModelBinder : IModelBinder
{
    private static readonly string[] DateTimeFormats = { "yyyyMMdd'T'HHmmss.FFFFFFFK", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK" };

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

        var stringValue = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).FirstValue;

        if (bindingContext.ModelType == typeof(DateTime?) && string.IsNullOrEmpty(stringValue))
        {
            bindingContext.Result = ModelBindingResult.Success(null);
            return Task.CompletedTask;
        }

        bindingContext.Result = DateTime.TryParseExact(stringValue, DateTimeFormats,
            CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)
            ? ModelBindingResult.Success(result)
            : ModelBindingResult.Failed();

        return Task.CompletedTask;
    }
}

Then add the following model binder provider:

public class DateTimeModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (context.Metadata.ModelType != typeof(DateTime) &&
            context.Metadata.ModelType != typeof(DateTime?))
            return null;

        return new BinderTypeModelBinder(typeof(DateTimeModelBinder));
    }
}

Then register the provider in your Startup.cs file:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
    {
        ...

        options.ModelBinderProviders.Insert(0, new DateTimeModelBinderProvider());

        ...
    }
}
Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • 1
    This is great. It seems when doing `ConfigureServices`, the order of the `ModelBinderProviders` matters which I assume is why you're doing an Insert rather than an Add to the collection. – Mike L Feb 06 '18 at 13:29
21

I found a gist on Google with code for an ISO 8601-compliant DateTime Model Binder, and then modified it like this:

public class DateTimeBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var name = bindingContext.ModelName;
        var value = bindingContext.ValueProvider.GetValue(name);
        if (value == null) 
            return null;

        DateTime date;
        if (DateTime.TryParse(value.AttemptedValue, null, DateTimeStyles.RoundtripKind, out date))
            return date;
        else
            return base.BindModel(controllerContext, bindingContext);
    }
}

I believe the gist code is too restrictive - it wants 6 decimal places on seconds or it will not accept the timestamp. This uses TryParse instead of TryParseExact, so it will technically accept a LOT of timestamp types. The important part is that it uses the DateTimeStyles.RoundtripKind to respect the time zone implied by the Z. So this is no longer technically an ISO 8601-specific implementation.

You could then hook this into the MVC pipeline with a model binder attribute or with this snippet in an App_Start:

var dateTimeBinder = new DateTimeBinder();

ModelBinders.Binders.Add(typeof(DateTime), dateTimeBinder);
ModelBinders.Binders.Add(typeof(DateTime?), dateTimeBinder);
Ian Kemp
  • 28,293
  • 19
  • 112
  • 138
David Boike
  • 18,545
  • 7
  • 59
  • 94
  • 5
    There's no need for you to parse the datetime value itself. As stated in the question the problem is not the parsing of the date (its parses just fine) its the behaviour of the default model binder to convert the parsed utc date into the server local datetime. I agree a model binder is probably a better fit than an action filter though - however all it needs to is get the value from the base.BindModel call, check if the value is Kind=Local then convert it to Utc and return. – Sam Nov 13 '12 at 10:24
  • 1
    Nice, I wonder why .NET doesn't parse ISO dates with `DateTimeStyles.RoundtripKind` by default. – Maksim Vi. Apr 14 '15 at 21:35
  • Without knowing the full picture, I'd say this should be the default. Now when I submit strings in ISO 8601 they are parsed correctly, and conversions to server local time are correct too. – beruic Apr 14 '16 at 10:42
9

I created this little attribute.

public class ConvertDateToUTCAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var dateArgs =
            filterContext.ActionParameters.Where(
                x => x.Value != null && x.Value.GetType().IsAssignableFrom(typeof(DateTime))).ToList();

        foreach (var keyValuePair in dateArgs)
        {
            var date = (DateTime) keyValuePair.Value;

            if (date.Kind == DateTimeKind.Local)
                filterContext.ActionParameters[keyValuePair.Key] = date.ToUniversalTime();
        }

        base.OnActionExecuting(filterContext);
    }
}

So this will leave dates that are Unspecified or already Utc alone. You can apply it the whole controller.

Sam
  • 1,725
  • 1
  • 17
  • 28
  • What if we are passing a custom class that has DateTime property inside it? IsAssignableFrom(typeof(DateTime)) shouldn't work. – Oleksii Aza Nov 25 '13 at 11:06
  • 1
    Yeh this only works for dates that are direct parameters to the action. A more robust alternative would probably be to use model binding. See the binder below and also my comment on it. – Sam Nov 26 '13 at 03:24
  • Why the downvotes? This is valid alternative to writing a model binder. – Sam Jul 15 '14 at 02:19
8

Alternatively, you can specify your bound objects to be DateTimeOffset rather than DateTime, and there will be no automatic conversion. As long as the incoming string has the Z suffix, you should get the original date with an offset of +00:00.

Bryan Johnson
  • 81
  • 1
  • 3
7

You may have to use the DateTime.ToUniversalTime() method to get back the UTC time.

Gautam Jain
  • 6,789
  • 10
  • 48
  • 67
  • 22
    This is the kind of thing that would be better handled by infrastructure so we don't have to think about it *every single time* we handle a DateTime. – David Boike Nov 12 '12 at 22:09
  • 1
    @DavidBoike : http://www.martin-brennan.com/custom-utc-datetime-model-binding-mvc/ – Ofer Zelig Nov 04 '15 at 02:39
2

I think this is resolved in ASP.NET Core 5.0.

https://github.com/dotnet/aspnetcore/issues/11584

Luke Puplett
  • 42,091
  • 47
  • 181
  • 266
2

This is resolved in ASP.NET Core 5.0+. But if you also want to treat the inputs that does not denote a local or UTC time as UTC you can do this:

using System;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

internal class CustomDateTimeModelBinderProvider : IModelBinderProvider
{
    internal const 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 CustomDateTimeModelBinder(SupportedStyles, loggerFactory);
        }

        return null;
    }
}
using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.Extensions.Logging;

internal class CustomDateTimeModelBinder : IModelBinder
{
    private readonly DateTimeModelBinder _dateTimeModelBinder;

    public CustomDateTimeModelBinder(DateTimeStyles supportedStyles, ILoggerFactory loggerFactory)
    {
        _dateTimeModelBinder = new DateTimeModelBinder(supportedStyles, loggerFactory);
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await _dateTimeModelBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        {
            var value = (DateTime)bindingContext.Result.Model!;

            if (value.Kind == DateTimeKind.Unspecified)
            {
                var model = DateTime.SpecifyKind(value, DateTimeKind.Utc);

                bindingContext.Result = ModelBindingResult.Success(model);
            }
        }
    }
}
services.AddMvc(o =>
{
    o.ModelBinderProviders.Insert(0, new CustomDateTimeModelBinderProvider());
});
ctyar
  • 931
  • 2
  • 10
  • 22