Skip to main content

How to write Rails middleware

·10 mins

In my last two posts about Rack, I wrote about the basics of Rack and how to write middleware. If you have no idea what this is about, I recommend reading the last two posts (in the order above). For the rest of you, carry on - today we will see how to write awesome Rails middleware and how to use it in any Rails application. Rails and Rack play together really nice, so keep on reading!

Rails on Rack #

Rails by default has bunch of middleware loaded that is crucial to Rails' working. If you want to see what middleware your Rails app is using, open it up in command line and run:

rake middleware

You will see a big list of middleware classes that your current Rails app is using. This is a sample output on a app that I am working on. Keep in mind that your output may vary.

use ActionDispatch::Static
use Rack::Lock
use #<ActiveSupport::Cache::Strategy::LocalCache::Middleware:0x007f93ff6810d8>
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use BetterErrors::Middleware
use ActionDispatch::RemoteIp
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::ConnectionAdapters::ConnectionManagement
use ActiveRecord::QueryCache
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ParamsParser
use ActionDispatch::Head
use Rack::ConditionalGet
use Rack::ETag
use ActionDispatch::BestStandardsSupport
use Warden::Manager
run MyApplication::Application.routes

The list of middleware is quite big. If you are curious what any of these middleware classes do, check this list out. Cool stuff, right? Lets see what rules and conventions apply to writing Rails middleware and how we can leverage those to write our own middleware.

Setting up a middleware class #

Just like any Rack middleware that we wrote before, we will need to write a middleware class. Since v3.2, Rails gave us the ability to add middleware classes to lib/middleware. So, lets add that class.

# lib/middleware/my_middleware.rb

class MyMiddleware
end

As always, there are some conventions of how Rails middleware should be created. Like any other middleware class, the class needs an initialize method and a call method. Also, the first argument of the initialize method is the application itself, and the first argument of the call method is the request environment.

# lib/middleware/my_middleware.rb

class MyMiddleware
  def initialize app
    @app = app
  end

  def call env
    # do something...
  end
end

Using our middleware #

Rails allows us to mount this middleware class in the middleware stack so the application can use it in runtime. Again, you can see the middleware stack by running

rake middleware

in your command line.

Mounting #

Mounting middleware is usually done in the config/application.rb file. But, Rails also allows us to mount different middleware for different environment. This means that you can mount middleware in any of the config/environments files. Mounting a middleware class is done with the command:

module MyRailsApplication
  class Application < Rails::Application
    *snip*

    config.middleware.use <class-name>, <first-argument>, <nth-argument>

    *snip*
  end
end

Or, in our case:

# config/application.rb
module MyRailsApplication
  class Application < Rails::Application
    *snip*

    config.middleware.use "MyMiddleware"

    *snip*
  end
end

There is this tiny caveat when mounting middleware - note that the class name is written as a string, not as a constant. If you mount a middleware class in the config/application.rb file the class name has to be a string. But, if you load the middleware class in the environment files (i.e. config/environment/development.rb) you can mount is as a constant:

# config/environment/development.rb
module MyRailsApplication
  class Application < Rails::Application
    *snip*

    config.middleware.use MyMiddleware

    *snip*
  end
end

As far as I can tell, the reason for this tiny caveat is when Rails is loading all the required files on server boot, when config/application.rb is loaded not all constants are loaded. But, due to the nature of the envronment files, they seem to be autoloaded at the end, when all of the constants are present in memory.

Mounting the middleware in the stack #

Adding our middleware at a certain point of the middleware stack is done via the insert_before and insert_after commands.

For example, if we, for whatever reason, want to add our MyMiddleware class just before the Rails::Rack::Logger middleware, we can use this line:

module MyRailsApplication
  class Application < Rails::Application
    *snip*

    config.middleware.insert_before "Rails::Rack::Logger", "MyMiddleware"

    *snip*
  end
end

On the flipside, if we want to add our MyMiddleware class after the Rails::Rack::Logger middleware, we can use this line:

