Breaking down the Rails request cycle
Discover the process of request handling in Rails
When we talk about the request that is coming to the Rails application, we usually consider two elements of the application: routes configuration and controller action. The route configuration decides which controller action should be triggered when the given request comes.
However, there is a lot more than that. Knowing the request cycle’s nuances allows us to understand the Rails application’s architecture better and extend it when needed. This article is a journey from a moment when the visitor submits the website address to a moment when a view in your Rails application is rendered. Jump on board!
All my notes are based on years of hands-on experience at iRonin.IT - a top software development company, where we provide custom software development and IT staff augmentation services for a wide array of technologies.
From the domain name to the server
To access the website, we are using domain names rather than IP addresses because it’s easier to remember them, and in most cases, the domain name is part of the company’s or person’s brand.
Domain Name System (DNS)
Each time you type the domain name into the browser bar and hit the enter key, the request to the Domain Name System (DNS) is sent. The DNS helps our computers to translate the domain name to the proper IP address under which the webserver is available. You can treat the IP address as a telephone number. Our browser can now “call” the number, and the server will respond.
HTTP and HTTPS protocols
Most likely, if the person that is calling and the person who is picking up the phone doesn’t talk in the same language, they won’t understand each other. The same applies to the browser and the server. Human beings can use languages like English, Spanish, etc., while it was agreed that HTTP will be the language that both the server and browser will understand.
HTTP and HTTPS are the same protocol, but the main difference is that with the safer version, the message we send from the browser to the server is encrypted, so nobody between those two points will be able to read it.
Now that we know how the request gets to the server and is understood by the server, it’s time to see how it gets processed and passed to the application.
The file in the Rails application that is triggered as the first
Have you ever wondered which file in the Rails application is executed first when the request comes to the server? It’s the file named config.ru
. This information reveals to us the truth behind processing the requests in our application. Let’s take a closer look at this file.
Rails application’s entry point
The config.ru
consists of two lines of code:
require_relative 'config/environment'
run Rails.application
The first line loads the config/environment.rb
file, and in the second line, we are passing the instance of our application to the run
method that is available out of the box. If you named your application as Guestbook, then the instance of your application would come as Guestbook::Application
.
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.
The simple web application with rack
If we would like to build a straightforward application that would imitate the Rails application and run it on Rack, we would have to write the following code:
# imitation_of_rails.rb
class ImitationOfRails
def call(env)
[200, {'Content-Type' => 'text/plain'}, ['Hello from every path']]
end
end
and we can now replace the contents in the config.ru
file:
# config.ru
require_relative 'imitation_of_rails.rb'
run ImitationOfRails.new
The rack gem comes with a web server called rackup, so you can execute the following command to start the server and see if our “imitation” of Rails works:
rackup
Of course, you can now extend our simple application and display different responses for different paths, but this part is just a topic for another article.
Rack and Rails
Now as we understand how Rack is working and how the application can use it, we can go back to Rails. Remember what we passed to the run method? It was Rails.application
instance. So as we know now, it implements the call
method so it can work with Rack. Let’s test it in the console:
2.7.0 :001 > Rails.application.call({})
Traceback (most recent call last):
1: from (irb):1
NoMethodError (undefined method 'start_with?' for nil:NilClass)
We end up with an error because we passed a blank environment hash, which is never the case. The application always receives the environment information containing a path, the request method, and other information.
We don’t have to build our env hash from the bottom; the Rack provides a nice method to produce a hash for any path in our application:
2.7.0 :001 > env = Rack::MockRequest.env_for('/')
2.7.0 :002 > Rails.application.call(env)
You can try executing the above code in the rails console inside a Rails project, and you will receive an output that is passed to the server when the request for the given path is made.
In the middle of the request process
We are in the middle of the request process - the server accepted the request and passed it to the application. It’s a perfect moment to mention the middleware.
The middleware, as the name suggests, is a code that is executed in the middle. Let’s modify our “imitation of Rails” application to demonstrate the simple middleware in action.
In the same directory you created imitation_of_rails.rb
and config.ru
files, create new file called hello_middleware.rb
with the following code:
class HelloMiddleware
def initialize(app, path:)
@app = app
@path = path
end
def call(env)
if env['PATH_INFO'] == @path
[200, {'Content-Type' => 'text/plain'}, ['Hello from middleware!']]
else
@app.call(env)
end
end
end
We would like to render a different message when the configured path is requested. Now it’s time to edit the config.ru
file and let Rack know that we want to have the custom message rendered when /home
is the requested path:
require_relative 'imitation_of_rails'
require_relative 'hello_middleware'
use HelloMiddleware, path: '/home'
run ImitationOfRails.new
You can execute the rackup command and see the results.
Rails middleware
Rack uses the run
method to run the application and use
method to run middleware - does it mean that Rails is not using any middleware if the config.ru
file does not contain any use
instructions? No. Rails is calling middlewares differently.
You can see the list of middlewares used by Rails by using the following command:
bin/rails middleware
The list is quite long. One of the middlewares that are shipped with Rails by the default is ActionDispatch::RequestId
. If we would look into the source of that class, we would see that it implements the call
method that accepts the env hash - just like Rack expects the middleware class to implement.
The initializer of the class also accepts the header argument. If this argument is passed, it looks for this header values as a request id; otherwise, it generates a unique string using SecureRandom.uuid
method.
The last position on the middlewares list is Guestbook::Application.routes
(if you named your application as Guestbook) - this is where our application is triggered.
Routes
Because the run
method is invoked on Guest::Application.routes
instance, this class implements the call
method. Let’s take a closer look at it.
Guest::Application.routes
returns instance of ActionDispatch::Routing::RouteSet
class. This class is responsible for matching the request path with routes defined in the config/routes.rb
file.
When the route is matched, the new instance of the controller is created, and the action method is invoked. Before it happens, the request and response objects are saved because they must be available in any place of the controller.
After the controller’s action is executed, the flash message is set if needed, and the response array is returned.
Controller’s middleware
We already discussed the middleware, but since we are talking about the controller structure, it is worth mentioning that each controller can have its list of middlewares.
The same rules apply as before - our middleware class has to implement the call
method that takes the env hash as an argument, and the initialize
method has to accept the app argument and optional arguments for configuration purposes:
class HelloMiddleware
def initialize(app)
@app = app
end
def call(env)
# do something or call @app.call(env)
end
end
You can now call the use method inside the given controller:
class UsersController < ApplicationController
use HelloMiddleware
def index
# list users
end
end
Such an approach gives us more granular control over middlewares that are triggered during the request process. You don’t have to add any conditionals to the middleware definition, as you can simply use middleware per controller if needed.
A moment before rendering
I mentioned that before the action is rendered, response and request object are saved to be accessible in the controller. It happens in the dispatch method from the ActionController::Metal
class:
def dispatch(name, request, response)
set_request!(request)
set_response!(response)
process(name)
request.commit_flash
to_a
end
The most exciting part of this method is the execution of the process
method. This method is also defined in multiple ancestors from our controller class.
If you would like to list all ancestors for the given controller, you can do it via UsersController.ancestors
.
Rendering
The final step in the request cycle is the rendering of the view for the action. The result of rendering is stored in the response.body
variable.
As you may remember, the response array contains three objects: response code, response body, and headers. The content-type header depends on the kind of view, and the response code depends on whether the given action was found and the request was accepted. The rendering itself is a larger topic and deserves its article so that I won’t go more in-depth right now.
The response cycle
As soon as the request cycle ends, the response cycle starts so the server can deliver the response to the browser.
As you can see, the request cycle is heavily based upon Rack. The root application object is a rack app, but controllers behave a little bit differently. Action methods do not return a response directly; they mutate the response object, which is later converted into a Rack response array.