Logo

Maarten Balliauw {blog}

ASP.NET, ASP.NET MVC, Windows Azure, PHP, ...

About the author

Maarten Balliauw is currently employed as a Technical Evangelist at JetBrains. His interests are mainly web applications developed in ASP.NET (C#) or PHP and the Windows Azure cloud platform.
More about me More about me
Send mail E-mail me


ASP.NET MVC Quickly Pro NuGet Subscribe to my RSS feed Follow me on Twitter! View Maarten Balliauw's profile on LinkedIn
Maarten Balliauw - MVP - Most Valuable Professional
Maarten Balliauw - ASPInsider

Search

Archive

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.

© Copyright Maarten Balliauw 2013


Translating routes (ASP.NET MVC and Webforms)

Localized route in ASP.NET MVC - Translated route in ASP.NET MVC For one of the first blog posts of the new year, I thought about doing something cool. And being someone working with ASP.NET MVC, I thought about a cool thing related to that: let’s do something with routes! Since System.Web.Routing is not limited to ASP.NET MVC, this post will also play nice with ASP.NET Webforms. But what’s the cool thing? How about… translating route values?

Allow me to explain… I’m tired of seeing URLs like http://www.example.com/en/products and http://www.example.com/nl/products. Or something similar, with query parameters like “?culture=en-US”. Or even worse stuff. Wouldn’t it be nice to have http://www.example.com/products mapping to the English version of the site and http://www.exaple.com/producten mapping to the Dutch version? Better to remember when giving away a link to someone, better for SEO as well.

Of course, we do want both URLs above to map to the ProductsController in our ASP.NET MVC application. We do not want to duplicate logic because of a language change, right? And what’s more: it’s not fun if this would mean having to switch from <%=Html.ActionLink(…)%> to something else because of this.

Let’s see if we can leverage the routing engine in System.Web.Routing for this…

Want the sample code? Check LocalizedRouteExample.zip (23.25 kb)

Mapping a translated route

First things first: here’s how I see a translated route being mapped in Global.asax.cs:

routes.MapTranslatedRoute(
    "TranslatedRoute",
    "{controller}/{action}/{id}",
    new { controller = "Home", action = "Index", id = "" },
    new { controller = translationProvider, action = translationProvider },
    true
);

Looks pretty much the same as you would normally map a route, right? There’s only one difference: the new { controller = translationProvider, action = translationProvider } line of code. This line of code basically tells the routing engine to use the object translationProvider as a provider which allows to translate a route value. In this case, the same translation provider will handle translating controller names and action names.

Translation providers

The translation provider being used can actually be anything, as long as it conforms to the following contract:

public interface IRouteValueTranslationProvider
{
    RouteValueTranslation TranslateToRouteValue(string translatedValue, CultureInfo culture);
    RouteValueTranslation TranslateToTranslatedValue(string routeValue, CultureInfo culture);
}

This contract provides 2 method definitions: one for mapping a translated value to a route value (like: mapping the Dutch “Thuis” to “Home”). The other method will do the opposite.

TranslatedRoute

The “core” of this solution is the TranslatedRoute class. It’s basically an overridden implementation of the System.Web.Routing.Route class, using the IRouteValueTranslationProvider for translating a route. As a bonus, it also tries to set the current thread culture to the CultureInfo detected based on the route being called. Note that this is just a reasonable guess, not the very truth. It will not detect nl-NL versus nl-BE, for example. Here’s the code:

public class TranslatedRoute : Route
{
    // ...

    public RouteValueDictionary RouteValueTranslationProviders { get; private set; }

    // ...

    public override RouteData GetRouteData(HttpContextBase httpContext)
    { 
        RouteData routeData = base.GetRouteData(httpContext);
        if (routeData == null) return null;

        // Translate route values
        foreach (KeyValuePair<string, object> pair in this.RouteValueTranslationProviders)
        {
            IRouteValueTranslationProvider translationProvider = pair.Value as IRouteValueTranslationProvider;
            if (translationProvider != null
                && routeData.Values.ContainsKey(pair.Key))
            {
                RouteValueTranslation translation = translationProvider.TranslateToRouteValue(
                    routeData.Values[pair.Key].ToString(),
                    CultureInfo.CurrentCulture);

                routeData.Values[pair.Key] = translation.RouteValue;

                // Store detected culture
                if (routeData.DataTokens[DetectedCultureKey] == null)
                {
                    routeData.DataTokens.Add(DetectedCultureKey, translation.Culture);
                }

                // Set detected culture
                if (this.SetDetectedCulture)
                {
                    System.Threading.Thread.CurrentThread.CurrentCulture = translation.Culture;
                    System.Threading.Thread.CurrentThread.CurrentUICulture = translation.Culture;
                }
            }
        }

        return routeData;
    }

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

        // Translate route values
        foreach (KeyValuePair<string, object> pair in this.RouteValueTranslationProviders)
        {
            IRouteValueTranslationProvider translationProvider = pair.Value as IRouteValueTranslationProvider;
            if (translationProvider != null
                && translatedValues.ContainsKey(pair.Key))
            {
                RouteValueTranslation translation =
                    translationProvider.TranslateToTranslatedValue(
                        translatedValues[pair.Key].ToString(), CultureInfo.CurrentCulture);

                translatedValues[pair.Key] = translation.TranslatedValue;
            }
        }

        return base.GetVirtualPath(requestContext, translatedValues);
    }
}

