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
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