dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.1k stars 4.7k forks source link

Why does System.Text.Json throw an exception when deserializing polymorphic types in case $type is not the first property #96088

Closed armanossiloko closed 9 months ago

armanossiloko commented 9 months ago

I have noticed some behavior that got me surprised. I have an abstract BaseClass and a DerivedClass.

[JsonPolymorphic]
[JsonDerivedType(typeof(DerivedClass), "derived")]
public abstract class BaseClass
{
    public BaseClass() { }
}
public class DerivedClass : BaseClass
{
    public string? Whatever { get; set; }
}

And now I have two JSON strings: the first JSON has the type discriminator ($type) as the very first property within the JSON - the second JSON string does not. When I perform JsonSerializer.Deserialize<BaseClass>(), an exception is thrown on the second JSON string.

var jsonWorks = "{\"$type\": \"derived\", \"whatever\": \"Bar\"}";
var jsonBreaks = "{\"whatever\": \"Bar\", \"$type\": \"derived\"}";

var obj1 = JsonSerializer.Deserialize<BaseClass>(jsonWorks);
var obj2 = JsonSerializer.Deserialize<BaseClass>(jsonBreaks); // This one will throw an exception

The exception that is thrown is of type System.NotSupportedException with the following message:

System.NotSupportedException: 'Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'. Path: $ | LineNumber: 0 | BytePositionInLine: 12.'

It also has an inner exception:

NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'.

At first glance I thought this would be a bug until someone pointed out this particular area in the documentation.

The type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like $id and $ref.

Since on the top level of any JSON string, you can't really have duplicate properties (e.g the property username cannot really appear twice on the same level within a JSON), why is System.Text.Json designed in such a way to throw this exception?

For anyone interested, I came across this while I was trying to map a property to jsonb using EntityFrameworkCore and within Npgsql. I did have to wrap the JsonSerializer methods into a new class which for the sake of example is called DatabaseJsonConverter below. Also, during the "serialization" part of an object, the $type does get written as the first property within the resulting JSON, but for some reason, after it's saved, the order of properties in my PostgreSQL instance is not the same it was in the metadata => DatabaseJsonConverter.Serialize(metadata) resulting string.

Image taken from within the database in Datagrip: image

builder.Entity<MyEntity>()
    .Property(e => e.MyProperty)
    .HasColumnType("jsonb")
    .HasConversion(
        metadata => DatabaseJsonConverter.Serialize(metadata),
        json => DatabaseJsonConverter.Deserialize<Metadata>(json)
    );
ghost commented 9 months ago

Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis See info in area-owners.md if you want to be subscribed.

Issue Details
I have noticed some behavior that got me surprised. I have an abstract BaseClass and a DerivedClass. ```csharp [JsonPolymorphic] [JsonDerivedType(typeof(DerivedClass), "derived")] public abstract class BaseClass { public BaseClass() { } } public class DerivedClass : BaseClass { public string? Whatever { get; set; } } ``` And now I have two JSON strings: the first JSON has the type discriminator (`$type`) as the very first property within the JSON - the second JSON string does not. When I perform `JsonSerializer.Deserialize()`, an exception is thrown on the second JSON string. ```csharp var jsonWorks = "{\"$type\": \"derived\", \"whatever\": \"Bar\"}"; var jsonBreaks = "{\"whatever\": \"Bar\", \"$type\": \"derived\"}"; var obj1 = JsonSerializer.Deserialize(jsonWorks); var obj2 = JsonSerializer.Deserialize(jsonBreaks); // This one will throw an exception ``` The exception that is thrown is of type `System.NotSupportedException` with the following message: ``` System.NotSupportedException: 'Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'. Path: $ | LineNumber: 0 | BytePositionInLine: 12.' ``` It also has an inner exception: ``` NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'MyApp.BaseClass'. ``` At first glance I thought this would be a bug until someone pointed out this [particular area in the documentation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-8-0#polymorphic-type-discriminators). ``` The type discriminator must be placed at the start of the JSON object, grouped together with other metadata properties like $id and $ref. ``` Since on the top level of any JSON string, you can't really have duplicate properties (e.g the property `username` cannot really appear twice on the same level within a JSON), why is `System.Text.Json` designed in such a way to throw this exception? For anyone interested, I came across this while I was trying to map a property to `jsonb` using EntityFrameworkCore and within Npgsql. I did have to wrap the JsonSerializer methods into a new class which for the sake of example is called `DatabaseJsonConverter` below. Also, during the "serialization" part of an object, the `$type` does get written as the first property within the resulting JSON, but for some reason, after it's saved, the order of properties in my PostgreSQL instance is not the same it was in the `metadata => DatabaseJsonConverter.Serialize(metadata)` resulting string. Image taken from within the database in Datagrip: ![image](https://github.com/dotnet/runtime/assets/16511442/0a956ea3-7766-49c6-9dbc-942ac3d6ff90) ```csharp builder.Entity() .Property(e => e.MyProperty) .HasColumnType("jsonb") .HasConversion( metadata => DatabaseJsonConverter.Serialize(metadata), json => DatabaseJsonConverter.Deserialize(json) ); ```
Author: armanossiloko
Assignees: -
Labels: `area-System.Text.Json`
Milestone: -
elgonzo commented 9 months ago

See this comment and the comment(s) it refers to: https://github.com/dotnet/runtime/issues/72604#issuecomment-1191524754

eiriktsarpalis commented 9 months ago

Closing as duplicate of #72604