Custom Json Converter For Passing DateTimeOffset between Blazor and JS. JS property Input/Output are different types

Viewed 715

I have a scenario where I have to serialize a DateTimeOffset to JSON as a simple string value (e.g. "2021-04-07T18:18:00.000Z", but deserialize it as a value embedded in an object (e.g. {"_date":"2021-04-07T18:18:00.000Z"}). How can I do that with System.Text.Json?

The details are as follows. I am wrapping a calendar UI lib for blazor. I have wrapped the JS classes with C# classes. When sending a calendar "schedule" (event) from Blazor to JS, the DateTimes are just passed as serialized strings. This works.

When sending a "schedule" from JS to Blazor, it comes back as an object(TZdate) and inside is a "_date" property.

The json object that comes back looks like this:

{
"end":{
      "_date":"2021-04-07T18:18:00.000Z"
       }
}

I've tried writing a custom converter for DateTimeOffset. Here is the read method:

        public override DateTimeOffset Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) =>
            DateTimeOffset.ParseExact(JsonDocument.Parse(reader.GetString()).RootElement.EnumerateObject().First().Value.EnumerateObject().First().Value.GetString(),
             TZDateFormat, CultureInfo.InvariantCulture);

The above doesn't work. I don't believe I'm using the reader class correctly or how to extract the "_date" from the json here.

Alternatively, I tried wrapping my DateTimeOffset Property with a new "TZDate" class and a property "_date", but this breaks on the JS side as the library is expecting a simple datetime string from C#, not an object.

Changing the JS library is probably not option.

What are my options or how do I work around this?

1 Answers

Your DateTimeOffset value is embedded inside an object like so:

{"_date":"2021-04-07T18:18:00.000Z"}

And you would like to extract the value of the inner _date property to return. You can do that using the following JsonConverter<DateTime>.Read() method:

public class DateTimeOffsetConverter : JsonConverter<DateTimeOffset>
{
    const string TZDateFormat = "O"; // Your custom format (not shown in your question).

    public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) =>
        // Write as a simple string.
        writer.WriteStringValue(value.ToString(TZDateFormat, CultureInfo.InvariantCulture));
    
    const string _date = "_date";

    public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.String:
                // A simple DateTimeOffset string "value"
                return DateTimeOffset.ParseExact(reader.GetString(), TZDateFormat, CultureInfo.InvariantCulture);
            case JsonTokenType.StartObject:
            {
                // A DateTimeOffset string embedded in an object { "_date" : "value" }
                using var doc = JsonDocument.ParseValue(ref reader);
                if (doc.RootElement.TryGetProperty(_date, out var value))
                    return DateTimeOffset.ParseExact(value.GetString(), TZDateFormat, CultureInfo.InvariantCulture);
                return default(DateTimeOffset); // Or throw an exception?
            }
            default:
                throw new JsonException(); // Unknown token type
        }
    }
}

In your Read() method you attempt to load the value returned by reader.GetString() into a JsonDocument, but at the beginning of the method the reader is positioned on the StartObject token not a value string, and reader.GetString() only returns the string value of the current token, not the current token and its children as you would seem to want. To load the current token and its children into a JsonDocument, use JsonDocument.ParseValue(Utf8JsonReader).

(As an aside, JsonDocument is disposable, and in fact must be disposed to return pooled memory back to the memory pool.)

If you would like to avoid constructing a JsonDocument you can stream through the JSON directly using just the Utf8JsonReader as follows:

static byte [] _date = Encoding.UTF8.GetBytes("_date");

public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
    switch (reader.TokenType)
    {
        case JsonTokenType.String:
            // A simple DateTimeOffset string "value"
            return DateTimeOffset.ParseExact(reader.GetString(), TZDateFormat, CultureInfo.InvariantCulture);
        case JsonTokenType.StartObject:
        {
            // A DateTimeOffset string embedded in an object { "_date" : "value" }
            DateTimeOffset? value = null;
            while (reader.Read())
            {
                switch (reader.TokenType)
                {
                    case JsonTokenType.EndObject:
                        return value.GetValueOrDefault();
                    case JsonTokenType.PropertyName:
                        var match = reader.ValueTextEquals(_date);
                        reader.Read();
                        if (match)
                            value = DateTimeOffset.ParseExact(reader.GetString(), TZDateFormat, CultureInfo.InvariantCulture);
                        else
                            reader.Skip();
                        break;
                    default:
                        throw new JsonException();
                }
            }
        }
        break;
    }
    throw new JsonException();
}

This is a bit more complex but should also be more performant.

In both cases I check to see whether the incoming value is an object or a simple string. If a simple string, I go ahead and parse it as a DateTimeOffset.

Demo fiddle here.

Related