Click here to Skip to main content
15,567,466 members
Articles / Web Development / ASP.NET
Article
Posted 22 Feb 2022

Stats

25.4K views
12 bookmarked

DateOnly in .NET 6 and ASP.NET Core 6

Rate me:
Please Sign up or sign in to vote.
5.00/5 (4 votes)
14 Nov 2022CPOL7 min read
Solutions for using DateOnly in ASP.NET Core 6, before 7 (Updated for .NET 7)
DateOnly introduced in .NET 6 is not good enough for ASP.NET Core 6 out of the box. This article provides solutions for utilizing DateOnly before Microsoft's .NET runtime team or ASP.NET Core team would fix the issues, probably in upcoming .NET 7.

Introduction

DateOnly is a newly introduced primitive data type in .NET 6. Apparently, it is good for presenting, passing and storing date only information, such as DateOrBirth, RegisterDate, and WhatEverEventDate.

In the past, .NET (Framework or Core) developers basically used three approaches:

  1. Use string like yyyy-MM-dd, or yyyyMMdd. And convert the object to DateTime for calculating date span.
  2. Use DateTime or DateTimeOffset and make sure TimeOfDay is Zero. And pay extra care when doing cross-timezone conversations.
  3. Use Noda Time library or alike. However, using an extra library may introduce some negative impacts depending on your contexts.

So having a dedicated type for date only info is really a blessing. However, I had found that DateOnly is not properly supported in ASP.NET Core or System.Text.Json yet. If you use DateOnly in Web API, you will soon get into trouble in bindings and serialization.

This article provides solutions for utilizing DateOnly in ASP.NET Core 6, before 7 is introduced in the future.

Important issues about ASP.NET 7

As of .NET 7 released on 9, November 2022, Microsoft's .NET development team had fixed most of the problems addressed in this article. If you are using ASP.NET 7 and not using JavaScript clients, you may skip this article. If you have been using the library provided in this article, the applications migrated to ASP.NET 7 won't be breaking, since Microsoft's .NET team apparently had used some similar solution in .NET 7. 

If you have JavaScript clients which will serialize Date only info in playload, you may be interested in reading further tips "DateOnly in ASP.NET 7 with JavaScript Clients".

Background

In the past, I used a derived class of Newtonsoft.Json.Converters.IsoDateTimeConverter for handling date only information.

C#
public class DateAndTimeConverter : IsoDateTimeConverter
{
    static readonly Type typeOfDateTime = typeof(DateTime);
    static readonly Type typeOfNullableDateTime = typeof(DateTime?);
    static readonly Type typeOfDateTimeOffset = typeof(DateTimeOffset);
    static readonly Type typeOfNullDateTimeOffset = typeof(DateTimeOffset?);

    public override void WriteJson
    (JsonWriter writer, object value, JsonSerializer serializer)
    {
        var type = value.GetType();
        if (type == typeOfDateTimeOffset)
        {
            var dto = (DateTimeOffset)value;
            if (dto == DateTimeOffset.MinValue)
            {
                writer.WriteNull();
                return;
            }
            else if (dto.TimeOfDay == TimeSpan.Zero)
            {
                writer.WriteValue(dto.ToString("yyyy-MM-dd"));
                return;
            }
        }
        else if (type == typeOfNullDateTimeOffset)
        {
            var dto = (DateTimeOffset?)value;
            if (!dto.HasValue || dto.Value == DateTimeOffset.MinValue)
            {
                writer.WriteNull();
                return;
            }
            else if (dto.Value.TimeOfDay == TimeSpan.Zero)
            {
                writer.WriteValue(dto.Value.ToString("yyyy-MM-dd"));
                return;
            }
        }
        else if (type == typeOfDateTime)
        {
            var dt = (DateTime)value;
            if (dt.TimeOfDay == TimeSpan.Zero)
            {
                writer.WriteValue(dt.ToString("yyyy-MM-dd"));
                return;
            }
        }
        else if (type == typeOfNullableDateTime)
        {
            var dto = (DateTime?)value;
            if (!dto.HasValue || dto.Value == DateTime.MinValue)
            {
                writer.WriteNull();
                return;
            }
            else if (dto.Value.TimeOfDay == TimeSpan.Zero)
            {
                writer.WriteValue(dto.Value.ToString("yyyy-MM-dd"));
                return;
            }
        }

        base.WriteJson(writer, value, serializer);
    }
}

These days, I would prefer to use JsonConverter<T>. This approach looks neater and is more flexible. And System.Text.Json has a class with similar interfaces.

Using the Code

