Skip to content
AgileLogics
7 min read

Building Web Apps with Rack

Prerequisites: Working with JSON and YAML, Gems and Bundler

Every Ruby web framework — Rails, Sinatra, Hanami, Roda — sits on Rack, a tiny standard interface between web servers and Ruby applications. Understanding it demystifies how Ruby web apps work.

The Rack contract

A Rack application is any object with a call method that:

  1. takes one argument, env — a hash describing the request, and
  2. returns an array of three things: status, headers, and a body (an object that responds to each, usually an array of strings).

A complete app

bundle init
bundle add rack rackup puma
# config.ru
require "json"

class App
  def call(env)
    request = Rack::Request.new(env)

    case [request.request_method, request.path_info]
    in ["GET", "/"]
      [200, { "content-type" => "text/plain" }, ["Hello from Rack"]]
    in ["GET", "/health"]
      json(200, { status: "ok" })
    in ["POST", "/echo"]
      json(201, { received: JSON.parse(request.body.read) })
    else
      json(404, { error: "not found" })
    end
  rescue JSON::ParserError
    json(400, { error: "invalid JSON" })
  end

  private

  def json(status, data)
    [status, { "content-type" => "application/json" }, [data.to_json]]
  end
end

run App.new
$ bundle exec rackup
Puma starting in single mode...
* Listening on http://127.0.0.1:9292

$ curl localhost:9292/health
{"status":"ok"}

config.ru ("rack up") is the standard entry point, and run tells Rack which app to serve. Rack 3 requires lowercase header names.

Middleware

Middleware wraps an app to add behavior before and after each request — logging, authentication, compression. A middleware is itself a Rack app that holds the next app:

class RequestTimer
  def initialize(app)
    @app = app
  end

  def call(env)
    started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    status, headers, body = @app.call(env)
    ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
    headers["server-timing"] = "app;dur=#{ms}"
    [status, headers, body]
  end
end

use RequestTimer   # in config.ru, before `run`

Rails' request pipeline is a stack of exactly these objects — bin/rails middleware lists them.

Testing Rack apps

The rack-test gem sends fake requests straight to the app, with no server running:

require "rack/test"

class AppTest < Minitest::Test
  include Rack::Test::Methods

  def app = App.new

  def test_health
    get "/health"
    assert_equal 200, last_response.status
    assert_equal({ "status" => "ok" }, JSON.parse(last_response.body))
  end
end

Where to go from here

For a real project, pick a framework on top of Rack: Sinatra or Roda for small services, Rails for full applications with databases, views, jobs, and mailers.