I am using routes.LowercaseUrls = true; in my MVC 4 application which is working great. However, parameters will also get lowercased, so if I have a route like
routes.MapRoute(
name: "MyController",
url: "foo/{hash}/{action}",
defaults: new { controller = "MyController", action = "Details" }
);
The link generated with
#Html.ActionLink("my link", "Details", new { hash=ViewBag.MyHash })
will have the {hash}-part of the URL lowercased as well, for example if ViewBag.MyHash = "aX3F5U" then the generated link will be /foo/ax3f5u instead of /foo/aX3F5U
Is there a way to force MVC to only lowercase the controller and action parts?
For older versions of MVC, the way to go seemed to be to implement a custom subclass of Route, however I don't know how/where to instantiate it, since the signature of the route constructors is quite different to MapRoute and I'm hoping there to be a simpler way.
I think the solution with a custom subclass of Route will be a good enough and simple, but at the same time a little bit ugly :)
You can add a CustomRoute at RegisterRoute method of RouteConfig.cs. Add the following code instead of routes.MapRoute
var route = new CustomRoute(new Route(
url: "{controller}/{action}/{id}",
defaults: new RouteValueDictionary() {
{ "controller", "Home" },
{ "action", "Index" },
{ "id", UrlParameter.Optional }
},
routeHandler: new MvcRouteHandler()
));
routes.Add(route);
Implementaion of particular CustomRoute may look like this:
public class CustomRoute : RouteBase
{
private readonly RouteBase route;
public CustomRoute(RouteBase route)
{
this.route = route;
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
values = new RouteValueDictionary(values.Select(v =>
{
return v.Key.Equals("action") || v.Key.Equals("controller")
? new KeyValuePair<String, Object>(v.Key, (v.Value as String).ToLower())
: v;
}).ToDictionary(v => v.Key, v => v.Value));
return route.GetVirtualPath(requestContext, values);
}
public override RouteData GetRouteData(HttpContextBase httpContext)
{
return route.GetRouteData(httpContext);
}
}
However it's not an optimal implementation. A complete example could use a combination of extensions on RouteCollection and a custom Route child to keep it as close as possible to the original routes.MapRoute(...) syntax:
LowercaseRoute Class:
public class LowercaseRoute : Route
{
public LowercaseRoute(string url, IRouteHandler routeHandler) : base(url, routeHandler) { }
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
values = new RouteValueDictionary(values.Select(v =>
{
return v.Key.Equals("action") || v.Key.Equals("controller")
? new KeyValuePair<String, Object>(v.Key, (v.Value as String).ToLower())
: v;
}).ToDictionary(v => v.Key, v => v.Value));
return base.GetVirtualPath(requestContext, values);
}
}
RouteCollectionExtensions Class:
public static class RouteCollectionExtensions
{
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url)
{
return MapLowercaseRoute(routes, name, url, null /* defaults */, (object)null /* constraints */);
}
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url, object defaults)
{
return MapLowercaseRoute(routes, name, url, defaults, (object)null /* constraints */);
}
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url, object defaults, object constraints)
{
return MapLowercaseRoute(routes, name, url, defaults, constraints, null /* namespaces */);
}
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url, string[] namespaces)
{
return MapLowercaseRoute(routes, name, url, null /* defaults */, null /* constraints */, namespaces);
}
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url, object defaults, string[] namespaces)
{
return MapLowercaseRoute(routes, name, url, defaults, null /* constraints */, namespaces);
}
[SuppressMessage("Microsoft.Design", "CA1054:UriParametersShouldNotBeStrings", MessageId = "2#", Justification = "This is not a regular URL as it may contain special routing characters.")]
public static Route MapLowercaseRoute(this RouteCollection routes, string name, string url, object defaults, object constraints, string[] namespaces)
{
if (routes == null)
{
throw new ArgumentNullException("routes");
}
if (url == null)
{
throw new ArgumentNullException("url");
}
Route route = new LowercaseRoute(url, new MvcRouteHandler())
{
Defaults = CreateRouteValueDictionary(defaults),
Constraints = CreateRouteValueDictionary(constraints),
DataTokens = new RouteValueDictionary()
};
if ((namespaces != null) && (namespaces.Length > 0))
{
route.DataTokens["Namespaces"] = namespaces;
}
routes.Add(name, route);
return route;
}
private static RouteValueDictionary CreateRouteValueDictionary(object values)
{
var dictionary = values as IDictionary<string, object>;
if (dictionary != null)
{
return new RouteValueDictionary(dictionary);
}
return new RouteValueDictionary(values);
}
}
You can now use MapLowercaseRoute instead of MapRoute, so
routes.MapRoute(
name: "MyController",
url: "foo/{hash}/{action}",
defaults: new { controller = "MyController", action = "Details" }
);
simply becomes
routes.MapLowercaseRoute(
name: "MyController",
url: "foo/{hash}/{action}",
defaults: new { controller = "MyController", action = "Details" }
);
exposing the desired behaviour.
Here is one simple way to do this,
public class MyRoute : Route
{
public MyRoute(string url, object defaults): base(url, new RouteValueDictionary(defaults), new MvcRouteHandler())
{
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
if (values["action"] != null)
values["action"] = values["action"].ToString().ToLowerInvariant();
if (values["controller"] != null)
values["controller"] = values["controller"].ToString().ToLowerInvariant();
return base.GetVirtualPath(requestContext, values);
}
}
routes.Add("Default",new MyRoute("{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = MyUrlParameter.Optional }));
See this blog post for detail.
If you look at private RouteCollection.NormalizeVirtualPath method you'll see that it simply uses virtualPath.ToLowerInvariant(). So there is no way to handle that. Even if you create your own route it will be lowercased.
But what you can do is to add hash after '#' sign i.e. "foo/{action}/#{hash}". I haven't
tried, but it should work. Just look at NormalizeVirtualPath implementation.
It's simple as 1.2.3 ,
look at this example
routes.MapRouteLowercase( // changed from routes.MapRoute
"Default",
"{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
It's simple to download and install it via Nuget, I use it.
PM> Install-Package LowercaseRoutesMVC
http://lowercaseroutesmvc.codeplex.com/
Related
I've translated my mvc website, which is working great. If I select another language (Dutch or English) the content gets translated.
This works because I set the culture in the session.
Now I want to show the selected culture(=culture) in the url.
If it is the default language it should not be showed in the url, only if it is not the default language it should show it in the url.
e.g.:
For default culture (dutch):
site.com/foo
site.com/foo/bar
site.com/foo/bar/5
For non-default culture (english):
site.com/en/foo
site.com/en/foo/bar
site.com/en/foo/bar/5
My problem is that I always see this:
site.com/nl/foo/bar/5
even if I clicked on English (see _Layout.cs). My content is translated in English but the route parameter in the url stays on "nl" instead of "en".
How can I solve this or what am I doing wrong?
I tried in the global.asax to set the RouteData but doesn't help.
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.IgnoreRoute("favicon.ico");
routes.LowercaseUrls = true;
routes.MapRoute(
name: "Errors",
url: "Error/{action}/{code}",
defaults: new { controller = "Error", action = "Other", code = RouteParameter.Optional }
);
routes.MapRoute(
name: "DefaultWithCulture",
url: "{culture}/{controller}/{action}/{id}",
defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional },
constraints: new { culture = "[a-z]{2}" }
);// or maybe: "[a-z]{2}-[a-z]{2}
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
Global.asax.cs:
protected void Application_Start()
{
MvcHandler.DisableMvcResponseHeader = true;
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
protected void Application_AcquireRequestState(object sender, EventArgs e)
{
if (HttpContext.Current.Session != null)
{
CultureInfo ci = (CultureInfo)this.Session["Culture"];
if (ci == null)
{
string langName = "nl";
if (HttpContext.Current.Request.UserLanguages != null && HttpContext.Current.Request.UserLanguages.Length != 0)
{
langName = HttpContext.Current.Request.UserLanguages[0].Substring(0, 2);
}
ci = new CultureInfo(langName);
this.Session["Culture"] = ci;
}
HttpContextBase currentContext = new HttpContextWrapper(HttpContext.Current);
RouteData routeData = RouteTable.Routes.GetRouteData(currentContext);
routeData.Values["culture"] = ci;
Thread.CurrentThread.CurrentUICulture = ci;
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);
}
}
_Layout.cs (where I let user change language)
// ...
<ul class="dropdown-menu" role="menu">
<li class="#isCurrentLang("nl")">#Html.ActionLink("Nederlands", "ChangeCulture", "Culture", new { lang = "nl", returnUrl = this.Request.RawUrl }, new { rel = "alternate", hreflang = "nl" })</li>
<li class="#isCurrentLang("en")">#Html.ActionLink("English", "ChangeCulture", "Culture", new { lang = "en", returnUrl = this.Request.RawUrl }, new { rel = "alternate", hreflang = "en" })</li>
</ul>
// ...
CultureController: (=where I set the Session that I use in GlobalAsax to change the CurrentCulture and CurrentUICulture)
public class CultureController : Controller
{
// GET: Culture
public ActionResult Index()
{
return RedirectToAction("Index", "Home");
}
public ActionResult ChangeCulture(string lang, string returnUrl)
{
Session["Culture"] = new CultureInfo(lang);
if (Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
else
{
return RedirectToAction("Index", "Home");
}
}
}
There are several issues with this approach, but it boils down to being a workflow issue.
You have a CultureController whose only purpose is to redirect the user to another page on the site. Keep in mind RedirectToAction will send an HTTP 302 response to the user's browser, which will tell it to lookup the new location on your server. This is an unnecessary round-trip across the network.
You are using session state to store the culture of the user when it is already available in the URL. Session state is totally unnecessary in this case.
You are reading the HttpContext.Current.Request.UserLanguages from the user, which might be different from the culture they requested in the URL.
The third issue is primarily because of a fundamentally different view between Microsoft and Google about how to handle globalization.
Microsoft's (original) view was that the same URL should be used for every culture and that the UserLanguages of the browser should determine what language the website should display.
Google's view is that every culture should be hosted on a different URL. This makes more sense if you think about it. It is desirable for every person who finds your website in the search results (SERPs) to be able to search for the content in their native language.
Globalization of a web site should be viewed as content rather than personalization - you are broadcasting a culture to a group of people, not an individual person. Therefore, it typically doesn't make sense to use any personalization features of ASP.NET such as session state or cookies to implement globalization - these features prevent search engines from indexing the content of your localized pages.
If you can send the user to a different culture simply by routing them to a new URL, there is far less to worry about - you don't need a separate page for the user to select their culture, simply include a link in the header or footer to change the culture of the existing page and then all of the links will automatically switch to the culture the user has chosen (because MVC automatically reuses route values from the current request).
Fixing the Issues
First of all, get rid of the CultureController and the code in the Application_AcquireRequestState method.
CultureFilter
Now, since culture is a cross-cutting concern, setting the culture of the current thread should be done in an IAuthorizationFilter. This ensures the culture is set before the ModelBinder is used in MVC.
using System.Globalization;
using System.Threading;
using System.Web.Mvc;
public class CultureFilter : IAuthorizationFilter
{
private readonly string defaultCulture;
public CultureFilter(string defaultCulture)
{
this.defaultCulture = defaultCulture;
}
public void OnAuthorization(AuthorizationContext filterContext)
{
var values = filterContext.RouteData.Values;
string culture = (string)values["culture"] ?? this.defaultCulture;
CultureInfo ci = new CultureInfo(culture);
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
}
}
You can set the filter globally by registering it as a global filter.
public class FilterConfig
{
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
filters.Add(new CultureFilter(defaultCulture: "nl"));
filters.Add(new HandleErrorAttribute());
}
}
Language Selection
You can simplify the language selection by linking to the same action and controller for the current page and including it as an option in the page header or footer in your _Layout.cshtml.
#{
var routeValues = this.ViewContext.RouteData.Values;
var controller = routeValues["controller"] as string;
var action = routeValues["action"] as string;
}
<ul>
<li>#Html.ActionLink("Nederlands", #action, #controller, new { culture = "nl" }, new { rel = "alternate", hreflang = "nl" })</li>
<li>#Html.ActionLink("English", #action, #controller, new { culture = "en" }, new { rel = "alternate", hreflang = "en" })</li>
</ul>
As mentioned previously, all other links on the page will automatically be passed a culture from the current context, so they will automatically stay within the same culture. There is no reason to pass the culture explicitly in those cases.
#ActionLink("About", "About", "Home")
With the above link, if the current URL is /Home/Contact, the link that is generated will be /Home/About. If the current URL is /en/Home/Contact, the link will be generated as /en/Home/About.
Default Culture
Finally, we get to the heart of your question. The reason your default culture is not being generated correctly is because routing is a 2-way map and regardless of whether you are matching an incoming request or generating an outgoing URL, the first match always wins. When building your URL, the first match is DefaultWithCulture.
Normally, you can fix this simply by reversing the order of the routes. However, in your case that would cause the incoming routes to fail.
So, the simplest option in your case is to build a custom route constraint to handle the special case of the default culture when generating the URL. You simply return false when the default culture is supplied and it will cause the .NET routing framework to skip the DefaultWithCulture route and move to the next registered route (in this case Default).
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Routing;
public class CultureConstraint : IRouteConstraint
{
private readonly string defaultCulture;
private readonly string pattern;
public CultureConstraint(string defaultCulture, string pattern)
{
this.defaultCulture = defaultCulture;
this.pattern = pattern;
}
public bool Match(
HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.UrlGeneration &&
this.defaultCulture.Equals(values[parameterName]))
{
return false;
}
else
{
return Regex.IsMatch((string)values[parameterName], "^" + pattern + "$");
}
}
}
All that is left is to add the constraint to your routing configuration. You also should remove the default setting for culture in the DefaultWithCulture route since you only want it to match when there is a culture supplied in the URL anyway. The Default route on the other hand should have a culture because there is no way to pass it through the URL.
routes.LowercaseUrls = true;
routes.MapRoute(
name: "Errors",
url: "Error/{action}/{code}",
defaults: new { controller = "Error", action = "Other", code = UrlParameter.Optional }
);
routes.MapRoute(
name: "DefaultWithCulture",
url: "{culture}/{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional }
);
AttributeRouting
NOTE: This section applies only if you are using MVC 5. You can skip this if you are using a previous version.
For AttributeRouting, you can simplify things by automating the creation of 2 different routes for each action. You need to tweak each route a little bit and add them to the same class structure that MapMvcAttributeRoutes uses. Unfortunately, Microsoft decided to make the types internal so it requires Reflection to instantiate and populate them.
RouteCollectionExtensions
Here we just use the built in functionality of MVC to scan our project and create a set of routes, then insert an additional route URL prefix for the culture and the CultureConstraint before adding the instances to our MVC RouteTable.
There is also a separate route that is created for resolving the URLs (the same way that AttributeRouting does it).
using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using System.Web.Mvc;
using System.Web.Mvc.Routing;
using System.Web.Routing;
public static class RouteCollectionExtensions
{
public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, object constraints)
{
MapLocalizedMvcAttributeRoutes(routes, urlPrefix, new RouteValueDictionary(constraints));
}
public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, RouteValueDictionary constraints)
{
var routeCollectionRouteType = Type.GetType("System.Web.Mvc.Routing.RouteCollectionRoute, System.Web.Mvc");
var subRouteCollectionType = Type.GetType("System.Web.Mvc.Routing.SubRouteCollection, System.Web.Mvc");
FieldInfo subRoutesInfo = routeCollectionRouteType.GetField("_subRoutes", BindingFlags.NonPublic | BindingFlags.Instance);
var subRoutes = Activator.CreateInstance(subRouteCollectionType);
var routeEntries = Activator.CreateInstance(routeCollectionRouteType, subRoutes);
// Add the route entries collection first to the route collection
routes.Add((RouteBase)routeEntries);
var localizedRouteTable = new RouteCollection();
// Get a copy of the attribute routes
localizedRouteTable.MapMvcAttributeRoutes();
foreach (var routeBase in localizedRouteTable)
{
if (routeBase.GetType().Equals(routeCollectionRouteType))
{
// Get the value of the _subRoutes field
var tempSubRoutes = subRoutesInfo.GetValue(routeBase);
// Get the PropertyInfo for the Entries property
PropertyInfo entriesInfo = subRouteCollectionType.GetProperty("Entries");
if (entriesInfo.PropertyType.GetInterfaces().Contains(typeof(IEnumerable)))
{
foreach (RouteEntry routeEntry in (IEnumerable)entriesInfo.GetValue(tempSubRoutes))
{
var route = routeEntry.Route;
// Create the localized route
var localizedRoute = CreateLocalizedRoute(route, urlPrefix, constraints);
// Add the localized route entry
var localizedRouteEntry = CreateLocalizedRouteEntry(routeEntry.Name, localizedRoute);
AddRouteEntry(subRouteCollectionType, subRoutes, localizedRouteEntry);
// Add the default route entry
AddRouteEntry(subRouteCollectionType, subRoutes, routeEntry);
// Add the localized link generation route
var localizedLinkGenerationRoute = CreateLinkGenerationRoute(localizedRoute);
routes.Add(localizedLinkGenerationRoute);
// Add the default link generation route
var linkGenerationRoute = CreateLinkGenerationRoute(route);
routes.Add(linkGenerationRoute);
}
}
}
}
}
private static Route CreateLocalizedRoute(Route route, string urlPrefix, RouteValueDictionary constraints)
{
// Add the URL prefix
var routeUrl = urlPrefix + route.Url;
// Combine the constraints
var routeConstraints = new RouteValueDictionary(constraints);
foreach (var constraint in route.Constraints)
{
routeConstraints.Add(constraint.Key, constraint.Value);
}
return new Route(routeUrl, route.Defaults, routeConstraints, route.DataTokens, route.RouteHandler);
}
private static RouteEntry CreateLocalizedRouteEntry(string name, Route route)
{
var localizedRouteEntryName = string.IsNullOrEmpty(name) ? null : name + "_Localized";
return new RouteEntry(localizedRouteEntryName, route);
}
private static void AddRouteEntry(Type subRouteCollectionType, object subRoutes, RouteEntry newEntry)
{
var addMethodInfo = subRouteCollectionType.GetMethod("Add");
addMethodInfo.Invoke(subRoutes, new[] { newEntry });
}
private static RouteBase CreateLinkGenerationRoute(Route innerRoute)
{
var linkGenerationRouteType = Type.GetType("System.Web.Mvc.Routing.LinkGenerationRoute, System.Web.Mvc");
return (RouteBase)Activator.CreateInstance(linkGenerationRouteType, innerRoute);
}
}
Then it is just a matter of calling this method instead of MapMvcAttributeRoutes.
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
// Call to register your localized and default attribute routes
routes.MapLocalizedMvcAttributeRoutes(
urlPrefix: "{culture}/",
constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
);
routes.MapRoute(
name: "DefaultWithCulture",
url: "{culture}/{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
}
Default culture fix
Incredible post by NightOwl888. There is something missing though - the normal(not localized) URL-generation attribute routes, which get added through reflection, also need a default culture parameter, otherwise you get a query parameter in the URL.
?culture=nl
In order to avoid this, these changes must be made:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Routing;
using System.Web.Routing;
namespace Endpoints.WebPublic.Infrastructure.Routing
{
public static class RouteCollectionExtensions
{
public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, object defaults, object constraints)
{
MapLocalizedMvcAttributeRoutes(routes, urlPrefix, new RouteValueDictionary(defaults), new RouteValueDictionary(constraints));
}
public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, RouteValueDictionary defaults, RouteValueDictionary constraints)
{
var routeCollectionRouteType = Type.GetType("System.Web.Mvc.Routing.RouteCollectionRoute, System.Web.Mvc");
var subRouteCollectionType = Type.GetType("System.Web.Mvc.Routing.SubRouteCollection, System.Web.Mvc");
FieldInfo subRoutesInfo = routeCollectionRouteType.GetField("_subRoutes", BindingFlags.NonPublic | BindingFlags.Instance);
var subRoutes = Activator.CreateInstance(subRouteCollectionType);
var routeEntries = Activator.CreateInstance(routeCollectionRouteType, subRoutes);
// Add the route entries collection first to the route collection
routes.Add((RouteBase)routeEntries);
var localizedRouteTable = new RouteCollection();
// Get a copy of the attribute routes
localizedRouteTable.MapMvcAttributeRoutes();
foreach (var routeBase in localizedRouteTable)
{
if (routeBase.GetType().Equals(routeCollectionRouteType))
{
// Get the value of the _subRoutes field
var tempSubRoutes = subRoutesInfo.GetValue(routeBase);
// Get the PropertyInfo for the Entries property
PropertyInfo entriesInfo = subRouteCollectionType.GetProperty("Entries");
if (entriesInfo.PropertyType.GetInterfaces().Contains(typeof(IEnumerable)))
{
foreach (RouteEntry routeEntry in (IEnumerable)entriesInfo.GetValue(tempSubRoutes))
{
var route = routeEntry.Route;
// Create the localized route
var localizedRoute = CreateLocalizedRoute(route, urlPrefix, constraints);
// Add the localized route entry
var localizedRouteEntry = CreateLocalizedRouteEntry(routeEntry.Name, localizedRoute);
AddRouteEntry(subRouteCollectionType, subRoutes, localizedRouteEntry);
// Add the default route entry
AddRouteEntry(subRouteCollectionType, subRoutes, routeEntry);
// Add the localized link generation route
var localizedLinkGenerationRoute = CreateLinkGenerationRoute(localizedRoute);
routes.Add(localizedLinkGenerationRoute);
// Add the default link generation route
//FIX: needed for default culture on normal attribute route
var newDefaults = new RouteValueDictionary(defaults);
route.Defaults.ToList().ForEach(x => newDefaults.Add(x.Key, x.Value));
var routeWithNewDefaults = new Route(route.Url, newDefaults, route.Constraints, route.DataTokens, route.RouteHandler);
var linkGenerationRoute = CreateLinkGenerationRoute(routeWithNewDefaults);
routes.Add(linkGenerationRoute);
}
}
}
}
}
private static Route CreateLocalizedRoute(Route route, string urlPrefix, RouteValueDictionary constraints)
{
// Add the URL prefix
var routeUrl = urlPrefix + route.Url;
// Combine the constraints
var routeConstraints = new RouteValueDictionary(constraints);
foreach (var constraint in route.Constraints)
{
routeConstraints.Add(constraint.Key, constraint.Value);
}
return new Route(routeUrl, route.Defaults, routeConstraints, route.DataTokens, route.RouteHandler);
}
private static RouteEntry CreateLocalizedRouteEntry(string name, Route route)
{
var localizedRouteEntryName = string.IsNullOrEmpty(name) ? null : name + "_Localized";
return new RouteEntry(localizedRouteEntryName, route);
}
private static void AddRouteEntry(Type subRouteCollectionType, object subRoutes, RouteEntry newEntry)
{
var addMethodInfo = subRouteCollectionType.GetMethod("Add");
addMethodInfo.Invoke(subRoutes, new[] { newEntry });
}
private static RouteBase CreateLinkGenerationRoute(Route innerRoute)
{
var linkGenerationRouteType = Type.GetType("System.Web.Mvc.Routing.LinkGenerationRoute, System.Web.Mvc");
return (RouteBase)Activator.CreateInstance(linkGenerationRouteType, innerRoute);
}
}
}
And to attribute routes registration:
RouteTable.Routes.MapLocalizedMvcAttributeRoutes(
urlPrefix: "{culture}/",
defaults: new { culture = "nl" },
constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
);
Better solution
And actually, after some time, I needed to add url translation, so i digged in more, and it appears there is no need to do the reflection hacking described. The ASP.NET guys thought about it, there is much cleaner solution - instead you can extend a DefaultDirectRouteProvider like this:
public static class RouteCollectionExtensions
{
public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string defaultCulture)
{
var routeProvider = new LocalizeDirectRouteProvider(
"{culture}/",
defaultCulture
);
routes.MapMvcAttributeRoutes(routeProvider);
}
}
class LocalizeDirectRouteProvider : DefaultDirectRouteProvider
{
ILogger _log = LogManager.GetCurrentClassLogger();
string _urlPrefix;
string _defaultCulture;
RouteValueDictionary _constraints;
public LocalizeDirectRouteProvider(string urlPrefix, string defaultCulture)
{
_urlPrefix = urlPrefix;
_defaultCulture = defaultCulture;
_constraints = new RouteValueDictionary() { { "culture", new CultureConstraint(defaultCulture: defaultCulture) } };
}
protected override IReadOnlyList<RouteEntry> GetActionDirectRoutes(
ActionDescriptor actionDescriptor,
IReadOnlyList<IDirectRouteFactory> factories,
IInlineConstraintResolver constraintResolver)
{
var originalEntries = base.GetActionDirectRoutes(actionDescriptor, factories, constraintResolver);
var finalEntries = new List<RouteEntry>();
foreach (RouteEntry originalEntry in originalEntries)
{
var localizedRoute = CreateLocalizedRoute(originalEntry.Route, _urlPrefix, _constraints);
var localizedRouteEntry = CreateLocalizedRouteEntry(originalEntry.Name, localizedRoute);
finalEntries.Add(localizedRouteEntry);
originalEntry.Route.Defaults.Add("culture", _defaultCulture);
finalEntries.Add(originalEntry);
}
return finalEntries;
}
private Route CreateLocalizedRoute(Route route, string urlPrefix, RouteValueDictionary constraints)
{
// Add the URL prefix
var routeUrl = urlPrefix + route.Url;
// Combine the constraints
var routeConstraints = new RouteValueDictionary(constraints);
foreach (var constraint in route.Constraints)
{
routeConstraints.Add(constraint.Key, constraint.Value);
}
return new Route(routeUrl, route.Defaults, routeConstraints, route.DataTokens, route.RouteHandler);
}
private RouteEntry CreateLocalizedRouteEntry(string name, Route route)
{
var localizedRouteEntryName = string.IsNullOrEmpty(name) ? null : name + "_Localized";
return new RouteEntry(localizedRouteEntryName, route);
}
}
There is a solution based on this, including url translation here: https://github.com/boudinov/mvc-5-routing-localization
I'm trying to build my tutorial project with routing. My main objective is to build two routes which won't generate 404 error in any case. By this I mean that if the path is wrong I want routing to use /Home/Index path. I have two following routes -
routes.MapRoute("Default", "{controller}/{action}",
new {controller = "Home", action = "Index"}
);
routes.MapRoute("Second", "{*catchall}",
new {controller = "Home", action = "Index", id = UrlParameter.Optional}
);
It works fine when I use nonexistent path which doesn't matches the first route, like this -
But if it does, then I have the following -
or
I understand the reason why it happens. However at the present moment, I only managed to find 'some sort' of a solution. Adding the following code to web.config file -
<customErrors mode="On">
<error statusCode="404" redirect="~/Home/Index"/>
</customErrors>
However, I don't think that it is the best way to solve this problem. Because, as far as I can understand, it simply catches all errors and redirects it to the correct path, without actually using routing. So I don't think I need this global handling.
So could somebody please give me a hint or provide me with a good solution to my problem. Thank you.
Well, you didn't really define what "wrong" routing is, so here is my definition:
The controller or action does not exist in the project.
If an "id" is passed, it must exist on the action method.
Routes
I used constraints to do this. AFAIK, it is not possible to use a constraint on an optional parameter. This means that in order to make the id parameter optional, you need 3 routes.
routes.MapRoute(
name: "DefaultWithID",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index" },
constraints: new { action = new ActionExistsConstraint(), id = new ParameterExistsConstraint() }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}",
defaults: new { controller = "Home", action = "Index" },
constraints: new { action = new ActionExistsConstraint() }
);
routes.MapRoute(
name: "Second",
url: "{*catchall}",
defaults: new { controller = "Home", action = "Index" }
);
ActionExistsConstraint
public class ActionExistsConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest)
{
var action = values["action"] as string;
var controller = values["controller"] as string;
var thisAssembly = this.GetType().Assembly;
Type[] types = thisAssembly.GetTypes();
Type type = types.Where(t => t.Name == (controller + "Controller")).SingleOrDefault();
// Ensure the action method exists
return type != null && type.GetMethod(action) != null;
}
return true;
}
}
ParameterExistsConstraint
public class ParameterExistsConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest)
{
var action = values["action"] as string;
var controller = values["controller"] as string;
var thisAssembly = this.GetType().Assembly;
Type[] types = thisAssembly.GetTypes();
Type type = types.Where(t => t.Name == (controller + "Controller")).SingleOrDefault();
var method = type.GetMethod(action);
if (type != null && method != null)
{
// Ensure the parameter exists on the action method
var param = method.GetParameters().Where(p => p.Name == parameterName).FirstOrDefault();
return param != null;
}
return false;
}
return true;
}
}
You can do this by using a custom RouteConstraint.
First, set your routes like this:
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
constraints: new { controller = new ControllerNameConstraint() }
);
routes.MapRoute(
name: "Second",
url: "{*wildcard}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
Now you can create the ControllerNameConstraint. I've done a very simple implementation here, you will need to change it so that it works with what you are trying to achieve (you have a bunch of options using reflection if you don't want to update it all the time)
public class ControllerNameConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (values[parameterName].ToString() == "Home" || values[parameterName].ToString() == "Account")
{
//the route is matched and will run the constrained ("Default") route
return true;
}
//the route is not matched to the constraint and will fall through to your wildcard route
return false;
}
}
I'm doing something very similar in an attempt to kind of create my own dynamic routing based on a database-controlled site navigation. Essentially, I wanted anything that hit a true defined route to go through the normal routing process, but then I could have URLs for things like content pages controlled entirely by their placement in the navigation. Anyways, for that I relied on the httpErrors Web.config declaration:
<system.webServer>
...
<httpErrors errorMode="Custom" existingResponse="Replace">
<remove statusCode="404" />
<error statusCode="404" responseMode="ExecuteURL" path="/error/404" />
</httpErrors>
Then, I have an ErrorController with an action to process 404s. In that action, I check the attempted URL against the database and if I find a matching item, I hand off the request to the appropriate place. If there's no match, then I just return a view, which is a custom 404 view I set up. The path portion above needs to be the URL to get to your 404 action. Mine is /error/404 because I'm using attribute routing and can make it whatever I want. If you're relying on the default route, you can't have an action named 404, so it would have to be something like /error/http404 or /error/notfound.
The way your are specifying your default route is looking for any controller/action paring and if they are not found to substitute with the defaults. If you call out the exact home route and leave out the controller and action keywords in the url when the map is created it will only match on those and all others will be caught by your catch all.
routes.MapRoute(
name: "Default",
url: "Home/Index/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
I do not know of any other routes you may have so I could not test with your exact scenario. Try this out and see if it helps you.
The answer from NightOwl888 is working for me, however the code for ActionExistsConstraint and ParameterExistsConstraint need to modify a little, to remove case-sensitive comparison. Here is my new code and it works perfectly in my case
public class ActionExistsConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest)
{
var action = values["action"] as string;
var controller = values["controller"] as string;
var thisAssembly = this.GetType().Assembly;
Type[] types = thisAssembly.GetTypes();
Type type = types.FirstOrDefault(t => t.Name.Equals(controller + "Controller", StringComparison.OrdinalIgnoreCase));
// Ensure the action method exists
return type != null &&
type.GetMethods().Any(x => x.Name.Equals(action, StringComparison.OrdinalIgnoreCase));
}
return true;
}
}
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest)
{
var action = values["action"] as string;
var controller = values["controller"] as string;
var thisAssembly = this.GetType().Assembly;
Type[] types = thisAssembly.GetTypes();
Type type = types.FirstOrDefault(t => t.Name .Equals(controller + "Controller", StringComparison.OrdinalIgnoreCase));
var method = type.GetMethods().FirstOrDefault(x => x.Name.Equals(action, StringComparison.OrdinalIgnoreCase));
if (type != null && method != null)
{
// Ensure the parameter exists on the action method
var param = method.GetParameters().FirstOrDefault(p => p.Name.Equals(parameterName, StringComparison.OrdinalIgnoreCase));
return param != null;
}
return false;
}
return true;
}
Please change route declaration with
routes.MapRoute("Second", "{*catchall}",
new {controller = "Home", action = "Index", id = UrlParameter.Optional}
);
routes.MapRoute("Default", "{controller}/{action}",
new {controller = "Home"`enter code here`, action = "Index"}
);
I'm trying to write a route with a nullable int in it. It should be possible to go to both /profile/ but also /profile/\d+.
routes.MapRoute("ProfileDetails", "profile/{userId}",
new {controller = "Profile",
action = "Details",
userId = UrlParameter.Optional},
new {userId = #"\d+"});
As you can see, I say that userId is optional but also that it should match the regular expression \d+. This does not work and I see why.
But how would I construct a route that matches just /profile/ but also /profile/ followed by a number?
The simplest way would be to just add another route without the userId parameter, so you have a fallback:
routes.MapRoute("ProfileDetails", "profile/{userId}",
new {controller = "Profile",
action = "Details",
userId = UrlParameter.Optional},
new {userId = #"\d+"});
routes.MapRoute("Profile", "profile",
new {controller = "Profile",
action = "Details"});
As far as I know, the only other way you can do this would be with a custom constraint. So your route would become:
routes.MapRoute("ProfileDetails", "profile/{userId}",
new {controller = "Profile",
action = "Details",
userId = UrlParameter.Optional},
new {userId = new NullableConstraint());
And the custom constraint code will look like this:
using System;
using System.Web;
using System.Web.Routing;
using System.Web.Mvc;
namespace YourNamespace
{
public class NullableConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest && parameterName == "userId")
{
// If the userId param is empty (weird way of checking, I know)
if (values["userId"] == UrlParameter.Optional)
return true;
// If the userId param is an int
int id;
if (Int32.TryParse(values["userId"].ToString(), out id))
return true;
}
return false;
}
}
}
I don't know that NullableConstraint is the best name here, but that's up to you!
It's possible something changed since this question was answered but I was able to change this:
routes.MapPageRoute(
null,
"projects/{operation}/{id}",
"~/Projects/ProjectWizard.aspx",
true,
new RouteValueDictionary(new
{
operation = "new",
id = UrlParameter.Optional
}),
new RouteValueDictionary(new
{
id = new NullableExpressionConstraint(#"\d+")
})
);
With this:
routes.MapPageRoute(
null,
"projects/{operation}/{id}",
"~/Projects/ProjectWizard.aspx",
true,
new RouteValueDictionary(new
{
operation = "new",
id = UrlParameter.Optional
}),
new RouteValueDictionary(new
{
id = #"\d*"
})
);
Simply using the * instead of the + in the regular expression accomplished the same task. The route still fired if the parameter was not included, but if included it would only fire if the value was a valid integer. Otherwise it would fail.
ASP.NET MVC 3 has solved this problem, and as Alex Ford brought out, you can use \d* instead of writing a custom constraint. If your pattern is more complicated, like looking for a year with \d{4}, just make sure your pattern matches what you want as well as an empty string, like (\d{4})? or \d{4}|^$. Whatever makes you happy.
If you are still using ASP.NET MVC 2 and want to use Mark Bell's example or NYCChris' example, please be aware that the route will match as long as the URL parameter contains a match to your pattern. This means that the pattern \d+ will match parameters like abc123def. To avoid this, wrap the pattern with ^( and )$ either when defining your routes or in the custom constraint. (If you look at System.Web.Routing.Route.ProcessConstraint in Reflector, you'll see that it does this for you when using the built in constraint. It also sets the CultureInvariant, Compiled, and IgnoreCase options.)
Since I already wrote my own custom constraint with the default behavior mentioned above before realizing I didn't have to use it, I'll leave it here:
public class OptionalConstraint : IRouteConstraint
{
public OptionalConstraint(Regex regex)
{
this.Regex = regex;
}
public OptionalConstraint(string pattern) :
this(new Regex("^(" + pattern + ")$",
RegexOptions.CultureInvariant |
RegexOptions.Compiled |
RegexOptions.IgnoreCase)) { }
public Regex Regex { get; set; }
public bool Match(HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if(routeDirection == RouteDirection.IncomingRequest)
{
object value = values[parameterName];
if(value == UrlParameter.Optional)
return true;
if(this.Regex.IsMatch(value.ToString()))
return true;
}
return false;
}
}
And here's an example route:
routes.MapRoute("PostsByDate",
"{year}/{month}",
new { controller = "Posts",
action = "ByDate",
month = UrlParameter.Optional },
new { year = #"\d{4}",
month = new OptionalConstraint(#"\d\d") });
should your regex be \d*?
Thanks to Mark Bell for this answer, it helped me quite a bit.
I'm wondering why you hard coded the check for "userId" in the constraint? I slightly rewrote your class like to user the parameterName parameter, and it seems to be working just fine.
Am I missing anything by doing it this way?
public class OptionalRegExConstraint : IRouteConstraint
{
private readonly Regex _regEx;
public OptionalRegExConstraint(string matchExpression=null)
{
if (!string.IsNullOrEmpty(matchExpression))
_regEx = new Regex(matchExpression);
}
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
if (routeDirection == RouteDirection.IncomingRequest)
{
if (values[parameterName] == UrlParameter.Optional) return true;
return _regEx != null && _regEx.Match(values[parameterName].ToString()).Success;
}
return false;
}
}
I needed to validate a few things with more than just a RegEx but was still getting an issue similar to this. My approach was to write a constraint wrapper for any custom route constraints I may already have:
public class OptionalRouteConstraint : IRouteConstraint
{
public IRouteConstraint Constraint { get; set; }
public bool Match
(
HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection
)
{
var value = values[parameterName];
if (value != UrlParameter.Optional)
{
return Constraint.Match(httpContext, route, parameterName, values, routeDirection);
}
else
{
return true;
}
}
}
And then, in constraints under a route in RouteConfig.cs, it would look like this:
defaults: new {
//... other params
userid = UrlParameter.Optional
}
constraints: new
{
//... other constraints
userid = new OptionalRouteConstraint { Constraint = new UserIdConstraint() }
}
I am new to asp.net and currently learning url routing
In facebook link(https://www.facebook.com/james.wood) how does james.wood work?
I tried doing the same thing in my asp.net by using this code added in my global
route.MapPageRoute("Profile", "epubtest/profile/{profileid}", "~/epubtest/Profile.aspx");
But I cant make it work with dot like james.wood
Does the dot means another parameter or just one single parameter?
It works without a dot on it just one single word
1) You can add this route:
routes.MapRoute(
name: "User",
url: "{username}",
defaults: new { controller = "Destiny", action = "Index" },
constraints: new { username = new UserNameConstraint() }
);
2) Create this class:
public class UserNameConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
{
List<string> users = new List<string>() { "username1", "username2" };
var username = values["username"].ToString().ToLower();
return users.Any(x => x.ToLower() == username);
}
}
3) DestinyController
public class DestinyController : Controller
{
public ActionResult Index(string username)
{
return View();
}
}
I hope I've helped.
Hugs!
I have a controller that looks like this:
public class PageController : Controller
{
public ActionResult Render(string url)
{
//this is just for testing!
return Content("url was " + url);
}
}
I'm trying to pass in the value of the url into the controller. For example:
http://www.site.com/products/something/else
Would pass "products/something/else" into my Render action of the PageController.
This is because we are using "products/something/else" as a unique key for a record in the database (legacy system, don't ask)
So, my resultant query would be something along the lines of this:
select * from foo where urlKey = 'products/something/else'
So far I have this in my RegisterRoutes section on Global.asax:
routes.MapRoute("pages", "{*url}", new { controller = "Page", action = "Render", url="/" });
But this isn't working as expected...
By visiting www.site.com/products/something/else, the value passed into the controller is "home/index/0"
The only route defined in RegisterRoutes is that described in the question.
The below class matches every route but you can modify as per your needs.
public class LegacyRoute : RouteBase
{
public override RouteData GetRouteData(HttpContextBase httpContext)
{
RouteData result = null;
string url = httpContext.Request.RawUrl.Substring(1);
result = new RouteData(this, new MvcRouteHandler());
result.Values.Add("controller", "Page");
result.Values.Add("action", "Render");
result.Values.Add("url", url);
return result;
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
return null;
}
}
In Global.asax.cs
routes.Add(new LegacyRoute());
Hope this helps, one of our routes does something similar and this is the code:
routes.MapRoute(
name: "Standard",
url: "{controller}/{action}/{id}",
defaults: new { id = UrlParameter.Optional, action = ControllersAndActions.TypicalController.IndexAction, page = 1 },
constraints: new
{
controller = ControllersAndActions.ControllerConstraintExpression
}
);