Catch-all routes and routing constraints in Rails applications
I’ve worked with more than one Ruby on Rails application that has a catch-all
GET
route at the end of its config/routes.rb
file:
Rails.application.routes.draw do
resources :posts
resources :users
# This route is defined after all other routes as a catch-all.
get "/*path", to: "fallbacks#show"
end
What this means is that there is a FallbacksController
that handles any
routing that isn’t already handled by previously-defined routes. In this simple
example, a path like /posts/123
, /posts/doesnt-exist
, or /users/brock
would
be rightly handled by the resource routes that have been defined, but some other
path like /about
or /contact
would be routed through
FallbacksController#show
. If the fallbacks controller becomes responsible for
figuring out what content the user wants to view or rendering some other page
like a 404 page:
class FallbacksController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :render_404
def show
# If a page with the given path cannot be found, `#find_by!`
# raises an `ActiveRecord::RecordNotFound` exception.
@page = Page.find_by!(path: params[:path])
end
end
This simple controller action looks the same way many other simple controller
actions look, where the main difference is that we’re handling requests for any
path not already handled by other routes. /about
would route to our static
page with a #path
attribute of /about
. /page-that-doesnt-exist
would route
to a 404.
When a catch-all route accumulates complexity
But the applications I’ve worked on that have implemented a catch-all route like
this don’t intend to just serve content for /about
or /contact
pages. They
all have implemented the catch-all so that they can present connected groups of
pages that have a tree-like structure, like a taxon tree:
/unisex
└── /unisex/jeans
└── /unisex/jeans/raw
└── /unisex/tops
└── /unisex/tops/oversized
And using this taxon tree, they often want to present increasingly filtered-down groups of products for a product listing page.
The Rails applications I’ve worked on that provide this catch-all route have all
stuffed a lot of complexity into their FallbacksController#show
action.
For example, if we decide that some subset of pages should be rendered in some other way:
class FallbacksController < ApplicationController
JEANS_SECTION = "/unisex/jeans"
TOPS_SECTION = /unisex/tops"
rescue_from ActiveRecord::RecordNotFound, with: :render_404
def show
path = params[:path]
# If a page with the given path cannot be found, `#find_by!`
# raises an `ActiveRecord::RecordNotFound` exception.
@page = Page.find_by!(path: params[:path])
if path.starts_with? JEANS_SECTION
render "jeans_listing"
elsif path.starts_with? TOPS_SECTION
render "tops_listing"
else
render "show"
end
end
end
Now, even though we are rendering three different templates, our only dependency
is a @page
record. In this example controller action, the code indicates that
our pages are basically all the same but can now be presented in three different
ways. In my opinion that’s an acceptable amount of complexity.
But this can get out of hand fast if we want other variations of the product
listing pages, or tack on other pages that require a top-level route like
/about
or /contact
:
class FallbacksController < ApplicationController
GRAVEYARD_CATEGORY = "graveyard"
JEANS_SECTION = "/unisex/jeans"
TOPS_SECTION = /unisex/tops"
STATIC_PAGES = ["/about", "/contact"]
rescue_from ActiveRecord::RecordNotFound, with: :render_404
def show
path = params[:path]
# If a page with the given path cannot be found, `#find_by!`
# raises an `ActiveRecord::RecordNotFound` exception.
@page = Page.find_by!(path: params[:path])
# Only include products that have been discontinued.
@products =
if path.includes?(GRAVEYARD_CATEGORY)
Product.where(discontinued_at: ..Time.now, taxons: @page.taxons)
else
Product.where(taxons: @page.taxons)
end
render_404 and return if (@products.none? && !STATIC_PAGES.include?(path))
if STATIC_PAGES.include?(path)
render "static_page"
elsif path.starts_with? JEANS_SECTION
render "jeans_listing"
elsif path.starts_with? TOPS_SECTION
render "tops_listing"
else
render "show"
end
end
end
So here we’ve added a lot of functionality to FallbacksController#show
.
- the rendering of static pages
- the rendering of a “product graveyard” for collections of discontinued products
- the rendering of a 404 page if a collection of
@products
happens to be empty and we’re not trying to render a static page like/about
.
Obviously this is a bit contrived; if we actually needed to render all of these pages from a single controller action, we might restructure it a bit. But hopefully this highlights the fact that… this should have always been served by many controllers.
Using typical Rails routes, undoing this technical debt could be a considerable
amount of work, though, if we don’t want to break the existing routing. And, of
course, maybe we do have requirements for static pages like the about page: that
they are served via /about
rather than /some-controller-prefix/about
.
Reducing complexity using routing constraints
The least-effort way around the controller action stuffing would be use routing contraints, which allows us to stop stuffing all of this complexity into a single controller action. You can express this either as a lamdba or as a class that adheres to Rails’s contraints API:
class PageConstraints
# Matches page paths. To be really nice, we'll optionally account for a
# slash at the beginning and/or end of the request path. i.e. `about`,
# `/about`, `about/`, or `/about/`.
PAGE_PATH_REGEXP = %r{\A/?[^/]*/?\z}
def matches?(request) = request.path.match?(PAGE_PATH_REGEXP)
end
class GraveyardCategoryConstraints
GRAVEYARD_CATEGORY_KEYWORD = "graveyard"
def matches?(request)
request.path.match? %r{.+#{GRAVEYARD_CATEGORY_KEYWORD}.*}
end
end
Rails.application.routes.draw do
resources :posts
resources :users
# Example using a custom constraints lambda.
get "/*path",
contraints: ->(request) {
request.path.match?(Product.known_category_prefixes)
},
to: "products#show"
# Example using a custom constraints class.
constraints GraveyardCategoryConstraints.new do
get "/*path", to: "graveyard_products#show"
end
# Example using a custom constraints class.
contraints PageConstraints.new do
get "/*path", to: "pages#show"
end
# Aaaaaannd the old fallback route.
get "/*path", to: "fallbacks#show"
end
I won’t include example code for all of the individual controllers, but I hope
you can see how splitting the single, monolithic FallbacksController#show
action into four separate controllers would make each of the actions much more
maintainable.