Concise & transparently localized Rails url_helper methods?

posted: April 24th, 2007 · by: Sven

in: Globalization, Programming · tagged as: , , , , , ·  5 comments »

Update: It’s worth noting that the following information has been updated in the follow-up article: Concise, localized Rails URL helpers? Solved (twice).

Recently Jeremy Hubert commented on my tutorial about routes setup for Globalize. In short he criticized that Globalize doesn’t provide a solution for transparently adding the locale to Rails url_helpers where needed. Instead of being able to say:


article_url(@article)   

You have to specify the locale for each and every call to an url_helper, like so:


article_url(@current_locale, @article)  

Needless to say that this does raise some eyebrows in the Rails community. So let’s see if we can come up with an acceptable solution here. This post describes some intermediary results.

Jeremy posted some lines of code to demonstrate a possible solution and get some thoughts going. He showed a modified version of the NamedRouteCollection::define_url_helper method. This method is responsible for creating all those funny url_helper methods that you get when you define a named route.

Unfortunately I couldn’t get this version working though. Instead of this:


article_path(@article) # /en/articles/1

I kept getting this:


article_path(@article) # /1/articles/localeen

Hmm. Looking a little closer at this line:


#{'args << { :locale => @current_locale }' if segment_keys.include?(:locale)}

… this seems to erroneously push a hash into the args array.

For reasons that I’ll try to explain later on this can’t work with args given as a hash anyways (i.e. with e.g. article_path(:id => 1)). On the other hand, when args is an array here (i.e. in the shorter and targeted syntax like in article_path(1)) the args variable will end up with a value like [1, {:locale => 'en'}] and it seems clear that this actually translates to the wrong URL above.

How about this?

So, standing on giant Jeremies’ shoulders I’ve tried to refine this a bit and found this line to be working a bit better:


args.unshift @current_locale unless Hash === args.first

This unshifts the current locale to front of the array, but it does it only if the first argument is not a Hash, i.e., only if the targeted array syntax has been used.

Also, I disliked overwriting the entire method a bit because it’s relatively prone to breakage if the original code changes in future. Thus I’ve constructed the following weird looking wrapper that does two things:

  • Wrap the define_url_helper method and hook up a workhorse method if the route involves a :locale segment. This method inject_locale_to_url_helper will be called immediately after the url_helper method has been defined.
  • inject_locale_to_url_helper will then wrap the just defined url_helper method and prepend the current locale to the parameters whenever the user actually calls the helper method.

The code looks quite clumbsy because of the duplicate usage of alias_method_chain:

  
module ActionController
  module Routing
    class RouteSet
      class NamedRouteCollection

        # hook into the url_helper creation process and add our call after
        # the helper has been created
        def define_url_helper_with_wrap_locale(route, name, kind, options)
          define_url_helper_without_wrap_locale(route, name, kind, options)
          if route.significant_keys.include? :locale
            inject_locale_to_url_helper url_helper_name(name, kind)
          end
        end
        alias_method_chain :define_url_helper, :wrap_locale

        # wrap the url_helper and prepends the locale to the parameters list
        # given by the user when the helper is actually used
        def inject_locale_to_url_helper(selector)
          @module.send :module_eval, <<-end_eval 
            def #{selector}_with_locale(*args)                            
              args.unshift @current_locale unless Hash === args.first
              #{selector}_without_locale(*args)
            end
          end_eval
          @module.send :alias_method_chain, selector, :locale  
        end
      end
    end
  end
end

Ok. This could already make a possible addition to Globalize, I guess.

It’s relatively unobtrusive in that it only wraps around existing methods. It also only relies on the assumption that route.significant_keys will tell us if :locale is included in this route. That makes it relatively save from future changes to the Rails code.

It allows to call url_helpers using the targeted array parameter syntax and omitting the locale from the parameter list:


article_path(@article)  

… assuming that @article.id == 1 and @current_locale == 'en' this will yield …


/en/articles/1  

If this covers our needs it should work fine and everything’s all nice and dandy.

Problem!

But there’s a shortcoming with this solution: you can’t specify a different locale in any resonable concise manner. E.g. neither of these would work:


article_path('fr') 
article_path('fr', @article) 
article_path(:locale => 'fr')
article_path(:locale => 'fr', :article => @article)

To only switch the current locale (as in “view this article in English, French, German, …”) you’d need to use a monster-like call like this:


article_path(:locale => 'fr', :controller => :articles, :action => :show, :article => @article)     

No, the usual shortcut version wouldn’t work. That’s because @current_locale would be unshifted to the parameter list:


article_path('fr', :articles, :show, @article)  

This would end up with a parameter list like ['en', 'fr', :articles, :show, @article] … and thus throw a routing error.

Really a problem?

One might argue that switching the locale is a relatively seldom needed functionality and this is still a huge improvement above needing to add the locale to each and every url_helper.

