Subscribe to the Hired Download: our newsletter for top talent like you!

screen-shot-2018-05-02-at-4-27-40-pm

Hyphenated Routes in Rails

If you’ve ever looked at a blog post online, chances are the last thing you really paid any attention to was the URL structure. While the humans in front of screens around the world typically only care about how easy to remember a URL is, search ranking bots actually care about a lot more. The domain name, words in the URL, how those words relate to the actual content, and the length all help play a small role in how well a page ranks in a search engine. While there have been varying statements from Google and SEO experts over the years — regardless of how big, if any, impact using hyphens versus underscores actually has — the recommendation has remained consistent: use hyphens in URLs if possible.

Now that we’ve settled the debate, let’s just set the configuration option in Rails to change the default behavior of creating underscored paths to use hyphens via the… Huh? Rails doesn’t give us the option to use the industry standard of hyphens? Seriously? Well, darn.

Okay… So let’s find a workaround?

Cracking Open the Rails Router

Let’s think about what we want to do a bit first. We simply want to take any defined route in our routes.rb and make it create a mapping with hyphens. In addition, our URL helpers should probably spit out hyphens as well. So how do we typically add routes?

# ...
get '/blog_posts/:post_id'
post '/api/blog_posts/:post_id/comments'
resources :admin_users
# etc

Given this, we should crack open the Rails source code and start searching for definitions for methods like #get#post, and #resources.

With a quick grep, we’ll find definitions for these methods within ActionDispatch::Routing::Mapper::Resources and ActionDispatch::Routing::Mapper::HttpHelpers. I think we might be onto something. The next step we should take is to follow the data until we find something that looks like it’s actually generating a string path.

Each of these methods eventually calls #map_method, which in turn calls #match. This #match method is, incidentally, the first mention of a path variable, which means we’re probably starting to get to where we want. Continuing our investigation of data flow, we’ll eventually find the #map_match method, followed by the #decomposed_match method. Finally within this method we find something that sounds entirely relevant: a call to a method named #add_route!

We could monkey patch this method and basically rewrite it, but there’s a lot going on in the 18 lines of code here. Ideally any time we touch something in Rails, we want to touch as little as possible so that we reduce side effects and also reduce the burden of maintenance as the original method changes. Luckily for us, there’s a line in this method that takes the path, escapes it, and passes it along to a normalizer:

path = Mapping.normalize_path URI.parser.escape(path), formatted

This looks like an ideal opportunity. Instead of modifying the behavior of a huge method that may or may not always get called when adding a route, a single use normalization method sounds like something that might be really easy to monkey patch.

def self.normalize_path(path, format)
 path = Mapper.normalize_path(path)

 if format == true
   "#{path}.:format"
 elsif optional_format?(path, format)
   "#{path}(.:format)"
 else
   path
 end
endr

It looks like this method is just taking a string and a format and giving back the path.

Note: You will notice that this is actually also calling yet another #normalize_path method, but I’ll save us the trouble and say I tested modifying that method and despite its name, it is actually also used to normalize path names as well, which causes errors when it tries to generate symbols for the URL helpers with a hyphen; ie: :my-route_path.

So the #normalize_path method in ActionDispatch::Routing::Mapper::Mapping is taking the string and further normalizing it, and adding a path. It would be nice to just be able to modify this method to also hyphenate instances of underscores before doing what it’s already doing. After all, we basically only want to add a single line, so re-writing the whole thing is overkill and also means we’ll risk deviating from the Rails implementation if it changes much.

Luckily, this is a great use case for the Ruby convenience alias_method, which copies a method definition to a new name. We can save this old implementation, create a new one, and then just call the old one. We”ll retain the current behavior of Rails while inserting a line of behavior into the stack of work being done.

Building a Concern

Rails gives us access to the ActiveSupport::Concern module which makes writing modules to a little cleaner. While I would typically advocate for avoiding Rails conveniences when writing a gem, this particular use case is Rails specific, so it should be fine to leverage other features Rails has to offer.

module TheHyphenator
 extend ActiveSupport::Concern

 included do
   class << self
     alias_method :original_normalize_path, :normalize_path

     def normalize_path(path, format)
       hyphenated_path = hyphenate!(path)
       original_normalize_path(hyphenated_path, format)
     end

     def hyphenate(path)
       path.split('/').map do |token|
         token.start_with?(':') ? token : token.tr('_', '-')
       end.join('/')
     end
   end
 end
end

ActionDispatch::Routing::Mapper::Mapping.include(TheHyphenator)

We’ve now got a module that hyphenates the path and then calls the original implementation of normalization to preserve any work that Rails is doing. By including the module into ActionDispatch::Routing::Mapper::Mapping the use of alias_method saves the original implementation of #normalize_path. We then overwrite the #normalize_path method with our own logic where we hyphenate the path and then pass it along to the original implementation. The #hyphenatemethod might look a little weird due to the token.start_with? ternary. We do this because, as mentioned before, we don’t want to change the name of any variables in the path. For example, /job_post/:job_id should actually route to /job-post/:job_id because symbols can’t have hyphens, plus the convention for variable names in Rails is that we always use underscores.

So there you have it. We just implemented what should be a Rails configuration option by exploring the Rails codebase and then leveraging modules and monkey-patching to modify default routing behavior. Pretty cool right?

Follow Up

Since we just did this exercise “together”, I decided there was no point in making you go do all this yourself, so I rewrote the code into a gem, which you can find at hired/the_hyphenator. Feel free to fork and modify to your heart’s content.

Also, I should note that using this gem requires a lot of thought about how your app is being used. If you include this gem in a new project, you shouldn’t have any issues. However, in a legacy project with things like links in the wild, hardcoded routes in Javascript, and external APIs, you might need to actually go a little further. For our use case, we actually don’t use the gem and have implemented a slightly more robust setup which does a few extra things:

  1. We redirect any GET requests to our application with underscores in the URL to a hyphenated version using Rack::Middleware.
  2. We dug through our codebase and replaced 100+ files worth of specs that were pointing to hardcoded URLs with underscores in them.
  3. We generate named routes using hyphens, but then also generate a duplicate URL at the legacy underscored path for any non-GET requests so that we don’t break any external facing APIs.

Stay tuned for a followup blog post where I’ll show how we met these extra requirements.

Cheers!