Dynamic route handling in Rails with the X-Cascade header
Recently, my team at Ramsey Solutions has
been working on migrating a legacy Ruby on Rails application (call it
legacy-app
) to a new Kotlin on Spring tech stack (call it new-app
). We
wanted to build new-app
in parallel with legacy-app
and slowly migrate
pages from one to the other. Based on our unique circumstances, we chose to
have new-app
sit on the same domain as legacy-app
(call it domain.com
)
but listen on a different context path. We have an Apache Web server that acts
as a layer 7 load balancer, and we can configure different HTTP request paths to
route between the two applications. Our architecture looks roughly like this:
In legacy-app
we have a resource (call it financials
) that sits on the path
/financials
. In new-app
, that same resource sits on /new/financials
. Now
for the fun part. When some users visited /financials
we needed to redirect
them to /new/financials
depending on the decision of an Optimizely feature
flag. Furthermore, we didn't want to intercept any requests to
/financials/:id
. In Rails terms, the index page should be conditionally routed
to another application while the show page should be left alone entirely.
Our simplified routes file looked like this:
Rails.application.routes.draw do
resources :financials
end
Since Rails processes routes in top-down order, we knew we could create a
higher-precedence route for /financials
, but how could it accomplish the logic
we wanted? Upon reading the Rails documentation on routing, we discovered
Request-Based
Constraints
which seemed promising. The constraint would be applied to the route and could
make arbitrary decisions on whether the route applied or not based on the
request object. However, we quickly found that the request object did not have
enough information to make our decision. We needed the full authentication flow
to have run plus our Optimizely SDK to have been loaded by the time we made our
routing decision. Prospects were starting to look bad.
Ideally, what we needed was the ability to send a higher-precedence route all the way to a controller and have the controller decide whether or not to handle the route. The controller would have access to the full functionality we needed. However, clearly that was too late in the routing process. If we were in a controller, the routing decision had already been made.
Then, I discovered this
article
from Ruby Pigeon on the Rails request/response cycle. Amazingly, deep within the
article the author makes note of a feature that solves our problem: the
X-Cascade
header.
The HTTP request/response handling portion of Rails is actually handled by an
entirely different project called Rack. Rack surfaces a series of middleware
that filter the request one-by-one and then likewise for the response.
Interestingly, Rack has a special header called X-Cascade
, and if one of your
Rails controllers sets the header to X-Cascade: pass
, it signifies to Rack
that the controller failed to handle the request and Rack resumes processing
the routes from where it left off! This was exactly what we needed.
With that idea in hand, we augmented our routes file like so:
Rails.application.routes.draw do
scope module: :dynamic_router do
resources :financials, only: %i(index)
end
resources :financials
end
Our higher precedence financials routes are hit first. We're able to name the resource and controller the same because we put it in a module. Furthermore, we're able to carve off only the index route, and it provides us with flexibility in the future.
Our implementation was beautifully simple:
module DynamicRouter
class FinancialsController < ApplicationController
include OptimizelyFeatureFlaggable
before_action :fallthrough_route
def index
redirect_to "https://domain.com/new/financials"
end
private
def fallthrough_route
return if optimizely.decide("send_user_to_new_app").enabled
head :ok, "X-Cascade": "pass"
end
end
end
Our index action simply redirects to the equivalent path on the new-app
.
However, we run a function beforehand that checks our feature flag. If the flag
is enabled, the index action processes as normal. Otherwise, the function sends
that magic X-Cascade: pass
header which stops the controller from processing
the index action and instead falls back to our routes. Then, Rails picks up the
second instance of resources :financials
and uses it to serve the request from
the legacy-app
.
This simple, elegant feature was a game-changer for us!
Published May 11, 2023 by Jacob Chappell