1

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,
            };
        }
    }
}
  • @Serge, There are important reasons I'd rather not to use Netwtonsoft. My classes already are heavily Netwtonsoft decorated but that is optimized for purposes other than ASP.Net deserializations. – T. Jastrzębski Aug 04 '22 at 15:03
  • You could create the whole new serializer already instead of a strange provider. – Serge Aug 04 '22 at 15:08
  • @Serge, Although I could create a separate serializer, I cannot have two sets of decorations - namely, class/property attributes. That would require another, parallel set of entities and I hope you agree that would not be a perfect solution. Unless, we want to be religious about Newtonsoft and nothing else matters. – T. Jastrzębski Aug 04 '22 at 15:18
  • The problem was brought up to Microsoft. See [issue 43095](https://github.com/dotnet/aspnetcore/issues/43095) – T. Jastrzębski Aug 20 '22 at 14:17

1 Answers1

1

Unfortunately, currently (September 2022) there is no good solution available.
See my discussion with Microsoft here. Supposedly, the problem will be solved by [JsonDerivedType] attibute when .Net 7 becomes available.