RSSAmplifier

DevelClan · Apr 30, 2025

Rack for Ruby: The Complete Beginner’s Guide

0
Sign in to vote or save

David Morales · DevelClan

David Morales David Morales

/

Futuristic metal shelf with glowing red rubies connected to industrial machines with cables in a dark laboratory
Table of Contents

Rack acts as a middle layer between a Ruby web server and a Ruby application. In fact, Rack is the foundation for every major Ruby web framework, such as Rails or Sinatra, which makes it a critical part of the Ruby ecosystem.

Let’s explore how Rack lets you spin up a local server in just a few lines and understand how HTTP requests are handled.

What is Rack?

Rack is a minimal interface for exposing a Ruby application to the Internet. It defines how this application should communicate with a Ruby web server (such as Puma or Unicorn).

The key features of Rack include:

  • Simple interface: A Ruby object must implement a single call method that receives a hash named env and returns an array. The env hash includes all the request data, and the array must contain three elements: an HTTP status code, headers, and the response body.

  • Portability: As long as the application follows the interface, it can run on any Rack-compliant web server without any changes.

  • Ecosystem: There is a wide variety of Rack-compatible gems — for sessions, compression, logging, authentication, and more — that can be easily plugged into the same interface.

“Hello World” with Rack

Let’s create a basic example to understand how Rack works. We will use Rack::Builder’s DSL, often referred to as the rackup pattern.

  1. Create a directory, for example: rack-demo:

    mkdir rack-demo

    cd rack-demo

  2. Generate a Gemfile and add the necessary dependencies:

    bundle init

    bundle add rack rackup puma

  3. Create the Rack configuration file config.ru with the following content:

    class HelloWorld

    def call(env)

    [200, { "content-type" => "text/plain" }, ["Hello world!"]]

    end

    end

    run HelloWorld.new

    First, define the class that represents the application. Then use Rack’s run method, passing the instance as an argument.

  4. Start the web server:

    bundle exec rackup

    You should see output like:

    Puma starting in single mode...

    * Puma version: 6.6.0 ("Return to Forever")

    * Ruby version: ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin24]

    * Min threads: 0

    * Max threads: 5

    * Environment: development

    * PID: 80042

    * Listening on http://127.0.0.1:9292

    * Listening on http://[::1]:9292

    Use Ctrl-C to stop

    Rack is using Puma on port 9292.

    When you run rackup, it automatically loads the config.ru file and requires rack, which is why the run method from Rack::Builder is available.

  5. Make a request to the server using curl or a browser:

    curl localhost:9292

    You will see the message rendered. No frameworks, no magic involved.

Anatomy of the call Method

Let’s look at the structure of the call method in more detail:

def call(env)

status = 200

headers = { "content-type" => "text/plain" }

body = ["Hello world!"]

[status, headers, body]

end

  • env: A hash that contains the request details (such as REQUEST_METHOD, PATH_INFO, QUERY_STRING, etc).
  • status: HTTP status code (200, 404, 500, etc).
  • headers: A hash of key-value pairs. content-type is required if the response includes a body. In this case, it is plain text.
  • body: Any object that implements each and returns strings. An array (which includes Enumerable) is a common choice.

Simulating Streaming

As mentioned earlier, the response body must be an object that includes Enumerable. If the body is HTML content and you send chunks with delays, the browser will render them as they arrive, creating a streaming effect.

Try the following:

class Application

def call(env)

status = 200

headers = { "content-type" => "text/html" }

body = Enumerator.new do |output|

output << "<h1>Some title</h1>\n"

sleep 1

output << "<p>First paragraph.</p>\n"

sleep 1

output << "<p>Second paragraph.</p>"

end

[status, headers, body]

end

end

run Application.new

You will see each section appear gradually.

Using a Lambda

Since call is the only required method, you can use lambdas or procs as Rack applications. Here is a simplified example:

application = -> (env) do

status = 200

headers = { "content-type" => "text/plain" }

body = ["Hello world!"]

[status, headers, body]

end

run application

Implementing a Simple Router

The env hash exposes both the HTTP method and the path.

Key What it contains
REQUEST_METHOD HTTP verb (GET, POST, PUT…)
PATH_INFO Part of the URL after the domain

This allows us to build a simple router:

class Application

def call(env)

method = env['REQUEST_METHOD']

path = env['PATH_INFO']

return method_not_allowed(method) unless method == 'GET'

case path

when '/'

ok("<h1>Main page</h1>")

when '/about'

ok("<h1>About</h1>")

