![]() |
is an IT engineer living in Italy. His main interests are related to open source ecosystems, Ruby, web application development, development on mobile devices, code quality. He can be reached through his blog or on Twitter. Carlo is a contributing editor with Rails Magazine, where he maintains a column on alternative web development frameworks. |
In the previous article we’ve seen what the Waves framework is and how it works. In this issue we look at another framework: Sinatra.
If you readers are like me, you approached Ruby language because fascinated by RubyOnRails framework. Isn’t it?
But - we repeat - Rails can’t be the best way for all problems… Sometimes we just need a fast way to publish a really simple web application, or we’d like to quickly expose some RESTful service. In such cases Sinatra comes in hand.
Before starting let me show you some “fundamentals” about Sinatra.
It’s based on Rack middleware, like Rails. So it will become always more easy to integrate and deploy Sinatra application.
It’s small, really small. Rails is actually around 90K LOC where Sinatra is less than 2K. That means less classes, less inheritance and and - to cite it’s creator Blake Mizerany when referring to other frameworks: exposed simplicity vs. hidden complexity.
You can find all the presented code here in a provided ZIP file.
Installation
First we have to install Sinatra, that’s really easy:
$ sudo gem install sinatra
Done.
Let’s look at the simplest possible application (yes, “Hello, world!”) contained in a single file:
# hello_world.rb
require 'rubygems'
require 'sinatra'
get '/' do
"Hello, world!"
end
Now execute it and point our browser to http://localhost:4567:
$ ruby ./hello_world.rb
We’ve done a (really simple) web application with 6 lines of code.
Not an MVC framework (…by default)
Surprise: Sinatra is not a MVC framework.
Sinatra simply ties specific URL to some ruby code that returns the URL “output”, no more no less. Of course no one prevent us to cleanly organize our code in order to keep application code separated from view so we can use - for example - whatever templating system we prefer. In fact Sinatra assumes very little about our application: it has only to provide output to some URLs.
We are used to see things separated:
- controllers specify action (often acting on some models)
- routes ties URLS and controllers’ action together.
In Sinatra we don’t have this, it is specified in just one line:
get '/some/url/here' do
...
end
post '/some/other/url' do
...
end
It’s up to us to organize code and files in order to achieve a sort of MVC behaviour, but the framework doesn’t force us to adhere to any particular pattern.
Routing
So let’s see how routes work with some examples:
# some_routes.rb
require 'rubygems'
require 'sinatra'
# http://localhost:4567/about
get '/about' do
"This is ABOUT page..."
end
# http://localhost:4567/people/carlopecchia
get '/people/:name' do
"You're requesting #{params[:name]} personal page"
end
# http://localhost:4567/post/2009/01/15
get %r{/post/(\d+)/(\d+)/(\d+)} do
"You're requesting post of the day #{params[:captures].reverse.join('/')}"
end
# http://localhost:4567/post/2009/01/15
get '/post/*/*/*' do
"You're requesting post of the day #{params[:splat].reverse.join('/')}"
end
# http://localhost:4567/download/2009/reportQ1.xls
get '/download/:year/*.*' do
year = params[:year]
file_name = params[:splat][0]
file_extension = params[:splat][1]
"You're requesting #{file_extension} report file (year #{year}) #{file_name}"
end
Notice the usage of :splat and :captures. Pretty easy, isn’t it?
We can also specify behaviour based on UserAgent and/or MIME-Type. Having this Sinatra application up and running (simply hit $ ruby ./user_agents.rb from your terminal):
# user_agents.rb
require 'rubygems'
require 'sinatra'get '/foo', :agent => /(curl)/ do
"Are you a bot?!\n"
endget '/foo' do
"<h1>Standard content for page Foo here :)</h1>\n"
end
issuing a request to http://localhost:4567/foo with our browser we’ll see:
when using cURL tool we obtain:
$ curl -v http://localhost:4567/foo
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /foo HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 16
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
Are you a bot?!
* Connection #0 to host localhost left intact
* Closing connection #0
as expected.
Want to differentiate by requested MIME type? Having this application:
# mime_types.rb
require 'rubygems'
require 'sinatra'
# route #1
get '/bar', :provides => 'text/plain' do
"Old plain text here!\n"
end
# route #2
get '/bar' do
"<html> Some <em>HTML content</em> here </html>\n"
end
pointing our browser to http://localhost:4567/bar we’ll see:
when forcing a different MIME type we obtain (with cURL):
$ curl -v -H "Accept: text/plain" http://localhost:4567/bar
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /bar HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 21
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
Old plain text here!
* Connection #0 to host localhost left intact
* Closing connection #0
It’s worth to note that order matters: if in previous example we swap the order of routes #1 and #2 we obtain a different application behaviour. That happeans because, for each route, Sinatra builds an internal look-up table: the first entry that matches the requesting route is called.
HTTP Methods
Of course we have access to standard HTTP methods: GET, POST, PUT, DELETE, HEAD with get, post, put, delete and head handlers.
Since actual browsers don’t support PUT and DELETE methods, the same workaround adopted by Rails exists in Sinatra, that is: use POST method with hidden _method parameter:
<form method="post" action="/34">
<input type="hidden" name="_method" value="delete" />
<button type="submit">Delete!</button>
</form>
Views
Views are handled in really simple way:
view files lives (by default) under ./views directory
using one of the following method we can invoke view rendering that is send to client browser: haml, sass, erb, builder
So we have, by default, the above mentioned tamplating systems available.
Need a layout?
If we put a file named layout.<template_system> (eg: layout.erb) under the views directory we have it. It’s also possible to inline template (really-one-single-file application).
Static files (CSS stylesheet, images, etc) live, by default, in ./public directory.
Models
As mentioned, it’s totally up to us to take care about models.
Simply put in application file the following lines:
require 'rubygems'
require 'sinatra'
require 'activerecord'
ActiveRecord::Base.establish_connection(
:adapter => 'sqlite3',
:dbfile => './db/sqlite3.db'
)
And we have access to ActiveRecord facilities. Alternatively, we can put all the models related stuff in a separate file(s) and “load” it in main application file.
ToDo application
As previous article of this serie, we want to write a simple ToDo application in order to explore the usage of Sinatra framework.
First, we define our application directory structure in order to have it clean and organized. We will use this kind of structure:
The app.rb file contains our application, and it has to:
load required gems
establish a databse connection, with an ORM
load required model(s) file
define routes according to our needs
The db directory contains a sqlite3 file used by the ORM, for course that is our own choice. The log directory contains log file for the ORM and, finally, the views directory contains layout and view files.
So, our application should provide us an easy way to insert, show and check items we have “to do”. For our purposes an item is a really simple model: a text field, a creation date and a status field.
In our app.rb we’ll put something like that:
# ./app.rb
require 'rubygems'
require 'sinatra'
# ORM & Models
load('./models.rb')
# Routes section...
In models.rb file we put our item class definition:
require 'rubygems'
require 'dm-core'
DataMapper.setup(:default, "sqlite3:///#{Dir.pwd}/db/sqlite3.db")
DataObjects::Sqlite3.logger = DataObjects::Logger.new('./log/dm.log', :debug)
class Item
include DataMapper::Resource
property :id, Integer, :serial => true
property :content, String
property :status, String
property :created_at, DateTime
def to_s
content
end
end
# migrate and populate with some data when executed alone
if $0 == __FILE__
Item.auto_migrate!
Item.create(:content => 'Buy a new MacBook', :created_at => DateTime.now)
Item.create(:content => 'Repair lawnmover', :created_at => DateTime.now)
end
We use DataMapper here mainly because of the usage of its “self documenting” property method. After setting the proper RDMS (sqlite3) and a log facility, we define the Item class.
If we execute that file, we also obtain a “migration” effect on the data base (beware: it’s always a destructive operation) and some sample data in it. So hit:
$ ruby ./models.rb && cat ./log/dm.log
in order to check that everything works fine.
RESTful (pain text) side
Ok, in first instance let’s start obtaining a “simple” RESTful behaviour from our application: we’d like to be able to interact with it without a browser, simple “text/plain” MIME file are ok (for now). Then we’ll add some nice web interface.
Well, we have to provide “classic” RESTful behaviour:
GET ‘/’ shows all the items
GET ‘/7’ shows the item with id #7
POST ‘/’ creates a new item (content provided through “content” params), and then shows it
PUT ‘/7’ modifies content of item with id #7, and then shows it
DELETE ‘/7’ remove item with id #7 from database
Simply using Sinatra DSL we obtain (in app.rb):
get '/', :provides => 'text/plain' do
@items = Item.all(:status => nil)
@items.join("\n")
end
Let’s test it using curl from the command line:
# GET /
$ curl -v -X GET -H "Accept: text/plain" http://localhost:4567
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 34
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
Buy a new MacBook
Repair lawnmover
* Connection #0 to host localhost left intact
* Closing connection #0
It works as expected. Let’s finish up with the following code:
get '/', :provides => 'text/plain' do
@items = Item.all(:status => nil)
@items.join("\n")
end
get '/:id', :provides => 'text/plain' do
@item = Item.get(params[:id])
"#{@item}"
end
post '/', :provides => 'text/plain' do
@item = Item.new
@item.attributes = {:content => params[:content], :created_at => DateTime.now}
@item.save
"#{@item}"
end
put '/:id', :provides => 'text/plain' do
@item = Item.get(params[:id])
@item.content = params[:content]
@item.save
"#{@item}"
end
delete '/:id', :provides => 'text/plain' do
@item = Item.get(params[:id])
@item.destroy
"ok, item #{params[:id]} deleted"
end
And let’s test it again with curl.
GET method:
# GET /2
$ curl -v -X GET -H "Accept: text/plain" http://localhost:4567/2
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> GET /2 HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 16
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
Repair lawnmover
* Connection #0 to host localhost left intact
* Closing connection #0
POST method:
# POST /
$ curl -v -X POST -H "Accept: text/plain" -d "content=write on Sinatra" http://localhost:4567
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> POST / HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
> Content-Length: 31
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 23
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
write on Sinatra
* Connection #0 to host localhost left intact
* Closing connection #0
PUT method:
# PUT /3
$ curl -v -X PUT -H "Accept: text/plain" -d "content=read/write on Sinatra" http://localhost:4567/3
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> PUT /3 HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
> Content-Length: 29
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 21
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
read/write on Sinatra
* Connection #0 to host localhost left intact
* Closing connection #0
DELETE method:
# DELETE /3
$ curl -v -X DELETE -H "Accept: text/plain" http://localhost:4567/3
* About to connect() to localhost port 4567 (#0)
* Trying 127.0.0.1... connected
* Connected to localhost (127.0.0.1) port 4567 (#0)
> DELETE /3 HTTP/1.1
> User-Agent: curl/7.16.3 (powerpc-apple-darwin9.0) libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
> Host: localhost:4567
> Accept: text/plain
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 18
< Connection: keep-alive
< Server: thin 1.0.0 codename That's What She Said
<
ok, item 3 deleted
* Connection #0 to host localhost left intact
* Closing connection #0
Now that we have a functioning application we’d like to provide a nice web interface to interact with it, using proper views, CSS, RSS feed and so on…
Web interface side
Well, the first thing to do is to put new get/post/etc definition below the previouses, otherwise they will glob client request and the application will stop correctly serving “text/plain” requests.
Let’s start by serving a page with all the current (yet not done) items:
get '/' do
@items = Item.all(:status => nil)
erb :index
end
with the companion views/index.erb file:
# index.erb
<% if @items.size > 0 %>
<ul>
<% @items.each do |item| %>
<li> <%= item.content %> [<a href="/edit/<%= item.id %>">edit</a>] </li>
<% end %>
</ul>
<% else %>
Nothing to do (for now...)
<% end %>
and pointing the browser to http://localhost:4567/ we can see:
Good, but pretty rough… Let’s add a layout (views/layout.erb):
# layout.erb
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html>
<title>ToDo manager - Sinatra</title>
<link rel="stylesheet" href="/style.css" type="text/css" media="screen" />
</head>
<body>
<div id="wrapper">
<div id="menu_wrapper">
<ul id="menu">
<li> <a href="/">All</a> </li>
<li> <a href="/new">New item</a> </li>
</ul>
<div style="clear: both;"></div>
</div>
<div id="content">
<%= yield %>
</div>
</div>
</body>
</html>
and a stylesheet via Sass (see views/style.ass), that we have to handle in our routes (alternatively we can also provide a static CSS file, of course):
get '/style.css' do
sass :style
end
Now everything looks pretty good:
As you can notice, we have already put the “New item” link in order to enable insertion of new items, as well as a “done” link to discriminate between “to do” and “done” items. In app.rb file we add the following lines to handle both the form view and processing sides:
get '/new' do
erb :new
end
post '/' do
@item = Item.new
@item.attributes = {:content => params[:content], :created_at => DateTime.now}
@item.save
redirect '/'
end
and this is the views/new.erb file:
<form action="/" method="post">
Insert a new ToDo: <input type="text" name="content" size="50" />
<input type="submit" value="Save">
</form>
Let’s see how it works:
Let’s add a route for “completed” items and another to see all our history:
get '/complete/:id' do
@item = Item.get(params[:id])
@item.status = 'completed'
@item.save
redirect '/'
end
get '/history' do
@items = Item.all(:status => 'completed', :order => [:created_at.desc])
erb :history
end
now we have to add the views for history (views/history.erb):
# history.erb
<ul>
<% @items.each do |item| %>
<li> <%= item.content %> (<%= item.created_at.strftime("%d.%m.%Y") %>) </li>
<% end %></ul>
After adding a proper menu link we have this:
Easy, isn’t it?
Now a little homework to readers: finish up with other routes and views in order to complete the application (ok, you can straight look at code…): editing and deletion of an item.
We want just to notice the previously mentioned workaroud needed to (not yet complaint) browsers (views/delete.erb):
<form action="/<%= @item.id %>" method="post">
<input name="_method" value="delete" type="hidden" />
<input type="submit" value="Remove!">
</form>
and how easily we can handle in our routes (app.rb):
delete '/:id' do
@item = Item.get(params[:id])
@item.destroy
redirect '/'
end
Now we have an almost completed application:
What we need more? Yes, an RSS feed (app.rb):
get '/rss.xml' do
@items = Item.all
builder do |xml|
xml.instruct! :xml, :version => '1.0'
xml.rss :version => "2.0" do
xml.channel do
xml.title "ToDo manager - Sinatra"
xml.description "My own to do list with Sinatra"
xml.link "http://localhost:4567/"
@items.each do |item|
xml.item do
xml.title item.content
xml.link "http://localhost:4567/#{item.id}"
xml.description item.content
xml.pubDate Time.parse(item.created_at.to_s).rfc822()
xml.guid "http://localhost:4567/#{item.id}"
end
end
end
end
end
end
And adding this line in layout header section:
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="http://localhost:4567/rss.xml" />
we have done:
Some extras
Before ending this article, we would like to notice some little features that can come in handy.
It’s possible to use filters in Sinatra, basically they are implemented with:
before do
...
end
instance inside your application. In the official Sinatra documentation site is showed how to emulate Rails handling of nested parameters:
before do
new_params = {}
params.each_pair do |full_key, value|
this_param = new_params
split_keys = full_key.split(/\]\[|\]|\[/)
split_keys.each_index do |index|
break if split_keys.length == index + 1
this_param[split_keys[index]] ||= {}
this_param = this_param[split_keys[index]]
end
this_param[split_keys.last] = value
end
request.params.replace new_params
end
So item[content] become accessible by params[:title][:content].
A useful command when we want to prevent some route to swallow other is pass:
get '/:post_slug' do
pass if params[:id] =~ /(\d{4})-(\d{2})-(\d{2})/
...
end
get '/:date' do
...
end
Two special routes are provided for handling “bad cases”:
- not_found for 404 code
- error for 5xx error code
Conclusion
Sinatra is very well suited for rapid development of small web application, where all the whole stuff of Rails is not needed. Or, obviously, it’s a winning tool when we have to develop an application mainly for delivering content through lightweight web services.
However with support of Rails Metal it should be possible to integrate a Sinatra application inside Rails and delegate, for example, some routes to the former while the bigger part of the application is still server by the latter.