Vanity URLs for Multiple Controllers in Rails
There is no denying that good URL design is crucial for a successful web application. It's the first thing visitors see of your site and if you did an alright job it's one of the things that they'll be able to recollect.
In my current project I wanted to route top level sections to different controllers, similar to Quora's URL structure.
'http://www.site.com/Nikola-Tesla' => UsersController
'http://www.site.com/Alternating-Current' => TopicsController
The Ruby on Rails router doesn't provide this functionality out the box but with some small changes we can make this work.
Requirements
- Clean URL's without numeric id
- Top level slugs should be able to point to several controllers
- Slug name can be changed, old slug redirects (301) to new one
- Letter case in slugs should be insensitive, redirect to original
The first thing you need to do is decide what model attribute will be used to generate the slug. I used full_name
and title
for the User
and Topic
models respectively.
Stringex
Normally you would need to have a function that converts the string property to a pretty looking slug. Replace spaces with dashes, normalize special characters, etc. Fortunately there is an excellent gem made for this called stringex. Once you have that installed add something along these lines to your model.
acts_as_url :title, sync_url: true, force_downcase: false
If the sync_url
option is set to true it will save the generated slug automatically to the url
attribute of your model. So make sure you add that to your database model and add an index as well since we'll be fetching the model based on that slug.
Override to_param
so the URL's are constructed with the new slug.
def to_param
url
end
Slug DB Table
We're gonna add a dedicated slug table which will hold the slug, the associated model name and id, and the date it was created. This enables 2 functions. To find the controller based on the slug and to keep a history of used slugs.
class CreateSlugs < ActiveRecord::Migration
def change
create_table :slugs do |t|
t.string :url, null: false
t.belongs_to :sluggable, polymorphic: true, index: true
t.datetime :created_at
end
add_index :slugs, :url, unique: true
end
end
Once that migration is added we have to make sure the slugs are copied into the table when a model instance is saved. This goes into the model.
has_many :slugs, as: :sluggable, dependent: :destroy
after_save :create_slug
def create_slug
return if !url_changed? || url == slugs.last.try(:url)
#re-use old slugs
previous = slugs.where('lower(url) = ?', url.downcase)
previous.delete_all
slugs.create!(url: url)
end
The last thing that is needed in the model is a method to retrieve an instance.
def self.find_by_slug(url)
#the second query is sometimes required when an old slug is used, history find
where('lower(url) = ?', url.downcase).first || Slug.where('lower(url) = ?', url.downcase).first.try(:sluggable)
end
Rack Router
This little Rack application will route the URL to the right controller.
class SlugRouter
def self.to(action)
new(action)
end
def initialize(action)
@action = action
end
def call(env)
params = env['action_dispatch.request.path_parameters']
params[:action] = @action
sluggable = Slug.where('lower(url) = ?', params[:slug].downcase).first
model = sluggable.try(:sluggable_type)
raise ActionController::RoutingError.new('Not Found') if !model
controller = [model.pluralize.camelize,'Controller'].join
params[:controller] = model.pluralize.downcase
controller.constantize.action(params[:action]).call(env)
end
end
Add this somewhere in a separate file under lib/slug_router.rb
and use require to make it available in your routes.rb
file. Now it's as easy as adding routes like these.
get '/:slug/edit', to: SlugRouter.to(:edit), as: :edit_slug
get '/:slug', to: SlugRouter.to(:show), as: :slug
match '/:slug', to: SlugRouter.to(:update), via: [:put, :patch]
delete '/:slug', to: SlugRouter.to(:destroy)
Redirects
The last thing to add is a function that redirects old slugs and letter case differences. This gets added to the controller as a before_action
.
def redirect_old_slug
@topic = Topic.find_by_slug(params[:slug])
id, rest = request.path.match(/(\/[^\/]+)(.*)/).captures
slug = slug_path(@topic)
if slug != id
return redirect_to(slug + rest), :status => :moved_permanently
end
end