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:

  1. the path must conform to a pattern that we support, and
  2. it must match a controller available in the application, and
  3. 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.