MVC 6 Custom Model Binder with Dependency Injection
Asked Answered
B

2

10

Right now my ViewModel looks like this:

public class MyViewModel
{
    private readonly IMyService myService;

    public ClaimantSearchViewModel(IMyService myService)
    {
        this.myService = myService;
    }
}

My Controller that consumes this ViewModel looks like this:

public class MyController : Controller
{
    private readonly IMyService myService;
    public HomeController(IMyService myService)
    {
        this.myService = myService;
    }

    public IActionResult Index()
    {
        var model = new MyViewModel(myService);

        return View(model);
    }

    [HttpPost]
    public async Task<IActionResult> Find()
    {
        var model = new MyViewModel(myService);
        await TryUpdateModelAsync(model);

        return View("Index", model);
    }
}

What I need is my Controller to look like is this:

public class MyController : Controller
{
    private readonly IServiceProvider servicePovider;
    public MyController(IServiceProvider servicePovider)
    {
        this.servicePovider = servicePovider;
    }

    public IActionResult Index()
    {
        var model = servicePovider.GetService(typeof(MyViewModel));

        return View(model);
    }

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

Right now, calling the first Index method works fine (with

builder.RegisterSource(new AnyConcreteTypeNotAlreadyRegisteredSource(x => x.Name.Contains("ViewModel")));

in my Startup class) but doing the POST to Index(MyViewModel model) gives you a No parameterless constructor defined for this object exception. I realize that a custom model binder that can use my DI will be the most likely solution... but I'm not able to find any help on how to even get started here. Please help me with this, especially for Autofac in MVC 6.

Bryantbryanty answered 25/2, 2016 at 0:34 Comment(5)
Check this answerGiovanna
Yes, I've seen that and we did finally get this working, but had to use somewhat hacky measures to get it done... to fix the hackyness we now need the answer to this question (asked by my co-worker) https://mcmap.net/q/1068920/-how-do-i-use-custom-model-binder-that-supports-dependency-injection-in-asp-net-core/550975 then we'll share our implementation hereBryantbryanty
I've also posted the question here: groups.google.com/forum/#!topic/autofac/1Zin8oh7x1EBryantbryanty
Also posted here: github.com/aspnet/Mvc/issues/4167Bryantbryanty
I managed to get the desired behavior by implementing a custom ContractResolver. See example at github.com/tjeerdhans/DiModelBindingExampleAlitaalitha
B
8

We got the answer here: https://github.com/aspnet/Mvc/issues/4167

And the answer is to use: [FromServices]

My Model ends up looking like this:

public class MyViewModel
{
    [FromServices]
    public IMyService myService { get; set; }

    public ClaimantSearchViewModel(IMyService myService)
    {
        this.myService = myService;
    }
}

Although it's sad to make that property public, it's much less sad than having to use a custom model binder.

Also, supposedly you should be able to pass [FromServices] as part of the param in the Action method, it does resolve the class, but that breaks the model binding... ie none of my properties got mapped. It looks like this: (but again, THIS DOES NOT WORK so use the above example)

public class MyController : Controller
{
    ... same as in OP

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

UPDATE 1

After working with the [FromServices] attribute we decided that property injection in all of our ViewModels was not the way we wanted to go, especially when thinking about long term maintenance with testing. SO we decided to remove the [FromServices] attributes and got our custom model binder working:

public class IoCModelBinder : IModelBinder
{
    public Task<ModelBindingResult> BindModelAsync(ModelBindingContext bindingContext)
    {
        var serviceProvider = bindingContext.OperationBindingContext.HttpContext.RequestServices;

        var model = serviceProvider.GetService(bindingContext.ModelType);
        bindingContext.Model = model;

        var binder = new GenericModelBinder();
        return binder.BindModelAsync(bindingContext);
    }
}

It's registered like this in the Startup ConfigureServices method:

