dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.

Home Page:https://docs.microsoft.com/dotnet/core/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Document a System.Text.Json TypeConverter to JsonConverter Adapter

cleftheris opened this issue · comments

Thanks for all your efforts.

I believe that there is a strong case for an additional converter that will act as an Adapter between System.ComponentModel.TypeConverter marked on existing types with an attribute and the new serializer via the System.Text.Json.Serialization.JsonConverter . This could be either in the box (optionally) or in the docs as an example.

As I am migrating a number of aspnetcore projects to use the built in Json serializer I have noticed that there are numerous times I rely on serialization to do the right thing regarding models that are already decorated with a TypeConverterAttribute that is there to take care of automatic convesions to and from strings. With newtonsoft JsonConvert and aspnetcore it used to discover and use them under the hood by default whenever needed to convert to and from json. Lets say for example I have a GeoPoint class that is decorated with a TypeConverterAttribute (ComponentModel) instead of writing a new JsonConverter for that it would be nice to have something like the following.

The adapter

    /// <summary>
    /// Adapter between <see cref="System.ComponentModel.TypeConverter"/> 
    /// and <see cref="JsonConverter"/>
    /// </summary>
    public class TypeConverterJsonAdapter : JsonConverter<object>
    {
        public override object Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) {

            var converter = TypeDescriptor.GetConverter(typeToConvert);
            var text = reader.GetString();
            return converter.ConvertFromString(text);
        }

        public override void Write(
            Utf8JsonWriter writer,
            object objectToWrite,
            JsonSerializerOptions options) {

            var converter = TypeDescriptor.GetConverter(objectToWrite);
            var text = converter.ConvertToString(objectToWrite);
            writer.WriteStringValue(text);
        }
        
        public override bool CanConvert(Type typeToConvert) {
            var hasConverter = typeToConvert.GetCustomAttributes<TypeConverterAttribute>(inherit: true).Any();
            return hasConverter;
        }
    }

The model

    /// <summary>
    /// Geographic location
    /// </summary>
    [TypeConverter(typeof(GeoPointTypeConverter))]
    public class GeoPoint
    {
        /// <summary>
        /// The latitude
        /// </summary>
        public double Latitude { get; set; }

        /// <summary>
        /// The logitude
        /// </summary>
        public double Longitude { get; set; }
    }

    /// <summary>
    /// Type converter for converting between <see cref="GeoPoint"/> and <seealso cref="string"/>
    /// </summary>
    public class GeoPointTypeConverter : TypeConverter
    {
          // ..... excluded for brevity ...
    }

the test

        [Fact]
        public void RoundtripTypeConverterAdapter() {
            var options = new JsonSerializerOptions();
            options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
            options.Converters.Add(new JsonStringEnumConverter());
            options.Converters.Add(new TypeConverterJsonAdapter());
            options.IgnoreNullValues = true;
            var model = new TestModel { Point = GeoPoint.Parse("37.9888529,23.7037796") };
            var jsonExpected = "{\"point\":\"37.9888529,23.7037796\"}";
            var json = JsonSerializer.Serialize(model, options);
            Assert.Equal(jsonExpected, json);
            var output = JsonSerializer.Deserialize<TestModel>(json, options);
            Assert.Equal(model.Point.Latitude, output.Point.Latitude);
        }

        class TestModel
        {
            public GeoPoint Point { get; set; }
        }

This is something I would like to see too!

I would also like to have support for fallback to the normal parser when the point is an object in the JSON, the only way I see right now is to write the full converter of the object to load all fields if it's an object.

For example:

{
  "point": {
    "Latitude": 37.9888529,
    "Longitude": 23.7037796
  }
}

@cleftheris
The proposed adapter does not seem to work if the TypeConverter attribute is added for the class property.
Is it possible to construct such adapter that would also work for TypeConverter attributes added to class properties?

UPDATE:
It does not work for:

public class TestModel
{
    [TypeConverter(typeof(TestConverter))]
    public IEnumerable<Guid?> Items { get; set; }
}

but work if wrapper is used:

public class TestModel
{
    //[TypeConverter(typeof(TestConverter))]
    public NullableGuidItems Items { get; set; }
}

[TypeConverter(typeof(TestConverter))]
public class NullableGuidItems: List<Guid?>
{
}

Yes I see what you mean.
I don't have an answer to this use case. One interesting thing here is that System.Text.Json does not take into account the runtime CLR type of the property value (as was the case with Newtonsoft Json) but instead uses the property type. So you are better of using concrete types there anyway.

PS: If you end up making a subclass of List<Guid?> and decorate it with the TypeConverter you don't need to decorate the property as well.

C.

@ahsonkhan @layomia Recently I came across a case that I had a value type in a collection (lets say List<GeoPoint> or GeoPoint[]). In aforementioned case the Serializer would not be able to deserialize an throws an InvalidCastException with message

