This post continues the quest we started in a previous post: to re-implement our own almost-compatible Sinatra clone. If you haven't read it, I suggest you at least give a quick skim.

In this post, we will tackle:

In the end of this post our mini framework will be able to handle most HTTP methods, parsing named parameters like get '/articles/:id', specify custom status code and headers, handle redirection, rendering HTML with some erb nicety, and have our own mini automated test set.

So let's begin.

Graceful Exiting with Signal Handlers

At the end of our previous post, we reached at a bare-minimum Sinatra implementation like this:

# fake_sinatra.rb
require 'webrick'

class FakeSinatra
  def self.get_instance(server=nil)
    @instance ||= new
  end

  def service(req, res)
    res.body = "#{@routes[req.path].call} - served by FakeSinatra"
  end

  def register(path, block)
    @routes ||= {}
    @routes[path] = block
  end

  module Helpers
    def get(path, &block)
      FakeSinatra.get_instance.register(path, block)
    end
    # TODO: implement more HTTP methods
  end
end

# so the user can call `get` etc
include FakeSinatra::Helpers

at_exit {
  server = WEBrick::HTTPServer.new(Port: 4567)
  server.mount('/', FakeSinatra)
  server.start
}

It allow us to register GET requests and will return plaintext responses. Before we start to add functionalities, there is a small annoying issue we want to fix: when we hit CTRL-C to kill the server, it displays some annoying stack trace messages:

➜  mini-sinatra git:(master) ruby app.rb
[2019-05-06 15:27:37] INFO  WEBrick 1.4.2
[2019-05-06 15:27:37] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
[2019-05-06 15:27:37] INFO  WEBrick::HTTPServer#start: pid=9646 port=4567
^C[2019-05-06 15:27:38] FATAL Interrupt:
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:170:in `select'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:170:in `block in start'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:32:in `start'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:157:in `start'
	/Users/forresty/workspace/theseus/mini-sinatra/fake_sinatra.rb:32:in `block in <top (required)>'
[2019-05-06 15:27:38] INFO  going to shutdown ...
[2019-05-06 15:27:38] INFO  WEBrick::HTTPServer#start done.
Traceback (most recent call last):
	4: from /Users/forresty/workspace/theseus/mini-sinatra/fake_sinatra.rb:32:in `block in <top (required)>'
	3: from /Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:157:in `start'
	2: from /Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:32:in `start'
	1: from /Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:170:in `block in start'
