Skip to content

Rack and Its Middleware

Photo of Jan Grela
Jan Grela
Ruby Developer

What Is Rack

Rack is an interface that sits between the web server and the application. What Rack does is wrap requests and responses with additional functionality and pass them through a defined processing flow. This design allows developers to build modular, flexible, and maintainable web applications.

Minimal Rack Application

Rack is distributed as a Ruby gem. To set it up, install Rack and a compatible web server. Many modern servers (such as Puma or Unicorn) recognize a Rack application and can connect to it.

gem install rack puma

Each Rack-based application includes a config.ru file, which serves as the entry point of the application. Create one with the following content:

#config.ru

class App
  def call(env)
    [200, { "Content-Type" => "text/plain" }, ["I am a Rack app\n"]]
  end
end

run App.new

A Rack-based application is an object that responds to the call method, which takes one argument - the environment (env). This argument is a hash that represents an HTTP request.

The call method returns an Array with three elements:

  1. HTTP status code
  2. Headers
  3. Response body with actual content

Next, call the run method with the App object. To execute the application, run the server. It will recognize the file as a Rack application and respond with the defined output.

bundle exec puma

This will start the server on port 9292. You can test it with:

% curl localhost:9292
I am a Rack app

This basic Rack application can be simplified using a lambda (or Proc):

#config.ru

run -> (env) { [200, { "Content-Type" => "text/plain" }, ["I am a Rack app\n"]] }

It works because run only requires an object that responds to call, and Proc implements that interface.

The real power of Rack lies in its middleware - objects that exist between the server and the application. By using middleware, you can customize your application’s flow, as these components provide additional functionality to it.

Rack Middleware and the Pipeline Design Pattern

All requests and responses between the web server and the application go through a pipeline of components called middleware. These internal Rack components handle tasks such as authentication, caching, and request manipulation. Although Rack provides a built-in set of middleware, many gems (such as rack-contrib) provide additional middleware functionalities.

A client request passes through the server and the middleware stack before reaching the application. The response then travels back in reverse order. This design filters and processes both requests and responses. Conceptually, it functions as a stack of components that wrap around your application.

┌──────────────────────────────┐
│           Client             │
└──────────────────────────────┘
Request │            ↑ Response
        ↓            │
┌──────────────────────────────┐
│           Server             │
└──────────────────────────────┘
        │            ↑
        |            │
┌───────↓────────────|─────────┐
│   ┌────────────────────┐     │
│   │    Middleware      │     │
│   └────────────────────┘     │
│       │            ↑         │
│       ↓            │         │
│   ┌────────────────────┐     │
│   │    Middleware      │     │
│   └────────────────────┘     │
│       │            ↑         │
│       ↓            │         │
│   ┌────────────────────┐     │
│   │    Middleware      │     │
│   └────────────────────┘     │
└───────|────────────↑─────────┘
        │            |     Rack
        ↓            │
┌──────────────────────────────┐
│     Application layer        │
└──────────────────────────────┘

Anatomy of Middleware

You are not limited to the provided middleware components; you can write your own. A middleware is essentially a class that implements two methods: initialize and call.

# config.ru

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

  def call(env)
    # Before processing (request goes down)

    # Call the next middleware or app
    status, headers, response = @app.call(env)

    # After processing (response goes up)

    # response from middleware
    [status, headers, response]
  end
end
  • The initialize method receives one required argument: the application object for the next middleware (or the final application).
  • It may also receive custom arguments.
  • The call method receives one argument (env), which describes the environment of the incoming HTTP request.
  • The method must return an array of three elements: the status, headers, and body.
  • During execution, @app.call(env) invokes the next middleware in the chain.
  • Operations performed before this call belong to the request flow (down the stack), while operations after belong to the response flow (up the stack).
  • Middleware that does not call the next layer is known as terminating middleware. It stops the request flow and initiates the response flow.

Using Middleware

To build the middleware stack, Rack provides the use method. Its arguments include the middleware class, optional parameters, and an optional block.

Usage in config.ru:

# config.ru

# Add a middleware layer
use ExampleMiddleware

# Pass argument and a block
use AnotherMiddleware, time: 30 do |record|
  record.name = 'Joe Doe'
end

# The final app that handles requests
run App.new

For more complex applications, the Rack::Builder class can be used:

# config.ru

app = Rack::Builder.new do
  use Middleware1
  use Middleware2
  run App.new
end
run app

Middleware Execution Example

To visualize middleware initialization and execution order, consider the following example:

# config.ru

class Middleware1
  def initialize(app)
    @app = app
    puts "Initialize Middleware 1 with #{app.class} instance"
  end

  def call(env)
    puts "Middleware 1 request flow"
    status, headers, response = @app.call(env)
    puts "Middleware 1 response flow"
    [status, headers, response]
  end
end

class Middleware2
  def initialize(app)
    @app = app
    puts "Initialize Middleware 2 with #{app.class} instance"
  end

  def call(env)
    puts "Middleware 2 request flow"
    status, headers, response = @app.call(env)
    puts "Middleware 2 response flow"
    [status, headers, response]
  end
