Write Software, Well

September 09, 2023

Rails Middleware: (Almost) Everything You Need to Know

💡 This is the eighth article in the series on building a web application in Ruby without using Rails. In the previous article, we added support for logging, and this one shows how to extract the logging functionality to a middleware, to keep our application free from peripheral concerns.

Most web applications have some functionality that's common for many (or even all) HTTP requests. For example, an application will authenticate the user and log the incoming HTTP request, add new headers, or check if the request is for a static file and serve it from a CDN without hitting the app server.

Middleware is an elegant way to organize this common functionality, instead of spreading it everywhere in your application code, which should focus on the logic of your application. In its essence, middleware is a self-contained application that operates on a request independently before or after your main application.

As we learned in the The Definitive Guide to Rack for Rails Developers, the Rack protocol provides a simple interface for web applications and web servers to talk to each other. This communication happens through the middleware pipeline.

In this post, we're going to do a deep dive into middleware; especially how and where they fit into your Rails applications. We'll learn what middleware is, why we need them, how they work, and why they're so important. I'll also show you how to create custom middleware for your Rails application and how to test it.

Finally, we'll pick up where we left off in the ruby-no-rails series and extract the logging functionality to a separate middleware.

What You'll Learn:

  • What is Middleware?
  • Why Use Middleware?
  • Create Custom Middleware
  • Middleware API in Rails
  • How to Modify Existing Middleware
  • How to Pass Parameters to Middleware
  • How to Test Middleware Classes
  • Extract Logging Middleware
  • Additional Resources

Sounds interesting? Let's get started.

What is Middleware?

A middleware is nothing but a class with a call method that receives the incoming HTTP request (env) from the web server, processes it before passing it to the Rails application, processes the response received from the application, and returns the response back to the web server.

When we say an application is Rack-compliant, it means the following:

  1. It has a call method that accepts a single argument env, containing all the data about the request, and
  2. It returns an array containing the status, headers, and response.

Because the Rack interface is so simple, you can use any code that implements this interface in a Rack application, allowing you to build small, focused, and reusable applications that work together to provide different functionalities. These mini-components are known as Middleware.

It's best to envision middleware as a series of "layers" HTTP requests must pass through before they hit your application. Each layer can examine the request, modify it, and even reject it entirely.

Here's a simple middleware that prints some text before and after passing the request to the application (or the next middleware in the pipeline).

class CustomMiddleware
def initialize(app)
@app = app
end

def call(env)
puts 'before'
result = @app.call(env)
puts 'after'
result
end
end

Two important things to note here:

  1. The middleware receives the application (or the next middleware) in the constructor.
  2. After receiving the response from the application, it has to return the response to the web server, or the next middleware in the pipeline.
Middleware Diagram

Why Use Middleware?

Middleware is very useful for writing logic that is not specific to your web application, such as authenticating the request, logging, or error handling. It focuses on doing one thing and doing it well.

Middleware sits between the user and the application code. When an HTTP request comes in, the middleware can intercept, examine, and modify it. Similarly, it can examine and modify the HTTP response before forwarding it to the user.

Middleware provides a convenient mechanism for inspecting and filtering HTTP requests entering your application. For example, Rails includes the Rack::MethodOverride middleware that inspects and overrides the HTTP verb of the incoming request.

💡Using middleware simplifies your application code, and it can only focus on the logic related to the application.

Authentication is another good use case for middleware. If the user is not authenticated, the middleware will redirect the user to your application's login screen. However, if the user is authenticated, the middleware will allow the request to proceed further into the application.

You can write middleware to perform a variety of tasks besides authentication. For example, as we'll see later, a logging middleware logs all incoming requests to your application.

There are several middleware (is 'middlewares' a word?) included in the Rails framework. You can see a list of all middleware by running the bin/rails middleware command. The Rails guides also provide a brief description of what each middleware does.

Middleware can perform tasks before or after passing the request deeper into the application. For example, the following middleware performs some task both before and after the request is handled by the application:

module Middleware
class DoSomething
def initialize(app)
@app = app
end

def call(env)
# perform something before passing the request to the application
process_request(env)

response = @app.call(env)

# perform something after receiving the response from the application.
# It should also return the response.
process_response(response)
end
end
end

No matter where a middleware performs its action, it still has to return a valid Rack-compatible response to the next middleware in the pipeline, or the web server.