Hm, yes. Still … I’d rather like so see some solution to this before trying to integrate it to Globalize.

Rails’ concept of parameter expiration in routes

Now let’s try to understand how it comes that it doesn’t work to just add :locale => 'en' to the hash syntax like in:


article_path(:locale => 'fr', :article => @article) # doesn't work :(

Why?

The reason for this lies buried in a concept that Rails calls “parameter expiration”. It seems hard enough to understand what is going on (Jamis Buck talks a bit about the concept here). But to me it’s quite unclear why exactly Rails does things this way.

Whatever the reason was for inventing this concept in the first place … it seems clearly to be the culprit for us not being able to use such a simple and intuitive call like the one above.

In Rails’ routing system the order of the single path segments that map to a controller, an action and a bunch parameters is crucial: the parameter expiration concept assumes that a change in a segment on the left side of the path (e.g. our :locale parameter) designates the necessity to update or specify all the segments that are on the right of it.

Sounds confusing? Yes. Look here:


# this is /en/articles/show/1 so we have:
# :locale => 'en', :controller => :articles, :action => :show, :id => 1

Now these will work perfectly:


article_url :id => 2
url_for :action => :edit, :id => 1
url_for :controller => :pages, :action => :show, :id => 1
article_url :locale => 'fr', :id => 1

The reason is that any of the “expired” parameters that have changed in comparsion to the current URL are given. “Expired” are parameters that have changed themselves OR are to the right of a changed parameter.

For the same reason these won’t work:


url_for :action => :edit # :id is missing
url_for :controller => :pages, :id => 1 # :action is missing
article_url :locale => 'fr' # everything besides :locale is missing

… because :id, :action and :locale respectively are to the right of a changed parameter (:action, :controller and :locale respectively). As you can see, because :locale is always on the leftmost side of our route it will always expire all of the other parameters of the route - so we need to specify all of them! But this is Rails. So this is only true most of the times … that is, except is it not true. Ahem ;-)

I guess the reasoning for this kind of confusing concept has most probably to do with the fact that it’s a rather uncommon case that parameters are left to the :controller and :action specification (like our :locale).

But on the other hand … I’m wondering if the confusion invented by this is really outweighed by the benefits.

Feedback? Suggestions?

I hope this article - in all it’s half-baked-ness - will get some further thoughts and discussion going.

What do you think?

Leave a comment

5 Comments

  1. Jeremy Hubert said August 6th, 2007 at 08:19 AM  

    Thank you for saying what I was trying to say in a much better fashion, and then some. :)

    Keep up the great work.

  2. Steve said August 8th, 2007 at 08:06 PM  

    I’m working on solving a similar problem - persisting some session-related info either as part of the route or as a parameter so that URLs can be always bookmarkable. In my case, it isn’t language, but the basic problem is still the same - how to smoothly integrate the info without goofing up all the urlhelpers. If anyone finds a good solution for this - even a simple way to just stick a parameter onto the end of the URL via an afterfilter in the Application Controller would work as a patch - please post a link to it.

  3. Sven said August 11th, 2007 at 06:04 PM  

    Thanks Jeremy! :)

    Steve,

    you might want to have a look at my follow-up article “Concise, localized Rails URL helpers? Solved (twice).”

  4. Adam Greene said December 21st, 2007 at 05:47 AM  

    I found this to be INCREDIBLY helpful. I’ve struggled with this on and off, and while I had a working solution it was brittle and broke when moving to rails 2.0.2. This is much more elegant.

    I wanted to pass along a little tweak. If a hash is passed in, the :locale will not be set. That is pretty easy to fix by doing this:

    def #{selector}withlocale(*args) if Hash === args.first args.first[:locale] ||= @locale else args.unshift @locale unless Hash === args.first end end

    Thanks again for this very elegant solution. smashing. Adam

  5. Sven said December 21st, 2007 at 09:29 AM  

    Hi Adam,

    thanks :) Have you seen the localized_url_helpers plugin? See http://www.artweb-design.de/2007/5/13/concise-localized-rails-url-helpers-solved-twice It was the result of what I’ve started here. I’m not sure if this works with Rails 2.0 though (from what I recall, it should)

Leave a comment

Name required
E-Mail and Website optional

If you can read this, you don't use a typical webbrowser that plays nice with CSS.
Please do not fill in anything here!

Hint: You're going to comment a Globalize-related article. If you are going to post a bugreport or ask a usage-related question you might want to consider subscribing to the Globalize users mailinglist instead and post over there. In any case, your comment is appreciated and welcome here, too :-)

Hint: Markdown will be applied to your comment. If you post any code, be sure to escape underscores (like so: \_) if you do not want them to be converted to an <em>phasis.

artweb design
Sven Fuchs
Grünberger Str. 65
10245 Berlin, Germany


http://www.artweb-design.de

Fon +49 (30) 47 98 69 96
Fax +49 (30) 47 98 69 97