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:
- Use
string
like yyyy-MM-dd
, or yyyyMMdd
. And convert the object to DateTime
for calculating date span. - Use
DateTime
or DateTimeOffset
and make sure TimeOfDay
is Zero. And pay extra care when doing cross-timezone conversations. - 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.
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.
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))
{
return DateOnly.FromDateTime(((DateTimeOffset)v).DateTime);
}
if (vType == typeof(string))
{
return DateOnly.Parse((string)v);
}
if (vType == typeof(DateTime))
{
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:
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
:
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:
[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:
var jsonSerializerSettings = new Newtonsoft.Json.JsonSerializerSettings()
{
NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore,
};
jsonSerializerSettings.DateParseHandling =
Newtonsoft.Json.DateParseHandling.DateTimeOffset;
jsonSerializerSettings.Converters.Add
(new DateTimeOffsetJsonConverter());
jsonSerializerSettings.Converters.Add
(new DateTimeOffsetNullableJsonConverter());
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);
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:
{ 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