├── .gitignore ├── README.textile ├── Rakefile ├── examples └── active_record │ ├── app.rb │ ├── db │ └── migrate │ │ └── 20090924173700_create_people.rb │ ├── setup_activerecord.rb │ └── views │ ├── layout.haml │ └── people │ ├── edit.haml │ ├── index.haml │ ├── new.haml │ └── show.haml ├── lib └── sinatra │ ├── rest.rb │ └── rest │ ├── adapters.rb │ └── rest.yaml ├── sinatra-rest.gemspec └── test ├── call_order_spec.rb ├── crud_spec.rb ├── helper.rb ├── helpers_spec.rb ├── inflection_spec.rb ├── routes_spec.rb ├── test_spec.rb └── views └── people ├── edit.haml ├── index.haml ├── new.haml └── show.haml /.gitignore: -------------------------------------------------------------------------------- 1 | TODO 2 | sinatra-rest-*.gem 3 | 4 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. Sinatra-REST 2 | 3 | Actually it's a set of templates to introduce RESTful routes in Sinatra. The 4 | only thing for you to do is to provide the views. The routes and some 5 | url helpers will be provided behind the scenes. 6 | 7 | 8 | h2. Installation 9 | 10 | Guess what! 11 | 12 | sudo gem source --add http://gems.github.com 13 | sudo gem install blindgaenger-sinatra-rest 14 | 15 | 16 | h2. Usage 17 | 18 | Of course you need to require the gem in your Sinatra application: 19 | 20 | require 'rubygems' 21 | require 'sinatra' 22 | require 'sinatra/rest' 23 | 24 | It's very similar to defining routes in Sinatra (@get@, @post@, ...). But this 25 | time you don't define the routes by yourself, but use the model's name for 26 | convention. 27 | 28 | For example, if the model's class is called @Person@ you only need to add this 29 | line: 30 | 31 | rest Person 32 | 33 | Which will add the following RESTful routes to your application. (Note the 34 | pluralization of @Person@ to the @/people/*@ routes.) 35 | 36 | * GET /people 37 | * GET /people/new 38 | * POST /people 39 | * GET /people/:id 40 | * GET /people/:id/edit 41 | * PUT /people/:id 42 | * DELETE /people/:id 43 | 44 | But the real benefit is, that these *routes define a restful standard behaviour* 45 | on your model, *appropriate routing and redirecting* and *named url helpers*. 46 | 47 | For instance, you can imagine the following code to be added for the @/people@ 48 | and @/people/:id@ routes. 49 | 50 |
51 | # simply add this line
52 |
53 | rest Person, :renderer => :erb
54 |
55 | # and this is generated for you
56 |
57 | get '/people' do
58 | @people = Person.all
59 | erb :"people/index", options
60 | end
61 |
62 | put '/people/:id' do
63 | @person = Person.find_by_id(params[:id])
64 | redirect url_for_people_show(@person), 'person updated'
65 | end
66 |
67 | # further restful routes for Person ...
68 |
69 |
70 | That's only half the truth! The routes are generated dynamically, so all
71 | defaults can be overridden (the behaviour, after/before callbacks, used renderer,
72 | which routes are added).
73 |
74 | For more details and options, please have a look at the pages in the
75 | "Sinatra-REST Wiki":http://github.com/blindgaenger/sinatra-rest/wikis on Github.
76 |
77 | h2. Links
78 |
79 | * "Homepage @ GitHub Pages":http://blindgaenger.github.com/sinatra-rest/
80 | * "Source Code @ GitHub":http://blindgaenger.github.com/sinatra-rest/
81 | * "Documentation @ rdoc.info":http://rdoc.info/projects/blindgaenger/sinatra-rest
82 | * "Continuous Integration @ RunCodeRun":http://runcoderun.com/blindgaenger/sinatra-rest
83 | * "Gem hosting @ Gemcutter":http://gemcutter.org/gems/sinatra-rest
84 |
85 | h2. Contact
86 |
87 | You can contact me via mail at blindgaenger at gmail dot com, or leave me a
88 | message on my "Github profile":http://github.com/blindgaenger.
89 |
90 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'spec/rake/spectask'
2 |
3 | task :default => :test
4 |
5 | desc "Run tests"
6 | Spec::Rake::SpecTask.new :test do |t|
7 | t.spec_opts = %w(--format specdoc --color) #--backtrace
8 | t.spec_files = FileList['test/*_spec.rb']
9 | end
10 |
11 |
--------------------------------------------------------------------------------
/examples/active_record/app.rb:
--------------------------------------------------------------------------------
1 | require 'sinatra'
2 |
3 |
4 | # add some data the first time or use an already existing db
5 | configure :development do
6 | load 'setup_activerecord.rb'
7 |
8 | Person.create(:name => 'foo')
9 | Person.create(:name => 'bar')
10 | Person.create(:name => 'baz')
11 | end
12 |
13 | # sinatra-rest needs to be loaded after ActiveRecord
14 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '../../lib')
15 | require 'sinatra/rest'
16 |
17 | rest Person
18 |
19 | get '/' do
20 | redirect '/people'
21 | end
22 |
23 |
--------------------------------------------------------------------------------
/examples/active_record/db/migrate/20090924173700_create_people.rb:
--------------------------------------------------------------------------------
1 | class CreatePeople < ActiveRecord::Migration
2 | def self.up
3 | create_table :people do |t|
4 | t.string :name
5 | t.timestamps
6 | end
7 | end
8 |
9 | def self.down
10 | drop_table :people
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/examples/active_record/setup_activerecord.rb:
--------------------------------------------------------------------------------
1 | require 'activerecord'
2 |
3 | ActiveRecord::Base.logger = Logger.new(STDOUT)
4 | ActiveRecord::Base.logger.level = Logger::WARN
5 |
6 | config = {
7 | :adapter => 'sqlite3',
8 | :database => "db/#{Sinatra::Application.environment.to_s}.sqlite3"
9 | }
10 | ActiveRecord::Base.establish_connection(config)
11 | ActiveRecord::Migrator.up('db/migrate')
12 |
13 | class Person < ActiveRecord::Base
14 | end
15 |
16 |
--------------------------------------------------------------------------------
/examples/active_record/views/layout.haml:
--------------------------------------------------------------------------------
1 | !!! XML
2 | !!! Strict
3 | %html{html_attrs}
4 | %head
5 | %meta{:"http-equiv"=>"content-type", :content=>"text/html; charset=UTF-8"}/
6 | %title People
7 | %style{:type=>"text/css", :media=>"screen"}
8 | :sass
9 | body
10 | font-family: "Lucida Grande", sans-serif
11 | font-size: 12px
12 | background-color: #E9ECEB
13 | color: #555555
14 |
15 | #container
16 | width: 400px
17 | margin: 25px auto
18 | padding: 10px 25px
19 | background-color: #FFFFFF
20 | border: 1px solid #D4D4D4
21 | :-moz-border-radius 8px
22 | :-webkit-border-radius 8px
23 |
24 | #footer
25 | text-align: center
26 | color: #909090
27 |
28 | h1
29 | color: #383838
30 | font-size: 2em
31 | font-weight: bold
32 |
33 | a
34 | color: #075FB2
35 | text-decoration: none
36 |
37 | .buttons *
38 | padding: 0 0.5em
39 | text-align: center
40 | margin: 1em 0
41 | display: inline
42 |
43 | %body
44 |
45 | #container
46 | = yield
47 |
48 | #footer
49 | powered by
50 | %a{:href=>'http://blindgaenger.github.com/sinatra-rest/'} sinatra-rest
51 |
--------------------------------------------------------------------------------
/examples/active_record/views/people/edit.haml:
--------------------------------------------------------------------------------
1 | %h1 Edit
2 |
3 | %form{:action=>url_for_people_update(@person), :method=>"post"}
4 | %input{:type=>"hidden", :name=>"_method", :value=>"put"}
5 | Name:
6 | %input{:name=>"name", :value=>@person.name}
7 | .buttons
8 | %a{:href => url_for_people_show(@person)} Cancel
9 | %button{:type => :submit} Update
10 |
--------------------------------------------------------------------------------
/examples/active_record/views/people/index.haml:
--------------------------------------------------------------------------------
1 | %h1 Index
2 |
3 | %ul
4 | - @people.each do |person|
5 | %li
6 | = person.id
7 | %a{:href => url_for_people_show(person)}
8 | = person.name
9 |
10 | .buttons
11 | %a{:href => url_for_people_new} New
12 |
--------------------------------------------------------------------------------
/examples/active_record/views/people/new.haml:
--------------------------------------------------------------------------------
1 | %h1 New
2 |
3 | %form{:action=>url_for_people_create, :method=>"post"}
4 | Name:
5 | %input{:name=>"name"}
6 | .buttons
7 | %a{:href => url_for_people_index} Cancel
8 | %button{:type => :submit} Create
9 |
10 |
--------------------------------------------------------------------------------
/examples/active_record/views/people/show.haml:
--------------------------------------------------------------------------------
1 | %h1 Show
2 |
3 | %table
4 | %tr
5 | %th Name
6 | %th Value
7 | %tr
8 | %td id
9 | %td= @person.id
10 | %tr
11 | %td Name
12 | %td= @person.name
13 |
14 | .buttons
15 | %a{:href => url_for_people_index} « Index
16 | %a{:href => url_for_people_edit(@person)} Edit
17 | %form{:action=>url_for_people_update(@person), :method=>"post"}
18 | %input{:type=>"hidden", :name=>"_method", :value=>"delete"}
19 | %button{:type => :submit} Delete
20 |
21 |
--------------------------------------------------------------------------------
/lib/sinatra/rest.rb:
--------------------------------------------------------------------------------
1 | require 'sinatra/base'
2 | require 'english/inflect'
3 |
4 | libdir = File.dirname(__FILE__) + "/rest"
5 | $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
6 | require 'adapters'
7 | require 'yaml'
8 |
9 | module Sinatra
10 |
11 | module REST
12 |
13 | #
14 | # adds restful routes and url helpers for the model
15 | def rest(model_class, options={}, &block)
16 | parse_args(model_class, options)
17 | read_config('rest/rest.yaml')
18 |
19 | # register model specific helpers
20 | helpers generate_helpers
21 |
22 | # create an own module, to override the template with custom methods
23 | # this way, you can still use #super# in the overridden methods
24 | controller = generate_controller
25 | if block_given?
26 | custom = CustomController.new(@plural)
27 | custom.instance_eval &block
28 | custom.module.send :include, controller
29 | controller = custom.module
30 | end
31 | helpers controller
32 |
33 | # register routes as DSL extension
34 | instance_eval generate_routes
35 | end
36 |
37 | protected
38 |
39 | ROUTES = {
40 | :all => [:index, :new, :create, :show, :edit, :update, :destroy],
41 | :readable => [:index, :show],
42 | :writeable => [:index, :show, :create, :update, :destroy],
43 | :editable => [:index, :show, :create, :update, :destroy, :new, :edit],
44 | }
45 |
46 | def parse_args(model_class, options)
47 | @model, @singular, @plural = conjugate(model_class)
48 | @renderer = (options.delete(:renderer) || :haml).to_s
49 | @route_flags = parse_routes(options.delete(:routes) || :all)
50 | end
51 |
52 | def parse_routes(routes)
53 | routes = [*routes].map {|route| ROUTES[route] || route}.flatten.uniq
54 | # keep the order of :all routes
55 | ROUTES[:all].select{|route| routes.include? route}
56 | end
57 |
58 | def read_config(filename)
59 | file = File.read(File.join(File.dirname(__FILE__), filename))
60 | @config = YAML.load file
61 | end
62 |
63 | #
64 | # creates the necessary forms of the model name
65 | # pretty much like ActiveSupport's inflections, but don't like to depend on
66 | def conjugate(model_class)
67 | model = model_class.to_s.match(/(\w+)$/)[0]
68 | singular = model.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
69 | return model, singular, singular.pluralize
70 | end
71 |
72 | def replace_variables(t, route=nil)
73 | if route
74 | t.gsub!('NAME', route.to_s)
75 | t.gsub!('VERB', @config[route][:verb].downcase)
76 | t.gsub!('URL', @config[route][:url])
77 | t.gsub!('CONTROL', @config[route][:control])
78 | t.gsub!('RENDER', @config[route][:render])
79 | end
80 | t.gsub!(/PLURAL/, @plural)
81 | t.gsub!(/SINGULAR/, @singular)
82 | t.gsub!(/MODEL/, @model)
83 | t.gsub!(/RENDERER/, @renderer)
84 | t
85 | end
86 |
87 | def generate_routes
88 | @route_flags.map{|r| route_template(r)}.join("\n\n")
89 | end
90 |
91 | def route_template(route)
92 | t = <<-RUBY
93 | VERB 'URL' do
94 | PLURAL_before :NAME
95 | PLURAL_NAME
96 | PLURAL_after :NAME
97 | RENDER
98 | end
99 | RUBY
100 | replace_variables(t, route)
101 | end
102 |
103 | def generate_helpers
104 | m = Module.new
105 | @route_flags.each {|r|
106 | m.module_eval helpers_template(r)
107 | }
108 | m
109 | end
110 |
111 | def helpers_template(route)
112 | t = <<-RUBY
113 | def url_for_PLURAL_NAME(model=nil)
114 | "URL"
115 | end
116 | RUBY
117 | helper_route = @config[route][:url].gsub(':id', '#{escape_model_id(model)}')
118 | t.gsub!('URL', helper_route)
119 | replace_variables(t, route)
120 | end
121 |
122 | def generate_controller
123 | m = Module.new
124 | t = <<-RUBY
125 | def PLURAL_before(name); end
126 | def PLURAL_after(name); end
127 | RUBY
128 | m.module_eval replace_variables(t)
129 |
130 | @route_flags.each {|route|
131 | m.module_eval controller_template(route)
132 | }
133 | m
134 | end
135 |
136 | def controller_template(route)
137 | t = <<-RUBY
138 | def PLURAL_NAME(options=params)
139 | mp = filter_model_params(options)
140 | CONTROL
141 | end
142 | RUBY
143 | replace_variables(t, route)
144 | end
145 |
146 | #
147 | # model unspecific helpers, will be included once
148 | module Helpers
149 | # for example _method will be removed
150 | def filter_model_params(params)
151 | params.reject {|k, v| k =~ /^_/}
152 | end
153 |
154 | def escape_model_id(model)
155 | if model.nil?
156 | raise 'can not generate url for nil'
157 | elsif model.kind_of?(String)
158 | Rack::Utils.escape(model)
159 | elsif model.kind_of?(Fixnum)
160 | model
161 | elsif model.id.kind_of? String
162 | Rack::Utils.escape(model.id)
163 | else
164 | model.id
165 | end
166 | end
167 |
168 | def call_model_method(model_class, name, options={})
169 | method = model_class.method(name)
170 | if options.nil? || method.arity == 0
171 | Kernel.warn "warning: calling #{model_class.to_s}##{name} with args, although it doesn't take args" if options
172 | method.call
173 | else
174 | method.call(options)
175 | end
176 | end
177 | end
178 |
179 | #
180 | # used as context to evaluate the controller's module
181 | class CustomController
182 | attr_reader :module
183 |
184 | def initialize(prefix)
185 | @prefix = prefix
186 | @module = Module.new
187 | end
188 |
189 | def before(options={}, &block) prefix :before, █ end
190 | def after(options={}, &block) prefix :after, █ end
191 | def index(options={}, &block) prefix :index, █ end
192 | def new(options={}, &block) prefix :new, █ end
193 | def create(options={}, &block) prefix :create, █ end
194 | def show(options={}, &block) prefix :show, █ end
195 | def edit(options={}, &block) prefix :edit, █ end
196 | def update(options={}, &block) prefix :update, █ end
197 | def destroy(options={}, &block) prefix :destroy, █ end
198 |
199 | private
200 | def prefix(name, &block)
201 | @module.send :define_method, "#{@prefix}_#{name}", &block if block_given?
202 | end
203 | end
204 |
205 | end # REST
206 |
207 | helpers REST::Helpers
208 | register REST
209 |
210 | end # Sinatra
211 |
--------------------------------------------------------------------------------
/lib/sinatra/rest/adapters.rb:
--------------------------------------------------------------------------------
1 | module Stone
2 | module Resource
3 | def find_by_id(id)
4 | get(id)
5 | end
6 |
7 | def delete(id)
8 | model = self.find_by_id(id)
9 | model.destroy if model
10 | end
11 | end
12 | end
13 |
14 | module DataMapper
15 | module Model
16 | def find_by_id(id)
17 | get(id)
18 | end
19 |
20 | def delete(id)
21 | model = self.find_by_id(id)
22 | model.destroy if model
23 | end
24 | end
25 | end
26 |
27 | module ActiveRecord
28 | class Base
29 | class << self
30 | def find_by_id(id)
31 | find(id)
32 | end
33 | end
34 | end
35 | end
36 |
37 |
38 |
--------------------------------------------------------------------------------
/lib/sinatra/rest/rest.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | :index:
3 | :verb: GET
4 | :url: /PLURAL
5 | :control: |-
6 | @PLURAL = call_model_method(MODEL, :all, mp)
7 | :render: |-
8 | RENDERER :'PLURAL/index', options
9 |
10 | :new:
11 | :verb: GET
12 | :url: /PLURAL/new
13 | :control: |-
14 | @SINGULAR = call_model_method(MODEL, :new, mp)
15 | :render: |-
16 | RENDERER :'PLURAL/new', options
17 |
18 | :create:
19 | :verb: POST
20 | :url: /PLURAL
21 | :control: |-
22 | @SINGULAR = call_model_method(MODEL, :new, mp)
23 | @SINGULAR.save
24 | :render: |-
25 | redirect url_for_PLURAL_show(@SINGULAR), 'SINGULAR created'
26 |
27 | :show:
28 | :verb: GET
29 | :url: /PLURAL/:id
30 | :control: |-
31 | @SINGULAR = call_model_method(MODEL, :find_by_id, mp[:id])
32 | :render: |-
33 | if @SINGULAR.nil?
34 | throw :halt, [404, 'SINGULAR not found']
35 | else
36 | RENDERER :'PLURAL/show', options
37 | end
38 |
39 | :edit:
40 | :verb: GET
41 | :url: /PLURAL/:id/edit
42 | :control: |-
43 | @SINGULAR = call_model_method(MODEL, :find_by_id, mp[:id])
44 | :render: |-
45 | RENDERER :'PLURAL/edit', options
46 |
47 | :update:
48 | :verb: PUT
49 | :url: /PLURAL/:id
50 | :control: |-
51 | @SINGULAR = call_model_method(MODEL, :find_by_id, mp[:id])
52 | @SINGULAR.update_attributes(mp) unless @SINGULAR.nil?
53 | :render: |-
54 | if @SINGULAR.nil?
55 | throw :halt, [404, 'SINGULAR not found']
56 | else
57 | redirect url_for_PLURAL_show(@SINGULAR), 'SINGULAR updated'
58 | end
59 |
60 | :destroy:
61 | :verb: DELETE
62 | :url: /PLURAL/:id
63 | :control: |-
64 | call_model_method(MODEL, :delete, mp[:id])
65 | :render: |-
66 | redirect url_for_PLURAL_index, 'SINGULAR destroyed'
67 |
68 |
--------------------------------------------------------------------------------
/sinatra-rest.gemspec:
--------------------------------------------------------------------------------
1 | Gem::Specification.new do |s|
2 | s.platform = Gem::Platform::RUBY
3 | s.name = "sinatra-rest"
4 | s.version = "0.3.3"
5 | s.date = "2009-09-25"
6 | s.authors = ["blindgaenger"]
7 | s.email = "blindgaenger@gmail.com"
8 | s.homepage = "http://github.com/blindgaenger/sinatra-rest"
9 | s.summary = "Generates RESTful routes for the models of a Sinatra application (ActiveRecord, DataMapper, Stone)"
10 |
11 | s.files = [
12 | "Rakefile",
13 | "README.textile",
14 | "lib/sinatra/rest.rb",
15 | "lib/sinatra/rest/adapters.rb",
16 | "lib/sinatra/rest/rest.yaml",
17 | "test/call_order_spec.rb",
18 | "test/crud_spec.rb",
19 | "test/helper.rb",
20 | "test/helpers_spec.rb",
21 | "test/inflection_spec.rb",
22 | "test/routes_spec.rb",
23 | "test/test_spec.rb",
24 | "test/views/people/edit.haml",
25 | "test/views/people/index.haml",
26 | "test/views/people/new.haml",
27 | "test/views/people/show.haml"
28 | ]
29 |
30 | s.require_paths = ["lib"]
31 | s.add_dependency "sinatra", [">= 0.9.0.5"]
32 | s.add_dependency "english", [">= 0.3.1"]
33 |
34 | s.has_rdoc = "false"
35 | end
36 |
37 |
--------------------------------------------------------------------------------
/test/call_order_spec.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/helper'
2 |
3 | describe 'call order' do
4 |
5 | def called_routes
6 | @app.call_order.map {|r, m| r}.uniq
7 | end
8 |
9 | def called_methods
10 | @app.call_order.map {|r, m| m}
11 | end
12 |
13 | before(:each) do
14 | mock_app {
15 | configure do
16 | set :call_order, []
17 | end
18 |
19 | rest Person do
20 | before do |route|
21 | options.call_order << [route, :before]
22 | super
23 | end
24 |
25 | after do |route|
26 | options.call_order << [route, :after]
27 | super
28 | end
29 |
30 | index do
31 | options.call_order << [:index, :index]
32 | super
33 | end
34 |
35 | new do
36 | options.call_order << [:new, :new]
37 | super
38 | end
39 |
40 | create do
41 | options.call_order << [:create, :create]
42 | super
43 | end
44 |
45 | show do
46 | options.call_order << [:show, :show]
47 | super
48 | end
49 |
50 | edit do
51 | options.call_order << [:edit, :edit]
52 | super
53 | end
54 |
55 | update do
56 | options.call_order << [:update, :update]
57 | super
58 | end
59 |
60 | destroy do
61 | options.call_order << [:destroy, :destroy]
62 | super
63 | end
64 | end
65 | }
66 | end
67 |
68 | it 'should call :index in the right order' do
69 | index '/people'
70 | called_methods.should == [:before, :index, :after]
71 | called_routes.should == [:index]
72 | end
73 |
74 | it 'should call :new in the right order' do
75 | new '/people/new'
76 | called_methods.should == [:before, :new, :after]
77 | called_routes.should == [:new]
78 | end
79 |
80 | it 'should call :create in the right order' do
81 | create('/people', :name => 'initial name')
82 | called_methods.should == [:before, :create, :after]
83 | called_routes.should == [:create]
84 | end
85 |
86 | it 'should call :show in the right order' do
87 | show '/people/1'
88 | called_methods.should == [:before, :show, :after]
89 | called_routes.should == [:show]
90 | end
91 |
92 | it 'should call :edit in the right order' do
93 | edit '/people/1/edit'
94 | called_methods.should == [:before, :edit, :after]
95 | called_routes.should == [:edit]
96 | end
97 |
98 | it 'should call :update in the right order' do
99 | update '/people/1', :name => 'new name'
100 | called_methods.should == [:before, :update, :after]
101 | called_routes.should == [:update]
102 | end
103 |
104 | it 'should call :destroy in the right order' do
105 | destroy '/people/1'
106 | called_methods.should == [:before, :destroy, :after]
107 | called_routes.should == [:destroy]
108 | end
109 |
110 | end
111 |
112 |
--------------------------------------------------------------------------------
/test/crud_spec.rb:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/helper'
2 |
3 | describe 'some use cases' do
4 |
5 | def total_models
6 | Person.all.size
7 | end
8 |
9 | require "rexml/document"
10 | def doc(xml)
11 | REXML::Document.new(xml.gsub(/>\s+, '><').strip)
12 | end
13 |
14 | before(:each) do
15 | Person.reset!
16 | mock_rest Person
17 | end
18 |
19 | it 'should list all persons' do
20 | get '/people'
21 | normalized_response.should == [200, '