Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use default serialization in a custom System.Text.Json JsonConverter?

I am writing a custom System.Text.Json.JsonConverter to upgrade an old data model to a new version. I have overridden Read() and implemented the necessary postprocessing. However, I don't need to do anything custom at all in the Write() method. How can I automatically generate the default serialization that I would get if I did not have a converter at all? Obviously I could just use different JsonSerializerOptions for deserialization and serialization, however my framework doesn't provide different options for each straightforwardly.

A simplified example follows. Say I formerly had the following data model:

public record Person(string Name);

Which I have upgraded to

public record Person(string FirstName, string LastName);

I have written a converter as follows:

public sealed class PersonConverter : JsonConverter<Person>
{
    record PersonDTO(string FirstName, string LastName, string Name); // A DTO with both the old and new properties.

    public override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var dto = JsonSerializer.Deserialize<PersonDTO>(ref reader, options);
        var oldNames = dto?.Name?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty<string>();
        return new Person(dto.FirstName ?? oldNames.FirstOrDefault(), dto.LastName ?? oldNames.LastOrDefault());
    }

    public override void Write(Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        => // What do I do here? I want to preserve other options such as options.PropertyNamingPolicy, which are lost by the following call
        JsonSerializer.Serialize(writer, person);
}

And round-trip with

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Converters = { new PersonConverter() },
};
var person = JsonSerializer.Deserialize<Person>(json, options);
var json2 = JsonSerializer.Serialize(person, options);

Then the result is {"FirstName":"FirstName","LastName":"LastName"} -- i.e. the camel casing during serialization is lost. But if I pass in options while writing by recursively calling

    public override void Write(Utf8JsonWriter writer, Person person, JsonSerializerOptions options)
        => // What do I do here? I want to preserve other options such as options.PropertyNamingPolicy, which are lost by the following call
        JsonSerializer.Serialize(writer, person, options);

Then serialization fails with a stack overflow.

How can I get an exact default serialization that ignores the custom converter? There is no equivalent to Json.NET's JsonConverter.CanWrite property.

Demo fiddle here.

like image 740
dbc Avatar asked Dec 23 '20 20:12

dbc


People also ask

How does JSON serialization work?

JSON is a format that encodes objects in a string. Serialization means to convert an object into that string, and deserialization is its inverse operation (convert string -> object). If you serialize this result it will generate a text with the structure and the record returned.

Which is better Newtonsoft JSON or System text JSON?

Json does case-insensitive property name matching by default. The System. Text. Json default is case-sensitive, which gives better performance since it's doing an exact match.

Is Newtonsoft JSON obsolete?

Obsolete. The value types allowed by the JsonSchema. JSON Schema validation has been moved to its own package. See https://www.newtonsoft.com/jsonschema for more details.


1 Answers

As explained in the docs, converters are chosen with the following precedence:

  • [JsonConverter] applied to a property.
  • A converter added to the Converters collection.
  • [JsonConverter] applied to a custom value type or POCO.

Each case needs to be dealt with separately.

  1. If you have [JsonConverter] applied to a property., then simply calling JsonSerializer.Serialize(writer, person, options); will generate a default serialization.

  2. If you have A converter added to the Converters collection., then inside the Write() (or Read()) method, you can copy the incoming options using the JsonSerializerOptions copy constructor, remove the converter from the copy's Converters list, and pass the modified copy into JsonSerializer.Serialize<T>(Utf8JsonWriter, T, JsonSerializerOptions);

    This can't be done as easily in .NET Core 3.x because the copy constructor does not exist in that version. Temporarily modifying the Converters collection of the incoming options to remove the converter would not be not thread safe and so is not recommended. Instead one would need create new options and manually copy each property as well as the Converters collection, skipping converts of type converterType.

  3. If you have [JsonConverter] applied to a custom value type or POCO. there does not appear to be a way to generate a default serialization.

Since, in the question, the converter is added to the Converters list, the following modified version correctly generates a default serialization:

public sealed class PersonConverter : DefaultConverterFactory<Person>
{
    record PersonDTO(string FirstName, string LastName, string Name); // A DTO with both the old and new properties.

    protected override Person Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions modifiedOptions)
    {
        var dto = JsonSerializer.Deserialize<PersonDTO>(ref reader, modifiedOptions);
        var oldNames = dto?.Name?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty<string>();
        return new Person(dto.FirstName ?? oldNames.FirstOrDefault(), dto.LastName ?? oldNames.LastOrDefault());
    }
}

public abstract class DefaultConverterFactory<T> : JsonConverterFactory
{
    class DefaultConverter : JsonConverter<T>
    {
        readonly JsonSerializerOptions modifiedOptions;
        readonly DefaultConverterFactory<T> factory;

        public DefaultConverter(JsonSerializerOptions options, DefaultConverterFactory<T> factory)
        {
            this.factory = factory;
            this.modifiedOptions = options.CopyAndRemoveConverter(factory.GetType());
        }

        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => factory.Write(writer, value, modifiedOptions);

        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => factory.Read(ref reader, typeToConvert, modifiedOptions);
    }

    protected virtual T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions modifiedOptions)
        => (T)JsonSerializer.Deserialize(ref reader, typeToConvert, modifiedOptions);

    protected virtual void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions modifiedOptions) 
        => JsonSerializer.Serialize(writer, value, modifiedOptions);

    public override bool CanConvert(Type typeToConvert) => typeof(T) == typeToConvert;

    public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new DefaultConverter(options, this);
}

public static class JsonSerializerExtensions
{
    public static JsonSerializerOptions CopyAndRemoveConverter(this JsonSerializerOptions options, Type converterType)
    {
        var copy = new JsonSerializerOptions(options);
        for (var i = copy.Converters.Count - 1; i >= 0; i--)
            if (copy.Converters[i].GetType() == converterType)
                copy.Converters.RemoveAt(i);
        return copy;
    }
}

Notes:

  • I used a converter factory rather than a converter as the base class for PersonConverter because it allowed me to conveniently cache the copied options inside the manufactured converter.

  • If you try to apply a DefaultConverterFactory<T> to a custom value type or POCO, e.g.

    [JsonConverter(typeof(PersonConverter))] public record Person(string FirstName, string LastName);
    

    A nasty stack overflow will occur.

Demo fiddle here.

like image 156
dbc Avatar answered Oct 24 '22 03:10

dbc