Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamic route prefix for controllers in separate library

I'm developing an MVC API in a separate class library. The API methods use attribute routing. The API will be used by other MVC applications (not built by me).

The main MVC application will reference my library assembly and call AddMvc() / UseMvc() in it's own startup class. It will be able to set the root API url's for my API library dynamically (from configuration or options setup delegate), so that it can make sure there are no conflicts with it's own routes, which can use either attribute routing or centralized routing.

So let's say my API library has a product/{id} route. The main application should be able to choose any route prefix, like api/product/{id} or some/other/prefix/product/{id}.

At startup, MVC will discover all controllers/routes in all referenced assemblies, and it will also discover and register my API library routes, but only on the hardcoded product/{id} route without any prefix.

I've been trying to get MVC to register the routes with a prefix, but so far no success. The main application will call custom AddMyApi() / UseMyApi() config methods, so I can do configuration / setup for my library. Some of the things I tried:

Mapping

app.Map("/custom-prefix", api =>
{
    api.UseMvc();
});

This will result in duplicate routes for both custom-prefix/product/{id} and product/{id}.

Route Convention

Based on http://www.strathweb.com/2016/06/global-route-prefix-with-asp-net-core-mvc-revisited/

services.AddMvc(options =>
{
    options.Conventions.Insert(0, new RouteConvention(new RouteAttribute("custom-prefix")));
});

It looks like this will not work because the options will be overwritten by the main application's call to AddMvc(), or the other way around, depending which gets called first.

Custom route attribute

A custom route attribute based on IRouteTemplateProvider on the Controller classes will not work because I need the prefix injected from an options class, and attributes do not support constructor injection.

Postpone discovery of routes

Based on http://www.strathweb.com/2015/04/asp-net-mvc-6-discovers-controllers/

I've added [NonController] to the library controllers to prevent them being discovered at the main application's startup. However I've not been able to add them later, and also I suppose I will run into the same problem of the main application overwriting the MVC options again.

Areas

I can't use areas, because the main application may decide to run the API from the root (without prefix).

So I'm stuck as to how to solve this problem. Any help is appreciated.

like image 356
Carvellis Avatar asked Feb 21 '17 10:02

Carvellis


1 Answers

I believe a convention is the right approach here and the bit you are missing is just providing the proper extension method for your library to be registered within MVC.

Start by creating a convention that will add a prefix to all controllers that pass a certain selector.

  • It is based on one I wrote for adding culture prefixes, but the idea is very similar to the article you linked.
  • Basically it will either update any existing AttributeRouteModel or add a new one if none is found.

This would be an example of such a convention:

public class ApiPrefixConvention: IApplicationModelConvention
{
    private readonly string prefix;
    private readonly Func<ControllerModel, bool> controllerSelector;
    private readonly AttributeRouteModel onlyPrefixRoute;
    private readonly AttributeRouteModel fullRoute;

    public ApiPrefixConvention(string prefix, Func<ControllerModel, bool> controllerSelector)
    {
        this.prefix = prefix;
        this.controllerSelector = controllerSelector;            

        // Prepare AttributeRouteModel local instances, ready to be added to the controllers

        //  This one is meant to be combined with existing route attributes
        onlyPrefixRoute = new AttributeRouteModel(new RouteAttribute(prefix));

        //  This one is meant to be added as the route for api controllers that do not specify any route attribute
        fullRoute = new AttributeRouteModel(
            new RouteAttribute("api/[controller]"));
    }

    public void Apply(ApplicationModel application)
    {
        // Loop through any controller matching our selector
        foreach (var controller in application.Controllers.Where(controllerSelector))
        {
            // Either update existing route attributes or add a new one
            if (controller.Selectors.Any(x => x.AttributeRouteModel != null))
            {
                AddPrefixesToExistingRoutes(controller);
            }
            else
            {
                AddNewRoute(controller);
            }
        }
    }        

    private void AddPrefixesToExistingRoutes(ControllerModel controller)
    {
        foreach (var selectorModel in controller.Selectors.Where(x => x.AttributeRouteModel != null).ToList())
        {
            // Merge existing route models with the api prefix
            var originalAttributeRoute = selectorModel.AttributeRouteModel;                
            selectorModel.AttributeRouteModel =
                AttributeRouteModel.CombineAttributeRouteModel(onlyPrefixRoute, originalAttributeRoute);
        }
    }

    private void AddNewRoute(ControllerModel controller)
    {
        // The controller has no route attributes, lets add a default api convention 
        var defaultSelector = controller.Selectors.First(s => s.AttributeRouteModel == null);
        defaultSelector.AttributeRouteModel = fullRoute;
    }
} 

Now, if this was all part of an app you are writing instead of a library, you would just register it as:

services.AddMvc(opts =>
{
    var prefixConvention = new ApiPrefixConvention("api/", (c) => c.ControllerType.Namespace == "WebApplication2.Controllers.Api");
    opts.Conventions.Insert(0, prefixConvention);
});

However since you are providing a library, what you want is to provide an extension method like AddMyLibrary("some/prefix") that will take care of adding this convention and any other setup like registering required services.

So you can write an extension method for IMvcBuilder and update the MvcOptions inside that method. The nice thing is that since is an extension of IMvcBuilder, it will always be called after the default AddMvc():

public static IMvcBuilder AddMyLibrary(this IMvcBuilder builder, string prefix = "api/")
{
    // instantiate the convention with the right selector for your library.
    // Check for namespace, marker attribute, name pattern, whatever your prefer
    var prefixConvention = new ApiPrefixConvention(prefix, (c) => c.ControllerType.Namespace == "WebApplication2.Controllers.Api");

    // Insert the convention within the MVC options
    builder.Services.Configure<MvcOptions>(opts => opts.Conventions.Insert(0, prefixConvention));

    // perform any extra setup required by your library, like registering services

    // return builder so it can be chained
    return builder;
}

Then you would ask users of your library to include it within their app as in:

services.AddMvc().AddMyLibrary("my/api/prefix/");
like image 61
Daniel J.G. Avatar answered Sep 30 '22 19:09

Daniel J.G.