/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:170:in `select': Interrupt

Not very elegant. Since we love Ruby and its beauty, we want to make sure our Sinatra clone can exit gracefully as well, like the original Sinatra did:

➜  mini-sinatra git:(master) bundle exec ruby app.rb
[2019-05-06 15:33:48] INFO  WEBrick 1.4.2
[2019-05-06 15:33:48] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
== Sinatra (v2.0.5) has taken the stage on 4567 for development with backup from WEBrick
[2019-05-06 15:33:48] INFO  WEBrick::HTTPServer#start: pid=9922 port=4567
^C== Sinatra has ended his set (crowd applauds)
[2019-05-06 15:33:52] INFO  going to shutdown ...
[2019-05-06 15:33:52] INFO  WEBrick::HTTPServer#start done.

But how does it work?

Process, Process ID, Parent Process and Signals

Actually, all *nix systems such as MacOS and Linux support a mechanism called Signal Handling. Basically it's a way for a process to send messages to another process. Pressing CTRL-C in the terminal is basically asking the terminal process to send SIGINT (which means the Interrupt Signal) to its foreground child process: the running Ruby process.

a normal Sinatra app start and terminate flow

But wait. What is a process? According to Wikipedia:

In computing, a process is the instance of a computer program that is being executed.

Whenever you opened your favorite web browser, or your code editor, or a music app, or any other apps, you started at least one process. Each process has a unique integer id (usually called PID, aka process id), a parent process and potentially any number of child processes. The reason I said "at least one" is because those processes might start child and grandchild processes as well.

If you are on the Mac you can open the Activity Monitor app to inspect all running processes. Following screenshot shows that WeChat is running with its PID set to 4848.

Sorry for the Japanese screenshot because I am learning the language

If you use a Mac, a regular process that's an instance of a Mac Application will have the parent set to launchd, which has ID 1.

this WeChat process has launchd as its parent

If you start  a program from the terminal, for example the htop, which is a nice way to view your system resource usage, the situation is a little bit more complicated than a standard Mac app:

htop display your system resource usage

Since every process has one and only one parent, we can use a nice little tool called pstree to display the process tree:

$ pstree -p 13954
-+= 00001 root /sbin/launchd
 \-+= 13643 forresty /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
   \-+= 13644 root login -pfl forresty /bin/bash -c exec -la zsh /bin/zsh
     \-+= 13645 forresty -zsh
       \--= 13954 forresty htop

Here 13954 is the ID of htop process, and it's run under the zsh shell under the Terminal.app under the launchd which is the parent of all Mac processes.

If we run pstree without any argument we get a full list of all running processes on our computer, here is a truncated version for my laptop:

➜  mini-sinatra git:(master) pstree
-+= 00001 root /sbin/launchd
 |--= 00077 root /Library/Application Support/iStat Menus 6/iStatMenusDaemon
 |--= 00521 forresty /usr/local/opt/redis/bin/redis-server *:6379
 |--= 00528 forresty /Library/Application Support/iStat Menus 6/iStatMenusAgent.app/Contents/MacOS/iStatMenusAgent
 |-+= 00533 forresty /usr/local/opt/postgresql/bin/postgres -D /usr/local/var/postgres
 | |--= 00628 forresty postgres: checkpointer process
 | |--= 00629 forresty postgres: writer process
 | |--= 00630 forresty postgres: wal writer process
 | |--= 00631 forresty postgres: autovacuum launcher process
 | |--= 00632 forresty postgres: stats collector process
 | \--= 00633 forresty postgres: bgworker: logical replication launcher
 |--= 00541 forresty /Library/Application Support/iStat Menus 6/iStat Menus Status.app/Contents/MacOS/iStat Menus Status
 |--= 00673 forresty /Library/Little Snitch/Little Snitch Network Monitor.app/Contents/MacOS/Little Snitch Network Monitor -psn_0
 |--= 00675 forresty /Applications/Bartender 3.app/Contents/MacOS/Bartender 3
 |-+= 00687 forresty /Applications/Docker.app/Contents/MacOS/Docker
 | \-+= 00780 forresty /Applications/Docker.app/Contents/MacOS/com.docker.supervisor -watchdog fd:0
 |   |--- 00781 forresty com.docker.osxfs serve --address fd:3 --connect vms/0/connect --control fd:4 --log-destination asl
 |   |-+- 00782 forresty com.docker.vpnkit --ethernet fd:3 --port vpnkit.port.sock --port hyperkit://:62373/./vms/0 --diagnostics
 |   | \--- 00788 forresty (uname)
 |   \-+- 00783 forresty com.docker.driver.amd64-linux -addr fd:3 -debug
 |     \--- 00793 forresty com.docker.hyperkit -A -u -F vms/0/hyperkit.pid -c 2 -m 2048M -s 0:0,hostbridge -s 31,lpc -s 1:0,virti
 |--= 01373 forresty /Library/Input Methods/Squirrel.app/Contents/MacOS/Squirrel
 |-+= 01489 forresty /Applications/iTerm.app/Contents/MacOS/iTerm2
 | |-+= 08553 forresty /Applications/iTerm.app/Contents/MacOS/iTerm2 --server /usr/bin/login -fpl forresty /Applications/iTerm.ap
 | | \-+= 08554 root /usr/bin/login -fpl forresty /Applications/iTerm.app/Contents/MacOS/iTerm2 --launch_shell
 | |   \--= 08555 forresty -zsh
 | \-+= 70635 forresty /Applications/iTerm.app/Contents/MacOS/iTerm2 --server /usr/bin/login -fpl forresty /Applications/iTerm.ap
 |   \-+= 70639 root /usr/bin/login -fpl forresty /Applications/iTerm.app/Contents/MacOS/iTerm2 --launch_shell
 |     \-+= 70643 forresty -zsh
 |       \-+= 10728 forresty pstree
 |         \--- 10729 root ps -axwwo user,pid,ppid,pgid,command
 |-+= 04353 forresty /Applications/Visual Studio Code.app/Contents/MacOS/Electron .
 | |--- 04361 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --type
 | |--- 04367 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --type
 | |-+- 08186 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --type
 | | |--- 08188 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --no
 | | |--- 08189 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper /App
 | | \--- 08192 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper /App
 | \-+- 69508 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --type
 |   |-+- 69510 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper --no
 |   | |--- 09396 forresty /usr/local/bin/git fetch
 |   | \--- 73961 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper /U
 |   \--- 69511 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app/Contents/MacOS/Code Helper /App
 |--- 04366 forresty /Applications/Visual Studio Code.app/Contents/Frameworks/Electron Framework.framework/Resources/crashpad_han
 |-+= 54409 forresty /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
 | |--- 08843 forresty /Applications/Google Chrome.app/Contents/Versions/76.0.3783.0/Google Chrome Helper.app/Contents/MacOS/Goog
 | |--- 08924 forresty /Applications/Google Chrome.app/Contents/Versions/76.0.3783.0/Google Chrome Helper.app/Contents/MacOS/Goog
 | \--- 66645 forresty /Applications/Google Chrome.app/Contents/Versions/76.0.3783.0/Google Chrome Helper.app/Contents/MacOS/Goog
 |--- 54417 forresty /Applications/Google Chrome.app/Contents/Versions/76.0.3783.0/Google Chrome Framework.framework/Helpers/chro
 \--= 63121 forresty /Applications/Preview.app/Contents/MacOS/Preview -psn_0_2794154

You will see that I am running redis, docker, PostgreSQL, iTerm2, VSCode, Chrome, Preview.app among other apps. Some apps by its nature will have multiple child processes, for example PostgreSQL need that since it is a complicated RDBMS; VSCode relies on Electron for rapid development with web technologies; Chrome loads every tab and every extension in a separated child process so it's fast and won't crash the whole app if one tab or one extension goes wrong.

Running processes consume memory and CPU, drains your battery, that's why it is a good idea to always keep an eye on what processes your computer is currently running, and don't open too many apps / chrome tabs and install too many chrome extensions.

We are not going to discuss processes further here. Jesse Storimer has a wonderful little book called Working with Unix Processes, it uses Ruby as the example language to explore the Unix Process land, I highly recommend you go buy it. I did it several years ago and it was a wonderful read.

It's a Trap: Catching the Signals

Okay, so much about the background knowledge of processes. Let's see how Sinatra handles the CTRL-C event (to be exact, it should be how it handles the SIGINT / SIGTERM signals):

# https://github.com/sinatra/sinatra/blob/v2.0.5/lib/sinatra/base.rb#L1543-L1556

def setup_traps
  if traps?
    at_exit { quit! }

    [:INT, :TERM].each do |signal|
      old_handler = trap(signal) do
        quit!
        old_handler.call if old_handler.respond_to?(:call)
      end
    end

    set :traps, false
  end
end

The method we are looking for is Kernel#trap, it takes a signal and a block, register the block to be called when the signal arrives. Here Sinatra also handles SIGTERM because by default when we use the kill command or Activity Monitor to stop the process, we are sending the terminate signal.

So let's improve our code to incorporate signal handling mechanism:

# fake_sinatra.rb
require 'webrick'

class FakeSinatra
  def self.get_instance(server=nil)
    @instance ||= new
  end

  def start
    @server = WEBrick::HTTPServer.new(Port: 4567)
    @server.mount('/', self.class)
    @server.start
  end

  def stop
    @server.stop
  end

  def service(req, res)
    res.body = "#{@routes[req.path].call} - served by FakeSinatra"
  end

  def register(path, block)
    @routes ||= {}
    @routes[path] = block
  end

  module Helpers
    def get(path, &block)
      FakeSinatra.get_instance.register(path, block)
    end
    # TODO: implement more HTTP methods
  end
end

# so the user can call `get` etc
include FakeSinatra::Helpers

at_exit { FakeSinatra.get_instance.start }

[:INT, :TERM].each do |signal|
  trap(signal) {
    puts "SIG#{signal} caught, shutting down gracefully..."
    FakeSinatra.get_instance.stop
  }
end

Try it out:

$ ruby app.rb
[2019-05-06 17:11:14] INFO  WEBrick 1.4.2
[2019-05-06 17:11:14] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
[2019-05-06 17:11:14] INFO  WEBrick::HTTPServer#start: pid=14835 port=4567
^CSIGINT caught, shutting down gracefully...
[2019-05-06 17:11:14] INFO  going to shutdown ...
[2019-05-06 17:11:14] INFO  WEBrick::HTTPServer#start done.

Awesome.

Additional HTTP Methods

Supporting additional HTTP methods is actually easy. Let's rush out to create a test app that registers a POST route first:

# app.rb
require_relative 'fake_sinatra'

get '/articles' do
  'list all articles'
end

post '/articles' do
  'creating a new article'
end

If we run the program, it should fail immediately since the post method is not implemented yet. But weird things happened:

$ ruby app.rb
[2019-05-06 17:39:26] INFO  WEBrick 1.4.2
[2019-05-06 17:39:26] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
[2019-05-06 17:39:26] INFO  WEBrick::HTTPServer#start: pid=16718 port=4567

Nothing.

But when we CTRL-C:

^CSIGINT caught, shutting down gracefully...
[2019-05-06 17:40:00] INFO  going to shutdown ...
[2019-05-06 17:40:00] INFO  WEBrick::HTTPServer#start done.
Traceback (most recent call last):
app.rb:8:in `<main>': undefined method `post' for main:Object (NoMethodError)

