Let's build something with Rack
Slow down!
When it’s time to build a web app, we go for the big guns. gem 'rails'
in the Gemfile
and we’re off to races. What if we didn’t? What if we tried to carefully craft every line in our app? What if we only added Sprockets when we needed it? What if we want granular control of our app’s security? Or what if we just want a better idea of what’s actually happening when a user visits our site?
Rack - The Bow Drill of Web Apps
Let’s build a simple web app using just rack and rack middleware. Along the way we’ll learn about some rack features like URLMap and writing custom middleware.
The App - Programmer of the Day
What are we without our past? Let’s write an app where we can keep track of our programming heroes. The app, PotD is our one stop for learning about awesome coders.
Planning
We’ll need a few routes:
- GET / gives you a link to the “All Programmers” page
- GET /programmers returns a list of our programmers
- GET /programmers/4 returns a programmer with an id of 4
We’ll also add Bootstrap since all websites need Bootstrap.
Starter App
I’ve set up a database and basic setup here. Other than rack
being in the Gemfile
there’s nothing webby about this!
Rackup!
In our config.ru
file we’ll start off by handling the GET /
requirement.
1
2
3
4
5
6
7
8
9
10
11
12
13
class App
def call(env)
request = Rack::Request.new(env)
if request.get? && request.path == "/"
Rack::Response.new("You're at the home page!")
else
Rack::Response.new("File not found", 404)
end
end
end
use Rack::ContentType
run App.new
If this looks crazy, take a look at the my Rack Basics post.
Running rackup
starts the app. Let’s make the homepage load a template.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class App
def call(env)
request = Rack::Request.new(env)
if request.get? && request.path == "/"
template = File.read('app/views/home/index.html.erb')
Rack::Response.new(template)
else
Rack::Response.new("File not found", 404)
end
end
end
use Rack::ContentType
run App.new
Put this template in app/views/home/index.html.erb. We’re not actually using erb for this template, but let’s keep the template names consistent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Potd</title>
</head>
<body>
<h1>Welcome to Programmer of the Day!</h1>
<ul>
<li>
<a href="/programmers">Click here</a> to get a list of our programmers.
</li>
<li>
<a href="/about">Click here</a> to learn more about the app.
</li>
</ul>
</body>
</html>
If you’re thinking about yield
and templates and all that… go read this. I’m not touching it in the post.
Cool! One story DONE! But this code looks crazy already. Let’s break some of this code into controllers.
1
2
3
4
5
6
7
# HomeController in app/controllers
class HomeController
def index
template = File.read('app/views/home/index.html.erb')
Rack::Response.new(template)
end
end
In config.ru
we bring in our environment
file so it loads all our classes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config.ru
require_relative 'config/environment'
class App
def call(env)
request = Rack::Request.new(env)
if request.get? && request.path == "/"
HomeController.new.index
else
Rack::Response.new("File not found", 404)
end
end
end
use Rack::ContentType
run App.new
Better… but we can do better by using rack’s URLMap
functionality. We can essentially break up parts of our app into parallel rack apps that only get called if a route matches. The cool thing is that we can set the middleware to run all ALL routes by setting it outside the block, or create our custom stack for each route.
Here’s the updated config.ru
and HomeController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# config.ru
require_relative 'config/environment'
use Rack::ContentType
map '/' do
run HomeController.new
end
# HomeController
class HomeController
attr_reader :request
def call(env)
@request = Rack::Request.new(env)
if request.get? && request.path == '/'
index
else
Rack::Response.new("File not found", 404)
end
end
def index
template = File.read('app/views/home/index.html.erb')
Rack::Response.new(template)
end
end
This is MUCH better. We had to give the controller it’s own #call
method since it gets called only when this route matches. There is a bit of a smell, and we have a hunch we’re heading to the duplication dunes, but we soldier on.
All the programmers!
Let’s make a new controller for our programmers route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# ProgrammersController
class ProgrammersController
attr_reader :request
def call(env)
@request = Rack::Request.new(env)
if request.get? && request.path == '/programmers'
index
else
Rack::Response.new("File not found", 404)
end
end
def index
programmers = Programmer.all
template = File.read('app/views/programmers/index.html.erb')
result = ERB.new(template).result(binding)
Rack::Response.new(result)
end
end
And the template
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Potd</title>
</head>
<body>
<h1>Checkout our programmers!</h1>
<ul>
<% programmers.each do |programmer| %>
<li><a href="/programmers/<%= programmer.id %>"><%= programmer.name %></a></li>
<% end %>
</ul>
</body>
</html>
Here we have the call method, again, acting as our router, delegating out to our index action or 404ing when it doesn’t find anything. Again… this smells, we see some duplication, but we’ll hold off. In the immortal words of Bobby “Crouton” Nadler, wait for duplication at least 3 times. By then, you should understand exactly what’s being repeated.
Our config.ru
looks like this now:
1
2
3
4
5
6
7
8
9
require_relative 'config/environment'
use Rack::ContentType
map '/programmers' do
run ProgrammersController.new
end
map '/' do
run HomeController.new
end
Dynamic Segments - Wrath of the Regex
Ok, so our next requirement is going to make us get… creative. We’re going to have to write a router, and write some interesting regular expressions. Let’s get started.
The code I wish worked looks something like this
1
2
3
4
5
# config/routes.rb
Routes = Router.new
Routes.add_route(method: "GET", path: "/programmers", handler: "ProgrammersController#index")
Routes.add_route(method: "GET", path: "/programmers/:id", handler: "ProgrammersController#show")
Routes.add_route(method: "GET", path: "/", handler: "HomeController#index")
It would handle any dynamic segment extraction, and call the right controller, modifying params along the way.
This changes our current strategy of using URLMap since the mapping will be made by the router, and not by individual blocks.
WARNING: LOTS OF CODE
Ok… so don’t run. We’re friends right? You trust me right? We’ll get through this. Just… don’t… run…
We’ll start with the new config.ru
file:
1
2
3
4
5
require_relative 'config/environment'
require_relative 'config/routes'
use Rack::ContentType
run Routes
Nice right? It loads all of our routes. Routes
was made a constant so we could load it in another file and it stay in memory. Basically it’s a global. Sue me.
We needed to make a Router
class to get support this awesome syntax
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# lib/router.rb
class Router
attr_reader :request, :routes
def initialize
@routes = {}
end
def call(env)
@request = Rack::Request.new(env)
route = routes[[request.request_method, request.path]]
if route
call_action_for(route)
else
Rack::Response.new("File not found", 404)
end
end
def add_route(method:, path:, handler:)
routes[[method, path]] = handler
end
private
def call_action_for(route)
controller, action = route.split("#")
controller_class = Kernel.const_get(controller)
controller_class.new.public_send(action)
end
end
DON’T RUN! Let me explain. You have to think of the router as having 2 big roles. Storing URL, Method combinations, and looking up what to do if it finds a match.
The methods related to that first job are #initialize
and #add_route
. Here, we’re just giving it the rules. The sexiness is in the #call
method. Rack will call this method when a request comes in and we’ll find a route.
In #add_route
we’re making the key of the hash… an array. You’re seeing that right. The value is the string version of the controller#action.
#call
method asks for the route. Not found? 404, Found? MAGIC.
1
2
3
4
5
def call_action_for(route)
controller, action = route.split("#")
controller_class = Kernel.const_get(controller)
controller_class.new.public_send(action)
end
Split the string, Find a controller with that name, instantiate and call the action.
Bad news
This doesn’t work for the /programmers/1
path. All that work… for NOTHING! Let’s make Sandi Metz proud and MAKE MOAR OBJECTS! We’ll need our “Route” to be smarter than just an array.
Route object
We need to write our own equality so that we can compare /programmers/:id
and have it equal /programmers/1
. We also want to make it so that /programmers
equals /programmers
(no dynamic segments here).
Here’s our first pass at it
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Route
attr_reader :method, :path
def initialize(method:, path:)
@method = method
@path = path
end
def ===(other)
match_path === other.path && method == other.method
end
private
def match_path
return path unless has_dynamic_segment?
Regexp.new(path.gsub(/:\w+/, '(\w+)') + "$")
end
def has_dynamic_segment?
path.include?(":")
end
end
We write a regular expression that replaces any part of the string that starts with a :
with a \w+
. Meaning replace /programmers/:id
into A regular expression containing /programmers/(\w+)
.
We updated our Router too:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Router
attr_reader :request, :routes
def initialize
@routes = {}
end
def call(env)
@request = Rack::Request.new(env)
current_route = Route.new(path:request.path, method:request.request_method)
route, handler = routes.find do |route, handler|
route === current_route
end
if route
call_action_for(handler)
else
Rack::Response.new("File not found", 404)
end
end
def add_route(method:, path:, handler:)
routes[Route.new(method:method, path:path)] = handler
end
private
def call_action_for(handler)
controller, action = handler.split("#")
controller_class = Kernel.const_get(controller)
controller_class.new.public_send(action)
end
end
This works! We call the right action based on a dynamic segment! We can’t stop yet. We’re so close. We need to modify the controllers to expect request information so it knows WHICH programmer to show, AND we need to modify the request so it contains the value of the dynamic segment.
Let’s do the easy stuff first. Your home controller should look like this:
1
2
3
4
5
6
7
8
9
10
11
12
class HomeController
attr_reader :request
def initialize(request)
@request = request
end
def index
template = File.read('app/views/home/index.html.erb')
Rack::Response.new(template)
end
end
And your ProgrammersController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ProgrammersController
attr_reader :request
def initialize(request)
@request = request
end
def index
programmers = Programmer.all
template = File.read('app/views/programmers/index.html.erb')
result = ERB.new(template).result(binding)
Rack::Response.new(result)
end
def show
programmer = Programmer.find(request.params["id"])
template = File.read("app/views/programmers/show.html.erb")
Rack::Response.new(ERB.new(template).result(binding))
end
end
Then make a template at app/views/programmers/show.html.erb
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Potd</title>
</head>
<body>
<h1><%= programmer.name</h1>
<h2><%= programmer.description %></h2>
</body>
</html>
Router updates
In the router, update the request to include the dynamic segment info.
1
2
3
4
5
6
#...
if route
route.update_params!(request)
call_action_for(handler, request)
else
#...
Then in the route, add the method.
1
2
3
4
5
6
7
8
def update_params!(request)
return unless has_dynamic_segment?
keys = path.scan(/:(\w+)/).flatten
values = request.path.scan(match_path).flatten
dynamic_segment_params = keys.zip(values).to_h
request.params.merge!(dynamic_segment_params)
end
Do nothing if there’s nothing dynamic. Otherwise extract the keys, and values, zip them them merge it into the request’s params hash.
One more thing
Add the Rack::Static Middleware. Any files in /public/css will be served without checking our routes. Great for stylesheets!
1
2
3
4
5
6
require_relative 'config/environment'
require_relative 'config/routes'
use Rack::ContentType
use Rack::Static, urls: ['/css'], root: 'public'
run Routes
Run this in your terminal. I’m assuming you have wget
like a respectable developer.
1
2
3
mkdir -p public/css
cd public/css
wget https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css
Then add this line to the head of all of your html pages
1
<link rel="stylesheet" href="/css/bootstrap.min.css">
That’s all!
Next time, we’ll handle posting from forms. I hope you found this useful, and enjoyed the journey.
comments powered by Disqus