else

not_found

end

end

private

def ok(body)

[200, {"content-type"=>"text/html"}, [body]]

end

def not_found

[404, {"content-type"=>"text/plain"}, ["The page does not exist"]]

end

def method_not_allowed(method)

[405, {"content-type"=>"text/plain"}, ["Unsupported method: #{method}"]]

end

end

run Application.new

Try the following URLs in your browser:

  • http://localhost:9292/
  • http://localhost:9292/about
  • http://localhost:9292/foo (should return a 404)

You can also make a POST request using curl:

curl -i -X POST http://localhost:9292

This will return:

HTTP/1.1 405 Method Not Allowed

content-type: text/plain

Content-Length: 24

Unsupported method: POST

Imagine rendering an ERB template for each route (as shown in this article about Embedding Ruby in HTML). You would be getting very close to how a microframework like Sinatra works.

Exercise

Create a Rack app that:

  1. Responds with a JSON object like {"time":"HH:MM:SS"} when you send a GET request to the /time path.
  2. Returns a 404 error for any other path.
Hint

You will need to set the content type to application/json and require the json library.

Solution

require "json"

class Application

def call(env)

if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == "/time"

body = { time: Time.now.strftime("%H:%M:%S") }.to_json

[200, {"content-type" => "application/json"}, [body]]

else

[404, {"content-type"=>"text/plain"}, ["Not Found"]]

end

end

end

run Application.new

Middleware: Rack’s Superpower

A middleware is a small Rack object that sits between the web server and your application. It receives the request, can modify it, and then optionally adjust the response as well.

Rack allows you to chain multiple middleware components together. Each one processes the request and passes it along until the final response is returned.

Here are some popular Rack middleware gems:

Gem Purpose
rack-static Serve static files
rack-deflater Gzip compression
rack-session Session management via cookies
rack-cache HTTP caching
rack-cors CORS support

To illustrate how middleware works, I will show you examples using a few of them.

Example with rack-static

The rack-static gem lets you serve static files.

  • Create an images folder in the same directory as your config.ru.

  • Place an image inside, e.g., sample.jpg.

  • Update your config.ru file:

    require 'rack/static'

    use Rack::Static, urls: ["/images"]

    application = -> (env) do

    [200, { 'content-type' => 'text/plain' }, ["Welcome to my static site!"]]

    end

    run application

  • Now try:

    • http://localhost:9292/ – You should see “Welcome to my static site!”

    • http://localhost:9292/images/sample.jpg – Your image should appear

    • http://localhost:9292/images/foo.jpg – Should show a 404: “File not found: /images/foo.jpg”

Example with rack-deflater

This gem compresses the response body using gzip.

  • Update your config.ru:

    require 'rack/deflater'

    use Rack::Deflater

    application = -> (env) do

    text = "Hello from Rack. " * 50

    [200, { 'content-type' => 'text/plain' }, [text]]

    end

    run application

  • Make a request using curl:

    curl -i http://localhost:9292

  • This shows the regular (uncompressed) response:

    HTTP/1.1 200 OK

    content-type: text/plain

    vary: Accept-Encoding

    Content-Length: 850

    Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack. Hello from Rack.

    Notice that the response body length is 850 characters. Rack::Deflater does not always compress the response—compression only occurs when it is explicitly requested.

  • Now request with compression enabled:

    curl -i -H "Accept-Encoding: gzip" http://localhost:9292 --output -

  • You will see an output similar to this:

    HTTP/1.1 200 OK

    content-type: text/plain

    vary: Accept-Encoding

    content-encoding: gzip

    Transfer-Encoding: chunked

    h���H+�JL�S� _�

    Here, the Content-Length header is missing because Rack::Deflater works in streaming mode. This means it reads the response body, compresses it in chunks, and sends each part as it is generated. Therefore, since the final size is not known in advance, that header is removed and replaced with Transfer-Encoding: chunked, which indicates that the response will be delivered in segments.

    The body is now in binary format, which is why strange characters appear. That is why we had to add --output - to force the output to the terminal. However, compare it to the previous response—the size has been significantly reduced!

Example with rack-session

This gem enables session storage in cookies. Here is how you can track user visits.

  • Update your config.ru:

    require 'rack/session'

    use Rack::Session::Cookie,

    key: "my.session",

    secret: ENV.fetch("SESSION_SECRET") { "1234567890"*8 },

    expire_after: 86_400 # 24h

    class Application

    def call(env)

    req = Rack::Request.new(env)

    # Ignore favicon.ico requests

    return [204, {}, []] if req.path == "/favicon.ico"

    session = env["rack.session"]

    session[:visits] = (session[:visits] || 0) + 1

    status = 200

    headers = { "content-type" => "text/html" }

    body = ["Visits: #{session[:visits]}"]

    [status, headers, body]

    end

    end

    run Application.new

