responder_controller 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,9 @@
1
+ *ResponderController 0.2.0*
2
+
3
+ * Added .serves_scopes, which takes :only and :except options to restrict the list of active
4
+ record scopes that #index will serve. If a forbidden scope is requested, ForbiddenScope will be
5
+ raised, which will render as a 403 if uncaught.
6
+
1
7
  *ResponderController 0.1.3*
2
8
 
3
9
  * Render BadScopes as 400s, not 422s
data/Rakefile CHANGED
@@ -10,7 +10,7 @@ begin
10
10
  gem.email = "phil.h.smith@gmail.com"
11
11
  gem.homepage = "http://github.com/phs/responder_controller"
12
12
  gem.authors = ["Phil Smith"]
13
- gem.add_dependency "activesupport", ">= 3.0.0.beta2"
13
+ gem.add_dependency "activesupport", ">= 3.0.0.beta3"
14
14
  gem.add_development_dependency "rspec", ">= 1.2.9"
15
15
  # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
16
16
  end
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.3.0
@@ -1,8 +1,11 @@
1
1
  require 'active_support/inflector'
2
2
  require 'active_support/core_ext/string/inflections'
3
- require 'active_support/core_ext/module/delegation'
4
3
 
5
4
  module ResponderController
5
+ autoload :ClassMethods, 'responder_controller/class_methods'
6
+ autoload :InstanceMethods, 'responder_controller/instance_methods'
7
+ autoload :Actions, 'responder_controller/actions'
8
+
6
9
  # Root of scope-related exceptions.
7
10
  class ScopeError < StandardError
8
11
  end
@@ -30,300 +33,4 @@ module ResponderController
30
33
  ForbiddenScope.name => :forbidden
31
34
  }) if defined? ActionDispatch
32
35
  end
