paint-brush
Root level slugs for multiple controllers with FriendlyIdby@mauddev
1,892 reads
1,892 reads

Root level slugs for multiple controllers with FriendlyId

by Maud de VriesAugust 5th, 2017
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Recently I encountered this question, working on the website for <a href="http://www.womenwhocode.com" target="_blank">WomenWhoCode</a>. Here’s the barebone version of our solution.
featured image - Root level slugs for multiple controllers with FriendlyId
Maud de Vries HackerNoon profile picture

The FriendlyId gem lets your app work with ‘friendly’ URL slugs like ‘myapp /cities/amsterdam’. You can map the slug to the root path, but what if you have two resources with friendly slugs, and you want both of them in the root path?

Recently I encountered this question, working on the website for WomenWhoCode. Here’s the barebone version of our solution.

We wanted to achieve that the app can use URLs with slugs on the root level for 2 different and unrelated resources, let’s name them City and Flower. Instead of URLs like myapp/cities/amsterdam, we want the slugs on root level for both resources, so that the app can use myapp/amsterdam and myapp/tulips.

Basic setup FriendlyID

Before we start, a quick reminder of the basic FriendlyId setup:



For Rails 5.1+ and FriendlyId version up to 5.2.1 :Before running the migration, go into the generated migration file and specify the Rails version: class CreateFriendlyIdSlugs < ActiveRecord::Migration**[5.1]**

The crux of our solution is the :history module with the friendly_id_slugs table, which is generated by the friendly_id generator (line 6^). The table has a slugscolumn, and also a sluggable_type and sluggable_id. The sluggable_type is named after the resource where the slug belongs. See? That comes in handy later on!

With FriendlyId set up, we can use the gem’s finder methods. Given a Thing :something with id: 123, we can find them the friendly way; Thing.friendly.find(params[:id]) will return our something whether we pass in the slug or the id. So the application can use both /things/something and things/123 .

For this article we use the friendly_id defaults; check the docs for tweaks and tips.

Goal

With the routing set up as usual with resources: flowers, the show view is pointed at with myapp/flowers/tulip. But now we want the slug at the root level: myapp/tulip.

For a single controller we can do that by adding the path option to the route:


# one controller root level slug# routes.rb

resources :flowers, only: :show, path: ''

… and then /myapp/tulip goes to the flowers controller’s #show action

So far no problem. But now we want the **flowers** slug and the **cities** slug at the root level.

If we add the second route declaration, for cities, also with path:'' , then the Rails router wouldn’t know if it should point to the flowers controller or to the cities controller. Actually: because routes are executed in the order that they appear in routes.rb, it will always point at the controller that is listed first. So, if the Flower routes appear before the City routes, with myapp/amsterdam, it’s the Flower controller that steps in and tries to find a _flower_ with the :slug param, and not the city we were looking for.

Action plan: SlugsController

The solution: instead of using the resources’ own controllers, we are going to add a new SlugsController. We will not be using the resources tables, but the friendly_id_slugs table to find the right record and render its show view (step 1). We’ll route the root level slugs to this new method (step 2). For better UI (and sanitizing?) we’ll normalize the user input (step 3). Finally, we need to make sure that slugs are unique not only for its own model, but also against the other models that share the root level slugs. For that, we’ll need a custom uniqueness validator (step 5), backed up by database constraints (step 6).

Here’s the plan:

  1. Create (or generate) a SlugsController with a :show action that uses the friendly_id_slugs table and its sluggable_type field to distinguish flowers and cities, and to render the view.
  2. Point root level slugs to the new SlugsController
  3. Normalize user input
  4. Create a custom validator for uniqueness of slugs against both models
  5. Add database constraints

Step 1 - Slugs Controller finds correct record

What happens here? First, we find the slug record in the friendly_id_slugs table (L4).

Now that we have access to the sluggable_type, we use the type to differentiate between the models in the :render_view method (L19). Then we use the friendly_id finder methods to set the ivar and render the view. The correct ivar is sent along, so the view can use it and we do not have to go through the resources’ own controller.

But, this won’t work with the current routes, so let’s fix that.

Step 2 - Route slugs to the new SlugsController


