yodatra 0.1.8 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/yodatra/base.rb CHANGED
@@ -10,6 +10,7 @@ module Yodatra
10
10
  class Base < Sinatra::Base
11
11
  configure :development do
12
12
  register Sinatra::Reloader
13
+ also_reload File.expand_path './**/*.rb'
13
14
  end
14
15
 
15
16
  register Yodatra::Boot
@@ -1,74 +1,116 @@
1
1
  module Yodatra
2
+ # This is a generic model controller that expose a REST API for your models.
3
+ # The responses are encoded in JSON.
4
+ #
5
+ # Simply create your controller that inherits from this class, keeping the naming convention.
6
+ #
7
+ # For example, given a <b>User</b> model, creating a <b>class UsersController < Yodatra::ModelsController</b>, it will expose these routes:
8
+ # GET /users
9
+ # => retrieves all users <i>(attributes exposed are limited by the <b>read_scope</b> method defined in the <b>UsersController</b>)</i>
10
+ #
11
+ # GET /users/:id
12
+ # => retrieves a user <i>(attributes exposed are limited by the <b>read_scope</b> method defined in the <b>UsersController</b>)</i>
13
+ #
14
+ # POST /users
15
+ # => creates a user <i>(attributes assignable are limited by the <b>user_params</b> method defined in the <b>UsersController</b>)</i>
16
+ #
17
+ # PUT /users/:id
18
+ # => updates a user <i>(attributes assignable are limited by the <b>user_params</b> method defined in the <b>UsersController</b>)</i>
19
+ #
20
+ # DELETE /users/:id
21
+ # => deletes a user
22
+ #
23
+ # If your model is referenced by another model, nested routes are also created for you. And you don't need to worry about the references/joins, they are done automaticly!
24
+ # For example, imagine a <b>Team</b> model that has many <b>User</b>s, the following routes will be exposed:
25
+ # GET /team/:team_id/users, GET /team/:team_id/users/:id, POST /team/:team_id/users, PUT /team/:team_id/users/:id and DESTROY /team/:team_id/users/:id
2
26
  class ModelsController < Sinatra::Base
3
27
 
4
28
  before do
5
29
  content_type 'application/json'
6
30
  end
7
31
 
32
+ # Generic route to target ONE resource
33
+ ONE_ROUTE =
34
+ %r{\A/([\w]+?)/([0-9]+)(?:/([\w]+?)/([0-9]+)){0,1}\Z}
35
+
36
+ # Generic route to target ALL resources
37
+ ALL_ROUTE =
38
+ %r{\A/([\w]+?)(?:/([0-9]+)/([\w]+?)){0,1}\Z}
39
+
8
40
  READ_ALL = :read_all
9
- get "/*" do
10
- pass unless involved?
11
- no_route if disabled? READ_ALL
12
- model_name.constantize.all.as_json(read_scope).to_json
41
+ get ALL_ROUTE do
42
+ retrieve_resources READ_ALL do |resource|
43
+ resource.all.as_json(read_scope).to_json
44
+ end
13
45
  end
14
46
 
15
47
  READ_ONE = :read
16
- get "/*/:id" do
17
- pass unless involved?
18
- no_route if disabled? READ_ONE
19
-
20
- @one = model_name.constantize.find params[:id]
21
- @one.as_json(read_scope).to_json
48
+ get ONE_ROUTE do
49
+ retrieve_resources READ_ONE do |resource|
50
+ resource.as_json(read_scope).to_json
51
+ end
22
52
  end
23
53
 
24
54
  CREATE_ONE = :create
25
- post "/*" do
26
- pass unless involved?
27
- no_route if disabled? CREATE_ONE
28
-
29
- hash = self.send("#{model_name.underscore}_params".to_sym)
30
- @one = model_name.constantize.new hash
55
+ post ALL_ROUTE do
56
+ retrieve_resources CREATE_ONE do |resource|
57
+ hash = self.send("#{model_name.underscore}_params".to_sym)
58
+ @one = resource.create hash
31
59
 
32
- if @one.save
33
- @one.as_json(read_scope).to_json
34
- else
35
- status 400
36
- @one.errors.full_messages.to_json
60
+ if @one.save
61
+ @one.as_json(read_scope).to_json
62
+ else
63
+ status 400
64
+ @one.errors.full_messages.to_json
65
+ end
37
66
  end
38
67
  end
39
68
 
40
69
  UPDATE_ONE = :update
