Abp Blazor WebAssembly - Polymorphic DTO Deserialization using System.Text.Json
@@abp 4.0 dto jobject
https://stackoverflow.com/questions/70032776/abp-blazor-webassembly-polymorphic-dto-deserialization-using-system-text-json
Abp Framework version: 5.0.0-beta2, UI: Blazor WebAssembly
I'm attempting to implement polymorphism within the ABP framework to be able to exchange derived classes between the API backend and the Blazor WebAssembly front end, and am having trouble getting Blazor to deserialize the JSON polymorphically:
// Output Dtos
public abstract class AnimalOutputDto : EntityDto<Guid>
{
public string Name { get; set; }
}
public class CatOutputDto : AnimalOutputDto
{
public string Name { get; set; }
public string Color { get; set; }
}
// Input Dtos
public abstract class AnimalInputDto : EntityDto<Guid>
{
public string Name { get; set; }
}
public class CatInputDto : AnimalInputDto
{
public string Name { get; set; }
public string Color { get; set; }
}
When passing models from the Blazor front-end to the HTTP API, I am able to correctly deserialize them by using a custom JsonConverter as described in this article, which I added to the HTTPAPI project and then referenced in the ConfigureServices method of the HTTPAPI.Host project:
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
var hostingEnvironment = context.Services.GetHostingEnvironment();
..Usual configuration statements..
ConfigureJsonConverters(context);
}
private void ConfigureJsonConverters(ServiceConfigurationContext context)
{
context.Services.AddControllers(options =>
{
}).AddJsonOptions(options => {
options.JsonSerializerOptions.Converters.Add(new AnimalJsonConverter());
});
}
When the model is passed back to the Blazor front-end I can verify that it is being serialized using the correct converter for the type also specified in the Microsoft article.
However, when the model is received by Blazor, an exception is thrown: it is clearly not recognising the polymorphic type, and instead is trying to deserialize the abstract base class:
Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.
It seems as if I would need to find a way to register the same custom JSON converter classes in the Blazor project as done in the HttpApi.Host project. However I cannot find any documentation on how this is done.
Does anybody have any information or guidance on this?
2 Answers
There are still some limitations using System.Text.Json
- have a look here: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to#table-of-differences-between-newtonsoftjson-and-systemtextjson
Although it has a workaround, Polymorphic serialization and deserialization seem to be one of them.
I think you can only use Newtonsoft.Json
on Blazor side.
Always Use the Newtonsoft.Json
If you want to continue to use the Newtonsoft.Json library for all the types, you can set
UseHybridSerializer
to false in thePreConfigureServices
method of your module class:PreConfigure<AbpJsonOptions>(options => { options.UseHybridSerializer = false; });
References:
-
1Thanks for the reply and the link to the ABP document. However, when I try to implement the converters as detailed in the ABP docs (inherit from IJsonSerializerProvider) ABP does not seem to pick up my converters; when debugging the breakpoints within them are never hit. I am registering them like this in each module within the ConfigureServices() method: Configure<Volo.Abp.Json.AbpJsonOptions>(options => { options.Providers.Insert(0, typeof(AnimalJsonConverter)); });– GDUnitNov 19, 2021 at 13:25
-
So I've tried all these options and I can't get the ABP JSON converters to be recognised, either inbound from Blazor to the backend or vice versa: - Creating instance of IJsonSerializerProvider (AnimalDtoJsonConverter) - Registering in PreConfigure() method of module (also tried in ConfigureServices()) - Tried inserting at position 0 (instead of Add()) but no difference - Tried setting hybrid serializer to false (and also tried setting the base DTO class as not supported) Whatever I do, I cannot get ABP to recognise and hit the serializer..– GDUnitNov 19, 2021 at 17:36
-
I'm sorry, but fortunately, I can see from the answer you added that the issue has been resolved, I'm glad about that. I'll test Polymorphic serialization and deserialization by setting
UseHybridSerializer
tofalse
. Nov 22, 2021 at 11:38 -
1Thanks. Note that my answer did not involve reverting to the Newtonsoft JSON, but rather using System.Text.Json.– GDUnitNov 23, 2021 at 7:03
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
Created a a new ABP project with the cli. In the HttpApiHost Module I added this: public override void PreConfigureServices(ServiceConfigurationContext context)
{
PreConfigure<AbpJsonOptions>(options =>
{
options.UseHybridSerializer = false;
options.DefaultDateTimeFormat = "yyyy/MM/dd HH:mm:ss";
});
}
Added a simple application service: public class DemoDto
{
public DateTime DateTest { get; set; }
}
public class DemoAppService : BookStoreAppService
{
public DemoDto GetDemo()
{
return new DemoDto()
{
DateTest = DateTime.UtcNow
};
}
}
When testing the endpoint the result was:
How can I get the datetime to serialize as the provided AbpJsonOptions.DefaultDateTimeFormat ? The actual issue I am facing in my original project is that the 'Z' is missing in the end. So when a datetime is serialized it shows as 2022-05-12T22:22:08.8273489 instead of 2022-05-12T22:22:08.8273489Z |
This is a typical XY problem, please describe your original problem in detail. |
@maliming The original problem is that an entity has a DateTime property. I am using Entity Framework as the database provider. When the entity is retrieved from the database the DateTime kind is set to unspecified. Because of this the serializer is not adding the 'Z' in the end because it doesn't know it is a UTC kind. That's why I wanted to specify the format myself since all the datetimes are always UTC. I am also configuring the clock: Configure<AbpClockOptions>(options => options.Kind = DateTimeKind.Utc);
|
Our framework has unit tests ensure that the problem you mentioned do not happen. Can you reproduce it with unit tests or a template project? |
After a lot of debugging I found the issue. It was because I am using a custom serializer that I forgot about. This serializer is set as an attribute on the Dto and it is only used in inheritance so the discriminator property is automatically serialized. However because this is a custom serializer the other serializers are not being applied anymore 🤦♂️ |
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