Let's Build a Sinatra

Gabe Berke-Williams

Sinatra is a domain-specific language for quickly creating web applications in Ruby. After using it on a few projects, I decided to find out how it works under the hood.

Here’s a step by step guide on how I wrote my own Sinatra.

What is Sinatra?

At its core, Sinatra is a Rack application. I already wrote about Rack, so if you’re a little fuzzy on how Rack works, that post is a great starting point. Sinatra is a layer on top of Rack: it provides an excellent DSL for specifying what your Rack app responds to, and what it sends back. For example, here’s a Sinatra application:

get "/hello" do
  [200, {}, "Hello from Sinatra!"]
end

post "/hello" do
  [200, {}, "Hello from a post-Sinatra world!"]
end

We should be able to run the code above, then send a GET to /hello on localhost and see “Hello from Sinatra!”. A POST to /hello should give us a snarky message about Sinatra. And visiting any route that we haven’t explicitly defined should give us a 404.

Technical architecture

After poring over the Sinatra source, I’ve distilled Sinatra down to a simplified technical architecture.

We’ll make a base Sinatra class that other classes can inherit from. It will store routes (like GET /hello) and actions to take when hitting those routes. For each request, it will match the requested route to the stored routes, and take action if there’s a match, or return a 404 if nothing matches.

OK let’s actually build it

Let’s call our version Nancy.

Here’s the first iteration: a class that has a method get that takes a path and a handler block.

# nancy.rb
require "rack"

module Nancy
  class Base
    def initialize
      @routes = {}
    end

    attr_reader :routes

    def get(path, &handler)
      route("GET", path, &handler)
    end

    private

    def route(verb, path, &handler)
      @routes[verb] ||= {}
      @routes[verb][path] = handler
    end
  end
end

The route method takes a verb, a path, and a handler block. It stores the handler in a nested hash of the verb and path, which ensures that routes with the same path like POST /hello and GET /hello won’t conflict.

Let’s add this at the bottom to try it out:

nancy = Nancy::Base.new

nancy.get "/hello" do
  [200, {}, ["Nancy says hello"]]
end

puts nancy.routes

Note that we currently have nancy.get instead of just get, but don’t worry, we’ll fix that at the end.

If we run ruby nancy.rb, we see:

{
  "GET" => {
    "/hello" => #<Proc:0x007fea4a185a88@nancy.rb:26>
  }
}

Cool! Calling nancy.get correctly adds a route.

Nancy on Rack

Now let’s make Nancy::Base a Rack app by adding a minimal call method, as described in my Rack post:

# nancy.rb
def call(env)
  @request = Rack::Request.new(env)
  verb = @request.request_method
  requested_path = @request.path_info

  handler = @routes[verb][requested_path]

  handler.call
end

First, we grab the verb and requested path (like GET and /the/path) from the env parameter using Rack::Request. Then we grab the handler block from @routes and call it. We are assuming that end users will ensure their block handler will return something that Rack can understand, which our block does.

Now that we’ve added a call method to Nancy::Base, let’s add a handler at the bottom:

nancy = Nancy::Base.new

nancy.get "/hello" do
  [200, {}, ["Nancy says hello"]]
end

# This line is new!
Rack::Handler::WEBrick.run nancy, Port: 9292

Rack handlers take a Rack app and actually run them. We’re using WEBrick because it’s built in to Ruby.

Run your file with ruby nancy.rb and visit http://localhost:9292/hello. You should see a greeting. Important future note: this code doesn’t automatically reload, so every time you change this file, you’ll need to hit Ctrl-c and run the code again.

Handling errors

Visiting a route that we’ve defined shows a message, but visiting a nonexistent route like http://localhost:9292/bad shows a gross Internal Server Error page. Let’s show a custom error page instead.

To do that, we need to modify our call method a little bit. Here’s a diff:

 def call(env)
   @request = Rack::Request.new(env)
   verb = @request.request_method
   requested_path = @request.path_info

