nfplee
nfplee

Reputation: 7977

System.Text.Json serialization doesn't work for abstract members

I have the following interface and it's implementation (with JSON serializers for both Newtonsoft.Json and System.Text.Json):

public interface IAmount {
    decimal Value { get; }
}

[Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))]
[System.Text.Json.Serialization.JsonConverter(typeof(SystemTextJsonConverter))]
public class Amount : IAmount {
    public Amount(decimal value) {
        Value = value;
    }

    public decimal Value { get; }
}

public class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter {
    public override bool CanConvert(Type objectType) => objectType.IsAssignableTo(typeof(IAmount));

    public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) {
        throw new NotImplementedException();
    }

    public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) {
        writer.WriteRawValue(((IAmount?)value)?.Value.ToString());
    }
}

public class SystemTextJsonConverter : System.Text.Json.Serialization.JsonConverter<object> {
    public override bool CanConvert(Type typeToConvert) => typeToConvert.IsAssignableTo(typeof(IAmount));

    public override object Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) {
        throw new NotImplementedException();
    }

    public override void Write(System.Text.Json.Utf8JsonWriter writer, object value, System.Text.Json.JsonSerializerOptions options) {
        writer.WriteRawValue(((IAmount)value).Value.ToString());
    }
}

This works fine if my object is of type Amount. For example (output in the comment next to each line):

var foo = new Amount(10);

Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(foo)); // 10
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(foo)); // 10

However if the object is of type IAmount it works fine for Newtonsoft.Json but not for System.Text.Json. For example:

IAmount foo = new Amount(10);

Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(foo)); // 10
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(foo)); // {"Value":10}

As you can see the output is different when using System.Text.Json. I tried putting a breakpoint against the CanCovert method, however it was never called.

I can fix this by adding a [System.Text.Json.Serialization.JsonConverter(typeof(SystemTextJsonConverter))] attribute against the interface but ideally I don't wish to do this. Does anyone know of an alternative solution for solving this without having to modify the interface?

Note that switching to Newtonsoft isn't an option.

Upvotes: 2

Views: 1970

Answers (1)

dbc
dbc

Reputation: 116980

This is as designed. System.Text.Json intentionally does not support polymorphism during serialization except when the object to be serialized is explicitly declared to be object or (starting with .NET 7) its opt-in support for polymorphism is enabled. From the docs:

Serialize properties of derived classes

In versions prior to .NET 7, System.Text.Json doesn't support the serialization of polymorphic type hierarchies. For example, if a property's type is an interface or an abstract class, only the properties defined on the interface or abstract class are serialized, even if the runtime type has additional properties. The exceptions to this behavior are explained in this section. For information about support in .NET 7, see Polymorphic serialization in .NET 7.

To serialize the properties of [a] derived type, use one of the following approaches:

  1. Call an overload of Serialize that lets you specify the type at runtime...

  2. Declare the object to be serialized as object.

While the documentation only states that properties of derived classes are not serialized, I believe that, since System.Text.Json is internally a contract-based serializer, it uses the entire contract of the declared type when serializing a derived type. Thus the metadata (including JsonConverterAttribute and any other JSON attributes that have been applied) as well as the properties are taken by reflecting the declared type (here IAmount) not the actual type (here Amount).

So, what are your options to work around this restriction?

Firstly, if IAmount is only ever implemented as Amount, you could introduce a JsonConverter which always serializes one type as some other compatible type:

public class AbstractToConcreteConverter<TAbstract, TConcrete> : JsonConverter<TAbstract> where TConcrete : TAbstract
{
    static AbstractToConcreteConverter()
    {
        if (typeof(TAbstract) == typeof(TConcrete))
            throw new ArgumentException(string.Format("Identical type {0} used for both TAbstract and TConcrete", typeof(TConcrete)));
    }
    
    public override TAbstract? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>
        JsonSerializer.Deserialize<TConcrete>(ref reader, options);

    public override void Write(System.Text.Json.Utf8JsonWriter writer, TAbstract value, System.Text.Json.JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, (TConcrete)value!, options);
}

Then either apply it to IAmount:

[JsonConverter(typeof(AbstractToConcreteConverter<IAmount, Amount>))]
public interface IAmount {
    decimal Value { get; }
}

Or add it in JsonSerializerOptions.Converters if IAmount cannot be modified, or if different concrete types will be used in different circumstances:

var options = new JsonSerializerOptions
{
    Converters = { new AbstractToConcreteConverter<IAmount, Amount>() }
    // Add other options as required
};
var systemJson = System.Text.Json.JsonSerializer.Serialize<IAmount>(foo, options);      
var foo2 = System.Text.Json.JsonSerializer.Deserialize<IAmount>(systemJson, options);

Demo fiddles #1 and #2 here and here.

Secondly, if you don't care about deserialization at all and want all values declared as interfaces to be serialized as their concrete types, you could introduce a converter factory that does just that:

public class ConcreteInterfaceSerializer : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert) => typeToConvert.IsInterface;
    
    class ConcreteInterfaceSerializerOfType<TInterface> : JsonConverter<TInterface> 
    {
        static ConcreteInterfaceSerializerOfType()
        {
            if (!typeof(TInterface).IsAbstract && !typeof(TInterface).IsInterface)
                throw new NotImplementedException(string.Format("Concrete class {0} is not supported", typeof(TInterface)));
        }   
    
        public override TInterface? Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>
            throw new NotImplementedException();

        public override void Write(System.Text.Json.Utf8JsonWriter writer, TInterface value, System.Text.Json.JsonSerializerOptions options) =>
            JsonSerializer.Serialize<object>(writer, value!, options);
    }
    
    public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) => 
        (JsonConverter)Activator.CreateInstance(
            typeof(ConcreteInterfaceSerializerOfType<>).MakeGenericType(new Type[] { type }),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: Array.Empty<object>(),
            culture: null).ThrowOnNull();
}

public static class ObjectExtensions
{
    public static T ThrowOnNull<T>(this T? value) where T : class => value ?? throw new ArgumentNullException();
}

And either apply it directly to IAmount:

[JsonConverter(typeof(ConcreteInterfaceSerializer))]
public interface IAmount {
    decimal Value { get; }
}

Or add it in options:

var options = new JsonSerializerOptions
{
    Converters = { new ConcreteInterfaceSerializer() },
};
var systemJson = System.Text.Json.JsonSerializer.Serialize<IAmount>(foo, options);

Demo fiddle #3 here.

Upvotes: 3

Related Questions