A Simple Router on Rack
This past weekend, I found myself playing around with Rack—a “a
minimal interface between webservers supporting Ruby and Ruby frameworks.”
Rack is—as you may know—one of the integral interfaces implemented
in Rails. The config.ru
file in the root of every Rails application
is commonplace in Rack applications. You consult this file each time you
start the Rails server,
and you can use it to insert middlewares into your stack.
While the “writing a lightweight Rack framework” exercise foreshadows another accidental, not-so-capable Sinatra, it can be fun and informative to experiment. Plus, when you’re in a rush to get the website up for next summer’s frozen banana stand, your most efficient route is most certainly framework development. And so I began.
Rack in Action
My progress thus far isn’t extraordinary, but I’m happy to have had the pleasure of deploying something non-Rails on Heroku. (Heroku has a nice guide on deploying a Rack application.) Progress aside, though, I’d like to focus on two facets of the router implementation: block-style configuration and mapping/decorating requests.
Adding Routes
Let’s begin with block-style configuration: This is a fairly common pattern.
You’ve seen it in various Ruby gems and—yes—in routes.rb
. Some might call
it sexy. I’ll call it “Ruby and its wonderful DSL-building features.” The
bulk of my implementation is below.
class Router
class << self
attr_accessor :routes
end
def self.routes
@routes ||= Routes.new
end
def self.load
yield(routes)
end
...
class Routes < Hash
def add(verb, path, mapping)
rkey = "#{verb}##{path}"
self[rkey] = mapping
end
end
end
First, we access the
eigenclass
of Router
and define a reader/writer. This allows us to access
the @routes
class-level instance variable (initialized just below)
directly from class methods. In this case, we define a Routes
class that
inherits from Ruby’s Hash
and implements a single instance method—Routes#add
. This
method will be responsible for mapping keys (created by the unique combination
of an HTTP verb and a URL path) to controller mappings (e.g., users#show
). We
define self.routes
to be a new instance of our Routes
class. Lastly, we include
a self.load
method, wherein we yield self.routes
to a block.
Let’s see the Router
class at work and define some routes.
Router.load do |routes|
routes.add "get", "/", "pages#index"
routes.add "get", "/users", "users#index"
routes.add "post", "/users", "users#create"
end
As you may have noticed, our DSL for building up routes is a bit different than
that implemented in Rails, but it has the same feel. Behind the scenes, we’re
simply building up a Routes
object with unique keys mapped to values that
have meaning within the context of our framework.
Routing and Decorating Requests
So far, we have an augmented hash that maps verb-path keys to controller
actions. Great. But how do we actually make use of these mappings? Furthermore,
if we want to use something like a dynamic path segment (the :id
in
/users/:id
), how do we go about sending the dynamic value along with the request?
Let’s start by looking at our application class, OnRackApp
.
class OnRackApp
def process_and_respond(env)
request = Rack::Request.new(env)
response = Rack::Response.new
response = Router.new.route(request, response)
response.finish
end
end
Rack provides us with some helpful classes for handling requests and responses,
Rack::Request
and Rack::Response
, respectively. The Rack::Request
class,
for instance, provides multiple params
methods for retrieving request
parameters (just like you see in the Rails controller). For now, we’ll isolate
these class names in OnRackApp
and allow our Router
class to expect certain
interfaces of request
and response
, rather than objects of specific classes. For some reason,
I elected to take a more functional approach here, instantiating a Router
object with no arguments and passing the request and response objects to the
Router#route
method. (Maybe this should be reconsidered?)
Let’s check out Router#route
. As we move through these methods, you will
continue to see a more functional style of data passing. There is mutation, but
it does not occur on the state of the object. Rather, it occurs on the
passed-around data. Again, there is design to be done here.
class Router
...
def route(request, response)
if mapping = map_and_parameterize(request)
map_and_invoke_respond(mapping, request, response)
else
respond_to_absent_mapping(response)
end
end
...
end
Now our router has access to a request object, which has access to—among other
properties—getters for request_method
and path
. You’ll recall that these
are the two components that we used to create the unique route key (where
request_method
is the HTTP verb and path
is the path). Thus, we can combine
these properties to create a key with which we can access the controller#action
mapping.
We can route a request.
class Router
...
private
def map_and_parameterize(req)
rkey = "#{req.request_method}##{req.path}"
if mapping = Router::routes[rkey]
return mapping
end
...
end
end
What happens, though, when we define a path with a dynamic route segment
(e.g., /users/:id
)? When we field a request for this route, we certainly
won’t find it in this format. Instead, we expect to see a request path like
/users/22
. So, for dynamic route segments, our simple key construction falls
flat.
Hope remains, of course. Because we receive a request path that has the same
pattern as the dynamically segmented path we defined, we can in effect use
pattern matching (Regexp
) to not only find the appropriate mapping, but also
to extract the segment name and value. I’m not by any means satisfied by the
rigid approach I’ve taken to date, but it is functioning. That always makes
for happy rabbits. You can check out router.rb
here.
You’ll see that—because we can extract each dynamic route segment—we can add it to the request params (or further decorate the request object) before passing it along to the controller. Pretty neat stuff.
Racking Up
While there are plenty of gaps in the router and framework architecture we’ve discussed here, there is a real benefit in thinking through this kind of design. Ruby affords us the ability to create fun domain specific languages (DSLs), and building your own half-functional framework can be a great way to experiment with different techniques and language constructs. And you can make a frozen banana stand website, too.