introspective_grape 0.5.5 → 0.6.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/Gemfile +22 -18
  4. data/README.md +2 -0
  5. data/introspective_grape.gemspec +5 -4
  6. data/lib/introspective_grape/api.rb +461 -454
  7. data/lib/introspective_grape/create_helpers.rb +25 -25
  8. data/lib/introspective_grape/traversal.rb +1 -1
  9. data/lib/introspective_grape/validators.rb +36 -34
  10. data/lib/introspective_grape/version.rb +5 -5
  11. data/spec/dummy/Gemfile +23 -22
  12. data/spec/dummy/app/api/api_helpers.rb +38 -37
  13. data/spec/dummy/app/api/dummy_api.rb +61 -61
  14. data/spec/dummy/app/api/permissions_helper.rb +7 -7
  15. data/spec/dummy/app/helpers/current.rb +3 -0
  16. data/spec/dummy/app/models/chat_message.rb +34 -34
  17. data/spec/dummy/app/models/chat_user.rb +16 -16
  18. data/spec/dummy/app/models/location.rb +26 -26
  19. data/spec/dummy/app/models/role.rb +30 -30
  20. data/spec/dummy/app/models/team.rb +14 -9
  21. data/spec/dummy/app/models/user/chatter.rb +79 -79
  22. data/spec/dummy/bin/bundle +3 -3
  23. data/spec/dummy/bin/rails +4 -4
  24. data/spec/dummy/bin/setup +36 -29
  25. data/spec/dummy/bin/update +31 -0
  26. data/spec/dummy/bin/yarn +11 -0
  27. data/spec/dummy/config/application.rb +30 -37
  28. data/spec/dummy/config/boot.rb +3 -6
  29. data/spec/dummy/config/environment.rb +5 -11
  30. data/spec/dummy/config/environments/development.rb +54 -42
  31. data/spec/dummy/config/environments/production.rb +81 -79
  32. data/spec/dummy/config/environments/test.rb +47 -44
  33. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  34. data/spec/dummy/config/initializers/assets.rb +14 -11
  35. data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
  36. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -3
  37. data/spec/dummy/config/initializers/inflections.rb +16 -16
  38. data/spec/dummy/config/initializers/new_framework_defaults_5_2.rb +38 -0
  39. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -14
  40. data/spec/dummy/config/locales/en.yml +33 -23
  41. data/spec/dummy/config/storage.yml +34 -0
  42. data/spec/models/image_spec.rb +10 -14
  43. data/spec/requests/project_api_spec.rb +185 -182
  44. data/spec/requests/user_api_spec.rb +221 -221
  45. data/spec/support/request_helpers.rb +22 -21
  46. metadata +21 -158
