Rack is a minimal, modular, and adaptable interface for developing web applications in Ruby. It provides a standard interface between web servers and Ruby web frameworks like Rails, Sinatra, and many others.
# Rack is the foundation that powers Ruby web applications
# It defines a simple interface: an app that responds to #call
# and returns an array with [status, headers, body]
# Basic Rack app structure:
app = lambda do |env|
[200, {'Content-Type' => 'text/plain'}, ['Hello World']]
end
A Rack application is any Ruby object that responds to the `call` method, takes one argument (the environment hash), and returns an array with three elements: status, headers, and body.
# app.rb
class HelloWorld
def call(env)
status = 200
headers = {'Content-Type' => 'text/html'}
body = ['<h1>Hello from Rack!</h1>']
[status, headers, body]
end
end
# Alternative: Using a proc
HelloWorldProc = proc do |env|
[200, {'Content-Type' => 'text/plain'}, ['Hello from Proc!']]
end
# config.ru
require_relative 'app'
# Run the Rack application
run HelloWorld.new
# Or run the proc version
# run HelloWorldProc
# Install rack gem
gem install rack
# Run the application
rackup config.ru
# Or specify port
rackup -p 3000 config.ru
# Visit http://localhost:9292 (default port)
The environment hash contains all the information about the HTTP request, including headers, request method, path, and more.
# env_inspector.rb
class EnvironmentInspector
def call(env)
body = ["<h1>Rack Environment</h1>"]
body << "<table border='1'>"
body << "<tr><th>Key</th><th>Value</th></tr>"
env.sort.each do |key, value|
body << "<tr>"
body << "<td>#{key}</td>"
body << "<td>#{value.inspect}</td>"
body << "</tr>"
end
body << "</table>"
[200, {'Content-Type' => 'text/html'}, body]
end
end
class RequestInfo
def call(env)
request_method = env['REQUEST_METHOD'] # GET, POST, etc.
path_info = env['PATH_INFO'] # /users/123
query_string = env['QUERY_STRING'] # name=john&age=30
content_type = env['CONTENT_TYPE'] # application/json
content_length = env['CONTENT_LENGTH'] # 1024
http_host = env['HTTP_HOST'] # localhost:3000
user_agent = env['HTTP_USER_AGENT'] # Browser info
body = [<<~HTML]
<h1>Request Information</h1>
<p><strong>Method:</strong> #{request_method}</p>
<p><strong>Path:</strong> #{path_info}</p>
<p><strong>Query:</strong> #{query_string}</p>
<p><strong>Host:</strong> #{http_host}</p>
<p><strong>User Agent:</strong> #{user_agent}</p>
HTML
[200, {'Content-Type' => 'text/html'}, body]
end
end
Middleware is a powerful Rack concept that allows you to wrap applications with additional functionality like logging, authentication, caching, and more.
# logger_middleware.rb
class LoggerMiddleware
def initialize(app)
@app = app
end
def call(env)
start_time = Time.now
# Call the next app in the stack
status, headers, body = @app.call(env)
end_time = Time.now
duration = ((end_time - start_time) * 1000).round(2)
puts "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} - #{status} (#{duration}ms)"
[status, headers, body]
end
end
# authentication_middleware.rb
class AuthenticationMiddleware
def initialize(app, username, password)
@app = app
@username = username
@password = password
end
def call(env)
auth_header = env['HTTP_AUTHORIZATION']
if auth_header && valid_credentials?(auth_header)
@app.call(env)
else
unauthorized_response
end
end
private
def valid_credentials?(auth_header)
# Basic HTTP authentication
encoded_credentials = auth_header.split(' ').last
credentials = Base64.decode64(encoded_credentials)
username, password = credentials.split(':')
username == @username && password == @password
end
def unauthorized_response
headers = {
'Content-Type' => 'text/plain',
'WWW-Authenticate' => 'Basic realm="Restricted Area"'
}
[401, headers, ['Unauthorized']]
end
end
# config.ru
require_relative 'app'
require_relative 'logger_middleware'
require_relative 'authentication_middleware'
# Add middleware to the stack
use LoggerMiddleware
use AuthenticationMiddleware, 'admin', 'secret'
# Built-in Rack middleware
use Rack::ContentLength # Automatically sets Content-Length header
use Rack::ShowExceptions # Shows detailed error pages in development
# Run the main application
run HelloWorld.new
Rack comes with many useful middleware components that you can use in your applications.
# config.ru with various middleware
use Rack::Deflater # Gzip compression
use Rack::ETag # Automatic ETag generation
use Rack::ConditionalGet # Handles If-None-Match and If-Modified-Since
use Rack::Head # Handles HEAD requests
use Rack::MethodOverride # Allows _method parameter for PUT/DELETE
use Rack::Static, root: 'public', urls: ['/css', '/js', '/images']
# CORS middleware
use Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :put, :delete]
end
end
# Session middleware
use Rack::Session::Cookie, secret: 'your-secret-key-here'
run MyApp.new
# Serving static files with Rack::Static
use Rack::Static,
urls: ['/css', '/js', '/images', '/favicon.ico'],
root: 'public',
index: 'index.html'
# Custom static file middleware
class StaticFileServer
def initialize(app, options = {})
@app = app
@root = options[:root] || 'public'
@urls = options[:urls] || []
end
def call(env)
path = env['PATH_INFO']
if static_file?(path)
serve_static_file(path)
else
@app.call(env)
end
end
private
def static_file?(path)
@urls.any? { |url| path.start_with?(url) }
end
def serve_static_file(path)
full_path = File.join(@root, path)
if File.exist?(full_path) && File.file?(full_path)
content = File.read(full_path)
content_type = determine_content_type(path)
[200, {'Content-Type' => content_type}, [content]]
else
[404, {'Content-Type' => 'text/plain'}, ['File not found']]
end
end
def determine_content_type(path)
case File.extname(path)
when '.html' then 'text/html'
when '.css' then 'text/css'
when '.js' then 'application/javascript'
when '.png' then 'image/png'
when '.jpg', '.jpeg' then 'image/jpeg'
else 'application/octet-stream'
end
end
end
While Rack doesn't include routing by default, you can easily implement basic routing functionality.
# router.rb
class Router
def initialize
@routes = {}
end
def add_route(method, path, app)
@routes[[method, path]] = app
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
app = @routes[[method, path]]
if app
app.call(env)
else
not_found_response
end
end
private
def not_found_response
[404, {'Content-Type' => 'text/html'}, ['<h1>404 Not Found</h1>']]
end
end
# Usage in config.ru
router = Router.new
# Define routes
router.add_route('GET', '/', lambda { |env|
[200, {'Content-Type' => 'text/html'}, ['<h1>Home Page</h1>']]
})
router.add_route('GET', '/about', lambda { |env|
[200, {'Content-Type' => 'text/html'}, ['<h1>About Us</h1>']]
})
router.add_route('POST', '/users', lambda { |env|
[201, {'Content-Type' => 'application/json'}, ['{"message": "User created"}']]
})
run router
class AdvancedRouter
def initialize
@routes = []
end
def add_route(method, pattern, &block)
@routes << {
method: method,
pattern: compile_pattern(pattern),
handler: block
}
end
def call(env)
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
route = find_route(method, path)
if route
# Extract parameters from the path
params = extract_params(route[:pattern], path)
env['rack.route_params'] = params
route[:handler].call(env)
else
not_found_response
end
end
private
def compile_pattern(pattern)
# Convert /users/:id to regex
regex_pattern = pattern.gsub(/:(\w+)/, '(?<\\1>[^/]+)')
/\A#{regex_pattern}\z/
end
def find_route(method, path)
@routes.find do |route|
route[:method] == method && route[:pattern].match(path)
end
end
def extract_params(pattern, path)
match = pattern.match(path)
match ? match.named_captures : {}
end
def not_found_response
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
end
# Usage
router = AdvancedRouter.new
router.add_route('GET', '/users/:id') do |env|
user_id = env['rack.route_params']['id']
[200, {'Content-Type' => 'application/json'}, ["{\"id\": \"#{user_id}\"}"]]
end
router.add_route('GET', '/posts/:post_id/comments/:id') do |env|
params = env['rack.route_params']
post_id = params['post_id']
comment_id = params['id']
body = "{\"post_id\": \"#{post_id}\", \"comment_id\": \"#{comment_id}\"}"
[200, {'Content-Type' => 'application/json'}, [body]]
end
run router
Let's build a more comprehensive Rack application that demonstrates multiple concepts working together.
# blog_app.rb
require 'json'
require 'erb'
class BlogApp
def initialize
@posts = [
{ id: 1, title: 'First Post', content: 'This is my first blog post!', created_at: Time.now - 86400 },
{ id: 2, title: 'Learning Rack', content: 'Rack is awesome for web development.', created_at: Time.now }
]
end
def call(env)
request = Rack::Request.new(env)
case [request.request_method, request.path_info]
when ['GET', '/']
home_page(request)
when ['GET', '/posts']
list_posts(request)
when ['GET', %r{^/posts/(\d+)$}]
show_post(request, $1.to_i)
when ['POST', '/posts']
create_post(request)
when ['GET', '/api/posts']
api_list_posts(request)
else
not_found
end
end
private
def home_page(request)
html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<title>My Rack Blog</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.post { border: 1px solid #ccc; padding: 20px; margin: 20px 0; }
.nav { margin-bottom: 30px; }
.nav a { margin-right: 20px; text-decoration: none; }
</style>
</head>
<body>
<div class="nav">
<a href="/">Home</a>
<a href="/posts">All Posts</a>
<a href="/api/posts">API</a>
</div>
<h1>Welcome to My Rack Blog</h1>
<p>This is a simple blog built with Rack!</p>
<h2>Recent Posts</h2>
#{@posts.last(3).map { |post| render_post_summary(post) }.join}
<h2>Create New Post</h2>
<form method="POST" action="/posts">
<p>
<label>Title:</label><br>
<input type="text" name="title" required style="width: 300px;">
</p>
<p>
<label>Content:</label><br>
<textarea name="content" required style="width: 300px; height: 100px;"></textarea>
</p>
<p>
<input type="submit" value="Create Post">
</p>
</form>
</body>
</html>
HTML
[200, {'Content-Type' => 'text/html'}, [html]]
end
def list_posts(request)
html = <<~HTML
<!DOCTYPE html>
<html>
<head><title>All Posts</title></head>
<body>
<h1>All Blog Posts</h1>
#{@posts.map { |post| render_post_summary(post) }.join}
<p><a href="/">← Back to Home</a></p>
</body>
</html>
HTML
[200, {'Content-Type' => 'text/html'}, [html]]
end
def show_post(request, post_id)
post = @posts.find { |p| p[:id] == post_id }
if post
html = <<~HTML
<!DOCTYPE html>
<html>
<head><title>#{post[:title]}</title></head>
<body>
<h1>#{post[:title]}</h1>
<p><small>Posted on #{post[:created_at].strftime('%Y-%m-%d %H:%M')}</small></p>
<div>#{post[:content]}</div>
<p><a href="/posts">← Back to All Posts</a></p>
</body>
</html>
HTML
[200, {'Content-Type' => 'text/html'}, [html]]
else
not_found
end
end
def create_post(request)
title = request.params['title']
content = request.params['content']
if title && content && !title.empty? && !content.empty?
new_id = (@posts.map { |p| p[:id] }.max || 0) + 1
new_post = {
id: new_id,
title: title,
content: content,
created_at: Time.now
}
@posts << new_post
# Redirect to the new post
[302, {'Location' => "/posts/#{new_id}"}, []]
else
[400, {'Content-Type' => 'text/html'}, ['<h1>400 Bad Request</h1><p>Title and content are required.</p>']]
end
end
def api_list_posts(request)
posts_json = @posts.map do |post|
{
id: post[:id],
title: post[:title],
content: post[:content],
created_at: post[:created_at].iso8601,
url: "/posts/#{post[:id]}"
}
end
[200, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(posts_json)]]
end
def render_post_summary(post)
<<~HTML
<div class="post">
<h3><a href="/posts/#{post[:id]}">#{post[:title]}</a></h3>
<p>#{post[:content][0..100]}#{post[:content].length > 100 ? '...' : ''}</p>
<p><small>Posted on #{post[:created_at].strftime('%Y-%m-%d %H:%M')}</small></p>
</div>
HTML
end
def not_found
[404, {'Content-Type' => 'text/html'}, ['<h1>404 Not Found</h1>']]
end
end
# config.ru
require_relative 'blog_app'
# Add middleware
use Rack::ShowExceptions
use Rack::MethodOverride
use Rack::ContentLength
# Handle static files
use Rack::Static,
urls: ['/css', '/js', '/images'],
root: 'public'
# Run the blog application
run BlogApp.new
Testing Rack applications is straightforward using the rack-test gem and standard testing frameworks.
# test_blog_app.rb
require 'minitest/autorun'
require 'rack/test'
require_relative 'blog_app'
class BlogAppTest < Minitest::Test
include Rack::Test::Methods
def app
BlogApp.new
end
def test_home_page
get '/'
assert_equal 200, last_response.status
assert_includes last_response.body, 'Welcome to My Rack Blog'
assert_includes last_response.body, 'form'
end
def test_api_posts
get '/api/posts'
assert_equal 200, last_response.status
assert_equal 'application/json', last_response.content_type
posts = JSON.parse(last_response.body)
assert posts.is_a?(Array)
assert posts.length > 0
end
def test_create_post
post '/posts', { title: 'Test Post', content: 'Test content' }
assert_equal 302, last_response.status
assert_match %r{/posts/\d+}, last_response.location
end
def test_show_post
get '/posts/1'
assert_equal 200, last_response.status
assert_includes last_response.body, 'First Post'
end
def test_not_found
get '/nonexistent'
assert_equal 404, last_response.status
end
end
Watch and learn rack interface