41
- put "/*/:id" do
42
- pass unless involved?
43
- no_route if disabled? UPDATE_ONE
44
-
45
- @one = model_name.constantize.find params[:id]
46
-
47
- if !@one.nil? && @one.update_attributes(params)
48
- @one.as_json(read_scope).to_json
49
- else
50
- status 400
51
- if !@one.nil?
52
- @one.errors.full_messages.to_json
70
+ put ONE_ROUTE do
71
+ retrieve_resources UPDATE_ONE do |resource|
72
+ hash = self.send("#{model_name.underscore}_params".to_sym)
73
+ if resource.update_attributes(hash)
74
+ resource.as_json(read_scope).to_json
53
75
  else
54
- ['record not found'].to_json
76
+ status 400
77
+ resource.errors.full_messages.to_json
55
78
  end
56
79
  end
57
80
  end
58
81
 
59
82
  DELETE_ONE = :delete
60
- delete "/*/:id" do
83
+ delete ONE_ROUTE do
84
+ retrieve_resources DELETE_ONE do |resource|
85
+ if resource.destroy
86
+ resource.as_json(read_scope).to_json
87
+ else
88
+ status 400
89
+ resource.errors.full_messages.to_json
90
+ end
91
+ end
92
+ end
93
+
94
+ # Defines a nested route or not and retrieves the correct resource (or resources)
95
+ # @param disables is the name to check if it was disabled
96
+ # @param &block to be yield with the retrieved resource
97
+ def retrieve_resources(disables)
61
98
  pass unless involved?
62
- no_route if disabled? DELETE_ONE
99
+ no_route if disabled? disables
63
100
 
64
- @one = model_name.constantize.find params[:id]
101
+ model = model_name.constantize
102
+ nested = nested_resources if nested?
65
103
 
66
- if @one.destroy
67
- @one.as_json(read_scope).to_json
104
+ if model.nil? || nested.nil? && nested?
105
+ raise ActiveRecord::RecordNotFound
68
106
  else
69
- status 400
70
- @one.errors.full_messages.to_json
107
+ model = nested if nested?
108
+ one_id = nested? ? params[:captures].fourth : params[:captures].second if params[:captures].length == 4
109
+ model = model.find one_id unless one_id.nil?
110
+ yield(model)
71
111
  end
112
+ rescue ActiveRecord::RecordNotFound
113
+ record_not_found
72
114
  end
73
115
 
74
116
  class << self
@@ -76,15 +118,51 @@ module Yodatra
76
118
  self.name.split('::').last.gsub(/sController/, '')
77
119
  end
78
120
 
79
- def route_name
80
- self.model_name.underscore
121
+ # This helper gives the ability to disable default root by specifying
122
+ # a list of routes to disable.
123
+ # @param *opts list of routes to disable (e.g. :create, :destroy)
124
+ def disable(*opts)
125
+ opts.each do |key|
126
+ method = "#{key}_disabled?".to_sym
127
+ undef_method method if method_defined? method
128
+ define_method method, Proc.new {|| true}
129
+ end
81
130
  end
82
131
  end
83
132
 
84
133
  private
85
134
 
135
+ def nested?
136
+ params[:captures].length >= 3 && params[:captures].first(3).none?(&:nil?)
137
+ end
138
+
139
+ def nested_resources
140
+ resources = nil
141
+ begin
142
+ parent_model = params[:captures].first.classify.constantize
143
+ rescue NameError
144
+ parent_model = nil
145
+ end
146
+
147
+ unless parent_model.nil?
148
+ parent = parent_model.find params[:captures].second
149
+ resources = parent.send(involved.to_sym) unless parent.reflections[involved.to_sym].nil?
150
+ end
151
+ resources
152
+ rescue ActiveRecord::RecordNotFound
153
+ nil
154
+ end
155
+
156
+ def involved
157
+ involved = params[:splat] && params[:splat].first
158
+ params[:captures].each_index { |i|
159
+ involved ||= params[:captures].last(i+1).first if !params[:captures].last(i+1).first.nil? && params[:captures].last(i+1).first.match(/[\d]+/).nil?
160
+ } unless params[:captures].nil?
161
+ involved
162
+ end
163
+
86
164
  def involved?
