Pretty URLs with Rails 6, FriendlyId, and CanCanCan
While building a Rails 6 application, I needed to generate pretty URLs using a model name. I also wanted these URLs to change when the model name was changed, and redirect to the latest URL if someone visited the old URL. As an added constraint, I’m using the CanCanCan authorization gem to handle rights management. Here is the setup I came up with to redirect old URLs while preserving most of the gem’s expected behaviour.
Adding the gems
Open your Gemfile and add the following lines:
# Gemfile
gem "friendly_id"
gem "cancancan"
Then download and add them to the bundle:
bundle
Installing the gems
Run the gems’ installers and migrations.
bundle exec rails generate friendly_id
bundle exec rails generate cancan:ability
bundle exec rails db:migrate
Setting up the model
extend FriendlyId
adds the gem’s methods and behavior to the model.friendly_id :slug_candidates, use: [:slugged, :history]
tells FriendlyId to:- generate slugs by calling the
slug_candidates
method, - automatically generate slugs,
- historicize previous slugs, to avoid broken links.
- generate slugs by calling the
# app/models/my_model.rb
class MyModel < ApplicationRecord
extend FriendlyId
friendly_id :slug_candidates, use: [:slugged, :history]
validates :name, presence: true
private
def slug_candidates
[:name, [:id, :name]]
end
def should_generate_new_friendly_id?
name_changed? || super
end
end
To note
The slug_candidates
method should return an array of symbols corresponding to method, or strings, which will be used to generate unique slugs.
The should_generate_new_friendly_id?
method is overridden to force slug generation when the underlying attribute changes.
Configuring CanCanCan in controllers
CanCanCan recommends always checking for authorization, skipping checks in specific controllers and actions explicitly.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
check_authorization
rescue_from CanCan::AccessDenied do |exception|
respond_to do |format|
format.json { head :forbidden, content_type: 'text/html' }
format.html { redirect_to main_app.root_url, notice: exception.message }
format.js { head :forbidden, content_type: 'text/html' }
end
end
end
Add load_and_authorize_resource
in controllers that need authorization.
This will automatically fetch the resources the current_user has access to, or raise a CanCan::AccessDenied
otherwise.
Add skip_authorization_check
in controllers that don’t need authorization, i.e. landing pages, custom 404, etc.
Setting up FriendlyId in controllers
By telling FriendlyId
to use the :slugged
module, Rails will automatically use slug instead of id to generate and parse URLs.
However, in order to redirect URLs using a previous slug, we need to override FriendlyId’s default behaviour,
which is to load the resource without emitting warnings or redirecting.
We’ll do that by defining a load_or_redirect
method.
This method (which should be private) loads the appropriate record in a model instance, or redirects if the given slug has been superseded.
In our controllers we use CanCanCan to load and authorize instance models by calling load_and_authorize_resource
.
This method will not attempt to set the instance model if it has already been set.
# app/controllers/my_models_controller.rb
class MyModelsController < ApplicationController
# Redirect if a previous slug is used because FriendlyId's history module
before_action :load_or_redirect, only: :show
# Tell CanCanCan to populate @need and @needs
load_and_authorize_resource
# [Usual actions: index, new, show, edit, update, etc]
private
def load_or_redirect
slug = params.fetch(:id)
@my_model = MyModel.find_by(slug: slug)
return unless @my_model.nil?
@my_model = MyModel.find_by_friendly_id(slug)
raise ActiveRecord::RecordNotFound if @my_model.nil?
redirect_to @my_model, status: :moved_permanently, notice: "You have been redirected, please use the current URL and update your bookmarks."
end