8

I am trying to design a modular Web API application (It is not an MVC app!) in which a user in admin role can add or remove modules without restarting the ASP.NET application.

  • Module: each module is an assembly (.dll file) that contains at least one class which is derived from ApiController.
  • Routing is based on Attribute Routing in ASP.NET Web API 2
  • Production of modules (assemblies) is not in the scope of this question.
  • Modules (assembly files) are copied to / deleted from `~/plugins/ folder in the root of the project. This process is not in the scope of this question either.
  • The main ASP.NET Web API project has basically only one controller to manage (add/remove) modules. The other controllers will be added as modules.

So the only controller in the main Web API project is:

[RoutePrefix("api/modules")]
public class ModulesController : ApiController
{
    private ModuleService _moduleService = new ModuleService();

    // GET: api/Modules
    [Route]
    public IEnumerable<string> Get()
    {
        return _moduleService.Get().Select(a => a.FullName);
    }

    // POST: api/Modules/{moduleName}
    [Route("{id}")]
    public void Post(string id)
    {
        Assembly _assembly;
        var result = _moduleService.TryLoad(id, out _assembly);

        if(!result) throw new Exception("problem loading " + id);

        // Refresh routs or add the new rout
        Configuration.Routes.Clear();
        Configuration.MapHttpAttributeRoutes();
        // ^ it does not work :(
    }

    // DELETE: api/Modules/{moduleName}
    [Route("{id}")]
    public void Delete(string id)
    {
        _moduleService.Remove(id);
    }
}

ModuleService.TryLoad() simply finds and loads the assembly to the application domain by using AppDomain.CurrentDomain.Load(). This part is working well.

Configuration.MapHttpAttributeRoutes() doesn't raise any error, but it breaks the whole routing system. After that line, any routing attempt causes this error:

The object has not yet been initialized. Ensure that HttpConfiguration.EnsureInitialized() is called in the application's startup code after all other initialization code.

I added HttpConfiguration.EnsureInitialized() to the code, but it didn't resolve the problem (same error).

Questions

  1. Does this design make any sense? Will it work?
  2. How can I add a new route to the route collection or refresh the route collection completely?
Tohid
  • 6,175
  • 7
  • 51
  • 80
  • 1
    Try to call `Configuration.Initializer(Configuration);` – Aleksey L. Aug 23 '16 at 06:18
  • @AlekseyL. Thank you. It resolved the error. But it seems that `Configuration.MapHttpAttributeRoutes()` doesn't pick the routes from the other assembly. It just searches the route inside the main project. I am looking to see if there is any overload fir it that we can pass the assembly name or path. – Tohid Aug 23 '16 at 14:50
  • This should help: http://stackoverflow.com/questions/25915205/web-api-2-is-it-possible-to-load-a-route-controller-programmatically – Tohid Aug 23 '16 at 17:11
  • That SO question still has error thrown. Where you able to add/register the controller and the associated routes successfully? – alltej Aug 24 '16 at 15:49
  • @alltej turns out that Web API uses `BuildManager` that collect all of the DLLs from `/bin` folder at the start-up time. Unlike `AppDomain.CurrentDomain`, it is not possible to add or load new DLL to `BuildManager` after the Web API has been loaded. So I guess this is a dead-end :( If somebody wants to restart the Web API app after adding modules, it's doable, but my intention was not to restart the application. – Tohid Aug 24 '16 at 15:58
  • Have you look into MEF ( Managed Extensibility Framework) to solve your problem? – alltej Aug 24 '16 at 16:00
  • @alltej yes. but I don't like it. MEF pollutes the code with too many attributes. The intention here is to develop a core application (like a host) that accept pluggable Web APIs. I had developed kinda the same thing with MVC 4 which loads all the assemblies (core + modules) at the start up (from different folders). I hoped I could do it with a Web API application, this time in a way that adding a new assembly doesn't need restarting the whole app. Unfortunately, I can't see a way to do so. I haven't looked at ASP.NET Core, hope this would be doable there. – Tohid Aug 24 '16 at 16:25
  • I read this, but can't understand it quite well: http://www.dotnetcurry.com/aspnet/898/inject-dynamic-runtime-controllers-libraries-aspnet-webapi – Tohid Aug 24 '16 at 16:48
  • Actually, I found a way to deliver the designer functionality by replacing the `IHttpControllerSelector`. I will post the answer + codes later. :) – Tohid Aug 24 '16 at 22:16

1 Answers1

9

I solved it.

Firstly, and thanks to @Aleksey L., a little change to the ModuleController (adding Configuration.Initializer(Configuration)):

[RoutePrefix("api/modules")]
public class ModulesController : ApiController
{
    private ModuleService _moduleService = new ModuleService();

    // Other codes

    public void Post(string id)
    {
        _moduleService.Load(id);

        Configuration.Routes.Clear();
        Configuration.MapHttpAttributeRoutes();
        Configuration.Initializer(Configuration);
    }

    // Other codes
}

Then we should extend DefaultHttpControllerSelector:

public class ModularHttpControllerSelector : DefaultHttpControllerSelector
{
    private readonly HttpConfiguration _configuration;

    public ModularHttpControllerSelector(HttpConfiguration configuration)
        : base(configuration)
    {
        _configuration = configuration;
    }

    public override IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
    {
        var result = base.GetControllerMapping();
        AddPluginsControllerMapping(ref result);
        return result;
    }

    private void AddPluginsControllerMapping(ref IDictionary<string, HttpControllerDescriptor> controllerMappings)
    {
        var custom_settings = _getControllerMapping();

        foreach (var item in custom_settings)
        {
            if (controllerMappings.ContainsKey(item.Key))
                controllerMappings[item.Key] = item.Value;
            else
                controllerMappings.Add(item.Key, item.Value);
        }
    }

    private ConcurrentDictionary<string, HttpControllerDescriptor> _getControllerMapping()
    {
        var result = new ConcurrentDictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
        var duplicateControllers = new HashSet<string>();
        Dictionary<string, ILookup<string, Type>> controllerTypeGroups = GetControllerTypeGroups();

        foreach (KeyValuePair<string, ILookup<string, Type>> controllerTypeGroup in controllerTypeGroups)
        {
            string controllerName = controllerTypeGroup.Key;

            foreach (IGrouping<string, Type> controllerTypesGroupedByNs in controllerTypeGroup.Value)
            {
                foreach (Type controllerType in controllerTypesGroupedByNs)
                {
                    if (result.Keys.Contains(controllerName))
                    {
                        duplicateControllers.Add(controllerName);
                        break;
                    }
                    else
                    {
                        result.TryAdd(controllerName, new HttpControllerDescriptor(_configuration, controllerName, controllerType));
                    }
                }
            }
        }

        foreach (string duplicateController in duplicateControllers)
        {
            HttpControllerDescriptor descriptor;
            result.TryRemove(duplicateController, out descriptor);
        }

        return result;
    }

    private Dictionary<string, ILookup<string, Type>> GetControllerTypeGroups()
    {
        IAssembliesResolver assembliesResolver = new DefaultAssembliesResolver(); //was: _configuration.Services.GetAssembliesResolver();
        IHttpControllerTypeResolver controllersResolver = new DefaultHttpControllerTypeResolver(); //was: _configuration.Services.GetHttpControllerTypeResolver();

        ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);
        var groupedByName = controllerTypes.GroupBy(
            t => t.Name.Substring(0, t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length),
            StringComparer.OrdinalIgnoreCase);

        return groupedByName.ToDictionary(
            g => g.Key,
            g => g.ToLookup(t => t.Namespace ?? String.Empty, StringComparer.OrdinalIgnoreCase),
            StringComparer.OrdinalIgnoreCase);
    }
}

And of course we have to replace the default HttpControllerSelector with our HttpControllerSelector, in the App_start\WebApiConfig.cs:

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        GlobalConfiguration.Configuration.Services.Replace(
            typeof(System.Web.Http.Dispatcher.IHttpControllerSelector),
            new ModularHttpControllerSelector(config));

        config.MapHttpAttributeRoutes();
    }
}

If anybody is interested in how I implemented the ModuleService, I can upload the code to GitHub.

Here is the whole source code in the GitHub: https://github.com/tohidazizi/modular-web-api-poc

Community
  • 1
  • 1
Tohid
  • 6,175
  • 7
  • 51
  • 80
  • 1
    I would be interested how you implemented it. The one I am working is it gets loaded during start-up. Yours is pretty much dynamic which will be something nice to have as well. Thanks! – alltej Sep 06 '16 at 13:03
  • @alltej - I updated the above answer with a link to the source code on GitHub. Just remember that this code is a proof of concept, not the best implementation. It needs to be fully implemented for a better performance. For example, I haven't implement any cache. – Tohid Sep 09 '16 at 00:37
  • @Tohid: Can you tell me how I can unload the assembly when user call DELETE method? – anhtuangv Apr 16 '17 at 08:16
  • Hi @anhtuangv . Here is a good blog post by Shazwazza about how it works: https://shazwazza.com/post/developing-a-plugin-framework-in-aspnet-with-medium-trust/ It is a little complicated buy it works. The trust level of your app on the server is also important. – Tohid Apr 17 '17 at 14:53
  • @anhtuangv - ...and in case you are interested in .NET Core : https://shazwazza.com/post/custom-assembly-loading-with-aspnet-core/ – Tohid Apr 17 '17 at 14:55
  • @Tohid: Thank you very much. – anhtuangv Apr 18 '17 at 04:56