DateOnlyJsonConverter is one of the converters in nuget package Fonlow.DateOnlyExtensions. You should be using DateOnlyJsonConverter in both ASP.NET Core controllers and .NET clients.

C#
public sealed class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
    public override void WriteJson
    (JsonWriter writer, DateOnly value, JsonSerializer serializer)
    {
        writer.WriteValue(value.ToString("O"));
    }

    public override DateOnly ReadJson
    (JsonReader reader, Type objectType, DateOnly existingValue, 
     bool hasExistingValue, JsonSerializer serializer)
    {
        var v = reader.Value;
        if (v == null)
        {
            return DateOnly.MinValue;
        }

        var vType = v.GetType();
        if (vType == typeof(DateTimeOffset)) //when the object is from a property 
                                             //in POST body. When used in service, 
                                             //better to have 
                                    //options.SerializerSettings.DateParseHandling = 
                                    //Newtonsoft.Json.DateParseHandling.DateTimeOffset;
        {
            return DateOnly.FromDateTime(((DateTimeOffset)v).DateTime);
        }

        if (vType == typeof(string))
        {
            return DateOnly.Parse((string)v); //DateOnly can parse 00001-01-01
        }

        if (vType == typeof(DateTime)) //when the object is from a property 
                                       //in POST body from a TS client
        {
            return DateOnly.FromDateTime((DateTime)v);
        }

        throw new NotSupportedException
              ($"Not yet support {vType} in {this.GetType()}.");
    }
}

In your ASP.NET Core Startup codes, inject the converters to controllers:

C#
public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers()
        .AddNewtonsoftJson(
            options =>
            {
                options.SerializerSettings.DateParseHandling =
                        Newtonsoft.Json.DateParseHandling.DateTimeOffset;
                options.SerializerSettings.Converters.Add
                                           (new DateOnlyJsonConverter());
                options.SerializerSettings.Converters.Add
                                           (new DateOnlyNullableJsonConverter());
            }
        );

In your .NET client codes with HttpClient, add the converters to JsonSerializerSettings:

C#
var jsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings()
{
    NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore,
};

jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter());
jsonSerializerSettings.Converters.Add(new DateOnlyNullableJsonConverter());
Api = new DemoWebApi.Controllers.Client.SuperDemo(httpClient, jsonSerializerSettings);

public partial class SuperDemo
{
    private System.Net.Http.HttpClient client;

    private JsonSerializerSettings jsonSerializerSettings;

    public SuperDemo(System.Net.Http.HttpClient client, 
                     JsonSerializerSettings jsonSerializerSettings = null)
    {
        if (client == null)
            throw new ArgumentNullException("Null HttpClient.", "client");

        if (client.BaseAddress == null)
            throw new ArgumentNullException("HttpClient has no BaseAddress", "client");

        this.client = client;
        this.jsonSerializerSettings = jsonSerializerSettings;
    }

