24

In MVC-5 I could edit the routetable after initial startup by accessing RouteTable.Routes. I wish to do the same in MVC-6 so I can add/delete routes during runtime (usefull for CMS).

The code to do it in MVC-5 is:

using (RouteTable.Routes.GetWriteLock())
{
    RouteTable.Routes.Clear();

    RouteTable.Routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    RouteTable.Routes.MapRoute(
        name: "Default",
        url: "{controller}/{action}/{id}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
}

But I can't find RouteTable.Routes or something similar in MVC-6. Any idea how I can still change the route collection during runtime?


I want to use this principle to add, for example, an extra url when a page is created in the CMS.

If you have a class like:

public class Page
{
    public int Id { get; set; }
    public string Url { get; set; }
    public string Html { get; set; }
}

And a controller like:

public class CmsController : Controller
{
    public ActionResult Index(int id)
    {
        var page = DbContext.Pages.Single(p => p.Id == id);
        return View("Layout", model: page.Html);
    }
}

Then when a page is added to the database I recreate the routecollection:

var routes = RouteTable.Routes;
using (routes.GetWriteLock())
{
    routes.Clear();
    foreach(var page in DbContext.Pages)
    {
        routes.MapRoute(
            name: Guid.NewGuid().ToString(),
            url: page.Url.TrimEnd('/'),
            defaults: new { controller = "Cms", action = "Index", id = page.Id }
        );
    }

    var defaultRoute = routes.MapRoute(
        name: "Default",
        url: "{controller}/{action}/{id}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
}

In this way I can add pages to the CMS that do not belong in conventions or strict templates. I can add a page with url /contact, but also a page with url /help/faq/how-does-this-work.

NightOwl888
  • 55,572
  • 24
  • 139
  • 212
SynerCoder
  • 12,493
  • 4
  • 47
  • 78
  • have you tried taking a dependency on IRouteBuilder so it would be injected into your class, and then maybe you could use that to add routes the same as in startup? I don't know if that will work but that is what I would try. – Joe Audette Sep 14 '15 at 14:52
  • @JoeAudette No, because I did look a bit into the source code, the routebuilder is just used once to 'build' the route, after which it is discarded. So after startup, the use of the builder is gone – SynerCoder Sep 14 '15 at 16:20
  • Take a look at the following sample where a custom convention is used to alter the routes: https://github.com/aspnet/Entropy/tree/dev/samples/Mvc.CustomRoutingConvention – Kiran Sep 14 '15 at 18:30
  • @KiranChalla Not good, that is just another way to define routing at the start, I want to change routing at runtime – SynerCoder Sep 14 '15 at 18:43
  • @SynerCoder: Curious, what is the scenario you are trying? – Kiran Sep 14 '15 at 18:48
  • @KiranChalla added use case – SynerCoder Sep 14 '15 at 18:56
  • Maybe it's a stupid from architectural point of view but if this is so important for the system and you don't have another way, why not store `IRouteBuilder routes` variable as global when it's first used in `Startup.cs`? – Viktor Bahtev Sep 14 '15 at 20:14
  • @ViktorBahtev That wont work. The RouteBuilder is used by mvc once to initiate the route, after that it is discarded and no longer used. So even if I have access to the route builder and change the routes, the changes wont be applied to the application since it is already running. – SynerCoder Sep 14 '15 at 20:43
  • @ViktorBahtev your suggestion is practically the same as the one from Joe Audette. And as I stated there and in my previous comment. That wont work. – SynerCoder Sep 14 '15 at 21:20
  • @KiranChalla Any idea how I can do this in MVC6? Is there a class like the RouteTable class? – SynerCoder Sep 15 '15 at 08:11

2 Answers2

15

The answer is that there is no reasonable way to do this, and even if you find a way it would not be a good practice.

An Incorrect Approach to the Problem

Basically, the route configuration of MVC versions past was meant to act like a DI configuration - that is, you put everything there in the composition root and then use that configuration during runtime. The problem was that you could push objects into the configuration at runtime (and many people did), which is not the right approach.

Now that the configuration has been replaced by a true DI container, this approach will no longer work. The registration step can now only be done at application startup.

The Correct Approach

The correct approach to customizing routing well beyond what the Route class could do in MVC versions past was to inherit RouteBase or Route.

AspNetCore (formerly known as MVC 6) has similar abstractions, IRouter and INamedRouter that fill the same role. Much like its predecessor, IRouter has just two methods to implement.

namespace Microsoft.AspNet.Routing
{
    public interface IRouter
    {
        // Derives a virtual path (URL) from a list of route values
        VirtualPathData GetVirtualPath(VirtualPathContext context);

        // Populates route data (including route values) based on the
        // request
        Task RouteAsync(RouteContext context);
    }
}

This interface is where you implement the 2-way nature of routing - URL to route values and route values to URL.

An Example: CachedRoute<TPrimaryKey>

Here is an example that tracks and caches a 1-1 mapping of primary key to URL. It is generic and I have tested that it works whether the primary key is int or Guid.

There is a pluggable piece that must be injected, ICachedRouteDataProvider where the query for the database can be implemented. You also need to supply the controller and action, so this route is generic enough to map multiple database queries to multiple action methods by using more than one instance.

using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

public class CachedRoute<TPrimaryKey> : IRouter
{
    private readonly string _controller;
    private readonly string _action;
    private readonly ICachedRouteDataProvider<TPrimaryKey> _dataProvider;
    private readonly IMemoryCache _cache;
    private readonly IRouter _target;
    private readonly string _cacheKey;
    private object _lock = new object();

    public CachedRoute(
        string controller, 
        string action, 
        ICachedRouteDataProvider<TPrimaryKey> dataProvider, 
        IMemoryCache cache, 
        IRouter target)
    {
        if (string.IsNullOrWhiteSpace(controller))
            throw new ArgumentNullException("controller");
        if (string.IsNullOrWhiteSpace(action))
            throw new ArgumentNullException("action");
        if (dataProvider == null)
            throw new ArgumentNullException("dataProvider");
        if (cache == null)
            throw new ArgumentNullException("cache");
        if (target == null)
            throw new ArgumentNullException("target");

        _controller = controller;
        _action = action;
        _dataProvider = dataProvider;
        _cache = cache;
        _target = target;

        // Set Defaults
        CacheTimeoutInSeconds = 900;
        _cacheKey = "__" + this.GetType().Name + "_GetPageList_" + _controller + "_" + _action;
    }

    public int CacheTimeoutInSeconds { get; set; }

    public async Task RouteAsync(RouteContext context)
    {
        var requestPath = context.HttpContext.Request.Path.Value;

        if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
        {
            // Trim the leading slash
            requestPath = requestPath.Substring(1);
        }

        // Get the page id that matches.
        TPrimaryKey id;

        //If this returns false, that means the URI did not match
        if (!GetPageList().TryGetValue(requestPath, out id))
        {
            return;
        }

        //Invoke MVC controller/action
        var routeData = context.RouteData;

        // TODO: You might want to use the page object (from the database) to
        // get both the controller and action, and possibly even an area.
        // Alternatively, you could create a route for each table and hard-code
        // this information.
        routeData.Values["controller"] = _controller;
        routeData.Values["action"] = _action;

        // This will be the primary key of the database row.
        // It might be an integer or a GUID.
        routeData.Values["id"] = id;

        await _target.RouteAsync(context);
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        VirtualPathData result = null;
        string virtualPath;

        if (TryFindMatch(GetPageList(), context.Values, out virtualPath))
        {
            result = new VirtualPathData(this, virtualPath);
        }

        return result;
    }

    private bool TryFindMatch(IDictionary<string, TPrimaryKey> pages, IDictionary<string, object> values, out string virtualPath)
    {
        virtualPath = string.Empty;
        TPrimaryKey id;
        object idObj;
        object controller;
        object action;

        if (!values.TryGetValue("id", out idObj))
        {
            return false;
        }

        id = SafeConvert<TPrimaryKey>(idObj);
        values.TryGetValue("controller", out controller);
        values.TryGetValue("action", out action);

        // The logic here should be the inverse of the logic in 
        // RouteAsync(). So, we match the same controller, action, and id.
        // If we had additional route values there, we would take them all 
        // into consideration during this step.
        if (action.Equals(_action) && controller.Equals(_controller))
        {
            // The 'OrDefault' case returns the default value of the type you're 
            // iterating over. For value types, it will be a new instance of that type. 
            // Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct), 
            // the 'OrDefault' case will not result in a null-reference exception. 
            // Since TKey here is string, the .Key of that new instance will be null.
            virtualPath = pages.FirstOrDefault(x => x.Value.Equals(id)).Key;
            if (!string.IsNullOrEmpty(virtualPath))
            {
                return true;
            }
        }
        return false;
    }

    private IDictionary<string, TPrimaryKey> GetPageList()
    {
        IDictionary<string, TPrimaryKey> pages;

        if (!_cache.TryGetValue(_cacheKey, out pages))
        {
            // Only allow one thread to poplate the data
            lock (_lock)
            {
                if (!_cache.TryGetValue(_cacheKey, out pages))
                {
                    pages = _dataProvider.GetPageToIdMap();

                    _cache.Set(_cacheKey, pages,
                        new MemoryCacheEntryOptions()
                        {
                            Priority = CacheItemPriority.NeverRemove,
                            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(this.CacheTimeoutInSeconds)
                        });
                }
            }
        }

        return pages;
    }

    private static T SafeConvert<T>(object obj)
    {
        if (typeof(T).Equals(typeof(Guid)))
        {
            if (obj.GetType() == typeof(string))
            {
                return (T)(object)new Guid(obj.ToString());
            }
            return (T)(object)Guid.Empty;
        }
        return (T)Convert.ChangeType(obj, typeof(T));
    }
}

CmsCachedRouteDataProvider

This is the implementation of the data provider that is basically what you need to do in your CMS.

public interface ICachedRouteDataProvider<TPrimaryKey>
{
    IDictionary<string, TPrimaryKey> GetPageToIdMap();
}

public class CmsCachedRouteDataProvider : ICachedRouteDataProvider<int>
{
    public IDictionary<string, int> GetPageToIdMap()
    {
        // Lookup the pages in DB
        return (from page in DbContext.Pages
                select new KeyValuePair<string, int>(
                    page.Url.TrimStart('/').TrimEnd('/'),
                    page.Id)
                ).ToDictionary(pair => pair.Key, pair => pair.Value);
    }
}

Usage

And here we add the route before the default route, and configure its options.

// Add MVC to the request pipeline.
app.UseMvc(routes =>
{
    routes.Routes.Add(
        new CachedRoute<int>(
            controller: "Cms",
            action: "Index",
            dataProvider: new CmsCachedRouteDataProvider(), 
            cache: routes.ServiceProvider.GetService<IMemoryCache>(), 
            target: routes.DefaultHandler)
        {
            CacheTimeoutInSeconds = 900
        });

    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

    // Uncomment the following line to add a route for porting Web API 2 controllers.
    // routes.MapWebApiRoute("DefaultApi", "api/{controller}/{id?}");
});

That's the gist of it. You could still improve things a bit.

I would personally use a factory pattern and inject the repository into the constructor of CmsCachedRouteDataProvider rather than hard coding DbContext everywhere, for example.

NightOwl888
  • 55,572
  • 24
  • 139
  • 212
  • I have to look into this, but it looks promising, maybe while I am researching this avenue I have a solution for your question :). But tonight I have guests so I will probably tomorrow that I'll check it out. But for now, looks very promissing. +1 – SynerCoder Sep 15 '15 at 17:04
  • 3
    @SynerCoder - I got a response why my code was failing, and have update my answer. – NightOwl888 Sep 16 '15 at 19:06
  • 1
    @nightowl888 I know this is old, but "context.isBound" and "context.IsHandled" do not exist in 1.1 (and maybe 1). Do you know what they were replaced with (if anything)? – nokturnal Jan 16 '17 at 16:46
  • I am trying to understand your code but not getting some things.can you please help me to understand it?? – I Love Stackoverflow Jun 06 '17 at 06:33
  • 1
    I have updated the answer to work with .NET Core 2.0. Let me know if there is anything that still doesn't make sense. – NightOwl888 Jan 06 '18 at 21:49
0

One simple way is if 404 error occured then you check the this quastion:

If URL exists in the routing list then redirect to that

sample .net core go to Startup.cs(project root) and then in Configure method add bottom code:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env){
    app.UseStatusCodePages(async context =>
                    {
                        var redirctPage = pageToRedirect(context);
                        context.HttpContext.Response.Redirect(redirctPage);

                    }
...
}
    private string pageToRedirect(StatusCodeContext context)
    {
        var def = "";
        if(context.HttpContext.Response.StatusCode==404){
            if (context.HttpContext.Request.Path.ToString().ToLower().Contains("/product/"))
            {
                def = "/Home/Product";
                def += context.HttpContext.Request.QueryString;
            }
            else if (context.HttpContext.Request.Path.ToString().ToLower()=="/news")//or you can call class that load info from DB to redirect
            {
                def = "/Home/News";
                def += context.HttpContext.Request.QueryString;
            }
            else//404 error page
                def = "/Home/Error?statusCode=" + context.HttpContext.Response.StatusCode;
        }else //other errors code
            def = "/Home/Error?statusCode=" + context.HttpContext.Response.StatusCode;

        return def;
    }
Ali Rasouli
  • 1,705
  • 18
  • 25