Is Polymorphic Deserialization Possible in System.Text.Json

Is polymorphic deserialization possible in System.Text.Json?


Is polymorphic deserialization possible in System.Text.Json?

The answer is yes and no, depending on what you mean by "possible".

There is no polymorphic deserialization (equivalent to Newtonsoft.Json's TypeNameHandling) support built-in to System.Text.Json. This is because reading the .NET type name specified as a string within the JSON payload (such as $type metadata property) to create your objects is not recommended since it introduces potential security concerns (see https://github.com/dotnet/corefx/issues/41347#issuecomment-535779492 for more info).

Allowing the payload to specify its own type information is a common source of vulnerabilities in web applications.

However, there is a way to add your own support for polymorphic deserialization by creating a JsonConverter<T>, so in that sense, it is possible.

The docs show an example of how to do that using a type discriminator property:
https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to#support-polymorphic-deserialization

Let's look at an example.

Say you have a base class and a couple of derived classes:

public class BaseClass
{
public int Int { get; set; }
}
public class DerivedA : BaseClass
{
public string Str { get; set; }
}
public class DerivedB : BaseClass
{
public bool Bool { get; set; }
}

You can create the following JsonConverter<BaseClass> that writes the type discriminator while serializing and reads it to figure out which type to deserialize. You can register that converter on the JsonSerializerOptions.

public class BaseClassConverter : JsonConverter<BaseClass>
{
private enum TypeDiscriminator
{
BaseClass = 0,
DerivedA = 1,
DerivedB = 2
}

public override bool CanConvert(Type type)
{
return typeof(BaseClass).IsAssignableFrom(type);
}

public override BaseClass Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}

if (!reader.Read()
|| reader.TokenType != JsonTokenType.PropertyName
|| reader.GetString() != "TypeDiscriminator")
{
throw new JsonException();
}

if (!reader.Read() || reader.TokenType != JsonTokenType.Number)
{
throw new JsonException();
}

BaseClass baseClass;
TypeDiscriminator typeDiscriminator = (TypeDiscriminator)reader.GetInt32();
switch (typeDiscriminator)
{
case TypeDiscriminator.DerivedA:
if (!reader.Read() || reader.GetString() != "TypeValue")
{
throw new JsonException();
}
if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
baseClass = (DerivedA)JsonSerializer.Deserialize(ref reader, typeof(DerivedA));
break;
case TypeDiscriminator.DerivedB:
if (!reader.Read() || reader.GetString() != "TypeValue")
{
throw new JsonException();
}
if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
baseClass = (DerivedB)JsonSerializer.Deserialize(ref reader, typeof(DerivedB));
break;
default:
throw new NotSupportedException();
}

if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject)
{
throw new JsonException();
}

return baseClass;
}

public override void Write(
Utf8JsonWriter writer,
BaseClass value,
JsonSerializerOptions options)
{
writer.WriteStartObject();

if (value is DerivedA derivedA)
{
writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.DerivedA);
writer.WritePropertyName("TypeValue");
JsonSerializer.Serialize(writer, derivedA);
}
else if (value is DerivedB derivedB)
{
writer.WriteNumber("TypeDiscriminator", (int)TypeDiscriminator.DerivedB);
writer.WritePropertyName("TypeValue");
JsonSerializer.Serialize(writer, derivedB);
}
else
{
throw new NotSupportedException();
}

writer.WriteEndObject();
}
}

This is what serialization and deserialization would look like (including comparison with Newtonsoft.Json):