The reason is when the NoMethodError is thrown, since no exception rescue is in place the program will crash, then the at_exit block is triggered. Remember what we did in at_exit block? we starts the webrick server. Therefore webrick will take over no matter what, only until CTRL-C is caught the server got shutdown and finally the error is shown.

$! – Global Variables in Ruby

Here we can use a Ruby trick: the global variable $! to handle the situation. Ruby has many built-in global variables, $! holds the current error object if it exists. Most of the time you don't need to use it, but in this case it is very handy. What we need to do is to add one line to the at_exit block:

at_exit {
  exit if $! # really exit
  FakeSinatra.get_instance.start
}

With that in place our program crashes as expected (sometimes crashing is a good thing!):

$ ruby app.rb
Traceback (most recent call last):
app.rb:8:in `<main>': undefined method `post' for main:Object (NoMethodError)

For reference, here's a list of relatively important global variables:

var meaning
$! latest error message
$_ string last read by gets
$& string last matched by regexp
$~ the last regexp match, as an array of subexpressions
$n the nth subexpression in the last match <- we will be using this later
$0 the name of the ruby script file
$* the command line arguments
$$ interpreter's process ID
$? exit status of last executed child process

Let's implement post support:

# fake_sinatra.rb
class FakeSinatra
  # ...omitted
  def service(req, res)
    method = req.request_method.to_sym
    res.body = "#{@routes[[method, req.path]].call} - served by FakeSinatra"
  end

  def register(method, path, block)
    @routes ||= {}
    @routes[[method, path]] = block
  end

  module Helpers
    def get(path, &block)
      FakeSinatra.get_instance.register(:GET, path, block)
    end
    def post(path, &block)
      FakeSinatra.get_instance.register(:POST, path, block)
    end
  end
