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.
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
Let’s Build a Web Application in Ruby without 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 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.
💡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 foundmessage.
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.
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.
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:
- I've made the
Routerclass a Singleton so we always have a single instance to work with. Refer theSingletondocumentation to learn more about how it works. In short, the Singleton pattern ensures that a class has only one globally accessible instance. - Added a
drawmethod on theRouterclass so we could call it asRouter.draw. This is a syntactic sugar to mimic Rails. - The
drawmethod accepts a block and executes that block in the context of the instance of theRouterclass. This is exactly what the Rails Router does. Refer to theinstance_execdocumentation 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 theenvhash in theapp.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
paramsobject - 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)
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?
Yeah, it's Excalidraw: https://excalidraw.com/