As Ruby developers, we are familiar with popular web frameworks such as Rails, Sinatra and Hanami. These frameworks make our lives much easier due to the built-in features, conventions that allow others to easily understand our code, and the use of the vast Ruby ecosystem.
Knowledge of these frameworks enable developers to create feature-rich web applications in a short amount of time. But do you know the layer beneath that framework? Are you aware that these frameworks use a mini-framework within? As part of the series on Web Application Basics, we will introduce Rack and why it is important for developers to know how it works.
What is Rack?
Rack is a Ruby library that serves as a Web Server interface. It provides a standard way for a Ruby application to communicate with a Web/Application Server. In the previous article we introduced some Application Servers for Ruby such as Webrick, Unicorn, and Puma.
A standard interface is essential as this makes it easy for applications to use different Application Servers. For example, in a Rails application we can use Webrick for development but use Puma in production. Rack makes sure that the application still works on these different server configurations.
Ruby applications that follow the Rack interface are called Rack applications or Rack apps. Frameworks such as Rails, Sinatra, Hanami, and Roda are all examples of Rack apps. This means that they can be seamlessly plugged in to different Application Servers, and as we can see later, Rack apps can also be used together like building blocks to enhance web applications.
Rack Apps
To conform to the Rack interface or API, the code must have a predefined input and output:
- It implements a call method
- The input parameter must be a hash, which is the request data
- It returns an output as an array
- The output array must contain the status code, headers, and the body
- The body must respond to each, e.g. Enumerable
To illustrate this, the code below shows a simple Ruby class that conforms to the above, and is therefore a valid Rack application.
class Application
def call(env)
status = 200
headers = { "Content-Type" => "text/html" }
body = ["Hello world!"]
[status, headers, body]
end
end
run Application.new
The code may not look like much, but this is already a web application that can be loaded into a browser. How can we do that?
Rackup
Rack comes with its built-in executable called rackup. This is a program that looks for the default Rack configuration file (config.ru) and then starts an Application Server called Webrick. It uses port 9292 by default, which can be accessed using a browser.
Webrick is a small and simple Ruby Application Server that is a part of the Ruby standard library. While it is not built for production systems, it is more than enough for development or for small, internal web applications.
To illustrate, create a file called config.ru in a directory with the following contents (same as the example above):
class Application
def call(env)
status = 200
headers = { "Content-Type" => "text/html" }
body = ["Hello world!"]
[status, headers, body]
end
end
run Application.new
Then run this with the rackup command:
> rackup
This command will look for the config.ru file we just created, then runs Webrick on port 9292. We can now view the web application in the browser!
However, this does not look like a web application at all. Instead of just displaying text in the browser, the body component of the response can also be an HTML string, or even an HTML file:
body = [File.read('index.html')]
So the Rack app now looks like this:
class Application
def call(env)
status = 200
headers = { "Content-Type" => "text/html" }
body = [File.read('index.html')]
[status, headers, body]
end
end
run Application.new
Simple Routing
While we are now able to display an HTML page, most of the time this is not enough. We need a way to control which page to show depending on the path or the HTTP method used. This is called routing.
Since the input of the Rack app is the request object, we can get the path or the HTTP method from the request headers. Instead of doing this manually, Rack provides a class called Rack::Request to make it easier to extract header data.
Here is an example on how to use this to get the request path and the HTTP method from the request:
request = Rack::Request.new(env)
path = request.path_info
method = request.request_method
From here, we can implement a basic routing mechanism to display different HTML pages depending on the URL:
class Application
def call(env)
request = Rack::Request.new(env)
status = 200
headers = { "Content-Type" => "text/html" }
path = request.path_info
page = if path == '/store'
'store.html'
else
'index.html'
end
body = [File.read(page)]
[status, headers, body]
end
end
run Application.new
This is a contrived example but it illustrates how we can extend the functionality of our simple Rack application to handle different scenarios based on the request.
Middleware
Another useful feature of Rack applications is extensibility. Since they are all conforming to a fixed API, they can be connected, or stacked, on top of one another. This is a powerful concept as we can easily extend the functionality of an application by stacking other Rack apps.
These extensions are called Rack middleware, and are invoked by the use syntax, as shown below:
use SecondMiddleware
use FirstMiddleware
run Application
As a middleware has access to the request data and can update the HTTP status and response body, certain features common in a web application are best handled using a middleware, such as:
- Logging
- Cookie/Session manipulation
- Exception handling
- Parameter parsing
- Header manipulation
Ruby on Rails
The Ruby on Rails framework, a Rack application itself, uses several middleware to extend its functionality. To show the middleware that a Rails application uses, run this command:
> rails middleware
For a default Rails 6 application, it will return these values:
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run MyApp::Application.routes
As we can see, Rails uses several Rack middleware to handle certain features, such as:
- Caching
- Logging
- Exception handling
- Parsing the request metadata
- Setting the response metadata
Beyond Rack
Rack is an important foundation for web application frameworks in Ruby. As developers, it is important for us to understand how it works so we can use it as an additional tool in our arsenal.
If you are building a simple web application, you may find that using a full-fledged framework like Rails is overkill. In these cases, you can just create your very own Rack application and serve it in the Internet through Unicorn or Puma. As these application servers work for any Rack app, your small app will work as seamlessly as when using Rails.