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.
Get Test Driving Rails and make your tests faster and easier to maintain.