2

I want any URL that ends with /templates/{filename} to map to a specific controller using either a route attribute i.e. something like:

public class TemplateController : Controller
{
    [Route("templates/{templateFilename}")]
    public ActionResult Index(string templateFilename)
    {
        ....
    }

}

which works, but the links referencing this route are relative so

  • http://localhost/templates/t1 -- works
  • http://localhost/foo/bar/templates/t2 -- breaks (404)

I need something like:

[Route("*/templates/{templateFilename}")]
Erik Philips
  • 53,428
  • 11
  • 128
  • 150
  • I don't see any code with a route starting with `foo/bar/templates/`. It seems that your code does what it says it does. – CodingNagger Mar 08 '18 at 16:12
  • you can't have file paths in your URL - how is your application supposed to know the difference between a routing slash and a directory tree slash? – Jakotheshadows Mar 08 '18 at 16:13
  • @Jakotheshadows I have this in my web.config... –  Mar 08 '18 at 16:17
  • as far as I can tell that just means you can serve an html file. It doesn't mean your route parameter is allowed to include slashes in it without mapping to an actual route with those slashes – Jakotheshadows Mar 08 '18 at 16:21
  • Any url ending with 'templates/{thefiletemplateName}' I want mapped to the TemplateController. How do I do it then? –  Mar 08 '18 at 16:23
  • you're already doing it, and it works! – Jakotheshadows Mar 08 '18 at 16:24
  • Then why do I get a 404 on any url other than the root / ? –  Mar 08 '18 at 16:26
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/166478/discussion-between-ralph-w-and-jakotheshadows). –  Mar 08 '18 at 16:36

1 Answers1

4

You cannot accomplish something like this with attribute routing. It is only possible to do advanced route matching by implementing IRouteConstraint or subclassing RouteBase.

In this case, it is simpler to subclass RouteBase. Here is an example:

public class EndsWithRoute : RouteBase
{
    private readonly Regex urlPattern;
    private readonly string controllerName;
    private readonly string actionName;
    private readonly string prefixName;
    private readonly string parameterName;

    public EndsWithRoute(string controllerName, string actionName, string prefixName, string parameterName)
    {
        if (string.IsNullOrWhiteSpace(controllerName))
            throw new ArgumentException($"'{nameof(controllerName)}' is required.");
        if (string.IsNullOrWhiteSpace(actionName))
            throw new ArgumentException($"'{nameof(actionName)}' is required.");
        if (string.IsNullOrWhiteSpace(prefixName))
            throw new ArgumentException($"'{nameof(prefixName)}' is required.");
        if (string.IsNullOrWhiteSpace(parameterName))
            throw new ArgumentException($"'{nameof(parameterName)}' is required.");

        this.controllerName = controllerName;
        this.actionName = actionName;
        this.prefixName = prefixName;
        this.parameterName = parameterName;
        this.urlPattern = new Regex($"{prefixName}/[^/]+/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    }

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        var path = httpContext.Request.Path;

        // Check if the URL pattern matches
        if (!urlPattern.IsMatch(path, 1))
            return null;

        // Get the value of the last segment
        var param = path.Split('/').Last();

        var routeData = new RouteData(this, new MvcRouteHandler());

        //Invoke MVC controller/action
        routeData.Values["controller"] = controllerName;
        routeData.Values["action"] = actionName;
        // Putting the myParam value into route values makes it
        // available to the model binder and to action method parameters.
        routeData.Values[parameterName] = param;

        return routeData;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        object controllerObj;
        object actionObj;
        object parameterObj;

        values.TryGetValue("controller", out controllerObj);
        values.TryGetValue("action", out actionObj);
        values.TryGetValue(parameterName, out parameterObj);

        if (controllerName.Equals(controllerObj.ToString(), StringComparison.OrdinalIgnoreCase) 
            && actionName.Equals(actionObj.ToString(), StringComparison.OrdinalIgnoreCase)
            && !string.IsNullOrEmpty(parameterObj.ToString()))
        {
            return new VirtualPathData(this, $"{prefixName}/{parameterObj.ToString()}".ToLowerInvariant());
        }
        return null;
    }
}

Usage

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.Add(new EndsWithRoute(
            controllerName: "Template",
            actionName: "Index",
            prefixName: "templates",
            parameterName: "templateFilename"));

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

This will match these URLs:

http://localhost/templates/t1
http://localhost/foo/bar/templates/t2

And send both of them to the TemplateController.Index() method with the last segment as the templateFilename parameter.

NOTE: For SEO purposes, it is generally not considered a good practice to put the same content on multiple URLs. If you do this, it is recommended to use a canonical tag to inform the search engines which of the URLs is the authoritative one.

See this to accomplish the same in ASP.NET Core.

NightOwl888
  • 55,572
  • 24
  • 139
  • 212
  • Thank you! This is what I thought was the case. –  Mar 08 '18 at 16:59
  • This is an aweful lot of work for something could have been done with a RouteConstraint. – Erik Philips Mar 08 '18 at 17:13
  • @ErikPhilips - tried to get it working with a route constraint, but there doesn't seem to be a way to accomplish it being that the incoming and outgoing URLs are different. But if you have a more concise way to accomplish this, it would be interesting to see. – NightOwl888 Mar 08 '18 at 17:16
  • Outgoing URL... by that do you mean a server side redirect or a 302? – Erik Philips Mar 08 '18 at 17:17
  • @ErikPhilips - I mean the URL that the route generates as opposed to the multiple URLs that it must match. Route constraints are good for excluding matches, but are limited in that you can't add additional matches beyond the original URL pattern AFAIK. – NightOwl888 Mar 08 '18 at 17:19
  • `routes.MapRoute("Templates", "{*path}", new { templatecontrollerstuff}, new { path ="" });` where path is your route constraint regex. – Erik Philips Mar 08 '18 at 17:28
  • 2
    @ErikPhilips - If you go that route, you end up with a route that can only match incoming URLs, but will require you to define a *second* route to generate outgoing URLs. It is a more complicated configuration than simply creating a `RouteBase` subclass, which can control routing in both directions (matching incoming URLs and generating URLs for the UI). If you don't need to generate URLs to put on the UI, then using a route constraint is less code, but it feels a bit broken because every time you use it you *have* to make a catch-all parameter otherwise the route constraint won't work. – NightOwl888 Mar 08 '18 at 18:40