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.