In the next section, we'll learn how you can create your own middleware.

Let's Add Custom Middleware in Rails

Imagine you want to add a new middleware for your application that does a simple thing: verify a token sent in the header by the client code. If this token matches our secret token, allow the request to proceed. Otherwise, immediately return an error without passing the request to the application.

To accomplish this, let's introduce a custom middleware, which is just a Ruby class with the interface we saw in the introduction. As mentioned above, it will have a call method that takes the incoming request and returns an array containing the HTTP status, headers, and the response body.

# lib/middleware/verify_token.rb

module Middleware
class VerifyToken
def initialize(app)
@app = app
end

def call(env)
request = ActionDispatch::Request.new(env)
if request.headers['token'] != 'my-secret-token'
return [200, {}, ['invalid or missing token']]
end

@app.call(env)
end
end
end

As you can see, in the call method, we'll first verify the token and return an error if it doesn't match our secret token. If it matches, we send the normal response.

Where do you put middleware in your Rails app?

Middleware can't be in app because they can't be reloaded. They should be in lib. - Rafael Franca

Let's create a new folder called middleware in the lib folder and add VerifyToken middleware in it.

We're not done yet, we need to tell Rails to insert this middleware in the middleware stack. Let's do this using the config.middleware object.

Rails Middleware API

Rails provides a simple configuration interface config.middleware for adding, removing, and modifying the middleware stack.

The object returned by config.middleware method is an instance of Rails::Configuration::MiddlewareStackProxy class. As the name suggests, it's a proxy for the Rails middleware stack that lets you configure the middleware for your application.

You can either config.middleware in the application.rb file or one of the environment-specific configuration files under the environments/<env>.rb file.

To add a new middleware, use the config.middleware.use method, passing the name of the Ruby class that acts as middleware. Rails will insert this middleware at the end of the existing middleware stack, meaning it will get executed in the end, just before your application is called.

# config/application.rb

require_relative "../lib/middleware/verify_token"'

module Blog
class Application < Rails::Application
config.middleware.use Middleware::VerifyToken
end
end

That's it! We've successfully added a middleware to verify an incoming request.

Instead of putting your middleware in the end, if you wish to insert it before or after another middleware, you can do so using the insert_before or insert_after method.

config.middleware.insert_before ActionDispatch::Callbacks, VerifyToken

This will insert the VerifyToken callback before the ActionDispatch::Callbacks middleware runs.

How to Modify Existing Middleware

In addition to inserting your own middleware, Rails also lets you modify its existing middleware stack, by allowing you to replace or even delete a middleware.

Replace Middleware

You can replace existing middleware in the middleware stack using config.middleware.swap method. For example,

config.middleware.swap ActionDispatch::RequestId, Custom::RequestId

will replace the ActionDispatch::RequestId module with the Custom::RequestId module.

Delete Middleware

You can delete existing middleware using the config.middleware.delete method.

config.middleware.delete ActionDispatch::Flash

Now the flash middleware won't run when Rails executes the middleware pipeline. You can also verify that it's removed by running the bin/rails middleware command.

Passing Parameters to Middleware

So far, we've run our middleware code without providing any additional parameters. Rails also makes it easy to pass extra parameters. For example, we can pass a default token for the VerifyToken middleware as follows:

# config/application.rb

# Set custom middleware
config.middleware.use Middleware::VerifyToken, default_token: 'another-token'

This parameter will be available in the constructor of the middleware. You can set it to an instance variable and access it anywhere else in the class.

module Middleware
class VerifyToken
def initialize(app, default_token: nil)
@app = app
@default_token = default_token
end

def call(env)
# remaining code
end
end
end

You can pass more than one parameters, if needed.

How to Test Middleware Classes

If you're adding a custom middleware with significant code or logic, it's a good idea to add tests for it. That said, you can simply test the side-effect of the middleware without directly testing the middleware class, as we've seen earlier, and that should be totally fine for most cases.

However, what if you want to actually test the VerifyToken class? How to initialize its dependency app , which is the whole Rails application itself?

Don't worry, you can pass the world's simplest Rack app instead ;)

require "test_helper"
require "middleware/verify_token.rb"

class VerifyTokenTest < ActiveSupport::TestCase
setup do
@app = ->(env) { [200, { 'Content-Type' => 'text/plain' }, [ 'success' ]] }
@middleware = Middleware::VerifyToken.new(@app)
@env = Rack::MockRequest.env_for("/middleware")
end

