20

I am attempting to convert this sample RouteBase implementation to work with MVC 6. I have worked out most of it by following the example in the Routing project, but I am getting tripped up on how to return the asynchronous Task from the method. I really don't care if it actually is asynchronous (cheers to anyone who can provide that answer), for now I just want to get it functioning.

I have the outgoing routes functioning (meaning ActionLink works fine when I put in the route values). The problem is with the RouteAsync method.

public 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 that matches.
    var page = GetPageList()
        .Where(x => x.VirtualPath.Equals(requestPath))
        .FirstOrDefault();

    // If we got back a null value set, that means the URI did not match
    if (page != null)
    {
        var routeData = new RouteData();

        // This doesn't work
        //var routeData = new RouteData(context.RouteData);

        // This doesn't work
        //routeData.Routers.Add(this);

        // This doesn't work
        //routeData.Routers.Add(new MvcRouteHandler());

        // 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"] = "CustomPage";
        routeData.Values["action"] = "Details";

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

        context.RouteData = routeData;

        // When there is a match, the code executes to here
        context.IsHandled = true; 

        // This test works
        //await context.HttpContext.Response.WriteAsync("Hello there");

        // This doesn't work
        //return Task.FromResult(routeData);

        // This doesn't work
        //return Task.FromResult(context);
    }

    // This satisfies the return statement, but 
    // I'm not sure it is the right thing to return.
    return Task.FromResult(0);
}

The entire method runs all the way through to the end when there is a match. But when it is done executing, it doesn't call the Details method of the CustomPage controller, as it should. I just get a blank white page in the browser.

I added the WriteAsync line as was done in this post and it writes Hello there to the blank page, but I can't understand why MVC isn't calling my controller (in previous versions this worked without a hitch). Unfortunately, that post covered every part of routing except for how to implement an IRouter or INamedRouter.

How can I make the RouteAsync method function?

Entire CustomRoute Implementation

using Microsoft.AspNet.Routing;
using Microsoft.Framework.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public class PageInfo
{
    // VirtualPath should not have a leading slash
    // example: events/conventions/mycon
    public string VirtualPath { get; set; }
    public int Id { get; set; }
}

public interface ICustomRoute : IRouter
{ }


public class CustomRoute : ICustomRoute
{
    private readonly IMemoryCache cache;
    private object synclock = new object();

    public CustomRoute(IMemoryCache cache)
    {
        this.cache = cache;
    }

    public 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 that matches.
        var page = GetPageList()
            .Where(x => x.VirtualPath.Equals(requestPath))
            .FirstOrDefault();

        // If we got back a null value set, that means the URI did not match
        if (page != null)
        {
            var routeData = new 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"] = "CustomPage";
            routeData.Values["action"] = "Details";

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

            context.RouteData = routeData;
            context.IsHandled = true; 
        }

        return Task.FromResult(0);
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        VirtualPathData result = null;
        PageInfo page = null;

        // Get all of the pages from the cache.
        var pages = GetPageList();

        if (TryFindMatch(pages, context.Values, out page))
        {
            result = new VirtualPathData(this, page.VirtualPath);
            context.IsBound = true;
        }

        return result;
    }

    private bool TryFindMatch(IEnumerable<PageInfo> pages, IDictionary<string, object> values, out PageInfo page)
    {
        page = null;
        int id;
        object idObj;
        object controller;
        object action;

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

        id = Convert.ToInt32(idObj);
        values.TryGetValue("controller", out controller);
        values.TryGetValue("action", out action);

        // The logic here should be the inverse of the logic in 
        // GetRouteData(). 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("Details") && controller.Equals("CustomPage"))
        {
            page = pages
                .Where(x => x.Id.Equals(id))
                .FirstOrDefault();
            if (page != null)
            {
                return true;
            }
        }
        return false;
    }

    private IEnumerable<PageInfo> GetPageList()
    {
        string key = "__CustomPageList";
        IEnumerable<PageInfo> pages;

        // Only allow one thread to poplate the data
        if (!this.cache.TryGetValue(key, out pages))
        {
            lock (synclock)
            {
                if (!this.cache.TryGetValue(key, out pages))
                {
                    // TODO: Retrieve the list of PageInfo objects from the database here.
                    pages = new List<PageInfo>()
                    {
                        new PageInfo() { Id = 1, VirtualPath = "somecategory/somesubcategory/content1" },
                        new PageInfo() { Id = 2, VirtualPath = "somecategory/somesubcategory/content2" },
                        new PageInfo() { Id = 3, VirtualPath = "somecategory/somesubcategory/content3" }
                    };

                    this.cache.Set(key, pages,
                        new MemoryCacheEntryOptions()
                        {
                            Priority = CacheItemPriority.NeverRemove,
                            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
                        });
                }
            }
        }

        return pages;
    }
}

