About Sinatra, DataMapper, and Heroku
Sinatra is a very simple Ruby framework for generating web applications. Thanks to its small size and tight design, it makes a great backend for building the kind of simple REST-oriented services that drive SproutCore applications.
DataMapper is a fast Ruby-based ORM for managing data in a database. We'll be using DataMapper to interface with a SQLite database for storage in this tutorial.
Before You Get Started
Before you start coding with Sinatra, you will need to make sure it is installed on your machine. Enter the following command to get all the Ruby modules you need:
sudo gem install sinatra data_mapper data_objects do_sqlite3 dm-sqlite-adapter json
This will install or update all of the packages we'll be using.
Create Your App
NOTE: If you want to skip writing the code for this yourself, just install the ruby gems above then checkout the samples-todos-sinatra Git repository at Github.
Most Sinatra applications reside in a single Ruby file. This one will be no different. To setup your app, just create a new ruby file and call it "tasks.rb". The entire app is included below, with comment:
in tasks.rb:
#!/usr/bin/env ruby
# Implements Tasks spec defined at:
# http://wiki.sproutcore.com/Todos+06-Building+the+Backend
require 'rubygems'
require 'sinatra'
require 'dm-core'
require 'dm-migrations/adapters/dm-sqlite-adapter'
require 'json'
# connect DataMapper to a local sqlite file.
DataMapper::setup(:default, ENV['DATABASE_URL'] ||
"sqlite3://#{File.join(File.dirname(__FILE__), 'tmp', 'tasks.db')}")
# define the Task model object we will use to store data
# in the server. Note the three properties defined. "id"
# will be used as the GUID for the task.
class Task
include DataMapper::Resource
property :id, Serial
property :description, Text, :required => true
property :is_done, Boolean
# helper method returns the URL for a task based on id
def url
"/tasks/#{self.id}"
end
# helper method converts the Task to json. Anytime you
# call to_json on a data structure with a Task, this will
# be used to convert the task itself
def to_json(*a)
{
'guid' => self.url,
'description' => self.description,
'isDone' => self.is_done
}.to_json(*a)
end
# keys that MUST be found in the json
REQUIRED = [:description, :is_done]
# ensure json is safe. If invalid json is received returns nil
def self.parse_json(body)
json = JSON.parse(body)
ret = { :description => json['description'], :is_done => json['isDone'] }
return nil if REQUIRED.find { |r| ret[r].nil? }
ret
end
end
# instructs DataMapper to setup your database as needed
DataMapper.auto_upgrade!
# return list of all installed tasks. Just get all tasks and
# return as JSON
get '/tasks' do
content_type 'application/json'
{ 'content' => Array(Task.all) }.to_json
end
# create a new task. request body to contain json
post '/tasks' do
opts = Task.parse_json(request.body.read) rescue nil
halt(401, 'Invalid Format') if opts.nil?
task = Task.new(opts)
halt(500, 'Could not save task') unless task.save
response['Location'] = task.url
response.status = 201
end
# Get an individual task
get "/tasks/:id" do
task = Task.get(params[:id]) rescue nil
halt(404, 'Not Found') if task.nil?
content_type 'application/json'
{ 'content' => task }.to_json
end
# Update an individual task
put "/tasks/:id" do
task = Task.get(params[:id]) rescue nil
halt(404, 'Not Found') if task.nil?
opts = Task.parse_json(request.body.read) rescue nil
halt(401, 'Invalid Format') if opts.nil?
task.description = opts[:description]
task.is_done = opts[:is_done]
task.save
response['Content-Type'] = 'application/json'
{ 'content' => task }.to_json
end
# Delete an invidual task
delete '/tasks/:id' do
task = Task.get(params[:id]) rescue nil
task.destroy unless task.nil?
end
Testing
That's all there is to it. Put the above file into tasks.rb and then start your server with the following command:
$ ruby ./tasks.rb
You should now be able to test that your server works. We recommend using HTTPClient if you are on a Mac since it makes it easy to construct HTTP requests and see their results.
Here are some basic HTTP actions you can try along with the expected results:
GET /tasks
This should return a list of tasks in JSON. If you try this first, the list of tasks should be empty. Once you've created a few tasks, the response should be something like:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 72
Connection: close
Server: thin 1.2.4 codename Flaming Astroboy
{"content":[{"isDone":true,"guid":"/tasks/8","description":"edit me "}]}
POST /tasks
When you send this HTTP request, make sure you include task JSON in the body. The post body should look something like this:
"POST /tasks" post body:
{ "description": "test task", "isDone": true }
The response from your post should be empty except for a status code of 201 "Created" and a "Location" header with the new URL for the created task:
HTTP/1.1 201 Created
Location: /tasks/9
Content-Type: text/html
Content-Length: 0
Connection: close
Server: thin 1.2.4 codename Flaming Astroboy
GET /tasks/(id)
Once you've created a task, you can try retrieving it. Just GET the URL returned by the Location header. The return value should be a content hash just like in /tasks:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 69
Connection: close
Server: thin 1.2.4 codename Flaming Astroboy
{"content":{"isDone":true,"guid":"/tasks/9","description":"test task"}}
PUT /tasks/(id)
This command should update the task. Like POST /tasks, you need to pass a post body with your new task. The post body should contain both the description and isDone properties to be valid. Be sure to PUT to a URL representing an existing task (like the one you created previously):
"PUT /tasks/(id)" post body:
{ "isDone": false, "description": "EDITED" }
The return value should indicate 200 OK and should also return the new task JSON. You should now be able to go GET /tasks/(id) and see the properties you just modified change.
DELETE /tasks/(id)
This command should delete a task. Try calling this method several times on a URL for a task you have created. After the first request, GET /tasks/(id) should return a 404. Subsequent requests should not raise any kind of error.
Setting Up Your Proxy
Once you've verified your new server is working as expected, you have just one step left before you can wire it up to your client. Currently your new Sinatra server is running on port 4567 while your SproutCore app is on port 4020. This means that by itself your SproutCore app still can't access the Sinatra server.
To solve this, you need to add a "proxy" statement to your SproutCore buildfile. This will tell SproutCore that certain URLs should be sent to your backend server instead of generating code.
Open the Buildfile in your SproutCore Todos app and add the following line to the bottom:
in todos/Buildfile:
proxy "/tasks", :to => "localhost:4567"
Now any request beginning with /tasks on localhost:4020 will be proxied to your Sinatra backend. Make sure your Sinatra server is running then restart your sc-server in the SproutCore app and test it out by visiting:
http://localhost:4020/tasks
In your web browser. You should see the JSON you expected to get from Sinatra. Congratulations, you have a simple web app up and running. Now you're ready to proceed with writing your Data Source.
Moving On
Go to Step 7: Writing Your Data Source ยป
Comments (8)
Larry Siden said
at 7:11 pm on Oct 27, 2009
tasks.rb from GitHub (commit f077842) doesn't work. When I copied and pasted the code from this page into tasks.rb on my server all was golden. Turns out the problem is "require static_assets". Someone want to fix and push?
Larry Siden said
at 7:21 pm on Oct 27, 2009
Very cool!, but I can't for the life of me figure out where it's putting tasks.db.
puts "sqlite3://#{File.join(File.dirname(__FILE__), 'tmp', 'tasks.db')}"
returns "sqlite3://./tmp/tasks.db", but there is no ./tmp/tasks.db in the dir I am running it tasks.rb from.
Jim Tobin said
at 5:23 pm on Nov 10, 2009
Did you find it? Mine is in /tmp
Larry Siden said
at 7:31 pm on Oct 27, 2009
I'm just learning Ruby. Can someone point me to a URL that will explain why to_json(*a) doesn't result in a stack overflow as it's defined? I'm sure I'm missing something.
Kristian Mandrup said
at 5:13 am on Dec 18, 2009
I am using ruby 1.9.2 and wanted to use mongrel. Can be installed like this:
$ sudo gem install mongrel --source http://gems.rubyinstaller.org
Then just restart sinatra server :) The "default" gem install mongrel fails on 1.9.1 due to some fastthread dependency issues...
Kristian Mandrup said
at 7:49 am on Dec 18, 2009
Aha, I figured out that you have to set the URL to http://localhost:4567/tasks in HTTPClient
Daniel said
at 10:44 am on Jul 9, 2010
When I use POST in HTTP Client it asks for a username and password. What do I enter?
Daniel said
at 8:47 am on Jul 24, 2010
Never mind, I forgot to set the post body. Another question - when I restart my computer, all the tasks are erased because they are in the tmp folder. How can I change where it stores the tasks? I tried changing where it says 'tmp' but I got error messages when I tried to run it in terminal.
You don't have permission to comment on this page.