introspective_grape 0.6.1 → 0.7.0

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