CustomRoute DI Registration

services.AddTransient<ICustomRoute, CustomRoute>();

MVC Route Configuration

// Add MVC to the request pipeline.
app.UseMvc(routes =>
{
    routes.Routes.Add(routes.ServiceProvider.GetService<ICustomRoute>());

    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?}");
});

In case it matters I am using Beta 5, DNX 4.5.1 and DNX Core 5.

Solution

I created a generic solution that can be used for a simple primary key to URL 2-way mapping in this answer based on the information I learned here. The controller, action, data provider, and datatype of the primary key can be specified when wiring it into MVC 6 routing.

Community
  • 1
  • 1
NightOwl888
  • 55,572
  • 24
  • 139
  • 212

2 Answers2

7

As @opiants said, the problem is that you are doing nothing in your RouteAsync method.

If your intention is to end up calling a controller action method, you could use the following approach than the default MVC routes:

By default MVC uses a TemplateRoute with an inner target IRouter. In RouteAsync, the TemplateRoute will delegate to the inner IRouter. This inner router is being set as the MvcRouteHandler by the default builder extensions. In your case, start by adding an IRouter as your inner target:

public class CustomRoute : ICustomRoute
{
    private readonly IMemoryCache cache;
    private readonly IRouter target;
    private object synclock = new object();

    public CustomRoute(IMemoryCache cache, IRouter target)
    {
        this.cache = cache;
        this.target = target;
    }

Then update your startup to set that target as the MvcRouteHandler, which has already been set as routes.DefaultHandler:

app.UseMvc(routes =>
{
    routes.Routes.Add(
       new CustomRoute(routes.ServiceProvider.GetRequiredService<IMemoryCache>(), 
                       routes.DefaultHandler));

    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?}");
});

Finally, update your AsyncRoute method to call the inner IRouter, which would be the MvcRouteHandler. You can use the implementation of that method in TemplateRoute as a guide. I have quickly used this approach and modified your method as follows:

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 that matches.
    var page = GetPageList()
        .Where(x => x.VirtualPath.Equals(requestPath))
        .FirstOrDefault();

    // If we got back a null value set, that means the URI did not match
    if (page == null)
    {
        return;
    }


    //Invoke MVC controller/action
    var oldRouteData = context.RouteData;
    var newRouteData = new RouteData(oldRouteData);
    newRouteData.Routers.Add(this.target);

    // 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.
    newRouteData.Values["controller"] = "CustomPage";
    newRouteData.Values["action"] = "Details";

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

    try
    {
        context.RouteData = newRouteData;
        await this.target.RouteAsync(context);
    }
    finally
    {
        // Restore the original values to prevent polluting the route data.
        if (!context.IsHandled)
        {
            context.RouteData = oldRouteData;
        }
    }
}

Update RC2

Looks like TemplateRoute is no longer around in RC2 aspnet Routing.

I investigated the history, and it was renamed RouteBase in commit 36180ab as part of a bigger refactoring.

