Mastodon

Ruby Redo’s: Sinatra

Sinatra is described as “a DSL for quickly creating web applications” and since I have been playing with DSLs lately I thought I would try my hand at making something like it. You can see in Sinatra’s famous three line “Hello World” that they are calling get directly on the top-level object called main:

require 'sinatra'

get '/hi' do
  "Hello World!"
end

So first thing we know is that we need a Rack compatible class. Why do we know that? Because Rack deals with the webserver/HTTP stuff as long as we follow their interface spec which saves us a lot of fussing with servers. The spec is simple:

A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns an Array of exactly three values: The status, the headers, and the body.

So that actually tells us a fair bit about the class we need to write. As the saying goes, “There are only two hard things in Computer Science: cache invalidation and naming things”, I think I will name this thing HTTP. So here is the minimum to conform with the Rack spec:

class HTTP
  def call env
    [200, {}, ""]
  end
end

If you are curious about what kind of stuff is going to end up in that env variable here is an example:

{
 "SERVER_SOFTWARE"=>"thin 1.5.0 codename Knife",
 "SERVER_NAME"=>"localhost",
 "rack.input"=> StringIO.new(),
 "rack.version"=>[1, 0],
 "errors"=>STDERR,
 "rack.multithread"=>false,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "REQUEST_METHOD"=>"GET",
 "REQUEST_PATH"=>"/test",
 "PATH_INFO" => "/test",
 "REQUEST_URI"=>"/test",
 "HTTP_VERSION"=>"HTTP/1.1",
 "HTTP_USER_AGENT"=>"curl/7.27.0",
 "HTTP_HOST"=>"localhost:8080",
 "HTTP_ACCEPT"=>"*/*",
 "GATEWTERFACE"=>"CGI/1.2",
 "SERVER_PORT"=>"8080",
 "QUERY_STRING"=>"",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "rack.url_scheme"=>"http",
 "SCRIPT_NAME"=>"",
 "REMOTE_ADDR" => "127.0.0.1"
}

Rack actually has a bunch of helpful classes that we are going to use to get thing working, lets add them in:

require 'rack'

class HTTP

  def call env
    request = Rack::Request.new env
    response = Rack::Response.new
    response.write "Hello Whirled"
    response.finish
  end

end

at_exit do
  Rack::Handler.default.run HTTP.new
end

You can see that we now have request (that wraps the environment hash that you can see us passing in) and a response object. Notice that response.finish is the last line of the call method now. This is because it returns the array of status, headers and body that Rack needs. The other thing to notice is that we are using Kernel#at_exit to run whatever Rack decides is the default webserver when the script exits. This actually gives us a working application that you can run like this:

mike@sleepycat:~/play/http$ ruby http.rb
>> Thin web server (v1.5.0 codename Knife)
>> Maximum connections set to 1024
>> Listening on 0.0.0.0:8080, CTRL+C to stop

Lets take another step and get that response.write out of there and put it in a Proc. Next, we use instance_eval to evaluate the Proc (which is being turned from a proc object back into a block with the & operator) in the context of the current object. Since the block is being evaluated in the context of the current object and not the context of the current method we need to make request and response instance variables and add some attr_accessor methods. You can see also that we are now storing the routes in a hash of hashes that is held in a class variable so that when we call new, our instance can still see the routes.

require 'rack'

class HTTP

  attr_accessor :request, :response

  @@routes = { get: {"/" => Proc.new {response.write "Hello Whirled"}} }

  def call env
    @request = Rack::Request.new env
    @response = Rack::Response.new
    the_proc = @@routes[:get]["/"]
    instance_eval &the_proc
    response.finish
  end

end

at_exit do
  Rack::Handler.default.run HTTP.new
end

Our next step is creating some way to add methods to that @@routes hash. For that we will create a class method called save_route. We’ll also add some other methods to the hash as well and make sure that if there is no block found for a url that we send back a 404:

require 'rack'

class HTTP

  attr_accessor :response, :request

  @@routes = { get: {}, put: {}, post: {}, delete: {}, patch: {}, head: {} }

  def self.save_route *args
    method, route, block = *args
    @@routes[method.downcase.to_sym][route] = block
  end

  def call env
    puts "Handling request for: #{env["PATH_INFO"]}"
    @request = Rack::Request.new env
    @response = Rack::Response.new
    block = @@routes[request.request_method.downcase.to_sym][request.path_info]
    # if no block is stored for that path; 404!
    block ? instance_eval(&block) : throw_404
    response.finish
  end

  private

  def throw_404
    @response.status = 404
    @response.write "<html>Page Not Found</html>"
  end

end

at_exit do
  Rack::Handler.default.run HTTP.new
end