end

class Middleware3
  def initialize(app)
    @app = app
    puts "Initialize Middleware 3 with #{app.class} instance"
  end

  def call(env)
    puts "Middleware 3 request flow"
    status, headers, response = @app.call(env)
    puts "Middleware 3 response flow"
    [status, headers, response]
  end
end

class App
  def initialize
    puts "Initialize #{self.class} instance"
  end

  def call(env)
    puts "Application layer response"

    [200, { "Content-Type" => "text/plain" }, ["I am a Rack app\n"]]
  end
end

use Middleware1
use Middleware2
use Middleware3
run App.new

Run the server, send a request, and observe the logs:

bundle exec puma
...

curl localhost:9292

Server Logs:

# server logs

# when the server starts - from initialize method
Initialize App instance
Initialize Middleware 3 with App instance
Initialize Middleware 2 with Middleware3 instance
Initialize Middleware 1 with Middleware2 instance

# when a request sent - from call method
Middleware 1 request flow
Middleware 2 request flow
Middleware 3 request flow
Application layer response
Middleware 3 response flow
Middleware 2 response flow
Middleware 1 response flow

We can observe a few things here:

  • The application and middleware components are initialized in reverse order, from the innermost to the outermost layer.
  • The app parameter in each initialize method represents the next component in the flow.
  • The logs show that the request travels down the stack to the application, and the response returns upward in reverse order.
  • Terminating middleware stops the request flow and immediately begins the response flow without reaching the application layer.

Rack and Rails

Rails is fundamentally a Rack application. It includes a config.ru file and employs a middleware pipeline.

To view the Rails middleware stack, run:

rails middleware

Example Output:

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActionDispatch::ServerTiming
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run NewApp::Application.routes

As shown above, Rails is Rack-based and depends heavily on middleware. These are the default middlewares used by Rails.

To add, remove, or reorder middleware, modify the application configuration file - typically config/application.rb.

# config/application.rb

class Application < Rails::Application
  # Add to the end of the stack
  config.middleware.use MyMiddleware

  # Insert before another middleware
  config.middleware.insert_before Rack::Runtime, MyMiddleware

  # Insert after another middleware
  config.middleware.insert_after Rack::Runtime, MyMiddleware

  # Insert at a specific index (0 is the beginning)
  config.middleware.insert 0, MyMiddleware

  # Remove middleware from the stack
  config.middleware.delete Rack::Runtime
end

Application configuration (including the middleware stack) is completed before the Rails app runs, as defined in config.ru.

A Practical Example

Let us write a middleware that produces thumbnails of static image files at a given size. For image processing, Ruby bindings for the vips library are used.

The ImageResizer middleware first checks whether a JPEG file exists under the request path. If it does, the file is read, resized, and written to a buffer to serve as the response content. This middleware responds with the resized image and appropriate image/jpeg headers. It acts as terminating middleware when a valid JPEG is found; otherwise, it passes control to the next component in the pipeline.

# config.ru

require 'vips'

class App
  def call(env)
    [200, {}, ["Rack'n'Roll"]]
  end
end

class ImageResizer
  def initialize(app, width: 300, height: 200)
    @app = app
    @width = width
    @height = height
  end

  def call(env)
    request = Rack::Request.new(env)
    path = request.path_info
    image = find_image(path)

    if image
      image = image.thumbnail_image(@width, height: @height)

      image_data = image.write_to_buffer(".jpg")

      headers = {
        'Content-Type' => "image/jpeg",
        'Content-Length' => image_data.bytesize.to_s
      }

      [200, headers, [image_data]]
    else
      @app.call(env)
    end
  end

  private

  def find_image(path)
    image_extensions = %w(.jpg .jpeg)
    return unless image_extensions.any? { |ext| path.downcase.end_with?(ext) }

    public_path = File.expand_path('public', __dir__)
    image_path = File.join(public_path, path)
    return unless File.exist?(image_path)

    Vips::Image.new_from_file(image_path)
  rescue Vips::Error
    nil
  end
end

use ImageResizer, width: 150, height: 100
run App.new

To test:

  • Place some images under public/images.
  • Run bundle exec puma to start the server.
  • Visit localhost:9292/images/example.jpg.

You should see the resized image if the file is a valid JPEG.

Summary

In Rack-based applications, knowing how to write and use middleware gives you real power over how requests and responses are processed. This modular design lets you build applications from independent layers, each adding its own functionality to the flow. It also makes the system easier to maintain, extend, and test.

Middleware promotes a clear and structured pipeline, where each component has a defined role in handling requests or responses. Understanding Rack and its middleware concept also helps in working with frameworks like Rails, which is built on top of Rack.

Overall, Rack is not only a bridge between the server and the application - it is also a flexible foundation that lets you shape how web applications behave and perform.

Did you like it?

Sign up To Visuality newsletter