The GetRouteData finds a corresponding route translation if I entered “/Thuis/Over” in the URL. The GetVirtualPath method does the opposite, and will be used for mapping a call to <%=Html.ActionLink(“About”, “About”, “Home”)%> to a route like “/Thuis/Over” if the current thread culture is nl-NL. This is not rocket science, it simply tries to translate every token in the requested path and update the route data with it so the ASP.NET MVC subsystem will know that “Thuis” maps to HomeController.

Tying everything together

We already tied the route definition in Global.asax.cs earlier in this blog post, but let’s do it again with a sample DictionaryRouteValueTranslationProvider that will be used for translating routes. This one goes in Global.asax.cs:

public static void RegisterRoutes(RouteCollection routes)
{
    CultureInfo cultureEN = CultureInfo.GetCultureInfo("en-US");
    CultureInfo cultureNL = CultureInfo.GetCultureInfo("nl-NL");
    CultureInfo cultureFR = CultureInfo.GetCultureInfo("fr-FR");

    DictionaryRouteValueTranslationProvider translationProvider = new DictionaryRouteValueTranslationProvider(
        new List<RouteValueTranslation> {
            new RouteValueTranslation(cultureEN, "Home", "Home"),
            new RouteValueTranslation(cultureEN, "About", "About"),
            new RouteValueTranslation(cultureNL, "Home", "Thuis"),
            new RouteValueTranslation(cultureNL, "About", "Over"),
            new RouteValueTranslation(cultureFR, "Home", "Demarrer"),
            new RouteValueTranslation(cultureFR, "About", "Infos")
        }
    );

    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapTranslatedRoute(
        "TranslatedRoute",
        "{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" },
        new { controller = translationProvider, action = translationProvider },
        true
    );

    routes.MapRoute(
        "Default",      // Route name
        "{controller}/{action}/{id}",   // URL with parameters
        new { controller = "Home", action = "Index", id = "" }  // Parameter defaults
    );

}

This is basically it! What I can now do is set the current thread’s culture to, let’s say fr-FR, and all action links generated by ASP.NET MVC will be using French. Easy? Yes! Cool? Yes!

Localizing ASP.NET MVC routing

Want the sample code? Check LocalizedRouteExample.zip (23.25 kb)


Categories: ASP.NET | C# | General | MVC | Projects

Comments (18) -

Tom Deleu Belgium |

Tuesday, January 26, 2010 9:28 AM

Tom Deleu

Aha, now THAT is a useful article. Been looking for such a functionality some weeks ago Smile

maartenba Belgium |

Tuesday, January 26, 2010 9:50 AM

maartenba

Not sure, URL's that have values in language A will be recognised by search engines as language A, and language B will be recognised as language B. Do you have any resources on how this would impact SEO?

Martin H. Normark Denmark |

Tuesday, January 26, 2010 4:58 PM

Martin H. Normark

I know that you can use Google Webmaster Tools, to control what language en subfolder will target. Also, be sure to set the meta tag: <meta http-equiv="Content-Language" content="nl" />...

You could also have a subdomain per language - this will make you able to have a local server for each language. Which is known to give you better results in search engines.

There's a few resources here: www.google.com/search

I got most of my knowledge on the topic from a well respected SEO consultant in Denmark, Thomas Rosenstand.

Tom Janssens Belgium |

Tuesday, January 26, 2010 10:02 AM

Tom Janssens

Oops, I have been looking at the source code, and apparently you are not using an intermediate controller but you add new route handlers.. However, kudos for the inspiration, and it still is a great implementation...

Arnis L. Latvia |

Tuesday, January 26, 2010 11:49 AM

Arnis L.

Read this right after reading post by K. Scott (odetocode.com/.../...-your-asp-net-mvc-routes.aspx).

Anyway - this is inspiring and cool. But still - wondering if there are any more caveats (e.g. - SEO related).

Augi Czech Republic |

