5

.NET7 Includes a lot of improvements for System.Text.Json serializer, one of which is polymorphic serialization of types using the new [JsonPolymorphic] attribute. I am trying to use it in my Asp.Net web API, however it doesn't seem to serialize the type discriminator despite the fact that the model is properly setup.

It only happens when trying to send the objects over the wire, when using JsonSerializer, everything appears to be working well. For example:

// This is my data model
[JsonPolymorphic]
[JsonDerivedType(typeof(SuccesfulResult), typeDiscriminator: "ok")]
[JsonDerivedType(typeof(ErrorResult), typeDiscriminator: "fail")]
public abstract record Result;
public record SuccesfulResult : Result;
public record ErrorResult(string Error) : Result;
// Some test code that actually works
var testData = new Result[]
{
    new SuccesfulResult(),
    new ErrorResult("Whoops...")
};

var serialized = JsonSerializer.SerializeToDocument(testData);
// Serialized string looks like this:
// [{ "$type": "ok" }, { "$type": "fail", "error": "Whoops..." }]
// So type discriminators are in place and all is well
var deserialized = serialized.Deserialize<IEnumerable<Result>>()!;

// This assertion passes succesfully!
// We deserialized a collection polymorphically and didn't lose any type information.
testData.ShouldDeepEqual(deserialized);
// However, when sending the object as a response in an API, the AspNet serializer
// seems to be ignoring the attributes:

[HttpGet("ok")]
public Result GetSuccesfulResult() => new SuccesfulResult();

[HttpGet("fail")]
public Result GetFailResult() => new ErrorResult("Whoops...");

Neither of these responses are annotated with a type discriminator and my strongly-typed clients can't deserialize the results into a proper hierarchy.

GET /api/ok HTTP/1.1
# =>
# ACTUAL: {}
# EXPECTED: { "$type": "ok" }
GET /api/fail HTTP/1.1
# =>
# ACTUAL: { "error": "Whoops..." }
# EXPECTED: { "$type": "fail", "error": "Whoops..." }

Am I missing some sort of API configuration that would make controllers serialize results in a polymorphic manner?

MarengoHue
  • 1,789
  • 13
  • 34
  • I am not sure, but it almost feels like ASP.NET is passing the object instance to be serialized _together_ with its type object to the serializer. Because that is a quirk of the current version System.Json.Text (see here for a demo of how the type meta data is not generated when the type object is passed to the serializier: https://dotnetfiddle.net/rXdlJw). Not sure how to fix that, though (i am not very knowledgeable about asp.net stuff...) :-( –  Dec 16 '22 at 16:41
  • Could this be a bug in the asp net then? I would have to browse through their sources to see how exactly they do serialization on their side – MarengoHue Dec 16 '22 at 16:45
  • Or alternatively, it might mean that some annotation would be needed on the derived types as well – MarengoHue Dec 16 '22 at 16:45
  • Yeah, it seems you found a fix (at least with the simple example case): https://dotnetfiddle.net/Pw6wBi Not sure if this is a happy accident or intended behavior (because the documentation [here](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-7-0) does not seem to mention this case), but it works... –  Dec 16 '22 at 16:52
  • It kind of seems unintentional considering JsonPolymorphic attribute throws an exception when it doesn’t have any known derived type attributes, but I’ll try – MarengoHue Dec 16 '22 at 16:55
  • Like in my dotnetfiddle example i linked to in my last comment, you need to put appropriate \[JsonDerivedType\] attributes on _both_ the base and the derived types to make both JsonSerializer.Serialize calls in my code example work... –  Dec 16 '22 at 16:56
  • In my case it is including the $type on the json, and deserializing to the correct type, but all the properties are null. – Baron Jan 19 '23 at 12:05

2 Answers2

8

Specifying [JsonDerivedType(...)] on individual subclasses and on the base type seems to resolve an issue but barely seems intentional. This possibly might be fixed in future releases.

[JsonPolymorphic]
[JsonDerivedType(typeof(SuccesfulResult), typeDiscriminator: "ok")]
[JsonDerivedType(typeof(ErrorResult), typeDiscriminator: "fail")]
public abstract record Result;

[JsonDerivedType(typeof(SuccesfulResult), typeDiscriminator: "ok")]
public record SuccesfulResult : Result;

[JsonDerivedType(typeof(ErrorResult), typeDiscriminator: "fail")]
public record ErrorResult(string Error) : Result;
MarengoHue
  • 1,789
  • 13
  • 34
  • That is a nice workaround, also remember that if you've customized the TypeDiscriminatorPropertyName using the JsonPolymorphic attibute you need to add it to the subclasses as well. – Anton Mihaylov Apr 01 '23 at 13:57
  • 1
    Having the same problem and the workaround works. I don't need the JsonPolymorphicAttribute though – VladL May 09 '23 at 17:28
2

You have to specify the base type when serializing for it to work without annotating every derived type with [JsonDerivedType(...)]

var serialized = JsonSerializer.SerializeToDocument<Result[]>(testData);

Update:

This is a known bug in ASP.NET Core tracked here and here. (Doesn't affect IEnumerable results)

The link has a workaround by a .NET Team member in this comment using a custom resolver as follows



    var options = new JsonSerializerOptions { TypeInfoResolver = new InheritedPolymorphismResolver() };
    JsonSerializer.Serialize(new Derived(), options); // {"$type":"derived","Y":0,"X":0}
    
    [JsonDerivedType(typeof(Base), typeDiscriminator: "base")]
    [JsonDerivedType(typeof(Derived), typeDiscriminator: "derived")]
    public class Base
    {
        public int X { get; set; }
    }
    
    public class Derived : Base
    {
        public int Y { get; set; }
    }
    
    public class InheritedPolymorphismResolver : DefaultJsonTypeInfoResolver
    {
        public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
        {
            JsonTypeInfo typeInfo = base.GetTypeInfo(type, options);
    
            // Only handles class hierarchies -- interface hierarchies left out intentionally here
            if (!type.IsSealed && typeInfo.PolymorphismOptions is null && type.BaseType != null)
            {
                // recursively resolve metadata for the base type and extract any derived type declarations that overlap with the current type
                JsonPolymorphismOptions? basePolymorphismOptions = GetTypeInfo(type.BaseType, options).PolymorphismOptions;
                if (basePolymorphismOptions != null)
                {
                    foreach (JsonDerivedType derivedType in basePolymorphismOptions.DerivedTypes)
                    {
                        if (type.IsAssignableFrom(derivedType.DerivedType))
                        {
                            typeInfo.PolymorphismOptions ??= new()
                            {
                                IgnoreUnrecognizedTypeDiscriminators = basePolymorphismOptions.IgnoreUnrecognizedTypeDiscriminators,
                                TypeDiscriminatorPropertyName = basePolymorphismOptions.TypeDiscriminatorPropertyName,
                                UnknownDerivedTypeHandling = basePolymorphismOptions.UnknownDerivedTypeHandling,
                            };
    
                            typeInfo.PolymorphismOptions.DerivedTypes.Add(derivedType);
                        }
                    }
                }
            }
    
            return typeInfo;
        }
    }

It should be fixed in .NET 8

AD88
  • 21
  • 3