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 the third article in the series on building a web application in Ruby without Rails, we'll build our own router to get a deeper understanding and appreciation of the Rails Router.

10 min read

This is the third article in the series on building a web application in Ruby without Rails.

In this post, 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

Note: If you haven't read them already, check out the first and second articles:

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.
Let’s Build a Webapp in Ruby without Rails: Serve Static Files and Render Dynamic Views
This is the second article in the series where we build a simple, yet complete web app in plain Ruby, without using Rails to better understand how Rails works. In this article, we’ll learn how to serve static files and use the ERB gem to render dynamic views. All in plain Ruby, without using Rails.

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 subscribing to the blog.

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. 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.
Rails Router
Rails Router

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. To keep things simple, I've removed the additional code to serve dynamic views that we added in the previous article.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response = ['<h1>Hello World!</h1>']
    
    [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 three assumptions:

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

Let's get to it.

The Router Class

Let's create a new router.rb file in the current directory with the following code.

# 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

The Router class maintains a @routes Hash as the internal data structure to store the URL patterns along with their corresponding handlers.

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.

# weby/app.rb

require_relative './router'

class App
  attr_reader :router

  def initialize
    @router = Router.new

    router.get('/') { "Akshay's Blog" }
    
    router.get('/articles') { 'All Articles' }
    
    router.get('/articles/1') { "First Article" }
  end

  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response_html = router.build_response(env['REQUEST_PATH'])
    
    [200, headers, [response_html]]
  end
end

Note that we're setting up the router and initializing the routes in the constructor, and not in the call method. This is important. The constructor is called at the beginning, and never again. In contrast, the call method is invoked every time a new request comes in. We want to initialize the router only once, at the beginning.

And we're done. Restart the application, and be prepared to be amazed.

the articles route

Other routes work as expected, too.

➜ curl localhost:9292
Akshay's Blog

➜ curl localhost:9292/articles
All Articles

➜ curl localhost:9292/articles/1
First Article

➜ curl localhost:9292/articles/x
no route found for /articles/x

➜ curl localhost:9292/random-url
no route found for /random-url

We have a functioning router implementation. Woohoo!

But It Doesn't Look Like Rails!

At the start of the post, I promised the following router API that looked similar to what we have in Rails.

Rails-like Router

The current implementation doesn't look like that at all!

I know, I know. I decided to build the simplest router first as jumping directly in the above implementation would've meant compressing a lot of things together. But now that we have a simple and functioning router, we can refactor and improve it to make it more Rails-like in three simple steps:

Step 1: Refactor the Router

Let's make a few small changes to the router, so it looks like this.

# weby/router.rb

require 'singleton'

class Router
  include Singleton  # 1

  attr_reader :routes

  class << self
    def draw(&blk)  # 2
      Router.instance.instance_exec(&blk)  # 3
    end
  end

  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

Three important things to note:

  1. I've made the Router class a Singleton so we always have a single instance to work with. Refer the Singleton documentation to learn more about how it works. In short, the Singleton pattern ensures that a class has only one globally accessible instance.
  2. Added a draw method on the Router class so we could call it as Router.draw. This is a syntactic sugar to mimic Rails.
  3. The draw method accepts a block and executes that block in the context of the instance of the Router class. This is exactly what the Rails Router does. Refer to the instance_exec documentation to learn how it works.

Now, let's put this Router class to good use by creating the routes in a separate file.

Step 2: Create the Routes

In the spirit of Rails, let's create a config/routes.rb file so we have a Rails-like structure. This file contains our application routes.

# config/routes.rb

require_relative '../router'

Router.draw do
  get('/') { "Akshay's Blog" }
  
  get('/articles') { 'All Articles' }
  
  get('/articles/1') { "First Article" }
end

Note that we're calling the draw method on the Router and passing a block. Ruby will execute this block (code within do..end above) in the context of the instance of the Router. Hence, the self object in that block will be the Router.

The above code is similar to:

router = Router.new

router.get('/') { "Akshay's Blog" }

# and so on...

Don't you just love metaprogramming in Ruby?

If you're curious to learn more about metaprogramming, check out the following article.
Metaprogramming in Ruby
Metaprogramming in Ruby enables you to produce elegant, clean, and beautiful programs as well as unreadable, complex code that’s not maintainable. This book teaches you the powerful metaprogramming concepts in Ruby, and how to use them judiciously.

There's only one thing remaining. Use those routes!

Step 3: Update the Application to use New Routes

Since we have defined the routes elsewhere, we don't need them in the application constructor. Let's remove them.

Here's the new app.rb file. Much cleaner, right?

# weby/app.rb

require_relative './config/routes'

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response_html = router.build_response(env['REQUEST_PATH'])
    
    [200, headers, [response_html]]
  end

  private
    def router
      Router.instance
    end
end

Note that we're using the Router.instance method added by the Singleton module to get the Router's instance.

That's it. Restart the application and give it a try. Everything should work as expected.

There's one small improvement we can do to make our Router more flexible and powerful.

Let's Pass the HTTP Environment to Routes

Right now, our routes cannot access the incoming request, i.e. the env hash. It would be really nice if they could use env to add more dynamic behavior. For example, you could access the cookies and session data in your handlers via the HTTP request.

Let's fix it.

💡
Before reading the next section, can you try implementing this on your own? We have the env hash in the app.rb. How would you pass it to the handlers?

All we need to do is pass env to the Router and then further pass it to the handlers when we call it. Here's the changelog.

# app.rb

response_html = router.build_response(env)

# router.rb

def build_response(env)
  path = env['REQUEST_PATH']
  handler = @routes[path] || ->(env) { "no route found for #{path}" }
  handler.call(env)  # pass the env hash to route handler
end

# config/routes.rb

get('/articles/1') do |env| 
  puts "Path: #{env['REQUEST_PATH']}"
  "First Article"
end
If you're curious how the above code works, especially how the handlers remember the env hash, read this: capture variables outside scope.

Now all our route handlers have access to the incoming HTTP request. Later, we'll wrap this env hash into a dedicated Request class for exposing a better API, just like the ActionDispatch::Request class in Rails.

That's a wrap. In future articles in this series, we'll explore the following topics.

  • Introduce controllers, models, and views, just like Rails!
  • Improve the project structure and organization
  • Add unit tests
  • Handle errors and logging
  • Process form inputs along with query strings into a params object
  • Connect to the database to store and fetch data
  • Add middleware to handle specific tasks like authentication
  • and much more...

If those sound interesting to you, consider subscribing to the blog.

Trust me, it's going to be a lot of fun, so stay tuned!!


I hope you liked this article and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.