LaunchSchool - An Online School for Developers /

Blog

Growing Your Own Web Framework With Rack Part 4

In part 3 of this series, we focused on isolating our view related code to a views directory, and moving it out of our main application code. In this post, we’ll continue to separate out the view related code for our other routes, and then finally, we’ll extract some more general purpose methods to a framework. This is the last step in our work to “grow” a web development framework. Let’s get started.

Cleaning up the #call method

There is still one more thing to address regarding our use of ERB within the HelloWorld application. Take a look at our call method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#hello_world.rb

def call(env)
  case env['REQUEST_PATH']
  when '/'
    content = File.read("views/index.erb")
    content = ERB.new(content)
    ['200', {"Content-Type" => "text/html"}, [content.result]]
  when '/advice'
    piece_of_advice = Advice.new.generate
    [
      '200',
      {"Content-Type" => 'text/html'},
      ["<html><body><b><em>#{piece_of_advice}</em></b></body></html>"]
    ]
  else
    [
      '404',
      {"Content-Type" => 'text/html', "Content-Length" => '48'},
      ["<html><body><h4>404 Not Found</h4></body></html>"]
    ]
  end
end

Originally, our call method focused purely on interacting with the request and then handling the response. Now, we have code related to reading in files and setting up a templating object, an object not directly related to the request or response. A good way to clean this up would be to move this code to its own method. Then, we can use this method from within our call method. Let’s give this a try.

First, we’ll invoke a non-existent method to mimic the interface we wish we had, and then we’ll go and implement that method.

Here is an updated call method, using a non-existent erb method that abstracts away the details of how ERB templates are prepared and rendered. We pass that erb method a symbol, signifying which template to render. Line 6 shows this idea in code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# hello_world.rb

def call(env)
  case env['REQUEST_PATH']
  when '/'
    ['200', {"Content-Type" => "text/html"}, [erb(:index)]]
  when '/advice'
    piece_of_advice = Advice.new.generate
    [
      '200',
      {"Content-Type" => 'text/html'},
      ["<html><body><b><em>#{piece_of_advice}</em></b></body></html>"]
    ]
  else
    [
      '404',
      {"Content-Type" => 'text/html', "Content-Length" => '48'},
      ["<html><body><h4>404 Not Found</h4></body></html>"]
    ]
  end
end

We then use the return value from the erb method as our response body. Now, here is the erb method implementation:

1
2
3
4
5
def erb(filename)
  path = File.expand_path("../views/#{filename}.erb", __FILE__)
  content = File.read(path)
  ERB.new(content).result
end

And with our new method, the entire HelloWorld application looks like:

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
# hello_world.rb

class HelloWorld
  def call(env)
    case env['REQUEST_PATH']
    when '/'
      ['200', {"Content-Type" => "text/html"}, [erb(:index)]]
    when '/advice'
      piece_of_advice = Advice.new.generate
      [
        '200',
        {"Content-Type" => 'text/html'},
        ["<html><body><b><em>#{piece_of_advice}</em></b></body></html>"]
      ]
    else
      [
        '404',
        {"Content-Type" => 'text/html', "Content-Length" => '48'},
        ["<html><body><h4>404 Not Found</h4></body></html>"]
      ]
    end
  end

  private

  def erb(filename)
    path = File.expand_path("../views/#{filename}.erb", __FILE__)
    content = File.read(path)
    ERB.new(content).result
  end
end

Notice, that we’re using the method File::expand_path. If you haven’t used this method before, it may look a somewhat esoteric. We’re using this method to obtain the full path to the view template in question. __FILE__ returns the relative path to the current file; in this case, to the file hello_world.rb. We use the current directory of __FILE__ as the starting point to find the full path. This will give us path/to/my_framework/erb_file. From there we navigate up to my_framework and then append /views/#{filename}.erb to obtain the full path to the view template.

Using that helpful erb method results in much cleaner code, and leaves our call method very intentional and clear.

We’re starting to separate the various concerns of our web application. We moved our view templates into a separate “views” directory. We’re trying to keep the call method clear and intentional, and extracted file processing code into separate methods. It’s starting to feel like we are setting the foundations for a more complex web application.

Adding More View Templates

There are two other things we need to implement to get the full web application working: we need a view template for both “advice” and “not found” pages. Below, we create two more ERB view templates for those responses. Just like we did earlier, we’re using special ERB tags so that we can embed Ruby code into our HTML.

views/advice.erb

1
2
3
4
5
<html>
  <body>
    <p><em><%= message %></em></p>
  </body>
