Build Your Own Router in Ruby

Build Your Own Router in Ruby

March 31, 2024

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.

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:

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

💡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

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

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['PATH_INFO'])

[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 when the application boots up, and is never executed 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.

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

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. In fact, we can remove the whole constructor!

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['PATH_INFO'])

[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.

We could stop here, but 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['PATH_INFO']
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['PATH_INFO']}"
"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 the course (currently in beta), 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...

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

If you've liked what you've seen so far, and would like to enroll in the course, you can do so here: Rails Companion

Sign up for my newsletter

Let's learn to become better developers.

Comments (2)

B
Ben

This has nothing to do with the actual post - nevertheless - am curious to know how you drew the "Router" diagram - which shows the path of an incoming request? any particular software that you used?

A
Akshay Khot

Yeah, it's Excalidraw: https://excalidraw.com/

Sign in to leave a comment.