Search code examples
c#asp.netasp.net-mvcasp.net-coreasp.net-core-mvc-2.0

Model binder for abstract class in asp.net core mvc 2


I've been trying to implement a model binder for an abstract class in ASP.NET Core 2 without success.

I've studied two articles in particular which look very good:

http://www.dotnetcurry.com/aspnet-mvc/1368/aspnet-core-mvc-custom-model-binding

Asp net core rc2. Abstract class model binding

There are two goals I'm trying to reach,

  1. Automatically create as many nested editors as needed from model (child nesting).
  2. Map the form values back into the model correctly.

Here's my code based on the articles mentioned above.

public class Trigger
{
    public ActionBase Action { get; set; }
}

[ModelBinder(BinderType = typeof(ActionModelBinder))]
public abstract class ActionBase
{
    public string Type => GetType().FullName;

    public ActionBase Action { get; set; }
}

public class ActionA : ActionBase
{
    public int IntProperty { get; set; }
}

public class ActionB : ActionBase
{
    public string StringProperty { get; set; }
}

public class ActionModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (context.Metadata.ModelType != typeof(ActionBase))
            return null;

        var binders = new Dictionary<string, IModelBinder>();
        foreach (var type in typeof(ActionModelBinderProvider).GetTypeInfo().Assembly.GetTypes())
        {
            var typeInfo = type.GetTypeInfo();
            if (typeInfo.IsAbstract || typeInfo.IsNested)
                continue;

            if (!(typeInfo.IsClass && typeInfo.IsPublic))
                continue;

            if (!typeof(ActionBase).IsAssignableFrom(type))
                continue;

            var metadata = context.MetadataProvider.GetMetadataForType(type);
            var binder = context.CreateBinder(metadata); // This is a BinderTypeModelBinder
            binders.Add(type.FullName, binder);
        }

        return new ActionModelBinder(context.MetadataProvider, binders);
    }
}

public class ActionModelBinder : IModelBinder
{
    private readonly IModelMetadataProvider _metadataProvider;
    private readonly Dictionary<string, IModelBinder> _binders;

    public ActionModelBinder(IModelMetadataProvider metadataProvider, Dictionary<string, IModelBinder> binders)
    {
        _metadataProvider = metadataProvider;
        _binders = binders;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var messageTypeModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "Type");
        var messageTypeResult = bindingContext.ValueProvider.GetValue(messageTypeModelName);
        if (messageTypeResult == ValueProviderResult.None)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        IModelBinder binder;
        if (!_binders.TryGetValue(messageTypeResult.FirstValue, out binder))
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        // Now know the type exists in the assembly.
        var type = Type.GetType(messageTypeResult.FirstValue);
        var metadata = _metadataProvider.GetMetadataForType(type);

        ModelBindingResult result;
        using (bindingContext.EnterNestedScope(metadata, bindingContext.FieldName, bindingContext.ModelName, model: null))
        {
            await binder.BindModelAsync(bindingContext);
            result = bindingContext.Result;
        }

        bindingContext.Result = result;
    }
}

Editor templates placed in correct location:

ActionA.cshtml

@model WebApplication1.Models.ActionA

<div class="row">
    <h4>Action A</h4>
    <div class="col-md-4">
        <div class="form-group">
            <label asp-for="IntProperty" class="control-label"></label>
            <input asp-for="IntProperty" class="form-control" />
            <span asp-validation-for="IntProperty" class="text-danger"></span>
        </div>
        <div class="form-group">
            <label asp-for="Type" class="control-label"></label>
        </div>
        @Html.EditorFor(x => x.Action)
    </div>
</div>

ActionB.cshtml

@model WebApplication1.Models.ActionB

<div class="row">
    <h4>Action B</h4>
    <div class="col-md-4">
        <div class="form-group">
            <label asp-for="StringProperty" class="control-label"></label>
            <input asp-for="StringProperty" class="form-control" />
            <span asp-validation-for="StringProperty" class="text-danger"></span>
        </div>
        <div class="form-group">
            <label asp-for="Type" class="control-label"></label>
        </div>
        @Html.EditorFor(x => x.Action)
    </div>
</div>

Index.cshtml

@model WebApplication1.Models.Trigger

<h2>Edit</h2>

<h4>Trigger</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Index">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            @Html.EditorFor(x=>x.Action)
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>

HomeController.cshtml

public class HomeController : Controller
{
    public IActionResult Index()
    {
        var trigger = new Trigger()
        {
            Action = new ActionA()
            {
                IntProperty = 1,
                Action = new ActionB()
                {
                    StringProperty = "foo"
                }
            }
        };

        return View(trigger);
    }

