Railsmagazine60x60 Sinatra: Fast Web Application in Ruby

by Carlo Pecchia

Issue: Winter Jam

published in December 2009

Author picture big

Carlo Pecchia 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.

logo_sinatra.png

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

/system/graphics/31/large/sinatra-0-hello_world.jpg?1261506727

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"
end

get '/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:

/system/graphics/32/large/sinatra-1-user_agent.jpg?1261506750

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:

/system/graphics/34/large/sinatra-2-mime_type.jpg?1261506783

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:

/system/graphics/33/large/sinatra-2-directory_structure.jpg?1261506763

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:

/system/graphics/35/large/sinatra-3-html_index.jpg?1261506805

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:

/system/graphics/36/large/sinatra-4-html_index_pretty.jpg?1261506819

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:

/system/graphics/37/large/sinatra-5-new_item.jpg?1261506835

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:

/system/graphics/38/large/sinatra-6-new_item_post.jpg?1261506854

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:

/system/graphics/40/large/sinatra-8-almost_completed.jpg?1261506873

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:

/system/graphics/41/large/sinatra-9-rss_feed.jpg?1261506883

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.

Resources
Sinatra
Sinatra book