33
-
34
- # Configure how the controller finds and serves models of what flavor.
35
- module ClassMethods
36
- # The underscored, fully-qualified name of the served model class.
37
- #
38
- # By default, it is the underscored controller class name, without +_controller+.
39
- def model_class_name
40
- @model_class_name || name.underscore.gsub(/_controller$/, '').singularize
41
- end
42
-
43
- # Declare the underscored, fully-qualified name of the served model class.
44
- #
45
- # Modules are declared with separating slashes, such as in <tt>admin/setting</tt>. Strings
46
- # or symbols are accepted, but other values (including actual classes) will raise
47
- # <tt>ArgumentError</tt>s.
48
- def serves_model(model_class_name)
49
- unless model_class_name.is_a? String or model_class_name.is_a? Symbol
50
- raise ArgumentError.new "Model must be a string or symbol"
51
- end
52
-
53
- @model_class_name = model_class_name.to_s
54
- end
55
-
56
- # Declare what active record scopes to allow or forbid to requests.
57
- #
58
- # .serves_scopes follows the regular :only/:except form: white-listed scopes are passed by
59
- # name as <tt>:only => [:allowed, :scopes]</tt> or <tt>:only => :just_one</tt>. Similarly,
60
- # black-listed ones are passed under <tt>:except</tt>.
61
- #
62
- # If a white-list is passed, all other requested scopes (i.e. scopes named by query parameters)
63
- # will be denied, raising <tt>ForbiddenScope</tt>. If a black-list is passed, only they will
64
- # raise the exception.
65
- def serves_scopes(options = nil)
66
- @serves_scopes ||= {}
67
-
68
- if options
69
- raise TypeError unless options.is_a? Hash
70
-
71
- new_keys = @serves_scopes.keys | options.keys
72
- unless new_keys == [:only] or new_keys == [:except]
73
- raise ArgumentError.new("serves_scopes takes exactly one of :only and :except")
74
- end
75
-
76
- @serves_scopes[options.keys.first] ||= []
77
- @serves_scopes[options.keys.first].concat [*options.values.first]
78
- end
79
-
80
- @serves_scopes
81
- end
82
-
83
- # Declare leading arguments ("responder context") for +respond_with+ calls.
84
- #
85
- # +respond_with+ creates urls from models. To avoid strongly coupling models to a url
86
- # structure, it can take any number of leading parameters a la +polymorphic_url+.
87
- # +responds_within+ declares these leading parameters, to be used on each +respond_with+ call.
88
- #
89
- # It takes either a varargs or a block, but not both. In
90
- # InstanceMethods#respond_with_contextual, the blocks are called with +instance_exec+, taking
91
- # the model (or models) as a parameter. They should return an array.
92
- def responds_within(*args, &block)
93
- if block and args.any?
94
- raise ArgumentError.new("responds_within can take arguments or a block, but not both")
95
- elsif block or args.any?
96
- @responds_within ||= []
97
- if not args.empty?
98
- @responds_within.concat args
99
- else
100
- @responds_within << block
101
- end
102
- end
103
-
104
- @responds_within || model_class_name.split('/')[0...-1].collect { |m| m.to_sym }
105
- end
106
-
107
- # The served model class, identified by #model_class_name.
108
- def model_class
109
- model_class_name.camelize.constantize
110
- end
111
-
112
- # Declare a class-level scope for model collections.
113
- #
114
- # The model class is expected to respond to +all+, returning an Enumerable of models.
115
- # Declared scopes are applied to (and replace) this collection, suitable for active record
116
- # scopes.
117
- #
118
- # It takes one of a string, symbol or block. Symbols and strings are called as methods on the
119
- # collection without arguments. Blocks are called with +instance_exec+ taking the current,
120
- # accumulated query and returning the new, scoped one.
121
- def scope(*args, &block)
122
- scope = args.first || block
123
-
124
- scope = scope.to_sym if String === scope
125
- unless scope.is_a? Symbol or scope.is_a? Proc
126
- raise ArgumentError.new "Scope must be a string, symbol or block"
127
- end
128
-
129
- (@scopes ||= []) << scope
130
- end
131
-
132
- # The array of declared class-level scopes, as symbols or procs.
133
- attr_reader :scopes
134
-
135
- # Declare a (non-singleton) parent resource class.
136
- #
137
- # <tt>children_of 'accounts/user'</tt> implies a scope and some responder context. The scope
138
- # performs an ActiveRecord <tt>where :user_id => params[:user_id]</tt>. The responder context
139
- # is a call to <tt>#responds_within</tt> declaring the parent model's modules along with the
140
- # parent itself, found with <tt>Accounts::User.find(params[:user_id])</tt>.
141
- def children_of(parent_model_class_name)
142
- parent_model_class_name = parent_model_class_name.to_s.underscore
143
-
144
- parent_name_parts = parent_model_class_name.split('/')
145
- parent_modules = parent_name_parts[0...-1].collect(&:to_sym)
146
- parent_id = "#{parent_name_parts.last}_id".to_sym # TODO: primary key
147
-
148
- scope do |query|
149
- query.where parent_id => params[parent_id]
150
- end
151
-
152
- responds_within do
153
- parent = parent_model_class_name.camelize.constantize.find params[parent_id]
154
- parent_modules + [parent]
155
- end
156
- end
157
- end
158
-
159
- # Instance methods that support the Actions module.
160
- module InstanceMethods
161
- delegate :model_class_name, :model_class, :scopes, :responds_within, :serves_scopes,
162
- :to => "self.class"
163
-
164
- # Apply scopes to the given query.
165
- #
166
- # Applicable scopes come from two places. They are either declared at the class level with
167
- # <tt>ClassMethods#scope</tt>, or named in the request itself. The former is good for
168
- # defining topics or enforcing security, while the latter is free slicing and dicing for
169
- # clients.
170
- #
171
- # Class-level scopes are applied first. Request scopes come after, and are discovered by
172
- # examining +params+. If any +params+ key matches a name found in
173
- # <tt>ClassMethods#model_class.scopes.keys</tt>, then it is taken to be a scope and is
174
- # applied. The values under that +params+ key are passed along as arguments.
175
- def scope(query)
176
- query = (scopes || []).inject(query) do |query, scope|
177
- if Symbol === scope and model_class.scopes.key? scope
178
- query.send scope
179
- elsif Proc === scope
180
- instance_exec query, &scope
181
- else
182
- raise ArgumentError.new "Unknown scope #{model_class}.#{scope}"
183
- end
184
- end
185
-
186
- requested = (model_class.scopes.keys & params.keys.collect { |k| k.to_sym })
187
- raise ForbiddenScope if serves_scopes[:only] && (requested - serves_scopes[:only]).any?
188
- raise ForbiddenScope if serves_scopes[:except] && (requested & serves_scopes[:except]).any?
189
-
190
- query = requested.inject(query) do |query, scope|
191
- query.send scope, *params[scope.to_s] rescue raise BadScope
192
- end
193
-
194
- query
195
- end
196
-
197
- # Find all models in #scope.
198
- #
199
- # The initial query is <tt>ClassMethods#model_class.scoped</tt>.
200
- def find_models
201
- scope model_class.scoped
202
- end
203
-
204
- # Find a particular model.
205
- #
206
- # #find_models is asked to find a model with <tt>params[:id]</tt>. This ensures that
207
- # class-level scopes are enforced (potentially for security.)
208
- def find_model
209
- find_models.find(params[:id])
210
- end
211
-
212
- # The underscored model class name, as a symbol.
213
- #
214
- # Model modules are omitted.
215
- def model_slug
216
- model_class_name.split('/').last.to_sym
217
- end
218
-
219
- # Like #model_slug, but plural.
220
- def models_slug
221
- model_slug.to_s.pluralize.to_sym
222
- end
223
-
224
- # The name of the instance variable holding a single model instance.
225
- def model_ivar
226
- "@#{model_slug}"
227
- end
228
-
229
- # The name of the instance variable holding a collection of models.
230
- def models_ivar
231
- model_ivar.pluralize
232
- end
233
-
234
- # Retrive #models_ivar
235
- def models
236
- instance_variable_get models_ivar
237
- end
238
-
239
- # Assign #models_ivar
240
- def models=(models)
241
- instance_variable_set models_ivar, models
242
- end
243
-
244
- # Retrive #model_ivar
245
- def model
246
- instance_variable_get model_ivar
247
- end
248
-
249
- # Assign #model_ivar
250
- def model=(model)
251
- instance_variable_set model_ivar, model
252
- end
253
-
254
- # Apply ClassMethods#responds_within to the given model (or symbol.)
255
- #
256
- # "Apply" just means turning +responds_within+ into an array and appending +model+ to the
257
- # end. If +responds_within+ is an array, it used directly.
258
- #
259
- # If it is a proc, it is called with +instance_exec+, passing +model+ in. It should return an
260
- # array, which +model+ will be appended to. (So, don't include it in the return value.)
261
- def responder_context(model)
262
- context = responds_within.collect do |o|
263
- o = instance_exec model, &o if o.is_a? Proc
264
- o
265
- end.flatten + [model]
266
- end
267
-
268
- # Pass +model+ through InstanceMethods#responder_context, and pass that to #respond_with.
269
- def respond_with_contextual(model)
270
- respond_with *responder_context(model)
271
- end
272
- end
273
-
274
- # The seven standard restful actions.
275
- module Actions
276
- # Find, assign and respond with models.
277
- def index
278
- self.models = find_models
279
- respond_with_contextual models
280
- end
281
-
282
- # Find, assign and respond with a single model.
283
- def show
284
- self.model = find_model
285
- respond_with_contextual model
286
- end
287
-
288
- # Build (but do not save), assign and respond with a new model.
289
- #
290
- # The new model is built from the <tt>InstanceMethods#find_models</tt> collection, meaning it
291
- # could inherit any properties implied by those scopes.
292
- def new
293
- self.model = find_models.build
294
- respond_with_contextual model
295
- end
296
-
297
- # Find, assign and respond with a single model.
298
- def edit
299
- self.model = find_model
300
- respond_with_contextual model
301
- end
302
-
303
- # Build, save, assign and respond with a new model.
304
- #
305
- # The model is created with attributes from the request params, under the
306
- # <tt>InstanceMethods#model_slug</tt> key.
307
- def create
308
- self.model = find_models.build(params[model_slug])
309
- model.save
310
- respond_with_contextual model
311
- end
312
-
313
- # Find, update, assign and respond with a single model.
314
- #
315
- # The new attributes are taken from the request params, under the
316
- # <tt>InstanceMethods#model_slug</tt> key.
317
- def update
318
- self.model = find_model
319
- model.update_attributes(params[model_slug])
320
- respond_with_contextual model
321
- end
322
-
323
- # Find and destroy a model. Respond with <tt>InstanceMethods#models_slug</tt>.
324
- def destroy
325
- find_model.destroy
326
- respond_with_contextual models_slug
327
- end
328
- end
329
36
  end
