Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a simple way to manually serialize/deserialize child objects in a custom converter in System.Text.Json?

NOTE: I am using Microsoft's new System.Text.Json and not Json.NET so make sure answers address this accordingly.

Consider these simple POCOs:

interface Vehicle {}

class Car : Vehicle {
    string make          { get; set; }
    int    numberOfDoors { get; set; }
}

class Bicycle : Vehicle {
    int frontGears { get; set; }
    int backGears  { get; set; }
}

The car can be represented in JSON like this...

{
  "make": "Smart",
  "numberOfDoors": 2
}

and the bicycle can be represented like this...

{
  "frontGears": 3,
  "backGears": 6
}

Pretty straight forward. Now consider this JSON.

[
  {
    "Car": {
      "make": "Smart",
      "numberOfDoors": 2
    }
  },
  {
    "Car": {
      "make": "Lexus",
      "numberOfDoors": 4
    }
  },
  {
    "Bicycle" : {
      "frontGears": 3,
      "backGears": 6
    }
  }
]

This is an array of objects where the property name is the key to know which type the corresponding nested object refers to.

While I know how to write a custom converter that uses the UTF8JsonReader to read the property names (e.g. 'Car' and 'Bicycle' and can write a switch statement accordingly, what I don't know is how to fall back to the default Car and Bicycle converters (i.e. the standard JSON converters) since I don't see any method on the reader to read in a specific typed object.

So how can you manually deserialize nested objects like this?

like image 377
Mark A. Donohoe Avatar asked Jan 14 '20 23:01

Mark A. Donohoe


People also ask

How do I deserialize text JSON?

A common way to deserialize JSON is to first create a class with properties and fields that represent one or more of the JSON properties. Then, to deserialize from a string or a file, call the JsonSerializer. Deserialize method.

How does JsonSerializer deserialize work?

Deserialize(Utf8JsonReader, Type, JsonSerializerOptions) Reads one JSON value (including objects or arrays) from the provided reader and converts it into an instance of a specified type.

Is polymorphic deserialization possible in System text JSON?

There is no polymorphic deserialization (equivalent to Newtonsoft. Json's TypeNameHandling ) support built-in to System.


2 Answers

I figured it out. You simply pass your reader/writer down to another instance of the JsonSerializer and it handles it as if it were a native object.

Here's a complete example you can paste into something like RoslynPad and just run it.

Here's the implementation...

using System;
using System.Collections.ObjectModel;
using System.Text.Json;
using System.Text.Json.Serialization;

public class HeterogenousListConverter<TItem, TList> : JsonConverter<TList>
where TItem : notnull
where TList : IList<TItem>, new() {

    public HeterogenousListConverter(params (string key, Type type)[] mappings){
        foreach(var (key, type) in mappings)
            KeyTypeLookup.Add(key, type);
    }

    public ReversibleLookup<string, Type> KeyTypeLookup = new ReversibleLookup<string, Type>();

    public override bool CanConvert(Type typeToConvert)
        => typeof(TList).IsAssignableFrom(typeToConvert);

    public override TList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options){

        // Helper function for validating where you are in the JSON    
        void validateToken(Utf8JsonReader reader, JsonTokenType tokenType){
            if(reader.TokenType != tokenType)
                throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token");
        }

        validateToken(reader, JsonTokenType.StartArray);

        var results = new TList();

        reader.Read(); // Advance to the first object after the StartArray token. This should be either a StartObject token, or the EndArray token. Anything else is invalid.

        while(reader.TokenType == JsonTokenType.StartObject){ // Start of 'wrapper' object

            reader.Read(); // Move to property name
            validateToken(reader, JsonTokenType.PropertyName);

            var typeKey = reader.GetString();

            reader.Read(); // Move to start of object (stored in this property)
            validateToken(reader, JsonTokenType.StartObject); // Start of vehicle

            if(KeyTypeLookup.TryGetValue(typeKey, out var concreteItemType)){
                var item = (TItem)JsonSerializer.Deserialize(ref reader, concreteItemType, options);
                results.Add(item);
            }
            else{
                throw new JsonException($"Unknown type key '{typeKey}' found");
            }

            reader.Read(); // Move past end of item object
            reader.Read(); // Move past end of 'wrapper' object
        }

        validateToken(reader, JsonTokenType.EndArray);

        return results;
    }

    public override void Write(Utf8JsonWriter writer, TList items, JsonSerializerOptions options){

        writer.WriteStartArray();

        foreach (var item in items){

            var itemType = item.GetType();            

            writer.WriteStartObject();

            if(KeyTypeLookup.ReverseLookup.TryGetValue(itemType, out var typeKey)){
                writer.WritePropertyName(typeKey);
                JsonSerializer.Serialize(writer, item, itemType, options);
            }
            else{
                throw new JsonException($"Unknown type '{itemType.FullName}' found");
            }

            writer.WriteEndObject();
        }

        writer.WriteEndArray();
    }
}

Here's the demo code...

#nullable disable

public interface IVehicle { }

public class Car : IVehicle {
    public string make          { get; set; } = null;
    public int    numberOfDoors { get; set; } = 0;

    public override string ToString()
        => $"{make} with {numberOfDoors} doors";
}

public class Bicycle : IVehicle{
    public int frontGears { get; set; } = 0;
    public int backGears  { get; set; } = 0;

    public override string ToString()
        => $"{nameof(Bicycle)} with {frontGears * backGears} gears";
}

string json = @"[
  {
    ""Car"": {
      ""make"": ""Smart"",
      ""numberOfDoors"": 2
    }
  },
  {
    ""Car"": {
      ""make"": ""Lexus"",
      ""numberOfDoors"": 4
    }
  },
  {
    ""Bicycle"": {
      ""frontGears"": 3,
      ""backGears"": 6
    }
  }
]";

