Rack is the web server that powers many Ruby web frameworks, including Rails. Let’s see how you can make your own simple HTTP router, using Rack.
This is based on a guide I followed by Adam Gamble about how to build a router. The guide is here.
A minimal Rack web server is as follows. Create a Gemfile:
source "http://rubygems.org"
gem "rack"
And run bundle. To create a simple Rack server, all you need is an object with the call method. call will be passed information about the web request. Create lib/basic_controller.rb:
class BasicController
def call(env)
[200, {}, ["Hello from basic controller"]]
end
end
And a basic_rack.ru at the top level:
require 'rack'
load 'lib/basic_controller.rb'
Rack::Handler::WEBrick.run(
BasicController.new, Port: 9000
)
And start the server using rackup basic_rack.rb. The following is displayed if everything went well
[2018-04-22 01:03:35] INFO WEBrick 1.3.1
[2018-04-22 01:03:35] INFO ruby 2.3.3 (2016-11-21) [universal.x86_64-darwin17]
[2018-04-22 01:03:35] INFO WEBrick::HTTPServer#start: pid=36872 port=9000
As a quick aside:
load and requireWe are using both load and require. What is the difference?
Easy. Well, sort of. Basically, load will execute some code in a ruby file. You should pass an absolute path. When calling load, the following are imported:
but not local variables. So when we did load("lib/basic_controller.rb"), we load the BasicController class. The .rbextension is also necessary. If you call load twice, the code will be executed twice.
require, on the other hand, only executes code once, even if you call it multiple times. require code is saved in a global variable called $LOADED_FEATURES.
Now we know the basics of a Rack application. Let’s make a more robust example, with controllers and a router.
Let’s start fresh. Create a Gemfile and add Rack:
gem "rack"
Create a config.ru and include the following:
require 'bundler'
Bundler.require
require File.join(File.dirname(__FILE__), "lib", "main_rack")
Try running rackup config.ru. It will complain that lib/main_rack.rb doesn’t exist. Create that, too, and add the following:
class MainRack
end
Run the app again (with rackup config.ru). Now we have a new error:
/Library/Ruby/Gems/2.3.0/gems/rack-2.0.4/lib/rack/builder.rb:146:in `to_app': missing run or map statement (RuntimeError)
This error is from Rack itself. Rack requires a run method, that takes a class with a call method. Recall the original BasicController:
class BasicController
def call(env)
[200, {}, ["Hello from basic controller"]]
end
end
Go ahead and create lib/request_handler.rb.
class RequestHandler
def call(env)
end
end
Great! Now running the app gives us the default output for a Rack server starting:
[2018-04-22 18:05:50] INFO WEBrick 1.3.1
[2018-04-22 18:05:50] INFO ruby 2.3.3 (2016-11-21) [universal.x86_64-darwin17]
[2018-04-22 18:05:50] INFO WEBrick::HTTPServer#start: pid=37786 port=9292
curl localhost:9292 returns a 500 error, though. Inspecting Rack’s output:
Rack::Lint::LintError: Status must be >=100 seen as integer
Right. call should return a status, headers, and a body. Let’s do that.
class RequestHandler
def call(env)
[200, {}, ["ok"]]
end
end
Now we can curl the Rack server. Let’s move on to adding some routes.
We will store the router reference in MainRackApplication.router, which will have a route_for method, that decides which controller will handle the request. Update request_handler.rb:
class RequestHandler
def call(env)
route = MainRackApplication.router.route_for(env)
if route
# stuff
else
return [404, {}, []]
end
end
end
curling the server now will result in an error, since neither the router class, not the route_for method, exists. Create lib/router.rb, with just enough to let the server run again with rackup config.ru and curl it:
class Router
def route_for(env)
end
end
Also, update lib_main_rack.rb to require the router, and set an instance variable:
require File.join(File.dirname(__FILE__), 'router.rb')
class MainRack
attr_reader :router
def initialize
@router = Router.new
end
end
Now curling the server returns a 404, since route_for returns nil.
Before we implement the router logic and controllers, we need to decide on the DSL for the router. Let’s make it like Rails’. Create config/routes.rb, and inside add two routes:
MainRackApplication.router.config do
get "/users", to: "users#index"
get "/users/show", to: "users#show"
end
We will define a config method on the router, which takes a block. Inside the block, we define a DSL of the following format:
[HTTP Verb] [path], to: "controller#method"
Soon we will use instance_eval to turn this into a function call to a method called get, which takes two arguments, a path and a hash of options including the controller and action.
Let’s load the routes on startup. In config.ru, require config/routes:
# ...
require File.join(File.dirname(__FILE__), "config", "routes")
# ...
Now when start the app, we are calling MainRack.router.config, which throws an error since it doesn’t exist. Let’s make it exist:
class Router
#...
def config
end
end
Okay, we can once again start the app without errors. But config doesn’t do anything. What should it do, then?
Our goal is to create a hash called @routes, that we can use to look up the correct controller for each incoming request. Let’s add an attr_reader and initialize an empty hash for the @routes. The hash will have a default value of an empty array. This means if you ask for an unknown key, you get an empty array by default.
class Router
attr_reader :routes
def initialize
@routes = Hash.new{ |hash, key| hash[key] = [] }
end
# ..
end
Now onto config. We want the @routes hash to look like this:
{ :get => [
"/test/show": {:klass => "TestController", :method => "show" }
]
}
Then if we get a GET request, we can loop all the GET routes, until we find the matching one. With this goal in mind, let’s implement config:
def config(&block)
instance_eval &block
end
This will produce the following, based on our config/routes.rb DSL:
get("users", { :to -> "users#show" })
So we need a get method, that takes a String path and an Hash of options.
def get(path, options={})
@routes[:get] << [path, parse_opts(options[:to])]
end
We should format the options nicely. If we just did [path, options[:to]] we would get:
@routes[:get] = [
[
"/users/show", "users#show"
]
]
We want to split “users#show” to a hash with controller and method keys. Let’s implement parase_opts:
def parse_opts(options)
controller, method = options.split("#")
{:controller => "#{controller.capitalize}Controller", :method => method}
end
Check it out by doing a puts after instance_eval in config and restarting the server:
Routes
{:get=>[["/users", {:controller=>"UsersController", :method=>"index"}], ["/users/show", {:controller=>"UsersController", :method=>"show"}]]}
Great!
Now we have the routes set up. Let’s add two simple controllers - a BaseController and a UseresController. Note we still have not implemented the logic to forward the request to the correct controller yet, but we do have a nice @routes hash that helps match requests to controllers. Create app/controllers.rb, and app/controllers/base_controller.rb, as wel las app/controllers/users_controller.rb.
First, add the following to app/controllers/base_controller.rb:
class BaseController
attr_reader :env
def initialize(env)
@env = env
end
end
This lets any controllers inheriting from BaseController access env, which contains all the information from Rack, such as the request path, headers, and so on.
Now add the following code to app/controllers/users_controller.rb:
class UsersController < BaseController
def index
end
def show
end
end
Remember, the controller has to return a response that complies to what Rack expects:
[status = 200, headers = {}, [body]]
Let’s go ahead and make class for this. Add it in lib/response.rb:
class Response
attr_accessor :status_code, :headers, :body
def initialize
@headers = {}
end
def rack_response
[status_code, headers, Array(body)]
end
end
Nothing too exciting. @headers aren’t necessary, so they are set to an empty hash by default.
Now we can head back to users_controller.rb and implement #index.
def index
Response.new.tap do |response|
response.body = "Hello from users#index\n"
response.status_code = 200
end
end
tap is another way to assign instance variables on a new object. Instead of
response = Response.new
response.body = "..."
return response
We can do
Response.new.tap do |response|
response.body = "..."
end
tap returns the newly created object instance.
We didn’t require the newly added response.rb, so do that in base_controller. This will let all controllers access the Response object.
require File.join(File.dirname(__FILE__), "..", "..", "lib", "response")
class BaseController
attr_reader :env
def initialize(env)
@env = env
end
end
Okay. We actually have enough to see this working now. Let’s finally do something in router#route_for. Before doing so, review lib/request_handler.rb:
class RequestHandler
def call(env)
route = MainRackApplication.router.route_for(env)
if route
# stuff
else
return [404, {}, []]
end
end
end
route should be a response object, with the format of:
[status = 200, headers = {}, [body]]
For now, update the if statement to return the route:
if route
route
else
return [404, {}, []]
end
Now in lib/router.rb:
def route_for(env)
# this will get the first route, which is
# ["/users", {:controller => "UsersController", :method => "index"}]
route = @routes[:get].first
# route it is an array. We are interested in the options, which is accessible by route.last
options = route.last
# create a new instance of the controller
# UsersController inherits from BasicController, where #initialize requires and `env` argument
controller = Module.const_get(options[:controller]).new(env)
# this line calls #index, which returns a response instance
res = controller.send(options[:method])
# return the correctly formatted rack_response
res.rack_response
end
If everything went well, you can restart the server and curling any url will return:
Hello from users#index
That was a lot of hardcoding, but it appears to be working! Let’s refactor and allow the code to work for any route. The hardcode we need to make dynamic is:
require the correct class for each route
send the correct method for each request
First, we can move the logic from route_for to a dedicated route object. Create lib/route.rb, and add:
require File.join(File.dirname(__FILE__), '../', 'app', 'controllers', 'base_controller')
class Route
attr_accessor :controller, :path, :instance_method
def initialize route_and_options
@path, options = route_and_options
@controller = options[:controller]
@instance_method = options[:method]
end
end
Each route will receive an array that has this shape:
[path = "/users", {:controller => "UsersController", :method => "index"}]
In initialize we assign the correct values to instance variables. The next thing is dynamically require the correct class:
def handle_requires
require File.join(File.dirname(__FILE__), '../', 'app', 'controllers', underscore(@controller))
end
def underscore str
str.gsub(/::/, '/').
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
gsub(/([a-z\d])([A-Z])/,'\1_\2').
tr("-", "_").
downcase
end
We stole underscore from Rails - we need to change UsersController to users_controller and so forth.
Now we just need the logic to choose the correct controller (we required all the controller, but we also need to create and instance) and then use send to call the correct instance method that corresponds to the request:
def klass
Module.const_get(@controller)
end
def execute(env)
klass.new(env).send(instance_method)
end
Looking good. Now we can clean up the route_for method in lib/router/rb:
def route_for(env)
path = env["PATH_INFO"]
method = env["REQUEST_METHOD"].downcase.to_sym
route_and_options = routes[method].find { |route|
route.first == path
}
route_and_options ? Route.new(route_and_options) : nil
end
Pretty simple. We just find the corresponding method, in this case :get, and the matching path. We could improve this by checking for Regexp like the original article. For now, simple is good.
Finally, we can update lib/request_handler.rb:
class RequestHandler
def call(env)
route = MainRackApplication.router.route_for(env)
if route
response = route.execute(env)
return response.rack_response
else
return [404, {}, []]
end
end
end
And that’s it! We can match any static route. Try restarting the application and executing curl localhost:9292/users/ -i and you should see:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Server: WEBrick/1.3.1 (Ruby/2.3.3/2016-11-21)
Date: Sun, 22 Apr 2018 11:52:07 GMT
Connection: Keep-Alive
Hello from users#index
Long article, but I learned a lot. You can find the original article here. Ruby is really nice to work with and has a lot of great utlities for simple metaprogramming.