Build Your Own Router

Build Your Own Router in Ruby

Have you always wondered what a Router is and how it works? I know I have. In this second bonus post in my Rails Companion course, we'll build our own router in Ruby to get a deeper understanding of the Rails Router. We'll also use some meta-programming to make it look just like the Rails router.

11 min read

This is the second bonus post in my course Rails Companion: build a web application in Ruby without Rails. Today, we'll learn how routing works in its essence and build our own router from scratch, without using any third-party gems. We'll use some metaprogramming, but I promise it's nothing complicated.

Our router will look somewhat similar to Rails. It will accept a URI and a block, providing a very simple and expressive method to define routes and behavior without complicated routing configuration.

Router.draw do
  get('/') { 'Hello world' }
end

The only difference is this: instead of directing to a resource, or a controller#action like Rails router, our router returns a plain string response, just like Sinatra.

By the way, if you haven't read them already, check out the course announcement and the first bonus article in the course:

Rails Companion: Build a Web App in Ruby Without Rails
In this course, we’ll build a web application in Ruby from scratch, without using Rails, to understand how web applications work and the core ideas behind Rails. In each lesson, we will build a specific feature from scratch using Ruby and understand the corresponding Rails concept in-depth.
Let’s Build a Web Application in Ruby without Rails
Rails is great for building web apps. But it can be quite overwhelming if you don’t know how web applications work. In this series of articles, we’ll build a simple but complete app in plain Ruby without Rails, to get a deeper understanding and appreciation of everything Rails does for us.

In future articles in the series, we'll implement our own controllers and models, process forms and query data, connect to the database, write our own authentication, and much more. All in plain Ruby, too! If that sounds interesting, consider enrolling in the course.

Finally, if you don't want to read the full article (>2000 words) and just want to see the final code, check out the GitHub repository and switch to the rails-router branch.

Alright, let's start by understanding what a router is and what it does, in the context of a web application.


What is a Router?

A router is that part of the web application that determines where the incoming request should go. It figures it out by examining the request URL and then invoking a pre-defined function (or a handler) for that path. Internally, it stores the mapping between the URL patterns and the corresponding handlers.

In Ruby on Rails, the router sends the incoming request to an action method on a controller.

Rails Router
Rails Router
💡
In the next post, we'll build our own controllers and modify our router (that we'll build today) to dispatch the request to the controller#action code.

To learn more about the Rails Router in-depth, please read the following post:

Understanding the Rails Router: Why, What, and How
The router is the entry point of your Rails application. It acts as the gatekeeper for all incoming HTTP requests, inspecting and sending them to a controller action; even filtering and rejecting them if necessary. In this article, we’ll do a deep dive into the Rails Router to understand it better.

In fact, it would be better if you first read the above article and then come back to this one. That will give you a much better context to understand this post. Don't sweat it, though! I assume no prior knowledge of routing to understand this post.

Current Setup

If you've read the first article in the series, you should have the following script, which is a barebone, simple-yet-complete web application.

# # weby/app.rb

class App
  def call(env)
    # a hash containing response headers
    headers = {
      "Content-Type" => "text/html"
    }

    # an array containing the HTML response string
    response = ["<h1>Hello World</h1>"]

    # an array containing
    # 1. HTTP status code
    # 2. HTTP headers 
    # 3. HTTP response
    [200, headers, response]
  end
end

Right now, our application does only one thing. Whenever a request arrives, it returns the response Hello World! to the browser. It sends the same response, regardless of the request URL.

➜ curl localhost:9292
<h1>Hello World</h1>

➜ curl localhost:9292/posts
<h1>Hello World</h1>

➜ curl localhost:9292/posts/new
<h1>Hello World</h1>

Having a web application that returns the same response for every request isn't very exciting... or useful! Let's make it smart by returning a different response based on the incoming HTTP request's path.

To keep things simple, I'll make the following assumptions:

  • Our application only supports HTTP GET requests,
  • It only needs to handle the following three routes.
URL Pattern Action
/ shows the home page
/articles shows all articles
/articles/1 shows a single article
/random "no route found"
  • For all other URLs, it returns no route found message.

Let's get to it.

The Router Class

Let's create a new Router class in the current directory as follows. It maintains a @routes Hash as the internal data structure to store the URL patterns along with their corresponding handlers.

# weby/router.rb

class Router
  def initialize
    @routes = {}
  end

  def get(path, &blk)
    @routes[path] = blk
  end

  def build_response(path)
    handler = @routes[path] || -> { "no route found for #{path}" } 
    handler.call 
  end
end
💡
A handler is simply a Ruby block that will be called when our app receives a request to the corresponding path.

If you're not that familiar with blocks and lambdas in Ruby, this code might not make much sense. I recommend you read the following post for a deeper understanding of blocks, procs, and lambdas.

Blocks, Procs, and Lambdas: A Beginner’s Guide to Closures and Anonymous Functions in Ruby
Closures and anonymous functions are very useful concepts, especially in functional programming. Ruby makes working with them a pleasure via its flavors like blocks, procs, and lambdas. This post provides an in-depth tutorial and shows the subtle differences between them.

The get method takes a URL path and a block as arguments. The block represents the handler code that should be executed when a request matching that path arrives. It then stores the path along with the handler in the @routes hash.

def get(path, &blk)
  @routes[path] = blk
end

Finally, the build_response method takes the path of the current request. It finds the corresponding handler from the @routes mapping. If the path is not stored in the mapping, we set the handler to a default lambda that returns the message no route found.

def build_response(path)
  handler = @routes[path] || -> { "no route found for #{path}" } 
  handler.call 
end

Once we find the handler block for the current path, we call it and return the generated output back to the application.

Using the Router

Let's put the Router class to good use.

First, we define the routes that our application needs, and then we use the build_response method on the router to generate the HTTP response.