        services.AddMvc().AddMvcOptions(options =>
        {
            options.ModelBinders.Clear();
            options.ModelBinders.Add(new IoCModelBinder());

        });

And that's it. (Not even sure that options.ModelBinders.Clear(); is needed.)

UPDATE 2 After going through various iterations of getting this to work (with help https://github.com/aspnet/Mvc/issues/4196), here is the final result:

public class IoCModelBinder : IModelBinder
{
    public async Task<ModelBindingResult> BindModelAsync(ModelBindingContext bindingContext)
    {   // For reference: https://github.com/aspnet/Mvc/issues/4196
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));

        if (bindingContext.Model == null && // This binder only constructs viewmodels, avoid infinite recursion.
                (
                    (bindingContext.ModelType.Namespace.StartsWith("OUR.SOLUTION.Web.ViewModels") && bindingContext.ModelType.IsClass)
                        ||
                    (bindingContext.ModelType.IsInterface)
                )
            )
        {
            var serviceProvider = bindingContext.OperationBindingContext.HttpContext.RequestServices;
            var model = serviceProvider.GetRequiredService(bindingContext.ModelType);

            // Call model binding recursively to set properties
            bindingContext.Model = model;
            var result = await bindingContext.OperationBindingContext.ModelBinder.BindModelAsync(bindingContext);

            bindingContext.ValidationState[model] = new ValidationStateEntry() { SuppressValidation = true };

            return result;
        }

        return await ModelBindingResult.NoResultAsync;
    }
}

You'd obviously want to replace OUR.SOLUTION... with whatever the namespace is for your ViewModels Our registration:

        services.AddMvc().AddMvcOptions(options =>
        {
            options.ModelBinders.Insert(0, new IoCModelBinder());
        });

UPDATE 3: This is the latest iteration of the Model Binder and its Provider that works with ASP.NET Core 2.X:

public class IocModelBinder : ComplexTypeModelBinder
{
    public IocModelBinder(IDictionary<ModelMetadata, IModelBinder> propertyBinders, ILoggerFactory loggerFactory) : base(propertyBinders, loggerFactory)
    {
    }

    protected override object CreateModel(ModelBindingContext bindingContext)
    {
        object model = bindingContext.HttpContext.RequestServices.GetService(bindingContext.ModelType) ?? base.CreateModel(bindingContext);

        if (bindingContext.HttpContext.Request.Method == "GET")
            bindingContext.ValidationState[model] = new ValidationStateEntry { SuppressValidation = true };
        return model;
    }
}

public class IocModelBinderProvider : IModelBinderProvider
{
    private readonly ILoggerFactory loggerFactory;

    public IocModelBinderProvider(ILoggerFactory loggerFactory)
    {
        this.loggerFactory = loggerFactory;
    }

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (!context.Metadata.IsComplexType || context.Metadata.IsCollectionType) return null;

        var propertyBinders = new Dictionary<ModelMetadata, IModelBinder>();
        foreach (ModelMetadata property in context.Metadata.Properties)
        {
            propertyBinders.Add(property, context.CreateBinder(property));
        }
        return new IocModelBinder(propertyBinders, loggerFactory);
    }
}

Then in Startup:

