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 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?
Solution 1:
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();
}
}