private static void PolymorphicSupportComparison()
{
var objects = new List<BaseClass> { new DerivedA(), new DerivedB() };

// Using: System.Text.Json
var options = new JsonSerializerOptions
{
Converters = { new BaseClassConverter() },
WriteIndented = true
};

string jsonString = JsonSerializer.Serialize(objects, options);
Console.WriteLine(jsonString);
/*
[
{
"TypeDiscriminator": 1,
"TypeValue": {
"Str": null,
"Int": 0
}
},
{
"TypeDiscriminator": 2,
"TypeValue": {
"Bool": false,
"Int": 0
}
}
]
*/

var roundTrip = JsonSerializer.Deserialize<List<BaseClass>>(jsonString, options);


// Using: Newtonsoft.Json
var settings = new Newtonsoft.Json.JsonSerializerSettings
{
TypeNameHandling = Newtonsoft.Json.TypeNameHandling.Objects,
Formatting = Newtonsoft.Json.Formatting.Indented
};

jsonString = Newtonsoft.Json.JsonConvert.SerializeObject(objects, settings);
Console.WriteLine(jsonString);
/*
[
{
"$type": "PolymorphicSerialization.DerivedA, PolymorphicSerialization",
"Str": null,
"Int": 0
},
{
"$type": "PolymorphicSerialization.DerivedB, PolymorphicSerialization",
"Bool": false,
"Int": 0
}
]
*/

var originalList = JsonConvert.DeserializeObject<List<BaseClass>>(jsonString, settings);

Debug.Assert(originalList[0].GetType() == roundTrip[0].GetType());
}

Here's another StackOverflow question that shows how to support polymorphic deserialization with interfaces (rather than abstract classes), but a similar solution would apply for any polymorphism:
Is there a simple way to manually serialize/deserialize child objects in a custom converter in System.Text.Json?

System.Text.Json - Polymorphic serialization for property is not working

A simpler way of doing it that works with any type is to cast the object to System.Object. This makes use of the fact that the runtime type is used when serializing System.Object.

JsonSerializer.Serialize((object)request, options);

However, this will only apply the polymorphic serialization to the root object. If the polymorphic object is not the root and is instead stored as a property of the root, then using a custom converter is useful. In order to make that converter more generic, you can give it a generic type.

class PolymoprphicConverter<T> : JsonConverter<T> {}

The rest stays the same but replacing KrakenSubscriptionDetails with T. When you want to serialize, simply specify the type as the generic

class KrakenSubscriptionDetailsParent
{
public KrakenSubscriptionDetails Details { get; set; }
}

KrakenSubscriptionDetailsParent parent = ...
options.Converters.Add(new PolymoprphicConverter<KrakenSubscriptionDetails>());
JsonSerializer.Serialize(parent, options);

A downside of this approach is that it does not work well with tree structures where the parent type is or derived from the child type. If you pass the options to JsonSerializer.Serialize, it will try to use the same converter to serialize the object, causing an infinite loop. To prevent this, you must make sure to remove the converter from the options before serializing, preventing the converter from being applied to children.

System.Text.Json and Dynamically Parsing polymorphic objects

I ended up solving my issue in slightly the same way I had seen a previous article using a discriminator. Since I do not control the API feeds, I do not have a discriminator to drive off of, so I am relying on the properties of the Json object.

Need to create a Converter:

public class PolicyConfigurationSettingsConverter : JsonConverter<PolicyConfigurationSettings>
{
public override PolicyConfigurationSettings Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
{
JsonDocument doc;
JsonDocument.TryParseValue( ref reader, out doc );

if ( doc.RootElement.TryGetProperty( "minimumApproverCount", out _ ) )
return JsonSerializer.Deserialize<MinimumNumberOfReviewers>( doc.RootElement.ToString(), options );
if ( doc.RootElement.TryGetProperty( "useSquashMerge", out _ ) )
return JsonSerializer.Deserialize<RequireAMergeStrategy>( doc.RootElement.ToString(), options );
if ( doc.RootElement.TryGetProperty( "scope", out _ ) )
return JsonSerializer.Deserialize<PolicyConfigurationSettingsScope>( doc.RootElement.ToString(), options );

return null;
}

public override void Write( Utf8JsonWriter writer, [DisallowNull] PolicyConfigurationSettings value, JsonSerializerOptions options )
{
if ( value.GetType() == typeof( MinimumNumberOfReviewers ) )
JsonSerializer.Serialize( writer, ( MinimumNumberOfReviewers )value, options );
if ( value.GetType() == typeof( RequireAMergeStrategy ) )
JsonSerializer.Serialize( writer, ( RequireAMergeStrategy )value, options );
if ( value.GetType() == typeof( PolicyConfigurationSettingsScope ) )
JsonSerializer.Serialize( writer, ( PolicyConfigurationSettingsScope )value, options );
}
}