Now that we can add routes to our class, we need to turn our attention to adding methods to the main object. The methods we want to add are the usual HTTP methods, get, put and whatnot.
We want to define an method for each of them like this:

def get *args, &block
  HTTP.save_route(:get, args.first, block)
end

But since that is kind of repetitive we are going to write some code that writes that code. Often you would use Module#define_method for this but the main object is weird and does not have that method. So I am going to use eval:

require 'rack'

class HTTP
  #... all the HTTP code ...
end

def http_methods
  %w(get put post delete head patch).each do |method_name|
     eval <<-EOMETH
     def #{method_name} *args, &block
       HTTP.save_route(:#{method_name}, args.first, block)
     end
     EOMETH
   end
end

at_exit do
  # ... rack stuff ...
end

Then we need to open the eigenclass of main and attach the methods to that. This is so the methods are available as instance methods of the main object. We can do that with Ruby’s somewhat bizarre syntax:

require 'rack'

class HTTP
  #... all the HTTP code ...
end

def http_methods
  #... all the HTTP code ...
end

class << self
  http_methods
end

at_exit do
  # ... rack stuff ...
end

With that we now have a working toy version of Sinatra. We can use it like this:

require_relative 'http'

get "/" do
  response.status = 200
  response["HTTP-Referrer"] = "Mike"
  response.write "<html>Hello Whirled</html>"
end

When you are working in Rails you often hear about Rack but its not usually something you interact directly with. Playing with this has really made me appreciate Sinatra for the intelligent, concise DSL that it is (which is easy to forget somehow). While playing with DSLs is fun, taking some time to get to know Rack has also really helped my understanding of Rails.

Ruby redo’s: The Rails router

One of the things that Ruby is famous for is the ease with which you can build Domain Specific Languages (DSLs). The routing DSL in Rails is one of the more recognizable features of the framework and a good example of a Ruby DSL. If you’ve worked with Rails at all you have seen this in config/routes.rb:

MyApp::Application.routes.draw do
  match "/foo", to: "foo#bar"
end

I’ve worked with Rails a fair bit and had a pretty good understanding of how to use the DSL but there is always more to be learned by implementing it (or something like it) yourself. So what are we implementing? We want something that behaves like the ActionDispatch Routeset:

[27] pry(main)> Rails.application.routes
=> #<ActionDispatch::Routing::RouteSet:0x0000000232c588>
[28] pry(main)> Rails.application.routes.draw do
[28] pry(main)*   match "/foo", to: "bar#baz", via: :get
[28] pry(main)* end
=> nil

So we can see that it has a draw method that accepts a block. That block contains a call to the method “match” and accepts a string and a hash of arguments. We called the match method in our code block above and passed that block to the draw methods. When match is evaluated it adds the given route to the set of routes for the application. Which means that if we dig into the routes we should find our path we specified (/foo). Sure enough:

[29] pry(main)> Rails.application.routes.router.routes.each{|r| puts r.path.spec};nil;
/assets
/foo(.:format)
/rails/info/properties(.:format)

As with most things in Ruby, its actually surprisingly little code to get such a thing working:

class RouteSet

  def initialize(routes = {})
    @routes = routes
  end

  def match(path, options)
    @routes[path]= options
  end

  def draw(&block)
    instance_eval &block
  end

  def to_s
    puts @routes.inspect
  end

end

We can play with it in Pry:

mike@sleepycat:~/projects/play$ pry -I.
[1] pry(main)> load 'routes.rb'
=> true
[2] pry(main)> routeset = RouteSet.new
{}
=> #<RouteSet:0x1561598>
[3] pry(main)> routeset.draw do
[3] pry(main)*   match "/foo", to: "bar#baz", via: :get
[3] pry(main)* end
=> {:to=>"bar#baz", :via=>:get}
[4] pry(main)> routeset.to_s
{"/foo"=>{:to=>"bar#baz", :via=>:get}}
=> nil

The secret DSL sauce is all in the draw method. Notice the ampersand in front of the block parameter:

  def draw(&block)
    instance_eval &block
  end

That ampersand operator wraps an incoming block in a Proc and then binds it to a local variable named block. The same operator is used to reverse that process, turning the contents of the block variable from a Proc back into a block. That block is then fed into instance_eval which evaluates the block in the context of the current object. The net effect is the same as if you had just written this:

  def draw
    match "/foo", to: "bar#baz", via: :get
  end

This process of taking a block of code defined somewhere and evaluating it in some other context is the key to DSLs in Ruby. Understanding the ampersand operator and its conversion between blocks and Procs is really important since this is really common in Ruby code. While is may be common, its not cheap. All that “binding to variables” stuff can be slow so in those moments where you care about speed you will want to use this instead:

  def draw
    instance_eval &Proc.new
  end

Playing with this stuff has really helped my understanding of both Ruby and Rails. I hope it helps you too.