Match route relative to path

2019-05-26 15:05发布

问题:

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}")]

回答1:

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.