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:
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:
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:
It means that when all middlewares are executed, Rack will run another small application to which instance points the
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
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)
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:
req variable contains the instance of
ActionDispatch::Request class which represents the HTTP request. The
@router variable contains the instance of the
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
The routes mapper
Since the routes configuration block is executed in the context of the
Mapper class, it simply means that methods like
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.