end
# ...omitted

Here we use WEBrick::HTTPRequest#request_method to detect the HTTP method used. Using a two element array [method, path] as key makes me really miss tuples in Python. But hey, you can't always get what you want.

We love DRY. With a little Ruby trick we can implement all other methods we want to support:

# snippet of fake_sinatra.rb
module Helpers
  %w{ get post patch put delete }.each do |method|
    define_method(method) do |path, &block|
      FakeSinatra.get_instance.register(method.upcase.to_sym, path, block)
    end
  end
end

Module#define_method allows us to create methods dynamically. It takes an argument which becomes the method name, and a block which becomes the method parameters and body. Ruby is awesome.

Add a small test route:

# snippet of app.rb

# TODO: use pattern matching
delete '/articles/1' do
  'deleting a article'
end

We can test our app with an awesome HTTP client called HTTPie:

HTTPie in Action

It works handsomely.

Query Parameters

Our application is flawed. Very flawed. It ignores all query parameters and doesn't understand routes with named parameters such as get '/articles/:id'. Let's fix this.

First we need to provide the params method. Let's first try to puts a p in the block:

# app.rb
require_relative 'fake_sinatra'

get '/articles' do
  p params
  'list all articles'
end

and test:

[2019-05-06 21:39:27] ERROR NameError: undefined local variable or method `params' for main:Object
	app.rb:5:in `block in <main>'
	/Users/forresty/workspace/theseus/mini-sinatra/fake_sinatra.rb:21:in `service'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/httpserver.rb:140:in `service'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/httpserver.rb:96:in `run'
	/Users/forresty/.rbenv/versions/2.6.2/lib/ruby/2.6.0/webrick/server.rb:307:in `block in start_thread'
::1 - - [06/May/2019:21:39:27 CST] "GET /articles HTTP/1.1" 500 339
- -> /articles

Look at this error:

ERROR NameError: undefined local variable or method `params' for main:Object

That means, we are trying to call params method on the main object. What's this main object?

The "main" Object: Secrets of "self"

The truth is: Ruby is so Object-Oriented that every method we call, we call it on an object. That means every method has an owner, there are no such things as free methods.

To be more technically correct, every time we call a method, we call the method on an object by sending that object a message, and the object that's receiving the message is called the receiver. This pattern is usually referred to as Message Passing.

If we do not specify a receiver for that method, the implied receiver will be self, the current object.

So in our code:

get '/articles' do
  p params
  'list all articles'
end

is equivalent to:

get '/articles' do
  p self.params # or self.__send__(:params) if :params is a private method
  'list all articles'
end

Ruby's main object is the top level object, it's the current object if you are outside any class and method body. Inside a class, self will be bound to the class object; inside a method, self will be bound to the same object that's calling the method; inside a block, it will be bound to the same context when the block is evaluated.

To reiterate:

  1. inside class context, self is bound to the class object
  2. inside method context, self is bound to the same object that's calling the method
    2.1. if it is an instance method, self is the instance
    2.2. if it is a class method, self is the class
  3. outside of any class or method, bound to the top level object main
  4. inside a block, bound to the same context specified in rule 1, 2 and 3

So if we don't do anything to change the self binding, it will be bound to the top level main for our app.rb since it's outside any class and method when we call the get method with our block:

# app.rb
require_relative 'fake_sinatra'

get '/articles' do
  p params
  'list all articles'
end

