Notes to self

Adding slugs to ActiveRecord models in Rails

Here’s how to implement a Sluggable module that turns an ActiveRecord model into one with a user-defined slug for nice page identifiers.

The basic idea of this module is that you’ll be able to find the said record with a slug in the URL and at the same time use path helpers by passing models as usual:

article_path(@article) # => /articles/:slug

To have slug in the URL instead of the id, we can tell Rails router the default param is slug instead of an id:

resources :articles, param: :slug

This works, but you still need to pass the slug parameter explicitely in the path helper:

article_path(slug: @article.slug) # => /articles/:slug

Not so pretty.

But redefining to_param on a model fixes this:

class Article < ActiveRecord::Base
  def to_param
    slug
  end
end

# Later
article_path(@article) # => /articles/:slug

Now that we know how paths work, we can implement a general Sluggable module for user-defined slugs:

# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    # Alphanumeric downcased URL identifier
    before_validation :downcase_slug

    validates :slug,
      presence: true,
      uniqueness: true,
      length: {minimum: 2, maximum: 30},
      format: {with: /\A[a-zA-Z0-9]+\Z/}

    # Avoid slug conflicts with routes
    validates_with ::RestrictedPathsValidator, if: :slug_changed?

    # For nice path helpers
    def to_param
      slug
    end

    private

    def downcase_slug
      self.slug = self.slug.downcase
    end
  end
end

# app/models/articles.rb
class Article < ActiveRecord::Base
  include Sluggable
end

This is a regular model concern that works with a previously added slug column on a model, but one important thing to realize is that you need to be careful about allowing users to choose restricted paths.

You need to avoid controller’s actions and perhaps some extra paths on top.

To this end we’ll define RestrictedPathsValidator:

# app/validators/restricted_paths_validator.rb
class RestrictedPathsValidator < ActiveModel::Validator
  RESTRICTED_PATHS = ArticlesController.action_methods + [
    "admin",
    "admins"
  ]

  def validate(record)
    if RESTRICTED_PATHS.include?(record.slug)
      record.errors.add :slug, :restricted_path
    end
  end
end

Finally, here are some tests you can add to a model implementing slugs including ones checking the controller methods:

require "test_helper"

class ArticleTest < ActiveSupport::TestCase
  test "validation should succeed for valid slug" do
    article = Article.new(name: "Title", slug: "title")
    assert article.valid?
  end

  test "slug is automatically downcased" do
    article = Article.new(name: "Title", slug: "title")
    article.save!

    assert_equal "space", article.reload.slug
  end

  test "validation should fail for invalid slug" do
    article = Article.new(name: "Title", slug: "-invalid-")
    assert_not article.valid?

    article = Article.new(name: "Title", slug: "-invalid-")
    assert_not article.valid?

    article = Article.new(name: "Title", slug: "a")
    assert_not article.valid?
  end

  test "validation should fail for slug referencing regular actions" do
    ArticlesController.action_methods.each do |action|
      article = Article.new(name: "Title", slug: action)
      assert_not article.valid?
      message = if action.include?("_")
        I18n.t("errors.messages.invalid")
      else
        I18n.t("activerecord.errors.messages.restricted_path")
      end
      assert_equal message, article.errors[:slug][0]
    end
  end
end

And that’s really it. The model id stayed in tact, so nothing else has to change in your application.

Check out my book
Interested in Ruby on Rails default testing stack? Take Minitest and fixtures for a spin with my latest book.

Get Test Driving Rails while it's in prerelease.

by Josef Strzibny
RSS