yodatra 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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