Daniel J.G.
  • 34,266
  • 9
  • 112
  • 112
  • Yeah I thought about having an inner IRouter as well but I don't think you need that. Setting the context.IsHandled to false and returning early will cause it to move to the next registered IRouter and will ultimately fallback to the routes.DefaultHandler (which is the MvcRouteHandler) – Dealdiane Sep 16 '15 at 13:00
  • Are you sure the DefaultHandler will be used if no route has a match? Looking at the code it seems to be used just for the [`MapRoute`](https://github.com/aspnet/Routing/blob/dev/src/Microsoft.AspNet.Routing/RouteBuilderExtensions.cs) extension method, so the MVC routes are added using TemplateRoute with an inner MvcRouteHandler – Daniel J.G. Sep 16 '15 at 13:04
  • Check also [`RouteBuilder.Build`](https://github.com/aspnet/Routing/blob/dev/src/Microsoft.AspNet.Routing/RouteBuilder.cs) which will just add each defined routes, but not the default handler – Daniel J.G. Sep 16 '15 at 13:05
  • @DanielJ.G. - That makes more sense. I was kind of wondering why the `MvcRouteHandler` was not part of the equation like it was in versions past. Not to mention, it didn't seem to make much sense to re-implement everything it already does. I will give it a try. – NightOwl888 Sep 16 '15 at 13:15
  • For me this was confusing at the beggining because MvcRouteHandler isn't used to match the incoming url, but to handle it. However it implements the same `IRouter` interface that you need to implement when writing a custom route with custom routing logic. So basically to implement your custom routing logic but still delegate into the MVC pipeline for serving the request, you have to create an `IRouter`, wrapping another `IRouter` which happens to be the `MvcRouteHandler`... Not sure if I explained myself! :) – Daniel J.G. Sep 16 '15 at 13:20
  • what "// Restore the original values to prevent polluting the route data." does mean? – pedrommuller Jun 23 '16 at 00:33
  • That was "inspired" by the old `TemplateRoute` class the answer was based upon. Since multiple routes might be evaluated, if your route changes the route data but doesnt end up handling the request, then make sure you leave the route data as it was before. – Daniel J.G. Jun 23 '16 at 09:47
2

Primary reason why that doesn't work is because you aren't doing anything in the RouteAsync method. Another reason is that how routing works in MVC 6 is very different to how the previous MVC routing worked so you're probably be better off writing it from scratch using the source code as reference as there are very few articles that tackle MVC 6 at the moment.

EDIT: @Daniel J.G. answer makes much more sense than this so use that if possible. This might fit someone else's use case so I'm leaving this here.

Here's a very simple IRouter implementation using beta7. This should work but you'll probably need to fill in the gaps. You'll need to remove the page != null and replace it with the code below and replace the controllers and actions:

if (page == null)
{
    // Move to next router
    return;
}

// TODO: Replace with correct controller
var controllerType = typeof(HomeController);
// TODO: Replace with correct action
var action = nameof(HomeController.Index);

// This is used to locate the razor view
// Remove the trailing "Controller" string
context.RouteData.Values["Controller"] = controllerType.Name.Substring(0, controllerType.Name.Length - 10);

var actionInvoker = context.HttpContext.RequestServices.GetRequiredService<IActionInvokerFactory>();

var descriptor = new ControllerActionDescriptor
{
    Name = action,
    MethodInfo = controllerType.GetTypeInfo().DeclaredMethods.Single(m => m.Name == action),
    ControllerTypeInfo = controllerType.GetTypeInfo(),
    // Setup filters
    FilterDescriptors = new List<FilterDescriptor>(),
    // Setup DI properties
    BoundProperties = new List<ParameterDescriptor>(0),
    // Setup action arguments
    Parameters = new List<ParameterDescriptor>(0),
    // Setup route constraints
    RouteConstraints = new List<RouteDataActionConstraint>(0),
    // This router will work fine without these props set
    //ControllerName = "Home",
    //DisplayName = "Home",
};

var accessor = context.HttpContext.RequestServices.GetRequiredService<IActionContextAccessor>();

accessor.ActionContext = new ActionContext(context.HttpContext, context.RouteData, descriptor);

var actionInvokerFactory = context.HttpContext.RequestServices.GetRequiredService<IActionInvokerFactory>();
var invoker = actionInvokerFactory.CreateInvoker(accessor.ActionContext);

// Render the page
await invoker.InvokeAsync();

// Don't execute the next IRouter
context.IsHandled = true;

return;

Make sure you add a reference to the Microsoft.Framework.DependencyInjection namespace to resolve the GetRequiredService extension.

After that, register the IRouter as per below:

app.UseMvc(routes =>
{
    // Run before any default IRouter implementation
    // or use .Add to run after all the default IRouter implementations
    routes.Routes.Insert(0, routes.ServiceProvider.GetRequiredService<CustomRoute>());

    // .. more code here ...
});

Then just register that in your IOC,

services.AddSingleton<CustomRoute>();

Another 'cleaner' approach would probably be to create a different implementation of IActionSelector.

Dealdiane
  • 3,984
  • 1
  • 24
  • 35
  • Close, but no cigar. After removing the nonsense about creating `IActionContextAccessor` only to set and then get one of its properties, I was able to invoke the method successfully. However, now I am getting a null reference exception. First 2 lines of the stack trace are: `Microsoft.AspNet.Mvc.UrlHelper..ctor(IScopedInstance`1 contextAccessor, IActionSelector actionSelector)` and `lambda_method(Closure , ServiceProvider )`. Starting to think there might be a bug. I am going to upgrade from Beta 5 to Beta 7 to see if I can make it work and if not, report it to Microsoft. – NightOwl888 Sep 16 '15 at 12:32
  • Sorry didn't read your question carefully and assumed you are on beta 7. The code above was tested in beta 7. Pretty sure that won't work on beta 5. See my edit about adding it to the routes.Routes.Insert as well. – Dealdiane Sep 16 '15 at 13:05