← Home

Concise & transparently localized Rails url_helper methods?

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?