Better Routing – Part 4
This is the fourth part of Building an MVC Framework with Ruby. The topics we’ll cover are:
-
Part 1 – Rack Deep Dive
-
Part 2 – Set up a Basic Framework
-
Part 3 – Autoloading and Utility Methods
-
Part 4 – Better Routing
-
Part 5 – Render, Redirect & Before_Action Methods in Controllers
-
Part 6 – Extract Complexities out of View with Helpers
-
Part 7 – Reduce Rework with Generators
-
Part 8 – Set up ORM for Read/Write to Database
-
Part 9 – Generate Database from Schema
-
Part 10 – Set up Migrations
The source code for this post is available on github.
In this post, we’re going to add a router that handles routing request based on user configuration. Topics that we will be covering include
- Route configuration
- Router
- Mapper
- Route
- Application
- Integration test.
1. Route Configuration
Since we are rebuilding rails, we will target a DSL that matches the rails router. config/routes.rb file will look like this.
1 2 3 4 5 6 7 8 9 10 |
TodoApplication.routes.draw do get "/todo", to: "custom#index" get "/todo/:id", to: "custom#show" get "/todo/new", to: "custom#new" get "/todo/:id/edit", to: "custom#edit" post "/todo/:id", to: "custom#create" put "/todo/:id", to: "custom#update" patch "/todo/:id", to: "custom#update" delete "/todo/:id", to: "custom#destroy" end |
Let’s look at how to implement the above DSL.
2. Router
Open lib/zucy/routing/router.rb and paste this code
1 2 3 4 5 6 7 8 9 |
module Zucy module Routing class Router def draw(&block) instance_eval &block end end end end |
We are creating a module called Routing that will hold all our routing logic. Class Router has a draw method which uses one(instance_eval
) of the many fantastic ruby built-in features for creating Domain Specific Languages (DSLs). The instance_eval
method takes either a string or a block and evaluates the passed block in the context of the object calling instance_eval
. You can do this with any object in Ruby, even a String:
1 |
$ "Hello.".instance_eval{ size } # => 6 |
Check out this link to know more about instance_eval. With instance eval in the picture, we just have to define get, post, put, patch and delete
method in our mapper class.
2.1 get
Open lib/zucy/routing/router.rb and paste this code
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 32 33 34 35 36 37 38 39 40 41 42 43 44 |
module Zucy module Routing class Router def draw(&block) instance_eval(&block) end def get(path, to:) path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[:get] << @route_data end def root(to) get "/", to: to end def endpoints @endpoints ||= Hash.new { |hash, key| hash[key] = [] } end private def pattern_for(path) placeholders = [] path.gsub!(/(:w+)/) do |match| placeholders << match[1..-1].freeze "(?<#{placeholders.last}>[^/?#]+)" end [/^#{path}$/, placeholders] end def controller_and_action_for(path_to) controller_path, action = path_to.split("#") controller = "#{controller_path.capitalize}Controller" [controller, action.to_sym] end end end end |
There is a lot going on here. Let’s try to break it down a little.
get
method accepts two parameters (path and a hash containingto
key).controller_and_action_for
accepts a parameter eg “custom#index” and returns an array containing the controller(in string form) to instantiate and the method to invoke.@route_data
is a hash containing all information about the route we are defining.pattern_for
method accepts a parameter(the path been defined) and returns an array containing regular expression pattern for the path and placeholders in the path. Notice that all placeholder in the path are converted to regexp named groups. This will make it easier to capture the value of the placeholder when an actual request that matches the pattern is made. To learn more about named groups, check out this link.endpoints
method creates an instance variable(@endpoints
) if it has not been created before and/or returns the same instance variable.@endpoints is set to a new instance of a hash and each key of the hash is set with a default value of an empty array. This means anytime you ask for a key in the hash that hasn’t been defined yet, it returns an empty array by default.
2.1.1 write test for the get method
Open spec/unit/router_spec.rb and paste this code.
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
require "spec_helper" class Zucy::Routing::Router attr_reader :route_data def draw(&block) instance_eval(&block) self end end describe Zucy::Routing::Router do def draw(&block) router = Zucy::Routing::Router.new router.draw(&block).route_data end def route(regexp, placeholders, controller, action, path) pattern = [regexp, placeholders] { path: path, pattern: pattern, klass_and_method: [controller, action] } end context "endpoints" do context "get '/photos', to: 'photos#index'" do subject do draw { get "/photos", to: "photos#index" } end route_data = { path: "/photos", pattern: [%r{^/photos$}, []], klass_and_method: ["PhotosController", :index] } it { is_expected.to eq route_data } end context "get '/photos/:id', to: 'photos#show'" do subject do draw { get "/photos/:id", to: "photos#show" } end route_data = { path: "/photos/:id", pattern: [%r{^/photos/(?<id>[^/?#]+)$}, ["id"]], klass_and_method: ["PhotosController", :show] } it { is_expected.to eq route_data } end context "get '/photos/:id/edit', to: 'photos#edit'" do subject do draw { get "/photos/:id/edit", to: "photos#edit" } end regexp = %r{^/photos/(?<id>[^/?#]+)/edit$} route_data = { path: "/photos/:id/edit", pattern: [regexp, ["id"]], klass_and_method: ["PhotosController", :edit] } it { is_expected.to eq route_data } end context "get 'album/:album_id/photos/:photo_id', to: 'photos#album_photo'" do subject do draw { get "/album/:album_id/photos/:photo_id", to: "photos#album_photo" } end regexp = %r{^/album/(?<album_id>[^/?#]+)/photos/(?<photo_id>[^/?#]+)$} route_data = { path: "/album/:album_id/photos/:photo_id", pattern: [regexp, ["album_id", "photo_id"]], klass_and_method: ["PhotosController", :album_photo] } it { is_expected.to eq route_data } end end end |
Notice that we are monkey patching our router class. The draw method was modified to return self, since we want to chain our method call. Try running the test using:
1 |
$ rspec |
Commit your changes
1 2 |
$ git add --all $ git commit -m "setup router class" |
2.2 post, put, patch, delete methods
Methods post, put, patch and delete
are going to be very similar to the get
methods.
Open lib/zucy/routing/router.rb and add the following methods to the Router class.
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 32 33 34 35 36 37 38 39 |
def post(path, to:) path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[:post] << @route_data end def put(path, to:) path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[:put] << @route_data end def patch(path, to:) path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[:patch] << @route_data end def delete(path, to:) path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[:delete] << @route_data end |
Commit your changes
1 2 |
$ git add --all $ git commit -m "add post, put, patch and delete method to router class" |
Notice that the difference between all these methods is the last line of each of the method. Let’s try things up a little bit. We define all the methods (get, post, put, patch, delete) at once using another another ruby magic method(define_method
). Check out this link to learn more about define_method.
2.3 Dry up code
Remove get, post, put, patch and delete method and replace them with this code in the body of router class.
1 2 3 4 5 6 7 8 9 10 11 |
[:get, :post, :put, :patch, :delete].each do |method_name| define_method(method_name) do |path, to:| path = "/#{path}" unless path[0] = "/" klass_and_method = controller_and_action_for(to) @route_data = { path: path, pattern: pattern_for(path), klass_and_method: klass_and_method } endpoints[method_name] << @route_data end end |
Commit your changes
1 2 |
$ git add --all $ git commit -m "dry up similar code in router class using ruby's define_method method" |
3.0 Mapper
Mapper class will be responsible for finding controller and action for a path when a request is made.
Open lib/zucy/routing/mapper.rb and paste this code.
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 |
module Zucy module Routing class Mapper def initialize(endpoints) @endpoints = endpoints end def map_to_route(request) @request = request path = request.path_info method = request.request_method.downcase.to_sym result = @endpoints[method].detect do |endpoint| match_path_with_pattern path, endpoint end return Route.new(@request, result[:klass_and_method]) if result end def match_path_with_pattern(path, endpoint) regexp, placeholders = endpoint[:pattern] if path =~ regexp match_data = Regexp.last_match placeholders.each do |placeholder| @request.update_param(placeholder, match_data[placeholder]) end true end end end end end |
Here we pass in endpoints from router as a parameter when initializing mapper. map_to_route
method returns a route object if we find an endpoint that matches the path of the request. match_path_with_pattern
method does the actual comparison between the path and an endpoint. On line 21 – 23, we loop through all placeholders, for each placeholder, we update request params with the placeholder as key and the match data value for the placeholder as value. This was possible because we used named groups when creating our regular expression.
Commit your changes
1 2 |
$ git add --all $ git commit -m "create mapper class" |
4.0 Route
We going to be creating the route class we instantiated in mapper.
Open lib/zucy/routing/route.rb and paste this code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
module Zucy module Routing class Route attr_reader :klass_name, :request, :method_name def initialize(request, klass_and_method) @klass_name, @method_name = klass_and_method @request = request end def klass klass_name.constantize end def dispatch klass.new(request).send(method_name) end end end end |
Commit your changes
1 2 |
$ git add --all $ git commit -m "create route class" |
5.0 Application
Let’s tie everything together in our application class.
Open lib/zucy/application.rb and paste this code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
module Zucy class Application attr_reader :routes def initialize @routes = Routing::Router.new end def call(env) @request = Rack::Request.new(env) route = mapper.map_to_route(@request) if route response = route.dispatch return [200, { "Content-Type" => "text/html" }, [response]] end [404, {}, ["Route not found"]] end def mapper @mapper ||= Routing::Mapper.new(routes.endpoints) end end end |
Commit your changes
1 2 |
$ git add --all $ git commit -m "update application class to use the new routing scheme" |
6.0 Integration test.
Let’s modify our integration test to make use of the new routing.
Open spec/zucy_spec.rb and replace app method with this code.
1 2 3 4 5 |
TodoApplication = Todolist::Application.new def app require "todolist/config/routes.rb" TodoApplication end |
Open spec/todolist/config/routes and paste this code.
1 2 3 4 5 6 7 8 9 10 |
TodoApplication.routes.draw do get "/todos", to: "todolist#index" get "/todo/:id", to: "todolist#show" get "/todo/new", to: "todolist#new" get "/todo/:id/edit", to: "todolist#edit" post "/todo/:id", to: "todolist#create" put "/todo/:id", to: "todolist#update" patch "/todo/:id", to: "todolist#update" delete "/todo/:id", to: "todolist#destroy" end |
Also open spec/todolist/controllers/todolist_controller.rb and paste this code.
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 |
class TodolistController def initialize(request) @request = request end def index "['Write a book', 'Build a house', 'Get married', 'Buy a car']" end def show "Write a book" end def create "Post go swimming" end def update "Put Write a book" end def destroy "Delete Write a book" end end |
Run your test again using
1 |
$ rspec |
All test should be passing at this point.
Commit your changes
1 2 |
$ git add --all $ git commit -m "update integration tests to use the new routing scheme" |
With this, we have built a better routing DSL. However we still have a long way to go. Keep calm and wait for the next post.
The source code for this post is available on github.
In the next post, we will be focusing on controllers and we will see how to render a views, redirect to another route etc.

- Ruby on Steroids(DSLs): The Powerful Spell Called DSL - March 14, 2016
- Ruby on Steroids: The Magic of MetaProgramming – Method Spells - March 12, 2016
- Ruby on Steroids: The Magic of MetaProgramming – Fellowship of Spells - February 28, 2016
- Ruby on Steroids: The Magic of MetaProgramming – An Unexpected Journey - February 27, 2016
- How to Delegate Like a Boss - February 24, 2016
- Better Routing – Part 4 - January 28, 2016
- Autoloading and Utility Methods – Part 3 - January 11, 2016
- Set up a Basic MVC Framework – Part 2 - December 22, 2015
- Rack Deep Dive – Part 1 - December 17, 2015