Dynamic controller routing in ASP.NET Core 3.0

Β· 1283 words Β· 7 minutes to read

One of the great extensibility points in the routing feature of the older versions of the ASP.NET MVC and ASP.NET Core MVC frameworks was the ability to pick up any route and dynamically point it at a given controller/action.

This had a lot of excellent use cases - as we will briefly see - and since ASP.NET Core 3.0 Preview 7, the feature is actually finally available in ASP.NET Core 3.0 (despite not being mentioned in the official release blog post).

So, let’s have a look at dynamic routing in ASP.NET Core 3.0 together!

Background πŸ”—

In a typical set up, MVC routes would be defined statically using route attributes:

When using this approach, each route has to be manually declared.

public class HomeController : Controller
{
   [Route("")]
   [Route("Home")]
   [Route("Home/Index")]
   public IActionResult Index()
   {
      return View();
   }
}

Alternatively, you can use the centralized routing model, which doesn’t require you to explicitly declare every route - the routes are “alive” automatically for all the discovered controllers. The pre-requisite is, however, that these controllers are there in the first place.

This is shown below, using the new syntax relevant for ASP.NET Core 3.0 and its endpoint routing approach.

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
    });

What both of these approaches have in common, is that the routes are known at application start up time. However, what if you want to be able to dynamically define routes - and add/remove them as the application is running?

There could be several use cases for this - we will name just a few and let your imagination (or, well, business needs) fill in the rest. For example:

  • route translation and changes to those translated routes, as well as roll out of new languagues
  • CMS type of applications where “pages” can be added without needing to create new controllers or to hardcode new routes in the source code
  • multi tenant applications where tenant routes can be activated/deactivated at any point

The process of handling this should actually be fairly understandable. You’d want to “intercept” the route handling early, check the current route values that have been resolved for it and “transform” them, using for example data from a database, into a new set of route values, such values, that point to an actually existing controller.

Sample problem - translated routes πŸ”—

In the older versions of ASP.NET Core MVC you’d typically solve this problem with a custom IRouter - this however is not supported anymore in ASP.NET Core 3.0, where routing is handled via the aforementioned endpoint routing. Thankfully, there is support for our scenarios - since ASP.NET Core 3.0 Preview 7 - via a new feature called MapDynamicControllerRoute and an extensibility point called DynamicRouteValueTransformer. Let’s have a look at a concrete example.

Imagine you have a single OrdersController and you’d want to support multiple translations for it:

public class OrdersController : Controller
{
    public IActionResult List()
    {
        return View();
    }
}

How could this look like? For example:

  • for English - /en/orders/list
  • for German - /de/bestellungen/liste
  • for Polish - /pl/zamowienia/lista

Route translations with dynamic routing πŸ”—

So how do we solve this now? Instead of using the default MVC route, we can use the new feature MapDynamicControllerRoute, and point it at our custom DynamicRouteValueTransformer, which will run the route value transformation that we mentioned earlier.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Latest);

        services.AddSingleton<TranslationTransformer>();
        services.AddSingleton<TranslationDatabase>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapDynamicControllerRoute<TranslationTransformer>("{language}/{controller}/{action}");
        });
    }
}

Our TranslationTransformer, which is a subclass of DynamicRouteValueTransformer, will be responsible for converting the incoming language-specific route values, which would normally not match to anything in our application (after all, we don’t have controller names in Polish or German), into a route value dictionary that would match a controller/action in our application. So, to make it clearer, in German case it will transform the controller name “Bestellungen” into “Orders” and action name “Liste” into “List”.

TranslationTransformer is passed as a generic type parameter into the MapDynamicControllerRoute, and it must be registered into the DI container, as it will be resolved from there by the framework. At that point, we also register something called a TranslationDatabase into the DI, but that’s just our custom demo helper - we will get to that later.

public class TranslationTransformer : DynamicRouteValueTransformer
{
    private readonly TranslationDatabase _translationDatabase;

    public TranslationTransformer(TranslationDatabase translationDatabase)
    {
        _translationDatabase = translationDatabase;
    }

    public override async ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values)
    {
        if (!values.ContainsKey("language") || !values.ContainsKey("controller") || !values.ContainsKey("action")) return values;

        var language = (string)values["language"];
        var controller = await _translationDatabase.Resolve(language, (string)values["controller"]);
        if (controller == null) return values;
        values["controller"] = controller;

        var action = await _translationDatabase.Resolve(language, (string)values["action"]);
        if (action == null) return values;
        values["action"] = action;

        return values;
    }
}

In the transformer, we try to pick up our 3 route segments - language, controller and action, and then try to locate their translations (or “mappings”) in a hypothetical translation DB. As we already mentioned, you’d typically want to drive stuff like this from a DB as that would allow you to dynamically influence routes at any point in the application lifetime. To illustrate this, we use the TranslationDatabase helper I mentioned briefly later. It’s here for demo purposes only - I will show it for the record next - though the code is not that important, instead, please close your eyes and imagine that it represents a real database connection.

public class TranslationDatabase
{
    private static Dictionary<string, Dictionary<string, string>> Translations = new Dictionary<string, Dictionary<string, string>>
    {
        {
            "en", new Dictionary<string, string>
            {
                { "orders", "orders" },
                { "list", "list" }
            }
        },
        {
            "de", new Dictionary<string, string>
            {
                { "bestellungen", "orders" },
                { "liste", "list" }
            }
        },
        {
            "pl", new Dictionary<string, string>
            {
                { "zamowienia", "order" },
                { "lista", "list" }
            }
        },
    };

    public async Task<string> Resolve(string lang, string value)
    {
        var normalizedLang = lang.ToLowerInvariant();
        var normalizedValue = value.ToLowerInvariant();
        if (Translations.ContainsKey(normalizedLang) && Translations[normalizedLang].ContainsKey(normalizedValue))
        {
            return Translations[normalizedLang][normalizedValue];
        }

        return null;
    }
}

At this point, we are pretty much there! With such a setup in your MVC app, you can start issuing requests to each of the three route definitions we mentioned earlier and call:

  • for English - /en/orders/list
  • for German - /de/bestellungen/liste
  • for Polish - /pl/zamowienia/lista

and in each case we will end up in our OrdersController and List action. Of course you can scale that approach further to many other controllers too. On top of that, changing anything, rolling out a new language or mapping a new route alias to the same controller/action in an already existing language is just a database-level change, which wouldn’t require any application changes or even a restart.

Please note that in this article we only focused on translating routes as an example of a feature you may want to build using the new dynamic controller functionality of ASP.NET Core 3.0. If you wish to fully implement localization in your app you may want to read the localization guide too, as you’d likely want to correctly set the CurrentCulture based on the route value for our language.

Finally, I also wanted to mentioned that the example we looked at, explicitly used {controller} and {action} placeholders in the route template. This is not mandatory - in other scenarios you could have defined a general purpose “catch-all” route - and transform that into controller/action route values.

Such a catch-all route is a typical solution in CMS systems, where you have different dynamic “pages” - and it could look like this:

endpoints.MapDynamicControllerRoute<PageTransformer>("pages/{**slug}");

Then you have to transform the entire URL portion after /pages into something that represents an existing executable controller - typically also using URL/route mapping from a database.

Hopefully you will find this article useful - all the demo source code is, as usually, available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP