Search code examples
json.netsystem.text.jsonjsonconverter

Migrate Newtonsoft JsonConverter to JsonConverter<T>


I have the following interface

public interface IApiResult<TResult> : IApiResult
{
    TResult Result { get; set; }
}

with a concrete class like this

public class ApiResult<TResult> : ApiResult, IApiResult<TResult>
{
    public ApiResult( TResult result ) : base() {
        Result = result;
    }

    public TResult Result { get; set; }
}

When I used Newtonsoft json library I used a JsonConverter to manage polimorphic serialization and deserialization this way

public override object ReadJson( JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer ) {
    var obj = JObject.Load( reader );
    var apiResult = new ApiResult.ApiResult().As<IApiResult>();
    //Check if is an IApiResult<T>
    if (obj.TryGetValue( nameof( ApiResult<object>.Result ), StringComparison.InvariantCultureIgnoreCase, out var jResult )) {
        //Retrieve the result property type in order to create the proper apiResult object
        var prop = objectType.GetProperty( nameof( ApiResult<object>.Result ) );
        var value = jResult.ToObject( prop.PropertyType, serializer );
        var rType = typeof( ApiResult<> ).MakeGenericType( prop.PropertyType );
        apiResult = Activator.CreateInstance( rType ).As<IApiResult>();
        prop.SetValue( apiResult, value );
    }
    //Set the messages
    var jMessages = obj.GetValue( nameof( ApiResult.ApiResult.Messages ), StringComparison.InvariantCultureIgnoreCase ).ToObject<JObject[]>();
    apiResult.Messages = DeserializeReasons( jMessages );
    return apiResult;
}

How can I migrate this code to System.Text.Json?

UPDATE My biggest problem is with the JObject.TryGetvalue function. This function would have returned a deserialized object that let me understood the type. Since that, I was only using some reflection to understand the type of ApiResult<T>. With the actual UTF8JsonReader class I am only able to read token by token so I can not replicate the previous behavior.