    public System.DateOnly PostDateOnly(System.DateOnly d, 
           Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/SuperDemo/DateOnly";
        using (var httpRequestMessage = 
               new HttpRequestMessage(HttpMethod.Post, requestUri))
        {
            using (var requestWriter = new System.IO.StringWriter())
            {
                var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
                requestSerializer.Serialize(requestWriter, d);
                var content = new StringContent(requestWriter.ToString(), 
                              System.Text.Encoding.UTF8, "application/json");
                httpRequestMessage.Content = content;
                if (handleHeaders != null)
                {
                    handleHeaders(httpRequestMessage.Headers);
                }

                var responseMessage = client.SendAsync(httpRequestMessage).Result;
                try
                {
                    responseMessage.EnsureSuccessStatusCodeEx();
                    var stream = responseMessage.Content.ReadAsStreamAsync().Result;
                    using (JsonReader jsonReader = new JsonTextReader
                                      (new System.IO.StreamReader(stream)))
                    {
                        var serializer = JsonSerializer.Create(jsonSerializerSettings);
                        return serializer.Deserialize<System.DateOnly>(jsonReader);
                    }
                }
                finally
                {
                    responseMessage.Dispose();
                }
            }
        }
    }

Now DateOnly could be utilized in ASP.NET Core 6 in almost all cases.

More examples could be found in the test suite.

Points of Interest

DateOnly in URL?

So far, with the custom JsonConverters, you may use a DateOnly object in the HTTP POST body and the returned result, as a standalone object or the value of a property of a complex object, however, using DateOnly object as a segment of URL is not yet possible because custom JsonConverter is not involved but "Microsoft.AspNetCore.Routing.EndpointMiddleware" along with Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexObjectModelBinder which may be using System.Text.Json.

Apparently, Microsoft's ASP.NET Core team needs to do something to give DateOnly the same treatment as for DateTimeOffset, while currently DateOnly is not listed as simple types at Model Binding in ASP.NET Core 6.

Nevertheless, this is not really a big problem to application developers who may just use a string type rather than DateOnly type for the URL parameter, and pass an ISO 8601 date string. For example:

C#
[HttpGet]
[Route("DateOnlyStringQuery")]
public DateOnly QueryDateOnlyAsString([FromQuery] string d)
{
    return DateOnly.Parse(d);
}

[Fact]
public async void TestQueryDateOnlyString()
{
    DateOnly d = new DateOnly(2008, 12, 18);
    var r = await api.QueryDateOnlyAsStringAsync(d.ToString("O"));
    Assert.Equal(d, r);
}

Or, simply use POST.

How about Code Generators?

Writing client codes talking to Web APIs sounds repetitive and boring. These days, many developers would prefer to use code generators to generate client API codes. And you may try WebApiClientGen and OpenApiClientGen, which both can generate client API codes like the one above.

Newtonsoft.Json or System.Text.Json?

As of .NET 6, there are still some cases which System.Text.Json cannot handle correctly while Newtonsoft.Json can. For more details, please read "Newtonsoft.Json vs System.Text.Json in ASP.NET Core 6".

How about .NET Framework Clients?

Apparently, Microsoft has no plan to back port DateOnly to .NET Framework. So if you have some .NET Framework client applications to maintain, and want to talk to an ASP.NET Core Web API service utilizing DateOnly, what can you do?

You may use DateTimeOffsetJsonConverter and DateTimeJsonConverter in Nuget package Fonlow.DateOnlyExtensionsNF. And examples of talking to a ASP.NET Core Web API using DateOnly could be found in this test suite.

WebApiClientGen generates C# client API codes always mapping DateOnly to DateOnly. And OpenApiClientGen has a setting "DateToDateOnly" default to True. If you want the generated codes to be used by both .NET Framework clients and .NET clients, you may keep "DateToDateOnly" to true. Make a copy of the generated codes and replace all "DateOnly" identifiers with "DateTimeOffset". And it shouldn't be hard for you to automate such variant of generated codes through a Powsershell script.

.NET Framework client application codes:

C#
var jsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings()
{
    NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore,
};

jsonSerializerSettings.DateParseHandling = 
    Newtonsoft.Json.DateParseHandling.DateTimeOffset; //needed to make sure 
                    //JSON serializers assume DateTimeOffset rather than DateTime.
jsonSerializerSettings.Converters.Add
    (new DateTimeOffsetJsonConverter()); //needed to handle DateOnly.MinValue
jsonSerializerSettings.Converters.Add
    (new DateTimeOffsetNullableJsonConverter()); //needed to handle DateOnly.MinValue
Api = new DemoWebApi.Controllers.Client.DateTypes(httpClient, jsonSerializerSettings);

[Fact]
public void TestPostDateOnly()
{
    var dateOnly = new DateTimeOffset(1988, 12, 23, 0, 0, 0, TimeSpan.Zero);
    var r = api.PostDateOnly(dateOnly);
    Assert.Equal(dateOnly.Date, r.Date);
    Assert.Equal(DateTimeOffset.Now.Offset, r.Offset); //Local date start, 
    //because the return  object is "1988-12-23". 
    //No matter the client sends "2022-03-12" or "2022-03-12T00:00:00+00:00" 
    //or "2022-03-12T00:00:00Z"
    Assert.Equal(TimeSpan.Zero, r.TimeOfDay);
}

You may notice that an extra setting DateParseHandling is needed. This ensures cross-timezone communications preserve correct timezone information, while NewtonSoft.Json JsonConverte.ReadJson() by default reads DateTimeOffset as DateTime, thus losing the timezone information. In contrast, in .NET 6, we don't need to use DateTimeOffset in client for DateOnly from server, thus there is no need for a converter.

How about JavaScript or TypeScript Clients?

Your JavaScript clients can only use Date object to talk to a Web API which always returns a yyyy-MM-dd string for date only data. Luckily, JavaScript can handle this well, probably because a Date object always uses UTC to store data internally. WebApiClientGen and OpenApiClientGen can generate client APIs for jQuery, Angular 2+, AXIOS, Aurelia and Fetch API. In this test suite at category "DateTypes API", you may see how a TypeScript application deals with DateOnly using the client API codes generated.

Date Pickers

Your client programs may use some date picker components. In .NET, you may need to make sure the date picker components are compatible with DateOnly, otherwise, you may need to stay with current practice of data binding.

If you are developing Web UI with a date picker component, you need to make sure that the date picked, e.g., "1980-01-01" is stored in the Date object as "1980-01-01T00:00:00.000Z" rather than "1979-12-31T14:00:00.000Z" (I am in Australia +10 timezone).

For example, when developing Angular SPA, I am using the DatePicker component of Angular Material Components. To ensure the Date object obtain "1980-01-01T00:00:00.000Z", there could be two approaches.

In @NgModule/providers, provide the following:

TypeScript
{ provide: DateAdapter, useClass: MomentDateAdapter, 
  deps: [MAT_DATE_LOCALE, MAT_MOMENT_DATE_ADAPTER_OPTIONS] },
{ provide: MAT_DATE_FORMATS, useValue: MAT_MOMENT_DATE_FORMATS },
{ provide: MAT_MOMENT_DATE_ADAPTER_OPTIONS, useValue: { useUtc: true, strict: true } },

In a complex business app which contains many lazy modules, you may declare these providers in each lazy module or component, while you may have 3rd party components which may prefer other settings for DatePicker.

Remarks

The date picker with the settings above may have Today's date highlighted in a circle match UTC today. For example, if you are in a +10 time zone and use the date picker before 10 am, you may see the circle highlighting yesterday's date. This is not nice, however, if you use such settings for date of birth, mostly it does not matter, since most dates of DOB in your database should be months ago.

The Convention of Mapping DateTime Objects to Date Only

The signal for mapping a DateTime object to date only info is to set TimeZone=Zero and TimeOfDate=Zero. Apparently, Moment.JS team and Angular Material Components teams used the same protocol. Likewise, both .NET clients and the ASP.NET Core Web API should use the same set of converters to ensure such protocol for dealing with DateTime and DateTimeOffset, otherwise, not only date only situations are having trouble, but DateTime.Min and DateTimeOffset.Min will get into trouble across timezones.

Remarks

If you have a legacy database which can store only DateTime for date only information, you need to examine how the application had stored dates.

DateOnly and Databases

Obviously, not all database engines support date only column. As far as I can see, MS SQL Server 2016 and MySql support date only data type.

With Entity Framework Code First, respective database specific library should map DateOnly to Date column type.

Integration Testing

When developing distributed applications, it is important to test cross-timezone issues when dealing with DateTime and DateOnly. You should have services and clients on machines/VMs sitting at different timezones during integration testing. I am in Australia, and I would typically have the test client or the integration test suite at +10:00 timezone, and the service at UTC or -10:00 timezone.

History

