introspective_grape 0.5.7 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +22 -18
  3. data/README.md +2 -0
  4. data/introspective_grape.gemspec +5 -4
  5. data/lib/introspective_grape/api.rb +461 -459
  6. data/lib/introspective_grape/create_helpers.rb +25 -25
  7. data/lib/introspective_grape/traversal.rb +1 -1
  8. data/lib/introspective_grape/validators.rb +36 -36
  9. data/lib/introspective_grape/version.rb +5 -5
  10. data/spec/dummy/Gemfile +23 -22
  11. data/spec/dummy/app/api/api_helpers.rb +38 -37
  12. data/spec/dummy/app/api/dummy_api.rb +61 -61
  13. data/spec/dummy/app/api/permissions_helper.rb +7 -7
  14. data/spec/dummy/app/helpers/current.rb +3 -0
  15. data/spec/dummy/app/models/chat_message.rb +34 -34
  16. data/spec/dummy/app/models/chat_user.rb +16 -16
  17. data/spec/dummy/app/models/location.rb +26 -26
  18. data/spec/dummy/app/models/role.rb +30 -30
  19. data/spec/dummy/app/models/team.rb +14 -9
  20. data/spec/dummy/app/models/user/chatter.rb +79 -79
  21. data/spec/dummy/bin/bundle +3 -3
  22. data/spec/dummy/bin/rails +4 -4
  23. data/spec/dummy/bin/setup +36 -29
  24. data/spec/dummy/bin/update +31 -0
  25. data/spec/dummy/bin/yarn +11 -0
  26. data/spec/dummy/config/application.rb +30 -37
  27. data/spec/dummy/config/boot.rb +3 -6
  28. data/spec/dummy/config/environment.rb +5 -11
  29. data/spec/dummy/config/environments/development.rb +54 -42
  30. data/spec/dummy/config/environments/production.rb +81 -79
  31. data/spec/dummy/config/environments/test.rb +47 -44
  32. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  33. data/spec/dummy/config/initializers/assets.rb +14 -11
  34. data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
  35. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -3
  36. data/spec/dummy/config/initializers/inflections.rb +16 -16
  37. data/spec/dummy/config/initializers/new_framework_defaults_5_2.rb +38 -0
  38. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -14
  39. data/spec/dummy/config/locales/en.yml +33 -23
  40. data/spec/dummy/config/storage.yml +34 -0
  41. data/spec/models/image_spec.rb +10 -14
  42. data/spec/requests/project_api_spec.rb +185 -182
  43. data/spec/requests/user_api_spec.rb +221 -221
  44. data/spec/support/request_helpers.rb +22 -21
  45. metadata +20 -157
