In part 1 we built a very simple rack application that responded with Hello World no matter what path you requested. In part 2 we're going to add a router that handles routing request based on user configuration. We'll go ahead and start fresh since we didn't write that much code the first time.

# Gemfile
source "http://rubygems.org"

ruby '2.0.0'
gem 'rack', '~> 1.5.2'

Pretty straight forward, the only gem we depend on is rack. If you would like to use a different web server than webrick feel free to add it now. (To do so, just add the thin or unicorn gem) Don't forget to bundle!

Now lets build the entry point for our rack application. We'll name this file config.ru

require 'bundler'
Bundler.require

require File.join(File.dirname(__FILE__),'lib', 'brain_rack')
require File.join(File.dirname(__FILE__),'lib', 'request_controller')

BrainRackApplication = BrainRack.new

# Load the routes
require File.join(File.dirname(__FILE__),'config', 'routes')

run RequestController.new

This file is pretty straight forward. First we use bundler to load the gems from our gemfile. We load some files (which we haven't created yet), we create an instance of our BrainRack application, and then we tell rack to run our application. Lets build the BrainRack class:

require File.join(File.dirname(__FILE__), 'router.rb')

class BrainRack
  attr_reader :router

  def initialize
    @router = Router.new
  end
end

This class is extremely simple, it basically is just a holder for our router at this point. In later parts of this series we'll add onto it.

If you remember from part 1, a rack expects an object that it can call the :call method on. It expects the response to be an array with the first element being the status code, the second element is a hash of headers, and the third is an array of responses. We'll make sure our RequestController adheres to this. We'll put this in lib/request_controller.rb

class RequestController
  def call(env)
    route = BrainRackApplication.router.route_for(env)
    if route
      response = route.execute(env)
      return response.rack_response
    else
      return [404, {}, []]
    end
  end
end

So rack is going to call the call method on this request controller instance. It passes in the env hash, which contains all the information about the request such as the path and the method. We're going to ask our router for the route for this request. The router will return that route, or nil if no route matched. We then execute the route (we'll explain this later) and return the rack_response to rack. If the route wasn't found (it was nil), then we return a generic 404.

Lets switch gears a little bit and look at what we want our route configuration to look like. I decided I wanted to target a DSL that almost exactly matches the Rails router. Here is what config/routes.rb will look like:

BrainRackApplication.router.config do
  get "/test", :to => "custom#index"
  get /.*/, :to => "custom#show"
end