-  handler = @routes[verb][requested_path]
-
-  handler.call
+  handler = @routes.fetch(verb, {}).fetch(requested_path, nil)

+  if handler
+    handler.call
+  else
+    [404, {}, ["Oops! No route for #{verb} #{requested_path}"]]
+  end
 end

If our nested @routes hash doesn’t have a handler defined for the requested verb/path combination, we now return a 404 with an error message.

Getting information about the request in the handler

Our nancy.get handler always shows the same content. But what if we want to use information about the request (like params) in our handler? The Rack::Request class that wraps the env has a method called params that contains information about all parameters provided to the method - GET, POST, PATCH, etc.

First, we need to add a params method to Nancy::Base:

module Nancy
  class Base
    #
    # ...other methods....
    #

    def params
      @request.params
    end
  end
end

We still need to give our route handlers (the block that we pass to each get) access to that params method, though.

Access to params

We have a params method on the instance of Nancy::Base, so let’s evaluate our route handler block in the context of that instance, to give it access to all of the methods. We can do that with instance_eval. If you’re a little fuzzy on instance_eval, try this article on DSLs, which goes into it in detail.

Here’s the change we need to make to the call method:

 if handler
-  handler.call
+  instance_eval(&handler)
 else
   [404, {}, ["Oops! Couldn't find #{verb} #{requested_path}"]]
 end

This is a little tricky, so I’m going to go over it in detail:

  • The original handler is a “free floating” lambda, without any context
  • If we call, uh, call on that handler it doesn’t have access to any of the Nancy::Base instance’s methods
  • When we instead run the handler using instance_eval, the handler block is run in the context of the Nancy::Base instance, which means it has access to that instance’s methods and instance variables

Now we have access to params in the handler block. Try adding the following to nancy.rb and then visiting http://localhost:9292/?foo=bar&hello=goodbye:

nancy.get "/" do
  [200, {}, ["Your params are #{params.inspect}"]]
end

Any other methods we add to Nancy::Base will also be available inside route handler blocks.

Supporting POST, PUT, etc

So far nancy.get works, but we haven’t defined methods for other common HTTP verbs yet. The code is very similar to get:

# nancy.rb
def post(path, &handler)
  route("POST", path, &handler)
end

def put(path, &handler)
  route("PUT", path, &handler)
end

def patch(path, &handler)
  route("PATCH", path, &handler)
end

def delete(path, &handler)
  route("DELETE", path, &handler)
end

In most POST and PUT requests, we’ll want to access the request body. Since the handler has access to every instance method on Nancy::Base, we need to add an instance method named request that has access to our @request instance variable that we set in call:

attr_reader :request

After adding that, we can access the request in every handler block:

nancy.post "/" do
  [200, {}, request.body]
end

Add that route, and now you can use curl to send the contents of a file to Nancy and she’ll echo it back to you:

$ curl --data "body is hello" localhost:9292
body is hello

Modern conveniences

Let’s spruce up the place:

  1. Handlers should be able to use params instead of request.params
  2. If a handler returns a string, assume that it is a successful response

params is fairly easy, we can add a small method to Nancy::Base:

def params
  request.params
end

For the second item, we need to check the result of the handler block in call:

   if handler
-    instance_eval(&handler)
+    result = instance_eval(&handler)
+    if result.class == String
+      [200, {}, [result]]
+    else
+      result
+    end
   else
     [404, {}, ["Oops! Couldn't find #{verb} #{requested_path}"]]
   end

Neat! If evaluating the block returns a String, we construct a successful Rack response; otherwise, we return the result of the block as-is. Now we can do this:

nancy.get "/hello" do
  "Nancy says hello!"
end

Delegating to Nancy::Application

That nancy.get is really getting me down. It’d be really cool if we could just do get. Here’s how.

Our strategy will be to make a Sinatra class that we can access from anywhere, then delegate get, post, etcetera to that class. An example will explain the “access from anywhere”: every time we call Nancy::Base.new, we get a new instance of Nancy::Base. So if we add routes to Nancy::Base.new, then in another file try running Nancy::Base.new with a Rack handler, we’d be running a brand new instance that doesn’t have any of our routes.

