introspective_grape 0.5.7 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
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