The Lie You've Been Living
Let's start with a confession: Rails is a callable.
Not metaphorically. Not "if you squint at it." Literally, mechanically, by definition — Rails is an object that responds to call. You pass it a hash, it returns an array. That's the whole thing. The routing DSL, the ActiveRecord integration, the asset pipeline, the mailers — all of it exists so that one call method can do something useful.
Here's the proof:
# config/environment.rb loads your Rails app
require_relative '../config/environment'
# This is your entire Rails application
app = Rails.application
# It responds to call
app.respond_to?(:call) # => true
# Call it directly with a minimal Rack environment
env = {
'REQUEST_METHOD' => 'GET',
'PATH_INFO' => '/',
'rack.input' => StringIO.new,
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => '3000',
'HTTP_VERSION' => 'HTTP/1.1',
}
status, headers, body = app.call(env)
puts status # 200
puts headers # {"Content-Type" => "text/html; charset=utf-8", ...}
body.each { |chunk| print chunk } # your HTML
Run that in a Rails console. It works. No HTTP required, no browser, no WEBrick. Just a hash in and an array out.
This is not a party trick. This is the entire basis of Ruby web development, and understanding it changes how you read framework code, debug middleware issues, and make architectural decisions.
Why This Matters
You've probably been writing Ruby web applications for a while. You know how to define routes, render templates, write controllers, handle authentication. You're productive. The framework handles the HTTP layer and you work at the application layer, which is exactly the correct division of labor in production.
The problem is that "the framework handles it" is a sentence that stops you from understanding what's actually happening. When something breaks in a way that the framework's error messages don't explain clearly, you're stuck. When you need to write middleware, you're cargo-culting examples. When you're evaluating whether to use Sinatra or Roda or some other non-Rails framework, you're guessing.
The Rack specification — which is what makes all of this work — is simple enough to explain completely in one chapter. The HTTP protocol that sits beneath it is simple enough to understand in an afternoon. Once you understand both, the entire ecosystem of Ruby web frameworks becomes readable rather than mysterious.
What This Book Is
This book is about three things:
-
Rack: The protocol that unifies Ruby web development. We'll read the spec, build apps against it directly, write our own server, and implement middleware from scratch.
-
Roda: A web framework that takes Rack seriously. Where Rails uses Rack as a compatibility layer (something your application sits on top of), Roda uses Rack as a foundation (something your application is built out of). The distinction matters.
-
The gap between them: What frameworks actually add, why those additions exist, and when you want them versus when you don't.
What This Book Is Not
This is not a Rails tutorial, a Sinatra tutorial, or a Roda tutorial in the conventional sense. There are plenty of those. This is a book about the layer beneath those frameworks, with enough framework coverage to make the comparison meaningful.
This is also not a "write everything from scratch" manifesto. Frameworks exist because they solve real problems, and Roda is genuinely excellent at what it does. The goal isn't to convince you to abandon your tools; it's to make you fluent enough in the underlying mechanics that you can use your tools with full understanding of what they're doing.
Prerequisites
You should know Ruby reasonably well — blocks, modules, objects, the basics of metaprogramming. You should have written at least one web application in Ruby, probably with Rails or Sinatra. You should be comfortable at the command line.
You do not need to know anything about Rack, Roda, or HTTP internals. We'll cover all of that.
A Note on the Code
Every code example in this book runs. If an example requires a gem, it says so. If it requires Ruby 3.x, it says so. The examples are not simplified pseudocode — they're actual Ruby that does actual things.
The simplest Rack application in this book is nine lines. The server we build from scratch is about a hundred. Neither of these is a toy, even though neither is production-ready. They're instructive, which is more useful.
Acknowledgements
Thanks to Georgiy Treyvus for coming up with the idea for this book.
Let's start by talking about what's actually happening on the wire.
HTTP Is Just Text
Before we talk about Rack, before we talk about Ruby, we need to talk about what's actually going on between the browser and your application. Because once you see it, you can't unsee it, and everything you've been doing will make more sense.
Open a terminal. We're going to make an HTTP request without a browser, without a library, without anything except nc (netcat) and our fingers.
The Actual Wire
$ printf "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" | nc example.com 80
You'll see something like:
HTTP/1.1 200 OK
Content-Encoding: gzip
Accept-Ranges: bytes
Age: 604476
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Mon, 19 Feb 2026 12:00:00 GMT
Etag: "3147526947+gzip"
Expires: Mon, 26 Feb 2026 12:00:00 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (nyb/1D2E)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 648
<!doctype html>
...
That's it. That's HTTP. Text goes in, text comes out. The format is rigid but the transport is a TCP socket, which is just a reliable stream of bytes. HTTP doesn't care that those bytes happen to be ASCII text. The convention is that they are.
The Request Format
An HTTP/1.1 request looks like this:
METHOD /path HTTP/1.1\r\n
Header-Name: header value\r\n
Another-Header: its value\r\n
\r\n
optional body here
The parts:
- Request line:
METHOD PATH HTTP-VERSION— all on one line,\r\nterminated - Headers:
Name: Valuepairs, one per line,\r\nterminated - Blank line: A
\r\non its own signals end of headers - Body: Optional. Present for POST/PUT, usually absent for GET. Length is specified in
Content-Length.
Let's build one by hand:
require 'socket'
# Open a TCP connection
socket = TCPSocket.new('httpbin.org', 80)
# Write a valid HTTP/1.1 request
request = [
"GET /get HTTP/1.1",
"Host: httpbin.org",
"Accept: application/json",
"Connection: close",
"", # blank line = end of headers
"" # body (empty)
].join("\r\n")
socket.write(request)
# Read the response
response = socket.read
socket.close
puts response
Run that. You'll get back a real HTTP response with JSON body. No gems, no frameworks. Just a TCP socket and text.
The Response Format
The response format mirrors the request:
HTTP/1.1 STATUS_CODE REASON_PHRASE\r\n
Header-Name: header value\r\n
Another-Header: its value\r\n
\r\n
body content here
The status line is HTTP-VERSION STATUS-CODE REASON-PHRASE. The status code is what you check in your application code and what browsers act on. The reason phrase ("OK", "Not Found", "Internal Server Error") is informational and largely ignored by machines.
# Parse an HTTP response by hand
response_text = <<~HTTP
HTTP/1.1 200 OK\r
Content-Type: text/plain\r
Content-Length: 13\r
\r
Hello, World!
HTTP
lines = response_text.split("\r\n")
# First line is the status line
status_line = lines.shift
version, code, *reason = status_line.split(' ')
status_code = code.to_i
puts "Version: #{version}" # HTTP/1.1
puts "Status: #{status_code}" # 200
puts "Reason: #{reason.join(' ')}" # OK
# Headers follow until the blank line
headers = {}
while (line = lines.shift) && !line.empty?
name, value = line.split(': ', 2)
headers[name] = value
end
puts "Headers: #{headers.inspect}"
# {"Content-Type" => "text/plain", "Content-Length" => "13"}
# Rest is body
body = lines.join("\r\n")
puts "Body: #{body}" # Hello, World!
What Servers Actually Do
A web server's job, stripped to its core:
- Listen on a TCP port (usually 80 or 443)
- Accept a connection
- Read bytes until you have a complete HTTP request
- Parse the request into a structured format
- Do something with it (your application code runs here)
- Format the response back into HTTP text
- Write it to the socket
- Close the connection (or keep it open for HTTP/1.1 keep-alive)
Step 5 is the only step that varies between applications. Steps 1-4 and 6-8 are the same for every web application ever written. Rack is what formalizes the handoff at step 5.
What Headers Are Actually Doing
Headers are metadata about the request or response. Nothing more. They're just key-value pairs that tell the other side how to interpret what it's receiving.
Some important ones:
Request headers you should know:
Host— which domain the client wants (required in HTTP/1.1, because one IP can serve many domains)Accept— what content types the client can handleContent-Type— what format the request body is in (important for POST)Content-Length— how many bytes in the bodyCookie— cookies, serialized asname=value; name2=value2Authorization— authentication credentials
Response headers you should know:
Content-Type— what format the body is in, e.g.text/html; charset=utf-8Content-Length— how many bytes in the body (so the client knows when it's done)Set-Cookie— asks the client to store a cookieLocation— used with 301/302 redirects to specify where to goCache-Control— caching instructions
Your framework sets most of these for you. But it's worth knowing they're just text in a predictable format.
POST Bodies
When you submit a form, the browser sends a POST request with the form data in the body. The format depends on the Content-Type header:
application/x-www-form-urlencoded (the default):
name=Alice&age=30&city=Auckland
multipart/form-data (for file uploads):
--boundary123
Content-Disposition: form-data; name="name"
Alice
--boundary123
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
[binary file data here]
--boundary123--
application/json (for API clients):
{"name": "Alice", "age": 30, "city": "Auckland"}
When Rails gives you params[:name], it has parsed one of these formats. When it fails in production with a cryptic body-parsing error, now you know where to look.
The Moment Where It Clicks
Here's the thing: HTTP is a protocol designed in 1991 and finalized in 1996. It was designed by people who expected it to be implemented in C and read by humans for debugging. The fact that it's text is a feature, not a coincidence.
This is why you can debug HTTP with nc, with curl -v, with browser DevTools. This is why log lines make sense. This is why you can write a minimal HTTP server in a hundred lines of Ruby (we will).
HTTP/2 and HTTP/3 are binary protocols, which is why you can't nc them as easily. But HTTP/1.1 is still everywhere, and Rack was designed around it.
Putting It Together: A Minimal HTTP Interaction in Ruby
require 'socket'
# Server side: accept one request and respond
server = TCPServer.new(2345)
puts "Listening on :2345"
Thread.new do
client = server.accept
# Read the request line
request_line = client.gets
puts "Got: #{request_line.chomp}"
# Read headers until blank line
headers = {}
while (line = client.gets.chomp) && !line.empty?
name, value = line.split(': ', 2)
headers[name] = value
end
# Build a response
body = "Hello from a real HTTP server!\n"
response = [
"HTTP/1.1 200 OK",
"Content-Type: text/plain",
"Content-Length: #{body.bytesize}",
"Connection: close",
"",
body
].join("\r\n")
client.write(response)
client.close
server.close
end
# Client side: make a request
sleep(0.1) # give server a moment to start
require 'socket'
socket = TCPSocket.new('localhost', 2345)
socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
puts socket.read
socket.close
Save that as http_demo.rb and run it:
$ ruby http_demo.rb
Listening on :2345
Got: GET / HTTP/1.1
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 31
Connection: close
Hello from a real HTTP server!
You just wrote an HTTP server and client from scratch. It handles exactly one request. It has no routing. It ignores the path. But it speaks valid HTTP and it works. Everything that comes after this chapter — Rack, Roda, Rails — is elaborating on this foundation.
The next question is: how do you plug your Ruby code into this in a standardized way, so that your code can run on any HTTP server, and any HTTP server can run your code? That's what Rack solves.
What Rails and Sinatra Are Actually Doing
Now that you know HTTP is text, let's talk about what happens between "the server receives bytes" and "your route handler runs." This is the part that frameworks describe as magic. It isn't.
The Stack
When an HTTP request hits a Rails or Sinatra application, it passes through several layers before your code sees it:
- The HTTP server (Puma, WEBrick, Unicorn) accepts a TCP connection and parses raw HTTP text into a structured Ruby hash.
- That hash gets passed through Rack middleware — a chain of objects that can inspect, modify, or halt the request before it reaches your application.
- Your application receives the (possibly modified) hash, runs your route handler, and returns a status code, headers, and body.
- The response travels back up through the middleware chain.
- The server serializes the response into HTTP text and writes it to the socket.
You control step 3. Rack owns step 2. The server owns steps 1, 4, and 5.
Here's the thing: steps 1 and 3 are what vary between server choices and framework choices. Steps 2 and 4 — the middleware chain — use the same protocol regardless of whether you're using Rails, Sinatra, Roda, or a handwritten Rack app.
Let's Look at Rails
# Gemfile
gem 'rails'
# config.ru (every Rails app has this)
require_relative 'config/environment'
run Rails.application
That run Rails.application line is the whole story. run is a Rack method that calls your object's call method for every request. Rails.application is a callable.
We can inspect what Rails actually does:
# In a Rails console
middleware_stack = Rails.application.middleware
middleware_stack.each do |middleware|
puts middleware.inspect
end
You'll see something like:
#<ActionDispatch::HostAuthorization ...>
#<Rack::Sendfile ...>
#<ActionDispatch::Static ...>
#<ActionDispatch::Executor ...>
#<ActiveSupport::Cache::Strategy::LocalCache::Middleware ...>
#<Rack::Runtime ...>
#<Rack::MethodOverride ...>
#<ActionDispatch::RequestId ...>
#<ActionDispatch::RemoteIp ...>
#<Sprockets::Rails::QuietAssets ...>
#<Rails::Rack::Logger ...>
#<ActionDispatch::ShowExceptions ...>
#<ActionDispatch::DebugExceptions ...>
#<ActionDispatch::ActionableExceptions ...>
#<ActionDispatch::Reloader ...>
#<ActionDispatch::Callbacks ...>
#<ActiveRecord::Migration::CheckPending ...>
#<ActionDispatch::Cookies ...>
#<ActionDispatch::Session::CookieStore ...>
#<ActionDispatch::Flash ...>
#<ActionDispatch::ContentSecurityPolicy::Middleware ...>
#<ActionDispatch::PermissionsPolicy::Middleware ...>
#<Rack::Head ...>
#<Rack::ConditionalGet ...>
#<Rack::ETag ...>
#<Rack::TempfileReaper ...>
That's over twenty pieces of middleware wrapping your application before a single request reaches your router. Most of them are doing something useful. Rack::MethodOverride is what makes _method=DELETE in form submissions work. ActionDispatch::Session::CookieStore is where sessions come from. Rack::ETag generates ETags for conditional GET responses.
At the very bottom of that stack is your router, which dispatches to controllers, which call your code. The router is also just a callable.
Let's Look at Sinatra
Sinatra is simpler, which makes it easier to see the structure:
require 'sinatra/base'
class MyApp < Sinatra::Base
get '/' do
'Hello, World!'
end
end
Sinatra::Base is a Rack application. It has a call method. When you write:
get '/' do
'Hello, World!'
end
...you're adding a route to a routing table that lives inside the call method. The call method looks at the env hash, extracts the HTTP method and path, finds a matching route, and calls your block.
Here's a rough but accurate implementation of what Sinatra's routing core does:
class TinySinatra
def initialize
@routes = {}
end
def get(path, &handler)
@routes[['GET', path]] = handler
end
def post(path, &handler)
@routes[['POST', path]] = handler
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
handler = @routes[[method, path]]
if handler
body = handler.call
[200, {'Content-Type' => 'text/html'}, [body]]
else
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
end
end
That's not a joke. Sinatra's actual implementation is more sophisticated (regex matching, parameter extraction, before/after filters, error handling, template rendering), but the structure is exactly this: a hash of routes, a call method that looks things up in the hash.
Let's verify it works:
app = TinySinatra.new
app.get('/') { 'Hello!' }
app.get('/about') { 'About page.' }
# Simulate what a Rack server does
env = {
'REQUEST_METHOD' => 'GET',
'PATH_INFO' => '/',
'rack.input' => StringIO.new,
}
status, headers, body = app.call(env)
puts status # 200
puts body # ["Hello!"]
env['PATH_INFO'] = '/missing'
status, headers, body = app.call(env)
puts status # 404
What Frameworks Actually Add
Now we can be precise about what you're paying for when you use a framework:
Rails adds:
- A routing DSL that handles parameters, constraints, and named routes
- Controllers with before/after actions, strong parameters, response helpers
- ActiveRecord (this alone is most of the value proposition)
- View rendering with template engines and layouts
- Asset pipeline
- A massive middleware stack with sensible defaults
- Conventions that allow code generation and eliminate boilerplate
- A very large community and ecosystem
Sinatra adds:
- A routing DSL (simpler than Rails's)
- Filters (before/after handlers)
- Template rendering
- A small, optional middleware stack
- Much less convention, more flexibility
What neither adds, because Rack already provides it:
- The protocol for receiving requests and returning responses
- The ability to run on any conforming server
- The middleware interface
This is why you can swap Puma for Unicorn without changing your application. This is why you can write middleware that works in both Rails and Sinatra apps. This is why a Rack app can be embedded inside a Rails app, and a Rails app can be mounted inside a Rack app. They all speak the same protocol.
The Middleware Chain Is Composable
Here's something you can do in Rails that will make the structure visible:
# config/application.rb
module MyApp
class Application < Rails::Application
# Add our own middleware at the front of the stack
config.middleware.use LoggingMiddleware
# Add middleware after a specific existing one
config.middleware.insert_after ActionDispatch::Flash, CustomMiddleware
# Remove middleware we don't need
config.middleware.delete Rack::Runtime
end
end
And here's a middleware that you could add to Rails, Sinatra, or a bare Rack app without modification:
class LoggingMiddleware
def initialize(app)
@app = app
end
def call(env)
start = Time.now
status, headers, body = @app.call(env)
elapsed = Time.now - start
puts "[#{status}] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} (#{elapsed.round(4)}s)"
[status, headers, body]
end
end
This is not framework-specific code. It's Rack code. It works because both Rails and Sinatra are Rack applications, and this is a Rack middleware.
The Call Stack
When a request comes in, execution looks like this:
Server.call(env)
LoggingMiddleware.call(env)
Rack::Session::Cookie.call(env)
Rack::MethodOverride.call(env)
YourApplication.call(env)
# Your route runs here
# Returns [200, headers, body]
# MethodOverride gets [200, headers, body]
# Session middleware gets [200, headers, body]
# Logging middleware gets [200, headers, body]
# Server sends the response
Each layer wraps the next. Each layer can modify the request env before passing it down, and modify the response before passing it up. The pattern is: wrap the inner app, call it, do something with the result.
This is just function composition, with objects instead of functions. If you've worked with function pipelines in Elixir or middleware in Express.js, it's the same idea.
Why This Matters for You
When something goes wrong in a web application, it happens at one of these layers:
- The server layer: connection issues, SSL errors, timeout behavior
- The middleware layer: session corruption, cookie issues, CSRF failures, content encoding problems
- The routing layer: 404s, parameter parsing, path matching
- The application layer: your actual code
When you don't know these layers exist, every bug is mysterious. When you do, you can narrow it down quickly. Is the bug in your code, or is it in the middleware below your code? Add a middleware that logs the env before your code runs. Is the response wrong, or is a middleware above you rewriting it? Log the response after your code runs.
The tools for this kind of debugging are available to you the moment you understand that your application is wrapped in a stack of callables.
That's what frameworks are doing. They're arranging callables in a useful order and providing defaults that most applications need. The next step is to look at the protocol that makes all of this possible.
The Rack Spec (It Fits on a Napkin)
The Rack specification defines the interface between Ruby web servers and Ruby web applications. It was designed by Christian Neukirchen in 2007, and despite fifteen years of Ruby web development since then, it hasn't needed fundamental changes. Simple things tend to be durable.
Here's the spec:
A Rack application is a Ruby object (not a class) that responds to
call. It takes exactly one argument, the environment, and returns a non-frozen Array of exactly three values: the status, the headers, and the body.
That's it. Three rules:
- Your app is an object (not a class — an instance)
- It has a
callmethod that takes an environment hash callreturns[status, headers, body]
Everything else is elaboration.
The Environment Hash
The environment (called env by convention) is a Ruby Hash containing information about the current request. The server populates it. Your application reads from it.
The Rack spec requires these keys:
| Key | Type | Description |
|---|---|---|
REQUEST_METHOD | String | "GET", "POST", "PUT", etc. |
SCRIPT_NAME | String | Mount point of the application (often "") |
PATH_INFO | String | Path component of the URL, e.g. "/users/42" |
QUERY_STRING | String | Query string without ?, e.g. "page=2&sort=name" |
SERVER_NAME | String | Hostname, e.g. "example.com" |
SERVER_PORT | String | Port as a string, e.g. "80" |
HTTP_* | String | HTTP request headers, upcased with hyphens replaced by underscores |
rack.version | Array | Rack version, e.g. [1, 3] |
rack.url_scheme | String | "http" or "https" |
rack.input | IO-like | The request body, readable via read, gets, each |
rack.errors | IO-like | Error stream (usually $stderr) |
rack.multithread | Boolean | Whether the server is multi-threaded |
rack.multiprocess | Boolean | Whether the server is multi-process |
rack.run_once | Boolean | Whether this process will handle only one request |
rack.hijack? | Boolean | Whether the server supports connection hijacking |
In practice, you'll mostly use REQUEST_METHOD, PATH_INFO, QUERY_STRING, and HTTP_* headers. The rack.input stream is important for POST bodies.
Some real-world additions that aren't in the base spec but you'll encounter:
rack.session— your session data (added by session middleware)rack.logger— a logger (added by logger middleware)action_dispatch.*— Rails-specific additionsHTTP_COOKIE— cookies as a string ("name=value; other=thing")
The Response Array
The response is [status, headers, body]:
Status: An integer HTTP status code. 200, 201, 301, 404, 500. That's it.
status = 200
Headers: A Hash of response headers. Keys are strings. Values are strings.
headers = {
'Content-Type' => 'text/html; charset=utf-8',
'Content-Length' => '13',
}
Body: An object that responds to each, yielding string chunks. Usually an Array of strings, sometimes an IO object for streaming.
body = ["Hello, World!"]
# Or for streaming:
body = SomeObject.new
def body.each
yield "chunk 1"
yield "chunk 2"
yield "chunk 3"
end
The full minimal response:
[200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]
The Simplest Possible Rack App
# hello.rb
require 'rack'
app = lambda do |env|
[200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]
end
Rack::Handler::WEBrick.run app, Port: 9292
Run it:
$ gem install rack
$ ruby hello.rb
[2026-02-19 12:00:00] INFO WEBrick 1.7.0
[2026-02-19 12:00:00] INFO ruby 3.3.0
[2026-02-19 12:00:00] INFO WEBrick::HTTPServer#start: pid=12345 port=9292
Then:
$ curl http://localhost:9292
Hello, World!
The lambda is a Rack application. It takes env, returns [status, headers, body]. The spec is satisfied.
The config.ru Format
Most Ruby web servers look for a config.ru file in the current directory. It's processed by Rack::Builder, which gives you a small DSL:
# config.ru
require_relative 'app'
use MyMiddleware # add middleware to the stack
use AnotherMiddleware, option: 'value'
run MyApplication.new # the innermost app
useadds a middleware layerrunsets the inner applicationmapmounts apps at different paths (more on this later)
You can run any config.ru with:
$ rackup # uses config.ru in current directory
$ rackup myapp.ru # uses a specific file
rackup figures out the best available server and starts it.
Reading the Environment
Here's a Rack app that echoes back what it received:
# echo.ru
require 'json'
app = lambda do |env|
# Collect interesting parts of the env
info = {
method: env['REQUEST_METHOD'],
path: env['PATH_INFO'],
query_string: env['QUERY_STRING'],
headers: env.select { |k, _| k.start_with?('HTTP_') },
}
# Read the body if there is one
body = env['rack.input'].read
info[:body] = body unless body.empty?
response_body = JSON.pretty_generate(info)
[
200,
{
'Content-Type' => 'application/json',
'Content-Length' => response_body.bytesize.to_s,
},
[response_body]
]
end
run app
$ rackup echo.ru &
$ curl -X POST http://localhost:9292/test?foo=bar \
-H 'Content-Type: application/json' \
-d '{"hello": "world"}'
{
"method": "POST",
"path": "/test",
"query_string": "foo=bar",
"headers": {
"HTTP_HOST": "localhost:9292",
"HTTP_USER_AGENT": "curl/7.88.1",
"HTTP_ACCEPT": "*/*",
"HTTP_CONTENT_TYPE": "application/json",
"HTTP_CONTENT_LENGTH": "18"
},
"body": "{\"hello\": \"world\"}"
}
Notice that Content-Type in the request becomes HTTP_CONTENT_TYPE in the env. The transformation is: HTTP_ prefix + uppercase + hyphens become underscores. The Host header becomes HTTP_HOST. User-Agent becomes HTTP_USER_AGENT.
There are two exceptions: Content-Type is available as both HTTP_CONTENT_TYPE and CONTENT_TYPE (without the HTTP_ prefix), and Content-Length is CONTENT_LENGTH. This is for historical compatibility.
Validation: Does Your App Comply?
Rack::Lint is a middleware that validates Rack compliance. Wrap your app with it during development:
# config.ru (development)
require 'rack'
app = lambda do |env|
[200, {'Content-Type' => 'text/plain'}, ['Hello']]
end
# Lint will raise on any spec violation
use Rack::Lint if ENV['RACK_ENV'] == 'development'
run app
Rack::Lint will raise an exception if:
- Your app doesn't return a three-element array
- The status isn't an integer
- Headers aren't a hash of strings
- The body doesn't respond to
each - The body elements aren't strings
- The env is missing required keys
It's useful when writing new middleware or apps. You won't see many Rack violations in production code because frameworks handle this — but when writing bare Rack code, Rack::Lint is your first line of defense.
The Spec Is Deliberately Minimal
The Rack spec doesn't say anything about:
- How to parse query strings
- How to parse cookies
- How to handle sessions
- How to do routing
- How to render templates
- How to parse JSON or form bodies
These are all optional. You can build them yourself, use Rack's helpers, or use a framework. The spec only defines the handshake between server and application, not what the application does with the request.
This minimalism is intentional and correct. It means any Ruby object that can accept a hash and return a three-element array is a web application. It means a Rails app and a Sinatra app and a Roda app and a hand-rolled lambda all speak the same language at the boundary between server and application.
The result is an ecosystem where you can mix and match: Rails routes can mount Sinatra apps, Sinatra apps can mount Rack apps, everything can be wrapped in arbitrary middleware, and the server doesn't care what you're running as long as you respond to call.
The Napkin Version
If you had to write the Rack spec on a napkin, it would say:
call(env) -> [status, headers, body]
env: Hash of CGI-style variables + rack.* keys
status: Integer HTTP status code
headers: Hash of {String => String}
body: Responds to each, yields strings
Everything else — sessions, routing, templates, auth — is above this abstraction. The abstraction itself is simple enough to hold in your head, which means you can reason about it clearly when things go wrong.
Next: let's use it.
Your First Rack App (No Training Wheels)
Let's build a Rack application that actually does something. Not a "hello world" one-liner, but a proper small application with routing, multiple responses, and request handling. We'll do it without a framework, using only the rack gem and Ruby's standard library.
Setup
mkdir rack-from-scratch
cd rack-from-scratch
bundle init
Add to your Gemfile:
gem 'rack'
bundle install
The Application
We're going to build a small API that manages a list of notes. In-memory storage, no database, no ORM. Just a hash and some Rack.
# app.rb
require 'json'
class NotesApp
def initialize
@notes = {}
@next_id = 1
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
case [method, path]
when ['GET', '/notes']
list_notes
when ['POST', '/notes']
create_note(env)
else
# Match /notes/123
if (match = path.match(%r{\A/notes/(\d+)\z}))
id = match[1].to_i
case method
when 'GET' then show_note(id)
when 'DELETE' then delete_note(id)
else method_not_allowed
end
else
not_found
end
end
end
private
def list_notes
json_response(200, @notes.values)
end
def show_note(id)
note = @notes[id]
return not_found unless note
json_response(200, note)
end
def create_note(env)
body = env['rack.input'].read
data = JSON.parse(body)
note = {
'id' => @next_id,
'content' => data['content'].to_s,
'created' => Time.now.iso8601,
}
@notes[@next_id] = note
@next_id += 1
json_response(201, note)
rescue JSON::ParserError
json_response(400, {'error' => 'Invalid JSON'})
end
def delete_note(id)
return not_found unless @notes.key?(id)
@notes.delete(id)
[204, {}, []]
end
def not_found
json_response(404, {'error' => 'Not found'})
end
def method_not_allowed
json_response(405, {'error' => 'Method not allowed'})
end
def json_response(status, data)
body = JSON.generate(data)
[
status,
{
'Content-Type' => 'application/json',
'Content-Length' => body.bytesize.to_s,
},
[body]
]
end
end
# config.ru
require_relative 'app'
run NotesApp.new
Start it:
$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.x
* Min threads: 0, max threads: 5
* Listening on http://127.0.0.1:9292
Using It
# Create a note
$ curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "Rack is just a contract"}' | jq .
{
"id": 1,
"content": "Rack is just a contract",
"created": "2026-02-19T12:00:00+00:00"
}
# Create another
$ curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "env is just a hash"}' | jq .
{
"id": 2,
"content": "env is just a hash",
"created": "2026-02-19T12:00:01+00:00"
}
# List all notes
$ curl -s http://localhost:9292/notes | jq .
[
{"id": 1, "content": "Rack is just a contract", "created": "..."},
{"id": 2, "content": "env is just a hash", "created": "..."}
]
# Get one note
$ curl -s http://localhost:9292/notes/1 | jq .
{"id": 1, "content": "Rack is just a contract", "created": "..."}
# Delete a note
$ curl -s -X DELETE http://localhost:9292/notes/1
# 204 No Content, empty body
# Confirm deletion
$ curl -s http://localhost:9292/notes/1 | jq .
{"error": "Not found"}
# Invalid JSON
$ curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d 'not json' | jq .
{"error": "Invalid JSON"}
This is a functional REST API. No framework. No router gem. About 80 lines of Ruby.
What We're Missing
This is a good moment to notice what we haven't done:
No request parsing helpers. We read env['rack.input'].read directly and parsed JSON ourselves. For URL-encoded form data, we'd need to parse name=value&other=thing ourselves, or reach for Rack::Utils.parse_query.
No URL helpers. We matched routes with a case statement and regex. This works but doesn't scale gracefully.
No content negotiation. We ignore the client's Accept header. A real API should check whether the client wants JSON before sending JSON.
No error handling for the whole app. If something explodes with an unexpected exception, Rack's handler returns a 500 with a generic page. We'd want to catch and format that ourselves.
No middleware. No logging, no session handling, no CORS headers.
These aren't criticisms — they're deliberate omissions to keep the example clear. For production, you'd add them, or use a framework that provides them as defaults.
Adding Rack's Own Helpers
The rack gem includes utilities you can use without a framework. Let's use a couple:
require 'json'
require 'rack'
class NotesApp
def call(env)
request = Rack::Request.new(env)
method = request.request_method
path = request.path_info
# Parse query parameters automatically
page = request.params['page']&.to_i || 1
# Check content type on POST
if request.post? && !request.content_type&.include?('application/json')
return json_response(415, {'error' => 'Content-Type must be application/json'})
end
# ... rest of routing ...
end
end
Rack::Request wraps the env hash and provides methods like:
request.get?,request.post?,request.delete?request.path_info— same asenv['PATH_INFO']request.params— merged GET and POST params, URL-decodedrequest.body.read— the request bodyrequest.content_typerequest.cookies— parsed cookie hashrequest.xhr?— true if it's an XMLHttpRequestrequest.ip— client IP address
And Rack::Response for building responses:
def json_response(status, data)
body = JSON.generate(data)
response = Rack::Response.new
response.status = status
response['Content-Type'] = 'application/json'
response.write(body)
response.finish # returns [status, headers, body]
end
These are thin wrappers around the same env hash and response array. They don't add framework overhead — they add ergonomics.
Writing a Test
Because this is plain Ruby, testing is straightforward. You don't need a test server. You just call call with a fake env:
# test_app.rb
require 'minitest/autorun'
require 'json'
require 'rack/mock'
require_relative 'app'
class TestNotesApp < Minitest::Test
def setup
@app = NotesApp.new
end
def test_empty_list
status, headers, body = get('/notes')
assert_equal 200, status
assert_equal 'application/json', headers['Content-Type']
assert_equal [], JSON.parse(body.join)
end
def test_create_note
status, headers, body = post('/notes', '{"content": "test note"}')
assert_equal 201, status
data = JSON.parse(body.join)
assert_equal 'test note', data['content']
assert data['id']
end
def test_show_note
_, _, body = post('/notes', '{"content": "hello"}')
id = JSON.parse(body.join)['id']
status, _, body = get("/notes/#{id}")
assert_equal 200, status
assert_equal 'hello', JSON.parse(body.join)['content']
end
def test_not_found
status, _, body = get('/notes/999')
assert_equal 404, status
assert_equal 'Not found', JSON.parse(body.join)['error']
end
def test_delete_note
_, _, body = post('/notes', '{"content": "delete me"}')
id = JSON.parse(body.join)['id']
status, _, _ = delete("/notes/#{id}")
assert_equal 204, status
status, _, _ = get("/notes/#{id}")
assert_equal 404, status
end
private
def get(path)
env = Rack::MockRequest.env_for(path, method: 'GET')
@app.call(env)
end
def post(path, body)
env = Rack::MockRequest.env_for(path,
method: 'POST',
input: body,
'CONTENT_TYPE' => 'application/json'
)
@app.call(env)
end
def delete(path)
env = Rack::MockRequest.env_for(path, method: 'DELETE')
@app.call(env)
end
end
Rack::MockRequest.env_for builds a valid Rack env hash for testing purposes. Run it:
$ ruby test_app.rb
Run options: --seed 12345
# Running:
.....
Finished in 0.001s, 4000.0 runs/s.
5 runs, 8 assertions, 0 failures, 0 errors, 0 skips
Five tests, sub-millisecond runtime, no HTTP server, no magic. The application is a Ruby object. You test it like one.
The Insight
Here's the moment this chapter promised:
A web application is a function. It takes input (the env hash) and returns output (status, headers, body). Testing it is exactly as easy as testing any other function. The fact that it handles HTTP is incidental to what it actually is: an object with a call method.
This is why Rack applications are easy to compose, easy to test, and easy to reason about. The framework complexity you're accustomed to isn't inherent to web development — it's a response to problems that arise at scale. At small scale, or with the right tools, you don't always need it.
Next: let's build the thing that calls your app — the server itself.
Build a Rack Server from Scratch
A Rack server has one job: accept HTTP connections, parse them into a Rack env hash, call your application, and serialize the response back into HTTP. Let's build one.
This isn't a production server. It handles one request at a time, ignores keep-alive, has no TLS, and will fall over under load. It is, however, a real HTTP server that speaks valid HTTP/1.1 and can run actual Rack applications. Understanding it will demystify everything that happens before your application code runs.
The Structure
A Rack server needs to:
- Listen on a TCP port
- Accept connections in a loop
- Parse the HTTP request into a Rack env hash
- Call the application with the env
- Serialize the
[status, headers, body]response into HTTP - Write it to the socket
Let's build each piece.
Step 1: The TCP Listener
require 'socket'
server = TCPServer.new('0.0.0.0', 9292)
puts "Listening on http://localhost:9292"
loop do
client = server.accept
# handle client
client.close
end
TCPServer.new opens a socket. server.accept blocks until a connection arrives, then returns a TCPSocket representing that connection. Straightforward.
Step 2: Parsing the HTTP Request
HTTP requests look like this:
GET /path?query=string HTTP/1.1\r\n
Host: localhost:9292\r\n
Accept: text/html\r\n
\r\n
We need to parse this into a Rack env hash. The tricky parts are:
- Headers end at a blank line (
\r\nalone) - The body follows the blank line, if
Content-Lengthis set - Header names become
HTTP_UPPERCASED_WITH_UNDERSCORES
def parse_request(client)
# Read the request line
request_line = client.gets&.chomp
return nil unless request_line
method, full_path, http_version = request_line.split(' ', 3)
path, query_string = full_path.split('?', 2)
# Read headers until blank line
headers = {}
while (line = client.gets&.chomp) && !line.empty?
name, value = line.split(': ', 2)
headers[name] = value
end
# Read body if Content-Length is present
body = ''
if (length = headers['Content-Length']&.to_i) && length > 0
body = client.read(length)
end
# Build the Rack env
env = {
# Required CGI variables
'REQUEST_METHOD' => method,
'SCRIPT_NAME' => '',
'PATH_INFO' => path,
'QUERY_STRING' => query_string || '',
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => '9292',
'HTTP_VERSION' => http_version,
'SERVER_PROTOCOL' => http_version,
# Rack-specific
'rack.version' => [1, 3],
'rack.input' => StringIO.new(body),
'rack.errors' => $stderr,
'rack.multithread' => false,
'rack.multiprocess' => false,
'rack.run_once' => false,
'rack.url_scheme' => 'http',
}
# Convert HTTP headers to CGI format
headers.each do |name, value|
# Content-Type and Content-Length get special treatment
key = case name
when 'Content-Type' then 'CONTENT_TYPE'
when 'Content-Length' then 'CONTENT_LENGTH'
else "HTTP_#{name.upcase.gsub('-', '_')}"
end
env[key] = value
end
env
end
The header name transformation — Content-Type becomes HTTP_CONTENT_TYPE, X-Request-Id becomes HTTP_X_REQUEST_ID — is a CGI convention that Rack inherits. It's annoying but consistent.
Step 3: Serializing the Response
The response is [status, headers, body]. We need to turn that into HTTP/1.1 text:
STATUS_PHRASES = {
200 => 'OK',
201 => 'Created',
204 => 'No Content',
301 => 'Moved Permanently',
302 => 'Found',
304 => 'Not Modified',
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
415 => 'Unsupported Media Type',
422 => 'Unprocessable Entity',
500 => 'Internal Server Error',
}.freeze
def send_response(client, status, headers, body)
phrase = STATUS_PHRASES[status] || 'Unknown'
# Status line
client.write("HTTP/1.1 #{status} #{phrase}\r\n")
# Headers
headers.each do |name, value|
client.write("#{name}: #{value}\r\n")
end
# Blank line separating headers from body
client.write("\r\n")
# Body — iterate over whatever the app gave us
body.each do |chunk|
client.write(chunk)
end
# Some body objects need to be closed (file handles, etc.)
body.close if body.respond_to?(:close)
end
Putting It Together
# tiny_server.rb
require 'socket'
require 'stringio'
STATUS_PHRASES = {
200 => 'OK', 201 => 'Created', 204 => 'No Content',
301 => 'Moved Permanently', 302 => 'Found',
400 => 'Bad Request', 401 => 'Unauthorized',
403 => 'Forbidden', 404 => 'Not Found',
405 => 'Method Not Allowed', 500 => 'Internal Server Error',
}.freeze
def parse_request(client)
request_line = client.gets&.chomp
return nil unless request_line && !request_line.empty?
method, full_path, http_version = request_line.split(' ', 3)
path, query_string = full_path.split('?', 2)
headers = {}
while (line = client.gets&.chomp) && !line.empty?
name, value = line.split(': ', 2)
headers[name] = value
end
body = ''
if (length = headers['Content-Length']&.to_i) && length > 0
body = client.read(length)
end
env = {
'REQUEST_METHOD' => method,
'SCRIPT_NAME' => '',
'PATH_INFO' => path,
'QUERY_STRING' => query_string || '',
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => '9292',
'SERVER_PROTOCOL' => http_version || 'HTTP/1.1',
'rack.version' => [1, 3],
'rack.input' => StringIO.new(body),
'rack.errors' => $stderr,
'rack.multithread' => false,
'rack.multiprocess' => false,
'rack.run_once' => false,
'rack.url_scheme' => 'http',
}
headers.each do |name, value|
key = case name
when 'Content-Type' then 'CONTENT_TYPE'
when 'Content-Length' then 'CONTENT_LENGTH'
else "HTTP_#{name.upcase.tr('-', '_')}"
end
env[key] = value
end
env
end
def send_response(client, status, headers, body)
phrase = STATUS_PHRASES[status] || 'Unknown'
client.write("HTTP/1.1 #{status} #{phrase}\r\n")
headers.each { |name, value| client.write("#{name}: #{value}\r\n") }
client.write("\r\n")
body.each { |chunk| client.write(chunk) }
body.close if body.respond_to?(:close)
end
def run(app, port: 9292)
server = TCPServer.new('0.0.0.0', port)
puts "TinyServer listening on http://localhost:#{port}"
loop do
client = server.accept
begin
env = parse_request(client)
if env
status, headers, body = app.call(env)
send_response(client, status, headers, body)
end
rescue => e
$stderr.puts "Error handling request: #{e.message}"
$stderr.puts e.backtrace.first(5).join("\n")
error_body = "Internal Server Error\n"
client.write("HTTP/1.1 500 Internal Server Error\r\n")
client.write("Content-Type: text/plain\r\n")
client.write("Content-Length: #{error_body.bytesize}\r\n")
client.write("\r\n")
client.write(error_body)
ensure
client.close
end
end
end
Running It with a Real App
Let's plug in the notes app from the previous chapter:
# run_notes.rb
require_relative 'tiny_server'
require_relative 'app' # the NotesApp from the previous chapter
run NotesApp.new, port: 9292
$ ruby run_notes.rb
TinyServer listening on http://localhost:9292
$ curl -s http://localhost:9292/notes
[]
$ curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "It works"}' | jq .
{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}
$ curl -s http://localhost:9292/notes | jq .
[{"id":1,"content":"It works","created":"2026-02-19T12:00:00+00:00"}]
Your handwritten server, running your handwritten app. Real HTTP, real TCP sockets.
Making It Threaded
The current server handles one request at a time — the next server.accept doesn't run until the current request is finished. For a learning tool, fine. For anything resembling concurrent use, we need threads:
def run(app, port: 9292)
server = TCPServer.new('0.0.0.0', port)
puts "TinyServer (threaded) on http://localhost:#{port}"
loop do
client = server.accept
Thread.new(client) do |conn|
begin
env = parse_request(conn)
if env
status, headers, body = app.call(env)
send_response(conn, status, headers, body)
end
rescue => e
$stderr.puts "Error: #{e.message}"
ensure
conn.close
end
end
end
end
Each connection gets its own thread. The main loop immediately returns to accept, ready for the next connection. This is essentially what WEBrick does (minus SSL, keep-alive, virtual host support, and a decade of edge-case handling).
What We're Not Handling
A production HTTP/1.1 server needs to handle:
- Keep-alive connections: HTTP/1.1 keeps connections open by default. Our server closes after every response, which is valid but wasteful.
- Chunked transfer encoding: When
Content-Lengthis unknown at response time, you can send data in chunks. - HTTP pipelining: Multiple requests on the same connection before any response.
- Request timeouts: A client that connects and never sends data will tie up a thread forever.
- Very large bodies: We read the entire body into memory. For file uploads, you'd want streaming.
- SSL/TLS: Everything above is cleartext.
- HTTP/2: A binary protocol with multiplexing; fundamentally different from HTTP/1.1.
Puma, the default Rails server, handles all of these. It's about 10,000 lines of code. Our server is about 80. The gap is instructive — those 9,920 lines are solving real, hard problems. But the core idea — parse a hash, call an object, serialize the result — is in our 80 lines.
The Moment
Here it is: the only thing a web server does is build a hash and call your code. The hash has a few required keys. Your code returns a three-element array. The server turns that array into text and sends it over a socket.
When Puma says it "runs Rack applications," this is what it means. When we say "Rack-compatible server," we mean "a server that knows how to build this specific hash and interpret this specific array." The protocol is simple enough that we just implemented a conforming server in under a hundred lines.
Next: the middleware chain that sits between the server and your app.
Middleware: Turtles All the Way Down
Middleware is the most overloaded term in web development. In Rack's context, it has a precise meaning: a middleware is a Rack application that wraps another Rack application.
That's it. An object with a call method that receives an inner app in its initializer, delegates to it, and adds some behavior before or after (or instead of) that delegation.
The Pattern
class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
# Do something before the inner app runs
status, headers, body = @app.call(env)
# Do something after the inner app runs
[status, headers, body]
end
end
This is the complete middleware pattern. Everything else is elaboration.
The initialize method receives the next application in the chain. The call method can:
- Inspect or modify
envbefore passing it down - Decide not to call
@appat all (short-circuit) - Inspect or modify the
[status, headers, body]before returning it up - Call
@appmultiple times (for retry logic) - Do work in a separate thread (for async logging)
A Real Example: Request Logging
class RequestLogger
def initialize(app, logger: $stdout)
@app = app
@logger = logger
end
def call(env)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
status, headers, body = @app.call(env)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
ms = (elapsed * 1000).round(1)
@logger.puts "[#{Time.now.strftime('%H:%M:%S')}] #{method} #{path} → #{status} (#{ms}ms)"
[status, headers, body]
end
end
Use it:
# config.ru
require_relative 'app'
require_relative 'request_logger'
use RequestLogger
run NotesApp.new
Now every request is logged:
[12:00:00] GET /notes → 200 (0.3ms)
[12:00:01] POST /notes → 201 (0.8ms)
[12:00:02] GET /notes/1 → 200 (0.2ms)
[12:00:03] DELETE /notes/1 → 204 (0.1ms)
[12:00:04] GET /notes/999 → 404 (0.1ms)
The application knows nothing about this. NotesApp didn't change. The logging behavior is composed around it.
A Real Example: Authentication
class BasicAuth
def initialize(app, realm:, credentials:)
@app = app
@realm = realm
@credentials = credentials # { username => password }
end
def call(env)
auth = env['HTTP_AUTHORIZATION']
if auth && auth.start_with?('Basic ')
encoded = auth.sub('Basic ', '')
username, password = Base64.decode64(encoded).split(':', 2)
if @credentials[username] == password
# Auth success — pass through to the app
env['authenticated_user'] = username
return @app.call(env)
end
end
# Auth failed — short-circuit, don't call the inner app
[
401,
{
'Content-Type' => 'text/plain',
'WWW-Authenticate' => "Basic realm=\"#{@realm}\"",
},
['Unauthorized']
]
end
end
Use it:
# config.ru
require 'base64'
require_relative 'app'
require_relative 'basic_auth'
use BasicAuth,
realm: 'Notes API',
credentials: { 'admin' => 'secret' }
run NotesApp.new
Now:
# No credentials
$ curl -s http://localhost:9292/notes
Unauthorized
# Wrong password
$ curl -s -u admin:wrong http://localhost:9292/notes
Unauthorized
# Correct credentials
$ curl -s -u admin:secret http://localhost:9292/notes
[]
The inner app still knows nothing about authentication. NotesApp didn't change. Authentication is entirely handled in the middleware layer.
A Real Example: Response Time Header
class ResponseTime
HEADER = 'X-Response-Time'.freeze
def initialize(app)
@app = app
end
def call(env)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
headers[HEADER] = "#{(elapsed * 1000).round(2)}ms"
[status, headers, body]
end
end
This modifies the response rather than the request. It adds a header after the inner app runs.
Composing Multiple Middlewares
When you use multiple middlewares in config.ru, they nest:
# config.ru
use ResponseTime
use RequestLogger
use BasicAuth, realm: 'Notes', credentials: { 'user' => 'pass' }
run NotesApp.new
The call stack for a request is:
ResponseTime.call(env)
RequestLogger.call(env)
BasicAuth.call(env)
NotesApp.call(env) # only if auth passes
BasicAuth returns [status, headers, body]
RequestLogger returns [status, headers, body] (after logging)
ResponseTime returns [status, headers, body] (after adding X-Response-Time)
The first use is the outermost layer. The run is the innermost. Middleware added first wraps everything else.
This has a non-obvious implication: RequestLogger runs inside ResponseTime. If RequestLogger adds 0.1ms of overhead, that overhead is included in the response time that ResponseTime measures. Whether that's what you want depends on what you're measuring.
Building a Middleware Stack Manually
Rack::Builder (what config.ru uses) is just a class that builds a chain of middlewares. We can do it manually to see the structure:
require_relative 'app'
require_relative 'request_logger'
require_relative 'response_time'
# Build the stack by hand — innermost to outermost
inner = NotesApp.new
logged = RequestLogger.new(inner)
timed = ResponseTime.new(logged)
# timed is the outermost app — this is what the server calls
status, headers, body = timed.call(env)
Or using Rack::Builder directly:
app = Rack::Builder.new do
use ResponseTime
use RequestLogger
run NotesApp.new
end
# app.call(env) now goes through the whole stack
Rack::Builder does exactly what we did manually — it builds a chain of closures, each wrapping the next.
Middleware from the Rack Gem
The rack gem ships with a set of useful middlewares:
# Adds X-Runtime header (response time)
use Rack::Runtime
# Rewrites POST bodies with _method=DELETE to DELETE requests
use Rack::MethodOverride
# Adds ETag and Last-Modified for conditional GET support
use Rack::ConditionalGet
use Rack::ETag
# Compresses responses with gzip when client supports it
use Rack::Deflater
# Serves static files from ./public
use Rack::Static, urls: ['/assets'], root: 'public'
# Basic request/response logging
use Rack::CommonLogger
# Cookie-based sessions
use Rack::Session::Cookie, secret: 'your_secret_key'
These are all just implementations of the same pattern: initialize with app, implement call.
Middleware Order Matters
# WRONG order: Static serves before auth check
use Rack::Static, urls: ['/admin/files']
use BasicAuth, realm: 'Admin'
run AdminApp.new
# RIGHT order: Auth runs first, wraps everything including Static
use BasicAuth, realm: 'Admin'
use Rack::Static, urls: ['/admin/files']
run AdminApp.new
In the wrong order, requests to /admin/files/secret.pdf bypass authentication because Rack::Static intercepts them before BasicAuth gets a chance to check credentials.
This kind of bug is especially fun to debug when you inherited the codebase.
Conditional Middleware
Sometimes you want middleware only in certain environments:
# config.ru
if ENV['RACK_ENV'] == 'development'
use Rack::Lint # validates Rack compliance — catches your bugs
use RequestLogger
end
use Rack::Session::Cookie, secret: ENV.fetch('SESSION_SECRET')
run MyApp.new
Rack::Lint is particularly useful during development — it validates that your app and middleware are conforming to the spec and raises helpful errors when they don't.
Writing Middleware That Passes Options
A common pattern is passing configuration at use-time:
class RateLimiter
def initialize(app, limit: 100, window: 60)
@app = app
@limit = limit
@window = window
@counts = Hash.new(0)
@mutex = Mutex.new
end
def call(env)
ip = env['REMOTE_ADDR']
@mutex.synchronize do
@counts[ip] += 1
if @counts[ip] > @limit
return [
429,
{'Content-Type' => 'text/plain', 'Retry-After' => @window.to_s},
['Too Many Requests']
]
end
end
@app.call(env)
end
end
# In config.ru:
use RateLimiter, limit: 50, window: 30
The initialize arguments after app are the middleware's configuration options. Rack::Builder passes them through when you write use RateLimiter, limit: 50.
The Insight
Here's what took me too long to realize: middleware is just objects calling other objects. There's no framework magic. There's no DSL. There's no reflection or code generation. It's a chain of Ruby objects where each one holds a reference to the next and delegates to it.
When you understand this, you can:
- Read any middleware and understand it immediately
- Write middleware that does exactly what you need
- Debug middleware issues by temporarily removing layers
- Understand why middleware order matters
The entire Rack ecosystem — gems, frameworks, servers — is built on this pattern. A Rails app with 20 middlewares is just 20 objects arranged in a chain. When something goes wrong in that chain, you now know how to find it.
Next: what those middlewares are usually protecting — your routing.
Routing Without a Framework (It's Just String Matching)
Routing is the process of mapping an incoming HTTP request to a handler. Frameworks make this look sophisticated. It isn't. It's string matching with some parameter extraction.
Let's implement a router from scratch, then look at what frameworks add on top.
What Routing Actually Is
Given a request with METHOD = "GET" and PATH_INFO = "/users/42/posts", routing finds the code that should handle it. The two inputs are the HTTP method and the path. The outputs are: either "run this code" or "404."
That's the whole problem. Everything else is ergonomics.
The Simplest Router
From the previous chapter's NotesApp, we had:
case [method, path]
when ['GET', '/notes']
list_notes
when ['POST', '/notes']
create_note(env)
end
This is a router. It matches on method and exact path. The problem: it doesn't handle path parameters (/notes/42).
Path Parameters via Regex
class Router
def initialize
@routes = []
end
def add(method, pattern, &handler)
# Convert /users/:id/posts to a regex with named captures
regex = pattern.gsub(/:(\w+)/, '(?<\1>[^/]+)')
regex = Regexp.new("\\A#{regex}\\z")
@routes << {method: method, pattern: regex, handler: handler}
end
def get(path, &block) = add('GET', path, &block)
def post(path, &block) = add('POST', path, &block)
def put(path, &block) = add('PUT', path, &block)
def patch(path, &block)= add('PATCH', path, &block)
def delete(path, &block)=add('DELETE', path, &block)
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
@routes.each do |route|
next unless route[:method] == method
next unless (match = route[:pattern].match(path))
# Extract named captures as string keys
params = match.named_captures
env['router.params'] = params
return route[:handler].call(env, params)
end
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
end
Using it:
router = Router.new
router.get('/') do |env, params|
[200, {'Content-Type' => 'text/plain'}, ['Welcome']]
end
router.get('/users/:id') do |env, params|
[200, {'Content-Type' => 'text/plain'}, ["User #{params['id']}"]]
end
router.get('/users/:user_id/posts/:id') do |env, params|
body = "Post #{params['id']} by user #{params['user_id']}"
[200, {'Content-Type' => 'text/plain'}, [body]]
end
router.post('/users') do |env, params|
# Create a user...
[201, {'Content-Type' => 'text/plain'}, ['Created']]
end
Test it:
require 'rack/mock'
def request(router, method, path)
env = Rack::MockRequest.env_for(path, method: method)
status, _, body = router.call(env)
[status, body.join]
end
puts request(router, 'GET', '/') # [200, "Welcome"]
puts request(router, 'GET', '/users/42') # [200, "User 42"]
puts request(router, 'GET', '/users/42/posts/7') # [200, "Post 7 by user 42"]
puts request(router, 'POST', '/users') # [201, "Created"]
puts request(router, 'GET', '/nonexistent') # [404, "Not Found"]
The Regex Trick
The pattern translation deserves a closer look:
pattern = '/users/:id/posts/:post_id'
# Step 1: Replace :param with a named capture group
regex_str = pattern.gsub(/:(\w+)/, '(?<\1>[^/]+)')
# => "/users/(?<id>[^/]+)/posts/(?<post_id>[^/]+)"
# Step 2: Anchor it
regex = Regexp.new("\\A#{regex_str}\\z")
# => /\A\/users\/(?<id>[^\/]+)\/posts\/(?<post_id>[^\/]+)\z/
# Test it
match = regex.match('/users/42/posts/7')
match.named_captures # => {"id"=>"42", "post_id"=>"7"}
[^/]+ matches one or more characters that aren't a slash — which is what a URL segment is. Named captures (the ?<name> syntax) let us extract those values by name.
This is what every Ruby routing library does underneath. Some add wildcard matching (*path), optional segments ((/edit)?), or format matching (.json). The core is always the same regex transform.
Constraint-Based Routing
Rails routes support constraints like id: /\d+/. We can add that:
def add(method, pattern, constraints: {}, &handler)
# Build base regex, replacing :param with a named capture
regex_str = pattern.gsub(/:(\w+)/) do |match|
param_name = $1
# Use constraint regex if provided, otherwise match any non-slash chars
param_pattern = constraints[param_name.to_sym]&.source || '[^/]+'
"(?<#{param_name}>#{param_pattern})"
end
regex = Regexp.new("\\A#{regex_str}\\z")
@routes << {method: method, pattern: regex, handler: handler}
end
# Usage: only match numeric IDs
router.get('/users/:id', constraints: {id: /\d+/}) do |env, params|
[200, {}, ["User #{params['id']}"]]
end
# This matches: GET /users/42
# This doesn't: GET /users/alice
Mounting Rack Apps at Paths
Routing isn't just about handlers — you can route to entire Rack applications:
class PathRouter
def initialize
@mounts = []
@routes = []
end
def mount(path, app)
@mounts << {prefix: path, app: app}
end
def call(env)
path = env['PATH_INFO']
# Check mounts first — rewrite PATH_INFO for the mounted app
@mounts.each do |mount|
if path.start_with?(mount[:prefix])
env = env.merge(
'SCRIPT_NAME' => env['SCRIPT_NAME'] + mount[:prefix],
'PATH_INFO' => path.sub(mount[:prefix], '') || '/',
)
return mount[:app].call(env)
end
end
# Then check plain routes
# ... (same as before)
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
end
# Example:
router = PathRouter.new
router.mount('/api/v1', ApiApp.new)
router.mount('/admin', AdminApp.new)
This is exactly how Rails's mount directive works. SCRIPT_NAME tracks how much of the path has been consumed by the routing layer, and PATH_INFO contains the remaining path for the mounted app to interpret.
A Full-Featured Example
Let's build a complete router that a small real application could actually use:
# router.rb
class Router
Route = Struct.new(:method, :pattern, :named_params, :handler)
def initialize
@routes = []
@not_found = method(:default_not_found)
@error = method(:default_error)
end
def get(path, &block) = define('GET', path, &block)
def post(path, &block) = define('POST', path, &block)
def put(path, &block) = define('PUT', path, &block)
def patch(path, &block) = define('PATCH', path, &block)
def delete(path, &block) = define('DELETE', path, &block)
def head(path, &block) = define('HEAD', path, &block)
def not_found(&block) = (@not_found = block)
def error(&block) = (@error = block)
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO'].chomp('/')
path = '/' if path.empty?
@routes.each do |route|
next unless route.method == method || (method == 'HEAD' && route.method == 'GET')
if (match = route.pattern.match(path))
params = match.named_captures
env['router.params'] = params
return route.handler.call(env, params)
end
end
@not_found.call(env)
rescue StandardError => e
@error.call(env, e)
end
private
def define(method, path, &handler)
named_params = path.scan(/:(\w+)/).flatten
pattern_str = path.gsub(/:(\w+)/, '(?<\1>[^/]+)')
pattern = Regexp.new("\\A#{pattern_str}\\z")
@routes << Route.new(method, pattern, named_params, handler)
end
def default_not_found(env)
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
def default_error(env, exception)
$stderr.puts "#{exception.class}: #{exception.message}"
$stderr.puts exception.backtrace.first(10).join("\n")
[500, {'Content-Type' => 'text/plain'}, ['Internal Server Error']]
end
end
Use it as a Rack app:
# config.ru
require_relative 'router'
require_relative 'handlers' # wherever your handler code lives
router = Router.new
router.get('/') do |env, params|
[200, {'Content-Type' => 'text/html'}, ['<h1>Home</h1>']]
end
router.get('/users/:id') do |env, params|
user = User.find(params['id'].to_i)
[200, {'Content-Type' => 'application/json'}, [user.to_json]]
end
router.not_found do |env|
[404, {'Content-Type' => 'text/html'}, ['<h1>Page Not Found</h1>']]
end
run router
What Frameworks Add on Top
Our router covers the basics. Here's what Rails's and Sinatra's routers add:
Named routes and URL helpers: user_path(id: 42) instead of "/users/#{42}". This requires storing route patterns as templates, not just regexes.
Nested resources: resources :users do; resources :posts; end generates all CRUD routes for posts nested under users. Our router requires you to define each route manually.
Route priorities and overrides: When multiple routes could match, Rails has a precise priority order. Our router uses first-match-wins, which is simpler but less flexible.
Format matching: Rails can route GET /users/42.json differently from GET /users/42, based on the format suffix or Accept header.
Redirect and inline responders: get '/old', redirect('/new') in Sinatra.
Route constraints with arbitrary code: Rails lets you pass lambdas as constraints.
These are real features that real applications use. They're also each independently implementable on top of what we've built — the router isn't magical, it's just accreted features.
The Insight
A router is a list of (method, pattern, handler) tuples. Matching is: iterate the list, test each pattern against the incoming path, run the first match. Everything else is optimization or ergonomics.
When you see a routing DSL — resources :users, namespace :api, scope '/v2' — it's generating these tuples. The DSL exists because writing tuples manually is tedious at scale. But it's still tuples.
If you ever need to debug a routing issue, you can inspect the generated routes. In Rails: Rails.application.routes.routes gives you the raw route list. In Sinatra: MyApp.routes shows all defined routes. You're looking at the tuples.
Next: making request and response handling a little less manual.
Request and Response Objects (DIY Edition)
By now you've been reading env['REQUEST_METHOD'] and returning [200, headers, body] directly. This works, but it's verbose. Real applications build thin wrapper objects around the raw data structures to make the common cases easier.
The rack gem ships with Rack::Request and Rack::Response. They're good. But let's build our own versions first, so we understand what they're doing and why.
The Problem with Raw Env
Accessing request data from the raw env hash has a few annoyances:
# Reading headers is inconsistent
host = env['HTTP_HOST'] # most headers
content_type = env['CONTENT_TYPE'] # except Content-Type
content_len = env['CONTENT_LENGTH'] # and Content-Length
# Checking HTTP method
is_get = env['REQUEST_METHOD'] == 'GET'
is_post = env['REQUEST_METHOD'] == 'POST'
# Reading the body (destructive! can only read once)
body = env['rack.input'].read
# Parsing query string
require 'uri'
params = URI.decode_www_form(env['QUERY_STRING']).to_h
# Getting the full URL
scheme = env['rack.url_scheme']
host = env['HTTP_HOST']
path = env['PATH_INFO']
qs = env['QUERY_STRING']
url = "#{scheme}://#{host}#{path}"
url += "?#{qs}" unless qs.empty?
None of this is wrong, but it's tedious. A request object wraps these lookups in named methods.
Building a Request Wrapper
class Request
attr_reader :env
def initialize(env)
@env = env
end
# HTTP method
def request_method = env['REQUEST_METHOD']
def get? = request_method == 'GET'
def post? = request_method == 'POST'
def put? = request_method == 'PUT'
def patch? = request_method == 'PATCH'
def delete? = request_method == 'DELETE'
def head? = request_method == 'HEAD'
# Path and query
def path = env['PATH_INFO']
def query_string = env['QUERY_STRING']
def script_name = env['SCRIPT_NAME']
def full_path = query_string.empty? ? path : "#{path}?#{query_string}"
# URL construction
def scheme = env['rack.url_scheme'] || 'http'
def host = env['HTTP_HOST'] || env['SERVER_NAME']
def port = env['SERVER_PORT']
def url = "#{scheme}://#{host}#{full_path}"
# Headers (Rack-normalized: HTTP_ACCEPT -> Accept)
def headers
@headers ||= env.each_with_object({}) do |(key, value), h|
if key.start_with?('HTTP_')
name = key.sub('HTTP_', '').split('_').map(&:capitalize).join('-')
h[name] = value
elsif key == 'CONTENT_TYPE'
h['Content-Type'] = value
elsif key == 'CONTENT_LENGTH'
h['Content-Length'] = value
end
end
end
def content_type = env['CONTENT_TYPE']
def content_length = env['CONTENT_LENGTH']&.to_i
# Individual header lookup — normalizes to Rack format
def [](header_name)
key = "HTTP_#{header_name.upcase.tr('-', '_')}"
env[key] || env[header_name.upcase.tr('-', '_')]
end
# Query parameters (parsed and decoded)
def query_params
@query_params ||= parse_query(query_string)
end
# POST body params (for application/x-www-form-urlencoded)
def post_params
@post_params ||= if content_type&.include?('application/x-www-form-urlencoded')
parse_query(body_string)
else
{}
end
end
# Merged params: query + post body (query takes precedence on collision)
def params
@params ||= post_params.merge(query_params)
end
# Raw body (reads once, then cached)
def body
env['rack.input']
end
def body_string
@body_string ||= begin
body.rewind # reset in case it was partially read
body.read
end
end
# JSON body parsing
def json
@json ||= if content_type&.include?('application/json')
require 'json'
JSON.parse(body_string)
end
end
# Cookies
def cookies
@cookies ||= parse_cookies(env['HTTP_COOKIE'] || '')
end
# IP address (respects X-Forwarded-For if behind a proxy)
def ip
env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
env['REMOTE_ADDR']
end
# Is this an AJAX request?
def xhr?
env['HTTP_X_REQUESTED_WITH']&.downcase == 'xmlhttprequest'
end
# What content types does the client accept?
def accepts?(mime_type)
accept = env['HTTP_ACCEPT'] || '*/*'
accept.include?(mime_type) || accept.include?('*/*')
end
private
def parse_query(string)
return {} if string.nil? || string.empty?
require 'uri'
URI.decode_www_form(string).each_with_object({}) do |(k, v), h|
if h.key?(k)
h[k] = Array(h[k]) << v
else
h[k] = v
end
end
end
def parse_cookies(string)
string.split('; ').each_with_object({}) do |pair, h|
name, value = pair.split('=', 2)
h[name] = value if name
end
end
end
Building a Response Helper
The response is [status, headers, body]. Building it manually is fine for simple cases but gets tedious when you're setting multiple headers or building the body incrementally.
class Response
attr_accessor :status
attr_reader :headers
def initialize(status = 200, headers = {})
@status = status
@headers = {'Content-Type' => 'text/html; charset=utf-8'}.merge(headers)
@body = []
@finished = false
end
# Write to the body buffer
def write(str)
raise 'Response already finished' if @finished
@body << str.to_s
self
end
def <<(str) = write(str)
# Set a header
def []=(name, value)
@headers[name] = value
end
def [](name)
@headers[name]
end
# Common response types
def set_cookie(name, value, options = {})
cookie = "#{name}=#{value}"
cookie += "; Path=#{options[:path] || '/'}"
cookie += "; HttpOnly" if options[:http_only] != false
cookie += "; Secure" if options[:secure]
cookie += "; SameSite=#{options[:same_site]}" if options[:same_site]
if options[:expires]
cookie += "; Expires=#{options[:expires].httpdate}"
end
# Multiple Set-Cookie headers need to be handled carefully
existing = @headers['Set-Cookie']
@headers['Set-Cookie'] = existing ? "#{existing}\n#{cookie}" : cookie
end
def delete_cookie(name)
set_cookie(name, '', expires: Time.at(0))
end
# Redirect helpers
def redirect(location, status = 302)
@status = status
@headers['Location'] = location
@body = []
self
end
# Finish: set Content-Length and return the Rack triple
def finish
@finished = true
body = @body
unless @headers['Content-Length']
size = body.sum(&:bytesize)
@headers['Content-Length'] = size.to_s
end
[@status, @headers, body]
end
# Convenience: finish with a body written all at once
def self.text(body, status: 200)
r = new(status, 'Content-Type' => 'text/plain')
r.write(body)
r.finish
end
def self.html(body, status: 200)
r = new(status, 'Content-Type' => 'text/html; charset=utf-8')
r.write(body)
r.finish
end
def self.json(data, status: 200)
require 'json'
body = JSON.generate(data)
r = new(status, 'Content-Type' => 'application/json')
r.write(body)
r.finish
end
def self.redirect(location, status: 302)
r = new(status)
r.redirect(location, status)
r.finish
end
end
Using Them Together
Here's our NotesApp rewritten with these helpers:
require_relative 'request'
require_relative 'response'
require 'json'
class NotesApp
def initialize
@notes = {}
@next_id = 1
end
def call(env)
req = Request.new(env)
case [req.request_method, req.path]
when ['GET', '/notes']
Response.json(@notes.values)
when ['POST', '/notes']
create_note(req)
else
if (match = req.path.match(%r{\A/notes/(\d+)\z}))
id = match[1].to_i
case req.request_method
when 'GET' then show_note(id)
when 'DELETE' then delete_note(id)
else Response.json({'error' => 'Method not allowed'}, status: 405)
end
else
Response.json({'error' => 'Not found'}, status: 404)
end
end
end
private
def show_note(id)
note = @notes[id]
return Response.json({'error' => 'Not found'}, status: 404) unless note
Response.json(note)
end
def create_note(req)
data = req.json
return Response.json({'error' => 'Invalid JSON'}, status: 400) unless data
note = {
'id' => @next_id,
'content' => data['content'].to_s,
'created' => Time.now.iso8601,
}
@notes[@next_id] = note
@next_id += 1
Response.json(note, status: 201)
rescue JSON::ParserError
Response.json({'error' => 'Invalid JSON'}, status: 400)
end
def delete_note(id)
return Response.json({'error' => 'Not found'}, status: 404) unless @notes.key?(id)
@notes.delete(id)
[204, {}, []]
end
end
Cleaner. req.json instead of JSON.parse(env['rack.input'].read). Response.json(data) instead of the three-line array construction.
Rack's Built-in Versions
Rack::Request and Rack::Response do everything above, plus:
Rack::Request#paramshandles multipart form data (file uploads)Rack::Request#sessionaccesses the session (set by session middleware)Rack::ResponsehandlesTransfer-Encoding: chunkedfor streaming- Both handle edge cases in encoding and parsing that our implementations skip
There's no reason to use our versions in production. But now you know what they are: thin wrappers that make the env hash and response array more ergonomic. Not magic. Not framework infrastructure. Just Ruby objects.
The Body Is Lazy
One important detail about the response body: it's supposed to be lazy. The Rack spec says the body responds to each, and the server calls each to get the chunks. It doesn't have to be an Array.
This means you can stream large responses without loading everything into memory:
class FileStreamer
def initialize(path)
@path = path
end
def each
File.open(@path, 'rb') do |file|
while (chunk = file.read(16_384)) # read 16KB at a time
yield chunk
end
end
end
def close
# nothing to close — File.open handles it
end
end
# In a Rack app:
def call(env)
[
200,
{
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="large_file.bin"',
},
FileStreamer.new('/path/to/large_file.bin')
]
end
The server calls .each on the body, yields chunks to the client as they're read, and memory usage stays flat even for multi-gigabyte files. Rack::Sendfile takes this further — if the server supports it, you tell the server the file path and the server handles the streaming at the OS level, bypassing Ruby entirely.
The Insight
Request and response objects are not architecture — they're ergonomics. The underlying data structures are still a hash and an array. The wrappers exist so you don't have to remember that Content-Type uses a different env key format than other headers, or that the body needs Content-Length set, or that rack.input might need to be rewound before reading.
When Rack::Request or Rack::Response does something unexpected, you can read its source. It's a short file. It's doing exactly what we just did.
This concludes the Rack section. You've seen the spec, built apps against it, written a server, implemented middleware, built a router, and wrapped the env in ergonomic objects. You know what's happening.
Next: why Rack alone still isn't quite enough for most applications.
Why Rack Alone Isn't Enough
We've spent several chapters demonstrating that you can build real web applications with nothing but Rack and Ruby's standard library. This is true. It's also incomplete.
Rack gives you a protocol. What it doesn't give you is a productive way to work at scale. Let's be precise about what's missing.
The Problems That Show Up
Problem 1: Routing Gets Ugly
Our hand-rolled router is workable for small applications. When an application grows to dozens of routes, a flat list of (method, pattern, handler) tuples becomes difficult to maintain.
Consider a typical CRUD application:
# GET /projects
# POST /projects
# GET /projects/:id
# PUT /projects/:id
# DELETE /projects/:id
# GET /projects/:id/tasks
# POST /projects/:id/tasks
# GET /projects/:id/tasks/:task_id
# PUT /projects/:id/tasks/:task_id
# DELETE /projects/:id/tasks/:task_id
# GET /projects/:id/collaborators
# POST /projects/:id/collaborators
# DELETE /projects/:id/collaborators/:user_id
Thirteen routes. For one resource with two nested sub-resources. A medium-sized application might have ten resources with similar nesting. That's over a hundred routes.
The flat list approach requires you to repeat the :id pattern in every route. It requires you to duplicate the /projects/:id prefix for every nested resource. When you change the prefix, you update dozens of patterns. When you forget one, you get a routing inconsistency.
More specifically: our flat router has no concept of shared route segments. Every route is independent. The information that /projects/:id/tasks and /projects/:id/collaborators both belong to the same project is not represented anywhere.
Problem 2: Before/After Actions Are Awkward
In Rails:
class ProjectsController < ApplicationController
before_action :authenticate!
before_action :find_project, only: [:show, :update, :destroy]
before_action :require_admin, only: [:destroy]
def show
render json: @project
end
end
In bare Rack, you'd write this as:
- A middleware that handles authentication (fine)
- Code in each route handler that loads the project (repetitive)
- A conditional check in the delete handler (manual)
You can build before-action systems — they're just arrays of procs called before the handler. But nothing in Rack provides this scaffolding. You write it yourself, every time.
Problem 3: Response Helpers Don't Exist
# In a framework
render json: @user, status: :created
# In bare Rack
body = @user.to_json
[201, {'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s}, [body]]
The framework version is shorter because the framework accumulated helper methods over time. render json: is syntactic sugar over exactly what the Rack version does. The sugar exists because you'd type the verbose version hundreds of times in a real application.
Problem 4: Configuration Seeps Everywhere
A bare Rack app has no natural place to put application-level configuration:
# Where does the database connection go?
# Where does the template engine get configured?
# Where do shared helpers live?
# Where does the application secret key live?
In Rails, the answer to all of these is "the application object and its configuration." In Sinatra, it's set :key, value and settings.key. In bare Rack, you figure it out yourself, and the answer is "some combination of globals, constants, and thread locals."
Problem 5: Template Rendering Is Your Problem
The Rack spec says nothing about templates. If you want ERB or Haml or Mustache, you call the library directly:
require 'erb'
template = ERB.new(File.read('views/users.html.erb'))
html = template.result(binding) # dangerous: binding exposes entire scope
[200, {'Content-Type' => 'text/html'}, [html]]
This works for one template. For a real application, you need:
- A way to find template files by name
- Layout support (a template inside a master template)
- Partials (including sub-templates)
- Content type detection
- Safe access to view helpers without exposing everything via
binding
Frameworks provide this. Bare Rack does not.
The Two Solutions
There are two ways to address these problems:
Solution A: Add exactly what you need, nothing more. Write a before-action system that handles your application's specific needs. Use a router library that handles the routing problem but nothing else. Add a template helper that handles the two template engines you actually use. Build up from Rack exactly as far as you need.
This is the Sinatra approach. Sinatra is a small framework that solves the routing and template problems while staying thin everywhere else. It works well for APIs and small web applications.
Solution B: Start with a minimal framework that has correct abstractions, then opt into features as you need them. Rather than building up from primitives, start with a framework that already has the right foundation, and enable features through a plugin system.
This is the Roda approach.
Where Sinatra Falls Short
Sinatra solves the routing problem with a flat DSL:
get '/projects' do
# ...
end
get '/projects/:id' do
id = params[:id]
# ...
end
But it inherits the flat-list problem. As an application grows, Sinatra routes become hard to organize. Sinatra's routing is sequential — the first matching route wins, and the routes are just checked in order. For large applications with complex routing, this can be surprisingly slow, and there's no structural way to represent the hierarchical nature of the URL namespace.
Sinatra also doesn't solve the "shared prefix" problem. If you rename /projects to /p, you update every route that starts with /projects.
What a Better Abstraction Looks Like
The insight that Roda is built on: the URL hierarchy is a tree, and your routing should be a tree too.
Instead of a flat list of routes:
GET /projects → list_projects
POST /projects → create_project
GET /projects/:id → show_project
PUT /projects/:id → update_project
DELETE /projects/:id → delete_project
...you write a tree:
/projects
GET → list_projects
POST → create_project
/:id
GET → show_project
PUT → update_project
DELETE → delete_project
/tasks
GET → list_tasks
POST → create_task
When a request comes in for DELETE /projects/42, you traverse the tree:
- Does the path start with
/projects? Yes. Consume it. - Is there a
:idsegment?42. Consume it. - Nothing remaining. Match on
DELETE. Run the handler.
The tree represents the actual structure of your URL namespace. The /projects prefix is stated once. The :id parameter is extracted once and available to all nested routes.
This is what Roda implements. It calls it a "routing tree," and it's the framework's central design decision.
Why This Matters for Performance
Sinatra's routing has O(n) complexity — in the worst case, it checks every route until it finds a match. For an application with 200 routes, a request to the last-defined route or a 404 checks all 200 patterns.
Roda's tree routing has O(log n) complexity roughly — it traverses the tree, discarding entire branches when the path prefix doesn't match. A request for /projects/42 immediately discards everything that doesn't start with /projects, then everything within /projects that doesn't match /42 or /:id. The routing work is proportional to the depth of the match, not the total number of routes.
For most applications, this difference is imperceptible. For high-traffic applications or applications with many routes, it matters.
What Roda Is
Roda is a small Ruby web framework built on Rack with:
- Tree-based routing
- A plugin system that lets you add features (sessions, template rendering, JSON helpers, CSRF protection) without pulling in everything at once
- Correct Rack integration throughout
- A minimal footprint when you use minimal plugins
It was created by Jeremy Evans (also the author of Sequel, the Ruby database toolkit) and is used in production by applications that process millions of requests per day.
It is not Rails. It doesn't have an ORM, a mailer, an asset pipeline, or conventions for where to put files. It has routing and a plugin system. You add what you need.
Next: let's look at how that routing tree actually works.
The Routing Tree (Roda's Big Idea)
Roda's routing tree is the thing that makes it different from every other Ruby web framework. Not just in performance, but in how you think about URL structure. Understanding it deeply makes you a more effective Roda developer and a more effective web developer in general.
The Core Idea
In a flat router, all routes are evaluated independently:
# Flat routing (Sinatra-style)
get '/projects' → list handler
post '/projects' → create handler
get '/projects/:id' → show handler
put '/projects/:id' → update handler
delete '/projects/:id' → delete handler
Each route is matched from scratch against every incoming request.
In Roda's tree router, routes are nested. You consume the path incrementally:
# Tree routing (Roda-style)
route do |r|
r.on 'projects' do # consumes "projects"
r.is do # matches if nothing left
r.get { list }
r.post { create }
end
r.on :id do # consumes a segment, captures it
project = Project[r.params['id']]
r.is do # matches if nothing left
r.get { show(project) }
r.put { update(project) }
r.delete { delete(project) }
end
r.on 'tasks' do # consumes "tasks"
# task routes here
end
end
end
end
The path is consumed incrementally as you descend the tree. Once r.on 'projects' matches and the projects segment is consumed, the code inside that block only sees the remaining path. The projects prefix is checked once.
How Roda Actually Works Internally
Roda's routing block is a plain Ruby block that is re-executed for every request. Each call to r.on, r.is, r.get, etc. checks the current remaining path against its argument. If the check passes, it consumes the matched portion and yields. If it fails, it returns without running the block.
# Conceptually, r.on works like this:
def on(segment)
if @remaining_path.start_with?("/#{segment}")
old_path = @remaining_path
@remaining_path = @remaining_path.sub("/#{segment}", '') || '/'
result = yield # run the block
@remaining_path = old_path # restore on backtrack
result
end
# If not matched, just return nil
end
This "consume and restore" behavior is what makes the tree routing work correctly: if a branch doesn't match, the path is restored and the next branch can try.
The critical performance consequence: when r.on 'projects' doesn't match (because the path is /users/42), none of the code inside that block runs at all. The entire subtree of project-related routes is skipped in one comparison.
A Working Example
Let's install Roda and build a concrete routing example:
gem install roda
# routing_demo.rb
require 'roda'
class App < Roda
route do |r|
# Match /
r.root do
"Welcome to the routing demo"
end
# Match anything starting with /api
r.on 'api' do
# Match /api/v1/...
r.on 'v1' do
# Match /api/v1/users or /api/v1/users/...
r.on 'users' do
r.is do
# Exactly /api/v1/users
r.get { "List users" }
r.post { "Create user" }
end
r.on Integer do |id|
# /api/v1/users/42 (Integer matcher converts to integer)
user_info = "User ##{id}"
r.is do
r.get { "Show #{user_info}" }
r.put { "Update #{user_info}" }
r.delete { "Delete #{user_info}" }
end
r.on 'posts' do
r.is do
r.get { "Posts for #{user_info}" }
r.post { "Create post for #{user_info}" }
end
end
end
end
end
end
end
end
# Run with: rackup -e 'run App'
# Or test with Rack::MockRequest:
require 'rack/mock'
def request(method, path)
env = Rack::MockRequest.env_for(path, method: method)
status, _, body = App.call(env)
"#{status}: #{body.join}"
end
puts request('GET', '/')
puts request('GET', '/api/v1/users')
puts request('POST', '/api/v1/users')
puts request('GET', '/api/v1/users/42')
puts request('DELETE', '/api/v1/users/42')
puts request('GET', '/api/v1/users/42/posts')
puts request('GET', '/nonexistent')
Output:
200: Welcome to the routing demo
200: List users
200: Create user
200: Show User #42
200: Delete User #42
200: Posts for User #42
404:
r.on, r.is, and r.get/post/etc.
These three methods do different things:
r.on(matcher) — partial match. Matches if the path starts with the segment, then yields with the segment consumed. Used for routing prefixes.
r.on 'admin' do
# runs for any path starting with /admin
# remaining path is everything after /admin
end
r.is(matcher) — exact match. Matches if the matcher matches AND nothing is left in the path. Used for terminal routes.
r.on 'users' do
r.is do
# runs only for exactly /users, not /users/anything
end
r.is :id do |id|
# runs only for exactly /users/something, captures :id
end
end
r.get, r.post, etc. — method match. These check the HTTP method. They imply r.is when used without arguments:
r.get do
# GET request, and nothing left in path
end
r.get 'status' do
# GET /status (consumes "status", matches end of path)
end
When you write r.get { "hello" } inside an r.on block, you're saying: "If the HTTP method is GET AND there's nothing left in the path, run this block."
Matchers
The argument to r.on and r.is is a "matcher." Roda has several built-in matchers:
String — matches a literal path segment:
r.on 'users' do ... end # matches /users/...
r.on 'api/v1' do ... end # matches /api/v1/... (multi-segment)
Symbol — captures any non-empty path segment as a string:
r.on :id do |id|
# id is a String, e.g., "42" or "alice"
end
Integer — captures a numeric path segment, converts to Integer:
r.on Integer do |id|
# id is an Integer, e.g., 42
# non-numeric segments don't match
end
String with captures — a regex-like pattern:
r.on /\d{4}-\d{2}-\d{2}/ do |date|
# matches a date like 2026-02-19
end
Regexp — matches against the remaining path:
r.on /posts-(\d+)/ do |post_id|
# captures the numeric part of "posts-42"
end
true — always matches (useful for catch-all routes):
r.on true do
"Catch-all handler"
end
Multiple arguments — all must match (AND logic):
r.on 'projects', Integer do |id|
# matches /projects/42, captures 42
end
Branching and Fallthrough
The routing block returns normally (not via exception) when a route matches. If nothing matches, the block returns nil, and Roda returns a 404.
This means you can have fallthrough behavior:
route do |r|
r.on 'admin' do
unless current_user&.admin?
r.redirect '/login'
end
r.get 'dashboard' do
"Admin dashboard"
end
end
end
If current_user isn't an admin, the redirect fires. If they are an admin, we proceed to the inner routes. The routing block is just Ruby — you can use conditionals, early returns, and any other control flow.
Route Variables and Scope
Because the routing block runs inside the Roda application instance, all instance methods and variables are available:
class App < Roda
def current_user
# ... look up user from session ...
end
def require_login!
r.redirect '/login' unless current_user
end
route do |r|
r.on 'account' do
require_login!
r.get 'profile' do
"Profile for #{current_user.name}"
end
end
end
end
require_login! is an instance method. current_user is an instance method. The routing block runs as an instance method (it's instance_exec'd), so it has access to both.
This is fundamentally different from Sinatra, where before blocks are separate from route handlers. In Roda, "before actions" are just code before the route match — you write them inline, as Ruby code.
Why This Is Better Than a Flat Router
Consider loading a project from the database. In a flat router, you do it in each handler that needs it:
# Flat router — repetitive
get '/projects/:id' do
project = Project.find(params[:id])
return 404 unless project
project.to_json
end
put '/projects/:id' do
project = Project.find(params[:id])
return 404 unless project
project.update(params[:data])
project.to_json
end
delete '/projects/:id' do
project = Project.find(params[:id])
return 404 unless project
project.destroy
204
end
In Roda's tree router, you do it once:
# Tree router — DRY
r.on 'projects', Integer do |id|
project = Project[id]
r.halt(404, 'Not found') unless project
r.get { project.to_json }
r.put { project.update(r.params[:data]); project.to_json }
r.delete { project.destroy; r.response.status = 204; '' }
end
The database lookup happens once, at the point where the :id segment is consumed. All handlers beneath that point get the already-loaded project. If the project doesn't exist, r.halt stops routing and returns 404 immediately.
This is not just more concise — it's correct in a way the flat version isn't. In the flat version, you can accidentally forget to load the project in one handler. In the tree version, the project is available to all inner handlers by construction.
The Insight
Here's the thing about tree routing that isn't obvious until you've worked with it: your routing code is a direct representation of your URL structure.
Look at the nested r.on calls in a Roda routing block, and you can read off the URL tree. Look at a flat list of Sinatra routes, and the URL structure is implicit — you have to mentally reconstruct it from the patterns.
When you need to add a new route under /api/v1/users/:id, you find the block that handles Integer inside the block that handles users inside the block that handles v1 inside the block that handles api. You put your new route there. It automatically benefits from any setup code (loading the user from the database, checking permissions) that already runs at that point in the tree.
This is the correctness argument for tree routing. The performance argument is real but secondary. The main reason to use Roda's routing tree is that it makes the relationship between your URL structure and your code explicit and maintainable.
Next: let's build our first real Roda application.
Your First Roda App
Theory is good. Working code is better. Let's build a complete Roda application that handles real concerns: routing, data handling, JSON responses, error handling, and a structure you could actually extend.
We'll build the same Notes API from the Rack chapter, but with Roda. Then we'll add things that would have been painful in bare Rack.
Setup
mkdir roda-notes
cd roda-notes
bundle init
# Gemfile
gem 'roda'
gem 'json'
bundle install
The Basic Application
# app.rb
require 'roda'
require 'json'
class NotesApp < Roda
plugin :json # Automatically serialize return values to JSON
plugin :json_parser # Parse JSON request bodies into r.params
plugin :halt # Allow r.halt for early exits
plugin :all_verbs # Support PUT, PATCH, DELETE in routes
NOTES = {}
NEXT_ID = [1] # Array so we can mutate it (class vars are awkward)
route do |r|
r.on 'notes' do
# GET /notes - list all notes
# POST /notes - create a note
r.is do
r.get do
NOTES.values
end
r.post do
content = r.params['content'].to_s
r.halt(422, {'error' => 'content is required'}) if content.empty?
id = NEXT_ID[0]
note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
NOTES[id] = note
NEXT_ID[0] += 1
response.status = 201
note
end
end
r.on Integer do |id|
note = NOTES[id]
r.halt(404, {'error' => 'Note not found'}) unless note
# GET /notes/:id
# DELETE /notes/:id
r.is do
r.get { note }
r.delete do
NOTES.delete(id)
response.status = 204
''
end
end
end
end
end
end
# config.ru
require_relative 'app'
run NotesApp
Notice that the routing block returns the note hash directly — not a Rack response array. The json plugin intercepts the return value and serializes it. This is Roda's plugin system at work.
Running It
$ bundle exec rackup
Puma starting in single mode...
* Listening on http://127.0.0.1:9292
$ curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "First note"}' | jq .
{"id":1,"content":"First note","created":"2026-02-19T12:00:00+00:00"}
$ curl -s http://localhost:9292/notes | jq .
[{"id":1,"content":"First note","created":"..."}]
$ curl -s http://localhost:9292/notes/1 | jq .
{"id":1,"content":"First note","created":"..."}
$ curl -s -X DELETE http://localhost:9292/notes/1
# 204, empty body
$ curl -s http://localhost:9292/notes/1 | jq .
{"error":"Note not found"}
Adding Structure
As the application grows, the single route block gets large. The natural Roda approach is to extract routes into methods:
class NotesApp < Roda
plugin :json
plugin :json_parser
plugin :halt
plugin :all_verbs
NOTES = {}
NEXT_ID = [1]
route do |r|
r.on 'notes' do
r.is { notes_collection(r) }
r.on Integer do |id|
note = find_note!(id)
r.is { note_resource(r, note, id) }
end
end
end
private
def find_note!(id)
NOTES[id] || r.halt(404, {'error' => 'Note not found'})
end
def notes_collection(r)
r.get { NOTES.values }
r.post { create_note(r) }
end
def note_resource(r, note, id)
r.get { note }
r.delete { delete_note(id) }
end
def create_note(r)
content = r.params['content'].to_s
r.halt(422, {'error' => 'content is required'}) if content.empty?
id = NEXT_ID[0]
note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
NOTES[id] = note
NEXT_ID[0] += 1
response.status = 201
note
end
def delete_note(id)
NOTES.delete(id)
response.status = 204
''
end
end
Now the route block is a compact index of what routes exist, and the implementation details are in named methods.
A More Complete Example: Multi-Resource API
Let's extend the application to handle two resources: notes and tags. Notes can have tags.
# app.rb
require 'roda'
require 'json'
require 'securerandom'
class App < Roda
plugin :json
plugin :json_parser
plugin :halt
plugin :all_verbs
plugin :status_handler
# Return appropriate errors for 404 and 405
status_handler(404) { {'error' => 'Not found'} }
status_handler(405) { {'error' => 'Method not allowed'} }
# In-memory storage (replace with a real database in production)
STORE = {
notes: {},
tags: {},
next_note_id: [1],
next_tag_id: [1],
}
route do |r|
r.on 'tags' do
r.is do
r.get { STORE[:tags].values }
r.post { create_tag(r) }
end
r.on Integer do |id|
tag = STORE[:tags][id]
r.halt(404) unless tag
r.is do
r.get { tag }
r.delete { STORE[:tags].delete(id); response.status = 204; '' }
end
end
end
r.on 'notes' do
r.is do
r.get do
# Support filtering by tag: GET /notes?tag=ruby
if (tag_name = r.params['tag'])
STORE[:notes].values.select { |n| n['tags'].include?(tag_name) }
else
STORE[:notes].values
end
end
r.post { create_note(r) }
end
r.on Integer do |id|
note = STORE[:notes][id]
r.halt(404) unless note
r.is do
r.get { note }
r.put { update_note(r, note) }
r.delete { STORE[:notes].delete(id); response.status = 204; '' }
end
r.on 'tags' do
r.is do
r.get { note['tags'] }
r.post do
tag_name = r.params['name'].to_s
r.halt(422, {'error' => 'name required'}) if tag_name.empty?
note['tags'] |= [tag_name] # union — no duplicates
note['tags']
end
end
r.on String do |tag_name|
r.is do
r.delete do
note['tags'].delete(tag_name)
response.status = 204
''
end
end
end
end
end
end
end
private
def create_note(r)
content = r.params['content'].to_s
r.halt(422, {'error' => 'content is required'}) if content.empty?
id = STORE[:next_note_id][0]
STORE[:next_note_id][0] += 1
note = {
'id' => id,
'content' => content,
'tags' => [],
'created' => Time.now.iso8601,
'updated' => Time.now.iso8601,
}
STORE[:notes][id] = note
response.status = 201
note
end
def update_note(r, note)
note['content'] = r.params['content'].to_s if r.params['content']
note['updated'] = Time.now.iso8601
note
end
def create_tag(r)
name = r.params['name'].to_s
r.halt(422, {'error' => 'name is required'}) if name.empty?
id = STORE[:next_tag_id][0]
STORE[:next_tag_id][0] += 1
tag = {'id' => id, 'name' => name}
STORE[:tags][id] = tag
response.status = 201
tag
end
end
Try it:
# Create some notes
curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "Rack is a protocol"}' | jq .id
curl -s -X POST http://localhost:9292/notes \
-H 'Content-Type: application/json' \
-d '{"content": "Roda uses tree routing"}' | jq .id
# Tag note 1
curl -s -X POST http://localhost:9292/notes/1/tags \
-H 'Content-Type: application/json' \
-d '{"name": "rack"}'
curl -s -X POST http://localhost:9292/notes/1/tags \
-H 'Content-Type: application/json' \
-d '{"name": "ruby"}'
# Tag note 2
curl -s -X POST http://localhost:9292/notes/2/tags \
-H 'Content-Type: application/json' \
-d '{"name": "roda"}'
curl -s -X POST http://localhost:9292/notes/2/tags \
-H 'Content-Type: application/json' \
-d '{"name": "ruby"}'
# Filter by tag
curl -s 'http://localhost:9292/notes?tag=ruby' | jq 'map(.content)'
# ["Rack is a protocol", "Roda uses tree routing"]
curl -s 'http://localhost:9292/notes?tag=rack' | jq 'map(.content)'
# ["Rack is a protocol"]
# Remove a tag
curl -s -X DELETE http://localhost:9292/notes/1/tags/ruby
# 204
curl -s 'http://localhost:9292/notes?tag=ruby' | jq 'map(.content)'
# ["Roda uses tree routing"]
What Roda Added
Compare this to the bare Rack version from two chapters ago:
What got better:
r.halt(404)instead of manually building[404, {...}, [...]]- Return values are automatically serialized as JSON (no manual
JSON.generate+ headers) r.paramsautomatically parsed JSON body (noJSON.parse(env['rack.input'].read))- The routing structure mirrors the URL structure exactly
- Database lookup (well, hash lookup) happens once for nested routes
What's still our responsibility:
- Input validation
- Error responses
- Storage (we're still using an in-memory hash)
- Authentication, authorization
What Roda didn't add that we didn't ask for:
- Database integration
- Template rendering
- Sessions (unless we add the plugin)
- CSRF protection (unless we add the plugin)
This is Roda's philosophy. The framework gives you routing and the infrastructure to opt into more. You add what you need.
The route Block Is Just Ruby
The most important thing to understand about Roda's routing is that the route block is plain Ruby code that runs for every request. There's no pre-compilation step, no route table generation at startup, no metaprogramming magic at call time.
When a request comes in for GET /notes/42, Roda:
- Creates a new instance of your application class
- Calls the
routeblock (viainstance_exec) with the request object - The block runs, evaluating
r.on 'notes'(which matches), thenr.on Integer(which matches42), thenr.is(which matches the end), thenr.get(which matches the method) - The return value of the block is the response
It's that mechanical. There's no magic routing table. The routing tree is expressed as a Ruby block, and the block runs as Ruby code.
This means you can do things in routes that you can't do in a static routing table:
route do |r|
# Routing based on configuration
if FEATURES[:new_api]
r.on 'api/v2' do
# new API routes
end
end
# Routing based on request data
r.on 'api' do
if r.env['HTTP_X_API_VERSION'] == '2'
# version 2 handlers
else
# version 1 handlers
end
end
# Dynamic routing (be careful with this)
r.on String do |segment|
page = Page.find_by_slug(segment)
r.halt(404) unless page
page.content
end
end
The route block is code. Use it like code.
What to Read Next
Now that you have a working Roda app, the next things to explore are:
- The plugin system — how to add features without the framework imposing them
- Middleware integration — how Roda works with Rack middleware
- Testing — which is genuinely pleasant, for reasons we'll get to
All of that is next.
Roda's Plugin System (Opt-In Everything)
Roda ships with a small core and a substantial library of optional plugins. The core gives you routing. Plugins give you everything else. This is not a compromise — it's a design philosophy with real consequences for performance, readability, and maintenance.
Why Plugins Instead of Defaults
Most frameworks come with everything on. Rails loads session handling, CSRF protection, cookie parsing, flash messages, and two dozen other features before your first line of code runs. This is convenient when you need all of those things. It's wasteful when you don't.
Roda's approach: the core does almost nothing beyond routing. Every other feature is a plugin you explicitly enable. If you don't use sessions, you don't pay for session handling. If you don't use templates, there's no template engine in memory. If you build a pure JSON API, there's no HTML-related code in your stack.
The practical consequence is a dramatically smaller memory footprint and faster boot time for applications that don't need the kitchen sink. Roda applications are consistently among the fastest Ruby web frameworks in benchmarks, and the plugin system is a significant reason why.
Loading Plugins
class App < Roda
plugin :json # JSON serialization of return values
plugin :json_parser # Parse JSON request bodies
plugin :halt # r.halt for early exit
plugin :all_verbs # PUT, PATCH, DELETE support
plugin :status_handler # Custom handlers for 404, 500, etc.
plugin :sessions, # Cookie-based sessions
secret: ENV['SESSION_SECRET']
end
plugin is a class method that extends the application class with the plugin's capabilities. Some plugins add instance methods. Some add class-level DSL. Some modify the request or response objects.
The Core Plugins
Here's what the most commonly used plugins provide:
json and json_parser
plugin :json
plugin :json_parser
route do |r|
r.post 'users' do
# r.params already has the parsed JSON body (via json_parser)
name = r.params['name']
user = create_user(name)
# Returning a Hash or Array automatically serializes to JSON (via json)
user
end
end
Without json, you'd write response['Content-Type'] = 'application/json'; JSON.generate(user) every time.
Without json_parser, you'd write JSON.parse(env['rack.input'].read) for every POST.
halt
plugin :halt
route do |r|
r.on 'users', Integer do |id|
user = User[id]
r.halt(404, {'error' => 'user not found'}) unless user
# user is definitely non-nil from here
r.get { user }
end
end
r.halt raises a special exception caught by Roda that immediately returns the given response. It's a clean way to implement guard clauses in routing code.
status_handler
plugin :status_handler
status_handler(404) do
{'error' => 'Not found', 'path' => request.path}
end
status_handler(500) do |e|
logger.error "#{e.class}: #{e.message}"
{'error' => 'Internal server error'}
end
status_handler registers a block that runs when Roda would otherwise return that status code — including when r.halt(404) is called and when an unhandled exception occurs.
sessions
plugin :sessions, secret: ENV.fetch('SESSION_SECRET')
route do |r|
r.post 'login' do
user = User.authenticate(r.params['username'], r.params['password'])
if user
session['user_id'] = user.id
{'status' => 'logged in'}
else
r.halt(401, {'error' => 'invalid credentials'})
end
end
r.get 'me' do
user_id = session['user_id']
r.halt(401, {'error' => 'not authenticated'}) unless user_id
User[user_id]
end
end
Sessions are signed cookies by default. The secret is used to generate the HMAC signature that prevents tampering.
render (Tilt/ERB templates)
plugin :render, views: 'views'
route do |r|
r.get 'users' do
@users = User.all
render('users/index') # renders views/users/index.erb
end
r.get 'users', Integer do |id|
@user = User[id]
r.halt(404) unless @user
render('users/show')
end
end
The render plugin uses Tilt under the hood, which supports ERB, Haml, Slim, and other template engines. Instance variables set in the routing block are available in templates.
csrf
plugin :csrf
route do |r|
# CSRF token is automatically checked on POST/PUT/PATCH/DELETE
# You need to include the token in your forms:
# <input type="hidden" name="_csrf" value="<%= csrf_token %>">
end
CSRF protection verifies that state-changing requests include a valid token. The csrf plugin adds this check automatically for non-GET requests, with a configurable token field name.
assets
plugin :assets,
css: ['app.css'],
js: ['app.js']
route do |r|
r.assets # serves /assets/... in development
# ...
end
The assets plugin handles serving static assets in development and generating fingerprinted asset paths for production.
websockets
plugin :websockets
route do |r|
r.get 'ws' do
r.websocket do |ws|
ws.on(:message) { |msg| ws.send("echo: #{msg}") }
ws.on(:close) { puts "disconnected" }
end
end
end
WebSocket support as a plugin. If you don't use WebSockets, none of this code is loaded.
Writing Your Own Plugin
A Roda plugin is a Ruby module. The structure:
module Roda::RodaPlugins::MyPlugin
# Code that runs when plugin is loaded
def self.configure(app, opts = {})
app.instance_variable_set(:@my_plugin_opts, opts)
end
# Methods added to the application class itself
module ClassMethods
def my_class_method
"available as App.my_class_method"
end
end
# Methods added to the application instance
# (available in the route block and route methods)
module InstanceMethods
def current_user
@current_user ||= User[session['user_id']]
end
def require_login!
r.halt(401, {'error' => 'not authenticated'}) unless current_user
end
end
# Methods added to r (the request object)
module RequestMethods
def require_permission!(permission)
user = scope.current_user # scope = the app instance
unless user&.has_permission?(permission)
halt(403, {'error' => 'forbidden'})
end
end
end
# Methods added to the response object
module ResponseMethods
def set_flash(message)
self['X-Flash-Message'] = message
end
end
end
# Register the plugin so `plugin :my_plugin` finds it
Roda::RodaPlugins.register_plugin(:my_plugin, Roda::RodaPlugins::MyPlugin)
Use it:
class App < Roda
plugin :my_plugin, some_option: 'value'
route do |r|
r.on 'protected' do
require_login! # from InstanceMethods
r.require_permission!(:admin) # from RequestMethods
r.get { current_user } # from InstanceMethods
end
end
end
This is not magic. module ClassMethods is extended into the application class. module InstanceMethods is included into the application class (so its methods become instance methods). module RequestMethods is included into Roda::RodaRequest. module ResponseMethods is included into Roda::RodaResponse.
Composing Plugins
One plugin can use another:
module Roda::RodaPlugins::RequiresJson
def self.load_dependencies(app)
app.plugin :json
app.plugin :json_parser
app.plugin :halt
end
module InstanceMethods
def json_only!
unless request.content_type&.include?('application/json')
r.halt(415, {'error' => 'application/json required'})
end
end
end
end
load_dependencies is called before configure, and loads other plugins that this plugin depends on. The dependent plugins are loaded once, even if multiple plugins list the same dependency.
The Plugin System Is Rack's Philosophy Applied Again
Rack's philosophy: define a small interface, let everything else be composed on top.
Roda's plugin philosophy: define a small core, let everything else be opted into explicitly.
Both philosophies produce systems that are:
- Comprehensible: you can see exactly what's in your application
- Measurable: you can benchmark only what you've loaded
- Debuggable: when something goes wrong, you know what's running
When a Rails application behaves unexpectedly, "some middleware somewhere is doing something" is often the explanation. When a Roda application behaves unexpectedly, you look at the plugins you loaded. The list is short and you wrote it.
The tradeoff is explicit configuration. You have to know which plugins to load. You can't rely on convention as a substitute for understanding. This is not a bug — it's the point.
What the Full Stack Looks Like
A production Roda application using many features:
class App < Roda
# Core functionality
plugin :json
plugin :json_parser
plugin :halt
plugin :all_verbs
# Request/response helpers
plugin :status_handler
plugin :request_headers
# Security
plugin :sessions, secret: ENV.fetch('SESSION_SECRET')
plugin :csrf
# Content
plugin :render, views: 'views', layout: 'layout'
plugin :assets, css: %w[app.css], js: %w[app.js]
# Performance
plugin :caching # ETag/Last-Modified support
plugin :content_for # Yield blocks into layouts
# Error handling
plugin :error_handler do |e|
logger.error "#{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}"
r.halt(500, {'error' => 'Internal server error'})
end
status_handler(404) { render('errors/404') }
status_handler(403) { render('errors/403') }
route do |r|
r.assets
r.on 'api' do
# API routes — JSON responses
r.on 'users' do
# ...
end
end
# Web routes — HTML responses
r.get 'dashboard' do
require_login!
@data = load_dashboard_data
render('dashboard')
end
end
end
This application has explicit sessions, CSRF protection, error handling, template rendering, and asset serving. Nothing is hidden. Every feature is a named plugin.
Next: how middleware fits into a Roda application.
Middleware in Roda
Roda is a Rack application. Everything we said about Rack middleware in Part II applies directly. But Roda also has opinions about how middleware should be organized, and it introduces a couple of patterns that are worth understanding.
Using Rack Middleware with Roda
Standard Rack middleware works identically with Roda as with any other Rack application:
class App < Roda
use Rack::Deflater # gzip compression
use Rack::Session::Cookie, # sessions (Rack's version, not Roda's)
secret: ENV['SESSION_SECRET']
route do |r|
r.get 'hello' do
'Hello, World!'
end
end
end
use is inherited from Rack::Builder and adds middleware to the stack that wraps your Roda application. These middlewares run before any Roda routing.
Middleware vs. Plugins
Roda has two mechanisms for adding cross-cutting behavior: Rack middleware and Roda plugins. The choice matters.
Use Rack middleware for:
- Behavior that applies to all Rack applications uniformly (compression, request logging)
- Third-party middleware that doesn't know about Roda
- Middleware that needs to run before Roda even sees the request
Use Roda plugins for:
- Behavior that needs access to Roda's routing context
- Features that need to know about sessions, current user, or other application state
- Authentication and authorization that should happen within routes
The practical difference: middleware runs before Roda creates a request context. Plugins run inside that context. If your authentication middleware needs to set a current_user that your routes can access, you need a plugin, because middleware has no access to the Roda application instance.
The middleware Plugin
Roda includes a middleware plugin that lets you use a Roda app as middleware inside another Roda app:
# api_app.rb
class ApiApp < Roda
plugin :json
plugin :middleware # makes this usable as middleware
route do |r|
r.on 'api' do
r.get 'status' do
{'status' => 'ok', 'app' => 'api'}
end
end
end
end
# web_app.rb
class WebApp < Roda
use ApiApp # ApiApp handles /api/* requests, passes others through
route do |r|
r.get '/' do
'Main web app'
end
end
end
When ApiApp receives a request that doesn't match any of its routes, it calls the next application in the stack (the @app in standard middleware terms). This is what the middleware plugin adds — the pass-through behavior.
Without plugin :middleware, a Roda app that doesn't match a route returns 404. With it, the app passes the request to the next layer.
Building an Auth Middleware in Roda Style
Here's a common pattern: an authentication middleware that sets the current user in the env, which is then read by the application's plugin:
# middleware/authenticate.rb
class Authenticate
def initialize(app, header: 'HTTP_AUTHORIZATION')
@app = app
@header = header
end
def call(env)
token = env[@header]&.sub('Bearer ', '')
if token
user = User.find_by_token(token)
env['current_user'] = user if user
end
@app.call(env)
end
end
# plugins/current_user.rb
module Roda::RodaPlugins::CurrentUser
module InstanceMethods
def current_user
# Read from env, set by the middleware
env['current_user']
end
def require_authenticated!
r.halt(401, {'error' => 'Authentication required'}) unless current_user
end
def require_admin!
require_authenticated!
r.halt(403, {'error' => 'Forbidden'}) unless current_user.admin?
end
end
end
# app.rb
class App < Roda
use Authenticate # runs before Roda, populates env['current_user']
plugin :json
plugin :halt
plugin :current_user # reads env['current_user'] in route context
route do |r|
r.get 'public' do
'No auth required'
end
r.on 'private' do
require_authenticated! # from plugin
r.get 'data' do
{'user' => current_user.name, 'data' => [1, 2, 3]}
end
end
r.on 'admin' do
require_admin! # from plugin
r.get 'users' do
User.all
end
end
end
end
The middleware handles authentication (token verification, user lookup). The plugin provides ergonomic access to the result within Roda's context. Clean separation of concerns.
Conditional Middleware
Because Roda is a class and use is a class method, you can conditionally load middleware:
class App < Roda
use Rack::Deflater # always compress
use RequestLogger if $stdout.tty? # log to console in development
use Sentry::Rack::CaptureExceptions # error tracking in production
route do |r|
# ...
end
end
Or based on environment:
class App < Roda
if ENV['RACK_ENV'] == 'development'
use Rack::Lint # validates Rack compliance
use BetterErrors::Middleware, allow_ip: '127.0.0.1'
end
route do |r|
# ...
end
end
Middleware Ordering with Roda
The middleware ordering rules from Part II still apply, but Roda adds one consideration: plugins that modify request or response handling (like sessions or csrf) run inside the Roda instance, after all Rack middleware. Rack-level session middleware runs before Roda; Roda's session plugin runs inside Roda.
This means you shouldn't mix Rack session middleware with Roda's session plugin:
# Don't do this
class App < Roda
use Rack::Session::Cookie, secret: 'secret1' # Rack-level sessions
plugin :sessions, secret: 'secret2' # Roda-level sessions
# They'll conflict — two different session stores
end
# Do this instead
class App < Roda
plugin :sessions, secret: ENV['SESSION_SECRET'] # Roda handles sessions
end
Roda's session plugin uses a slightly different cookie format than Rack::Session::Cookie, so if you're migrating from a Rack session setup, you'll need to handle the transition carefully.
Inspecting the Middleware Stack
To see what middleware is in your stack:
# In a Rack app or Rails console
App.middleware.each { |m| p m }
Or at the Rack level:
# config.ru
require_relative 'app'
# Introspect before running
puts "Middleware stack:"
app = App
while app.respond_to?(:app)
puts " #{app.class}"
app = app.app
end
run App
In production, it's worth auditing your middleware stack. Every middleware is code that runs for every request. If you have middleware you're not using, remove it.
The Right Mental Model
Think of a Roda application as three nested layers:
[Rack middleware layer]
Rack::Deflater
YourAuthMiddleware
Rack::RequestId
↓ env hash passes through here
[Roda routing layer]
plugin :sessions reads/writes cookies
plugin :csrf validates tokens
r.on / r.is / r.get match paths and methods
↓ reaches your route block
[Your application layer]
current_user
business logic
data access
Middleware is for infrastructure concerns that don't need to know they're running in Roda. Plugins are for application concerns that benefit from Roda's routing context.
When you're unsure which to use: if it needs access to session, current_user, or any Roda-specific context, it's a plugin. If it treats the request as an opaque Rack env hash, it's middleware.
Next: testing Roda applications, which is one of the genuine pleasures of this stack.
Testing Roda Apps
Testing Roda applications is straightforward, and that's not an accident. Because a Roda app is a Rack-compatible callable, testing it requires no test server, no HTTP round-trips, and no framework-specific test DSL. You call call, you check the result.
Let's build a complete test suite.
Setup
# Gemfile
gem 'roda'
gem 'rack-test' # Rack testing helpers
gem 'minitest' # or rspec, your choice
gem 'json'
rack-test is a gem that provides a convenient DSL on top of Rack::MockRequest. It's the standard testing library for Rack applications, and it works with any Rack-compatible framework.
The Application Under Test
# app.rb
require 'roda'
require 'json'
class App < Roda
plugin :json
plugin :json_parser
plugin :halt
plugin :all_verbs
plugin :sessions, secret: 'test-secret-at-least-64-bytes-long-for-production-use'
STORE = Hash.new { |h, k| h[k] = {} }
NEXT_ID = Hash.new(1)
route do |r|
r.on 'notes' do
r.is do
r.get { STORE[:notes].values }
r.post { create_note(r) }
end
r.on Integer do |id|
note = STORE[:notes][id]
r.halt(404, {'error' => 'not found'}) unless note
r.is do
r.get { note }
r.put { update_note(r, note) }
r.delete { STORE[:notes].delete(id); response.status = 204; '' }
end
end
end
r.on 'auth' do
r.post 'login' do
if r.params['password'] == 'correct-password'
session['user'] = r.params['username']
{'status' => 'logged in', 'user' => r.params['username']}
else
r.halt(401, {'error' => 'invalid credentials'})
end
end
r.get 'whoami' do
user = session['user']
r.halt(401, {'error' => 'not authenticated'}) unless user
{'user' => user}
end
r.delete 'logout' do
session.clear
response.status = 204
''
end
end
end
private
def create_note(r)
content = r.params['content'].to_s
r.halt(422, {'error' => 'content required'}) if content.empty?
id = NEXT_ID[:note]
NEXT_ID[:note] += 1
note = {'id' => id, 'content' => content, 'created' => Time.now.iso8601}
STORE[:notes][id] = note
response.status = 201
note
end
def update_note(r, note)
note['content'] = r.params['content'] if r.params['content']
note['updated'] = Time.now.iso8601
note
end
end
Testing with rack-test
# test/test_app.rb
require 'minitest/autorun'
require 'rack/test'
require 'json'
require_relative '../app'
class AppTest < Minitest::Test
include Rack::Test::Methods
def app
App # rack-test calls App.call(env) for each request
end
# Reset storage before each test
def setup
App::STORE.clear
App::NEXT_ID.clear
end
# --- Notes ---
def test_empty_notes_list
get '/notes'
assert_equal 200, last_response.status
assert_equal 'application/json', last_response.content_type
assert_equal [], JSON.parse(last_response.body)
end
def test_create_note
post_json '/notes', content: 'Test note'
assert_equal 201, last_response.status
data = parse_response
assert_equal 'Test note', data['content']
assert_kind_of Integer, data['id']
assert data['created']
end
def test_create_note_requires_content
post_json '/notes', {}
assert_equal 422, last_response.status
assert_equal 'content required', parse_response['error']
end
def test_get_note
post_json '/notes', content: 'My note'
id = parse_response['id']
get "/notes/#{id}"
assert_equal 200, last_response.status
assert_equal 'My note', parse_response['content']
end
def test_get_missing_note
get '/notes/999'
assert_equal 404, last_response.status
assert_equal 'not found', parse_response['error']
end
def test_update_note
post_json '/notes', content: 'Original'
id = parse_response['id']
put_json "/notes/#{id}", content: 'Updated'
assert_equal 200, last_response.status
assert_equal 'Updated', parse_response['content']
assert parse_response['updated']
end
def test_delete_note
post_json '/notes', content: 'To delete'
id = parse_response['id']
delete "/notes/#{id}"
assert_equal 204, last_response.status
get "/notes/#{id}"
assert_equal 404, last_response.status
end
def test_multiple_notes
3.times { |i| post_json '/notes', content: "Note #{i}" }
get '/notes'
notes = JSON.parse(last_response.body)
assert_equal 3, notes.length
end
# --- Authentication ---
def test_login_success
post_json '/auth/login',
username: 'alice',
password: 'correct-password'
assert_equal 200, last_response.status
assert_equal 'alice', parse_response['user']
end
def test_login_failure
post_json '/auth/login',
username: 'alice',
password: 'wrong-password'
assert_equal 401, last_response.status
assert_equal 'invalid credentials', parse_response['error']
end
def test_whoami_when_not_authenticated
get '/auth/whoami'
assert_equal 401, last_response.status
end
def test_whoami_when_authenticated
post_json '/auth/login', username: 'alice', password: 'correct-password'
get '/auth/whoami'
assert_equal 200, last_response.status
assert_equal 'alice', parse_response['user']
end
def test_logout
post_json '/auth/login', username: 'alice', password: 'correct-password'
get '/auth/whoami'
assert_equal 200, last_response.status
delete '/auth/logout'
assert_equal 204, last_response.status
get '/auth/whoami'
assert_equal 401, last_response.status
end
# --- Helper methods ---
private
def post_json(path, data)
post path, data.to_json, 'CONTENT_TYPE' => 'application/json'
end
def put_json(path, data)
put path, data.to_json, 'CONTENT_TYPE' => 'application/json'
end
def parse_response
JSON.parse(last_response.body)
end
end
Run it:
$ ruby test/test_app.rb
Run options: --seed 42
# Running:
.............
Finished in 0.023s, 565.2 runs/s.
13 runs, 22 assertions, 0 failures, 0 errors, 0 skips
Thirteen tests, 23 milliseconds. No HTTP server, no database, no external dependencies.
What rack-test Provides
Rack::Test::Methods mixes in methods:
get(path, params, headers)— GET requestpost(path, body, headers)— POST requestput(path, body, headers)— PUT requestpatch(path, body, headers)— PATCH requestdelete(path, params, headers)— DELETE requestlast_response— the response from the last requestlast_request— the request that was sent
last_response has:
.status— integer status code.body— response body string.headers— response headers hash.content_type— Content-Type header
Sessions are maintained between requests automatically — rack-test includes cookies in subsequent requests. This is how the authentication test flow works: login, then whoami, without any manual cookie handling.
Testing Without rack-test
You don't need rack-test. It's convenient, but you can call your Roda app directly:
class MinimalTest < Minitest::Test
def make_request(method, path, body: nil, headers: {})
env = Rack::MockRequest.env_for(path,
method: method,
input: body ? StringIO.new(body) : StringIO.new,
'CONTENT_TYPE' => headers['Content-Type'] || 'application/json',
)
App.call(env)
end
def test_get_notes
status, headers, body = make_request('GET', '/notes')
assert_equal 200, status
assert_equal [], JSON.parse(body.join)
end
end
This is more verbose but requires no additional gems. It's what rack-test does internally.
Integration Tests vs. Unit Tests
The tests above are integration tests — they exercise the full routing stack. For complex business logic, you'll also want unit tests that test specific methods in isolation:
class NoteCreationTest < Minitest::Test
def test_note_content_validation
app = App.new({}) # Roda app instance — this is unusual but possible
# Test helper methods directly if they're accessible
# Usually better to test through the HTTP interface
end
end
In practice, for Roda applications, the HTTP-level integration tests are usually sufficient. The routing layer is thin — it delegates to methods or objects that contain business logic. Test those objects directly, and test the routing integration through HTTP.
Testing Middleware
To test middleware in isolation, wrap it around a simple app:
class MiddlewareTest < Minitest::Test
include Rack::Test::Methods
def app
# Wrap the middleware around a simple echo app
echo_app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['ok']] }
Rack::Builder.new do
use RateLimiter, limit: 3
run echo_app
end
end
def test_allows_requests_under_limit
3.times do
get '/'
assert_equal 200, last_response.status
end
end
def test_blocks_requests_over_limit
3.times { get '/' }
get '/'
assert_equal 429, last_response.status
end
end
Test the middleware, not the application. Middleware should have no knowledge of the application it wraps, and its tests should reflect that.
Test Structure for Real Applications
For a larger application, organize tests by resource:
test/
test_helper.rb # Minitest + rack-test setup, shared helpers
integration/
test_notes.rb # Notes resource tests
test_auth.rb # Authentication tests
test_users.rb # User resource tests
unit/
test_note.rb # Note model/service tests (no HTTP)
test_auth_service.rb
middleware/
test_rate_limiter.rb
# test/test_helper.rb
require 'minitest/autorun'
require 'rack/test'
require 'json'
require_relative '../app'
class IntegrationTest < Minitest::Test
include Rack::Test::Methods
def app
App
end
def setup
App::STORE.clear # or reset your database
end
def post_json(path, data)
post path, data.to_json, 'CONTENT_TYPE' => 'application/json'
end
def put_json(path, data)
put path, data.to_json, 'CONTENT_TYPE' => 'application/json'
end
def parse_response
JSON.parse(last_response.body)
end
def assert_status(expected)
assert_equal expected, last_response.status,
"Expected status #{expected}, got #{last_response.status}: #{last_response.body}"
end
end
# test/integration/test_notes.rb
require_relative '../test_helper'
class NotesTest < IntegrationTest
def test_crud_lifecycle
# Create
post_json '/notes', content: 'Learn Roda'
assert_status 201
id = parse_response['id']
# Read
get "/notes/#{id}"
assert_status 200
assert_equal 'Learn Roda', parse_response['content']
# Update
put_json "/notes/#{id}", content: 'Understand Roda'
assert_status 200
assert_equal 'Understand Roda', parse_response['content']
# Delete
delete "/notes/#{id}"
assert_status 204
get "/notes/#{id}"
assert_status 404
end
end
The Insight
Testing Roda applications is fast because there's no framework overhead, no HTTP round-trips, and no external services. The test suite from this chapter runs in milliseconds. A test suite with 500 tests that each take 2ms is done in a second.
Compare to a Rails application where integration tests spin up a test server, make HTTP requests, and wait for responses. Rails tests are slower partly because Rails does more, and partly because the abstraction layers make it harder to test at the right level.
Roda's thin stack means you can always test at exactly the right level: HTTP integration tests for routing concerns, unit tests for business logic, middleware tests for middleware. There's no intermediate layer that's difficult to test in isolation.
This is the compounding benefit of understanding your tools. When you understand what a Roda application is — a Rack callable with a routing block — you know exactly how to test it. No test framework magic required.
Roll Your Own Mini-Framework (For Fun and Understanding)
Everything you've learned in this book is now in service of one exercise: building a small but complete web framework from scratch. Not a toy — a usable framework with routing, middleware support, a plugin system, and a test helper.
This is not an exercise in "why bother with frameworks?" Frameworks exist because this exercise, repeated at production scale, is what produces them. The point is to internalize the patterns by building them, so that you can read Roda's source, or Rails's source, or any Ruby web framework's source, and recognize what you're looking at.
The Design
Our framework will be called Kiwi. It will have:
- Rack compatibility (obviously)
- A routing DSL with HTTP method helpers
- A simple plugin system
- A before/after filter mechanism
- A test helper
- About 150 lines of code
# kiwi.rb
require 'rack'
module Kiwi
class Application
# Class-level state
class << self
def routes
@routes ||= Hash.new { |h, k| h[k] = [] }
end
def filters
@filters ||= {before: [], after: []}
end
def plugins
@plugins ||= []
end
# HTTP method DSL
def get(path, &handler) = add_route('GET', path, &handler)
def post(path, &handler) = add_route('POST', path, &handler)
def put(path, &handler) = add_route('PUT', path, &handler)
def patch(path, &handler) = add_route('PATCH', path, &handler)
def delete(path, &handler) = add_route('DELETE', path, &handler)
def before(&block) = filters[:before] << block
def after(&block) = filters[:after] << block
def plugin(mod)
extend mod::ClassMethods if mod.const_defined?(:ClassMethods)
include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
mod.setup(self) if mod.respond_to?(:setup)
plugins << mod
end
# Make the class itself a Rack app
def call(env)
new(env).dispatch
end
# Middleware support via Rack::Builder
def use(middleware, *args, **kwargs, &block)
@builder ||= Rack::Builder.new
@builder.use(middleware, *args, **kwargs, &block)
@builder.run(method(:dispatch_directly))
end
def to_app
if @builder
@builder.to_app
else
method(:dispatch_directly)
end
end
private
def dispatch_directly(env)
new(env).dispatch
end
def add_route(method, path, &handler)
pattern, param_names = compile_pattern(path)
routes[method] << {pattern: pattern, params: param_names, handler: handler}
end
def compile_pattern(path)
param_names = []
pattern_str = path.gsub(/:(\w+)/) do
param_names << $1
'([^/]+)'
end
pattern = Regexp.new("\\A#{pattern_str}\\z")
[pattern, param_names]
end
end
# Instance methods (one instance per request)
attr_reader :env, :request, :response, :params
def initialize(env)
@env = env
@request = Rack::Request.new(env)
@response = Rack::Response.new
@params = {}
end
def dispatch
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
# Run before filters
self.class.filters[:before].each { |f| instance_exec(&f) }
result = find_and_run_route(method, path)
# Run after filters
self.class.filters[:after].each { |f| instance_exec(&f) }
# Handle the result
case result
when Array # raw Rack response
result
when String
response.write(result)
response.finish
else
response.status = 404
response.write('Not Found')
response.finish
end
rescue HaltError => e
e.response
end
private
def find_and_run_route(method, path)
candidates = self.class.routes[method] || []
candidates.each do |route|
match = route[:pattern].match(path)
next unless match
# Populate params hash
route[:params].each_with_index do |name, i|
@params[name] = match[i + 1]
end
@params.merge!(request.params)
return instance_exec(@params, &route[:handler])
end
nil # no match → 404
end
end
# Early exit mechanism
class HaltError < StandardError
attr_reader :response
def initialize(status, body = '', headers = {})
@response = [status, {'Content-Type' => 'text/plain'}.merge(headers), [body]]
end
end
module InstanceMethods
def halt(status, body = '', headers = {})
raise HaltError.new(status, body, headers)
end
def redirect(location, status = 302)
halt(status, '', 'Location' => location)
end
def json(data, status: 200)
require 'json'
body = JSON.generate(data)
response.status = status
response['Content-Type'] = 'application/json'
response.write(body)
response.finish
end
end
Application.include(InstanceMethods)
end
That's the core. ~120 lines. Let's verify it works:
# test_kiwi.rb
require_relative 'kiwi'
require 'rack/mock'
require 'json'
class BlogApp < Kiwi::Application
POSTS = {}
NEXT = [1]
before do
response['X-Framework'] = 'Kiwi/0.1'
end
get '/' do |params|
json({posts: POSTS.count, message: 'Welcome to KiwiBlog'})
end
get '/posts' do |params|
json(POSTS.values)
end
post '/posts' do |params|
id = NEXT[0]
NEXT[0] += 1
post = {id: id, title: params['title'], body: params['body']}
POSTS[id] = post
json(post, status: 201)
end
get '/posts/:id' do |params|
post = POSTS[params['id'].to_i]
halt(404, 'Post not found') unless post
json(post)
end
delete '/posts/:id' do |params|
halt(404) unless POSTS.delete(params['id'].to_i)
response.status = 204
''
end
end
# Test it
def req(method, path, body: nil)
env = Rack::MockRequest.env_for(path,
method: method,
input: body ? StringIO.new(body) : StringIO.new,
'CONTENT_TYPE' => 'application/json'
)
BlogApp.call(env)
end
status, headers, body = req('GET', '/')
puts "#{status}: #{body.join}"
# 200: {"posts":0,"message":"Welcome to KiwiBlog"}
puts headers['X-Framework']
# Kiwi/0.1
status, headers, body = req('POST', '/posts',
body: '{"title":"Hello","body":"World"}'
)
puts "#{status}: #{body.join}"
# 201: {"id":1,"title":"Hello","body":"World"}
status, _, body = req('GET', '/posts/1')
puts "#{status}: #{body.join}"
# 200: {"id":1,"title":"Hello","body":"World"}
status, _, _ = req('DELETE', '/posts/1')
puts status
# 204
status, _, body = req('GET', '/posts/1')
puts "#{status}: #{body.join}"
# 404: Post not found
Adding the Plugin System
# Add to Kiwi::Application class methods:
def self.plugin(mod)
mod.extend(mod::ClassMethods) if mod.const_defined?(:ClassMethods)
mod.setup(self) if mod.respond_to?(:setup)
include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
end
# A plugin: JSON request body parsing
module Kiwi
module JsonBody
def self.setup(app)
# no class-level setup needed
end
module InstanceMethods
def json_body
return {} unless env['CONTENT_TYPE']&.include?('application/json')
require 'json'
@json_body ||= JSON.parse(request.body.read)
rescue JSON::ParserError
halt(400, 'Invalid JSON')
end
end
end
# A plugin: simple authentication
module SimpleAuth
def self.setup(app)
app.before do
@authenticated = session_valid?
end
end
module InstanceMethods
def authenticated?
@authenticated
end
def require_auth!
halt(401, 'Unauthorized') unless authenticated?
end
private
def session_valid?
# In real life: check a session cookie or Authorization header
env['HTTP_AUTHORIZATION'] == 'Bearer valid-token'
end
end
end
end
# Using plugins:
class SecureApp < Kiwi::Application
plugin Kiwi::JsonBody
plugin Kiwi::SimpleAuth
get '/public' do |_|
'Anyone can see this'
end
get '/private' do |_|
require_auth!
json({secret: 'classified data'})
end
post '/data' do |_|
require_auth!
data = json_body
json({received: data}, status: 201)
end
end
Adding a Test Helper
# kiwi/test_helper.rb
module Kiwi
module TestHelper
def self.included(base)
base.include(InstanceMethods)
end
module InstanceMethods
def app_class
raise NotImplementedError, "Define app_class in your test"
end
def get(path, headers: {})
call_app('GET', path, headers: headers)
end
def post(path, body: nil, headers: {})
call_app('POST', path, body: body, headers: headers)
end
def put(path, body: nil, headers: {})
call_app('PUT', path, body: body, headers: headers)
end
def delete(path, headers: {})
call_app('DELETE', path, headers: headers)
end
def post_json(path, data)
post(path,
body: JSON.generate(data),
headers: {'CONTENT_TYPE' => 'application/json'}
)
end
def last_status = @last_response[0]
def last_headers = @last_response[1]
def last_body = @last_response[2].join
def last_json = JSON.parse(last_body)
private
def call_app(method, path, body: nil, headers: {})
env = Rack::MockRequest.env_for(path,
{method: method, input: body ? StringIO.new(body) : StringIO.new}
.merge(headers)
)
@last_response = app_class.call(env)
end
end
end
end
# Using it:
require 'minitest/autorun'
require_relative 'kiwi/test_helper'
require_relative 'blog_app'
class BlogAppTest < Minitest::Test
include Kiwi::TestHelper
def app_class = BlogApp
def setup = BlogApp::POSTS.clear
def test_home
get '/'
assert_equal 200, last_status
assert_equal 0, last_json['posts']
end
def test_create_and_read_post
post_json '/posts', title: 'Test', body: 'Content'
assert_equal 201, last_status
id = last_json['id']
get "/posts/#{id}"
assert_equal 200, last_status
assert_equal 'Test', last_json['title']
end
end
What 150 Lines Taught Us
Our mini-framework implements:
- ✅ Rack compliance
- ✅ HTTP method routing (GET, POST, PUT, PATCH, DELETE)
- ✅ Path parameter extraction
- ✅ Query parameter merging
- ✅ Before/after filters
- ✅ JSON helpers
- ✅ Early exit via
halt - ✅ Redirect helper
- ✅ Plugin system
- ✅ Test helper
What it doesn't have:
- ❌ Tree routing (all routes are flat, O(n) matching)
- ❌ Route priorities
- ❌ Template rendering
- ❌ Sessions
- ❌ CSRF protection
- ❌ Asset handling
- ❌ WebSocket support
- ❌ Streaming responses
- ❌ Content negotiation
- ❌ A decade of bug fixes
The gap between our 150 lines and Roda's ~3,000 lines (core only) is exactly those missing features, plus the handling of edge cases we haven't considered (unusual HTTP methods, malformed requests, encoding issues, thread safety).
The gap between Roda and Rails is the additional 60,000+ lines that provide ActiveRecord, ActionView, ActionMailer, and the rest.
None of those additional lines are magic. They're solutions to real problems, written by people who understood exactly the same foundation we just built.
Where to Go From Here
If you want to keep exploring:
- Read Roda's source — it's well-organized and the plugins are independently readable
- Read Rack's source — the middleware in the gem are good examples of the pattern
- Read Sinatra's source — it's a single large file and instructive in how a flat DSL framework is built
- Look at Cuba — a micro-framework even simpler than Kiwi that influenced Roda
The pattern you've built is the pattern everything uses. The rest is features.
Rails vs Sinatra vs Roda (Now That You Know What They Are)
Having built a Rack server, written middleware, implemented a router, and assembled a mini-framework, we're in a position to compare these three frameworks with precision rather than opinion.
This is not a benchmark article. Benchmarks are useful but narrow. This is a comparison of design philosophies, and what those philosophies mean for the kind of applications each framework is appropriate for.
The Common Foundation
All three frameworks:
- Are valid Rack applications — they respond to
call(env)and return[status, headers, body] - Can use any Rack-compatible server (Puma, Unicorn, Falcon, WEBrick)
- Can use any Rack middleware
- Can be mounted inside each other
Here's the proof of the last point:
# A Rails app mounting a Sinatra app mounting a Roda app
# This is contrived, but it works.
# roda_api.rb
class RodaApi < Roda
plugin :json
route do |r|
r.get 'status' do
{'roda' => true}
end
end
end
# sinatra_app.rb
require 'sinatra/base'
class SinatraApp < Sinatra::Base
get '/sinatra/status' do
content_type :json
{sinatra: true}.to_json
end
# Mount the Roda app
map('/roda') { run RodaApi }
end
# In config/routes.rb (Rails)
Rails.application.routes.draw do
mount SinatraApp => '/sinatra-land'
# GET /sinatra-land/sinatra/status → Sinatra handles it
# GET /sinatra-land/roda/status → Roda handles it
end
This works because they all speak Rack. mount in Rails routes calls the mounted app's call method for matching requests.
Rails
What it is: A full-stack web framework with conventions for everything. Database ORM (ActiveRecord), view rendering (ActionView), email (ActionMailer), jobs (ActiveJob), WebSockets (ActionCable), asset compilation, and more — all integrated and conventionally configured.
What it gives you:
rails generatescaffolds entire CRUD resources in seconds- ActiveRecord is a mature, powerful ORM with a large ecosystem of gems
- Rails's conventions mean you can pick up a Rails project and know where things live
- ActiveSupport adds useful Ruby core extensions
- Security features (CSRF, XSS protection) on by default
- The largest Ruby web framework community, by far
What it costs:
- Memory: a minimal Rails app uses ~70-100MB of RAM at startup, before your code runs
- Boot time: several seconds in development, longer in large applications
- Complexity: the middleware stack, before/after callback chains, and concerns can make tracing execution surprisingly difficult
- Convention lock-in: stepping outside Rails conventions (different ORMs, custom routing) requires fighting the framework
When to choose Rails:
- You're building an application that fits the Rails sweet spot: database-backed web application with HTML views, standard CRUD operations, authenticated users
- You value the breadth of the ecosystem (authentication gems like Devise, admin interfaces like ActiveAdmin, pagination, etc.)
- Your team already knows Rails
- You need to move fast and the application is relatively conventional
When Rails is the wrong choice:
- Pure JSON APIs where ActiveRecord's overhead isn't needed
- Applications with unusual routing requirements (Rails's router is powerful but the flat route list doesn't scale as well as a tree)
- High-traffic applications where memory and boot time matter significantly
- Microservices where you want minimal dependencies
# Rails: conventions do a lot of work
class ProjectsController < ApplicationController
before_action :authenticate!
before_action :set_project, only: [:show, :update, :destroy]
def index
@projects = current_user.projects
render json: @projects
end
def show
render json: @project
end
def create
@project = current_user.projects.build(project_params)
if @project.save
render json: @project, status: :created
else
render json: @project.errors, status: :unprocessable_entity
end
end
private
def set_project
@project = current_user.projects.find(params[:id])
end
def project_params
params.require(:project).permit(:name, :description)
end
end
Sinatra
What it is: A minimal web DSL. Routes, filters, helpers, template rendering. Nothing else.
What it gives you:
- Simple, readable routing DSL
- Template rendering via Tilt (ERB, Haml, Slim, Markdown, etc.)
- Before/after filters
- Helper methods via
helpers do - Very fast startup time
What it costs:
- Flat route list (same performance concerns as our hand-rolled router for large apps)
- No built-in solution for many common concerns (sessions via Rack middleware, auth you write yourself)
- Routing doesn't compose well — there's no natural way to express hierarchical routes
- The DSL can mislead you: Sinatra looks simple but some things (understanding scope in blocks, class vs. instance methods) are surprising
When to choose Sinatra:
- Small APIs with a modest number of routes
- Webhook receivers
- Development tools and internal utilities
- Prototypes
- Applications where you want to understand exactly what's in your stack
When Sinatra is the wrong choice:
- Large applications with complex routing (the flat route list becomes a maintenance burden)
- Applications that need the performance of tree routing
- Any application where you'd reinvent what Roda provides
# Sinatra: explicit and readable, but flat
class App < Sinatra::Base
before do
halt 401 unless authenticated?
end
get '/projects' do
content_type :json
Project.all.to_json
end
get '/projects/:id' do
project = Project.find_by(id: params[:id])
halt 404 unless project
content_type :json
project.to_json
end
post '/projects' do
project = Project.create(params[:project])
content_type :json
status 201
project.to_json
end
end
Roda
What it is: A Rack framework built around a routing tree and an opt-in plugin system.
What it gives you:
- Tree routing that scales to large applications
- Plugin system that keeps your footprint small
- Correct routing semantics (shared setup for nested routes)
- Fast — consistently one of the fastest Ruby web frameworks
- Excellent test ergonomics
- A codebase small enough to read and understand fully
What it costs:
- Smaller ecosystem than Rails (fewer ready-made gems designed for Roda)
- No ORM included (you choose Sequel, ActiveRecord, or ROM)
- More explicit configuration (everything is opt-in, nothing is automatic)
- Less documentation and fewer tutorials than Rails
When to choose Roda:
- JSON APIs, especially those with complex routing
- Applications where memory and performance are important
- Teams that value explicit over implicit
- Applications using Sequel (Jeremy Evans authored both)
- When you want a framework whose internals you can actually understand
When Roda is the wrong choice:
- When you need the Rails ecosystem (Devise, RailsAdmin, etc.) specifically
- Teams that are deeply invested in Rails conventions and don't want to change
# Roda: hierarchical, DRY, explicit
class App < Roda
plugin :json
plugin :json_parser
plugin :halt
plugin :all_verbs
route do |r|
authenticate!
r.on 'projects' do
r.is do
r.get { Project.all }
r.post do
project = Project.create(r.params['project'])
response.status = 201
project
end
end
r.on Integer do |id|
project = Project[id]
r.halt(404, {'error' => 'not found'}) unless project
# All three handlers share the already-loaded project
r.get { project }
r.put { project.update(r.params['project']); project }
r.delete { project.destroy; response.status = 204; '' }
end
end
end
end
The Actual Decision Criteria
When choosing a framework, the questions that matter:
1. What is the team's existing knowledge? A team that knows Rails deeply will be more productive with Rails than with Roda, even if Roda is theoretically a better fit. Retraining time is a real cost.
2. What does the application actually need? If you need ActiveRecord's specific feature set (STI, counter caches, extensive callbacks), Rails's integration with it is valuable. If you're choosing your ORM separately (Sequel, ROM), Roda or Sinatra might be cleaner.
3. What are the performance requirements? A small API that gets 100 requests per second doesn't need Roda's performance advantages. An API getting 10,000 requests per second might care very much.
4. How complex is the routing? 10 routes: all three work fine. 50 routes: Rails and Roda both handle it; Sinatra starts to get unwieldy. 200 routes: Roda's tree routing is a genuine advantage.
5. How much do you value explicitness? Rails convention is powerful when you know what the conventions are and your application follows them. When you step outside them, it's friction. Roda's opt-in approach means you always know what's running.
What They Actually Share
This is the more important point: all three frameworks are doing the same fundamental thing. They receive a Rack env hash, they route the request to a handler, the handler returns a response, the response is sent.
The differences are:
- How routing is expressed and evaluated
- What defaults are provided
- What the plugin/gem ecosystem looks like
- Memory and performance characteristics
None of these differences are about what's possible. Everything you can build with Rails, you can build with Roda or Sinatra. The differences are about convenience, convention, and performance.
When you know Rack, you know that Rails isn't doing magic — it's routing to a callable and returning a response array. When you know routing is pattern matching, you can read any framework's routing code. When you know middleware is just objects wrapping other objects, you can debug middleware issues in any framework.
The framework you choose matters less than you probably thought before reading this book. Your understanding of what it's doing matters considerably more.
A Note on Performance Numbers
Roda is consistently faster than Sinatra and dramatically faster than Rails in benchmarks. Some rough ordering for a "hello world" endpoint:
| Framework | Throughput (relative) | Memory |
|---|---|---|
| Roda | ~100% | ~30MB |
| Sinatra | ~60-70% | ~40MB |
| Rails | ~20-30% | ~100MB |
These numbers are meaningless without context. A "hello world" benchmark measures framework overhead, not application overhead. In a real application:
- Your business logic usually dominates the response time
- Database queries are usually the bottleneck, not routing
- Memory differences matter at scale (100 processes × 70MB difference = 7GB)
The performance advantage of Roda matters when you're CPU-bound on framework overhead, which happens at high traffic volume or in very thin services. For most applications, pick the framework your team knows best and optimize later if needed.
The understanding advantage — knowing what your framework is doing — matters always.
You Know Too Much Now (What to Do With It)
You started this book knowing how to use Ruby web frameworks. You end it knowing what they are.
The gap between "knowing how to use" and "knowing what it is" is the difference between operating a tool and understanding it. Both are useful. The second makes you significantly more capable with the first.
Let's account for what actually changed.
What You Now Know
HTTP is text with a defined format. A request is a method, a path, headers, and an optional body — all ASCII text over a TCP socket. A response is a status code, headers, and a body. You've built both from raw sockets. This means you can debug HTTP problems at the wire level, read network traces, and understand error messages that previously seemed cryptic.
Rack is a contract. The Rack spec defines one thing: your application receives a hash, it returns a three-element array. That contract is what makes every Ruby web framework interoperable. Rails, Sinatra, and Roda all honor it. You've read the spec, built conforming applications, built a conforming server, and written middleware. The contract is no longer abstract.
Middleware is function composition. An object that wraps another object and delegates to it, with behavior added before or after. That's it. You've written several middlewares. You can now read any middleware in any framework and understand exactly what it does, because they all use the same pattern.
Routing is pattern matching. A router maintains a list (or tree) of (method, pattern, handler) tuples. When a request arrives, it's matched against the list. You've built a flat router. You've used Roda's tree router. You understand why tree routing has better performance characteristics and better structural properties for large applications.
Frameworks are solutions to repeated problems. Rails solves the problem of building database-backed web applications with HTML views at scale. Sinatra solves the problem of writing small web services without a lot of boilerplate. Roda solves the problem of routing and opt-in features for applications that need both correctness and performance. None of them are magic. You've built a mini-framework that demonstrates the core of what all of them do.
What You Can Do With It
Read framework source code. Before reading this book, opening actiondispatch/routing.rb or roda.rb probably felt like reading a different language. Now it's Ruby solving problems you understand. When something in your application behaves unexpectedly, you can follow the execution into the framework and find the answer.
Write middleware confidently. Request logging, authentication, rate limiting, content negotiation, CORS headers — these all belong in middleware. You know the pattern. You know where they go in the stack. You know how to test them.
Debug routing problems. Routing bugs in production are some of the most disorienting because the framework seems to be lying to you. Now you know what the framework is doing. You can inspect the route table, add logging middleware to see what the env contains before routing, and identify whether the problem is in the route definition or the request.
Make informed framework choices. When someone proposes switching from Sinatra to Rails (or from Rails to Roda, or from Roda to anything else), you can evaluate the trade-offs with precision. Not "Rails has more features" or "Roda is faster" — specific statements about routing architecture, memory footprint, middleware organization, and ecosystem breadth.
Evaluate Rack middleware gems. The Ruby ecosystem has hundreds of Rack middleware gems for caching, authentication, logging, profiling, and more. You can now read any of them, understand what they do, and decide whether they're appropriate for your stack.
Build things the framework doesn't support. Sometimes you need something the framework doesn't provide. Before this book, that meant searching for a gem or filing a feature request. Now it might mean writing fifty lines of Rack middleware that solve exactly your problem without any framework coupling.
The Unsexy Part
There's a tendency in the software field to fetishize "building from scratch" as a virtue. It isn't. The appropriate level of abstraction for any given task is the one that lets you solve the problem without drowning in detail.
For most web applications, Rails is not only appropriate — it's excellent. The conventions are sound. ActiveRecord is battle-tested. The ecosystem is mature. "Building from scratch because you understand how it works" is not a good reason to write your own ORM.
What this book gives you is not a reason to abandon your existing tools. It gives you the ability to use them with full understanding. That's different, and more valuable.
The developer who knows Rails inside-out and understands the Rack layer beneath it will outperform the developer who knows only Rails, and will outperform the developer who insists on building everything from scratch. Understanding the foundation makes you better at using the abstractions built on it.
A Few Things Worth Doing
If you want to reinforce what you've learned:
Read Rack's middleware implementations. Rack::Session::Cookie, Rack::Runtime, Rack::Deflater — these are all short, well-written, and instructive. They're in your gems directory right now.
Read Roda's core. The main roda.rb file is about 1,000 lines and implements everything we discussed in Part III. Read it front to back. You'll recognize every pattern.
Open a Rails console and poke the middleware stack. Rails.application.middleware.each { |m| p m }. Find the session middleware. Find the router. Understand what order they're in and why.
Write a middleware for an existing application. Add request timing headers. Add a custom logger that formats output the way you want. Add a middleware that blocks requests from specific IP ranges. These are practical, finite exercises that solidify the pattern.
Read the Rack spec. The actual Rack specification is short enough to read in fifteen minutes. You know enough now to understand every word of it.
The Last Thing
At the beginning of this book, we said that Rails is just a callable. You may have believed it as a statement, but not felt it as a truth.
By now you've built a callable that speaks HTTP. You've built a server that calls callables. You've built middleware that wraps callables in other callables. You've built a routing tree that dispatches to callables. You've seen how Roda is a callable, and how its plugins and route handlers are all ultimately callables.
When you now hear "Rails is just a callable," you know it's true in the same way you know that a skyscraper is just concrete and steel. The material is simple. The engineering is sophisticated. Knowing the material doesn't diminish the engineering — it lets you appreciate it more accurately.
Go build something. You know what it's made of.