The Basics of Rack for Ruby

ayushn21

Ayush Newatia

Posted on November 13, 2024

The Basics of Rack for Ruby

Rack is the foundation for every popular Ruby web framework in existence. It standardizes an interface between a Ruby application and a web server. This mechanism allows us to pair any Rack-compliant web server (such as Puma, Unicorn, or Falcon) with any Rack-compliant web framework (like Rails, Sinatra, Roda, or Hanami).

Separating the concerns like this is immensely powerful and provides a lot of flexibility. It does, however, also come with limitations.

Rack 2 operated on the assumption that every request must provide a response and close the connection. It made no facility for persistent connections to enable pathways like WebSockets.

Developers had to make use of a hacky escape hatch to take over connections from Rack to implement WebSockets or similar persistent connections.

This all changed with Rack 3. But first, let's backtrack and take a closer look at Rack itself.

A Barebones Rack App

A basic Rack app looks like this:

class App
  def call(env)
    [200, { "Content-Type" => "text/plain" }, ["Hello World"]]
  end
end

run App.new
Enter fullscreen mode Exit fullscreen mode

env is a hash containing request-specific information such as HTTP headers. When a request is made, the call method is called, and we return an array representing the response.

The first element is the HTTP response code, in this case 200. The second element is a hash containing any Rack and HTTP response headers we wish to send. Finally, the last element is an array of strings representing the response body.

Let's organize this app into a folder and run it.

$ mkdir rack-demo
$ cd rack-demo
$ bundle init
$ bundle add rack rackup
$ touch app.rb
$ touch config.ru
Enter fullscreen mode Exit fullscreen mode

Fill in app.rb with the following:

class App
  def call(env)
    [200, { "content-type" => "text/plain" }, ["Hello World"]]
  end
end
Enter fullscreen mode Exit fullscreen mode

And config.ru with:

require_relative "app"

run App.new
Enter fullscreen mode Exit fullscreen mode

We can run this app using the default WEBrick server by running:

$ bundle exec rackup
Enter fullscreen mode Exit fullscreen mode

The server will run on port 9292. We can verify this with a curl command.

$ curl localhost:9292
Hello World
Enter fullscreen mode Exit fullscreen mode

That's got the basic app running!

Changing Web Servers

WEBrick is a development-only server, so let's swap it out for Puma:

$ bundle add puma
Enter fullscreen mode Exit fullscreen mode

Now try running rackup again. You'll see it has automatically detected Puma in the bundle and started that instead of WEBrick!

$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango")
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 45877
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stop
Enter fullscreen mode Exit fullscreen mode

I recommend starting Puma directly instead of using rackup, as that allows us to pass configuration arguments should we want to. The -w 4 below starts Puma using 4 workers, meaning 4 instances of Puma are started up simultaneously.

$ bundle exec puma -w 4
[45968] Puma starting in cluster mode...
[45968] * Puma version: 6.4.2 (ruby 3.2.2-p53) ("The Eagle of Durango")
[45968] *  Min threads: 0
[45968] *  Max threads: 5
[45968] *  Environment: development
[45968] *   Master PID: 45968
[45968] *      Workers: 4
[45968] *     Restarts: () hot () phased
[45968] * Listening on http://0.0.0.0:9292
[45968] Use Ctrl-C to stop
[45968] - Worker 0 (PID: 45981) booted in 0.0s, phase: 0
[45968] - Worker 1 (PID: 45982) booted in 0.0s, phase: 0
[45968] - Worker 2 (PID: 45983) booted in 0.0s, phase: 0
[45968] - Worker 3 (PID: 45984) booted in 0.0s, phase: 0
Enter fullscreen mode Exit fullscreen mode

This basic app demonstrates the Rack interface. An incoming HTTP request is parsed into the env hash and provided to the application. The application processes the request and supplies an array as the response that the server formats and sends to the client.

Rack Compliance In Frameworks

Every compliant web framework follows the Rack spec under the hood and provides an access point to go down to this level.

In Rails, we can send a Rack response in a controller:

class HomeController
  def index
    self.response = [200, {}, ["I'm Home!"]]
  end
end
Enter fullscreen mode Exit fullscreen mode

Similarly, in Roda:

route do |r|
  r.on "home" do
    r.halt [200, {}, ["I'm Home!"]]
  end
end
Enter fullscreen mode Exit fullscreen mode

Every Rack-compliant framework will have a slightly different syntax for accomplishing this, but since they're all sending Rack responses under the hood, they will have an API for you to access that response.

You can find the full, relatively accessible Rack specification on GitHub.

Wrapping Up

As this demo shows, Rack operates under the assumption that a request comes in, is processed by a web application, and a response is sent back. Throwing persistent connections into the mix totally breaks this model, yet Rack-compliant frameworks like Rails implement WebSockets.

In the next post, part two of a three-part series, we'll cover how to take over connections from Rack so we can hold persistent connections to enable pathways such as WebSockets.

Until then, happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

💖 💪 🙅 🚩
ayushn21
Ayush Newatia

Posted on November 13, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related