Rack and Its Middleware
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:
- HTTP status code
- Headers
- 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
initializemethod receives one required argument: the application object for the next middleware (or the final application). - It may also receive custom arguments.
- The
callmethod 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
appparameter in eachinitializemethod 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 pumato 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.