Tuesday, January 26, 2010 1:37 PM

Augi

Hehe, I blogged about url localization in ASP.NET MVC the last weekend (proof of concept only): www.augi.cz/.../

Your code is very nice and usable. I have only two notes:
1) I would use "Convert.ToString(routeData.Values[pair.Key])" instead of "routeData.Values[pair.Key].ToString()".
2) You are setting current culture by changing culture of current thread. It's noticable that this will not work with asynchronous controller (because different thread could be used for the second phase - it would require thread culture re-setting).

maartenba Belgium |

Tuesday, January 26, 2010 2:10 PM

maartenba

Good tips, thanks!

Tony Testa United States |

Thursday, January 28, 2010 7:09 PM

Tony Testa

How does this work in a hosted environment where you don't have access to the underlying IIS?  Do you still have issues with routes and not having the .aspx situation?

maartenba Belgium |

Thursday, January 28, 2010 7:21 PM

maartenba

It's just like any other route, so if normal routing works you should be fine with this solution.

Matteo Italy |

Friday, December 03, 2010 12:49 PM

Matteo

I've found a bug in the TranslateToRouteValue method, where you do the checks like this:

t.TranslatedValue == translatedValue

This makes the translated routes case sensitive. If I type in the address bar a translated route like "/Foo/Bar" all works fine, but if I type "/foo/bar" it returns a 404.

To fix this, just change the comparisons like this:

t.TranslatedValue.ToLower() == translatedValue.ToLower()

So the check becomes case insensitive and all works fine Smile

Marthijn Netherlands |

Tuesday, January 11, 2011 2:00 PM

Marthijn

Thanks for this post!
I have a question, in my project I have a route www.example.com/search which I mapped to /Home/Search:
routes.MapRoute("SearchRoute", "search", new { controller = "Home", action = "Search" });
Is there a way to translate the url (in this case 'search')? Or do I have to create another route with the translated url?

maartenba Belgium |

Tuesday, January 11, 2011 2:38 PM

maartenba

That would require another translation...

Dave Switzerland |

Friday, January 21, 2011 11:05 PM

Dave

Hi Maarten

Thanks for this great post, it's the cleanest solution I found and I started immediately to implement it in my current project. Though I got a problem with RenderAction in my views. The translation seems not to happen correctly for these routes.

I tried also to reproduce this behaviour with your sample application. Adding ActionResult Partial() to the HomeController, a PartialView called Partial.ascx and <% Html.RenderAction("Partial") %>, actually gives me the same error (The controller for path '/Thuis/Over' was not found or does not implement IController).

I tried to find out, where the problem resides, but couldn't figure it out. Any idea? I would highly appreciate your help here. Thanks!

Oleg Kosmakov Ukraine |

Friday, February 25, 2011 6:18 PM

Oleg Kosmakov

Hi
I have the same problem with Html.RenderAction. Can anyone tell how to fix it?

Marco Leo Italy |

Monday, March 07, 2011 4:46 PM

Marco Leo

Hi I have the same problem with the RenderAction  Samebody Fixed it??

Maria Spain |

Wednesday, March 09, 2011 11:30 AM

Maria

Hi, the problem with RenderAction I realize that only happens when you are rendering to the same controller.

Example:
RenderAction("Index","Home"); this is correct! but when you make RenderAction("List", Home); inside Index.cshtml or Index.aspx you got the error.
If you change de action List to another controller, i'ts done ok this way RenderAction("List", SubHome);
I know that it's not an elegant solution but it works!

Greetings for the article Smile

Cory Canada |

Thursday, March 17, 2011 10:45 PM

Cory

Has anyone tried this with Areas?
It does not seem to handle areas.

I did make an adjustment to allow a Translatedroute to set the route.DataTokens["area"] = AreaName;
When debugging, the translation is found but I still get a 404.

Example Area registration
context.MapTranslatedRoute(AreaName, "Account_default", "Account", new { controller = "Account", action = "Profile" }, new { controller = _translationProvider }, true);

any help would be appreciated!

Gee Belgium |

Wednesday, March 23, 2011 4:51 PM

Gee

I had problems with area too.

When you don't use TranslatedRoute, but the default "MapRoute" method, you'll notice that some DataTokenValues are added when using areas.
Just add these tokens to the RouteData, for example:

                varRouteData.DataTokens.Add("Namespaces", "<your namespace here>.*");
                varRouteData.DataTokens.Add("area", "<your area here>");
                varRouteData.DataTokens.Add("UseNamespaceFallback", false);

It did the trick for me!



Thx for the very nice article, worx like a charm!

Pingbacks and trackbacks (1)+

Comments are closed