So let’s define an instance of Nancy::Base that we can reference:

module Nancy
  class Base
    # methods...
  end

  Application = Base.new
end

Try changing your routes to use Nancy::Application:

nancy_application = Nancy::Application

nancy_application.get "/hello" do
  "Nancy::Application says hello"
end

# Use `nancy_application,` not `nancy`
Rack::Handler::WEBrick.run nancy_application, Port: 9292

That’s step 1. Step 2 is to delegate methods to Nancy::Application. Add the following code (taken from Sinatra) to nancy.rb:

module Nancy
  module Delegator
    def self.delegate(*methods, to:)
      Array(methods).each do |method_name|
        define_method(method_name) do |*args, &block|
          to.send(method_name, *args, &block)
        end

        private method_name
      end
    end

    delegate :get, :patch, :put, :post, :delete, :head, to: Application
  end
end

Nancy::Delegator will delegate get, patch, post, etc to Nancy::Application so that calling get in context with Nancy::Delegator will behave exactly like calling Nancy::Application.get.

Now let’s include it everywhere. Add this line to nancy.rb outside of the Nancy module:

include Nancy::Delegator

Now we can delete all of the Nancy::Base.new and nancy_application lines and try the fancy new routes:

get "/bare-get" do
  "Whoa, it works!"
end

post "/" do
  request.body.read
end

Rack::Handler::WEBrick.run Nancy::Application, Port: 9292

Plus it works when run with rackup via config.ru:

# config.ru
require "./nancy"

run Nancy::Application

Here’s the full final code:

# nancy.rb
require "rack"

module Nancy
  class Base
    def initialize
      @routes = {}
    end

    attr_reader :routes

    def get(path, &handler)
      route("GET", path, &handler)
    end

    def post(path, &handler)
      route("POST", path, &handler)
    end

    def put(path, &handler)
      route("PUT", path, &handler)
    end

    def patch(path, &handler)
      route("PATCH", path, &handler)
    end

    def delete(path, &handler)
      route("DELETE", path, &handler)
    end

    def head(path, &handler)
      route("HEAD", path, &handler)
    end

    def call(env)
      @request = Rack::Request.new(env)
      verb = @request.request_method
      requested_path = @request.path_info

      handler = @routes.fetch(verb, {}).fetch(requested_path, nil)

      if handler
        result = instance_eval(&handler)
        if result.class == String
          [200, {}, [result]]
        else
          result
        end
      else
        [404, {}, ["Oops! No route for #{verb} #{requested_path}"]]
      end
    end

    attr_reader :request

    private

    def route(verb, path, &handler)
      @routes[verb] ||= {}
      @routes[verb][path] = handler
    end

    def params
      @request.params
    end
  end

  Application = Base.new

  module Delegator
    def self.delegate(*methods, to:)
      Array(methods).each do |method_name|
        define_method(method_name) do |*args, &block|
          to.send(method_name, *args, &block)
        end

        private method_name
      end
    end

    delegate :get, :patch, :put, :post, :delete, :head, to: Application
  end
end

include Nancy::Delegator

Here’s an app that uses Nancy:

# app.rb
# run with `ruby app.rb`
require "./nancy"

get "/" do
  "Hey there!"
end

Rack::Handler::WEBrick.run Nancy::Application, Port: 9292

And that’s Nancy Sinatra! Let’s review what we can do with this code:

  • Write any Rack app, with a simpler interface: if Rack can do it, so can Nancy.
  • We can use bare methods (get instead of nancy.get).
  • We can subclass Nancy::Base to make our own custom apps.

Further reading

Sinatra’s source code is almost all in base.rb. It’s dense, but more understandable after reading this post. I’d start with the call! method; also check out the Response class, which is a subclass of Rack::Response. One thing to keep in mind is that Sinatra is class-based, while Nancy is object-based; where Nancy uses an instance-level get method, Sinatra uses a class-level get method.