Solution

  • Your question boils down to, Inside JsonConverter<T>.Read(), how can I scan forward in, or load the contents of, a Utf8JsonReader to determine the polymorphic type of object to deserialize without having to manually deserialize token by token as shown in the documentation example?

    As of .NET 6 you have a couple options to accomplish this.

    Firstly you can copy the Utf8JsonReader struct and scan forward in the copy until you find the property or properties you want. The original, incoming Utf8JsonReader will be unchanged and still point to the beginning of the incoming JSON value. System.Text.Json will always preload the entire JSON object, array or primitive to be deserialized before calling JsonConverter<T>.Read() so you can be certain the require values are present.

    To do this, introduce the following extension methods:

    public static partial class JsonExtensions
    {
        public delegate TValue? DeserializeValue<TValue>(ref Utf8JsonReader reader, JsonSerializerOptions options);
        
        ///Scan forward in a copy of the Utf8JsonReader to find a property with the specified name at the current depth, and return its value.
        ///The Utf8JsonReader is not passed by reference so the state of the caller's reader is unchanged.
        ///This method should only be called inside JsonConverter<T>.Read(), at which point the entire JSON for the object being read should have been pre-loaded
        public static bool TryGetPropertyValue<TValue>(this Utf8JsonReader reader, string name, StringComparison comparison, JsonSerializerOptions options, out TValue? value) =>
            reader.TryGetPropertyValue<TValue>(name, comparison, options, (ref Utf8JsonReader r, JsonSerializerOptions o) => JsonSerializer.Deserialize<TValue>(ref r, o), out value);
        
        public static bool TryGetPropertyValue<TValue>(this Utf8JsonReader reader, string name, StringComparison comparison, JsonSerializerOptions options, DeserializeValue<TValue> deserialize, out TValue? value)
        {
            if (reader.TokenType == JsonTokenType.Null)
                goto fail;
            else if (reader.TokenType == JsonTokenType.StartObject)
                reader.ReadAndAssert();
            do
            {
                if (reader.TokenType != JsonTokenType.PropertyName)
                    throw new JsonException();
                var currentName = reader.GetString();
                reader.ReadAndAssert();
                if (String.Equals(name, currentName, comparison))
                {
                    value = deserialize(ref reader, options);
                    return true;
                }
                else
                {
                    reader.Skip();
                }
            }
            while (reader.Read() && reader.TokenType != JsonTokenType.EndObject);
            
            fail:
            value = default;
            return false;
        }
        
        static void ReadAndAssert(ref this Utf8JsonReader reader)
        {
            if (!reader.Read())
                throw new JsonException();
        }
    }
    
    public static partial class ObjectExtensions
    {
        public static T ThrowOnNull<T>(this T? value) where T : class => value ?? throw new ArgumentNullException();
    }
    

    And now your Newtonsoft converter might be rewritten to look something like:

    public class ApiResultConverter : System.Text.Json.Serialization.JsonConverter<IApiResult>
    {
        record MessagesDTO(Message [] Messages);  // Message is the presumed type the array elements of ApiResult.ApiResult.Messages, which is not shown in your question.
        
        public override IApiResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            IApiResult? apiResult = null;
            if (reader.TryGetPropertyValue(nameof( ApiResult<object>.Result ), StringComparison.OrdinalIgnoreCase, options, 
                (ref Utf8JsonReader r, JsonSerializerOptions o) =>
                {
                    var prop = typeToConvert.GetProperty( nameof( ApiResult<object>.Result ) ).ThrowOnNull();
                    return (Value : JsonSerializer.Deserialize(ref r, prop.PropertyType, o), Property : prop);
                },
                out var tuple))
            {
                var rType = typeof( ApiResult<> ).MakeGenericType( tuple.Property.PropertyType );
                apiResult = Activator.CreateInstance( rType ).As<IApiResult>().ThrowOnNull();
                tuple.Property.SetValue( apiResult, tuple.Value );
            }
            if (apiResult == null)
                apiResult = new ApiResult.ApiResult().As<IApiResult>();
            // Now consume the contents of the Utf8JsonReader by deserializing to MessagesDTO.
            var dto = JsonSerializer.Deserialize<MessagesDTO>(ref reader, options);
            apiResult.Messages = dto?.Messages ?? Array.Empty<Message>();
    
            return apiResult;            
        }
        
        public override void Write(Utf8JsonWriter writer, IApiResult value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, value.GetType(), options);
    }
    

    Notes:

    • This approach works well when scanning forward for a single simple property, e.g. a type discriminator string. If the type discriminator string is likely to be at the beginning of the JSON object it will be quite efficient. (This does not seem to apply in your case.)

    • JsonConverter<T>.Read() must completely consume the incoming token. E.g. if the incoming token is of type JsonTokenType.StartObject then, when exiting, the reader must be positioned on a token of type JsonTokenType.EndObject at the same depth. Thus if you only scan forward in copies of the incoming Utf8JsonWriter you must advance the incoming reader to the end of the current token by calling reader.Skip() before exiting.

    • Both Json.NET and System.Text.Json use StringComparison.OrdinalIgnoreCase for case-invariant property name matching, so I recommend doing so as well.

    Secondly, you can load the contents of your Utf8JsonReader into a JsonDocument or JsonNode, query its properties, then deserialize to your final desired type with one of the JsonSerializer.Deserialzie() overloads that accepts a JSON document or node.

    Using this approach with JsonObject in place of JObject, your converter might look something like:

    public class ApiResultConverter : System.Text.Json.Serialization.JsonConverter<IApiResult>
    {
        public override IApiResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var obj = JsonNode.Parse(ref reader, new JsonNodeOptions { PropertyNameCaseInsensitive = true })?.AsObject();
            if (obj == null)
                return null; // Or throw JsonException() if you prefer
            IApiResult? apiResult = null;
            if (obj.TryGetPropertyValue( nameof( ApiResult<object>.Result ), out var jResult )) 
            {
                //Retrieve the result property type in order to create the proper apiResult object
                var prop = typeToConvert.GetProperty( nameof( ApiResult<object>.Result ) ).ThrowOnNull();
                var value = JsonSerializer.Deserialize(jResult, prop.PropertyType, options);
                var rType = typeof( ApiResult<> ).MakeGenericType( prop.PropertyType );
                apiResult = Activator.CreateInstance( rType ).As<IApiResult>();
                prop.SetValue( apiResult, value );
            }
            if (apiResult == null)
                apiResult = new ApiResult.ApiResult().As<IApiResult>();
            //Set the messages
            JsonObject? []? messages = obj[nameof( ApiResult.Messages )]?.AsArray()?.Select(i => i?.AsObject())?.ToArray();
            apiResult.Messages = DeserializeReasons(messages); // Not shown in your question
            return apiResult;
        }
        
        static JsonObject? [] DeserializeReasons(JsonObject? []? messages) => messages == null ? Array.Empty<JsonObject>() : messages;
        
        public override void Write(Utf8JsonWriter writer, IApiResult value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, value.GetType(), options);
    }
    
    public static partial class ObjectExtensions
    {
        public static T ThrowOnNull<T>(this T? value) where T : class => value ?? throw new ArgumentNullException();
    }
    

    Notes:

    • This approach works well when you have multiple properties (possibly with complex values) to search for and load during the conversion process.

    • By loading the JsonObject with JsonNodeOptions.PropertyNameCaseInsensitive = true, all property name lookups in the deserialized JsonNode hierarchy will be case-insensitive (using StringComparer.OrdinalIgnoreCase matching as shown in the source).

    • Since your question does not include a compilable example, the above converters are untested.