responder_controller 0.2.0 → 0.3.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/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