config.middleware.insert_after "Rails::Rack::Logger", "MyMiddleware"

Another convenience method that Rails provides us is swap, as in swapping middleware. For example, if you wrote your own params parser middleware, that will substitute ActionDispatch::ParamsParser, you can swap it using:

config.middleware.swap "ActionDispatch::ParamsParser", "MyParamsParser"

This will swap the middleware classes, as in, it will use MyParamsParser instead of ActionDispatch::ParamsParser.

Just to give you an idea, this can be useful when debugging - you can extend the existing middleware, add some logging so you can see how the data mutates in the params parsers and continue with the normal execution of parsing the params. One can enable that middleware just in development mode, so the middleware is swapped only in development environment.

That being said, lets see how we can write our own tiny middleware and mount it to an existing Rails application.

DeltaLogger #

We will write a tiny middleware class that will calculate the delta time of the request and log it to the Rails console. You can open any Rails application that you have laying in your computer and play with it. I promise we won’t do anything malicious. :-)

First, we need a middleware class. Lets add it to the lib/middleware directory. It’s worth mentioning that the directory does not exist by default. So, if it is missing - feel free to create it.

Next, we’ll need to add the initialize and the call method. Remember, the first argument of the initialize method is the application, and the first argument of the call method is the request environment.

# lib/middleware/delta_logger.rb

class DeltaLogger
  def initialize app
    @app = app
  end

  def call env
    # do something...
  end
end

Next, we’ll need to calculate the total time that the applicaiton took to process the request and log it:

# lib/middleware/delta_logger.rb

class DeltaLogger
  def initialize app
    @app = app
  end

  def call env
    request_started_on = Time.now
    @status, @headers, @response = @app.call(env)
    request_ended_on = Time.now

    Rails.logger.debug "=" * 50
    Rails.logger.debug "Request delta time: #{request_ended_on - request_started_on} seconds."
    Rails.logger.debug "=" * 50

    [@status, @headers, @response]
  end
end

As you can see, this is quite trivial. We save the time before the request has been passed onto the rest of the middleware stack and the time after the middleware has finished with the request. Then, we subtract the start time from the end time and we get a number of seconds that the request processing took.

What’s cool in Rails middleware is that we have the Rails application available to us in the scope of the middleware. This allows us to use to Rails logger and log the delta time.

If you managed to add your middleware to the Rails app, go ahead and boot it. When the server boots, issue any request to it. In the logs you will see something like:

==================================================
Request delta time: 1.40877 seconds.
==================================================

This is our DeltaLogger logging the delta time.

Formatting the output #

In one of the examples in the introduction of this post, I mentioned the option of passing arguments to the middleware class. Lets see how we can use this feature to improve the formatting of the DeltaLogger.

Passing arguments is done by adding the arguments after the class name in the mounting command:

config.middleware.use "MyMiddleware", "First Argument", { second: "argument" }, ["nth-argument"]

In our tiny example, we can send through the character that we want the output to be formatted with. Currently, our default formatting is done with the equals sign. Making this customizable is easily done by sending this character as the first argument:

# lib/middleware/delta_logger.rb

class DeltaLogger
  def initialize app, formatting_char = '='
    @app = app
    @formatting_char = formatting_char
  end

  def call env
    request_started_on = Time.now
    @status, @headers, @response = @app.call(env)
    request_ended_on = Time.now

    Rails.logger.debug @formatting_char * 50
    Rails.logger.debug "Request delta time: #{request_ended_on - request_started_on} seconds."
    Rails.logger.debug @formatting_char * 50

    [@status, @headers, @response]
  end
end

Now, we can change the output when adding the DeltaLogger to the middleware stack:

config.middleware.use "DeltaLogger", "*"

It’s worth mentioning that if your Rails application is running, when changing a middleware class you will have to reboot the application so the new changes in the middleware can be picked up. This happens because Rails loads the middleware only once - on boot.

Now, when we send a request in the logs we can see that the DeltaLogger output changed:

**************************************************
Request delta time: 1.40877 seconds.
**************************************************

Logging levels #

Another way to leverage arguments is to make the logging level customizable. For example, you might want to change the logging level. In Rails, there are six different logging levels: :debug, :info, :warn, :error, :fatal, and :unknown. Lets make the logging level customizable.

# lib/middleware/delta_logger.rb

VALID_LOG_LEVELS = [:debug, :info, :warn, :error, :fatal, :unknown]

class DeltaLogger
  def initialize app, log_level
    @app = app
    # Default to :info log level if the user sets an invalid log level.
    @log_level = VALID_LOG_LEVELS.include?(log_level) ?  log_level : :info
  end

  def call env
    request_started_on = Time.now
    @status, @headers, @response = @app.call(env)
    request_ended_on = Time.now

    Rails.logger.send(@log_level, '=' * 50)
    Rails.logger.send(@log_level, "Request delta time: #{request_ended_on - request_started_on} seconds.")
    Rails.logger.send(@log_level, '=' * 50)

    [@status, @headers, @response]
  end
end

Now, in our application.rb (or environment.rb) file, we can set the logger to use the desired loging level:

config.middleware.use "DeltaLogger", :warn

If we reboot the Rails app and send a new request, the logs will show:

[WARN] ==================================================
[WARN] Request delta time: 0.270595 seconds.
[WARN] ==================================================

Be aware that your output may vary because logging output relies on the logger formatter that your application is using.

If you are not seeing the exact output, you can plug this logging formatter in your app:

# lib/delta_formatter.rb

class DeltaFormatter < Logger::Formatter
  def call(severity, time, program_name, msg)
    "[#{severity}] #{String === msg ? msg : msg.inspect}\n"
  end
end

You can use this formatter by including it into application.rb:

*snipped*
require "./lib/delta_formatter"

module MyApplication
  class Application < Rails::Application
    config.autoload_paths += %W( #{config.root}/lib/**/*)

    *snipped*

    config.middleware.use "DeltaLogger", :warn
    config.log_formatter = DeltaFormatter.new
  end
end

After adding this, you’ll need to reboot your server and you should see the output with the severity tag.

Thread safety #

Last of the important topics about Rails middleware is thread-safety. When using Puma or Unicorn web servers, one of the strong sides of these servers is that they are threaded. Since our middleware runs on these servers, it has to be thread-safe, meaning, it should easily spawn multiple duplicates of it so different threads can use different objects of the same middleware.

The easiest and most efficient way to do this is to dup the middleware object that is created in runtime.

# lib/middleware/delta_logger.rb

VALID_LOG_LEVELS = [:debug, :info, :warn, :error, :fatal, :unknown]

class DeltaLogger
  def initialize app, log_level
    @app = app
    # Default to :info log level if the user sets an invalid log level.
    @log_level = VALID_LOG_LEVELS.include?(log_level) ? log_level : :info
  end

  def call env
    dup._call env
  end

  def _call env
    request_started_on = Time.now
    @status, @headers, @response = @app.call(env)
    request_ended_on = Time.now

    Rails.logger.send(@log_level, '=' * 50)
    Rails.logger.send(@log_level, "Request delta time: #{request_ended_on - request_started_on} seconds.")
    Rails.logger.send(@log_level, '=' * 50)

    [@status, @headers, @response]
  end
end

By adding the _call method and duplicating the object we make sure that any instance variables we set in _call will be set on the duped instance, not the original. This allows the web server to use a separate (duped) object for each thread which will contain different data, based on it’s execution.

Outro #

As you can see, there’s quite a bit that needed to be covered in this post. I hope that this explained how you can write your own Rails middleware and mount it in the middleware stack. It’s very easy to get creative with Rails middleware as it iss highly customizable, quite low-level(ish) while you have the whole app available in runtime. Next time, we will work together on building and testing a real-life example of Rails middleware.

Until then, feel free to drop a comment, I would love to hear your opinions, problems and code that you’ve come up with.