One Take on Configuring Rails Routes and asset_host

We recently had an interesting requirement surface. In anticipation of the release of a number of demo environments, our customer requested that system configuration be able to be done at the server level. The goal was to avoid being forced to use source control to manage configuration files.

A little background is in order. The systems that we are working on are pretty large. There are two distinct, decoupled systems, each consisting of between 10 and 40 subsystems. Of these subsystems, a handful on each side are Rails applications. Most others are small Ruby application that talk a central Rinda server.

We already have a configuration strategy in place for the two systems, which involves a central configuration file per system which populates a globally accessible configuration hash. The directory structure looks something like this:

1 /deployment
2 /deployment/system_one
3 /deployment/system_one/etc
4 /deployment/system_one/etc/config.rb
5 /deployment/system_one/rails_app_one
6 /deployment/system_one/non_rails_subsystem_one

The systemone/etc/config.rb file creates the configuration hash and populates it with all of the appropriate configurations. In practice, config.rb actually loads the appropriate configuration file based on an environment variable, i.e. RAILSENV, but we will ignore that here for brevity and clarity:

1 APPLICATION_CONFIGURATION ||= {}
2 APPLICATION_CONFIGURATION[:foo] = "bar"

From the context of a Rails application, this central configuration file is loaded from railsappone/config/environment.rb:

1 # Load the system configuration
2 require File.expand_path("/deployment/system_one/etc/config")
3 
4 # Bootstrap the Rails environment, frameworks, and default configuration
5 require File.join(File.dirname(__FILE__), 'boot')

With this configuration strategy in place already, the team was tasked with setting up multiple demo environments for the entire system, each on a different server and accessed via a different external URL.

We could have attempted to solve the configuration issues by just adding many separate Rails environments, say demo1 and demo2, but there were a couple problems with that.

First, the configurations for the different environments would have been nearly identical. Second, we wanted to avoid the complexity of relying on source control to manage the configurations for each deployment.

In most cases, relying on the Rails convention of setting up a new environment makes a lot of sense. However, the only differences between the environments would be setting the asset_host and routes.

Storing this in source control means that in order to make a change to the external URL means that files under source control need to be modified, checked in and then redeployed to the affected system. It makes much more sense to have some reasonable defaults in source control and then provide a mechanism to override these configurations at the server level.

The solution to this problem ended up being quite simple. We first agreed on an acceptable location for the server configuration file:

1 deployment/config
2 deployment/config/server_config.rb

In order to allow for overriding the system configuration, this code was added to /deployment/system_one/etc/config.rb:

1 # Allow for configuration to be overridden by 
2 # a config file that is not under source control
3 
4 server_config_file = File.expand_path("/deployment/config/server_config.rb")
5 require server_config_file if File.exists?(server_config_file)

The server configuration in deployment/config/serverconfig.rb will be loaded and executed, if it exists. If it doesn’t exist, the default configurations will be used, which is the desirable behavior.

The first task was to configure the assethost. To start with, we add the desired assethost configuration to /deployment/config/serverconfig.rb:

1 APPLICATION_CONFIGURATION[:my_rails_app_asset_host] = "https://www.example.com/my_rails_app/demo"

For each Rails environment that we want the assethost to be configurable from the server configuration file, just add this line to the appropriate config file:

1 config.action_controller.asset_host = APPLICATION_CONFIGURATION[:my_rails_app_asset_host]

You would want to add a separate assethost configuration for each Rails application, and each Rails application would set its own assethost to the appropriate configuration.

For a simple configuration like asset_host, this works great. For routes, though, it gets a bit more complicated. We need a Mapper instance in order to build a route. For example, your routes configuration looks something like this:

1 ActionController::Routing::Routes.draw do |map|
2     map.connect ':controller/:action/:id'
3 end

We initially saw two options.

  1. Create a data structure representing the desired routes, store it in the configuration, then from the routes file iterate through the structure created in the configuration file, creating the appropriate routes;

  2. Store some Ruby code in the configuration so that the context can be passed to the block at the run-time.

This second option eliminates the need for a secondary data structure to represent the routes. How better to configure the routes than with the actual code used to configure them?

It turned out that this was nearly as simple as the assethost configuration. First, let’s define the routes in /deployment/config/serverconfig.rb:

1 APPLICATION_CONFIGURATION[:my_rails_app_routes] = lambda do |map|
2   map.connect 'my_rails_app/demo/:controller/:action/:id'
3   map.connect 'my_rails_app/demo',           :controller => "login", :action => "index"
4   map.login   'my_rails_app/demo/login', :controller => "login", :action => "index"
5 end

We have used the lambda method to convert a block into a Proc object. The Proc object is stored as a value in a hash which can be executed later. The rest should look familiar to anyone using Rails; it is exactly what we would have had in our routes file. Now we can do this:

1 ActionController::Routing::Routes.draw do |map|
2     APPLICATION_CONFIGURATION[:my_rails_app_routes].call(map) if APPLICATION_CONFIGURATION[:my_rails_app_routes]
3     # Install the default route as the lowest priority.
4     map.connect ':controller/:action/:id.:format'
5 end

In order to invoke the Proc object stored in serverconfig.rb, we just send the call message to the Proc stored in the configuration hash, if it exists.

In our implementation, we invoke the Proc configured in serverconfig.rb before all of the default routes, with the assumption that the configured routes should have the highest priority. If we run into a case where this isn’t the case, we can address that problem then.

Jim Suchy, Software Craftsman

Jim Suchy is a developer, a zymologist and a co-organizer of the Chicago Software Craftsmanship and Software Craftsmanship McHenry County user groups.