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:
- Static files are checked first. These are files like css, js & images. This setting is enabled by default if a directory named “public” exists
- The before filter is run
- Route matching
- 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 🙂
Good post. It makes me learn about more about sinatra.
Thanks for reading! 🙂