Rack Middleware from Scratch
Rack is the foundation that powers nearly every Ruby web framework. Whether you use Rails, Sinatra, or Hanami, at their core these frameworks all speak Rack. Understanding how Rack middleware works gives you deep insight into how requests flow through your application and empowers you to intercept, modify, or reject requests at any point in that flow.
In this tutorial, you will build Rack middleware from scratch. You will learn the exact contract that middleware must fulfill, how the middleware stack processes requests, and how to chain multiple middleware pieces together.
What is Rack Middleware?
Rack middleware is a class that sits between your web server and your application. It can inspect incoming requests, modify them, pass them along to the next piece of the stack, and then inspect or modify the response before it returns to the client.
The key insight is this: middleware is just a wrapper. Each piece of middleware receives a request, does something with it, then either handles the request completely or passes it to the next middleware (or the final app). This creates a chain where each link can transform the request or response.
The Middleware Contract
Any valid Rack middleware must follow a simple contract. It needs to:
- Accept an
appin its constructor - Define a
call(env)method that takes the environment hash and returns a three-element array:[status, headers, body]
The environment hash is a plain Ruby Hash containing all request information. Here are the most important keys you will work with:
REQUEST_METHOD— The HTTP verb (GET, POST, PUT, etc.)PATH_INFO— The path being requestedQUERY_STRING— Everything after the?in the URLHTTP_*— Custom headers (e.g.,HTTP_USER_AGENT)rack.input— The request body as an IO-like object
Building Your First Middleware
Create a new Ruby file and build a simple logging middleware that records every request:
class LoggerMiddleware
def initialize(app)
@app = app
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
timestamp = Time.now.utc.iso8601
puts "[#{timestamp}] #{method} #{path}"
@app.call(env)
end
end
This middleware does three things:
- Extracts the HTTP method and path from the environment
- Logs the request with a timestamp
- Passes the request to the next piece in the chain (the app)
Notice that @app.call(env) returns the three-element response array. We return that same array from our middleware, which is why it works as a pass-through by default.
Building a Request Timer
Let us build something more useful: a timing middleware that measures how long the request took to process:
class TimerMiddleware
def initialize(app)
@app = app
end
def call(env)
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
duration = ((end_time - start_time) * 1000).round(2)
headers['X-Response-Time'] = "#{duration}ms"
[status, headers, body]
end
end
This middleware wraps the app call. It captures the start time, lets the request proceed through the app, captures the end time, and adds a custom header with the duration. The key difference from the logger is that we unpack the response, modify the headers, and return the modified response.
Building a Conditional Redirect Middleware
Now let us build middleware that can reject requests entirely. This redirect middleware sends users to a different path if they try to access a specific route:
class RedirectMiddleware
def initialize(app, from:, to:)
@app = app
@from = from
@to = to
end
def call(env)
path = env['PATH_INFO']
if path.start_with?(@from)
new_path = path.sub(@from, @to)
query = env['QUERY_STRING']
redirect_url = "#{env['SCRIPT_NAME']}#{new_path}"
redirect_url += "?#{query}" unless query.nil? || query.empty?
return [301, {'Location' => redirect_url}, ['Redirecting...']]
end
@app.call(env)
end
end
This middleware checks if the requested path starts with a certain prefix. If it does, it returns a 301 redirect response with the new location. Otherwise, it passes the request along normally.
Combining Middleware with Rack::Builder
Now that you have three middleware classes, let us combine them into a stack. Rack provides Rack::Builder for this purpose:
require 'rack'
# Your simple Rack app
class App
def call(env)
[200, {'Content-Type' => 'text/plain'}, ['Hello from the app!']]
end
end
# Build the middleware stack
app = Rack::Builder.new do
use RedirectMiddleware, from: '/old', to: '/new'
use TimerMiddleware
use LoggerMiddleware
run App.new
end
# Run it with Rack handler
Rack::Handler::WEBrick.run(app, Port: 3000)
The order matters. When a request comes in, it flows through the middleware in this order:
RedirectMiddlewarefirst — can redirect before anything else happensTimerMiddlewaresecond — wraps the timing around everything below itLoggerMiddlewarethird — logs after the request completesAppfinally — the actual application
The response then flows back up through the same middleware in reverse order, which is why middleware can modify both the request going down and the response coming back.
Alternative: Using use Directly
You can also use the simpler use method directly without Rack::Builder:
app = RedirectMiddleware.new(
TimerMiddleware.new(
LoggerMiddleware.new(
App.new
)
)
)
This manual nesting produces the same result as using Rack::Builder, just with more explicit control over the order. Each middleware wraps the next one in the chain.
Understanding the Response Format
All Rack responses must be a three-element array:
- Status — An integer HTTP status code (200, 404, 500, etc.)
- Headers — A hash of response headers
- Body — An enumerable that yields the response body strings
Here is a JSON response example:
def call(env)
json = JSON.generate({ message: 'Hello' })
[200, {'Content-Type' => 'application/json'}, [json]]
end
Notice that the body is an array containing a single string. This is valid because the body just needs to be enumerable.
Middleware That Modifies Request Bodies
Reading and modifying request bodies is trickier because the body can only be read once. Here is a middleware that reads the body, modifies it, and replaces it in the environment:
class BodyReaderMiddleware
def initialize(app)
@app = app
end
def call(env)
# Read the request body
body = env['rack.input'].read
env['rack.input'].rewind # Allow downstream to read it again
# Store parsed body in env for downstream apps
env['request.body'] = body
@app.call(env)
end
end
The rack.input key contains the request body as an IO-like object. After reading it, you must call rewind if anything else needs to read it.
What Comes Next
You have built several middleware pieces from scratch and learned how to combine them into a stack. This pattern is exactly how Rails, Sinatra, and other frameworks organize their request processing.
To continue learning:
- Explore how Rails implements its own middleware stack
- Learn about Rack’s built-in middleware like
Rack::StaticorRack::Deflater - Build a simple authentication middleware that checks for valid tokens
See Also
- Ruby Modules and Mixins — Understanding modules helps you organize middleware code
- Ruby Hashes — The env hash is just a Ruby hash, so mastering hashes makes working with Rack easier
- Ruby Methods — Methods are the building blocks of middleware classes