var converter = new HeterogenousListConverter<IVehicle, ObservableCollection<IVehicle>>(
    (nameof(Car),     typeof(Car)),
    (nameof(Bicycle), typeof(Bicycle))
);

var options = new JsonSerializerOptions();
options.Converters.Add(converter);

var vehicles = JsonSerializer.Deserialize<ObservableCollection<IVehicle>>(json, options);
Console.Write($"{vehicles.Count} Vehicles: {String.Join(", ",  vehicles.Select(v => v.ToString())) }");

var json2 = JsonSerializer.Serialize(vehicles, options);
Console.WriteLine(json2);

Console.WriteLine($"Completed at {DateTime.Now}");

Here's the supporting two-way lookup used above...

using System.Collections.ObjectModel;
using System.Diagnostics;

public class ReversibleLookup<T1, T2> : ReadOnlyDictionary<T1, T2>
where T1 : notnull 
where T2 : notnull {

    public ReversibleLookup(params (T1, T2)[] mappings)
    : base(new Dictionary<T1, T2>()){

        ReverseLookup = new ReadOnlyDictionary<T2, T1>(reverseLookup);

        foreach(var mapping in mappings)
            Add(mapping.Item1, mapping.Item2);
    }

    private readonly Dictionary<T2, T1> reverseLookup = new Dictionary<T2, T1>();
    public ReadOnlyDictionary<T2, T1> ReverseLookup { get; }

    [DebuggerHidden]
    public void Add(T1 value1, T2 value2) {

        if(ContainsKey(value1))
            throw new InvalidOperationException($"{nameof(value1)} is not unique");

        if(ReverseLookup.ContainsKey(value2))
            throw new InvalidOperationException($"{nameof(value2)} is not unique");

        Dictionary.Add(value1, value2);
        reverseLookup.Add(value2, value1);
    }

    public void Clear(){
        Dictionary.Clear();
        reverseLookup.Clear();        
    }
}
like image 191
Mark A. Donohoe Avatar answered Nov 04 '22 14:11

Mark A. Donohoe


Here is another solution that builds upon the previous ones (with slightly different JSON structure).

Notable differences:

  • Discriminator is part of the object (no need to use wrapper objects)
  • To my own surprise, it is not necessary to remove the converter with recursive (de)serialize calls (.NET 6)
  • I didn't add custom lookup, see previous answers

The code:

var foo = new[] {
    new Foo
    {
        Inner = new Bar
        {
            Value = 42,
        },
    },
    new Foo
    {
        Inner = new Baz
        {
            Value = "Hello",
        },
    },
};

var opts = new JsonSerializerOptions
{
    Converters =
    {
        new PolymorphicJsonConverterWithDiscriminator<Base>(typeof(Bar), typeof(Baz)),
    },

};

var json = JsonSerializer.Serialize(foo, opts);
var foo2 = JsonSerializer.Deserialize<Foo[]>(json, opts);

Console.WriteLine(foo2 is not null && foo2.SequenceEqual(foo));
Console.ReadLine();
 
public static class Constants
{
    public const string DiscriminatorPropertyName = "$type";
}

public record Foo
{
    public Base? Inner { get; set; }
}

public abstract record Base();

public record Bar : Base
{
    [JsonPropertyName(DiscriminatorPropertyName)]
    [JsonPropertyOrder(int.MinValue)]
    public string TypeDiscriminator { get => nameof(Bar); init { if (value != nameof(Bar)) throw new ArgumentException(); } }
    public int Value { get; set; }
}

public record Baz : Base
{
    [JsonPropertyName(DiscriminatorPropertyName)]
    [JsonPropertyOrder(int.MinValue)]
    public string TypeDiscriminator { get => nameof(Baz); init { if (value != nameof(Baz)) throw new ArgumentException(); } }
    public string? Value { get; set; }
}

public class PolymorphicJsonConverterWithDiscriminator<TBase> : JsonConverter<TBase>
    where TBase : class
{
    private readonly Type[] supportedTypes;

    public PolymorphicJsonConverterWithDiscriminator(params Type[] supportedTypes)
    {
        this.supportedTypes = supportedTypes;
    }

    public override TBase? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // Clone the reader so we can pass the original to Deserialize.
        var readerClone = reader;

        if (readerClone.TokenType == JsonTokenType.Null)
        {
            return null;
        }

        if (readerClone.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.PropertyName)
        {
            throw new JsonException();
        }

        var propertyName = readerClone.GetString();
        if (propertyName != DiscriminatorPropertyName)
        {
            throw new JsonException();
        }

        readerClone.Read();
        if (readerClone.TokenType != JsonTokenType.String)
        {
            throw new JsonException();
        }

        var typeIdentifier = readerClone.GetString();

        var specificType = supportedTypes.FirstOrDefault(t => t.Name == typeIdentifier)
            ?? throw new JsonException();

        return (TBase?)JsonSerializer.Deserialize(ref reader, specificType, options);
    }

    public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options)
    {
        // Cast to object which forces the serializer to use runtime type.
        JsonSerializer.Serialize(writer, value, typeof(object), options);
    }
}

Sample JSON:

[
  {
    "Inner": {
      "$type": "Bar",
      "Value": 42
    }
  },
  {
    "Inner": {
      "$type": "Baz",
      "Value": "Hello"
    }
  }
]
like image 27
Honza R Avatar answered Nov 04 '22 13:11

Honza R