Pretty, short urls for every route in your Rails app

… and check why 5600+ Rails engineers read also this

Pretty, short urls for every route in your Rails app

Photo remix available thanks to the courtesy of Moyan Brenn. CC BY 2.0

One of our recent project had the requirement so that admins are able to generate short top level urls (like /cool) for every page in our system. Basically a url shortening service inside our app. This might be especially usefull in your app if those urls are meant to appear in printed materials (like /productName or /awesomePromotion). Let’s see what choices we have in our Rails routing.

Top level routing for multiple resources

If your requirements are less strict, you might be in a better position to use a simpler solution. Let’s say that your current routing rules are like:

resources :authors
resources :posts

#author GET    /authors/:id(.:format)      authors#show
#  post GET    /posts/:id(.:format)        posts#show

We assume that :id might be either resource id or its slug and you handle that in your controller (using friendly_id gem or whatever other solution you use).

And you would like to add route like:

match '/:slug'

that would either route to AuthorsController or PostController depending on what the slug points to. Our client wants Pretty Urls:

/rails-team
/rails-4-0-2-have-been-released

Well, you can solve this problem with constraints.

class AuthorUrlConstrainer
  def matches?(request)
    id = request.path.gsub("/", "")
    Author.find_by_slug(id)
  end
end

constraints(AuthorUrlConstrainer.new) do
  match '/:id', to: "authors#show", as: 'short_author'
end
class PostUrlConstrainer
  def matches?(request)
    id = request.path.gsub("/", "")
    Post.find_by_slug(id)
  end
end

constraints(PostUrlConstrainer.new) do
  match '/:id', to: "posts#show", as: 'short_post'
end

This will work fine but there are few downsides to such solution and you need to remember about couple of things.

First, you must make sure that slugs are unique across all your resources that you use this for. In our project this is the responsibility of services which first try to reserve the slug across the whole application, and assign it to the resource if it succeeded. But you can also implement it with a hook in your ActiveRecord class. It’s up to you whether you choose more coupled or decoupled solution.

The second problem is that adding more resources leads to more DB queries. In your example the second resource (posts) triggers a query for authors first (because the first constraint is checked first) and only if it does not match, we try to find the post. N-th resource will trigger N db queries before we match it. That is obviously not good.

Render or redirect

One of the thing that you are going to decide is whether visiting such short url should lead to rendering the page or redirection.

What we saw in previous chapter gives us rendering. So the browser is going to display the visited url such as /MartinFowler . In such case there might be multiple URLs pointing to the same resource in your application and for best SEO you probably should standarize which url is the canonical: /authors/MartinFowler or /MartinFowler/ ? Eventually you might also consider dropping the longer URL entirely in your app to have a consistent routing.

You won’t have such dillemmas if you go with redirecting so that /MartinFowler simply redirects to /authors/MartinFowler. It is not hard with Rails routing. Just change

constraints(AuthorUrlConstrainer.new) do
  match '/:id', to: "authors#show", as: 'short_author'
end

into

constraints(AuthorUrlConstrainer.new) do
  match('/:id', as: 'short_author', to: redirect do |params, request|
    Rails.application.routes_url_helpers.author_path(params[:id])
  end)
end

Top level routing for everything

But we started with the requirement that every page can have its short version if admins generate it. In such case we store the slug and the path that it was generated based on in Short::Url class. It has the slug and target attributes.

class Vanity::Url < ActiveRecord::Base
  validates_format_of     :slug, with: /\A[0-9a-z\-\_]+\z/i
  validates_uniqueness_of :slug, case_sensitive: false

  def action
    [:render, :redirect].sample
  end
end

url = Short::Url.new
url.slug = "fowler"
url.target = "/authors/MartinFowler"
url.save!

Now our routing can use that information.

class ShortDispatcher
  def initialize(router)
    @router = router
  end
  def call(env)
    id     = env["action_dispatch.request.path_parameters"][:id]
    slug   = Short::Url.find_by_slug(id)
    strategy(slug).call(@router, env)
  end

  private

  def strategy(url)
    {redirect: Redirect, render: Render }.fetch(url.action).new(url)
  end

  class Redirect
    def initialize(url)
      @url = url
    end
    def call(router, env)
      to = @url.target
      router.redirect{|p, req| to }.call(env)
    end
  end

  class Render
    def initialize(url)
      @url = url
    end
    def call(router, env)
      routing    = Rails.application.routes.recognize_path(@url.target)
      controller = (routing.delete(:controller) + "_controller").
        classify.
        constantize
      action     = routing.delete(:action)
      env["action_dispatch.request.path_parameters"] = routing
      controller.action(action).call(env)
    end
  end
end

match '/:id', to: ShortDispatcher.new(self)

You can simplify this code greatly (and throw away most of it) if you go with either render or redirect and don’t mix those two approaches. I just wanted to show that you can use any of them.

Let’s focus on the Render strategy for this moment. What happens here. Assuming some visited /fowler in the browser, we found the right Short::Url in the dispatcher, now in our Render#call we need to do some work that usually Rails does for us.

First we need to recognize what the long, target url (/authors/MartinFowler) points to.

routing = Rails.application.routes.recognize_path(@url.target)
# => {:action=>"show", :controller=>"authors", :id=>"1"}

Based on that knowledge we can obtain the controller class.

controller = (routing.delete(:controller) + "_controller").classify.constantize
# => AuthorsController

And we know what controller action should be processed.

action = routing.delete(:action)
# => "show"

No we can trick rails into thinking that the actual parameters coming from recognized url were different

env["action_dispatch.request.path_parameters"] = routing
# => {:id => "MartinFowler"}

If we generated the slug url based on nested resources path, we would have here two hash keys with ids, instead of just one.

And at the and we create new instance of rack compatible application based on the #show() method of our controller. And we put everything in motion with #call() and pass it env (the Hash with Rack environment).

controller.action(action).call(env)
# AuthorsController.action("show").call(env)

That’s it. You delegated the job back to the rails controller that you already have had implemented. Great job! Now our admins can generate those short urls like crazy for the printed materials.

Is it any good?

Interestingly, after prooving that this is possible, I am not sure whether we should be actually doing it 😉 . What’s your opinion? Would you rather render or redirect? Should we be solving this on application level (render) or HTTP level (redirect) ?

Don’t miss our next blog post

Subscribe to our newsletter below so that you are always the first one to get the knowledge that you might find useful in your everyday programmer job. Content is mostly focused on (but not limited to) Rails, Webdevelopment and Agile. 2200 readers are already enjoying great content and we are regularly included in Ruby Weekly issues.

You can also follow us on Twitter Facebook, or Google Plus

You might also like