services.AddMvc(options =>
{
    // add IoC model binder.
    IModelBinderProvider complexBinder = options.ModelBinderProviders.FirstOrDefault(x => x.GetType() == typeof(ComplexTypeModelBinderProvider));
    int complexBinderIndex = options.ModelBinderProviders.IndexOf(complexBinder);
    options.ModelBinderProviders.RemoveAt(complexBinderIndex);
    options.ModelBinderProviders.Insert(complexBinderIndex, new IocModelBinderProvider(loggerFactory));
Bryantbryanty answered 26/2, 2016 at 20:17 Comment(1)
In latest ASP.NET Core 1.0, there is no OperationBindingContext on bindingContext, have you any update for latest asp.net core ?Inflated
G
0

This question is tagged with ASP.NET Core, so here's our solution for dotnet core 3.1.

Outline of our solution: TheProject needs to make ICustomerService available to an object created automatically in the request pipeline. Classes that need this are tagged with an interface, IUsesCustomerService. This interface is then checked by the Binder on object creation, and special case is handled.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;

namespace TheProject.Infrastructure.DependencyInjection
{
    /// <summary>
    /// This is a simple pass through class to the binder class.
    /// It gathers some information from the context and passes it along.
    /// </summary>
    public class TheProjectModelBinderProvider : IModelBinderProvider
    {
        public TheProjectModelBinderProvider()
        {
        }

        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            ILoggerFactory ilogger;

            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // The Binder that gets returned is a <ComplexTypeModelBinder>, but I'm
            // not sure what side effects returning early here might cause.
            if (!context.Metadata.IsComplexType || context.Metadata.IsCollectionType)
            {
                return null;
            }

            var propertyBinders = new Dictionary<ModelMetadata, IModelBinder>();
            foreach (ModelMetadata property in context.Metadata.Properties)
            {
                propertyBinders.Add(property, context.CreateBinder(property));
            }

            ilogger = (ILoggerFactory)context.Services.GetService(typeof(ILoggerFactory));

            return new TheProjectModelBinder(propertyBinders, ilogger);
        }
    }
    
    /// <summary>
    /// Custom model binder.
    /// Allows interception of endpoint method to adjust object construction
    /// (allows automatically setting properties on an object that ASP.NET creates for the endpoint).
    /// Here this is used to make sure the <see cref="ICustomerService"/> is set correctly.
    /// </summary>
    public class TheProjectModelBinder : ComplexTypeModelBinder
    {
        public TheProjectModelBinder(IDictionary<ModelMetadata, IModelBinder> propertyBinders, ILoggerFactory loggerFactory)
            : base(propertyBinders, loggerFactory)
        {
        }

        /// <summary>
        /// Method to construct an object. This normally calls the default constructor.
        /// This method does not set property values, setting those are handled elsewhere in the pipeline,
        /// with the exception of any special properties handled here.
        /// </summary>
        /// <param name="bindingContext">Context.</param>
        /// <returns>Newly created object.</returns>
        protected override object CreateModel(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
                throw new ArgumentNullException(nameof(bindingContext));

            var customerService = (ICustomerService)bindingContext.HttpContext.RequestServices.GetService(typeof(ICustomerService));
            bool setcustomerService = false;

            object model;

            if (typeof(IUsesCustomerService).IsAssignableFrom(bindingContext.ModelType))
            {
                setcustomerService = true;
            }
            
            // I think you can also just call Activator.CreateInstance here.
            // The end result is an object that's constructed, but no properties are set yet.
            model = base.CreateModel(bindingContext);

            if (setcustomerService)
            {
                ((IUsesCustomerService)model).SetcustomerService(customerService);
            }

            return model;
        }
    }
}

Then in the startup code, make sure to set AddMvcOptions.

public void ConfigureServices(IServiceCollection services)
{
    // ...
    
    // asp.net core 3.1 MVC setup 
    services.AddControllersWithViews()
        .AddApplicationPart(assembly)
        .AddRazorRuntimeCompilation()
        .AddMvcOptions(options =>
        {
            IModelBinderProvider complexBinder = options.ModelBinderProviders.FirstOrDefault(x => x.GetType() == typeof(ComplexTypeModelBinderProvider));
            int complexBinderIndex = options.ModelBinderProviders.IndexOf(complexBinder);
            options.ModelBinderProviders.RemoveAt(complexBinderIndex);
            options.ModelBinderProviders.Insert(complexBinderIndex, new Infrastructure.DependencyInjection.TheProjectModelBinderProvider());
        });
}
Genous answered 5/2, 2021 at 0:33 Comment(1)
what is assembly here? services.AddControllersWithViews() .AddApplicationPart(assembly)Metabolic

© 2022 - 2024 — McMap. All rights reserved.