Skip to content

Commit e5c3945

Browse files
DavidLiedleclaude
andcommitted
Add chapter: The Rack Spec (It Fits on a Napkin)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ebefc31 commit e5c3945

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

src/rack/the-spec.md

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# The Rack Spec (It Fits on a Napkin)
2+
3+
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.
4+
5+
Here's the spec:
6+
7+
> 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*.
8+
9+
That's it. Three rules:
10+
11+
1. Your app is an object (not a class — an instance)
12+
2. It has a `call` method that takes an environment hash
13+
3. `call` returns `[status, headers, body]`
14+
15+
Everything else is elaboration.
16+
17+
## The Environment Hash
18+
19+
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.
20+
21+
The Rack spec requires these keys:
22+
23+
| Key | Type | Description |
24+
|-----|------|-------------|
25+
| `REQUEST_METHOD` | String | `"GET"`, `"POST"`, `"PUT"`, etc. |
26+
| `SCRIPT_NAME` | String | Mount point of the application (often `""`) |
27+
| `PATH_INFO` | String | Path component of the URL, e.g. `"/users/42"` |
28+
| `QUERY_STRING` | String | Query string without `?`, e.g. `"page=2&sort=name"` |
29+
| `SERVER_NAME` | String | Hostname, e.g. `"example.com"` |
30+
| `SERVER_PORT` | String | Port as a string, e.g. `"80"` |
31+
| `HTTP_*` | String | HTTP request headers, upcased with hyphens replaced by underscores |
32+
| `rack.version` | Array | Rack version, e.g. `[1, 3]` |
33+
| `rack.url_scheme` | String | `"http"` or `"https"` |
34+
| `rack.input` | IO-like | The request body, readable via `read`, `gets`, `each` |
35+
| `rack.errors` | IO-like | Error stream (usually `$stderr`) |
36+
| `rack.multithread` | Boolean | Whether the server is multi-threaded |
37+
| `rack.multiprocess` | Boolean | Whether the server is multi-process |
38+
| `rack.run_once` | Boolean | Whether this process will handle only one request |
39+
| `rack.hijack?` | Boolean | Whether the server supports connection hijacking |
40+
41+
In practice, you'll mostly use `REQUEST_METHOD`, `PATH_INFO`, `QUERY_STRING`, and `HTTP_*` headers. The `rack.input` stream is important for POST bodies.
42+
43+
Some real-world additions that aren't in the base spec but you'll encounter:
44+
45+
- `rack.session` — your session data (added by session middleware)
46+
- `rack.logger` — a logger (added by logger middleware)
47+
- `action_dispatch.*` — Rails-specific additions
48+
- `HTTP_COOKIE` — cookies as a string (`"name=value; other=thing"`)
49+
50+
## The Response Array
51+
52+
The response is `[status, headers, body]`:
53+
54+
**Status**: An integer HTTP status code. `200`, `201`, `301`, `404`, `500`. That's it.
55+
56+
```ruby
57+
status = 200
58+
```
59+
60+
**Headers**: A Hash of response headers. Keys are strings. Values are strings.
61+
62+
```ruby
63+
headers = {
64+
'Content-Type' => 'text/html; charset=utf-8',
65+
'Content-Length' => '13',
66+
}
67+
```
68+
69+
**Body**: An object that responds to `each`, yielding string chunks. Usually an Array of strings, sometimes an IO object for streaming.
70+
71+
```ruby
72+
body = ["Hello, World!"]
73+
74+
# Or for streaming:
75+
body = SomeObject.new
76+
def body.each
77+
yield "chunk 1"
78+
yield "chunk 2"
79+
yield "chunk 3"
80+
end
81+
```
82+
83+
The full minimal response:
84+
85+
```ruby
86+
[200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]
87+
```
88+
89+
## The Simplest Possible Rack App
90+
91+
```ruby
92+
# hello.rb
93+
require 'rack'
94+
95+
app = lambda do |env|
96+
[200, {'Content-Type' => 'text/plain'}, ['Hello, World!']]
97+
end
98+
99+
Rack::Handler::WEBrick.run app, Port: 9292
100+
```
101+
102+
Run it:
103+
104+
```bash
105+
$ gem install rack
106+
$ ruby hello.rb
107+
[2026-02-19 12:00:00] INFO WEBrick 1.7.0
108+
[2026-02-19 12:00:00] INFO ruby 3.3.0
109+
[2026-02-19 12:00:00] INFO WEBrick::HTTPServer#start: pid=12345 port=9292
110+
```
111+
112+
Then:
113+
114+
```bash
115+
$ curl http://localhost:9292
116+
Hello, World!
117+
```
118+
119+
The lambda is a Rack application. It takes `env`, returns `[status, headers, body]`. The spec is satisfied.
120+
121+
## The config.ru Format
122+
123+
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:
124+
125+
```ruby
126+
# config.ru
127+
128+
require_relative 'app'
129+
130+
use MyMiddleware # add middleware to the stack
131+
use AnotherMiddleware, option: 'value'
132+
133+
run MyApplication.new # the innermost app
134+
```
135+
136+
- `use` adds a middleware layer
137+
- `run` sets the inner application
138+
- `map` mounts apps at different paths (more on this later)
139+
140+
You can run any `config.ru` with:
141+
142+
```bash
143+
$ rackup # uses config.ru in current directory
144+
$ rackup myapp.ru # uses a specific file
145+
```
146+
147+
`rackup` figures out the best available server and starts it.
148+
149+
## Reading the Environment
150+
151+
Here's a Rack app that echoes back what it received:
152+
153+
```ruby
154+
# echo.ru
155+
require 'json'
156+
157+
app = lambda do |env|
158+
# Collect interesting parts of the env
159+
info = {
160+
method: env['REQUEST_METHOD'],
161+
path: env['PATH_INFO'],
162+
query_string: env['QUERY_STRING'],
163+
headers: env.select { |k, _| k.start_with?('HTTP_') },
164+
}
165+
166+
# Read the body if there is one
167+
body = env['rack.input'].read
168+
info[:body] = body unless body.empty?
169+
170+
response_body = JSON.pretty_generate(info)
171+
172+
[
173+
200,
174+
{
175+
'Content-Type' => 'application/json',
176+
'Content-Length' => response_body.bytesize.to_s,
177+
},
178+
[response_body]
179+
]
180+
end
181+
182+
run app
183+
```
184+
185+
```bash
186+
$ rackup echo.ru &
187+
$ curl -X POST http://localhost:9292/test?foo=bar \
188+
-H 'Content-Type: application/json' \
189+
-d '{"hello": "world"}'
190+
```
191+
192+
```json
193+
{
194+
"method": "POST",
195+
"path": "/test",
196+
"query_string": "foo=bar",
197+
"headers": {
198+
"HTTP_HOST": "localhost:9292",
199+
"HTTP_USER_AGENT": "curl/7.88.1",
200+
"HTTP_ACCEPT": "*/*",
201+
"HTTP_CONTENT_TYPE": "application/json",
202+
"HTTP_CONTENT_LENGTH": "18"
203+
},
204+
"body": "{\"hello\": \"world\"}"
205+
}
206+
```
207+
208+
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`.
209+
210+
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.
211+
212+
## Validation: Does Your App Comply?
213+
214+
`Rack::Lint` is a middleware that validates Rack compliance. Wrap your app with it during development:
215+
216+
```ruby
217+
# config.ru (development)
218+
require 'rack'
219+
220+
app = lambda do |env|
221+
[200, {'Content-Type' => 'text/plain'}, ['Hello']]
222+
end
223+
224+
# Lint will raise on any spec violation
225+
use Rack::Lint if ENV['RACK_ENV'] == 'development'
226+
run app
227+
```
228+
229+
`Rack::Lint` will raise an exception if:
230+
- Your app doesn't return a three-element array
231+
- The status isn't an integer
232+
- Headers aren't a hash of strings
233+
- The body doesn't respond to `each`
234+
- The body elements aren't strings
235+
- The env is missing required keys
236+
237+
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.
238+
239+
## The Spec Is Deliberately Minimal
240+
241+
The Rack spec doesn't say anything about:
242+
- How to parse query strings
243+
- How to parse cookies
244+
- How to handle sessions
245+
- How to do routing
246+
- How to render templates
247+
- How to parse JSON or form bodies
248+
249+
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.
250+
251+
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.
252+
253+
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`.
254+
255+
## The Napkin Version
256+
257+
If you had to write the Rack spec on a napkin, it would say:
258+
259+
```
260+
call(env) -> [status, headers, body]
261+
262+
env: Hash of CGI-style variables + rack.* keys
263+
status: Integer HTTP status code
264+
headers: Hash of {String => String}
265+
body: Responds to each, yields strings
266+
```
267+
268+
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.
269+
270+
Next: let's use it.

0 commit comments

Comments
 (0)