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 require
We 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 .rb
extension 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.