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

Publié le 18 juillet 2020 et étiqueté : , .