A pragmatic guide to building a Rack application from scratch
You are being redirected to https://thoughtbot.com/blog/ruby-rack-tutorial
Confession Time: Until recently, I didn’t really understand what Rack did. I knew it had something to do with Rails, but I was never in a situation where I needed to use it directly. Not only that, but I realized that I didn’t actually know how to make a server-side web application from scratch because I had become so dependent on Rails.
So, I decided to use my investment time to explore Rack and build something real. What resulted was Resolved. The application itself is simple (it just returns a domain’s name servers), but the knowledge I gained about the HTTP specification and other concepts was invaluable.
Here are some of the topics I was forced to understand because they were no longer abstracted away by Rails (or any other framework). You can get more context by looking at the commit history.
- Rendering dynamic content with
ERB
. - Using HTTP compression and caching to drastically improve application performance.
- Error handling.
- Building a simple logging mechanism.
- HTTP security best practices.
- Configuring a test suite and GitHub Actions from scratch.
Below is what we’ll be building in this tutorial.
Build an initial proof of concept
Before we can begin building something, we’ll need to install the Rack library and Puma.
bundle add rack puma
The Rack library is the mechanism that will handle incoming and outgoing web requests, and Puma is the mechanism that will serve the Rack application. Note that there are other supported web servers.
Surprisingly, we don’t actually need the Rack library to build a Rack application, but we do need it to connect our application to our server as explained in our Upcase lesson on Rack.
Rack is the underlying technology behind nearly all of the web frameworks in the Ruby world.
“Rack” is actually a few different things:
- An architecture - Rack defines a very simple interface, and any code that conforms to this interface can be used in a Rack application. This makes it very easy to build small, focused, and reusable bits of code and then use Rack to compose these bits into a larger application.
- A Ruby gem - Rack is distributed as a Ruby gem that provides the glue code needed to compose our code.
With our initial setup out of the way, we can actually begin to build our application.
Let’s start by rendering something simple to the screen. We’ll create a Rack
compliant object with a call
method that takes an env
argument. The
env argument is a hash representing the current request data.
In order to be Rack compliant, an object needs to adhere to this specific
interface. Namely, it should respond to call
and return an array of three
values:
# app/app.rb
class App
def call(env)
[200, {}, ["Hello World"]]
end
end
This is exactly what our object does. When a request is made to our application,
our server will respond with a 200
status code, no header information, and a
response body of “Hello World”.
Before we can actually view this in a browser, we need to create a config.ru
file which Puma will look for on boot.
# config.ru
require_relative "app/app"
run App.new
If we run bundle exec puma -p 3000
we should be able to navigate to
http://localhost:3000
and see our simple application.
Create a simple render method
Now that we can render something in the browser, let’s introduce a simple render method inspired by Rails’ render method.
First, we’ll need to create a few ERB templates. We’ll start with a basic layout inspired by Rails’ application layout.
<% # app/views/layout.html.erb %>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Find a domain's name servers">
<title>Resolved</title>
</head>
<body>
<%= @content %>
</body>
</html>
We’ll also add another template to act as a partial which will render the content for the homepage.
<% # app/views/home.html.erb %>
<p>Enter a domain name</p>
Now we can update our application by introducing a method to render our ERB
templates.
class App
def call(env)
- [200, {}, ["Hello World"]]
+ render("home")
+ end
+
+ private
+
+ def render(template, status_code: 200)
+ @content = render_template(template)
+ body = render_template("layout")
+ headers = {"Content-Type" => "text/html; charset=utf-8"}
+
+ [status_code, headers, [body]]
+ end
+
+ def render_template(template)
+ template = File.read("./app/views/#{template}.html.erb")
+ erb = ERB.new(template)
+ erb.result(binding)
end
end
This works by first rendering our partial, and then assigning it to @content
,
which will be picked up by the layout. The mechanism responsible for this is
erb.result(binding)
which encapsulates the context of our code to be used
later. This is similar to how instance variables set in a Rails controller are
then made available in a corresponding view. Note that we also set the headers
to "text/html; charset=utf-8"
.
If we restart our server and navigate back to http://localhost:3000
we should
see the new homepage.
Handle invalid routes
You might have noticed that no matter what route we visit, we also see the homepage. This is because we’re not actually handling the incoming requests.
Let’s fix this by defining a root path, and falling back to a 404-page otherwise.
class App
def call(env)
- render("home")
+ req = Rack::Request.new(env)
+ path = req.path_info
+
+ case path
+ when "/"
+ render("home")
+ else
+ handle_missing_path
+ end
end
private
@@ -23,4 +31,11 @@ class App
erb = ERB.new(template)
erb.result(binding)
end
+
+ def handle_missing_path
+ body = File.read("./public/404.html")
+ headers = {"Content-Type" => "text/html; charset=utf-8"}
+
+ [404, headers, [body]]
+ end
end
We introduce Rack::Request to make it easier to interface with the env
that is passed to our application by adding convenience methods for us, such as
path_info.
We’ll also need to create a static HTML file for our application to render. We
could do this server-side with our render
method, but it’s more performant to
render a static file. Rails also renders static files when requests are in the
400 and 500 range.
<!-- public/404.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Find a domain's name servers" />
<title>Resolved</title>
</head>
<body>
<h1>Page not found</h1>
</body>
</html>
Finally, we’ll want to introduce Rack::Static so that direct requests to our
404 file will be processed correctly. In order to use Rack::Static
, we
introduce Rack::Builder to construct our application. If we did not do this,
we would have had to add an additional when
branch to our case
statement to
handle direct requests to the /404.html
path.
require_relative "app/app"
-run App.new
+app = Rack::Builder.new do
+ use Rack::Static,
+ root: "public",
+ urls: ["/404.html"],
+ header_rules: [
+ [%w[html], {"Content-Type" => "text/html; charset=utf-8"}],
+ ]
+ run App.new
+end
+
+run app
You’ll also note that we add a header_rules
option which sets the
Content-Type
for all files ending in .html
. If we restart our application
and visit an invalid route, we’ll see the static 404-page, as well as the
correct response.
If we directly visit our 404-page at http://localhost:3000/404.html
we’ll see
the same layout, but the status will be 200
. This is how thoughtbot’s
404-page works, too.
Render local variables
Now that we have basic routing, let’s begin to build our form. Our initial goal
is to build a simple form that makes a GET
request. By default, this will add
a query string to the URL with the values set by the form. We’ll want that value
to be persisted in the form’s input.
First, we’ll update our home partial by adding a form with a url
field.
<h1>Resolved</h1>
<p>Enter a domain name</p>
+<form method="get" action="/">
+ <label for="url">Url</label>
+ <input type="url" name="url" id="url" required value="<%= @locals[:url] %>">
+ <button>Submit</button>
+</form>
Next, let’s update our render
method to accept local variables to be used in
our partials.
case path
when "/"
- render("home")
+ render("home", url: req.params["url"])
else
handle_missing_path
end
@@ -18,7 +18,8 @@ class App
private
- def render(template, status_code: 200)
+ def render(template, status_code: 200, **locals)
+ @locals = locals
@content = render_template(template)
body = render_template("layout")
headers = {"Content-Type" => "text/html; charset=utf-8"}
We’ll leverage the #params helper method to get the value from the form. If we restart our server and fill out our form, we’ll see that the value persists on the subsequent request.
Now that we have a working form, we can actually implement the logic to return the name servers.
case path
when "/"
- render("home", url: req.params["url"])
+ url = req.params["url"]
+
+ if url
+ render("home", url:, name_servers: name_servers_for(url))
+ else
+ render("home")
+ end
else
handle_missing_path
end
@@ -39,4 +47,10 @@ class App
[404, headers, [body]]
end
+
+ def name_servers_for(url)
+ host = URI(url).host
+ res = Resolv::DNS.new
+ res.getresources(host, Resolv::DNS::Resource::IN::NS)
+ end
end
<input type="url" name="url" id="url" required value="<%= @locals[:url] %>">
<button>Submit</button>
</form>
+<% if @locals[:name_servers] %>
+ <p>Name Servers:</p>
+ <ul>
+ <% @locals[:name_servers].each do |name_server| %>
+ <li><%= name_server.name.to_s %></li>
+ <% end %>
+ </ul>
+<% end %>
If we restart our server and fill out the form, we’ll see that it now returns name servers.
Render errors
Our application does not yet handle errors. For example, it will break if it’s unable to resolve the name servers for an invalid host.
We can update #name_servers_for
to handle errors, and pass the message to
the newly added announcement
keyword argument on #render
, which gets assigned to
@announcement
.
url = req.params["url"]
if url
- render("home", url:, name_servers: name_servers_for(url))
+ result = name_servers_for(url)
+
+ if result.success
+ render("home", url:, name_servers: result.payload)
+ else
+ render("home", url:, announcement: result.error, status_code: 422)
+ end
else
render("home")
end
@@ -26,8 +32,9 @@ class App
private
- def render(template, status_code: 200, **locals)
+ def render(template, status_code: 200, announcement: nil, **locals)
@locals = locals
+ @announcement = announcement
@content = render_template(template)
body = render_template("layout")
headers = {"Content-Type" => "text/html; charset=utf-8"}
@@ -49,8 +56,17 @@ class App
end
def name_servers_for(url)
- host = URI(url).host
- res = Resolv::DNS.new
- res.getresources(host, Resolv::DNS::Resource::IN::NS)
+ result = Struct.new(:success, :payload, :error, keyword_init: true)
+
+ begin
+ host = URI(url).host
+ res = Resolv::DNS.new
+ payload = res.getresources(host, Resolv::DNS::Resource::IN::NS)
+ raise Resolv::ResolvError, "Could not resolve DNS records for #{host}" if payload.empty?
+
+ result.new(success: true, payload: payload)
+ rescue Resolv::ResolvError, URI::InvalidURIError => error
+ result.new(success: false, error: error.message)
+ end
end
end
Now we just need to update our layout template to include @announcement
.
<title>Resolved</title>
</head>
<body>
+ <%= @announcement %>
<%= @content %>
</body>
</html>
If we restart our application and fill out the form with an invalid host, we’ll
see an error message instead. We also see that we now return a semantically
correct 422
status code.
Style application
Now that we have a fully functioning application, let’s introduce some styles. We’ll use Bootstrap for demonstration purposes.
First, let’s update our Rack::Static
declaration by adding our CSS. While
we’re at it, we’ll also add a favicon and create a new header rule to ensure all
static assets are cached.
app = Rack::Builder.new do
use Rack::Static,
root: "public",
- urls: ["/404.html"],
+ urls: ["/css", "/favicon.ico", "/404.html"],
header_rules: [
[%w[html], {"Content-Type" => "text/html; charset=utf-8"}],
+ [:all, {"Cache-Control" => "public, max-age=31536000"}]
]
run App.new
end
Now we can update our application template and 404-page to use the style sheet and favicon.
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Find a domain's name servers">
<title>Resolved</title>
+ <link rel="icon" href="favicon.ico" />
+ <link rel="stylesheet" type="text/css" href="/css/styles.css">
</head>
</html>
And with that, we should have a fully styled application. We can double-check by restarting the server.
Next steps
Although this application is production ready, there are still several things we can do to improve its performance and security, such as compressing and caching requests and using HTTP security best practices. There’s also an opportunity to improve the developer experience by introducing a logging mechanism, as well as using a more heuristic error handling mechanism.
If those topics are of interest, you can reference the source code from which this post was based on in the meantime.