Deserialization of polymorphic and complex objects in ASP.Net is a well know topic. Common solutions I came across rely on JsonConverter or JsonSubTypes.
However, the challenge here is NOT to use Newtonsoft.Json at all but rely on the new System.Text.Json and Microsoft.AspNetCore.Mvc.ModelBinding instead. The reason: my classes already are heavily 'Netwtonsoft decorated' but this decoration (class/property attributes) is optimized and customized for purposes other than ASP.Net deserialization.
Microsoft has a solution relying on ModelBinder attribute described here. I am able to correctly deserialize polymorphic objects but not complex objects. That is, polymorphic objects containing collection of other, non-polymorphic objects do not get deserialized properly.
public abstract class Vehicle
{
public abstract string Kind { get; set; }
public string Make { get; set; }
public RepairRecord[]? RepairHistory { get; set; }
public override string ToString()
{
return JsonSerializer.Serialize(this);
}
}
public class Car : Vehicle
{
public override string Kind { get; set; } = nameof(Car);
public int CylinderCount { get; set; }
}
public class Bicycle : Vehicle
{
public override string Kind { get; set; } = nameof(Bicycle);
public bool HasStand { get; set; }
}
public class RepairRecord
{
public DateTime DateTime { get; set; }
public string Description { get; set; }
}
[HttpPut]
public IActionResult Create([ModelBinder(typeof(VehicleModelBinder))] Vehicle vehicle)
{
_logger.LogInformation(vehicle.ToString());
return new OkResult();
}
The problem: deserialized vehicle is missing RepairHistory
records in the Create()
method.
What am I missing? Please advise.
Complete, working code below.
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new VehicleModelBinderProvider());
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.UseAllOfForInheritance(); // enabling inheritance - this allows to maintain the inheritance hierarchy in any generated client model
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
public abstract class Vehicle
{
public abstract string Kind { get; set; }
public string Make { get; set; }
public RepairRecord[]? RepairHistory { get; set; }
public override string ToString()
{
return JsonSerializer.Serialize(this);
}
}
public class Car : Vehicle
{
public override string Kind { get; set; } = nameof(Car);
public int CylinderCount { get; set; }
}
public class Bicycle : Vehicle
{
public override string Kind { get; set; } = nameof(Bicycle);
public bool HasStand { get; set; }
}
public class RepairRecord
{
public DateTime DateTime { get; set; }
public string Description { get; set; }
}
[ApiController]
[Route("")]
public class Controller : ControllerBase
{
private readonly ILogger<Controller> _logger;
public Controller(ILogger<Controller> logger)
{
_logger = logger;
}
[HttpPost]
public IActionResult Create([ModelBinder(typeof(VehicleModelBinder))] Vehicle vehicle)
{
_logger.LogInformation(vehicle.ToString());
return new OkResult();
}
}
public class VehicleModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType != typeof(Vehicle))
{
return null;
}
var subclasses = new[] { typeof(Car), typeof(Bicycle), };
var binders = new Dictionary<Type, (ModelMetadata, IModelBinder)>();
foreach (var type in subclasses)
{
var modelMetadata = context.MetadataProvider.GetMetadataForType(type);
binders[type] = (modelMetadata, context.CreateBinder(modelMetadata));
}
return new VehicleModelBinder(binders);
}
}
public class VehicleModelBinder : IModelBinder
{
private Dictionary<Type, (ModelMetadata, IModelBinder)> binders;
public VehicleModelBinder(Dictionary<Type, (ModelMetadata, IModelBinder)> binders)
{
this.binders = binders;
}
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
var modelKindName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, nameof(Vehicle.Kind));
var modelTypeValue = bindingContext.ValueProvider.GetValue(modelKindName).FirstValue;
IModelBinder modelBinder;
ModelMetadata modelMetadata;
if (modelTypeValue == nameof(Car))
{
(modelMetadata, modelBinder) = binders[typeof(Car)];
}
else if (modelTypeValue == nameof(Bicycle))
{
(modelMetadata, modelBinder) = binders[typeof(Bicycle)];
}
else
{
bindingContext.Result = ModelBindingResult.Failed();
return;
}
var newBindingContext = DefaultModelBindingContext.CreateBindingContext(
bindingContext.ActionContext,
bindingContext.ValueProvider,
modelMetadata,
bindingInfo: null,
bindingContext.ModelName);
await modelBinder.BindModelAsync(newBindingContext);
bindingContext.Result = newBindingContext.Result;
if (newBindingContext.Result.IsModelSet)
{
// Setting the ValidationState ensures properties on derived types are correctly
bindingContext.ValidationState[newBindingContext.Result.Model] = new ValidationStateEntry
{
Metadata = modelMetadata,
};
}
}
}