Search code examples
c#.net-coresystem.text.jsonjsonconverter

Deserialize complex polymorphic types with System.Text.Json


The dotnet example in the documentation:

https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to#support-polymorphic-deserialization

shows manually parsing each property of a polymorphic type. However:

  • my polymorphic objects are complex deep hierarchies, I can't hand code every field so I need to invoke the JsonSerializer.
  • the clue for the type is specified in sibling fields. Given there is no guarantee about json element order, a Utf8JsonReader may not have read the type information before it encounters the polymorphic type.

e.g.

[JsonConverter(typeof(MessageConverter))]
public class Message
{
    public string Type { get; set; } // indicates what implementation IBody is
    public IBody Body { get; set; }
}

public interface IBody 
{
}

public class BodyA : IBody
{
    // a big object hierarchy but just showing one property for simplicity 
    public string A { get; set; }
}

public class BodyB : IBody
{
    // a big object hierarchy but just showing one property for simplicity 
    public string B { get; set; }
}

public class MessageConverter : JsonConverter<Message>
{
    public override bool CanConvert(Type objectType) =>
        objectType == typeof(Message);

    public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var message = new Message();

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                break;
            }
            
            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                var propertyName = reader.GetString();
                reader.Read();

                switch (propertyName)
                {
                    case "Type":
                        message.Type = reader.GetString();
                        break;
                    case "Body":
                        // Body might be read before "Message.Type" so can't parse it yet
                        message.Body = /* help - what am I? */;
                        break;
                }
            }
        }

        return message;
    }

    public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options)
        throw new NotImplementedException();
}

Looking at Utf8JsonReader:

  • Is there a way to peek at future elements or move the parser position back?
  • Is there an efficient way to cache part of the json hierarchy for deferred parsing?

Solution

  • The current solution I have is, if necessary, use a JsonDocument to cache part of the json for deferred parsing.

    I don't like is that I can't see a way to invoke JsonSerializer on a JsonDocument so I have to convert it back to text with GetRawText() which won't be very efficient.

    public class MessageConverter : JsonConverter<Message>
    {
        public override bool CanConvert(Type objectType) =>
            objectType == typeof(Message);
    
        public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var message = new Message();
    
            JsonDocument cachedBody = null;
            
            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.EndObject)
                {
                    break;
                }
                
                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    var propertyName = reader.GetString();
                    reader.Read();
    
                    switch (propertyName)
                    {
                        case "Type":
                            message.Type = reader.GetString();
                            break;
                        case "Body":
    
                            if (message.Type != null)
                            {
                                message.Body = message.Type switch
                                {
                                    "A" => JsonSerializer.Deserialize<BodyA>(ref reader, options),
                                    "B" => JsonSerializer.Deserialize<BodyB>(ref reader, options),
                                    _ => throw new Exception($"Cannot parse message body of type {message.Type}")
                                };
                            }
                            else
                            {
                                cachedBody = JsonDocument.ParseValue(ref reader);
                            }
                            break;
                    }
                }
            }
    
            if (message.Body == null)
            {
                if (cachedBody == null)
                {
                    throw new Exception($"Missing message body");
                }
    
                try
                {
                    Log.Write("using cache");
                    
                    message.Body = message.Type switch
                    {
                        "A" => JsonSerializer.Deserialize<BodyA>(cachedBody.RootElement.GetRawText()),
                        "B" => JsonSerializer.Deserialize<BodyB>(cachedBody.RootElement.GetRawText()),
                        _ => throw new Exception($"Cannot parse message body of type {message.Type}")
                    };
                }
                finally
                {
                    cachedBody.Dispose();
                }
            }
    
            return message;
        }
    
        public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options)
        {
            writer.WriteStartObject();
            
            writer.WritePropertyName("Type");
            writer.WriteStringValue(value.Type);
            
            writer.WritePropertyName("Body");
            JsonSerializer.Serialize<object>(writer, value.Body, options);
            
            writer.WriteEndObject();
        }
    }