Using a name for a URL permalink
In this lesson walk through how to use a custom URL/slug and override the built in ID lookup in Rails.
Guide Tasks
  • Read Tutorial

Let's move onto one of our more straightforward models, the Topic model. Even though the model itself is quite straightforward, there is some custom behavior that it needs to perform. Namely it has to override the default behavior for a show action in Rails. Let's look at a basic example of how routing works by default in Rails:

If you have a Topic model and you want to see a specific topic, you would need to go to a route such as:

/topics/123

This has a few negatives:

  1. It's not a great looking route, I typically prefer URLs to be more descriptive

  2. It's not helpful for search engine optimization. Google highly values the words found in a URL, so it's preferable to list out keywords instead of simply an ID

Extending our example from above, what we want the Topic URL too look like might be:

/topics/coding

Doesn't that look better? It's also easier for users to remember and more search engine friendly. For a real world example, notice how the category URLs are structured on Reddit.com?

large

Isn't that easier to remember than something like reddit.com/r/782634893/

Creating the model

First create a git branch so this work can be isolated. Run git checkout -b add-topic. Now let's run the generator for our Topic feature, we know that topics need to have a single attribute: title. I don't create tests in advanced for this since RSpec automatically creates a model spec file for us after running a generator. For topics I'm going to use the resource generator since I want:

  • A database table

  • An ActiveRecord model file

  • An empty controller

  • An empty views directory

  • All of the basic CRUD routes

Run the following command in the terminal:

rails g resource Topic title:string

This will create a skeleton for our Topic, run rake db:migrate and we'll be ready to go!

Implementing Friendly ID

We're going to leverage the Friendly ID ruby gem to accomplish the custom slug feature. So let's add the gem to the Gemfile:

gem 'friendly_id', '~> 5.1'

After running bundle we now have access to all of the custom slug functionality that comes with friendly_id. Let's create some specs to outline the behavior that we want topics to have in the application. We don't have to setup base case tests to check on things such as Topic can be created. Our other specs are going to test this behavior thoroughly so I find it a waste of time to create tests for functionality like that. First we'll setup our model specs:

# spec/models/topic_spec.rb

require 'rails_helper'

RSpec.describe Topic, type: :model do
  before do
    @topic = Topic.create(title: "Sports")
  end

  describe 'validations' do
    xit 'cannot be created without a title' do
    end

    xit 'cannot be created without a slug' do
    end
  end

  describe 'callbacks' do
    xit 'automatically sets the slug value even with a nil value submitted' do
    end
  end
end

If you're unfamiliar with the syntax, by putting x in front of each of the specs it will mark them as pending. I like to do this since I prefer to create a group of specs but then I prefer to implement their functionality one at a time. If you run rspec right now you'll see that it lists out each of the specs as pending instead of failing.

large

Before we even get into implementation you'll notice that we're already missing an attribute: slug. Let's create a migration to add this to the topics table:

rails g migration add_slug_to_topics slug:string

After running rake db:migrate we can now remove the x from each of the validation specs (you could do it one at a time, but I'm not a fan of wasting time on basic specs like this so I'll combine specs for nearly identical functionality). The updated specs should look like this:

# spec/models/topic_spec.rb

  describe 'validations' do
    it 'cannot be created without a title' do
      @topic.title = nil
      expect(@topic).to_not be_valid
    end

    it 'cannot be created without a slug' do
      @topic.slug = nil
      expect(@topic).to_not be_valid
    end
  end

Running these specs will give us an error, as expected. To fix this add in the validation to the model file:

# app/models/topic.rb

class Topic < ActiveRecord::Base
  validates_presence_of :title, :slug
end

Running rspec will now pass those tests. Now let's implement the friendly slug feature. Update the spec like below:

# spec/models/topic_spec.rb

describe 'callbacks' do
  it 'automatically sets the slug value even with a nil value submitted' do
    expect(@topic.slug).to_not eq(nil)
  end
end

With the way that the friendly_id gem works, if no default URL is given for the slug it will auto-generate one, so this spec essentially tests to ensure that friendly ID is working properly, even though our topics will most likely always have a custom slug supplied. Running the tests will give a failure since we haven't implemented the feature yet. Let's do that now. Open up the Topic model file and update it like below:

# app/models/topic.rb

class Topic < ActiveRecord::Base
  extend FriendlyId
  friendly_id :title, use: :slugged
  validates_uniqueness_of :slug
  validates_presence_of :title, :slug
end

This is all we need to do to get the slug functionality working. Open up the rails console by running rails c and test it out with a few different topics:

Topic.create!(title: "Baseball")
Topic.create!(title: "Star Wars")

large

Pretty cool, right? Notice how it automatically makes the slugs URL friendly (hence the name)? We'll need to use a special method to override the default ID lookup method that Rails utilizes, however that will wait for when we're building out the views.

It looks like we ran into a little unexpected behavior from the Gem, since it runs the slug generation process after our callbacks our slug validation spec is always going to fail. If you run rspec you'll see it returns a failure:

Failures:

  1) Topic validations cannot be created without a slug
     Failure/Error: expect(@topic).to_not be_valid
       expected #<Topic id: 2, title: "Sports", created_at: "2016-02-10 01:01:43", updated_at: "2016-02-10 01:01:43", slug: "sports"> not to be valid

I left this in the guide so you could see how the gem works, even setting the slug value to nil will not cause an error or leave the slug value blank. So we can simply remove the slug validation spec and we'll be back to all green.

Lastly, did you see how I included validates_uniqueness_of :slug in the model file? If you open up the console and try running:

Topic.create!(title: "Star Wars")

It creates a slug of "star-wars-7f3801a6-4795-4d17-b11a-58b2d5d10bdb", this is the gem placing a hash at the end of the name to ensure it remains unique. This is helpful since it ensures that you'll never end up with two identical slugs, however it's not a great URL. For this application we're going to have a small number of topics, so it wouldn't make sense to spend the time on the advanced implementation, however we'll implement a more advanced version of friendly ID for our posts that will create a better looking URL that still remains unique.

Resources