  • 22nd February, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Australia Australia
I started my IT career in programming on different embedded devices since 1992, such as credit card readers, smart card readers and Palm Pilot.

Since 2000, I have mostly been developing business applications on Windows platforms while also developing some tools for myself and developers around the world, so we developers could focus more on delivering business values rather than repetitive tasks of handling technical details.

Beside technical works, I enjoy reading literatures, playing balls, cooking and gardening.

Comments and Discussions

 
QuestionExtension Methods Pin
#realJSOP28-Mar-22 1:01
mva#realJSOP28-Mar-22 1:01 
AnswerRe: Extension Methods Pin
Zijian1-Apr-22 13:33
Zijian1-Apr-22 13:33 
GeneralRe: Extension Methods Pin
#realJSOP2-Apr-22 0:48
mva#realJSOP2-Apr-22 0:48 
GeneralRe: Extension Methods Pin
Zijian4-Apr-22 14:30
Zijian4-Apr-22 14:30 
QuestionWhy "DateOnly"? Pin
Paulo Zemek13-Mar-22 18:14
Paulo Zemek13-Mar-22 18:14 
Nothing against your article. Yet, I can't understand why name a type "DateOnly". Why not just "Date"?
After all, we don't name other types like StringOnly, GuidOnly or similar.
Date makes sense, DateTime is actually the exception, which has 2 types in one... and Time (not TimeOnly) also makes sense.

But maybe my new types need to be IntOnly, CharOnly and the like...

Anyways, sorry for this comment on your article. Your article is good, the new type(-name) is the issue.

AnswerRe: Why "DateOnly"? Pin
Zijian14-Mar-22 1:13
Zijian14-Mar-22 1:13 
GeneralRe: Why "DateOnly"? Pin
Paulo Zemek14-Mar-22 1:39
Paulo Zemek14-Mar-22 1:39 
QuestionMessage Closed Pin
12-Mar-22 22:15
Daved worner12-Mar-22 22:15 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.