</html>

views/not_found.erb

1
2
3
4
5
<html>
  <body>
    <h2>404 Not Found</h2>
  </body>
</html>

Our file structure now looks like this:

1
2
3
4
5
6
7
8
9
10
my_framework/
 ├── Gemfile
 ├── Gemfile.lock
 ├── config.ru
 ├── hello_world.rb
 ├── advice.rb
 └── views/
        ├── index.erb
        ├── advice.erb
        └── not_found.erb

We want to be able to use randomly generated content from the Advice class in a view template, views/advice.erb.

Notice that, as we did earlier, we’ve written the code we would like to see in the advice.erb view template. There are certain parts of the code example above that wouldn’t work. For instance, see how we have something called message in our advice.erb template? Where is that coming from? For now, we want message to represent the dynamic content we want to display to the client.

Let’s update the erb method so that the above template works as intended.

1
2
3
4
5
6
7
8
9
10
# hello_world.rb
# updated erb method

def erb(filename, local = {})
  b = binding
  message = local[:message]
  path = File.expand_path("../views/#{filename}.erb", __FILE__)
  content = File.read(path)
  ERB.new(content).result(b)
end

We didn’t need to add much to our erb method. There are two lines added and one line altered. If you’re unfamiliar with closures and bindings, don’t worry about the first line in the method, b = binding; just know that it’s necessary. (Note that if you are a Launch School student, course 130 talks about what a binding is and how it relates to closures). The next line takes the value from our passed in hash and assigns it to a variable. The key we’re expecting is called :message, and if that key doesn’t exist, then the message variable is nil. The message local variable is then made available within our ERB template when we pass in the binding, b, to our ERB template object on the last line. Again, if you’re unsure how binding works, just know that the message local variable is made available to the view templates when local[:message] is not nil.

With all of that done, now all three of our view templates are ready for display to the client. In this section, we’ve completed some separation of responsibility within our application by allowing dynamic content to be injected into our view templates. We’re now ready to take the next step of tying together our view templates to our call method.

Refactoring and Streamlining our Application

We’ve refactored our erb method, and now we can deliver dynamic content. But, there are still a couple of other things that need to be addressed. Our application has grown, and doesn’t just deal with delivering a static message to the client that says, “Hello World!” anymore. Our codebase should reflect that change in intent. Let’s rename our application class to a more generically named App. The filename should be updated as well. We’ll also have to update our config.ru file so that it works with our new application name. This is what you should have after making these changes:

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
# app.rb
require_relative 'advice'

class App
  def call(env)
    case env['REQUEST_PATH']
    when '/'
      ['200', {"Content-Type" => 'text/html'}, [erb(:index)]]
    when '/advice'
      piece_of_advice = Advice.new.generate
      [
        '200',
        {"Content-Type" => 'text/html'},
        [erb(:advice, message: piece_of_advice)]
      ]
    else
      [
        '404',
        {"Content-Type" => 'text/html', "Content-Length" => '61'},
        [erb(:not_found)]
      ]
    end
  end

  private

  def erb(filename, local = {})
    b = binding
    message = local[:message]
    path = File.expand_path("../views/#{filename}.erb", __FILE__)
    content = File.read(path)
    ERB.new(content).result(b)
  end
end
1
2
3
4
#config.ru
require_relative 'app'

run App.new

Another nice optimization we can make is to update how we compose our response. Let’s analyze our call method one more time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def call(env)
  case env['REQUEST_PATH']
  when '/'
    ['200', {"Content-Type" => 'text/html'}, [erb(:index)]]
  when '/advice'
    piece_of_advice = Advice.new.generate
    [
      '200',
      {"Content-Type" => 'text/html'},
      [erb(:advice, message: piece_of_advice)]
    ]
  else
    [
      '404',
      {"Content-Type" => 'text/html', "Content-Length" => '61'},
      [erb(:not_found)]
    ]
  end
end

See how we have to use the result of the erb method within an array literal? We also have to manually list out the status code, headers, and the response body. It would be nice if we could use a more natural syntax for delivering a response from our call method. Let’s give that a try. Once again, let’s write the code we would like to see, and then we’ll implement it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def call(env)
  case env['REQUEST_PATH']
  when '/'
    status = '200'
    headers = {"Content-Type" => 'text/html'}
    response(status, headers) do
      erb :index
    end
  when '/advice'
    piece_of_advice = Advice.new.generate
    status = '200'
    headers = {"Content-Type" => 'text/html'}
    response(status, headers) do
      erb :advice, message: piece_of_advice
    end
  else
    status = '404'
    headers = {"Content-Type" => 'text/html', "Content-Length" => '61'}
    response(status, headers) do
      erb :not_found
    end
  end
