4

I have a situation where a site may need a link to redirect to certain controllers based on database results.

For example:

site.com/abcd

needs to return the result from a Item Controller, which would normally be called as /item/view/123

The key here is that I can't hard code the abcd into the routing. And some links may go to an Item Controller, others may go to an Orders controller.

I've tried a catchall route to a controller than then loads up the desired controller, but the environment is not set so it does not work properly (it can't find the views).

NightOwl888
  • 55,572
  • 24
  • 139
  • 212
  • Does [RedirectToAction()](https://msdn.microsoft.com/en-us/library/system.web.mvc.controller.redirecttoaction(v=vs.118).aspx) do what you want? – JosephGarrone Jan 30 '16 at 02:07
  • 1
    One controller can redirect to other controllers or even just ask them to render their content as it was its own view. Automatic routing may work but we don't know your logic behind it – Adriano Repetti Jan 30 '16 at 02:08

1 Answers1

2

You can get whatever behavior you desire by implementing IRouter as in this answer, including basing your logic on data from an external source (such as a config file or database).

This is much more flexible than a catchall route because it lets you choose the controller on the fly.

public class MyRoute : IRouter
{
    private readonly IRouter innerRouter;

    public MyRoute(IRouter innerRouter)
    {
        if (innerRouter == null)
            throw new ArgumentNullException("innerRouter");
        this.innerRouter = innerRouter;
    }

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

        if (!requestPath.StartsWith("abcd"))
        {
            return;
        }

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

        newRouteData.Values["controller"] = "Item";
        newRouteData.Values["action"] = "View";
        newRouteData.Values["id"] = 123;

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

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

        var values = context.Values;
        var controller = Convert.ToString(values["controller"]);
        var action = Convert.ToString(values["action"]);
        var id = Convert.ToString(values["id"]);

        if ("Item".Equals(controller) && "View".Equals(action))
        {
            result = new VirtualPathData(this, "abcd?id=" + id);
            context.IsBound = true;
        }

        // IMPORTANT: Always return null if there is no match.
        // This tells .NET routing to check the next route that is registered.
        return result;
    }
}

Usage

// Add MVC to the request pipeline.
app.UseMvc(routes =>
{
    routes.Routes.Add(new MyRoute(
        innerRouter: 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?}");
});

The GetVirtualPath should mirror what the RouteAsync does. RouteAsync converts a URL into route values, and the GetVirtualPath should convert the same route data back into the same URL.

The easiest way to accomplish this is to use a data structure to create a two-way mapping between these 2 data points (as in the linked answer) so you don't have to continually change the logic within these 2 methods. This data structure should be cached and not do anything too resource intensive, since every request will use it to determine where to send each URL.

Alternatively, you could create a separate route for each of your individual pieces of logic and register them all at application startup. However, you need to ensure they are registered in the correct order and that each route will only match the correct set of URLs and correct set of RouteValues.

NOTE: For a scenario such as this you should almost never need to use RedirectToAction. Keep in mind redirecting will send an HTTP 302 request to the browser, which tells it to lookup another location on your server. This is unnecessary overhead in most cases because it is much more efficient just to route the initial request to the controller you want.

Community
  • 1
  • 1
NightOwl888
  • 55,572
  • 24
  • 139
  • 212
  • Thanks for this! One question: the return of a string in GetVirtualPath is causing a build error. – John Campion Jr. Jan 30 '16 at 14:11
  • One other question - would there be a way to have this only called for the catchall route? Since I don't what /abcd could be, I only want to do the database lookup if no other routes already match the URL. – John Campion Jr. Jan 30 '16 at 14:18
  • For your first question - that was an error copying code on my part. I have corrected the answer. – NightOwl888 Jan 30 '16 at 17:35
  • For your second question - you could pass in the URL, controller, and action as constructor parameters, but if you do that you might as well be using the built in `MapRoute` method, which would do exactly the same thing. A catchall route is only for the case where you want to dump all of your unmatched requests into a specific controller. Some people put their "routing" in a controller as a result, but if you ask me, that is the wrong place to do it. See my linked answer for a pre-made database-driven route - you just need to supply a query to get the data (pkey, controller, action, url). – NightOwl888 Jan 30 '16 at 17:58
  • You just need to change the code slightly in my linked answer to pull the controller and action from the database instead of passing them into the constructor and use a class to store your controller-action-url mappings if you don't want to base it all on a primary key. – NightOwl888 Jan 30 '16 at 18:01
  • See [this answer](http://stackoverflow.com/questions/31934144/multiple-levels-in-mvc-custom-routing/31958586#31958586) for an example. It is for MVC 5, but it shows how to use a class as a map between values rather than using a dictionary as I did in my other linked answer. In your case, the class would need to be for VirtualPath, Controller, and Action. – NightOwl888 Jan 30 '16 at 18:05
  • But whatever the case, you should not put a direct database call in the route, but instead you should cache the values from the database. Routing is order specific, so your application won't work right if you just move the route to the end like a catch-all route. – NightOwl888 Jan 30 '16 at 18:08
  • I understand the need for caching and how to do the database querying from the other article. I'm trying to see if there is a way to only call this route if no other route matches the url. I don't want this called except as a last resort if nothing else matches. – John Campion Jr. Jan 31 '16 at 03:31
  • To match the url "abcd" you *must* register the route before the default route or it will never match because the default route will match "abcd" and in .NET routing the first match always wins. – NightOwl888 Jan 31 '16 at 05:26