HomeRamblings  ⁄  ProgrammingRuby Language

Creating Dynamic Routes at runtime in Rails 4

Published: January 06, 2014 (over 3 years ago)
Updated: about 2 years ago

I was working on a fairly simple site that has a content management component to it and wanted to find a way to dynamically generate the routes to the pages that were managed on the back-end by the site authors. This article presents an alternative to the usual approach of creating a catch-all route in the routes.rb by generating specific routes to each and every page.

Preamble

Sometimes, its faster to re-invent the wheel than to figure out thousands of lines of other people’s code. Rails is great in that way – you can look at the ecosystem and find lots of great pre-existing solutions or you can roll your own in very short order when the requirements are clear and precise. Such is the situation I found myself in while trying to get one of the popular content management systems working for one of my clients, who wanted something very, very simple to navigate and use. Rails CMS’ like Refinery and LocomotiveCMS are great in their own right, but do carry a learning curve and we happened to have the requirement specs firmly laid out. After fiddling with a few of these open source solutions, we came to the conclusion that it was faster to build than to integrate and refine, so off to the races we went.

The Page model

A simple Page model was created to hold the contents of the page, including title, sub-title, description, and body and authoring info. To route to the page, a #name attribute was added that was basically a sluggerized version of the title (e.g. “About Us” title => “about_us” name), but the user could edit if they desired. The desired result was that http://examplecms.com/about_us led to displaying the “About Us” page. The migration for the Page model follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
class CreatePages < ActiveRecord::Migration
  def change
    create_table :pages do |t|
      t.string :name, null: false, unique: true
      t.string :title, null: false
      t.string :sub_title
      t.text :description
      t.text :body
      t.integer :author_id
      t.timestamps
    end
  end
end

In our first attempt to get routing working for named pages, we found the easiest and most common advice was to add a match-all route at the end of the config/routes.rb file that would send any unmatched routes to the Pages Controller. It looked like this:

1
2
3
4
5
ComingSoon::Application.routes.draw do
  root 'home#index'
  get 'not_found' => 'pages#not_found'
  get '*page' => 'pages#show'
end

And then in the Pages Controller:

1
2
3
4
5
6
class PagesController < ApplicationController
  def show
    @page = Page.find_by_name(params[:page])
    redirect_to not_found_path unless @page
  end
end

This worked as expected, but led to some undesirable side-effects which cropped up as development went on. First, anything and everything that didn’t match was sent to the the Page Controller’s #show action. Anytime we introduced a typo in a Javascript or Stylesheet include tag, we wound up here and a hard to debug issue with resources not being found until we realized the catchall route was the culprit masking the real problem. Worse, when sprockets assets didn’t compile quite right on production, all we saw in the log files was the PagesController#not_found action firing, so it was time to revisit this solution with the question, “what if only just the page routes necessary were drawn on the routes and the catch all could be avoided altogether. To my surprise, it was not easy to find a dynamic run-time solution already out there in the wild. There were a couple gems out there such as circuits, but all pre-dated Rails 4 and were overly complicated. The clue to solving this came from how Devise integrates itself into Rails projects and led to the following solution.

Introducing the Dynamic Router

The first step was to set up a means of injecting the Pages routes into the routing table and triggering a reload of the routes whenever the pages were edited. This led to the DynamicRouter class to encapsulate the (re)loading of the routes: app/models/dynamic_router.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DynamicRouter
  def self.load
    ComingSoon::Application.routes.draw do
      Page.all.each do |pg|
        puts "Routing #{pg.name}"
        get "/#{pg.name}", :to => "pages#show", defaults: { id: pg.id }
      end
    end
  end

  def self.reload
    ComingSoon::Application.routes_reloader.reload!
  end
end

The interesting bit of this code is the “get” matcher injected for each Page in the database. Since the Page#name field doesn’t precede with “/”, we’re prefixing this as we build the route. Since the exact ID of the page is known, we set the ID parameter via the :defaults and it is accessible in the Controller action as params[:id]. This makes it simple to retrieve the page by Page#id as shown here:

1
2
3
4
5
6
class PagesController < ApplicationController
  def show
    @page = Page.find(params[:id])
    redirect_to not_found_path unless @page
  end
end

Generating the Dynamic Routes

To get the dynamic routes generating, the config/routes.rb file was edited to replace the catch-all route with a call on the DynamicRouter.load

1
2
3
4
5
ComingSoon::Application.routes.draw do
  root 'home#index'
  get 'not_found' => 'pages#not_found'
  DynamicRouter.load
end

As you can see here, the DynamicRouter’s #load will be called each time the Rails app loads the routes, which is when the application is first loaded, and if in development mode, whenever the routes.rb file changes. Note that the first route that the root is mapped to is simply a collective resource page which shows collections of news, events, pages, etc. that the site hosts so not subjected to direct editing by the client and hence not a part of the dynamic routing scheme. How you choose to manage your home page is entirely up to you and could conceivably be a dynamically constructed Page as well. The final missing piece is to generate the new page routes whenever a Page was edited by the end-user. For this simple application, an after_save callback was added to the Page model:

1
2
3
4
5
6
7
8
9
10
class Page < ActiveRecord::Base
  validates :name, uniqueness: true, presence: true
  validates :title, presence: true

  after_save :reload_routes

  def reload_routes
    DynamicRouter.reload
  end
end

If I were doing a site that was going to have a few hundred pages, I would probably further refine this to only edit the affected route and only if the Page#name was actually changed by the edit, but this is a simple website with perhaps 5 pages at most, so this solution is kept simple and concise. There you have it, a concise way to generate routes based on models and data in the database and most importantly without resorting to a wildcard route matcher that can potentially open up several security issues, not to mention debugging headaches since your logs won’t be reflecting what’s really going on. If you go with a solution like this, be sure and sanitize the Page#name field before saving and generating routes from it. The above is a very simplistic implementation meant to show only the bare necessities of implementation.

comments powered by Disqus