A Quick Analysis of How Sinatra Works

Sinatra is a Ruby web framework.

It’s like Rails little brother…

Let’s explore how Sinatra works:

  • What happens when you require Sinatra into your project?
  • How does route matching work?
  • How are requests & responses processed?

So many questions, but so little time…

No problem!

I did the hard work for you & put together this article where I answer these questions so you can learn faster!

Sinatra Initialization

It all starts with one file: sinatra.rb.

All this file does is to require main.rb, not very exciting right?

Now it gets more interesting!

Inside main.rb you’ll find a require for base.rb & you’ll also find the code for option parsing (port, environment, quiet mode, etc.).

Sinatra uses optparse, from Ruby’s standard library.

What else can you find here?

Take a look at this at_exit block:

at_exit { Application.run! if $!.nil? && Application.run? }

This is a bit of code that will run when the program ends.

What happens is that all your code will be read by Ruby & since you don’t have any loops, sleeps or anything like that your program will end naturally.

…but just before it ends the at_exit block will trigger!

Then Sinatra takes over & starts a web server so it can handle requests.

Here’s the code that does that:

begin
  start_server(handler, server_settings, handler_name, &block)
rescue Errno::EADDRINUSE
  $stderr.puts "== Someone is already performing on port #{port}!"
  raise
end

# Part of base.rb `run!` method

Oh and another important thing happens here:

extend Sinatra::Delegator

Sinatra::Delegator is a module that defines Sinatra DSL methods like get, post & set.

That’s why you can do this:

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

Sinatra extends the global main object with this module.

Request & Response Processing

Ok, so at this point we have a running server ready to accept new connections.

But what happens when a new connection is received?

Well Sinatra, just like Rails & other Ruby web frameworks, uses Rack to handle all the lower level stuff.

Rack expects a call method to be available on your application. That’s just an object that you give to Rack when you initialize it.

In the case of Sinatra this object is the Sinatra::Base class.

Here’s the method:

# Rack call interface.

def call!(env)
  @env      = env
  @request  = Request.new(env)
  @response = Response.new

  invoke { dispatch! }
  invoke { error_block!(response.status) } unless @env['sinatra.error']

  @response.finish
end

# Modified version of Sinatra's call method (for clarity)

It seems like we need to investigate the dispatch! method next to show how a request is handled.

Here’s that method:

def dispatch!
  invoke do
    static! if settings.static? && (request.get? || request.head?)
    filter! :before
    route!
  end
rescue ::Exception => boom
  invoke { handle_exception!(boom) }
ensure
  filter! :after unless env['sinatra.static_file']
end

# Edited down to the important parts

The request in broken into 4 steps:

  1. Static files are checked first. These are files like css, js & images. This setting is enabled by default if a directory named “public” exists
  2. The before filter is run
  3. Route matching
  4. The after filter is run

Now we can dig into each step to see what happens in more detail.

Serving Static Files

The static! method is pretty simple:

def static!(options = {})
  return if (public_dir = settings.public_folder).nil?
  path = File.expand_path("#{public_dir}#{URI_INSTANCE.unescape(request.path_info)}" )
  return unless File.file?(path)

  cache_control(*settings.static_cache_control) if settings.static_cache_control?
  send_file(path, options)
end

This code checks if the requested file exists, then it sets the “Cache Control” HTTP header.

On the last line it calls send_file & it does just what the name says 🙂

Before Filter

A before filter allows you to run code before trying to find a matching route.

This is how a filter is added:

# Define a before filter.

# Runs before all requests within the same context as route handlers
# and may access/modify the request and response.

@filters = {:before => [], :after => []}

def before(path = /.*/, **options, &block)
  add_filter(:before, path, options, &block)
end

def after(path = /.*/, **options, &block)
  add_filter(:after, path, options, &block)
end

def add_filter(type, path = /.*/, **options, &block)
  filters[type] << compile!(type, path, block, options)
end

As you can see filters is just a hash with two keys, one for each filter type.

But what is compile!?

This method returns an array with 3 elements: a pattern, an array of conditions & a wrapper.

The same method is used for generating routes (when you use a get or post block):

def get(path, opts = {}, &block)
  route('GET', path, opts, &block)
end

def route(verb, path, options = {}, &block)
  signature = compile!(verb, path, block, options)

  (@routes[verb] ||= []) << signature

  signature
end

# Methods edited for clarity

From this we can learn that Sinatra filters behave & work the same way as routes.

Route Matching

The next step in the request processing cycle is route matching:

def route!(base = settings, pass_block = nil)
  routes = base.routes[@request.request_method]

  routes.each do |pattern, conditions, block|
    process_route(pattern, conditions)
    route_eval
  end

  route_missing
end

# Edited method

This code goes over every single route that matches the request method (get, post, etc).

Route matching happens inside the process_route method:

def process_route(pattern, keys, conditions, block = nil, values = [])
  route = @request.path_info
  route = '/' if route.empty? and not settings.empty_path_info?

  return unless match = pattern.match(route)
end

Where pattern is a regular expression.

If a route matches both the path & the conditions then route_eval will be called, which evaluates the block (the body of your get / post route) & ends the route matching process.

# Run a route block and throw :halt with the result.
def route_eval
  throw :halt, yield
end

This uses the unusual catch / throw mechanism for flow control.

I would recommend against it because it can be very confusing to follow the flow of code, but it's interesting to see a real-world example of this feature in use.

Response Building

The last step of the request cycle is to prepare the response.

So where does the response go?

The invoke method gathers the response like this:

res = catch(:halt) { yield }

This result is assigned to the response body using the body method:

body(res)

Now if we look back where we started, the call method, we will find this line of code:

@response.finish

This calls the finish method on @response, which is a Rack::Response object.

In other words, this will actually trigger the response to be sent to the client.

Bonus: How The Set Method Works

The set method is part of Sinatra's DSL (Domain-Specific Language) & it lets you set configuration options anywhere in your Sinatra application.

Example:

set :public_folder, '/var/www'

Every time you use set Sinatra creates 3 methods (via metaprogramming):

define_singleton("#{option}=", setter) if setter
define_singleton(option, getter)       if getter
define_singleton("#{option}?", "!!#{option}") unless method_defined? "#{option}?"

The 3 methods are (with public_folder as example):

  • public_folder
  • public_folder=
  • public_folder?

This method will also call the setter method (public_folder=) if it already exists:

if respond_to?("#{option}=") && !ignore_setter
  return __send__("#{option}=", value)
end

Remember that metaprogramming is not free, so I would just stick with an options hash. You don't need those fancy methods.

Summary

You learned how Sinatra gets initialized, how it handles a request & the different steps it takes until a response can be produced. This will help you learn a few Ruby tricks & to understand Sinatra better!

Don't forget to share this post with other Ruby developers so they can learn from it too 🙂

2 thoughts on “A Quick Analysis of How Sinatra Works”

Comments are closed.