end

This looks a bit nicer. We don’t have to insert an Array literal into our call method, which reads a little better. Now that we have the code we wished we had, let’s implement a response method and get this all working.

1
2
3
4
def response(status, headers, body = '')
  body = yield if block_given?
  [status, headers, [body]]
end

Seems simple enough, if we do want to use a view template, then we pass it in as a block of code to our response method. Otherwise, we allow the user to specify the response value as a third method argument. (Note: blocks and processing this type of code is explained in detail in course 130). The creation and organization of the response itself is encapsulated within this method as well, pushing the unsightly nested array syntax to this private method. This means we can use a much more natural syntax in our call method, which is where we’ll be writing most of our application logic code. Consolidating the core processing of the response into this response method also gives us one place to update should we have new requirements in the future.

Start of a Framework

We’ve created some pretty useful abstractions. However, let’s say we wanted to make another web application, or integrate two or more applications within a larger one. We would have to redefine certain, more general purpose methods in each app. This is where frameworks come into the picture. And now is a great time to create a small framework to hold the common methods that we may want to use between different web applications.

In particular, the response and erb methods are prime candidates for our little framework. Let’s create that now. Any name will do for this framework. For now, we’ll call it “Monroe”, so we’ll create a class Monroe in a new file monroe.rb.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# monroe.rb

class Monroe
  def erb(filename, local = {})
    b = binding
    message = local[:message]
    path = File.expand_path("../views/#{filename}.erb", __FILE__)
    content = File.read(path)
    ERB.new(content).result(b)
  end

  def response(status, headers, body = '')
    body = yield if block_given?
    [status, headers, [body]]
  end
end

And now that we’ve created this framework, let’s have our application inherit from it. Don’t forget to remove these framework methods from your version of App as well.

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
# app.rb

require_relative 'monroe'
require_relative 'advice'

class App < Monroe
  def call(env)
    case env['REQUEST_PATH']
    when '/'
      status = '200'
      headers = {"Content-Type" => 'text/html'}
      response(status, headers) do
        erb :index
      end
    when '/advice'
      status = '200'
      headers = {"Content-Type" => 'text/html'}
      piece_of_advice = Advice.new.generate
      response(status, headers) do
        erb :advice, message: piece_of_advice
      end
    else
      status = '404'
      headers = {"Content-Type" => 'text/html', "Content-Length" => '61'}
      response(status, headers) do
        erb :not_found
      end
    end
  end
end

There we go. Now our Rack application focuses solely on handling the request and creating and returning a response. Anything more general purpose has been moved to our framework, monroe.rb. This separation of responsibilities really goes a long way in keeping things easier to manage. It also help future-proof our application as well.

Final Showcase - Our Application So Far

A fair amount of code has been written, but we haven’t been testing it out lately. Let’s take a final look at our application and see what it can do. Here is what our entire application looks like thus far:

Application and Configuration Files

1
2
3
4
5
6
7
8
9
10
11
my_framework/
 ├── Gemfile
 ├── Gemfile.lock
 ├── config.ru
 ├── app.rb
 ├── advice.rb
 ├── monroe.rb
 └── views/
        ├── index.erb
        ├── advice.erb
        └── not_found.erb
1
2
3
4
5
#config.ru

require_relative 'app'

run App.new
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# advice.rb

class Advice
  def initialize
    @advice_list = [
      "Look deep into nature, and then you will understand everything better.",
      "I have found the paradox, that if you love until it hurts, there can be no more hurt, only more love.",
      "What we think, we become.",
      "Love all, trust a few, do wrong to none.",
      "Oh, my friend, it's not what they take away from you that counts. It's what you do with what you have left.",
      "Lost time is never found again.",
      "Nothing will work unless you do."
    ]
  end

  def generate
    @advice_list.sample
  end
end
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
# app.rb

require_relative 'monroe'
require_relative 'advice'

class App < Monroe
  def call(env)
    case env['REQUEST_PATH']
    when '/'
      status = '200'
      headers = {"Content-Type" => 'text/html'}
      response(status, headers) do
        erb :index
      end
    when '/advice'
      status = '200'
      headers = {"Content-Type" => 'text/html'}
      piece_of_advice = Advice.new.generate
      response(status, headers) do
        erb :advice, message: piece_of_advice
      end
    else
      status = '404'
      headers = {"Content-Type" => 'text/html', "Content-Length" => '61'}
      response(status, headers) do
        erb :not_found
      end
    end
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# monroe.rb

