polymorphic Json deserialization in nested scenario
Asked Answered
K

1

-1

I have some classes that I want to (de-)serialize:

public class Top
{
    public Top(Sub content) { Content = content; }

    public Sub Content { get; init; }
}

public class Sub
{
    public Sub(Sub? entry) { Entry = entry; Type = SubType.super; }

    public Sub? Entry { get; init; }
    public SubType Type { get; init; }
}

public class SubA : Sub
{
    public SubA(Sub? entry) : base(entry) { Type = SubType.a; }
}

public enum SubType { super, a }

Example object:

var top = new Top(new SubA(new Sub(new SubA(null))));

To serialize, I just need to use JsonSerializer.Serialize with some options to get what I want:

var json = JsonSerializer.Serialize(top, new JsonSerializerOptions
    { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
      Converters = { _enumConverter } });
// result:
// {"Content":{"Entry":{"Entry":{"Type":"a"},"Type":"super"},"Type":"a"}}

Deserializing does not work out of the box - it always deserializes to Sub, never to SubA. So I tried writing my own JsonConverter that finds the type T to deserialize to (from the JSON Type property), then calls the appropriate JsonSerializer.Deserialize<T> method. But I end up either in a StackOverflow or in losing my converter after one level:

public class SubConverter : JsonConverter<Sub>
{    
    public override BaseType Read(ref Utf8JsonReader reader, Type typeToConvert,
        JsonSerializerOptions options)
    {
        // Create a copy of the reader to find type. Necessary because after type was found, we
        // need to deserialize from the start, but resetting to start position is not possible.
        var typeReader = reader;

        bool discriminatorFound = false;
        while (typeReader.Read())
        {
            if (typeReader.TokenType == JsonTokenType.StartObject
                || typeReader.TokenType == JsonTokenType.StartArray)
            {
                typeReader.Skip();
                continue;
            }
            if (typeReader.TokenType != JsonTokenType.PropertyName)
                continue;
            if (typeReader.GetString() != TypeDiscriminatorPropertyName)
                continue;
            discriminatorFound = true;
            break;
        }

        if (!discriminatorFound)
            throw new JsonException(
                $"type discriminator property \"{TypeDiscriminatorPropertyName}\" was not found");

        if (!typeReader.Read() || typeReader.TokenType != JsonTokenType.String)
            throw new JsonException("type discriminator value does not exist or is not a string");

        var typeString = typeReader.GetString();
        var deserializationType = typeString == SubType.super.ToString() ? typeof(Sub) : typeof(SubA);

        // !!!
        // if THIS, is not removed, will get infinite loop (-> StackOverflowException)
        // if THIS is removed, will not get polymorphic deserialization in properties below
        var options2 = new JsonSerializerOptions(options);
        if (options2.Converters.Contains(this))
            options2.Converters.Remove(this);
        BaseType inst = (BaseType)JsonSerializer.Deserialize(ref reader, deserializationType, options2)!;

        return inst;
    }

    // not needed; we only use this converter for deserialization
    public override void Write(Utf8JsonWriter writer, BaseType value, JsonSerializerOptions options)
    { throw new NotImplementedException(); }
}

If I just pass the options unchanged into JsonSerializer.Deserialize, I will get an inifinite loop (JsonSerializer.Deserialize will call SubConverter.Read and vice versa).

If I remove the SubConverter from the options as in the code above, I lose it for all content in the levels below. So instead of the original object foo that was like this:

Top -> SubA -> Sub -> SubA

I now get

Top -> SubA -> Sub -> Sub
                ^      ^
                │      └─ because `SubConverter` was removed, cannot deserialize as SubA
                └─ here we removed `SubConverter`

What do I do now?

I do not want to write the whole deserialization on my own, only the necessary bit(s). (My real use case is much more complex than the classes in this question.)

Karykaryl answered 9/3, 2022 at 9:17 Comment(2)
Is this a duplicate of How to use default serialization in a custom System.Text.Json JsonConverter??Syrinx
@Syrinx No. In that question there is nothing about polymorphic conversion. Also, there is no issue in removing the (stack overflow causing) converter from the options because it is not needed anywhere below. In my case it IS needed below.Karykaryl
K
-1

I found help in the docs on how to write a custom converter and migrate from Newtonsoft.Json to System.Text.Json: The solution is to register the converter not via JsonSerializerOptions but via JsonConverterAttribute on the properties of my POCOs (not the on type, this will lead to inifinite recursion as well!).

First, we take the SubConverter from my question and change its Write method to use the default serialization (because when a Converter is registered via JsonConverterAttribute, it will be used for both serialization and deserialization):

public override void Write(Utf8JsonWriter writer, BaseType value, JsonSerializerOptions options)
{
    JsonSerializer.Serialize(writer, value, options);
}

Second, we remove the options2 from inside the Read method, leaving us with

...
BaseType inst = (BaseType)JsonSerializer.Deserialize(ref reader, deserializationType, options)!;
...

Third, we add the attribute [JsonConverter(typeof(SubConverter))] to both the Content property of Top and the Entry property of Sub.

Now we can simply do (de-)serialization like this:

var options = new JsonSerializerOptions
    { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
      Converters = { _enumConverter } };
var json = JsonSerializer.Serialize(top, options);
var fromJson = JsonDeserializer<Top>(json, options);
Karykaryl answered 9/3, 2022 at 11:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.