87
- params[:splat].first == model_name.downcase
165
+ !involved.match(/#{model_name.underscore}[s]?/).nil?
88
166
  end
89
167
 
90
168
  # read_scope defaults to all attrs of the model
@@ -95,7 +173,7 @@ module Yodatra
95
173
  # create/update scope defaults to all data given in the POST/PUT
96
174
  def method_missing(name, *args)
97
175
  if name.to_s == "#{model_name.underscore}_params"
98
- return params.reject{|k,v| %w(splat captures).include? k}
176
+ return params.reject{|k,v| %w(splat captures id updated_at created_at).include? k}
99
177
  end
100
178
  end
101
179
 
@@ -103,25 +181,18 @@ module Yodatra
103
181
  self.class.model_name
104
182
  end
105
183
 
106
- def route_name
107
- self.class.route_name
108
- end
109
-
110
184
  def disabled? key
111
- params[:splat].first != route_name || self.class.method_defined?(key) && self.send(key)
185
+ method = ((nested? ? 'nested_' : '')+"#{key}_disabled?").to_sym
186
+ self.class.method_defined?(method) && self.send(method)
112
187
  end
113
188
 
114
189
  def no_route
115
190
  pass
116
191
  end
117
192
 
118
- class << self
119
- def disable(*opts)
120
- opts.each do |key|
121
- undef_method(key) if method_defined? key
122
- define_method(key, Proc.new {|| true})
123
- end
124
- end
193
+ def record_not_found
194
+ status 404
195
+ ['record not found'].to_json
125
196
  end
126
197
 
127
198
  end
@@ -22,5 +22,5 @@ begin
22
22
  end
23
23
  end
24
24
  rescue LoadError
25
- raise "Error: in order to use Yodatra's throttling middleware you will need Redis. Add 'redis' to your Gemfile or simply gem install 'redis'"
25
+ raise LoadError, "In order to use Yodatra's throttling middleware you will need Redis. Add 'redis' to your Gemfile or simply gem install 'redis'", __FILE__
26
26
  end
@@ -1,3 +1,3 @@
1
1
  module Yodatra
2
- VERSION = '0.1.8'
2
+ VERSION = '0.2.0'
3
3
  end
data/lib/yodatra.rb CHANGED
@@ -1 +1,2 @@
1
- require 'yodatra/base'
1
+ require 'yodatra/base'
2
+ require 'yodatra/version'
@@ -0,0 +1,50 @@
1
+ # Mock model constructed for the tests
2
+ class Model
3
+ ALL = %w(a b c)
4
+ class << self
5
+ def all; ALL.map{ |e| Model.new({:data=> e }) }; end
6
+ def find(id)
7
+ if id.to_i < ALL.length
8
+ Model.new({:data => ALL[id.to_i]})
9
+ else
10
+ raise ActiveRecord::RecordNotFound
11
+ end
12
+ end
13
+ def create(param); me = self.new(param); me.save; me; end
14
+ end
15
+ def initialize(param); @data = param[:data]; self; end
16
+ def save
17
+ unless @data.nil? || @data.match(/[\d]+/)
18
+ ALL.push(@data) unless ALL.include?(@data)
19
+ true
20
+ else
21
+ false
22
+ end
23
+ end
24
+ def update_attributes params
25
+ if params[:data] != @data
26
+ unless params[:data].match(/[\d]+/)
27
+ ALL[ALL.index(@data)] = params[:data]
28
+ @data = params[:data]
29
+ true
30
+ else
31
+ false
32
+ end
33
+ end
34
+ end
35
+ def destroy
36
+ if ALL.include? @data
37
+ ALL.delete @data
38
+ true
39
+ else
40
+ false
41
+ end
42
+ end
43
+ def reflections
44
+ {:models => Hash.new}
45
+ end
46
+ def models
47
+ Model
48
+ end
49
+ def errors; []; end
50
+ end
@@ -1,31 +1,5 @@
1
1
  require File.expand_path '../../spec_helper.rb', __FILE__
2
-
3
- # Mock model constructed for the tests
4
- class Model
5
- ALL = %w(a b c)
6
- class << self
7
- def all; ALL.map{ |e| Model.new({:data=> e }) }; end
8
- def find(id); Model.new({:data => ALL[id.to_i]}); end
9
- end
10
- def initialize(param); @data = param[:data]; end
11
- def save
12
- if @data.is_a? String
13
- ALL.push(@data)
14
- true
15
- else
16
- false
17
- end
18
- end
19
- def destroy
20
- if ALL.include? @data
21
- ALL.delete @data
22
- true
23
- else
24
- false
25
- end
26
- end
27
- def errors; []; end
28
- end
2
+ require File.expand_path '../../data/model.rb', __FILE__
29
3
 
30
4
  describe 'Model controller' do
31
5
 
@@ -37,7 +11,15 @@ describe 'Model controller' do
37
11
  describe 'Getting a collection of the Model' do
38
12
  context 'default' do
39
13
  it 'should have a GET all route' do
40
- get '/model'
14
+ get '/models'
15
+
16
+ last_response.should be_ok
17
+ expect(last_response.body).to eq(Model::ALL.map{|e| {:data => e} }.to_json)
18
+ end
19
+ end
20
+ context 'nested' do
21
+ it 'should have a GET all route' do
22
+ get '/models/1/models'
41
23
 
42
24
  last_response.should be_ok
43
25
  expect(last_response.body).to eq(Model::ALL.map{|e| {:data => e} }.to_json)
@@ -50,7 +32,7 @@ describe 'Model controller' do
50
32
  end
51
33
  end
52
34
  it 'should fail with no route available' do
53
- get '/model'
35
+ get '/models'
54
36
 
55
37
  last_response.should_not be_ok
56
38
  end
@@ -58,7 +40,7 @@ describe 'Model controller' do
58
40
  end
59
41
  describe 'getting an specific Model instance' do
60
42
  it 'should have a GET one route' do
61
- get '/model/2'
43
+ get '/models/2'
62
44
 
63
45
  last_response.should be_ok
64
46
  expect(last_response.body).to eq({ :data => 'c'}.to_json)
@@ -70,7 +52,7 @@ describe 'Model controller' do
70
52
  end
71
53
  end
72
54
  it 'should fail with no route available' do
73
- get '/model/1'
55
+ get '/models/1'
74
56
 
75
57
  last_response.should_not be_ok
76
58
  end
@@ -78,18 +60,18 @@ describe 'Model controller' do
78
60
  end
79
61
  describe 'creating a Model instance' do
80
62
  context 'with correct model params' do
81
- it 'adds creates an instance, saves it and succeed' do
63
+ it 'creates an instance, saves it and succeed' do
82
64
  expect{
83
- post '/model', {:data => 'd'}
65
+ post '/models', {:data => 'd'}
84
66
  }.to change(Model::ALL, :length).by(1)
85
67
 
86
68
  last_response.should be_ok
87
69
  end
88
70
  end
89
71
  context 'with incorrect params' do
90
- it 'doesn t create an instance and fails' do
72
+ it 'does not create an instance and fails' do
91
73
  expect{
92
- post '/model', {}
74
+ post '/models', {}
93
75
  }.to change(Model::ALL, :length).by(0)
94
76
 
95
77
  last_response.should_not be_ok
@@ -103,26 +85,86 @@ describe 'Model controller' do
103
85
  end
104
86
  end
105
87
  it 'should fail with no route available' do
106
- post '/model', {:data => 'd'}
88
+ post '/models', {:data => 'd'}
107
89
 
108
90
  last_response.should_not be_ok
109
91
  end
110
92
  end
111
93
  end
94
+ describe 'updating a Model instance' do
95
+ context 'that does not exist' do
96
+ it 'replies with an error' do
97
+ expect{
98
+ put '/models/21', {:data => 'e'}
99
+ }.to change(Model::ALL, :length).by(0)
100
+
101
+ last_response.should_not be_ok
102
+ expect(last_response.body).to eq(['record not found'].to_json)
103
+ end
104
+ end
105
+ context 'that already exist' do
106
+ context 'with correct model params' do
107
+ it 'updates the model, saves it and succeed' do
108
+ expect{
109
+ put '/models/2', {:data => 'e'}
110
+ }.to change(Model::ALL, :length).by(0)
111
+
112
+ last_response.should be_ok
113
+ expect(last_response.body).to eq({ :data => 'e'}.to_json)
114
+ expect(Model.find(2).to_json).to eq({ :data => 'e'}.to_json)
115
+ end
116
+ end
117
+ context 'with incorrect params' do
118
+ it 'replies with an error message' do
119
+ expect{
120
+ put '/models/2', {:data => 321}
121
+ }.to change(Model::ALL, :length).by(0)
122
+
123
+ last_response.should_not be_ok
124
+ expect(last_response.body).to eq(@errors.to_json)
125
+ end
126
+ end
127
+ context 'when the updating route is disabled' do
128
+ before do
129
+ class Yodatra::ModelsController
130
+ disable :update
131
+ end
132
+ end
133
+ it 'should fail with no route available' do
134
+ put '/models', {:data => 'd'}
135
+
136
+ last_response.should_not be_ok
137
+ end
138
+ end
139
+ end
140
+ end
112
141
  describe 'deleting a Model instance' do
113
142
  context 'targeting an existing instance' do
114
143
  it 'deletes the instance and succeed' do
115
144
  expect{
116
- delete '/model/1'
145
+ delete '/models/1'
117
146
  }.to change(Model::ALL, :length).by(-1)
118
147
 
119
148
  last_response.should be_ok
120
149
  end
121
150
  end
151
+ context 'targeting an existing instance but deletion fails' do
152
+ before do
153
+ allow_any_instance_of(Model).to receive(:destroy).and_return(false)
154
+ end
155
+ it 'should not delete the instance and fails' do
156
+ expect{
157
+ delete '/models/1/models/1'
158
+ }.to change(Model::ALL, :length).by(0)
159
+
160
+ last_response.should_not be_ok
161
+ expect(last_response.body).to eq(@errors.to_json)
162
+ end
163
+ end
122
164
  context 'targeting a not existing instance' do
123
165
  it 'does not delete any instance and fails' do
124
166
  expect{
125
- delete '/model/6'
167
+ delete '/models/6'
126
168
  }.to change(Model::ALL, :length).by(0)
127
169
 
128
170
  last_response.should_not be_ok
@@ -135,11 +177,39 @@ describe 'Model controller' do
135
177
  end
136
178
  end
137
179
  it 'should fail with no route available' do
138
- delete '/model/2'
180
+ delete '/models/2'
139
181
 
140
182
  last_response.should_not be_ok
141
183
  end
142
184
  end
143
185
  end
144
186
 
187
+ describe 'non existing models' do
188
+ context 'in nested routes' do
189
+ context 'with wrong route name' do
190
+ before do
191
+ class Yodatra::ModelsController
192
+ method = "read_all_disabled?".to_sym
193
+ undef_method method if method_defined? method
194
+ end
195
+ end
196
+ it 'fails with a record not found message' do
197
+ get '/modeels/1/models'
198
+
199
+ last_response.should_not be_ok
200
+ expect(last_response.body).to eq(['record not found'].to_json)
201
+ end
202
+ end
203
+ context 'with non existant parent model' do
204
+ it 'fails with a record not found message' do
205
+ get '/models/123/models'
206
+
207
+ last_response.should_not be_ok
208
+ expect(last_response.body).to eq(['record not found'].to_json)
209
+ end
210
+ end
211
+ end
212
+
213
+ end
214
+
145
215
  end
@@ -0,0 +1,22 @@
1
+ require File.expand_path '../../spec_helper.rb', __FILE__
2
+
3
+ describe 'Throttling middleware' do
4
+ context 'When redis gem is not installed' do
5
+ it 'raises an exception when required' do
6
+ expect{
7
+ require 'yodatra/throttling'
8
+ }.to raise_error LoadError, /gem install 'redis'/
9
+ end
10
+ end
11
+
12
+ #context 'When redis is installed' do
13
+ # it 'does not raise an exception when required' do
14
+ # allow_any_instance_of(Kernel).to receive(:require).and_call_original
15
+ # allow_any_instance_of(Kernel).to receive(:require).with('redis').and_return(true)
16
+ #
17
+ # expect{
18
+ # require 'yodatra/throttling'
19
+ # }.not_to raise_error
20
+ # end
21
+ #end
22
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yodatra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-03-07 00:00:00.000000000 Z
12
+ date: 2014-03-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -142,8 +142,10 @@ files:
142
142
  - lib/yodatra/throttling.rb
143
143
  - lib/yodatra/utils.rb
144
144
  - lib/yodatra/version.rb
145
+ - spec/data/model.rb
145
146
  - spec/spec_helper.rb
146
147
  - spec/unit/models_controller_spec.rb
148
+ - spec/unit/throttling_spec.rb
147
149
  - yodatra.gemspec
148
150
  homepage: http://squareteam.github.io/yodatra
149
151
  licenses:
@@ -179,3 +181,4 @@ specification_version: 3
179
181
  summary: Classy backend development with the speed of Sinatra and the power of ActiveRecord
180
182
  test_files:
181
183
  - spec/unit/models_controller_spec.rb
184
+ - spec/unit/throttling_spec.rb