Search code examples
c#json.net-6.0system.text.jsonjson-serialization

System.Text.Json Custom JsonConverter Write() never called


I have a .NET 6 solution for which I'm trying to override the default format of DateTimeOffset's when calling JsonObject.ToJsonString(). This is all using the native System.Text.Json libraries.

I've added a custom DateTimeOffsetConverter:

public class DateTimeOffsetConverter : JsonConverter<DateTimeOffset>
{
    private readonly string _format;

    public DateTimeOffsetConverter(string format)
    {
        _format = format;
    }

    public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        Debug.Assert(typeToConvert == typeof(DateTimeOffset));
        return DateTimeOffset.Parse(reader.GetString());
    }

    public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString(_format));
    }

    public override bool CanConvert(Type typeToConvert)
    {
        return (typeToConvert == typeof(DateTimeOffset));
    }
}

But when I try to use it, the code is never hit beyond the constructor being called.

What am I missing that's preventing the JsonConverter being called?

Here's my code which tries to make use of the functionality:

[Theory]
[InlineData("New Zealand Standard Time")]
[InlineData("India Standard Time")]
[InlineData("Central Brazilian Standard Time")]
[InlineData("W. Australia Standard Time")]
public void DateTimeOffsetIsSerializedCorrectlyTest(string timeZoneId)
{
    const string DateTimeFormat = "yyyy-MM-ddTHH:mm:ss.fffzzz";
    var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
    var dateTimeOffset = new DateTimeOffset(DateTimeOffset.Now.DateTime, timeZoneInfo.BaseUtcOffset);

    var json = new JsonObject
    {
        { "value", dateTimeOffset }
    };

    var options = new JsonSerializerOptions
    {
        Converters = { new DateTimeOffsetConverter(DateTimeFormat) }
    };
    string jsonString = json.ToJsonString(options);

    Assert.Contains(jsonString, dateTimeOffset.ToString(DateTimeFormat));
}

There's a number of closely related question already posted, who's solutions I've experimented with, but none seem to address my precise scenario.


Solution

  • It looks as though the converter is applied at the time the DateTimeOffset is converted to a JsonNode rather than when the JsonNode is formatted to a string. Thus you may generate the required JSON by explicitly serializing your DateTimeOffset rather than relying on implicit conversion:

    var options = new JsonSerializerOptions
    {
        Converters = { new DateTimeOffsetConverter(DateTimeFormat) }
    };
    
    var json = new JsonObject
    {
        { "value", JsonSerializer.SerializeToNode(dateTimeOffset, options) }
    };
    

    This results in {"value":"2022-11-27T15:10:23.570\u002B12:00"}. Note that the + is escaped. If your require it not to be escaped so that your Assert.Contains(jsonString, dateTimeOffset.ToString(DateTimeFormat)); passes successfully, use UnsafeRelaxedJsonEscaping:

    var formattingOptions = new JsonSerializerOptions
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        // Other formatting options as required, e.g.
        //WriteIndented = true, // This option also seems to be applied when the JsonNode is formatted to a string, rather than when it is constructed
    };
    
    string jsonString = json.ToJsonString(formattingOptions);
    

    Which results in {"value":"2022-11-27T15:17:44.142+12:00"}.

    Demo fiddle #1 here.

    Notes:

    • The documentation page How to use a JSON document, Utf8JsonReader, and Utf8JsonWriter in System.Text.Json: JsonNode with JsonSerializerOptions states:

      You can use JsonSerializer to serialize and deserialize an instance of JsonNode. However, if you use an overload that takes JsonSerializerOptions, the options instance is only used to get custom converters. Other features of the options instance are not used. ...

      This statement seems to be incorrect; your DateTimeOffsetConverter is not picked up even if you serialize your JsonNode hierarchy with JsonSerializer.Serialize(json, options).

      Demo fiddle #2 here.

    • I can't find any documentation listing the options applied during JsonNode construction vs JsonNode string formatting. The docs do show that WriteIndented is applied during string formatting. Some experimentation shows that, in addition, Encoder and (surprisingly) NumberHandling are applied during string formatting.

      Demo fiddle #3 here.

    • Some debugging shows why your DateTimeOffsetConverter is not applied during string formatting. The DateTimeOffset to JsonNode implicit conversion operator:

      JsonNode node = dateTimeOffset;
      

      Returns an object of type JsonValueTrimmable<DateTimeOffset>. This type includes its own internal converter:

      internal sealed partial class JsonValueTrimmable<TValue> : JsonValue<TValue>
      {
          private readonly JsonTypeInfo<TValue>? _jsonTypeInfo;
          private readonly JsonConverter<TValue>? _converter;
      

      If we access the value of _converter with reflection, we find it is initialized to the system converter DateTimeOffsetConverter:

      var fi = node.GetType().GetField("_converter", BindingFlags.NonPublic | BindingFlags.Instance);
      Console.WriteLine("JsonValueTrimmable<T>._converter = {0}", fi.GetValue(node)); // Prints System.Text.Json.Serialization.Converters.DateTimeOffsetConverter
      

      This system converter ignores the incoming options and simply calls Utf8JsonWriter.WriteStringValue(DateTimeOffset);.

      Demo fiddle #4 here.