Here, we point the slugs to the show action in the slugs controller. The regular routes come first, to keep routes like /myapp/cities working. If you happen to have namespaced routes, like /admin/flowers, make sure to put those higher up in the routes file too, or the slugs controller will go looking for a flower or a city called ‘admin’.

In order for this to work, make sure to check the original show action. Any ivars set there, or any logic performed on the ivars, won’t be available in the view. In our case, it meant that we needed to refactor the show action, and ended up with a nice and clean oneliner: @city = City.friendly.find(params[:id] The rest was moved to a view helper, a scope and instance methods in the model.

Now, let’s make sure that we only accept slugs formatted just like FriendlyId formats slugs.

Step 3 - Normalize user input

The normalizer method strips all characters that are non-word characters, plus the  because FriendlyId uses that as a word-separator. \w in the regex matches letters, numbers, and underscore.

We store the normalized string in @slug_params. With the ivar set here, we don’t need the :slug_params method anymore, so we skip it and change all the slug_params into @slug_params (L6, and 2 times in the render_view method).

The routing and rendering are working. One thing left to do: validating the uniqueness of the slugs. We don’t want a tulip called ‘amsterdam’ to be allowed the slug ‘amsterdam’ if that slug is already in use for the city ‘Amsterdam’.

Step 4 - Validate uniqueness against all slugs: EachValidator

A regular Rails uniqueness validator in the model would ensure that the slugs for one resource be unique. But we have an extra requirement, due to the ‘double routing’ we use. Opposed to the default behaviour of FriendlyId, our flower slugs can’t be the same as an existing city slug.

Therefore we’ll create a custom validator that validates uniqueness against both models. And we’ll use this validator in each model.


The EachValidator is the way to go for validating attributes. It requires the implementation of avalidate_each method. I made the message for invalid slugs the same as the message for standard uniqueness validations.The valid_slug? method (L10) iterates over the array with model names. We need the model constant, not the model name, so we constantize the string.

Then (L13) we use a predicate method from the FriendlyId::FinderMethods module, to check if the slug exists in each model. I am not particularly happy with the return false if-statement, but for now, I liked it a bit better than other options, mostly for readability’s sake.


We break out of the loop as soon as there is a match because any match makes the new slug invalid. In our case, one of the models has a lot more records than the other; that model comes last in the array (in L4).

We call this validator in both models:

#in flower.rb and city.rb:

validates :slug, presence: true, slug: true, if: :slug_changed?

slug:true is the actual call to the custom SlugValidator. It is fired only if and when the slug has changed. In the example project, that happens only on :create; in a real project, that needs fine-tuning.

Step 5 - Add database constraints

One valuable lesson I learned with this issue, is to back uniqueness validations with database constraints. When two users are adding the same slug at the exact same time, the Rails validation may pass. In that case, we want the database to prevent that the equal slugs both are being saved.

We already have database constraints on each model (see above, the first gist, L7 & 8). All we have to do is adding a unique index for the friendly_id_slugs table. (Note the spelling uniq.)

# in terminal:

$ rails g migration AddIndexToFriendlyIdSlugs slug:uniq

# don't migrate yet

This will create the migration file. Remove the add_column line.

# in db/migrate/2017...._add_index_to_friendly_id_slugs.rb




class AddIndexToFriendlyIdSlugs < ActiveRecord::Migration[5.1] def change# remove the following line:add_column :friendly_id_slugs, :slug, :string add_index :friendly_id_slugs, :slug, unique: trueend


# then run$ rails db:migrate

After removing the redundant line from the change method and migrating, the following index is added to db/schema.rb :

t.index ["slug"], name: "index_friendly_id_slugs_on_slug", unique: true

And with this last step, our root level slug system is in place. Yay 🎉 !


For the simplicity of the example project, we use the FriendlyId defaults. Do check the docs and read other examples to make the experience for your users even better. The complete sample project: https://github.com/F3PiX/slugs_example


Thank you, code and text reviewers: Zassmin, Jeanine Soterwood and Tonee (twitter: @tonee). Thanks to Pablo Rivera for the inspiring story on technical posts: https://dev.to/yelluw/how-to-do-technical-blogging I used the tips to write this post.

Read more:

Questions or an opinion on the text or the code? I’d love to hear from you!