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.
-
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.)
-
Added an enum property which overrides the base class and thereby denotes the derived class type, serving as a discriminator
-
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://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-6-0 https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonconverter-1?view=net-6.0 https://learn.microsoft.com/en-us/dotnet/api/system.text.json.serialization.jsonconverterattribute?view=net-6.0
https://docs.abp.io/en/abp/4.4/Migration-Guides/Abp-4_0#unsupported-types
-------------->@@abp 4.4 PreConfigure<AbpJsonOptions>(options =>
https://github.com/abpframework/abp/issues/12604
public override void PreConfigureServices(ServiceConfigurationContext context)
{
PreConfigure<AbpJsonOptions>(options =>
{
options.UseHybridSerializer = false;
options.DefaultDateTimeFormat = "yyyy/MM/dd HH:mm:ss";
});
}
https://github.com/abpframework/abp/issues/12604