@@ -1,454 +1,461 @@
1
- require 'action_controller'
2
- require 'kaminari'
3
- #require 'byebug'
4
- require 'grape-kaminari'
5
- require 'introspective_grape/validators'
6
-
7
- class IntrospectiveGrapeError < StandardError
8
- end
9
-
10
- module IntrospectiveGrape
11
- # rubocop:disable Metrics/ClassLength
12
- class API < Grape::API::Instance
13
- # rubocop:enable Metrics/ClassLength
14
- extend IntrospectiveGrape::Helpers
15
- extend IntrospectiveGrape::CreateHelpers
16
- extend IntrospectiveGrape::Filters
17
- extend IntrospectiveGrape::Traversal
18
- extend IntrospectiveGrape::Doc
19
- extend IntrospectiveGrape::SnakeParams
20
- include Grape::Kaminari
21
-
22
- # Allow files to be uploaded through ActionController:
23
- ActionController::Parameters::PERMITTED_SCALAR_TYPES.push Rack::Multipart::UploadedFile, ActionController::Parameters
24
-
25
- # Generate uniform RESTful APIs for an ActiveRecord Model:
26
- #
27
- # class <Some API Controller> < IntrospectiveGrape::API
28
- # exclude_actions Model, :index,:show,:create,:update,:destroy
29
- # default_includes Model, <associations for eager loading>
30
- # restful <Model Class>, [<strong, param, fields>]
31
- #
32
- # class <Model>Entity < Grape::Entity
33
- # expose :id, :attribute
34
- # expose :association, using: <Association>Entity>
35
- # end
36
- # end
37
- #
38
- # To define a Grape param type for a virtual attribute or override the defaut param
39
- # type from model introspection, define a class method in the model with the param
40
- # types for the attributes specified in a hash:
41
- #
42
- # def self.grape_param_types
43
- # { "<attribute name>" => Grape::API::Boolean,
44
- # "<attribute name>" => Integer,
45
- # "<attribute name>" => String }
46
- # end
47
- #
48
- # For nested models declared in Rails' strong params both the Grape params for the
49
- # nested params as well as nested routes will be declared, allowing for
50
- # a good deal of flexibility for API consumers out of the box, nested params for
51
- # bulk updates and nested routes for interacting with single records.
52
- #
53
-
54
- class << self
55
- PLURAL_REFLECTIONS = [ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasManyReflection].freeze
56
- # mapping of activerecord/postgres 'type's to ruby data classes, where they differ
57
- PG2RUBY = { datetime: DateTime }.freeze
58
-
59
- def inherited(child)
60
- super(child)
61
- child.before do
62
- # Ensure that a user is logged in.
63
- send(IntrospectiveGrape::API.authentication_method(self))
64
- end
65
-
66
- child.snake_params_before_validation if IntrospectiveGrape.config.camelize_parameters
67
- end
68
-
69
- # We will probably need before and after hooks eventually, but haven't yet...
70
- # api_actions.each do |a|
71
- # define_method "before_#{a}_hook" do |model, params| ; end
72
- # define_method "after_#{a}_hook" do |model, params| ; end
73
- # end
74
-
75
- # rubocop:disable Metrics/AbcSize
76
- def restful(model, strong_params=[], routes=[])
77
- raise IntrospectiveGrapeError.new("#{model.name}'s attribute_param_types class method needs to be changed to grape_param_types") if model.respond_to?(:attribute_param_types)
78
-
79
- # Recursively define endpoints for the model and any nested models.
80
- #
81
- # model: the model class for the API
82
- # whitelist: a list of fields in Rail's strong params structure, also used to
83
- # generate grape's permitted params.
84
- # routes: An array of OpenStruct representations of a nested route's ancestors
85
- #
86
-
87
- # Defining the api will break pending migrations during db:migrate, so bail:
88
- begin ActiveRecord::Migration.check_pending! rescue return end
89
-
90
- # normalize the whitelist to symbols
91
- strong_params.map! {|f| f.is_a?(String) ? f.to_sym : f }
92
- # default to a flat representation of the model's attributes if left unspecified
93
- strong_params = strong_params.blank? ? model.attribute_names.map(&:to_sym) - %i(id updated_at created_at) : strong_params
94
-
95
- # The strong params will be the same for all routes, differing from the Grape params
96
- # when routes are nested
97
- whitelist = whitelist( strong_params )
98
-
99
- # As routes are nested keep track of the routes, we are preventing siblings from
100
- # appending to the routes array here:
101
- routes = build_routes(routes, model)
102
- define_routes(routes, whitelist)
103
-
104
- # Top level declaration of the Grape::API namespace for the resource:
105
- resource routes.first.name.pluralize do
106
- # yield to append additional routes under the root namespace
107
- yield if block_given?
108
- end
109
- end
110
-
111
- def define_routes(routes, api_params)
112
- define_endpoints(routes, api_params)
113
- # recursively define endpoints
114
- model = routes.last.model || return
115
-
116
- api_params.select {|a| a.is_a?(Hash) }.each do |nested|
117
- # Recursively add RESTful nested routes for every nested model:
118
- nested.each do |r, fields|
119
- # Look at model.reflections to find the association's class name:
120
- reflection_name = r.to_s.sub(/_attributes$/, '')
121
- begin
122
- relation = model.reflections[reflection_name].class_name.constantize
123
- rescue StandardError
124
- Rails.logger.fatal "Can't find associated model for #{r} on #{model}"
125
- end
126
-
127
- next_routes = build_routes(routes, relation, reflection_name)
128
- define_routes(next_routes, fields)
129
- end
130
- end
131
- end
132
-
133
- def define_index(dsl, routes, model, api_params)
134
- root = routes.first
135
- klass = routes.first.klass
136
- name = routes.last.name.pluralize
137
- simple_filters(klass, model, api_params)
138
-
139
- dsl.desc "list #{name}" do
140
- detail klass.index_documentation(name)
141
- end
142
- dsl.params do
143
- klass.declare_filter_params(self, klass, model, api_params)
144
- use :pagination, per_page: klass.pagination[:per_page]||25, max_per_page: klass.pagination[:max_per_page], offset: klass.pagination[:offset]||0 if klass.pagination
145
- end
146
- dsl.get '/' do
147
- # Invoke the policy for the action, defined in the policy classes for the model:
148
- authorize root.model.new, :index?
149
-
150
- # Nested route indexes need to be scoped by the API's top level policy class:
151
- records = policy_scope( root.model.includes(klass.default_includes(root.model)) )
152
- records = klass.apply_filter_params(klass, model, api_params, params, records)
153
- records = records.map {|r| klass.find_leaves( routes, r, params ) }.flatten.compact.uniq
154
-
155
- # paginate the records using Kaminari
156
- records = paginate(Kaminari.paginate_array(records)) if klass.pagination
157
- present records, with: "#{klass}::#{model}Entity".constantize
158
- end
159
- end
160
-
161
- def define_show(dsl, routes, model, _api_params)
162
- name = routes.last.name.singularize
163
- klass = routes.first.klass
164
- dsl.desc "retrieve a #{name}" do
165
- detail klass.show_documentation(name)
166
- end
167
- dsl.get ":#{routes.last.swagger_key}" do
168
- authorize @model, :show?
169
- present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
170
- end
171
- end
172
-
173
- def define_create(dsl, routes, model, api_params)
174
- name = routes.last.name.singularize
175
- klass = routes.first.klass
176
- dsl.desc "create a #{name}" do
177
- detail klass.create_documentation(name)
178
- end
179
- dsl.params do
180
- klass.generate_params(self, :create, model, api_params, true)
181
- end
182
- dsl.post do
183
- representation = @model ? klass.add_new_records_to_root_record(self, routes, params, @model) : klass.create_new_record(self, routes, params)
184
- present representation, with: "#{klass}::#{model}Entity".constantize
185
- end
186
- end
187
-
188
- def define_update(dsl, routes, model, api_params)
189
- klass = routes.first.klass
190
- name = routes.last.name.singularize
191
- dsl.desc "update a #{name}" do
192
- detail klass.update_documentation(name)
193
- end
194
- dsl.params do
195
- klass.generate_params(self, :update, model, api_params, true)
196
- end
197
- dsl.put ":#{routes.last.swagger_key}" do
198
- authorize @model, :update?
199
-
200
- @model.update_attributes!( safe_params(params).permit(klass.whitelist) )
201
-
202
- if IntrospectiveGrape.config.skip_object_reload
203
- present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
204
- else
205
- default_includes = routes.first.klass.default_includes(routes.first.model)
206
- present klass.find_leaf(routes, @model.class.includes(default_includes).find(@model.id), params), with: "#{klass}::#{model}Entity".constantize
207
- end
208
- end
209
- end
210
-
211
- # rubocop:enable Metrics/AbcSize
212
- def define_destroy(dsl, routes, _model, _api_params)
213
- klass = routes.first.klass
214
- name = routes.last.name.singularize
215
- dsl.desc "destroy a #{name}" do
216
- detail klass.destroy_documentation(name)
217
- end
218
- dsl.delete ":#{routes.last.swagger_key}" do
219
- authorize @model, :destroy?
220
- present status: (klass.find_leaf(routes, @model, params).destroy ? true : false)
221
- end
222
- end
223
-
224
- def convert_nested_params_hash(dsl, routes)
225
- root = routes.first
226
- klass = self
227
- dsl.after_validation do
228
- next unless params[root.key] # there was no one, nothing to see
229
-
230
- # After Grape validates its parameters:
231
- # 1) Find the root model instance for the API if its passed (implicitly either
232
- # an update/destroy on the root node or it's a nested route
233
- # 2) For nested endpoints convert the params hash into Rails-compliant nested
234
- # attributes, to be passed to the root instance for update. This keeps
235
- # behavior consistent between bulk and single record updates.
236
- @model = root.model.includes( root.klass.default_includes(root.model) ).find(params[root.key])
237
- @params.merge!( klass.merge_nested_params(routes, params) )
238
- end
239
- end
240
-
241
- def merge_nested_params(routes, params)
242
- attr = params.reject {|k| [routes.first.key, :api_key].include?(k) }
243
- build_nested_attributes(routes[1..-1], attr)
244
- end
245
-
246
- def define_restful_api(dsl, routes, model, api_params)
247
- # declare index, show, update, create, and destroy methods:
248
- API_ACTIONS.each do |action|
249
- send("define_#{action}", dsl, routes, model, api_params) unless exclude_actions(model).include?(action)
250
- end
251
- end
252
-
253
- def define_endpoints(routes, api_params)
254
- # De-reference these as local variables from their class scope, or when we make
255
- # calls to the API they will be whatever they were last set to by the recursive
256
- # calls to "nest_routes".
257
- routes = routes.clone
258
- api_params = api_params.clone
259
- model = routes.last.model || return
260
-
261
- # We define the param keys for ID fields in camelcase for swagger's URL substitution,
262
- # they'll come back in snake case in the params hash, the API as a whole is agnostic:
263
- namespace = routes[0..-2].map {|p| "#{p.name.pluralize}/:#{p.swagger_key}/" }.join + routes.last.name.pluralize
264
-
265
- klass = self # the 'resource' block changes the context to the Grape::API::Instance...
266
- resource namespace do
267
- klass.convert_nested_params_hash(self, routes)
268
- klass.define_restful_api(self, routes, model, api_params)
269
- end
270
- end
271
-
272
- def build_routes(routes, model, reflection_name=nil)
273
- routes = routes.clone
274
- # routes: the existing routes array passed from the parent
275
- # model: the model being manipulated in this leaf
276
- # reflection_name: the association name from the original strong_params declaration
277
- #
278
- # Constructs an array representation of the route's models and their associations,
279
- # a /root/:rootId/branch/:branchId/leaf/:leafId path would have flat array like
280
- # [root, branch, leaf] representing the path structure and its models, used to
281
- # manipulate ActiveRecord relationships and params hashes and so on.
282
- parent_model = routes.last&.model
283
- return routes if model == parent_model
284
-
285
- name = reflection_name || model.name.underscore
286
- reflection = parent_model&.reflections&.fetch(reflection_name)
287
- swagger_key = IntrospectiveGrape.config.camelize_parameters ? "#{name.singularize.camelize(:lower)}Id" : "#{name.singularize}_id"
288
-
289
- routes.push OpenStruct.new(klass: self, name: name, param: "#{name}_attributes", model: model,
290
- many?: plural?(parent_model, reflection),
291
- key: "#{name.singularize}_id".to_sym,
292
- swagger_key: swagger_key, reflection: reflection)
293
- end
294
-
295
- def plural?(model, reflection)
296
- (model && PLURAL_REFLECTIONS.include?(reflection.class))
297
- end
298
-
299
- def build_nested_attributes(routes, hash)
300
- # Recursively re-express the flat attributes hash from nested routes as nested
301
- # attributes that can be used to perform an update on the root model.
302
-
303
- # do nothing if the params are already nested.
304
- return {} if routes.blank? || hash[routes.first.param]
305
-
306
- route = routes.shift
307
- # change 'x_id' to 'x_attributes': { id: id, y_attributes: {} }
308
- id = hash.delete route.key
309
- attributes = id ? { id: id } : {}
310
-
311
- attributes.merge!( hash ) if routes.blank? # assign param values to the last reference
312
-
313
- if route.many? # nest it in an array if it is a has_many association
314
- { route.param => [attributes.merge( build_nested_attributes(routes, hash) )] }
315
- else
316
- { route.param => attributes.merge( build_nested_attributes(routes, hash) ) }
317
- end
318
- end
319
-
320
- def generate_params(dsl, action, model, fields, is_root_endpoint=false)
321
- # We'll be doing a recursive walk (to handle nested attributes) down the
322
- # whitelisted params, generating the Grape param definitions by introspecting
323
- # on the model and its associations.
324
- raise "Invalid action: #{action}" unless %i(update create).include?(action)
325
-
326
- # dsl : The Grape::Validations::ParamsScope object
327
- # action: create or update
328
- # model : The ActiveRecord model class
329
- # fields: The whitelisted data structure for Rails' strong params, from which we
330
- # infer Grape's parameters
331
-
332
- # skip the ID param at the root level endpoint, so we don't duplicate the URL parameter (api/v#/model/modelId)
333
- fields -= [:id] if is_root_endpoint
334
-
335
- fields.each do |field|
336
- if field.is_a?(Hash)
337
- generate_nested_params(dsl, action, model, field)
338
- elsif action == :create && param_required?(model, field)
339
- # All params are optional on an update, only require them during creation.
340
- # Updating a record with new child models will have to rely on ActiveRecord
341
- # validations:
342
- dsl.requires field, { type: param_type(model, field) }.merge( validations(model, field) )
343
- else
344
- # dsl.optional field, *options
345
- dsl.optional field, { type: param_type(model, field) }.merge( validations(model, field) )
346
- end
347
- end
348
- end
349
-
350
- def validations(model, field)
351
- (model.try(:grape_validations) || {}).with_indifferent_access[field] || {}
352
- end
353
-
354
- def generate_nested_params(dsl, action, model, fields)
355
- klass = self
356
- fields.each do |r, v|
357
- # Look at model.reflections to find the association's class name:
358
- reflection = r.to_s.sub(/_attributes$/, '') # the reflection name
359
- relation = find_relation(model, reflection)
360
-
361
- if file_attachment?(model, r)
362
- # Handle Carrierwave file upload fields
363
- s = %i(filename type name tempfile head) - v
364
- Rails.logger.warn "Missing required file upload parameters #{s} for uploader field #{r}" if s.present?
365
- elsif plural_reflection?(model, reflection)
366
- # In case you need a refresher on how these work:
367
- # http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
368
- dsl.optional r, type: Array do |dl|
369
- klass.generate_params(dl, action, relation, v)
370
- klass.add_destroy_param(dl, model, reflection, action)
371
- end
372
- else
373
- # TODO: handle any remaining correctly. Presently defaults to a Hash
374
- # http://www.rubydoc.info/github/rails/rails/ActiveRecord/Reflection
375
- # ThroughReflection, HasOneReflection,
376
- # HasAndBelongsToManyReflection, BelongsToReflection
377
- dsl.optional r, type: Hash do |dl|
378
- klass.generate_params(dl, action, relation, v)
379
- klass.add_destroy_param(dl, model, reflection, action)
380
- end
381
- end
382
- end
383
- end
384
-
385
- def plural_reflection?(model, reflection)
386
- PLURAL_REFLECTIONS.include?( model.reflections[reflection].class )
387
- end
388
-
389
- def find_relation(model, reflection)
390
- begin
391
- model.reflections[reflection].class_name.constantize
392
- rescue StandardError
393
- model
394
- end
395
- end
396
-
397
- def file_attachment?(model, field)
398
- (model.respond_to?(:uploaders) && model.uploaders[field.to_sym]) || # carrierwave
399
- (model.respond_to?(:attachment_definitions) && model.attachment_definitions[field.to_sym]) ||
400
- (defined?(Paperclip::Attachment) && model.send(:new).try(field).is_a?(Paperclip::Attachment))
401
- end
402
-
403
- def param_type(model, field)
404
- # Translate from the AR type to the GrapeParam types
405
- field = field.to_s
406
- db_type = (model&.try(:columns_hash) || {})[field]&.type
407
-
408
- # Check if it's a file attachment, look for an override class from the model,
409
- # check PG2RUBY, use the database type, or fail over to a String:
410
- uploaded_file?(model, field) ||
411
- check_model_for_type(model, field) ||
412
- PG2RUBY[db_type] ||
413
- db_type_constant(db_type) ||
414
- String # default to String if nothing else works
415
- end
416
-
417
- def uploaded_file?(model, field)
418
- file_attachment?(model, field) && Rack::Multipart::UploadedFile
419
- end
420
-
421
- def check_model_for_type(model, field)
422
- (model.try(:grape_param_types) || {}).with_indifferent_access[field]
423
- end
424
-
425
- def db_type_constant(db_type)
426
- begin
427
- db_type.to_s.camelize.constantize
428
- rescue StandardError
429
- nil
430
- end
431
- end
432
-
433
- def param_required?(model, field)
434
- # Detect if the field is a required field for the create action
435
- return false if skip_presence_validations.include?(field)
436
-
437
- validated_field = field.match?(/_id/) ? field.to_s.sub(/_id\z/, '').to_sym : field.to_sym
438
-
439
- model.validators_on(validated_field).any? {|v| v.is_a? ActiveRecord::Validations::PresenceValidator }
440
- end
441
-
442
- def add_destroy_param(dsl, model, reflection, action)
443
- return if action == :create
444
-
445
- raise "#{model} does not accept nested attributes for #{reflection}" unless model.nested_attributes_options[reflection.to_sym]
446
-
447
- return unless model.nested_attributes_options[reflection.to_sym][:allow_destroy]
448
-
449
- # If destruction is allowed append the _destroy field
450
- dsl.optional '_destroy', type: Integer
451
- end
452
- end
453
- end
454
- end
1
+ require 'action_controller'
2
+ require 'kaminari'
3
+ # require 'byebug'
4
+ require 'grape-kaminari'
5
+ require 'introspective_grape/validators'
6
+
7
+ class IntrospectiveGrapeError < StandardError
8
+ end
9
+
10
+ module IntrospectiveGrape
11
+ # rubocop:disable Metrics/ClassLength
12
+ class API < Grape::API::Instance
13
+ # rubocop:enable Metrics/ClassLength
14
+ extend IntrospectiveGrape::Helpers
15
+ extend IntrospectiveGrape::CreateHelpers
16
+ extend IntrospectiveGrape::Filters
17
+ extend IntrospectiveGrape::Traversal
18
+ extend IntrospectiveGrape::Doc
19
+ extend IntrospectiveGrape::SnakeParams
20
+ include Grape::Kaminari
21
+
22
+ # Allow files to be uploaded through ActionController:
23
+ ActionController::Parameters::PERMITTED_SCALAR_TYPES.push Rack::Multipart::UploadedFile, ActionController::Parameters
24
+
25
+ # Generate uniform RESTful APIs for an ActiveRecord Model:
26
+ #
27
+ # class <Some API Controller> < IntrospectiveGrape::API
28
+ # exclude_actions Model, :index,:show,:create,:update,:destroy
29
+ # default_includes Model, <associations for eager loading>
30
+ # restful <Model Class>, [<strong, param, fields>]
31
+ #
32
+ # class <Model>Entity < Grape::Entity
33
+ # expose :id, :attribute
34
+ # expose :association, using: <Association>Entity>
35
+ # end
36
+ # end
37
+ #
38
+ # To define a Grape param type for a virtual attribute or override the defaut param
39
+ # type from model introspection, define a class method in the model with the param
40
+ # types for the attributes specified in a hash:
41
+ #
42
+ # def self.grape_param_types
43
+ # { "<attribute name>" => Grape::API::Boolean,
44
+ # "<attribute name>" => Integer,
45
+ # "<attribute name>" => String }
46
+ # end
47
+ #
48
+ # For nested models declared in Rails' strong params both the Grape params for the
49
+ # nested params as well as nested routes will be declared, allowing for
50
+ # a good deal of flexibility for API consumers out of the box, nested params for
51
+ # bulk updates and nested routes for interacting with single records.
52
+ #
53
+
54
+ class << self
55
+ PLURAL_REFLECTIONS = [ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasManyReflection].freeze
56
+ # mapping of activerecord/postgres 'type's to ruby data classes, where they differ
57
+ PG2RUBY = { datetime: DateTime }.freeze
58
+
59
+ def inherited(child)
60
+ super(child)
61
+ child.before do
62
+ # Ensure that a user is logged in.
63
+ send(IntrospectiveGrape::API.authentication_method(self))
64
+ end
65
+
66
+ child.snake_params_before_validation if IntrospectiveGrape.config.camelize_parameters
67
+ end
68
+
69
+ # We will probably need before and after hooks eventually, but haven't yet...
70
+ # api_actions.each do |a|
71
+ # define_method "before_#{a}_hook" do |model, params| ; end
72
+ # define_method "after_#{a}_hook" do |model, params| ; end
73
+ # end
74
+
75
+ # rubocop:disable Metrics/AbcSize
76
+ def restful(model, strong_params=[], routes=[])
77
+ raise IntrospectiveGrapeError.new("#{model.name}'s attribute_param_types class method needs to be changed to grape_param_types") if model.respond_to?(:attribute_param_types)
78
+
79
+ # Recursively define endpoints for the model and any nested models.
80
+ #
81
+ # model: the model class for the API
82
+ # whitelist: a list of fields in Rail's strong params structure, also used to
83
+ # generate grape's permitted params.
84
+ # routes: An array of OpenStruct representations of a nested route's ancestors
85
+ #
86
+
87
+ # Defining the api will break pending migrations during db:migrate, so bail:
88
+ begin ActiveRecord::Migration.check_pending! rescue return end
89
+
90
+ # normalize the whitelist to symbols
91
+ strong_params.map! {|f| f.is_a?(String) ? f.to_sym : f }
92
+ # default to a flat representation of the model's attributes if left unspecified
93
+ strong_params = strong_params.blank? ? model.attribute_names.map(&:to_sym) - %i(id updated_at created_at) : strong_params
94
+
95
+ # The strong params will be the same for all routes, differing from the Grape params
96
+ # when routes are nested
97
+ whitelist = whitelist( strong_params )
98
+
99
+ # As routes are nested keep track of the routes, we are preventing siblings from
100
+ # appending to the routes array here:
101
+ routes = build_routes(routes, model)
102
+
103
+ # Top level declaration of the Grape::API namespace for the resource:
104
+ resource routes.first.name.pluralize do
105
+ # yield to prepend user-defined routes under the root namespace first,
106
+ yield if block_given?
107
+ end
108
+
109
+ # Then define IntrospectiveGrape's routes:
110
+ define_routes(routes, whitelist)
111
+ end
112
+
113
+ def define_routes(routes, api_params)
114
+ define_endpoints(routes, api_params)
115
+ # recursively define endpoints
116
+ model = routes.last.model || return
117
+
118
+ api_params.select {|a| a.is_a?(Hash) }.each do |nested|
119
+ # Recursively add RESTful nested routes for every nested model:
120
+ nested.each do |r, fields|
121
+ # Look at model.reflections to find the association's class name:
122
+ reflection_name = r.to_s.sub(/_attributes$/, '')
123
+ begin
124
+ relation = model.reflections[reflection_name].class_name.constantize
125
+ rescue StandardError
126
+ Rails.logger.fatal "Can't find associated model for #{r} on #{model}"
127
+ end
128
+
129
+ next_routes = build_routes(routes, relation, reflection_name)
130
+ define_routes(next_routes, fields)
131
+ end
132
+ end
133
+ end
134
+
135
+ def define_index(dsl, routes, model, api_params)
136
+ root = routes.first
137
+ klass = routes.first.klass
138
+ name = routes.last.name.pluralize
139
+ simple_filters(klass, model, api_params)
140
+
141
+ dsl.desc "list #{name}" do
142
+ detail klass.index_documentation(name)
143
+ end
144
+ dsl.params do
145
+ klass.declare_filter_params(self, klass, model, api_params)
146
+ use :pagination, per_page: klass.pagination[:per_page]||25, max_per_page: klass.pagination[:max_per_page], offset: klass.pagination[:offset]||0 if klass.pagination
147
+ end
148
+ dsl.get '/' do
149
+ # Invoke the policy for the action, defined in the policy classes for the model:
150
+ authorize root.model.new, :index?
151
+
152
+ # Nested route indexes need to be scoped by the API's top level policy class:
153
+ records = policy_scope( root.model.includes(klass.default_includes(root.model)) )
154
+ records = klass.apply_filter_params(klass, model, api_params, params, records)
155
+ records = records.map {|r| klass.find_leaves( routes, r, params ) }.flatten.compact.uniq
156
+
157
+ # paginate the records using Kaminari
158
+ records = paginate(Kaminari.paginate_array(records)) if klass.pagination
159
+ present records, with: "#{klass}::#{model}Entity".constantize
160
+ end
161
+ end
162
+
163
+ def define_show(dsl, routes, model, _api_params)
164
+ name = routes.last.name.singularize
165
+ klass = routes.first.klass
166
+ dsl.desc "retrieve a #{name}" do
167
+ detail klass.show_documentation(name)
168
+ end
169
+ dsl.get ":#{routes.last.swagger_key}" do
170
+ authorize @model, :show?
171
+ present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
172
+ end
173
+ end
174
+
175
+ def define_create(dsl, routes, model, api_params)
176
+ name = routes.last.name.singularize
177
+ klass = routes.first.klass
178
+ dsl.desc "create a #{name}" do
179
+ detail klass.create_documentation(name)
180
+ end
181
+ dsl.params do
182
+ klass.generate_params(self, :create, model, api_params, true)
183
+ end
184
+ dsl.post do
185
+ representation = @model ? klass.add_new_records_to_root_record(self, routes, params, @model) : klass.create_new_record(self, routes, params)
186
+ present representation, with: "#{klass}::#{model}Entity".constantize
187
+ end
188
+ end
189
+
190
+ def define_update(dsl, routes, model, api_params)
191
+ klass = routes.first.klass
192
+ name = routes.last.name.singularize
193
+ dsl.desc "update a #{name}" do
194
+ detail klass.update_documentation(name)
195
+ end
196
+ dsl.params do
197
+ klass.generate_params(self, :update, model, api_params, true)
198
+ end
199
+ dsl.put ":#{routes.last.swagger_key}" do
200
+ authorize @model, :update?
201
+
202
+ @model.update!( safe_params(params).permit(klass.whitelist) )
203
+
204
+ if IntrospectiveGrape.config.skip_object_reload
205
+ present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
206
+ else
207
+ default_includes = routes.first.klass.default_includes(routes.first.model)
208
+ present klass.find_leaf(routes, @model.class.includes(default_includes).find(@model.id), params), with: "#{klass}::#{model}Entity".constantize
209
+ end
210
+ end
211
+ end
212
+
213
+ def define_destroy(dsl, routes, model, _api_params)
214
+ klass = routes.first.klass
215
+ name = routes.last.name.singularize
216
+ dsl.desc "destroy a #{name}" do
217
+ detail klass.destroy_documentation(name)
218
+ end
219
+ dsl.params do
220
+ requires routes.last.swagger_key, type: klass.param_type(model, model.primary_key)
221
+ end
222
+ dsl.delete ":#{routes.last.swagger_key}" do
223
+ authorize @model, :destroy?
224
+ present status: (klass.find_leaf(routes, @model, params).destroy ? true : false)
225
+ end
226
+ end
227
+ # rubocop:enable Metrics/AbcSize
228
+
229
+ def convert_nested_params_hash(dsl, routes)
230
+ root = routes.first
231
+ klass = self
232
+ dsl.after_validation do
233
+ next unless params[root.key] # there was no one, nothing to see
234
+
235
+ # After Grape validates its parameters:
236
+ # 1) Find the root model instance for the API if its passed (implicitly either
237
+ # an update/destroy on the root node or it's a nested route
238
+ # 2) For nested endpoints convert the params hash into Rails-compliant nested
239
+ # attributes, to be passed to the root instance for update. This keeps
240
+ # behavior consistent between bulk and single record updates.
241
+ @model = root.model.includes( root.klass.default_includes(root.model) ).find(params[root.key])
242
+ @params.merge!( klass.merge_nested_params(routes, params) )
243
+ end
244
+ end
245
+
246
+ def merge_nested_params(routes, params)
247
+ attr = params.reject {|k| [routes.first.key, :api_key].include?(k) }
248
+ build_nested_attributes(routes[1..], attr)
249
+ end
250
+
251
+ def define_restful_api(dsl, routes, model, api_params)
252
+ # declare index, show, update, create, and destroy methods:
253
+ API_ACTIONS.each do |action|
254
+ send("define_#{action}", dsl, routes, model, api_params) unless exclude_actions(model).include?(action)
255
+ end
256
+ end
257
+
258
+ def define_endpoints(routes, api_params)
259
+ # De-reference these as local variables from their class scope, or when we make
260
+ # calls to the API they will be whatever they were last set to by the recursive
261
+ # calls to "nest_routes".
262
+ routes = routes.clone
263
+ api_params = api_params.clone
264
+ model = routes.last.model || return
265
+
266
+ # We define the param keys for ID fields in camelcase for swagger's URL substitution,
267
+ # they'll come back in snake case in the params hash, the API as a whole is agnostic:
268
+ namespace = routes[0..-2].map {|p| "#{p.name.pluralize}/:#{p.swagger_key}/" }.join + routes.last.name.pluralize
269
+
270
+ klass = self # the 'resource' block changes the context to the Grape::API::Instance...
271
+ resource namespace do
272
+ klass.convert_nested_params_hash(self, routes)
273
+ klass.define_restful_api(self, routes, model, api_params)
274
+ end
275
+ end
276
+
277
+ def build_routes(routes, model, reflection_name=nil)
278
+ routes = routes.clone
279
+ # routes: the existing routes array passed from the parent
280
+ # model: the model being manipulated in this leaf
281
+ # reflection_name: the association name from the original strong_params declaration
282
+ #
283
+ # Constructs an array representation of the route's models and their associations,
284
+ # a /root/:rootId/branch/:branchId/leaf/:leafId path would have flat array like
285
+ # [root, branch, leaf] representing the path structure and its models, used to
286
+ # manipulate ActiveRecord relationships and params hashes and so on.
287
+ parent_model = routes.last&.model
288
+ return routes if model == parent_model
289
+
290
+ name = reflection_name || model.name.underscore
291
+ reflection = parent_model&.reflections&.fetch(reflection_name)
292
+ swagger_key = IntrospectiveGrape.config.camelize_parameters ? "#{name.singularize.camelize(:lower)}Id" : "#{name.singularize}_id"
293
+
294
+ routes.push OpenStruct.new( # rubocop:disable Style/OpenStructUse
295
+ klass: self, name: name, param: "#{name}_attributes", model: model,
296
+ many?: plural?(parent_model, reflection),
297
+ key: "#{name.singularize}_id".to_sym,
298
+ swagger_key: swagger_key, reflection: reflection
299
+ )
300
+ end
301
+
302
+ def plural?(model, reflection)
303
+ (model && PLURAL_REFLECTIONS.include?(reflection.class))
304
+ end
305
+
306
+ def build_nested_attributes(routes, hash)
307
+ # Recursively re-express the flat attributes hash from nested routes as nested
308
+ # attributes that can be used to perform an update on the root model.
309
+
310
+ # do nothing if the params are already nested.
311
+ return {} if routes.blank? || hash[routes.first.param]
312
+
313
+ route = routes.shift
314
+ # change 'x_id' to 'x_attributes': { id: id, y_attributes: {} }
315
+ id = hash.delete route.key
316
+ attributes = id ? { id: id } : {}
317
+
318
+ attributes.merge!( hash ) if routes.blank? # assign param values to the last reference
319
+
320
+ if route.many? # nest it in an array if it is a has_many association
321
+ { route.param => [attributes.merge( build_nested_attributes(routes, hash) )] }
322
+ else
323
+ { route.param => attributes.merge( build_nested_attributes(routes, hash) ) }
324
+ end
325
+ end
326
+
327
+ def generate_params(dsl, action, model, fields, is_root_endpoint=false)
328
+ # We'll be doing a recursive walk (to handle nested attributes) down the
329
+ # whitelisted params, generating the Grape param definitions by introspecting
330
+ # on the model and its associations.
331
+ raise "Invalid action: #{action}" unless %i(update create).include?(action)
332
+
333
+ # dsl : The Grape::Validations::ParamsScope object
334
+ # action: create or update
335
+ # model : The ActiveRecord model class
336
+ # fields: The whitelisted data structure for Rails' strong params, from which we
337
+ # infer Grape's parameters
338
+
339
+ # skip the ID param at the root level endpoint, so we don't duplicate the URL parameter (api/v#/model/modelId)
340
+ fields -= [:id] if is_root_endpoint
341
+
342
+ fields.each do |field|
343
+ if field.is_a?(Hash)
344
+ generate_nested_params(dsl, action, model, field)
345
+ elsif action == :create && param_required?(model, field)
346
+ # All params are optional on an update, only require them during creation.
347
+ # Updating a record with new child models will have to rely on ActiveRecord
348
+ # validations:
349
+ dsl.requires field, { type: param_type(model, field) }.merge( validations(model, field) )
350
+ else
351
+ # dsl.optional field, *options
352
+ dsl.optional field, { type: param_type(model, field) }.merge( validations(model, field) )
353
+ end
354
+ end
355
+ end
356
+
357
+ def validations(model, field)
358
+ (model.try(:grape_validations) || {}).with_indifferent_access[field] || {}
359
+ end
360
+
361
+ def generate_nested_params(dsl, action, model, fields)
362
+ klass = self
363
+ fields.each do |r, v|
364
+ # Look at model.reflections to find the association's class name:
365
+ reflection = r.to_s.sub(/_attributes$/, '') # the reflection name
366
+ relation = find_relation(model, reflection)
367
+
368
+ if file_attachment?(model, r)
369
+ # Handle Carrierwave file upload fields
370
+ s = %i(filename type name tempfile head) - v
371
+ Rails.logger.warn "Missing required file upload parameters #{s} for uploader field #{r}" if s.present?
372
+ elsif plural_reflection?(model, reflection)
373
+ # In case you need a refresher on how these work:
374
+ # http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
375
+ dsl.optional r, type: Array do |dl|
376
+ klass.generate_params(dl, action, relation, v)
377
+ klass.add_destroy_param(dl, model, reflection, action)
378
+ end
379
+ else
380
+ # TODO: handle any remaining correctly. Presently defaults to a Hash
381
+ # http://www.rubydoc.info/github/rails/rails/ActiveRecord/Reflection
382
+ # ThroughReflection, HasOneReflection,
383
+ # HasAndBelongsToManyReflection, BelongsToReflection
384
+ dsl.optional r, type: Hash do |dl|
385
+ klass.generate_params(dl, action, relation, v)
386
+ klass.add_destroy_param(dl, model, reflection, action)
387
+ end
388
+ end
389
+ end
390
+ end
391
+
392
+ def plural_reflection?(model, reflection)
393
+ PLURAL_REFLECTIONS.include?( model.reflections[reflection].class )
394
+ end
395
+
396
+ def find_relation(model, reflection)
397
+ begin
398
+ model.reflections[reflection].class_name.constantize
399
+ rescue StandardError
400
+ model
401
+ end
402
+ end
403
+
404
+ def file_attachment?(model, field)
405
+ (model.respond_to?(:uploaders) && model.uploaders[field.to_sym]) || # carrierwave
406
+ (model.respond_to?(:attachment_definitions) && model.attachment_definitions[field.to_sym]) ||
407
+ (defined?(Paperclip::Attachment) && model.send(:new).try(field).is_a?(Paperclip::Attachment))
408
+ end
409
+
410
+ def param_type(model, field)
411
+ # Translate from the AR type to the GrapeParam types
412
+ field = field.to_s
413
+ db_type = (model&.try(:columns_hash) || {})[field]&.type
414
+
415
+ # Check if it's a file attachment, look for an override class from the model,
416
+ # check PG2RUBY, use the database type, or fail over to a String:
417
+ uploaded_file?(model, field) ||
418
+ check_model_for_type(model, field) ||
419
+ PG2RUBY[db_type] ||
420
+ db_type_constant(db_type) ||
421
+ String # default to String if nothing else works
422
+ end
423
+
424
+ def uploaded_file?(model, field)
425
+ file_attachment?(model, field) && Rack::Multipart::UploadedFile
426
+ end
427
+
428
+ def check_model_for_type(model, field)
429
+ (model.try(:grape_param_types) || {}).with_indifferent_access[field]
430
+ end
431
+
432
+ def db_type_constant(db_type)
433
+ begin
434
+ db_type.to_s.camelize.constantize
435
+ rescue StandardError
436
+ nil
437
+ end
438
+ end
439
+
440
+ def param_required?(model, field)
441
+ # Detect if the field is a required field for the create action
442
+ return false if skip_presence_validations.include?(field)
443
+
444
+ validated_field = field.match?(/_id/) ? field.to_s.sub(/_id\z/, '').to_sym : field.to_sym
445
+
446
+ model.validators_on(validated_field).any? {|v| v.is_a? ActiveRecord::Validations::PresenceValidator }
447
+ end
448
+
449
+ def add_destroy_param(dsl, model, reflection, action)
450
+ return if action == :create
451
+
452
+ raise "#{model} does not accept nested attributes for #{reflection}" unless model.nested_attributes_options[reflection.to_sym]
453
+
454
+ return unless model.nested_attributes_options[reflection.to_sym][:allow_destroy]
455
+
456
+ # If destruction is allowed append the _destroy field
457
+ dsl.optional '_destroy', type: Integer
458
+ end
459
+ end
460
+ end
461
+ end