Restart Rack and visit the page in your browser. You will see that the total number of visits is 1. If you reload the page, it will increase to 2, and so on. Open the Developer Tools, and you will find the cookie under Storage.

Creating Your Own Middleware

After reviewing examples of existing middleware, let’s now see how to create one from scratch.

Unlike a regular Rack app, a middleware must be defined as a class because it receives the next app in its constructor:

class MiddleTimer

def initialize(app) = @app = app

def call(env)

start = Time.now

status, headers, body = @app.call(env)

duration = ((Time.now - start) * 1000).round

duration_message = "\n[#{env['PATH_INFO']}] took #{duration}ms"

[status, headers, body << duration_message]

end

end

use MiddleTimer

run -> (env) do

sleep(rand(0.1..0.5)) # Simulate some processing time

[200, { 'content-type' => 'text/plain' }, ["A random sleep was performed."]]

end

  • In this example, the middleware is first defined using the MiddleTimer class:
  • It defines the constructor, which stores the app that invoked it.
    • It captures the current timestamp.
    • It calls the call method of the app that invoked it. If multiple middleware components are chained, this call invokes the next one. In our case, it is the app itself—the lambda defined at the end of the file. Then, it stores the response by destructuring the returned array into separate variables. This allows the middleware to modify the response.
  • It calculates the duration by subtracting the initial timestamp from the current one and rounding the result to remove decimals.
    • It prints the current path and the time taken to the console.
  • It returns the standard Rack array, but with the calculated duration appended to the body.
  • Next, the middleware is initialized using use and the class name.
  • Finally, a Rack application is created using a lambda that simulates a random delay. In this case, the lambda is passed directly to the run call.

Open the browser and you will see the text along with the time it took to process the request.

Chaining Middleware

Here is how you can chain two middleware components: one that adds a prefix to the response body, and one that adds a suffix.

class PrefixingMiddleware

def initialize(app)

@app = app

end

def call(env)

status, headers, body = @app.call(env)

body = ["<p>This is a prefix</p>"] + body

[status, headers, body]

end

end

class PostfixingMiddleware

def initialize(app)

@app = app

end

def call(env)

status, headers, body = @app.call(env)

body += ["<p>This is a postfix</p>"]

[status, headers, body]

end

end

use PrefixingMiddleware

use PostfixingMiddleware

run -> (env) {

[200, {"content-type" => "text/html"}, ["<p>This is a Rack app</p>"]]

}

When the file is executed, Rack builds the middleware chain from bottom to top:

  1. It starts with the run statement and executes the lambda.
  2. Then, the code moves upward, instantiating PostfixingMiddleware and passing the Rack application (the lambda we defined) as an argument.
  3. It continues to the next middleware, instantiating PrefixingMiddleware and passing it an instance of PostfixingMiddleware.

At this point, the chain is fully constructed and ready for execution. The request will begin at the first element in the chain (PrefixingMiddleware), which calls its call method with the web request (captured in the env variable).

The first thing the call method does is call the call method of the next component, which is PostfixingMiddleware. That in turn calls the application (the lambda), which simply returns the array.

Execution then moves back up the chain: the array is returned from the lambda to PostfixingMiddleware, which modifies the body and returns the array. This is then received by PrefixingMiddleware, which also modifies the response and returns the final array.

This completes the chain.

The result is a response that includes the prefix, main content, and suffix in that order.

Conclusion

Rack is a minimal interface that sits between a Ruby application (which could be a framework like Ruby on Rails) and a Ruby web server. This setup allows any Rack-compliant application to work with different web servers without changing its code.

Rack defines:

  1. A structure for the request (a hash with many environment keys and values).
  2. A structure for the response (an array with status, headers, and body).
  3. A common interface between applications and web servers.

Rack also supports modular components called middleware, which can be chained together to build up functionality in a clean and reusable way.

Test your knowledge

  1. Which single method must any object implement to be a Rack application?

  1. What three elements must the call method return?

  1. Which of the following objects meets Rack’s requirements for the body part of the response?

  1. Because Rack only requires a call method, which of the following can itself be the entire Rack application?

  1. In what order does Rack build the middleware chain declared in config.ru?

Read the original on develclan.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.