Then need to create a JsonSerializerOptions object to add the Converter

public static JsonSerializerOptions PolicyConfigurationSettingsSerializerOptions()
{
var serializeOptions = new JsonSerializerOptions();
serializeOptions.Converters.Add( new PolicyConfigurationSettingsConverter() );
return serializeOptions;
}

Pass the options into your Serializer/Deserializer statement.

Below is the PolicyConfigurationSettings class

public abstract class PolicyConfigurationSettings
{
[JsonPropertyName( "scope" )]
public List<PolicyConfigurationScope> Scope { get; set; }
}

public class MinimumNumberOfReviewers : PolicyConfigurationSettings
{
[JsonPropertyName( "minimumApproverCount" )]
public int MinimumApproverCount { get; set; }
[JsonPropertyName( "creatorVoteCounts" )]
public bool CreatorVoteCounts { get; set; }
[JsonPropertyName( "allowDownvotes" )]
public bool AllowDownvotes { get; set; }
[JsonPropertyName( "resetOnSourcePush" )]
public bool ResetOnSourcePush { get; set; }
}

public class RequireAMergeStrategy : PolicyConfigurationSettings
{
[JsonPropertyName( "useSquashMerge" )]
public bool UseSquashMerge { get; set; }
}

public class PolicyConfigurationSettingsScope : PolicyConfigurationSettings { }

Select type to use for deserialization in System.Text.Json

Thanks to @dbc for pointing me in the right direction, here's the converter I've ended up with:

public class JobBaseConverter : JsonConverter<JobBase>
{
public override JobBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}

using (var jsonDocument = JsonDocument.ParseValue(ref reader))
{
if (jsonDocument.RootElement.TryGetProperty(nameof(ScheduledJob.Schedule), out var typeProperty))
{
return JsonSerializer.Deserialize<ScheduledJob>(jsonObject, options);
}
else
{
return JsonSerializer.Deserialize<OnDemandJob>(jsonObject, options);
}
}
}

public override void Write(Utf8JsonWriter writer, JobBase value, JsonSerializerOptions options)
{
if(value is ScheduledJob)
{
JsonSerializer.Serialize(writer, value as ScheduledJob, options);
}
else
{
JsonSerializer.Serialize(writer, value as OnDemandJob, options);
}
}
}

Abp Blazor WebAssembly - Polymorphic DTO Deserialization using System.Text.Json

I managed to make this work by using the JsonConvert class and [JsonConverter] attribute. There is no configuration necessary in the ConfigureServices() method this way.

  1. Added input and output DTOs to my .Application.Contracts project, and decorated these with [JsonConverter(typeof(MyConverterClass))] attributes on the BASE CLASSES ONLY (adding this attribute to a child class will cause a loop within the serializer it seems.)

  2. Added an enum property which overrides the base class and thereby denotes the derived class type, serving as a discriminator

  3. Created an appropriate converter class (in the same project as the DTOs) on the lines of the following

DTO classes:

    [JsonConvert(typeof(AnimalInputJsonConverter))]
public abstract class AnimalInputDto : EntityDto<Guid>
{
public string Name { get; set; }
public virtual AnimalType AnimalType => AnimalType.NotSelected
}

public class CatInputDto : AnimalInputDto
{
public override AnimalType AnimalType => AnimalType.Cat
[.. more properties specific to Cat]
}

[JsonConvert(typeof(AnimalOutputJsonConverter))]
public abstract class AnimalOutputDto : EntityDto<Guid>
{
public string Name { get; set; }
public virtual AnimalType AnimalType => AnimalType.NotSelected
}

public class CatOutputDto : AnimalOutputDto
{
public override AnimalType AnimalType => AnimalType.Cat
[.. more properties specific to Cat]
}