We want the params to work, that is, we want to change the context of @routes[[method, req.path]].call to the FakeSinatra instance in following code:

def service(req, res)
  method = req.request_method.to_sym
  # the "served by FakeSinatra" signature is removed for brevity
  res.body = @routes[[method, req.path]].call
end

We can achieve this with BasicObject#instance_eval. According to the documentation:

instance_eval {|obj| block } → obj: Evaluates the given block within the context of the receiver (obj). In order to set the context, the variable self is set to obj while the code is executing, giving the code access to obj's instance variables and private methods.

If we change the implementation of FakeSinatra#service to:

class FakeSinatra
  # ...omitted
  def service(req, res)
    method = req.request_method.to_sym
    block = @routes[[method, req.path]]
    res.body = FakeSinatra.get_instance.instance_eval(&block)
  end

  def params
    { answer: 42 } # dummy object, act as a placeholder
  end
  # ...omitted
end

We got the dummy params object:

$ ruby app.rb
[2019-05-06 22:40:47] INFO  WEBrick 1.4.2
[2019-05-06 22:40:47] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
[2019-05-06 22:40:47] INFO  WEBrick::HTTPServer#start: pid=26138 port=4567
{:answer=>42}
::1 - - [06/May/2019:22:40:49 CST] "GET /articles HTTP/1.1" 200 17
- -> /articles

Here with the magic of instance_eval we changed the self inside get, post and all other http method blocks:

# snippet of app.rb
get '/self' do
  self
end
$ http localhost:4567/self
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 33
Date: Tue, 07 May 2019 09:53:03 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

#<FakeSinatra:0x00007fb753ab1c80>

So what's left is to get the real params. Again after some source digging on the webrick we find that WEBrick::HTTPRequest#query is (almost) what we want. So let's finish this:

# fake_sinatra.rb
def service(req, res)
  @params = req.query
  method = req.request_method.to_sym
  block = @routes[[method, req.path]]
  res.body = FakeSinatra.get_instance.instance_eval(&block).to_s
end

def params; @params; end
# app.rb
get '/inspect' do
  params
end
$ http localhost:4567/inspect\?foo=bar
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 14
Date: Mon, 06 May 2019 14:52:49 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

{"foo"=>"bar"}

$ http localhost:4567/inspect
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 2
Date: Mon, 06 May 2019 14:52:57 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

{}

Looking good. So what we need is to enable the wildcard-fu on our routes.

By the way, if you need more information on self or just want to clear the FUD, go read Metaprogramming Ruby. Trust me, it is awesome.

Take a Walk on the Wild Side: Wildcard Routes (aka Named Params)

Not a big deal. Just some regular-expression-fu. Basically, what we want is to turn '/articles/:id' into %r{\A/articles/([^/]+)/?\z}, and '/restaurants/:id/comments' into %r{\A/restaurants/([^/]+)/comments/?\z}, while keeping '/articles/' almost intact as %r{\A/articles/?\z}.

For simplicity, we are not going to allow using more than 1 named parameter in the route. Most of the time, it's not a good pattern anyway.

# fake_sinatra.rb
def service(req, res)
  @params = req.query
  method = req.request_method.to_sym

  named_param = nil
  block = @routes.find { |path, _|
    pattern, named_param = to_pattern(path)
    pattern =~ req.path
  }[1][method] rescue nil

  if block
    # $1 will be the named param value
    @params.merge!({ named_param => $1 }) if named_param
    res.body = FakeSinatra.get_instance.instance_eval(&block).to_s
  else
    res.body = "unknown route: #{method} #{req.path}"
  end
end

def register(method, path, block)
  @routes ||= {}
  @routes[path] ||= {}
  @routes[path][method] = block
end

private

