Todos 06-Building with Sinatra and DataMapper


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 ยป