Converter example (the code is essentially the same between input and output DTOs)

    public class AnimalInputDtoJsonConverter : JsonConverter<AnimalInputDto>
{
public override bool CanConvert(Type typeToConvert) =>
typeof(AnimalInputDto).IsAssignableFrom(typeToConvert);

public override AnimalInputDto Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Take a copy of the reader as we need to check through the object first before deserializing.
Utf8JsonReader readerClone = reader;

if (readerClone.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}

AnimalType typeDiscriminator = AnimalType.NotSelected;
string camelCasedPropertyName =
nameof(AnimalDto.AnimalType).ToCamelCase();

// Loop through the JSON tokens. Look for the required property by name.
while (readerClone.Read())
{
if (readerClone.TokenType == JsonTokenType.PropertyName && readerClone.GetString() == camelCasedPropertyName)
{
// Move on to the value, which has to parse out to an enum
readerClone.Read();
if (readerClone.TokenType == JsonTokenType.Number)
{
int value = readerClone.GetInt32();
try
{
typeDiscriminator = (AnimalType)value;
break;
}
catch (InvalidCastException)
{
throw new JsonException($"{value} is not a recognised integer representation of {typeof(AnimalType)}");
}
}
}
}

AnimalInputDto target = typeDiscriminator switch
{
AnimalType.Cat => JsonSerializer.Deserialize<CatInputDto>(ref reader, options),
_ => throw new NotSupportedException($"The supplied object is not a recognised derivative of {typeof(AnimalInputDto)}")
};

return target;
}

public override void Write(
Utf8JsonWriter writer,
AnimalInputDto value,
JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}

Furthermore, a generic approach seems possible, although this code is not optimised or performance tested, I expect performance penalties from use of reflection and instantiation of objects using Activator.CreateInstance() to check the value of their discriminator.

Note that the below assumes that the discriminator property is an enum, and that the derived class has this property named exactly the same as the enumerated type:

Used as follows:

    [JsonConvert(typeof(PolymorphicJsonConverter<AnimalInputDto, AnimalType>))]
public abstract class AnimalInputDto : EntityDto<Guid>
{
public string Name { get; set; }
public virtual AnimalType AnimalType => AnimalType.NotSelected
}

...

public class PolymorphicJsonConverter<T, U> : JsonConverter<T>
where T : EntityDto<Guid>
where U : Enum
{
public string TypeDiscriminator { get; private set; }
public string TypeDiscriminatorCamelCase { get; private set; }

public List<Type> DerivableTypes { get; private set; }

public PolymorphicJsonConverter()
: base()
{
TypeDiscriminator = typeof(U).Name;
TypeDiscriminatorCamelCase = TypeDiscriminator.ToCamelCase();
DerivableTypes = new List<Type>();
foreach (var domainAssembly in AppDomain.CurrentDomain.GetAssemblies())
{
var assemblyTypes = domainAssembly.GetTypes()
.Where(type => type.IsSubclassOf(typeof(T)) && !type.IsAbstract);

DerivableTypes.AddRange(assemblyTypes);
}
}

public override bool CanConvert(Type typeToConvert) =>
typeof(T).IsAssignableFrom(typeToConvert);

public override T Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Take a copy of the reader as we need to check through the object first before deserializing.
Utf8JsonReader readerClone = reader;

if (readerClone.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}

U typeDiscriminatorValue = (U)Enum.ToObject(typeof(U), 0);

// Loop through the JSON tokens. Look for the required property by name.
while (readerClone.Read())
{
if (readerClone.TokenType == JsonTokenType.PropertyName && readerClone.GetString() == TypeDiscriminatorCamelCase)
{
// Move on to the value, which has to parse out to an enum
readerClone.Read();
if (readerClone.TokenType == JsonTokenType.Number)
{
int value = readerClone.GetInt32();
try
{
typeDiscriminatorValue = (U)Enum.ToObject(typeof(U), value);
break;
}
catch (InvalidCastException)
{
throw new NotSupportedException($"{value} is not a recognised integer representation of {typeof(U)}");
}
}
}
}

T target = null;

foreach(var dt in DerivableTypes)
{
var newInst = Activator.CreateInstance(dt);
var propValue = (U)newInst.GetType().GetProperty(TypeDiscriminator).GetValue(newInst, null);
if (propValue.Equals(typeDiscriminatorValue))
{
target = (T)JsonSerializer.Deserialize(ref reader, dt, options);
}
}

if (target == null)
{
throw new NotSupportedException($"The supplied object is not a recognised derivative of {typeof(T)}");
}

return target;
}