test 'check presence of a valid token' do
response = @middleware.call(@env)
assert_equal [200, {}, ["invalid or missing token"]], response
end
end

Note that we're using Rack::MockRequest to build the env hash.

Now that you have all the necessary dependencies, you can test your custom middleware to your heart's content.

Extract Logging Middleware in Our No-Rails App

If you haven't been following the Ruby-no-Rails series, this section might not make much sense. I recommend that you start from the first article: Let's Build a Web Application in Ruby without Rails

In the previous article, we implemented the logging functionality for our simple web application written in vanilla Ruby.

Logging in Ruby: (Almost) Everything You Need to Know

However, one thing that's been bothering me is how the logging-related code is scattered throughout our application, i.e. app.rb file.

# app.rb

require 'debug'
require 'logger'
require 'zeitwerk'

require_relative 'config/routes'

class App
attr_reader :logger

def initialize
# ...

@logger = Logger.new('log/development.log')
end

def call(env)
logger.info "#{env['REQUEST_METHOD']}: #{env['REQUEST_PATH']}"

headers = { 'Content-Type' => 'text/html' }

response_html = router.build_response(env)

[200, headers, [response_html]]
rescue => e
logger.error("Oops, something went wrong. Error: #{e.message}")
[200, headers, ["Error: #{e.message}"]]
end

private
# ...
end

If you remember, whenever the Puma web server receives a new HTTP request, it calls the call method on the App class. Since we want to log each and every incoming request, logging is an excellent candidate for extraction as a middleware. That way, it can live in its own module without affecting the rest of the app.

In fact, we're actually already using some middleware in our simple application. The reloader middleware checks if the application code is changed or not and reloads the code if it did. The config.ru file holds the middleware chain.

# config.ru

require 'rack'
require_relative 'app'

# Reload source after change
use Rack::Reloader, 0

# Serve all requests beginning with /public
# from the "public" folder and favicon.ico
# from the current directory
use Rack::Static, urls: ['/public', "/favicon.ico"]

run App.new

The basic idea is to insert the logging middleware into this chain. The logging middleware will log a request, then call the call method of the next handler in the chain, and return the response.

Let's create a new middleware directory in our project and add a logging.rb file in it, which will contain the logging-related code.

# middleware/logging.rb

require 'logger'

class Logging
attr_reader :logger

def initialize(app)
@app = app
@logger = Logger.new('log/development.log')
end

def call(env)
logger.info "#{env['REQUEST_METHOD']}: #{env['REQUEST_PATH']}"
@app.call(env) # call the next middleware or our application
rescue => e
logger.error("Oops, something went wrong. Error: #{e.message}")
[200, { 'Content-Type' => 'text/html' }, ["Error: #{e.message}"]]
end
end

Next, we need to tell Rack to insert this logging middleware just before our main application.

# config.ru

require 'rack'

require_relative 'app'
require_relative 'middleware/logging'

# other middleware

use Logging

run App.new

Finally, we can get rid of all the logging code from app.rb. Here's the diff:

Diff

Nice and clean.

Start the web server by running puma in the terminal, and hit the URL localhost:9292. If you open the development.log file, you'll notice that the application is still logging the incoming requests as expected.

💡Check out the changes in this PR and the complete source code on the middleware branch on Github.

I should probably stop here. If you're still reading this (and I know you are), you should have a much better understanding of the concept of middleware. If you're curious to learn more, here're some great resources for you about middleware.

Additional Resources

Subscribe to get future posts via email (or grab the RSS feed)

Comments (4)

I
Ian

Great article, Akshay! I noticed it looks like the description for your test block was copied and pasted from somewhere else since it doesn't match what's actually being tested: test 'deleting the associated quotes' do response = @middleware.call(@env) assert_equal [200, {}, ["invalid or missing token"]], response end

A
Akshay Khot

Oops! Thanks for pointing it out, Ian. I've updated the test description. Thank you!

F
Fred

Hello and thanks for your post, very well explained. It seems you're always returning a 200 http response code from your middleware. You should probably return a 401 for VerifyToken middleware when the token is invalid and a 500 for Logging when an exception is caught.

A
Akshay Khot

Good catch, Fred. Thanks for pointing it out.

Sign in to leave a comment.