@@ -0,0 +1,57 @@
1
+ module ResponderController
2
+ # The seven standard restful actions.
3
+ module Actions
4
+ # Find, assign and respond with models.to_a.
5
+ def index
6
+ self.models = find_models
7
+ respond_with_contextual models.to_a
8
+ end
9
+
10
+ # Find, assign and respond with a single model.
11
+ def show
12
+ self.model = find_model
13
+ respond_with_contextual model
14
+ end
15
+
16
+ # Build (but do not save), assign and respond with a new model.
17
+ #
18
+ # The new model is built from the <tt>InstanceMethods#find_models</tt> collection, meaning it
19
+ # could inherit any properties implied by those scopes.
20
+ def new
21
+ self.model = find_models.build
22
+ respond_with_contextual model
23
+ end
24
+
25
+ # Find, assign and respond with a single model.
26
+ def edit
27
+ self.model = find_model
28
+ respond_with_contextual model
29
+ end
30
+
31
+ # Build, save, assign and respond with a new model.
32
+ #
33
+ # The model is created with attributes from the request params, under the
34
+ # <tt>InstanceMethods#model_slug</tt> key.
35
+ def create
36
+ self.model = find_models.build(params[model_slug])
37
+ model.save
38
+ respond_with_contextual model
39
+ end
40
+
41
+ # Find, update, assign and respond with a single model.
42
+ #
43
+ # The new attributes are taken from the request params, under the
44
+ # <tt>InstanceMethods#model_slug</tt> key.
45
+ def update
46
+ self.model = find_model
47
+ model.update_attributes(params[model_slug])
48
+ respond_with_contextual model
49
+ end
50
+
51
+ # Find and destroy a model. Respond with <tt>InstanceMethods#models_slug</tt>.
52
+ def destroy
53
+ find_model.destroy
54
+ respond_with_contextual models_slug
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,126 @@
1
+ module ResponderController
2
+ # Configure how the controller finds and serves models of what flavor.
3
+ module ClassMethods
4
+ # The underscored, fully-qualified name of the served model class.
5
+ #
6
+ # By default, it is the underscored controller class name, without +_controller+.
7
+ def model_class_name
8
+ @model_class_name || name.underscore.gsub(/_controller$/, '').singularize
9
+ end
10
+
11
+ # Declare the underscored, fully-qualified name of the served model class.
12
+ #
13
+ # Modules are declared with separating slashes, such as in <tt>admin/setting</tt>. Strings
14
+ # or symbols are accepted, but other values (including actual classes) will raise
15
+ # <tt>ArgumentError</tt>s.
16
+ def serves_model(model_class_name)
17
+ unless model_class_name.is_a? String or model_class_name.is_a? Symbol
18
+ raise ArgumentError.new "Model must be a string or symbol"
19
+ end
20
+
21
+ @model_class_name = model_class_name.to_s
22
+ end
23
+
24
+ # Declare what active record scopes to allow or forbid to requests.
25
+ #
26
+ # .serves_scopes follows the regular :only/:except form: white-listed scopes are passed by
27
+ # name as <tt>:only => [:allowed, :scopes]</tt> or <tt>:only => :just_one</tt>. Similarly,
28
+ # black-listed ones are passed under <tt>:except</tt>.
29
+ #
30
+ # If a white-list is passed, all other requested scopes (i.e. scopes named by query parameters)
31
+ # will be denied, raising <tt>ForbiddenScope</tt>. If a black-list is passed, only they will
32
+ # raise the exception.
33
+ def serves_scopes(options = nil)
34
+ @serves_scopes ||= {}
35
+
36
+ if options
37
+ raise TypeError unless options.is_a? Hash
38
+
39
+ new_keys = @serves_scopes.keys | options.keys
40
+ unless new_keys == [:only] or new_keys == [:except]
41
+ raise ArgumentError.new("serves_scopes takes exactly one of :only and :except")
42
+ end
43
+
44
+ @serves_scopes[options.keys.first] ||= []
45
+ @serves_scopes[options.keys.first].concat [*options.values.first]
46
+ end
47
+
48
+ @serves_scopes
49
+ end
50
+
51
+ # Declare leading arguments ("responder context") for +respond_with+ calls.
52
+ #
53
+ # +respond_with+ creates urls from models. To avoid strongly coupling models to a url
54
+ # structure, it can take any number of leading parameters a la +polymorphic_url+.
55
+ # +responds_within+ declares these leading parameters, to be used on each +respond_with+ call.
56
+ #
57
+ # It takes either a varargs or a block, but not both. In
58
+ # InstanceMethods#respond_with_contextual, the blocks are called with +instance_exec+, taking
59
+ # the model (or models) as a parameter. They should return an array.
60
+ def responds_within(*args, &block)
61
+ if block and args.any?
62
+ raise ArgumentError.new("responds_within can take arguments or a block, but not both")
63
+ elsif block or args.any?
64
+ @responds_within ||= []
65
+ if not args.empty?
66
+ @responds_within.concat args
67
+ else
68
+ @responds_within << block
69
+ end
70
+ end
71
+
72
+ @responds_within || model_class_name.split('/')[0...-1].collect { |m| m.to_sym }
73
+ end
74
+
75
+ # The served model class, identified by #model_class_name.
76
+ def model_class
77
+ model_class_name.camelize.constantize
78
+ end
79
+
80
+ # Declare a class-level scope for model collections.
81
+ #
82
+ # The model class is expected to respond to +all+, returning an Enumerable of models.
83
+ # Declared scopes are applied to (and replace) this collection, suitable for active record
84
+ # scopes.
85
+ #
86
+ # It takes one of a string, symbol or block. Symbols and strings are called as methods on the
87
+ # collection without arguments. Blocks are called with +instance_exec+ taking the current,
88
+ # accumulated query and returning the new, scoped one.
89
+ def scope(*args, &block)
90
+ scope = args.first || block
91
+
92
+ scope = scope.to_sym if String === scope
93
+ unless scope.is_a? Symbol or scope.is_a? Proc
94
+ raise ArgumentError.new "Scope must be a string, symbol or block"
95
+ end
96
+
97
+ (@scopes ||= []) << scope
98
+ end
99
+
100
+ # The array of declared class-level scopes, as symbols or procs.
101
+ attr_reader :scopes
102
+
103
+ # Declare a (non-singleton) parent resource class.
104
+ #
105
+ # <tt>children_of 'accounts/user'</tt> implies a scope and some responder context. The scope
106
+ # performs an ActiveRecord <tt>where :user_id => params[:user_id]</tt>. The responder context
107
+ # is a call to <tt>#responds_within</tt> declaring the parent model's modules along with the
108
+ # parent itself, found with <tt>Accounts::User.find(params[:user_id])</tt>.
109
+ def children_of(parent_model_class_name)
110
+ parent_model_class_name = parent_model_class_name.to_s.underscore
111
+
112
+ parent_name_parts = parent_model_class_name.split('/')
113
+ parent_modules = parent_name_parts[0...-1].collect(&:to_sym)
114
+ parent_id = "#{parent_name_parts.last}_id".to_sym # TODO: primary key
115
+
116
+ scope do |query|
117
+ query.where parent_id => params[parent_id]
118
+ end
119
+
120
+ responds_within do
121
+ parent = parent_model_class_name.camelize.constantize.find params[parent_id]
122
+ parent_modules + [parent]
123
+ end
124
+ end
125
+ end
126
+ end