    [HttpPost]
    public IActionResult Index(Trigger model)
    {
        return View(model);
    }
}

In regards with goal no. 1 only the first action is rendered, even though it has a child-action.

When trying to post back (goal no. 2) I get an exception:

InvalidOperationException: Unable to resolve service for type 'System.Collections.Generic.Dictionary`2[System.String,Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder]' while attempting to activate 'WebApplication1.ActionModelBinder'.

Any assistance to this is greatly appreciated!


Solution

  • I had incorrectly added the ModelBinder attribute to the class upon which I wanted to perform custom binding.

    [ModelBinder(BinderType = typeof(ActionModelBinder))]
    public abstract class ActionBase
    {
        public string Type => GetType().FullName;
    
        public ActionBase Action { get; set; }
    }
    

    This caused the provider code to be bypassed - removing this attribute resolved several issues.

    I refactored the provider and binder to be generic so there's no need to duplicate code.

    public class AbstractModelBinderProvider<T> : IModelBinderProvider where T : class
    {
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            if (context == null)
                throw new ArgumentNullException(nameof(context));
    
            if (context.Metadata.ModelType != typeof(T))
                return null;
    
            var binders = new Dictionary<string, IModelBinder>();
            foreach (var type in typeof(AbstractModelBinderProvider<>).GetTypeInfo().Assembly.GetTypes())
            {
                var typeInfo = type.GetTypeInfo();
                if (typeInfo.IsAbstract || typeInfo.IsNested)
                    continue;
    
                if (!(typeInfo.IsClass && typeInfo.IsPublic))
                    continue;
    
                if (!typeof(T).IsAssignableFrom(type))
                    continue;
    
                var metadata = context.MetadataProvider.GetMetadataForType(type);
                var binder = context.CreateBinder(metadata);
                binders.Add(type.FullName, binder);
            }
    
            return new AbstractModelBinder(context.MetadataProvider, binders);
        }
    }
    
    public class AbstractModelBinder : IModelBinder
    {
        private readonly IModelMetadataProvider _metadataProvider;
        private readonly Dictionary<string, IModelBinder> _binders;
    
        public AbstractModelBinder(IModelMetadataProvider metadataProvider, Dictionary<string, IModelBinder> binders)
        {
            _metadataProvider = metadataProvider;
            _binders = binders;
        }
    
        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var messageTypeModelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "Type");
            var typeResult = bindingContext.ValueProvider.GetValue(messageTypeModelName);
            if (typeResult == ValueProviderResult.None)
            {
                bindingContext.Result = ModelBindingResult.Failed();
                return;
            }
    
            IModelBinder binder;
            if (!_binders.TryGetValue(typeResult.FirstValue, out binder))
            {
                bindingContext.Result = ModelBindingResult.Failed();
                return;
            }
    
            var type = Type.GetType(typeResult.FirstValue);
    
            var metadata = _metadataProvider.GetMetadataForType(type);
    
            ModelBindingResult result;
            using (bindingContext.EnterNestedScope(metadata, bindingContext.FieldName, bindingContext.ModelName, model: null))
            {
                await binder.BindModelAsync(bindingContext);
                result = bindingContext.Result;
            }
    
            bindingContext.Result = result;
    
            return;
        }
    }
    

    And register the providers in configuraton:

    services.AddMvc(opts =>
    {
        opts.ModelBinderProviders.Insert(0, new AbstractModelBinderProvider<ActionViewModel>());
        opts.ModelBinderProviders.Insert(0, new AbstractModelBinderProvider<TriggerViewModel>());
    });
    

    It's also possible to change the AbstractModelBinderProvider to accept a parameterized collection of types to handle instead of the generic type to reduce the number of providers if there are many abstract classes to handle.

    In regards to be able to nest children there are some limitations one must be aware of.

    See: In an Editor Template call another Editor Template with the same Model

    Short answer is to use partials instead, like this:

    @model ActionViewModel
    
    @if (Model == null)
    {
        return;
    }
    
    <div class="actionRow">
        @using (Html.BeginCollectionItem("Actions"))
        {
            <input type="hidden" asp-for="Type" />
            <input type="hidden" asp-for="Id" />
    
            if (Model is CustomActionViewModel)
            {
                @Html.Partial("EditorTemplates/CustomAction", Model);
            }
    
        }
    </div>
    

    The BeginCollectionItem is a html helper.

    See: https://github.com/danludwig/BeginCollectionItem

    And: https://github.com/saad749/BeginCollectionItemCore