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 theNancy::Base
instance’s methods - When we instead run the handler using
instance_eval
, the handler block is run in the context of theNancy::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:
- Handlers should be able to use
params
instead ofrequest.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
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 ofnancy.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.