Death by Quadratic LogoMenu

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:

Architecture

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

Jacob Chappell
Jacob Chappell is a Senior Software Engineer working for Ramsey Solutions to bring life change to the financial industry. He holds a Master's degree in Computer Science from the University of Kentucky and has been developing software since 2005.