Message:
System.InvalidCastException : Unable to cast object of type 'System.Collections.Generic.List1[GeoPoint]' to type 'System.Collections.Generic.IList1[System.Object]'.
Stack Trace:
JsonSerializer.ApplyValueToEnumerable[TProperty](TProperty& value, ReadStack& state)
JsonPropertyInfoNotNullableContravariant`4.OnReadEnumerable(ReadStack& state, Utf8JsonReader& reader)
JsonPropertyInfo.ReadEnumerable(JsonTokenType tokenType, ReadStack& state, Utf8JsonReader& reader)
JsonPropertyInfo.Read(JsonTokenType tokenType, ReadStack& state, Utf8JsonReader& reader)
JsonSerializer.ReadCore(JsonSerializerOptions options, Utf8JsonReader& reader, ReadStack& readStack)
JsonSerializer.ReadCore(Type returnType, JsonSerializerOptions options, Utf8JsonReader& reader)
JsonSerializer.Deserialize(String json, Type returnType, JsonSerializerOptions options)
JsonSerializer.Deserialize[TValue](String json, JsonSerializerOptions options)
TextJsonTests.RoundtripTypeConverterAdapterWithCollections() line 65

I so I improved the TypeConverterJsonAdapter to do the right thing though the use of a JsonConverterFactory. Now it works both on collection properties as well as single item properties. Works both in the v4.7.x and the v5.0.0 of System.Text.Json

The adapter

    /// <summary>
    /// Adapter between <see cref="TypeConverter"/> and <see cref="JsonConverter"/>.
    /// </summary>
    public class TypeConverterJsonAdapter<T> : JsonConverter<T>
    {
        /// <inheritdoc/>
        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
            var converter = TypeDescriptor.GetConverter(typeToConvert);
            var text = reader.GetString();
            return (T)converter.ConvertFromString(text);
        }

        /// <inheritdoc/>
        public override void Write(Utf8JsonWriter writer, T objectToWrite, JsonSerializerOptions options) {
            var converter = TypeDescriptor.GetConverter(objectToWrite);
            var text = converter.ConvertToString(objectToWrite);
            writer.WriteStringValue(text);
        }

        /// <inheritdoc/>
        public override bool CanConvert(Type typeToConvert) {
            var hasConverter = typeToConvert.GetCustomAttributes<TypeConverterAttribute>(inherit: true).Any();
            if (!hasConverter) {
                return false;
            }
            return true;
        }
    }

    /// <inheritdoc />
    public class TypeConverterJsonAdapter : TypeConverterJsonAdapter<object> { }

    /// <summary>
    /// A factory used to create various <see cref="TypeConverterJsonAdapter{T}"/> instances.
    /// </summary>
    public class TypeConverterJsonAdapterFactory : JsonConverterFactory
    {
        /// <inheritdoc />
        public override bool CanConvert(Type typeToConvert) {
            var hasConverter = typeToConvert.GetCustomAttributes<TypeConverterAttribute>(inherit: true).Any();
            if (!hasConverter) {
                return false;
            }
            return true;
        }

        /// <inheritdoc />
        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
            var converterType = typeof(TypeConverterJsonAdapter<>).MakeGenericType(typeToConvert);
            return (JsonConverter)Activator.CreateInstance(converterType);
        }
    }

The model

    /// <summary>
    /// Geographic location
    /// </summary>
    [TypeConverter(typeof(GeoPointTypeConverter))]
    public class GeoPoint
    {
        /// <summary>
        /// The latitude
        /// </summary>
        public double Latitude { get; set; }

        /// <summary>
        /// The logitude
        /// </summary>
        public double Longitude { get; set; }
    }

    /// <summary>
    /// Type converter for converting between <see cref="GeoPoint"/> and <seealso cref="string"/>
    /// </summary>
    public class GeoPointTypeConverter : TypeConverter
    {
          // ..... excluded for brevity ...
    }

No notice in the test I configure the options with the TypeConverterJsonAdapterFactory instead of the simple TypeConverterJsonAdapter.

the test

        [Fact]
        public void RoundtripTypeConverterAdapter_WithCollections() {
            var options = new JsonSerializerOptions();
            options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
            options.Converters.Add(new JsonStringEnumConverter());
            options.Converters.Add(new TypeConverterJsonAdapterFactory());
            options.IgnoreNullValues = true;
            var model = new TestModel { 
                Point = GeoPoint.Parse("37.9888529,23.7037796"), 
                PointList = new List<GeoPoint> {   
                    GeoPoint.Parse("37.9888529,23.7037796"), 
                    GeoPoint.Parse("37.9689383,23.7309977")
                } 
            };
            var jsonExpected = "{\"point\":\"37.9888529,23.7037796\",\"pointList\":[\"37.9888529,23.7037796\",\"37.9689383,23.7309977\"]}";
            var json = JsonSerializer.Serialize(model, options);
            Assert.Equal(jsonExpected, json);
            var output = JsonSerializer.Deserialize<TestModel>(json, options);
            Assert.Equal(model.Point.Latitude, output.Point.Latitude);
        }

        class TestModel
        {
            public GeoPoint Point { get; set; }
            public List<GeoPoint> PointList { get; set; }
        }