public override void Write(
Utf8JsonWriter writer,
T value,
JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}

}

Inspiration for the above / further reading:
https://getyourbitstogether.com/polymorphic-serialization-in-system-text-json/
https://vpaulino.wordpress.com/2021/02/23/deserializing-polymorphic-types-with-system-text-json/
https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-6-0
https://docs.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonconverter-1?view=net-6.0
https://docs.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonconverterattribute?view=net-6.0

Deserialize complex polymorphic types with System.Text.Json

The current solution I have is, if necessary, use a JsonDocument to cache part of the json for deferred parsing.

I don't like is that I can't see a way to invoke JsonSerializer on a JsonDocument so I have to convert it back to text with GetRawText() which won't be very efficient.

public class MessageConverter : JsonConverter<Message>
{
public override bool CanConvert(Type objectType) =>
objectType == typeof(Message);

public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var message = new Message();

JsonDocument cachedBody = null;

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}

if (reader.TokenType == JsonTokenType.PropertyName)
{
var propertyName = reader.GetString();
reader.Read();

switch (propertyName)
{
case "Type":
message.Type = reader.GetString();
break;
case "Body":

if (message.Type != null)
{
message.Body = message.Type switch
{
"A" => JsonSerializer.Deserialize<BodyA>(ref reader, options),
"B" => JsonSerializer.Deserialize<BodyB>(ref reader, options),
_ => throw new Exception($"Cannot parse message body of type {message.Type}")
};
}
else
{
cachedBody = JsonDocument.ParseValue(ref reader);
}
break;
}
}
}

if (message.Body == null)
{
if (cachedBody == null)
{
throw new Exception($"Missing message body");
}

try
{
Log.Write("using cache");

message.Body = message.Type switch
{
"A" => JsonSerializer.Deserialize<BodyA>(cachedBody.RootElement.GetRawText()),
"B" => JsonSerializer.Deserialize<BodyB>(cachedBody.RootElement.GetRawText()),
_ => throw new Exception($"Cannot parse message body of type {message.Type}")
};
}
finally
{
cachedBody.Dispose();
}
}

return message;
}

public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options)
{
writer.WriteStartObject();

writer.WritePropertyName("Type");
writer.WriteStringValue(value.Type);

writer.WritePropertyName("Body");
JsonSerializer.Serialize<object>(writer, value.Body, options);

writer.WriteEndObject();
}
}

JsonConverter - WebApi - Case Sensitivity - Polymorphic

Newtonsoft was case insensitive.

With System.Text.Json you have to pull some more levers.

https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to?pivots=dotnet-6-0#case-insensitive-deserialization

Case-insensitive deserialization During deserialization,
Newtonsoft.Json does case-insensitive property name matching by
default. The System.Text.Json default is case-sensitive, which gives
better performance since it's doing an exact match. For information
about how to do case-insensitive matching, see Case-insensitive
property matching.

See this below URL as well:

https://makolyte.com/csharp-case-sensitivity-in-json-deserialization/

Here is a possible way to deal with it:

https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-character-casing

above url is ::: How to enable case-insensitive property name matching with
System.Text.Json

https://docs.microsoft.com/en-us/dotnet/api/system.text.json.jsonelement.getproperty?view=net-6.0#system-text-json-jsonelement-getproperty(system-string)

Remarks
Property name matching is performed as an ordinal, case-sensitive comparison.

I don't think you can overcome that behavior on the "roll your own".

But maybe you can chase this:

https://docs.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions.propertynamecaseinsensitive?view=net-6.0#system-text-json-jsonserializeroptions-propertynamecaseinsensitive

But that seems to be without "roll your own".



Related Topics



Leave a reply



Submit