@@ -1,459 +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
-
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_attributes!( 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
- # rubocop:enable Metrics/AbcSize
214
- def define_destroy(dsl, routes, model, _api_params)
215
- klass = routes.first.klass
216
- name = routes.last.name.singularize
217
- dsl.desc "destroy a #{name}" do
218
- detail klass.destroy_documentation(name)
219
- end
220
- dsl.params do
221
- requires routes.last.swagger_key, type: klass.param_type(model, model.primary_key)
222
- end
223
- dsl.delete ":#{routes.last.swagger_key}" do
224
- authorize @model, :destroy?
225
- present status: (klass.find_leaf(routes, @model, params).destroy ? true : false)
226
- end
227
- end
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..-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(klass: self, name: name, param: "#{name}_attributes", model: model,
295
- many?: plural?(parent_model, reflection),
296
- key: "#{name.singularize}_id".to_sym,
297
- swagger_key: swagger_key, reflection: reflection)
298
- end
299
-
300
- def plural?(model, reflection)
301
- (model && PLURAL_REFLECTIONS.include?(reflection.class))
302
- end
303
-
304
- def build_nested_attributes(routes, hash)
305
- # Recursively re-express the flat attributes hash from nested routes as nested
306
- # attributes that can be used to perform an update on the root model.
307
-
308
- # do nothing if the params are already nested.
309
- return {} if routes.blank? || hash[routes.first.param]
310
-
311
- route = routes.shift
312
- # change 'x_id' to 'x_attributes': { id: id, y_attributes: {} }
313
- id = hash.delete route.key
314
- attributes = id ? { id: id } : {}
315
-
316
- attributes.merge!( hash ) if routes.blank? # assign param values to the last reference
317
-
318
- if route.many? # nest it in an array if it is a has_many association
319
- { route.param => [attributes.merge( build_nested_attributes(routes, hash) )] }
320
- else
321
- { route.param => attributes.merge( build_nested_attributes(routes, hash) ) }
322
- end
323
- end
324
-
325
- def generate_params(dsl, action, model, fields, is_root_endpoint=false)
326
- # We'll be doing a recursive walk (to handle nested attributes) down the
327
- # whitelisted params, generating the Grape param definitions by introspecting
328
- # on the model and its associations.
329
- raise "Invalid action: #{action}" unless %i(update create).include?(action)
330
-
331
- # dsl : The Grape::Validations::ParamsScope object
332
- # action: create or update
333
- # model : The ActiveRecord model class
334
- # fields: The whitelisted data structure for Rails' strong params, from which we
335
- # infer Grape's parameters
336
-
337
- # skip the ID param at the root level endpoint, so we don't duplicate the URL parameter (api/v#/model/modelId)
338
- fields -= [:id] if is_root_endpoint
339
-
340
- fields.each do |field|
341
- if field.is_a?(Hash)
342
- generate_nested_params(dsl, action, model, field)
343
- elsif action == :create && param_required?(model, field)
344
- # All params are optional on an update, only require them during creation.
345
- # Updating a record with new child models will have to rely on ActiveRecord
346
- # validations:
347
- dsl.requires field, { type: param_type(model, field) }.merge( validations(model, field) )
348
- else
349
- # dsl.optional field, *options
350
- dsl.optional field, { type: param_type(model, field) }.merge( validations(model, field) )
351
- end
352
- end
353
- end
354
-
355
- def validations(model, field)
356
- (model.try(:grape_validations) || {}).with_indifferent_access[field] || {}
357
- end
358
-
359
- def generate_nested_params(dsl, action, model, fields)
360
- klass = self
361
- fields.each do |r, v|
362
- # Look at model.reflections to find the association's class name:
363
- reflection = r.to_s.sub(/_attributes$/, '') # the reflection name
364
- relation = find_relation(model, reflection)
365
-
366
- if file_attachment?(model, r)
367
- # Handle Carrierwave file upload fields
368
- s = %i(filename type name tempfile head) - v
369
- Rails.logger.warn "Missing required file upload parameters #{s} for uploader field #{r}" if s.present?
370
- elsif plural_reflection?(model, reflection)
371
- # In case you need a refresher on how these work:
372
- # http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
373
- dsl.optional r, type: Array do |dl|
374
- klass.generate_params(dl, action, relation, v)
375
- klass.add_destroy_param(dl, model, reflection, action)
376
- end
377
- else
378
- # TODO: handle any remaining correctly. Presently defaults to a Hash
379
- # http://www.rubydoc.info/github/rails/rails/ActiveRecord/Reflection
380
- # ThroughReflection, HasOneReflection,
381
- # HasAndBelongsToManyReflection, BelongsToReflection
382
- dsl.optional r, type: Hash do |dl|
383
- klass.generate_params(dl, action, relation, v)
384
- klass.add_destroy_param(dl, model, reflection, action)
385
- end
386
- end
387
- end
388
- end
389
-
390
- def plural_reflection?(model, reflection)
391
- PLURAL_REFLECTIONS.include?( model.reflections[reflection].class )
392
- end
393
-
394
- def find_relation(model, reflection)
395
- begin
396
- model.reflections[reflection].class_name.constantize
397
- rescue StandardError
398
- model
399
- end
400
- end
401
-
402
- def file_attachment?(model, field)
403
- (model.respond_to?(:uploaders) && model.uploaders[field.to_sym]) || # carrierwave
404
- (model.respond_to?(:attachment_definitions) && model.attachment_definitions[field.to_sym]) ||
405
- (defined?(Paperclip::Attachment) && model.send(:new).try(field).is_a?(Paperclip::Attachment))
406
- end
407
-
408
- def param_type(model, field)
409
- # Translate from the AR type to the GrapeParam types
410
- field = field.to_s
411
- db_type = (model&.try(:columns_hash) || {})[field]&.type
412
-
413
- # Check if it's a file attachment, look for an override class from the model,
414
- # check PG2RUBY, use the database type, or fail over to a String:
415
- uploaded_file?(model, field) ||
416
- check_model_for_type(model, field) ||
417
- PG2RUBY[db_type] ||
418
- db_type_constant(db_type) ||
419
- String # default to String if nothing else works
420
- end
421
-
422
- def uploaded_file?(model, field)
423
- file_attachment?(model, field) && Rack::Multipart::UploadedFile
424
- end
425
-
426
- def check_model_for_type(model, field)
427
- (model.try(:grape_param_types) || {}).with_indifferent_access[field]
428
- end
429
-
430
- def db_type_constant(db_type)
431
- begin
432
- db_type.to_s.camelize.constantize
433
- rescue StandardError
434
- nil
435
- end
436
- end
437
-
438
- def param_required?(model, field)
439
- # Detect if the field is a required field for the create action
440
- return false if skip_presence_validations.include?(field)
441
-
442
- validated_field = field.match?(/_id/) ? field.to_s.sub(/_id\z/, '').to_sym : field.to_sym
443
-
444
- model.validators_on(validated_field).any? {|v| v.is_a? ActiveRecord::Validations::PresenceValidator }
445
- end
446
-
447
- def add_destroy_param(dsl, model, reflection, action)
448
- return if action == :create
449
-
450
- raise "#{model} does not accept nested attributes for #{reflection}" unless model.nested_attributes_options[reflection.to_sym]
451
-
452
- return unless model.nested_attributes_options[reflection.to_sym][:allow_destroy]
453
-
454
- # If destruction is allowed append the _destroy field
455
- dsl.optional '_destroy', type: Integer
456
- end
457
- end
458
- end
459
- 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