MVC 4: Custom Route & Html.Action out of synch

1.1k views Asked by At

So I have this custom route, which sets up the route table based on culture in the URL, but when I call Url.Action(...), it does not generate the localized URL. Any ideas what I'm doing wrong? The culture is changing on the page and I am able to determine what language user has selected, but Url.Action is not generating localized URL..

This is the custom route, which changes the route table values (not sure if this standard way of doing it):

public class CultureRoute : Route
{
    public CultureRoute(string url, object defaults, object contraints)
        : base(url, new MvcRouteHandler())
    {
        base.Defaults = CreateRouteValueDictionary(defaults);
        base.Constraints = CreateRouteValueDictionary(contraints);
    }
    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        var routeData = base.GetRouteData(httpContext);
        if (routeData != null)
        {
            var culture = routeData.Values["culture"].ToString();
            var cookie = httpContext.Request.Cookies["culture"];
            var areEqual = false;
            if (cookie == null || cookie.Value == "" || !(areEqual = string.Equals(culture, cookie.Value, StringComparison.OrdinalIgnoreCase)))
            {
                routeData.Values["culture"] = culture;
                httpContext.Response.Cookies.Add(new HttpCookie("culture", culture));                    
            }
            else if (!areEqual)
            {                    
                routeData.Values["culture"] = cookie.Value;
            }
            CultureHelper.SetCurrentCulture(culture);
        }            
        return routeData;
    }
    private static RouteValueDictionary CreateRouteValueDictionary(object values)
    {
        var dictionary = values as IDictionary<string, object>;
        if (dictionary != null)
        {
            return new RouteValueDictionary(dictionary);
        }
        else
        {
            return new RouteValueDictionary(values);
        }
    }  
}

and this helper class to set the thread culture:

public class CultureHelper
{
    public static void SetCurrentCulture(string culture)
    {
        var info = CultureInfo.CreateSpecificCulture(culture);
        Thread.CurrentThread.CurrentCulture = info;
        Thread.CurrentThread.CurrentUICulture = info;  
    }
    public static string GetCurrentCulture(bool ignoreRouteData = false)
    {
        if (!ignoreRouteData)
        {
            var routeData = HttpContext.Current.Request.RequestContext.RouteData;
            object culture;
            if (routeData.Values.TryGetValue("culture", out culture))
            {
                return culture.ToString();
            }
        }
        var cookie = HttpContext.Current.Request.Cookies["culture"];
        if (cookie != null && cookie.Value != null)
        {
            return cookie.Value;
        }
        return GetThreadCulture();
    }
    public static string GetThreadCulture()
    {
        var culture = Thread.CurrentThread.CurrentCulture.Name;
        if (culture.IndexOf('-') > -1)
        {
            culture = culture.Substring(0, 2);
        }
        return culture;
    }
}

and also the RouteConfig class, which is called from the Global.asax and sets up routes using my custom route class:

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

        routes.Add("Partial", new CultureRoute(
            "{culture}/{cotroller}/partial/{view}",
            new { culture = "ka", controller = "home", action = "partial", view = "" },
            new { culture = "(ka|en)" }));

        routes.Add("Default", new CultureRoute(
            "{culture}/{controller}/{action}/{id}",
            new { culture = "ka", controller = "home", action = "index", id = UrlParameter.Optional },
            new { culture = "(ka|en)" }));
    }
}

but without this extension method, I am not able to generate culture based route i.e. Url.Action does not generate URL based on route table the custom route class creates:

public static string Action2(this UrlHelper helper, string action)
{ 
    var culture = CultureHelper.GetThreadCulture();
    return helper.Action(action, new { culture = culture });
}
2

There are 2 answers

0
Ostati On BEST ANSWER

It was actually something else causing the incorrect behavior. I had an extension method that was generating actions to switch the language and it modified the route data.

public static string CultureRoute(this UrlHelper helper, string culture = "ka") 
{
    var values = helper.RequestContext.RouteData.Values;
    string actionName = values["action"].ToString();
    if (values.ContainsKey("culture"))
    {
        values["culture"] = culture;
    }
    else
    {
        values.Add("culture", culture);
    }
    return helper.Action(actionName, HttpContext.Current.Request.QueryString.ToRouteValues());
} 

I changed it to this and it works:

public static string CultureRoute(this UrlHelper helper, string culture = "ka") 
{
    var values = helper.RequestContext.RouteData.Values;
    string actionName = values["action"].ToString();
    return helper.Action(actionName, new { culture = culture });
}

I do not need to override GetVirtualPath method to get it to work.. I think most of the time we can get away with Route class and just overriding GetRouteData, but would like to hear what others think...

1
NightOwl888 On

For it to build the URL in ActionLink, you need to override the reverse look-up method named GetVirtualPath as well. Here is an example of how I did it (but I am inheriting RouteBase instead of Route, so yours may need to be done differently).

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        VirtualPathData result = null;

        if (requestContext.RouteData.IsAreaMatch(this.area))
        {
            var tenant = this.appContext.CurrentTenant;

            // Get all of the pages
            var pages = this.routeUrlPageListFactory.GetRouteUrlPageList(tenant.Id);
            IRouteUrlPageInfo page = null;

            if (this.TryFindMatch(pages, values, out page))
            {
                result = new VirtualPathData(this, page.VirtualPath);
            }
        }

        return result;
    }

    private bool TryFindMatch(IEnumerable<IRouteUrlPageInfo> pages, RouteValueDictionary values, out IRouteUrlPageInfo page)
    {
        page = null;
        Guid contentId = Guid.Empty;

        var action = Convert.ToString(values["action"]);
        var controller = Convert.ToString(values["controller"]);
        var localeId = (int?)values["localeId"];

        if (localeId == null)
        {
            return false;
        }

        if (Guid.TryParse(Convert.ToString(values["id"]), out contentId) && action == "Index")
        {
            page = pages
                .Where(x => x.ContentId.Equals(contentId) &&
                    x.ContentType.ToString().Equals(controller, StringComparison.InvariantCultureIgnoreCase))
                .Where(x => x.LocaleId.Equals(localeId))
                .FirstOrDefault();

            if (page != null)
            {
                return true;
            }
        }

        return false;
    }

I found that since several of my routes need to be localized and internally I am using the CultureInfo.LCID rather than a culture string to identify culture, that it was better to put the culture parsing code in the Application_BeginRequest event in Global.asax. But that may not be necessary if you are only using the culture string internally.

BTW - I don't think that using a cookie is necessary in your case since the culture can be derived directly from the URL. It seems like unnecessary overhead, especially when you consider that cookies are transferred on every request (including images and javascript files). Not to mention the security implications of doing it this way - you should at the very least encrypt the value in the cookie. Here is an example that shows how to properly sanitize cookie data.