def to_pattern(path)
  # '/articles/' => %r{\A/articles/?\z}
  # '/articles/:id' => %r{\A/articles/([^/]+)/?\z}
  # '/restaurants/:id/comments' => %r{\A/restaurants/([^/]+)/comments/?\z}

  # remove trailing slashes then add named capture group
  path = path.gsub(/\/+\z/, '').gsub(/\:([^\/]+)/, '([^/]+)')

  # $1 will be the matched named param key if present
  [%r{\A#{path}/?\z}, $1]
end

Here in service we use inline rescue to quickly bypass many different situations where nil values will cause us troubles:

block = @routes.find { |path, _|
  pattern, named_param = to_pattern(path)
  pattern =~ req.path
}[1][method] rescue nil

This basically mean if anything wrong happens, set value of block  to nil.

Remember the list of global variables? Now $1 really comes handy. In #to_pattern $1 is the name of the params, so '/articles/:id' will give us 'id'; and in #service it will be the value from the captured group of pattern =~ req.path (for example, %r{\A/articles/([^/]+)/?\z}), therefore the value of the param.

Note that obviously our implementation here is not performant at all: we are calling to_pattern multiple times for every incoming request.

But optimization will degrade the readability of our code, also it is not the focus at the moment, since we are still very early in development. As the wise said, premature optimization is the root of all evil.

Ready for test drive:

# app.rb
require_relative 'fake_sinatra'

get '/articles/' do
  params
end

get '/articles/:id' do
  params
end

get '/restaurants/:id/comments' do
  params
end
$ http -b localhost:4567/articles
{}

$ http -b localhost:4567/articles/1
{"id"=>"1"}

$ http -b localhost:4567/articles/1\?foo=bar
{"foo"=>"bar", "id"=>"1"}

$ http localhost:4567/restaurants
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 31
Date: Mon, 06 May 2019 15:42:25 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

unknown route: GET /restaurants

It was one hell of a hack, but fortunately it works.

HTTP Status Code, or How I Became a Teapot

Our Sinatra clone is taking shape. But now it is a bit boring: it only outputs plaintext, and even when we got unknown route, it still says HTTP/1.1 200 OK.

I mean, how can that be ok? It should have said 404 Not Found!

Also sometimes, when our app server become sentient, it might want declare itself as 418 I'm a teapot. I mean why not? Aren't we all created equal? We should have prepared for that.

If you don't already know, per MDN:

HTTP response status codes indicate whether a specific HTTP request has been successfully completed. Responses are grouped in five classes: informational responses, successful responses, redirects, client errors, and servers errors.

Basically 2xx means ok, 3xx means redirection, 4xx means you are wrong and 5xx means it is server's fault. Here is a very nice website that shows you different status codes and their meanings: https://httpstatuses.com

In Sinatra we can set custom status code like this:

post '/articles' do
  status 201
  'a new is article created.'
end

Then we have:

$ http post localhost:4567/articles
HTTP/1.1 201 Created
Connection: Keep-Alive
Content-Length: 0
Content-Type: text/html;charset=utf-8
Date: Tue, 07 May 2019 04:43:14 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-Xss-Protection: 1; mode=block

Should be easy. Let's go ahead and implement it:

# snippet of fake_sinatra.rb

def service(req, res)
  @status = 200 # reset to default
  # ...omitted

  if block
    # ...omitted
    res.body = FakeSinatra.get_instance.instance_eval(&block).to_s
    res.status = @status # need to do this after evaluating the block
  else
    # ...omitted
    res.status = 404
  end
end

def status(code)
  @status = code
end

And it works:

$ http post localhost:4567/articles
HTTP/1.1 201 Created
Connection: Keep-Alive
Content-Length: 2
Date: Tue, 07 May 2019 04:52:18 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

{}

$ http localhost:4567/articles/1
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Length: 11
Date: Tue, 07 May 2019 04:52:11 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

{"id"=>"1"}

$ http post localhost:4567/non-existent
HTTP/1.1 404 Not Found
Connection: Keep-Alive
Content-Length: 33
Date: Tue, 07 May 2019 04:52:14 GMT
Server: WEBrick/1.4.2 (Ruby/2.6.2/2019-03-13)

unknown route: POST /non-existent

Our #service method is getting a bit complicated, let's apply some refactoring:

# snippet of fake_sinatra.rb

def service(req, res)
  @status = 200 # reset to default

  block, path_params = match_route(req)

  return not_found!(req, res) if block.nil?

  @params = req.query.merge(path_params)
  res.body = FakeSinatra.get_instance.instance_eval(&block).to_s
  res.status = @status # need to do this after evaluating the block
end

private

def not_found!(req, res)
  method = req.request_method.to_sym

  res.status = 404
  res.body = "unknown route: #{method} #{req.path}"
end

def match_route(req)
  method = req.request_method.to_sym

  named_param = nil

  block = @routes.find { |path, _|
    pattern, named_param = to_pattern(path)
    pattern =~ req.path
  }[1][method] rescue nil

  # $1 will be the named param value
  [block, { named_param => $1 }.compact]
end

We Write Tests Because We Are Lazy

As we keep improving our mini-framework it's getting more and more tedious to manually run tests after each modification. But if we do not run tests, there might be bugs. As a great hacker once said, there are three virtues of a programmer:

  1. laziness
  2. impatience
  3. hubris

If we have bugs in our code it will hurt our hubris, but we are also too lazy to manually run tests.

Hence we write automated tests.

Since our codebase is so simple now, let's don't introduce a test framework, but instead just roll our own.

Process#fork

Remember previously we talked about child processes? It's very easy to start one ourselves:

# test.rb

app_pid = fork {
  # will be run in the child process
  # parent never reaches here
  load 'app.rb'
}

# will be run in the parent process
# child never reaches here

10.times { |i| puts "shutting down in #{10-i}"; sleep 1 }

Process.kill(:INT, app_pid)

Here we used Process#fork to create a new child process, the child process will have a copy of everything the parent process has, and continues the execution in the block provided to the fork call. The only line of code inside the block is the load method call, which is basically running every line of code in app.rb. So the role of the child process is to achieve the same effect as running ruby app.rb, only we don't need to do that manually.

The Process#fork will return the PID of the child process, therefore later in the parent process we can kill it with Process.kill.

Test it:

$ ruby test.rb
shutting down in 10
[2019-05-07 13:24:30] INFO  WEBrick 1.4.2
[2019-05-07 13:24:30] INFO  ruby 2.6.2 (2019-03-13) [x86_64-darwin18]
[2019-05-07 13:24:30] INFO  WEBrick::HTTPServer#start: pid=5410 port=4567
shutting down in 9
shutting down in 8
shutting down in 7
shutting down in 6
shutting down in 5
shutting down in 4
shutting down in 3
shutting down in 2
shutting down in 1
SIGINT caught, shutting down gracefully...
[2019-05-07 13:24:40] INFO  going to shutdown ...
[2019-05-07 13:24:40] INFO  WEBrick::HTTPServer#start done.

Note that the PID shown by webrick in above example is 5410, which is the PID of the child process, since the webrick server is started in the child process. If we are fast enough to identify the PID of the parent process before time runs out:

# 1. print all process info
# 2. find ruby ones
# 3. exclude ones that contains 5410 which is the child pid
# 4. print the second column
$ ps aux | grep ruby | grep -v 5410 | awk '{print $2}'
5382

Don't worry too much if you are not fully comfortable with above shell script, later we will have a post discuss how to write our own shell.

In this case, the parent PID is 5382, then we can run pstree:

$ pstree 5382
-+= 05382 forresty ruby test.rb
 \--- 05410 forresty ruby test.rb

We are able to visualize the tree we started (or planted?). Again, if you are interested in how processes work, I highly recommend you go read the Working With Unix Processes book.

Testing with Real HTTP Requests

Now what we need to do is to replace the countdown with some real test code. Later when our fake Sinatra implementation is Rack-compatible we can use libraries like rake-test to fake HTTP requests, but now let's simply send real ones:

# test.rb

app_pid = fork {
  # will be run in the child process
  # parent never reaches here

  # silence webrick
  [$stdout, $stderr].each { |f| f.reopen("/dev/null", "w") }

  # start the app
  load 'app.rb'
}

# will be run in the parent process
# child never reaches here

# ensure killing webrick even when errors are raised
at_exit { Process.kill(:INT, app_pid) }

require 'net/http'

def request(method, path, body={})
  uri = URI("http://localhost:4567#{path}")
  res = case method
  when :get
    Net::HTTP.get_response(uri)
  when :post
    Net::HTTP.post_form(uri, body)
  else
    raise "method '#{method}' is not supported yet"
  end

  # status, headers, body
  [ res.code.to_i, Hash[res.each.to_a], res.body ]
rescue Errno::ECONNREFUSED
  sleep 1 # if webrick is not ready we try again later
  retry
end

def get(path)
  request(:get, path)
end

def post(path, body={})
  request(:post, path, body)
end

def assert_equal(expected, actual)
  assert expected == actual, "expecting #{expected.inspect}, got #{actual.inspect}"
end

def assert_match(pattern, actual)
  assert pattern =~ actual, "expecting #{actual.inspect} to match #{pattern.inspect}"
end

def assert(condition, message)
  raise message unless condition
  print '.'
end

status, headers, body = get('/articles/1')
assert_equal 200, status
assert_equal %[{"id"=>"1"}], body

status, headers, body = get('/articles/1?foo=bar')
assert_equal 200, status
assert_equal %[{"foo"=>"bar", "id"=>"1"}], body

status, headers, body = get('/restaurants/233/comments')
assert_equal 200, status
assert_equal %[{"id"=>"233"}], body

status, headers, body = post('/articles')
assert_equal 201, status
assert_equal %[{}], body

status, headers, body = get('/non-existent')
assert_equal 404, status
assert_match /unknown/, body

puts
puts
puts "all passed."

Here $stdout, $stderr again are two important global variables in Ruby. Actually, every *nix process will have 3 special files open, they are called the standard streams:

  • Standard Input: $stdin
  • Standard Output: $stdout
  • Standard Error: $stderr

When we run a program and see results printing out to the terminal, that's because the program is printing to either stdout or stderr. And since /dev/null is a special location, which will discard anything that got written to it (like a blackhole! Wait. More like a trash bin with a portal at the bottom), we can silence the webrick child process by pointing its stdout and stderr to /dev/null.

Another trick we did is we register the at_exit callback on the parent side immediately after the fork call, so that any bad things happen in the future we can ensure the child process will be clean up.

Also we want to rescue Errno::ECONNREFUSED and sleep then retry in #request method because the HTTP requests may be send before webrick is ready to receive them.

Note that we don't need a third party HTTP library, we can just use net/http.

And it works.

$ ruby test.rb
..........

all passed.

Setting Response Headers

Now with testing in place we are much more confident to move forward quickly. Let's implement the ability to set headers.

In Sinatra it works like this:

get '/header' do
  headers "X-Custom-Value" => "This is a custom HTTP header."
  'ok'
end

get '/headers' do
  headers "X-Custom-Value" => "foo", "X-Custom-Value-2" => "bar"
  'ok'
end

So after checking the source code of webrick we go ahead and implement it like this:

# snippet of fake_sinatra.rb
def service(req, res)
  @status, @headers = 200, {} # reset to default

  # ...omitted
  res.body = FakeSinatra.get_instance.instance_eval(&block).to_s
  # ...omitted
  @headers.each { |k, v| res[k] = v }
end

private

def headers(additional_headers)
  @headers = additional_headers
end

Add some test:

_, headers, _ = get('/header')
# webrick turn headers into lowercase form
assert_match /custom/, headers['x-custom-value']

_, headers, _ = get('/headers')
assert_equal 'foo', headers['x-custom-value']
assert_equal 'bar', headers['x-custom-value-2']

And run:

$ ruby test.rb
.............

all passed.

Redirection: HTTP 301 / 302 and Location Header

HTTP redirection is actually very simple. The client sends a request to a URL, the server says, "no, what you are looking for is not here, but I know where it is". The server tells the client this with HTTP status code 3xx, where most of the time is either 301 or 302, and a "Location" header, so the client can decide whether it wants to follow the redirection. The redirection part is actually optional.

created with Monodraw

Sinatra's API for redirection is this:

get '/temp-redirect' do
  redirect 'http://google.com'
end

get '/perm-redirect' do
  redirect 'http://google.com', 301 # permanent redirection
end

Since we already have a mechanism to set status code and header, let's implement redirection:

private

def redirect(target, code=302)
  status(code)
  headers 'Location' => target
end

Add more tests:

status, _, _ = get('/temp-redirect')
assert_equal 302, status

status, headers, _ = get('/perm-redirect')
assert_equal 301, status
assert_equal 'http://google.com', headers['location']

When we run automated tests they all passed, but we are cautious, so we manually try them. Here we use -L option of curl so that it will follow redirection like our browsers do:

$ curl -L http://localhost:4567/temp-redirect
... a long HTML from google.com

$ curl -L http://localhost:4567/perm-redirect
... a long HTML from google.com

It works.

ERB

Let's add templating capabilities and call it a day. There are many template engines, but ERB is the built-in one and the chef's choice in Rails's omakase. Later we will add support for other template engines, for now let's just implement erb.

The simplest use case maybe this:

# snippet of app.rb
get '/index' do
  erb :index
end
<!-- ./views/index.erb -->
<h1>it works</h1>

Per erb documentation, we implement the erb method as this:

def erb(view)
  path = File.expand_path("./views/#{view}.erb", __dir__)
  template = ERB.new(File.read(path))

  template.result(binding)
end

__dir__ is a special method that always return the current file's directory. Here the binding is a special Ruby method, basically it represents the context of current object self, and in this case, the FakeSinatra instance.

Objects of class Binding encapsulate the execution context at some particular place in the code and retain this context for future use. The variables, methods, value of self, and possibly an iterator block that can be accessed in this context are all retained. Binding objects can be created usingKernel#binding

Test it out:

http://localhost:4567/index

Voila.

Even better, since we are using the same binding, we will be able to setup instance variables and the view will pick them up immediately:

get '/time' do
  @time = Time.now
  erb :time
end
<!-- views/time.erb -->
<h1><%= @time %></h1>
http://localhost:4567/time

Just be careful not to incidentally overwrite the internal ivars.

Wrapping Up

So that's it for today. The source code of this post lives in the same repository as the previous one, go check it out.

As of now our mini-framework has 83 lines of code and 67 lines of test.

$ cloc fake_sinatra.rb
       1 text file.
       1 unique file.
       0 files ignored.

github.com/AlDanial/cloc v 1.80  T=0.02 s (63.8 files/s, 6505.6 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Ruby                             1             28              8             83
-------------------------------------------------------------------------------
$ cloc test.rb
       1 text file.
       1 unique file.
       0 files ignored.

github.com/AlDanial/cloc v 1.80  T=0.01 s (84.4 files/s, 7003.0 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Ruby                             1             24             10             67

Later we will have one or more follow up posts to discuss how we can improve our mini framework to:

As usual, welcome to leave me a message on the comment area or drop me an email at afu@forresty.com. Thanks for reading.