Rails under the hood: Routes

Routes engine is the core part of every Rails application. Thanks to the config/routes.rb file, we can easily define the application’s routes using special DSL. Let’s take a closer look at the coder under the hood to understand a bit of Rails’ magic.

The main entry point for routes is the instance of ActionDispatch::Routing::RouteSet class accessible via Rails.application.routes configuration variable.

The road to the routes

Every Rails application is based on the Rack. When the request comes to your server, the config.ru file is executed first. When you open it, you will notice that it’s straightforward:

require_relative "config/environment"
 
run Rails.application

The environment data is loaded, and then the Rails application is passed to the run method. Then the run method is provided by Rack - API for Ruby frameworks to communicate with web servers. Rack informs the web application about the request using the call method.

The call method implemented by the web application has to accept one argument, the env hash, and has to return an array with three elements - status, headers, and response body.

Middlewares in action

In simple words, we can say that when the Rails.application is passed to the run method, a set of middlewares are executed. The middleware is a code that is performed between the request and response.

In Rails, you can check the list of mounted middlewares by running the following command:

bin/rails middleware

Some of the middleware is responsible for parsing cookies, and the other one is responsible for writing logs or checking for migrations that were not yet run. At the end of the list, there is the following item:

run YourApplication::Application.routes

It means that when all middlewares are executed, Rack will run another small application to which instance points the YourApplication::Application.routes variable.

Routes rack application

As I mentioned before, a simple web application served with Rack has to implement the call method, which will accept one argument, the environment hash, and return an array with three elements - status, headers, and response body.

The routes instance also provides that method. When it’s called, three things happen in the following order:

  • The request object is created
  • Path information is saved to the request object
  • The router is serving the request

Let’s take a deep dive into every one of those steps to see how routing is working under the Rails hood.

Create request object

The ActionDispatch::Request class is responsible for parsing the request and it accepts one argument:

def make_request(env)
  ActionDispatch::Request.new(env)
end

The env argument contains the information about the incoming request, including a bunch of HTTP_ headers, rack headers, and server configuration. It also includes other values set by the middlewares or gems. Depending on the size of the Rails application, the env hash can contain dozens to hundreds of keys.

If you would like to play with the request class in the console, Rack provides a nice way to mock the request environment data and pass it as a normal request:

env = Rack::MockRequest.env_for('/')
request = ActionDispatch::Request.new(env)

Normalize path

When the request object is created, the request path is updated via the Journey::Router::Utils helper and normalize_path method. The method removes the / suffix if present and ensures that the proper encoding is set on the path.

Such an updated path is then added again to the ActionDispatch::Request object and passed to the router.

Serve the path with the router

The last step is to trigger the router with the request object we created and updated in the previous steps:

@router.serve(req)

The req variable contains the instance of ActionDispatch::Request class which represents the HTTP request. The @router variable contains the instance of the Journey::Routes class.

When the router is triggered, the routes are already loaded, and it’s possible to match the correct route. I need to take a step back to show you how routes configuration is parsed and loaded, so it’s possible to use them when the request came.

Loading routes configuration

If would open the config/routes.rb file, you will notice that the routes are configured inside the Rails.application.routes.draw block. The draw method comes from the ActionDispatch::Routing::RouteSet class and simply eval the passed block in the context of the ActionDispatch::Routing::Mapper class.

The routes mapper

Since the routes configuration block is executed in the context of the Mapper class, it simply means that methods like get, post, or resources are defined in that class. The question is: what happened when one of those methods is executed?

The route configuration

The route definition and the request type, and any additional params are passed to the mapper method. The main job of that method is to parse the configuration params whether a string or hash is passed.

The next step is to validate the parameters against the possible options and correct formats. When the data is valid, the AST node is created for the path.

The AST stands for Abstract Syntax Tree, and it’s used to analyze the given structure according to the defined and specific grammatic rules. The Rubocop gem also utilizes AST nodes to parse the code syntax. It’s a more advanced topic that I won’t cover in this article.

The last step of the configuration process is to get the AST node and the configuration and add it to the add_route method from the ActionDispatch::Routing::RouteSet class. That method shows some deprecations information depending on the set of params passed. It saves the route configuration in ActionDispatch::Journey::Routes, an enumerable used later to find a proper route for the incoming request.

That way, we came back again to the place where routes are matched against the path from the request. The AST parsing is done, and the matching route is selected so the request can move to the controller.

The next part of the journey

When the proper controller and action is selected for the request, the serve method from ActionDispatch::Routing::RouteSet::Dispatcher is called along with the request object.

At that point, the request object contains the controller class. The make_response! class method is invoked on the controller class to create a response object that later will be updated with the information that should be returned to the Rack server.

This part is important information from the request-response cycle. The response is not returned directly, but the response object is mutated:

def self.make_response!(request)
  ActionDispatch::Response.new.tap do |res|
    res.request = request
  end
end

When the response object is initiated, the next step is to invoke the dispatch method on the controller class.

The controller in the action

At this point, the routes part is done, and the job is on the controller side. The controller’s instance is created, and any middlewares defined for that specific controller are now triggered.