This is the second article of the series where I build a simple Ruby web application with Rack.
In the previous post I intruduced Rack and described what it is, what problems it solves and its relationship with Rails. I then started to build a very simple Rack application, with the aim of evolve it into a naive web framework.
Today, I’m going to continue where we left off and add routing and controllers to handle requests for different RESTful paths.
Simplifications and constraints
Before diving into the code, I must repeat what I wrote in the first post. This started as a learning exercise, and the goal here is help understand what happens under the hood of a web application framework like Rails.
I don’t think that it would be useful to optimize for performance or try to cover all corner cases. In fact, I’ll try to write intention revealing code that is easy to understand, and will not worry about using underperformant techniques if they can make the implementation simpler or more expressive.
Also, I’ll set some constraints to what our Rack application will do and accept. For example, implementing a routing DSL like the one used in Rails is out of scope, and in this post I’ll only add support for a subset of the usual RESTful routes.
Router and controllers
At the end of the first post, our application structure was still made of two simple files: a ruby file with the application object and a rackup file to bootstrap the Rack app:
1 2 3 | rack_demo/ ├─ application.rb └─ rack_demo.ru |
To implement routing we’ll have to add, well, a router, plus controllers to route the requests to. We’re going to have these files live inside an app/
directory:
1 2 3 4 5 6 7 8 | rack_demo/ ├─ app/ │ ├─ controllers/ │ │ ├─ base_controller.rb │ │ └─ dogs_controller.rb │ └─ router.rb ├─ application.rb └─ rack_demo.ru |
Please note that we are going to use a base_controller
that will implement all functionality which we expect will be useful for any concrete controller, for example methods to generate a response or access the request parameters. The imaginative name for our actual controller is dogs_controller
because we’re going to work with dogs.
Rack requests
At the end of part one we isolated our application object as an instance of Application
, which is passed to Rack’s run
method when the server starts. For each HTTP request Rack will send it the call
message with the Rack environment as the argument env
.
As we saw in the previos post, env
is a big hash containing all the info received with the request. Luckily, Rack comes with a handy class to wrap env
hashes with Rack::Request
objects, which provide a high level interface to extract information like the request method, the headers and the parameters. I recommend checking its implementation to understand how the data of an HTTP request gets processed and presented in Rack applications.
As we can see, the Rack::Request
class provides a lot of the functionality found in Rails’ ActionDispatch::Request
, which is in fact a direct subclass.
Since we have no need to extend it we will use it directly.
Request routing
Armed with this new tool, we can expand the simple application.rb
file we wrote in part one. What about this:
1 2 3 4 5 6 7 8 9 10 11 12 13 | app_files = File.expand_path('../app/**/*.rb', __FILE__) Dir.glob(app_files).each { |file| require(file) } class Application def call(env) request = Rack::Request.new(env) serve_request(request) end def serve_request(request) Router.new(request).route! end end |
Let’s look at the new parts.
At the top we’re requiring the other files of the application. Everyting will live inside app/
, thus we can load everything recursively.
The call
method now instantiates a new request
object, and we’ve hidden the details of producing a Rack response inside the serve_request
method. In there, we’re further delegating the heavy-lifting to the router. The Application
only needs to know about the interface to the router and that route!
will return a valid Rack response.
For our first iteration on the router we implement the bare minimum that could possibly work.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class Router def initialize(request) @request = request end def route! if @request.path == "/" [200, { "Content-Type" => "text/plain" }, ["Hello from the Router"]] else not_found end end private def not_found(msg = "Not Found") [404, { "Content-Type" => "text/plain" }, [msg]] end end |
The root path will receive a successful (200) plain text response (notice the Content-Type
header), everything else will get a 404.
This apparently simple distinction is quite important. It is semantically correct and the first step to make our app respond coherently to requests. Bear with me for a minute and let me explain with a quick example.
Accessing the Rack app with a browser will cause the browser to automatically request the file /favicon.ico
, because browsers will try to get that file automatically. You can see the requests in the Rack logs in the terminal or in the developer tools of the browser. Browsers will cache the initial response, so you might have to empty the cache (or restart the Rack app on a different port) to ensure that the browser requests it again.
Notice how, without the router, requests for the non existent favicon file receive successful 200 responses from our app. Those responses do not contain the favicon file, but the "Hello Rack!"
response bodies returned by the initial version of the app.
What happenes is that the client (the browser) requests a specific resource (the favicon) and the server responds with something else entirely (text data instead of image data) and pretends that everything is fine (200 status code). That’s no good.
This is why returning 200 (and a body) for the root path and 404 for everything else is important. It means that that the Rack app is behaving like a proper web application. After all, standards are what keeps the internet together.
Dissecting the path
So far, our router is not really doing much. We can improve it by analyzing the requested path and extracting some useful info on where the request should be routed to.
As we said, we’re not going to accept just any path. We’re going to only work with RESTful routes, and only with the ones that adhere to the following Rails-inspired convention:
1 2 3 4 | GET /resource # index - get a list of the resources GET /resource/:id # show - get a specific resource GET /resource/new # new - get an HTML page with a form POST /resource # create - create a new resource |
The first step, then, is to teach the router to recongize requests by looking at their path and HTTP method. We can do it by adding these private methods to our router:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class Router # ... def route_info @route_info ||= begin resource = path_fragments[0] || "base" id, action = find_id_and_action(path_fragments[1]) { resource: resource, action: action, id: id } end end def find_id_and_action(fragment) case fragment when "new" [nil, :new] when nil action = @request.get? ? :index : :create [nil, action] else [fragment, :show] end end def path_fragments @fragments ||= @request.path.split("/").reject { |s| s.empty? } end end |
There, route_info
would return a hash with the “routing coordinates” of the request.
The resource name will always be found it the first path component. The second path component will be interpreted according to the HTTP method of the request. If the request is for the naked domain, the resource value will be set to base
because we’re going to route it to the BaseController
for convenience.
The code above means that, for example, a request for GET /dogs
will be routed to:
1 | { resource: "dogs", action: :index, id: nil } |
and a request for GET /dogs/42
will be routed to:
1 | { resource: "dogs", action: :show, id: "42" } |
The next step is to use this information to get the proper controller. This can be done with:
1 2 3 4 5 6 7 8 9 10 11 12 | class Router # ... def controller_name "#{route_info[:resource].capitalize}Controller" end def controller_class Object.const_get(controller_name) rescue NameError nil end end |
And with that in place, we can put all the pieces together and rewrite the Router#route!
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class Router # ... def route! if klass = controller_class add_route_info_to_request_params! controller = klass.new(@request) action = route_info[:action] if controller.respond_to?(action) puts "\nRouting to #{klass}##{action}" return controller.public_send(action) end end not_found end private def add_route_info_to_request_params! @request.params.merge!(route_info) end # ... end |
Let’s begin by explaining the intended behaviour: if the requested path can be matched to something that the application understands try to generate a response, else, respond with a 404 “not found”. There, something that the application understands means:
- the path must conform to a pattern that we support, and
- it must match a controller available in the application, and
- it must match an action implemented on that controller.
Point 1 and 2 are taken care of by the Router#controller_class
method, because it will return nil
if the requested path does not reference a supported resource, or a controller class if it does.
Point 3 is verified with controller.respond_to?(action)
, on line 10. There we verify if the requested action is available on the selected controller.
Next, notice how we’re enriching the request object’s parameters with the extracted routing info on line 5, (normally @request.params
would contain data from the query string or from a submitted HTML form).
Done that, we initialize an instance of the controller with the enriched request object (the controller will need access to the request data) and, if the action is implemented, we send the controller a message and return the result immediately.
If any of the above fails, the last line of the method is executed and a 404 response is retuned.
This is a good moment to step back and look at what will happen next in the request lifecycle.
The Application
object we saw above is where we passed the request data to the Router
to obtain a Rack response value. The return value of Router#route!
, either generated by a controler or the default 404, is hence returned to the Application
object, and passed back to Rack to produce an HTTP response.
The Controllers
So far, we’ve been moving from the outside in: we started by modifying our Application
object to use a Router
, and while doing so we wrote code against a then hypothetical routing interface. When we moved one layer deeper to implement the Router
, we made assumptions about still hypothetical controllers.
It is now time to move to this last bit of the response handling chain, with the confidence that, as long as it returns a valid Rack response array, things should just work.
Let’s start with a base class for all our controllers, which implementes common functions that we would expect to have available in the controllers of a Rails-like framework:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class BaseController attr_reader :request def initialize(request) @request = request end private def build_response(body, status: 200) [status, { "Content-Type" => "text/html" }, [body]] end def redirect_to(uri) [302, { "Location" => uri }, []] end def params request.params end end |
We want to initialize controller instances with the current Rack::Request
object and, to hide this detail from the more concrete subclasses, we’re exposing it with a request
getter method (line 2). We also want to provide a convenient shortcut to the request parameters, and that’s why we wrap them with the params
method (line 18).
The build_response
and redirec_to
methods are what we’ll use to generate the actions’ return values. Rather than exposing the ability to customize the response HTTP headers we’re abstracting away from them with higher level methods: a redirect will always have the Location
HTTP header and, for the moment, all our response bodies will be text/html
. Also notice how the build_response
method will allow us to customize the response status code which otherwise defaults to 200.
With these very basic building blocks in place, we can add BaseController#index
, which will be used to render the home page of the application. Since now we’re properly declaring the response body as text/html
, we can finally use structured HTML markup instead of plain strings:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class BaseController # ... def index build_response <<~HTML <html> <head><title>A Rack Demo</title></head> <body> <h1>This is the root page</h1> <p>Hello from a controller!</p> </body> </html> HTML end # ... end |
With this, we’ve finally got enough pieces in the right place to test our stack.
Restart the Rack server in the console and visit the root path. Without any CSS it will looks a bit ugly, but the rendered root page should match the HTML we just added in the last code snippet.
Good, time to move to the DogsController
. With all the infrastructure we’ve built so far it should be extremely easy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | require_relative './base_controller.rb' class DogsController < BaseController # GET /dogs # def index build_response dog_page("this should be a list of dogs") end # GET /dogs/:id # def show build_response dog_page("this should show dog ##{params[:id]}") end # GET /dogs/new # def new build_response dog_page("a page to create a new dog") end # POST /dogs # not implemented for now # def create redirect_to "/dogs" end private def dog_page(message) <<~HTML <html> <head><title>A Rack Demo</title></head> <body> <h1>This is DogsController##{params[:action]}</h1> <p>#{message}</p> </body> </html> HTML end end |
Done. That was it, really.
Try and visit any of the expected paths to check the responses. We can also verify that the create
action correctly redirects with:
1 2 3 4 5 6 | $ curl -X POST -I localhost:9292/dogs HTTP/1.1 302 Moved Temporarily Location: /dogs Transfer-Encoding: chunked Connection: close Server: thin |
What all of this means
Our controllers don’t do much at the moment. We have been talking about listing, showing and creating dog resources, but we don’t have any yet. That will come later.
For the moment, however, we have built a scaffolding that allows us to expand the application easily, and with reusable patterns. For example, it would be very easy now to add a new AlpacasController
which would just work the way we expect it to. In other words, we’ve been laying the groundwork for building a framework.
Wrap up
With this we conclude parth two. In the next post we will pick up where we left off and expand the controllers with a view templating system.