class Monroe
  def erb(filename, local = {})
    b = binding
    message = local[:message]
    path = File.expand_path("../views/#{filename}.erb", __FILE__)
    content = File.read(path)
    ERB.new(content).result(b)
  end

  def response(status, headers, body = '')
    body = yield if block_given?
    [status, headers, [body]]
  end
end

The View Templates

views/index.erb

1
2
3
4
5
<html>
  <body>
    <h2> Hello World!</h2>
  </body>
</html>

views/advice.erb

1
2
3
4
5
<html>
  <body>
    <p><em><%= message %></em></p>
  </body>
</html>

views/not_found.erb

1
2
3
4
5
<html>
  <body>
    <h2>404 Not Found</h2>
  </body>
</html>

Testing

Considering what we have above, let’s test each URL endpoint, one by one. First, let’s hit the main page and make sure our index template renders to the client correctly.

Looks good, we’re seeing “Hello World!”, just as expected. Next, let’s try the advice page. Each time we navigate to this page, we should get a random piece of advice. To check this, we’ll be using the terminal.

First Request

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -X GET localhost:9595/advice -m 30 -v
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 81
Content-Type: text/html
Date: Fri, 23 Dec 2016 20:55:02 GMT
Server: WEBrick/1.3.1 (Ruby/2.3.3/2016-11-21)

<html>
  <body>
    <p><em> What we think, we become.</em></p>
  </body>
</html>

Second Request

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -X GET localhost:9595/advice -m 30 -v
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 88
Content-Type: text/html
Date: Fri, 23 Dec 2016 20:55:49 GMT
Server: WEBrick/1.3.1 (Ruby/2.3.3/2016-11-21)

<html>
  <body>
    <p><em> Nothing will work unless you do.</em></p>
  </body>
</html>

Third Request

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -X GET localhost:9595/advice -m 30 -v
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 87
Content-Type: text/html
Date: Fri, 23 Dec 2016 20:56:12 GMT
Server: WEBrick/1.3.1 (Ruby/2.3.3/2016-11-21)

<html>
  <body>
    <p><em> Lost time is never found again.</em></p>
  </body>
</html>

Everything seems to be in order. Each time a request is sent for the advice page, we get back a piece of advice. Since this is random, it’s possible to get the same thing more than once, but it seems we were lucky this time around.

Finally, let’s try out our catchall route. If we try to request a page that doesn’t exist within our application, then a 404 not found page should be displayed.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -X GET localhost:9595/whatever -m 30 -v
HTTP/1.1 404 Not Found
Connection: Keep-Alive
Content-Length: 61
Content-Type: text/html
Date: Fri, 23 Dec 2016 21:02:20 GMT
Server: WEBrick/1.3.1 (Ruby/2.3.3/2016-11-21)

<html>
  <body>
    <h2>404 Not Found</h2>
  </body>
</html>

Great, just what we wanted. And just to confirm, we’ll show a screenshot of the 404 page.

That is our entire application. It has some pretty simple capabilities, but it started out even simpler, since originally all it could do was display “Hello World!” to the user. From there, we slowly grew the functionality of the web application to include responding to dynamic content, moving that dynamic content into view templates, and adding a simple router to handle different request paths. Finally, we extracted common boilerplate code to serve as the beginnings of our very own web application framework.

Conclusion

Rack is a web server interface, which gives back-end application developers a stable communication protocol between application code and web servers. Rack helps alleviate the need to write, rewrite, and maintain lower level boilerplate code related to working with servers.

In this blog post, we assume Rack as the baseline, and build up our application using the conventions provided to us by Rack. When we “assume rack” and follow the conventions it gives us, our task of filling in the implementation details of an application is far more streamlined; we don’t have to use time to write code for connecting a client to various web servers or worry about what type of connection to use.

As we’ve shown throughout this series, we can create various modular parts of an application such as: specific files for display to the user (view templates), classes which can be used as models of objects we want to represent in our application (our advice.rb file), or code that enables us to control how we handle a request and what type of response we send back (our main router). We then extracted common code that we can reuse for other web applications as the seed for our very own web application development framework.

This has been an introduction and overview of how to use Rack to create an application, and how to build up a framework. Currently, there are various widely used frameworks that rely on Rack. Hopefully the information we’ve covered in this series will help demystify some of the magic behind those frameworks, and give you a mental model of how those frameworks are built.

rack, ruby