So we have two routes here, the first is a get to /test which should hit the Custom controller class index method (which we haven't built yet). The second is a regular expression which matches any number of characters. This route hits the custom controllers show method. Notice that the order the routes are in the file matters. Technically going to /test would hit the regex route also, but since the /test route is first it will hit that. So routes closer to the top take priority.

Now lets look at the Router class and we'll see how we made this dsl. I'm going to go ahead and list the entire file and we'll talk about the pieces individually.

require File.join(File.dirname(__FILE__), 'route')

class Router
  attr_reader :routes

  def initialize
    @routes = Hash.new { |hash, key| hash[key] = [] }
  end

  def config &block
    instance_eval &block
  end

  def get path, options = {}
    @routes[:get] << [path, parse_to(options[:to])]
  end

  def route_for env
    path   = env["PATH_INFO"]
    method = env["REQUEST_METHOD"].downcase.to_sym
    route_array = routes[method].detect do |route|
      case route.first
      when String
        path == route.first
      when Regexp
        path =~ route.first
      end
    end
    return Route.new(route_array) if route_array
    return nil #No route matched
  end

  private
  def parse_to to_string
    klass, method = to_string.split("#")
    {:klass => klass.capitalize, :method => method}
  end
end

There is a lot going on here, and it probably would be nice to refactor into seperate pieces, but for now this is what it is. Lets take a look at the @routes instance variable in initialize. It is set to a new instance of a hash, but with a default value of an empty array. This means anytime you ask for a key in the hash that hasn't been definied yet it returns an empty array by default. For more information on this read this

Now lets take a look at the config method. This method is called in config/routes.rb on the first line.

BrainRackApplication.router.config do

We call the config method and pass it a block. The block that is passed in gets instance_eval'd on an implicit self. That means that in config/routes.rb when you call get "some_path", :to => "some_controller#some_method" its actually calling router.get("some_path", :to => "some_controller#some_method"). This is using metaprogramming to build the nice DSL that we want.

The get method works by adding a new array to the @routes[:get] array. The first value of this array is the path, i.e. "/test". The second element of array is the response of the parse_to method which is a hash like so:

{:klass => "custom", :method => "index"}

So as you call get for each of your routes in config/routes.rb it builds up an array full of routes that we store for when requests start coming in. Right now we only support get requests, but we could easily add post, patch, put, delete, etc just by adding more methods to this class.

Now lets see how the routing actually happens. Take a look back at lib/request_controller.rb

class RequestController
  def call(env)
    route = BrainRackApplication.router.route_for(env)
    if route
      response = route.execute(env)
      return response.rack_response
    else
      return [404, {}, []]
    end
  end
end

Notice on the 3rd line we call router.route_for(env). If you remember the env is passed from rack and it contains information about the request. Here is a sample env:

{"SERVER_SOFTWARE"=>"thin 1.6.1 codename Death Proof",
 "SERVER_NAME"=>"localhost",
 "rack.input"=>
  #<Rack::Lint::InputWrapper:0x000000021d0bd0
   @input=#<StringIO:0x0000000354bb88>>,
 "rack.version"=>[1, 0],
 "rack.errors"=>
  #<Rack::Lint::ErrorWrapper:0x000000021d0a90 @error=#<IO:<STDERR>>>,
 "rack.multithread"=>false,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "REQUEST_METHOD"=>"GET",
 "REQUEST_PATH"=>"/",
 "PATH_INFO"=>"/",
 "REQUEST_URI"=>"/",
 "HTTP_VERSION"=>"HTTP/1.1",
 "HTTP_HOST"=>"localhost:9292",
 "HTTP_CONNECTION"=>"keep-alive",
 "HTTP_ACCEPT"=>
  "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
 "HTTP_USER_AGENT"=>
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57 Safari/537.36",
 "HTTP_ACCEPT_ENCODING"=>"gzip,deflate,sdch",
 "HTTP_ACCEPT_LANGUAGE"=>"en-US,en;q=0.8",
 "GATEWAY_INTERFACE"=>"CGI/1.2",
 "SERVER_PORT"=>"9292",
 "QUERY_STRING"=>"",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "rack.url_scheme"=>"http",
 "SCRIPT_NAME"=>"",
 "REMOTE_ADDR"=>"127.0.0.1",
 "async.callback"=>#<Method: Thin::Connection#post_process>,
 "async.close"=>#<EventMachine::DefaultDeferrable:0x000000021c1ba8>}

It's literally a hash with all the information from the request. We really only care about two pieces for now. REQUEST_METHOD and PATH_INFO. Just in case we wanted to add more complex routing in the future, we pass the entire hash into the route_for method of the router. You've already written it to lib/router.rb, but i'll show it again to make it easier on you.

  def route_for env
    path   = env["PATH_INFO"]
    method = env["REQUEST_METHOD"].downcase.to_sym
    route_array = routes[method].detect do |route|
      case route.first
      when String
        path == route.first
      when Regexp
        path =~ route.first
      end
    end
    return Route.new(route_array) if route_array
    return nil #No route matched
  end

We grab the two keys of the rack hash that we care about and put them into local variables. Since this is a get request, method is going to equal :get. We lookup the array in the routes hash using the method key. We then use ruby's detect to determine the first route that matches our request, if any. Remeber each route now is an array that looks like so:

["/test", {:klass => "custom", :method => "index"}]

The detection does a case statement on the path. If route.first.is_a?(String) then we check to see if it literally equals the path that was requested for rack.

path == route.first

If route.first.is_a?(Regexp) then we check to see if the requests path matches this regex. If either statements return true we have found our matching route. If not we move on to the next route. If we successfully find a route that matches, we return it as a new instance of the Route class, otherwise we return nil.

That brings us to the lib/route.rb Route class:

require File.join(File.dirname(__FILE__), '../', 'app', 'controllers', 'base_controller')

class Route
  attr_accessor :klass_name, :path, :instance_method
  def initialize route_array
    @path            = route_array.first
    @klass_name      = route_array.last[:klass]
    @instance_method = route_array.last[:method]
    handle_requires
  end

  def klass
    Module.const_get(klass_name)
  end

  def execute(env)
    klass.new(env).send(instance_method.to_sym)
  end

  def handle_requires
    require File.join(File.dirname(__FILE__), '../', 'app', 'controllers', klass_name.downcase + '.rb')
  end
end

This class is responsible for requiring the correct controller in app/controllers, and giving a path to execution. Executing the route just instantiates the controller with the request env, and calls the method specified in the route.

We're almost done. We just need to add a couple of more files. First lets add our base controller. This should reside in app/controllers/base_controller.rb

require File.join(File.dirname(FILE), '..', '..', 'lib', 'response')

class BaseController

attr_reader :env

def initialize env
  @env = env
end

end

It is very straight forward, it just requires the response class, and handles initialization with the request. Lets look at our custom controller which inherits from the base controller.

class Custom < BaseController

def index
  Response.new.tap do |response|
    response.body = "Hello World"
    response.status_code = 200
  end
end

def show
  Response.new.tap do |response|
    response.body = "Catchall Route"
    response.status_code = 200
  end
end

end

If you remember in our routes configuration we configured two routes, one that pointed at custom#index, and the other that pointed at custom#show. Those methods are defined here and return a response instance with details of our response including the status code and body. Lets look at the response class 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

Once you configure this response with a body, status_code, and optionally headers it can generate a response that rack understands. Which gets used back in lib/request_handler.rb

class RequestController
  def call(env)
    route = BrainRackApplication.router.route_for(env)
    if route
      response = route.execute(env)
      return response.rack_response
    else
      return [404, {}, []]
    end
  end
end

On the 5th line we call response.rack_response and we're done!

Finally, to run your server you would type:

rackup config.ru

I hope this tutorial helped you understand how to build a very simple framework. In future parts I hope to add views that automatically get rendered based on convention much like Rails does. If you have any questions, or you see an error feel free to post a comment and i'll respond as quick as I can.

Tags:

Adam has worked with Isotope 11 for 4 years and has been a professional software developer for over 12 years. He has been lead developer on multiple Fortune 500 projects. He is the author of "Beginning Rails 4" which was published by Apress in September 2013.