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.

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.

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.

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.

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.

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.

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.

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

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

on that handler it doesn’t have access to any of the 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.

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

Let’s spruce up the place:

Handlers should be able to use params instead of request.params 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

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 ).

instead of ). We can subclass Nancy::Base to make our own custom apps.