NOTE: I am using Microsoft's new
System.Text.Json
and notJson.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?
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.
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.
There is no polymorphic deserialization (equivalent to Newtonsoft. Json's TypeNameHandling ) support built-in to System.
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();
}
}
Here is another solution that builds upon the previous ones (with slightly different JSON structure).
Notable differences:
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"
}
}
]
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With