Using DateTimeStyles.RoundtripKind when binding DateTime with IConfiguration

Viewed 205

TL;DR: I would like the result of binding DateTime values from IConfiguration to be the same as DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind), but by default it is the same as DateTime.Parse(stringValue).


I'm using the binding functionality of IConfiguration to pull DateTime values out of configuration.

Say I have a JSON configuration that looks like this:

{
    testValue: "2020-03-08T14:37:46.9793888Z"
}

I might pull the value out as a DateTime in one of a few ways. Here are the first two I thought of:

// Directly from IConfiguration
var dateTimeValue = configuration.GetValue<DateTime>("testValue");
// By configuring options

// Assuming this class exists
private class TestOptions
{
    public DateTime TestValue { get; set; }
}

// Set up options on a service provider
var services = new ServiceCollection();
services.Configure<TestOptions>(configuration);
var provider = services.BuildServiceProvider();

// Get as a service
var options = provider.GetRequiredService<IOptions<TestOptions>>().Value;
return options.TestValue;

The result of doing these, at least by default, is more or less equivalent to applying an ordinary parse:

// Bad, ignores Kind
var dateTimeValue = DateTime.Parse(stringValue)

I'm working on code that pays attention to the Kind property of the DateTime. The ordinary parse ignores "Z" times and makes anything with a time zone indication Local. An invocation like this, with the RoundtripKind style, fixes the problem:

// Good, uses Kind if available
var dateTimeValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind);

Is there any way to force the configuration binder to use this invocation instead of the default?

1 Answers

I had a thought: I could replace the DateTime field in the option class with a wrapper type, then implement the parsing on that. We change the type of the field like so:

private class TestOptions
{
    //public DateTime TestValue { get; set; }
    public RoundtripDateTime TestValue { get; set; }
}

We make up a wrapper type (I made it a struct, but a class also works), including implicit conversions to and from DateTime (so that at least some code doesn't need to be changed) and nice Parse() and ToString() methods. We include a [TypeConverter] attribute so that the configuration binder detects the converter class.

    [TypeConverter(typeof(RoundtripDateTimeTypeConverter))]
    public struct RoundtripDateTime
    {
        public RoundtripDateTime(DateTime value)
        {
            Value = value;
        }

        public DateTime Value { get; set; }

        public static implicit operator DateTime(RoundtripDateTime roundTripDateTime) => roundTripDateTime.Value;
        public static implicit operator RoundtripDateTime(DateTime dateTime) => new RoundtripDateTime(dateTime);

        public override string ToString() => Value.ToString("o");
        public static RoundtripDateTime Parse(string s) => DateTime.Parse(s, null, DateTimeStyles.RoundtripKind);
        public static RoundtripDateTime Parse(string s, CultureInfo culture) => DateTime.Parse(s, culture, DateTimeStyles.RoundtripKind);
    }

Finally, we include a new TypeConverter implementation that will handle instances of the wrapper. (I cheated and looked up the source to DateTimeConverter to find out what it was already doing, but this is actually much simpler because it always operates in culture-ignorant mode. [Edit: For conversion from string, added culture to help Parse() deal with datetimes that aren't in the ISO8601 format. It's still pretty simple.])

    public class RoundtripDateTimeTypeConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
        {
            return (sourceType == typeof(string)) || base.CanConvertFrom(context, sourceType);
        }

        public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
        {
            if (value is string stringValue)
            {
                var text = stringValue.Trim();

                if (text == string.Empty)
                {
                    return (RoundtripDateTime)DateTime.MinValue;
                }
                else
                {
                    try
                    {
                        return RoundtripDateTime.Parse(text, culture ?? CultureInfo.InvariantCulture);
                    }
                    catch (FormatException e)
                    {
                        throw new FormatException($"'{value}' is not a valid value for {nameof(RoundtripDateTime)}.", e);
                    }
                }
            }
            else
            {
                return base.ConvertFrom(context, culture, value);
            }
        }

        public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
        {
            if (destinationType == typeof(string) && value is RoundtripDateTime roundtripDateTimeValue)
            {
                return (roundtripDateTimeValue.Value == DateTime.MinValue)
                    ? string.Empty
                    : roundtripDateTimeValue.ToString();
            }
            else
            {
                return base.ConvertTo(context, culture, value, destinationType);
            }
        }
    }

This all may require some tweaking, but I think I have something I can work with.

If you have any better ideas, feel free to add another answer.

Related