model-api 0.8.3

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 3bdc8741b309bfe1b4d504a67b34a7101ffc3486
4
+ data.tar.gz: c1519aa053d77afb3d1eff0f1861be9a4fc5a804
5
+ SHA512:
6
+ metadata.gz: 708e946d85d6516b2dda287d2c5ad4fffe1b302a597bfbcc5f424308a8c1209b0ec9ba65ab388fa527da1488d2de2512c095172e7ffcfa21e28c90e4398af5c4
7
+ data.tar.gz: 08fe91d60003278e4e5ce049570fdca9f00be075779f35e7c57627be9a7ce678fdadd9eef0cabbde8b6d0cf3e5b43b85e37e091d786955d4cf6d1ce82ef2be34
@@ -0,0 +1,2 @@
1
+ # model_api
2
+ Ruby gem allowing Ruby on Rails developers to create REST API’s using metadata defined inside their ActiveRecord models.
@@ -0,0 +1,38 @@
1
+ require 'model-api/base_controller.rb'
2
+ require 'model-api/bypass_parse_middleware.rb'
3
+ require 'model-api/hash_metadata.rb'
4
+ require 'model-api/model.rb'
5
+ require 'model-api/not_found_exception.rb'
6
+ require 'model-api/open_api_extensions.rb'
7
+ require 'model-api/renderer.rb'
8
+ require 'model-api/simple_metadata.rb'
9
+ require 'model-api/suppress_login_redirect_middleware.rb'
10
+ require 'model-api/unauthorized_exception.rb'
11
+ require 'model-api/utils.rb'
12
+
13
+ module ModelApi
14
+ class << self
15
+ def configure(metadata = nil, &block)
16
+ return unless metadata.is_a?(Hash) || block_given?
17
+ global_metadata = @model_api_global_metadata || default_global_metadata
18
+ if metadata.is_a?(Hash)
19
+ global_metadata = OpenApi::Utils.merge_hash(global_metadata, metadata)
20
+ end
21
+ if block_given?
22
+ config = OpenStruct.new(global_metadata)
23
+ block.call(config)
24
+ global_metadata = OpenApi::Utils.merge_hash(global_metadata, config.to_h.symbolize_keys)
25
+ end
26
+ @model_api_global_metadata = global_metadata
27
+ end
28
+
29
+ def global_metadata
30
+ @model_api_global_metadata || default_global_metadata
31
+ end
32
+
33
+ def default_global_metadata
34
+ {
35
+ }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,1352 @@
1
+ module ModelApi
2
+ module BaseController
3
+ module ClassMethods
4
+ def model_class
5
+ nil
6
+ end
7
+
8
+ def base_api_options
9
+ {}
10
+ end
11
+
12
+ def base_admin_api_options
13
+ base_api_options.merge(admin_only: true)
14
+ end
15
+ end
16
+
17
+ def self.included(base)
18
+ base.extend(ClassMethods)
19
+
20
+ base.send(:include, InstanceMethods)
21
+
22
+ base.send(:before_filter, :common_headers)
23
+
24
+ base.send(:rescue_from, Exception, with: :unhandled_exception)
25
+ base.send(:respond_to, :json, :xml)
26
+ end
27
+
28
+ module InstanceMethods
29
+ SIMPLE_ID_REGEX = /\A[0-9]+\Z/
30
+ UUID_REGEX = /\A[0-9A-Za-z]{8}-?[0-9A-Za-z]{4}-?[0-9A-Za-z]{4}-?[0-9A-Za-z]{4}-?[0-9A-Za-z]\
31
+ {12}\Z/x
32
+ DEFAULT_PAGE_SIZE = 100
33
+
34
+ protected
35
+
36
+ def model_class
37
+ self.class.model_class
38
+ end
39
+
40
+ def render_collection(collection, opts = {})
41
+ return unless ensure_admin_if_admin_only(opts)
42
+ opts = prepare_options(opts)
43
+ opts[:operation] ||= :index
44
+ return unless validate_read_operation(collection, opts[:operation], opts)
45
+
46
+ coll_route = opts[:collection_route] || self
47
+ collection_links = { self: coll_route }
48
+ collection = process_collection_includes(collection, opts)
49
+ collection, _result_filters = filter_collection(collection, find_filter_params, opts)
50
+ collection, _result_sorts = sort_collection(collection, find_sort_params, opts)
51
+ collection, collection_links, opts = paginate_collection(collection,
52
+ collection_links, opts, coll_route)
53
+
54
+ opts[:collection_links] = collection_links.merge(opts[:collection_links] || {})
55
+ .reverse_merge(common_response_links(opts))
56
+ add_collection_object_route(opts)
57
+ ModelApi::Renderer.render(self, collection, opts)
58
+ end
59
+
60
+ def render_object(obj, opts = {})
61
+ return unless ensure_admin_if_admin_only(opts)
62
+ opts = prepare_options(opts)
63
+ klass = Utils.find_class(obj, opts)
64
+ object_route = opts[:object_route] || self
65
+
66
+ opts[:object_links] = { self: object_route }
67
+ if obj.is_a?(ActiveRecord::Base)
68
+ return unless validate_read_operation(obj, opts[:operation], opts)
69
+ unless obj.present?
70
+ return not_found(opts.merge(class: klass, field: :id))
71
+ end
72
+ opts[:object_links].merge!(opts[:object_links] || {})
73
+ else
74
+ return not_found(opts) if obj.nil?
75
+ obj = ModelApi::Utils.ext_value(obj, opts) unless opts[:raw_output]
76
+ opts[:object_links].merge!(opts[:links] || {})
77
+ end
78
+
79
+ opts[:operation] ||= :show
80
+ opts[:object_links].reverse_merge!(common_response_links(opts))
81
+ ModelApi::Renderer.render(self, obj, opts)
82
+ end
83
+
84
+ def do_create(opts = {})
85
+ klass = opts[:model_class] || model_class
86
+ return unless ensure_admin_if_admin_only(opts)
87
+ unless klass.is_a?(Class) && klass < ActiveRecord::Base
88
+ fail 'Unable to process object creation; Missing or invalid model class'
89
+ end
90
+ obj, opts = prepare_object_for_create(klass, opts)
91
+ return bad_payload(class: klass) if opts[:bad_payload]
92
+ create_and_render_object(obj, opts)
93
+ end
94
+
95
+ def prepare_object_for_create(klass, opts = {})
96
+ opts = prepare_options(opts)
97
+ get_updated_object(klass, get_operation(:create, opts), opts)
98
+ end
99
+
100
+ def create_and_render_object(obj, opts = {})
101
+ opts = prepare_options(opts)
102
+ object_link_options = opts[:object_link_options]
103
+ object_link_options[:action] = :show
104
+ save_and_render_object(obj, get_operation(:create, opts), opts.merge(location_header: true))
105
+ end
106
+
107
+ def do_update(obj, opts = {})
108
+ return unless ensure_admin_if_admin_only(opts)
109
+ obj, opts = prepare_object_for_update(obj, opts)
110
+ return bad_payload(class: klass) if opts[:bad_payload]
111
+ unless obj.present?
112
+ return not_found(opts.merge(class: Utils.find_class(obj, opts), field: :id))
113
+ end
114
+ update_and_render_object(obj, opts)
115
+ end
116
+
117
+ def prepare_object_for_update(obj, opts = {})
118
+ opts = prepare_options(opts)
119
+ get_updated_object(obj, get_operation(:update, opts), opts)
120
+ end
121
+
122
+ def update_and_render_object(obj, opts = {})
123
+ opts = prepare_options(opts)
124
+ save_and_render_object(obj, get_operation(:update, opts), opts)
125
+ end
126
+
127
+ def save_and_render_object(obj, operation, opts = {})
128
+ status, msgs = Utils.process_updated_model_save(obj, operation, opts)
129
+ add_hateoas_links_for_updated_object(operation, opts)
130
+ successful = ModelApi::Utils.response_successful?(status)
131
+ ModelApi::Renderer.render(self, successful ? obj : opts[:request_obj],
132
+ opts.merge(status: status, operation: :show, messages: msgs))
133
+ end
134
+
135
+ def do_destroy(obj, opts = {})
136
+ return unless ensure_admin_if_admin_only(opts)
137
+ opts = prepare_options(opts)
138
+ obj = obj.first if obj.is_a?(ActiveRecord::Relation)
139
+
140
+ add_hateoas_links_for_update(opts)
141
+ unless obj.present?
142
+ return not_found(opts.merge(class: klass, field: :id))
143
+ end
144
+
145
+ operation = opts[:operation] = get_operation(:destroy, opts)
146
+ Utils.validate_operation(obj, operation, opts)
147
+ response_status, errs_or_msgs = Utils.process_object_destroy(obj, operation, opts)
148
+
149
+ add_hateoas_links_for_updated_object(operation, opts)
150
+ klass = Utils.find_class(obj, opts)
151
+ ModelApi::Renderer.render(self, obj, opts.merge(status: response_status,
152
+ root: ModelApi::Utils.model_name(klass).singular, messages: errs_or_msgs))
153
+ end
154
+
155
+ def common_response_links(_opts = {})
156
+ {}
157
+ end
158
+
159
+ def prepare_options(opts)
160
+ opts = opts.symbolize_keys
161
+ opts[:user] = user = filter_by_user
162
+ opts[:user_id] = user.try(:id)
163
+ opts[:admin] = user.try(:admin_api_user?) ? true : false
164
+ opts[:admin_content] = admin_content?
165
+ opts[:collection_link_options] = opts[:object_link_options] =
166
+ request.query_parameters.to_h.symbolize_keys
167
+ opts
168
+ end
169
+
170
+ def id_info(opts = {})
171
+ id_info = {}
172
+ id_info[:id_attribute] = (opts[:id_attribute] || :id).to_sym
173
+ id_info[:id_param] = (opts[:id_param] || :id).to_sym
174
+ id_info[:id_value] = (opts[:id_value] || params[id_info[:id_param]]).to_s
175
+ id_info
176
+ end
177
+
178
+ def api_query(opts = {})
179
+ klass = opts[:model_class] || model_class
180
+ unless klass < ActiveRecord::Base
181
+ fail 'Expected model class to be an ActiveRecord::Base subclass'
182
+ end
183
+ query = klass.all
184
+ if (deleted_col = klass.columns_hash['deleted']).present?
185
+ case deleted_col.type
186
+ when :boolean
187
+ query = query.where(deleted: false)
188
+ when :integer, :decimal
189
+ query = query.where(deleted: 0)
190
+ end
191
+ end
192
+ Utils.apply_context(query, opts)
193
+ end
194
+
195
+ def common_object_query(opts = {})
196
+ klass = opts[:model_class] || model_class
197
+ coll_query = Utils.apply_context(api_query(opts), opts)
198
+ id_info = opts[:id_info] || id_info(opts)
199
+ query = coll_query.where(id_info[:id_attribute] => id_info[:id_value])
200
+ if !admin_access?
201
+ unless opts.include?(:user_filter) && !opts[:user_filter]
202
+ query = user_query(query, opts.merge(model_class: klass))
203
+ end
204
+ elsif id_info[:id_attribute] != :id && !id_info[:id_attribute].to_s.ends_with?('.id') &&
205
+ klass.column_names.include?('id') && !query.exists?
206
+ # Admins can optionally use record ID's if the ID field happens to be something else
207
+ query = coll_query.where(id: id_info[:id_value])
208
+ end
209
+ unless (not_found_error = opts[:not_found_error]).blank? || query.exists?
210
+ not_found_error = not_found_error.call(params[:id]) if not_found_error.respond_to?(:call)
211
+ if not_found_error == true
212
+ not_found_error = "#{klass.model_name.human} '#{id_info[:id_value]}' not found."
213
+ end
214
+ fail ModelApi::NotFoundException.new(id_info[:id_param], not_found_error.to_s)
215
+ end
216
+ query
217
+ end
218
+
219
+ def collection_query(opts = {})
220
+ opts = base_api_options.merge(opts)
221
+ klass = opts[:model_class] || model_class
222
+ query = api_query(opts)
223
+ unless (opts.include?(:user_filter) && !opts[:user_filter]) ||
224
+ (admin_access? && (admin_content? || filtered_by_foreign_key?(query)))
225
+ query = user_query(query, opts.merge(model_class: klass))
226
+ end
227
+ query
228
+ end
229
+
230
+ def object_query(opts = {})
231
+ common_object_query(base_api_options.merge(opts))
232
+ end
233
+
234
+ def user_query(query, opts = {})
235
+ user = opts[:user] || filter_by_user
236
+ klass = opts[:model_class] || model_class
237
+ user_id_col = opts[:user_id_column] || :user_id
238
+ user_assoc = opts[:user_association] || :user
239
+ user_id = user.send(opts[:user_id_attribute] || :id)
240
+ if klass.columns_hash.include?(user_id_col.to_s)
241
+ query = query.where(user_id_col => user_id)
242
+ elsif (assoc = klass.reflect_on_association(user_assoc)).present? &&
243
+ [:belongs_to, :has_one].include?(assoc.macro)
244
+ query = query.joins(user_assoc).where(
245
+ "#{assoc.klass.table_name}.#{assoc.klass.primary_key}" => user_id)
246
+ elsif opts[:user_filter]
247
+ fail "Unable to filter results by user; no '#{user_id_col}' column or " \
248
+ "'#{user_assoc}' association found!"
249
+ end
250
+ query
251
+ end
252
+
253
+ def base_api_options
254
+ self.class.base_api_options
255
+ end
256
+
257
+ def base_admin_api_options
258
+ base_api_options.merge(admin_only: true)
259
+ end
260
+
261
+ def ensure_admin
262
+ return true if current_user.try(:admin_api_user?)
263
+
264
+ # Mask presence of endpoint if user is not authorized to access it
265
+ not_found
266
+ false
267
+ end
268
+
269
+ def unhandled_exception(err)
270
+ return if handle_api_exceptions(err)
271
+ error_id = LogUtils.log_and_notify(err)
272
+ return if performed?
273
+ error_details = {}
274
+ if Rails.env == 'development'
275
+ error_details[:message] = "Exception: #{err.message}"
276
+ error_details[:error_event_id] = error_id
277
+ error_details[:backtrace] = err.backtrace
278
+ else
279
+ error_details[:message] = 'An internal server error has occurred ' \
280
+ 'while processing your request. Please contact customer ' \
281
+ 'support, referencing the following error event id, for ' \
282
+ "assistance: #{error_id}"
283
+ error_details[:error_event_id] = error_id
284
+ end
285
+ ModelApi::Renderer.render(self, error_details, root: :error_details,
286
+ status: :internal_server_error)
287
+ end
288
+
289
+ def handle_api_exceptions(err)
290
+ if err.is_a?(ModelApi::NotFoundException)
291
+ not_found(field: err.field, message: err.message)
292
+ elsif err.is_a?(ModelApi::UnauthorizedException)
293
+ unauthorized
294
+ else
295
+ return false
296
+ end
297
+ true
298
+ end
299
+
300
+ def doorkeeper_unauthorized_render_options(error: nil)
301
+ { json: unauthorized(error: 'Not authorized to access resource', message: error.description,
302
+ format: :json, generate_body_only: true) }
303
+ end
304
+
305
+ # Indicates whether user has access to data they do not own.
306
+ def admin_access?
307
+ false
308
+ end
309
+
310
+ # Indicates whether API should render administrator-only content in API responses
311
+ def admin_content?
312
+ param = request.query_parameters[:admin]
313
+ param.present? && param.to_i != 0 && admin_access?
314
+ end
315
+
316
+ def resource_parent_id(parent_model_class, opts = {})
317
+ id_info = id_info(opts.reverse_merge(id_param: "#{parent_model_class.name.underscore}_id"))
318
+ model_name = parent_model_class.model_name.human
319
+ if id_info[:id_value].blank?
320
+ unless opts[:optional]
321
+ fail ModelApi::NotFoundException.new(id_info[:id_param], "#{model_name} not found")
322
+ end
323
+ return nil
324
+ end
325
+ query = common_object_query(opts.merge(model_class: parent_model_class, id_info: id_info))
326
+ parent_id = query.pluck(:id).first
327
+ if parent_id.blank?
328
+ unless opts[:optional]
329
+ fail ModelApi::NotFoundException.new(id_info[:id_param],
330
+ "#{model_name} '#{id_info[:id_value]}' not found")
331
+ end
332
+ return nil
333
+ end
334
+ parent_id
335
+ end
336
+
337
+ def simple_error(status, error, opts = {})
338
+ opts = opts.dup
339
+ klass = opts[:class]
340
+ opts[:root] = ModelApi::Utils.model_name(klass).singular if klass.present?
341
+ if error.is_a?(Array)
342
+ errs_or_msgs = error.map do |e|
343
+ if e.is_a?(Hash)
344
+ next e if e.include?(:error) && e.include?(:message)
345
+ next e.reverse_merge(
346
+ error: e[:error] || 'Unspecified error',
347
+ message: e[:message] || e[:error] || 'Unspecified error')
348
+ end
349
+ { error: e.to_s, message: e.to_s }
350
+ end
351
+ elsif error.is_a?(Hash)
352
+ errs_or_msgs = [error]
353
+ else
354
+ errs_or_msgs = [{ error: error, message: opts[:message] || error }]
355
+ end
356
+ errs_or_msgs[0][:field] = opts[:field] if opts.include?(:field)
357
+ ModelApi::Renderer.render(self, opts[:request_obj], opts.merge(status: status,
358
+ messages: errs_or_msgs))
359
+ end
360
+
361
+ def not_found(opts = {})
362
+ opts = opts.dup
363
+ opts[:message] ||= 'No resource found at the path provided or matching the criteria ' \
364
+ 'specified'
365
+ simple_error(:not_found, opts.delete(:error) || 'No resource found', opts)
366
+ end
367
+
368
+ def bad_payload(opts = {})
369
+ opts = opts.dup
370
+ format = opts[:format] || identify_format
371
+ opts[:message] ||= "A properly-formatted #{format.to_s.upcase} " \
372
+ 'payload was expected in the HTTP request body but not found'
373
+ simple_error(:bad_request, opts.delete(:error) || 'Missing/invalid request body (payload)',
374
+ opts)
375
+ end
376
+
377
+ def bad_request(error, message, opts = {})
378
+ opts[:message] = message || 'This request is invalid for the resource in its present state'
379
+ simple_error(:bad_request, error || 'Invalid API request', opts)
380
+ end
381
+
382
+ def unauthorized(opts = {})
383
+ opts = opts.dup
384
+ opts[:message] ||= 'Missing one or more privileges required to complete request'
385
+ simple_error(:unauthorized, opts.delete(:error) || 'Not authorized', opts)
386
+ end
387
+
388
+ def not_implemented(opts = {})
389
+ opts = opts.dup
390
+ opts[:message] ||= 'This API feature is presently unavailable'
391
+ simple_error(:not_implemented, opts.delete(:error) || 'Not implemented', opts)
392
+ end
393
+
394
+ def validate_read_operation(obj, operation, opts = {})
395
+ status, errors = Utils.validate_operation(obj, operation, opts)
396
+ return true if status.nil? && errors.nil?
397
+ if errors.nil? && (status.is_a?(Array) || status.present?)
398
+ return true if (errors = status).blank?
399
+ status = :bad_request
400
+ end
401
+ return true unless errors.present?
402
+ errors = [errors] unless errors.is_a?(Array)
403
+ simple_error(status, errors, opts)
404
+ false
405
+ end
406
+
407
+ def current_user
408
+ return @devise_user if @devise_user.present?
409
+ return @current_user if instance_variable_defined?(:@current_user)
410
+ unless doorkeeper_token.present? &&
411
+ doorkeeper_token.resource_owner_id.present?
412
+ return (@current_user = nil)
413
+ end
414
+ @current_user = User.find(doorkeeper_token.resource_owner_id)
415
+ end
416
+
417
+ def filter_by_user
418
+ if admin_access?
419
+ if (user_id = request.query_parameters[:user_id] ||
420
+ request.query_parameters[:user]).present?
421
+ return User.where(id: user_id.to_i).first || current_user
422
+ elsif (username = request.query_parameters[:username]).present?
423
+ return User.where(username: username.to_s).first || current_user
424
+ elsif (user_email = request.query_parameters[:user_email]).present?
425
+ return User.where(email: user_email.to_s).first || current_user
426
+ end
427
+ end
428
+ current_user
429
+ end
430
+
431
+ def common_headers
432
+ ModelApi::Utils.common_http_headers.each do |k, v|
433
+ response.headers[k] = v
434
+ end
435
+ end
436
+
437
+ def identify_format
438
+ format = self.request.format.symbol rescue :json
439
+ format == :xml ? :xml : :json
440
+ end
441
+
442
+ def ensure_admin_if_admin_only(opts = {})
443
+ return true unless opts[:admin_only]
444
+ ensure_admin
445
+ end
446
+
447
+ def get_operation(default_operation, opts = {})
448
+ if opts.key?(:operation)
449
+ return opts[:operation]
450
+ elsif action_name.start_with?('create')
451
+ return :create
452
+ elsif action_name.start_with?('update')
453
+ return :update
454
+ elsif action_name.start_with?('patch')
455
+ return :patch
456
+ elsif action_name.start_with?('destroy')
457
+ return :destroy
458
+ else
459
+ return default_operation
460
+ end
461
+ end
462
+
463
+ def get_updated_object(obj_or_class, operation, opts = {})
464
+ opts = opts.symbolize_keys
465
+ opts[:operation] = operation
466
+ req_body, format = ModelApi::Utils.parse_request_body(request)
467
+ if obj_or_class.is_a?(Class)
468
+ klass = Utils.class_or_sti_subclass(obj_or_class, req_body, operation, opts)
469
+ obj = nil
470
+ elsif obj_or_class.is_a?(ActiveRecord::Base)
471
+ obj = obj_or_class
472
+ klass = obj.class
473
+ elsif obj_or_class.is_a?(ActiveRecord::Relation)
474
+ klass = obj_or_class.klass
475
+ obj = obj_or_class.first
476
+ end
477
+ opts[:api_attr_metadata] = ModelApi::Utils.filtered_attrs(klass, operation, opts)
478
+ opts[:api_model_metadata] = model_metadata = ModelApi::Utils.model_metadata(klass)
479
+ opts[:ignored_fields] = []
480
+ return [nil, opts.merge(bad_payload: true)] if req_body.nil?
481
+ obj = klass.new if obj.nil?
482
+ add_hateoas_links_for_update(opts)
483
+ verify_update_request_body(req_body, format, opts)
484
+ root_elem = opts[:root] = ModelApi::Utils.model_name(klass).singular
485
+ request_obj = opts[:request_obj] = Utils.object_from_req_body(root_elem, req_body, format)
486
+ Utils.apply_updates(obj, request_obj, operation, opts)
487
+ opts.freeze
488
+ ModelApi::Utils.invoke_callback(model_metadata[:after_initialize], obj, opts)
489
+ [obj, opts]
490
+ end
491
+
492
+ private
493
+
494
+ def find_filter_params
495
+ request.query_parameters.reject do |param, _value|
496
+ %w(access_token sort_by admin).include?(param)
497
+ end
498
+ end
499
+
500
+ def find_sort_params
501
+ sort_by = params[:sort_by]
502
+ return {} if sort_by.blank?
503
+ sort_by = sort_by.to_s.strip
504
+ if sort_by.starts_with?('{') || sort_by.starts_with?('[')
505
+ process_json_sort_params(sort_by)
506
+ else
507
+ process_simple_sort_params(sort_by)
508
+ end
509
+ end
510
+
511
+ def process_json_sort_params(sort_by)
512
+ sort_params = {}
513
+ sort_json_obj = (JSON.parse(sort_by) rescue {})
514
+ sort_json_obj = Hash[sort_json_obj.map { |v| [v, nil] }] if sort_json_obj.is_a?(Array)
515
+ sort_json_obj.each do |key, value|
516
+ next if key.blank?
517
+ value_lc = value.to_s.downcase
518
+ if %w(a asc ascending).include?(value_lc)
519
+ order = :asc
520
+ elsif %w(d desc descending).include?(value_lc)
521
+ order = :desc
522
+ else
523
+ order = :default
524
+ end
525
+ sort_params[key] = order
526
+ end
527
+ sort_params
528
+ end
529
+
530
+ def process_simple_sort_params(sort_by)
531
+ sort_params = {}
532
+ sort_by.split(',').each do |key|
533
+ key = key.to_s.strip
534
+ key_lc = key.downcase
535
+ if key_lc.ends_with?('_a') || key_lc.ends_with?(' a')
536
+ key = sort_by[key[0..-3]]
537
+ order = :asc
538
+ elsif key_lc.ends_with?('_asc') || key_lc.ends_with?(' asc')
539
+ key = sort_by[key[0..-5]]
540
+ order = :asc
541
+ elsif key_lc.ends_with?('_ascending') || key_lc.ends_with?(' ascending')
542
+ key = sort_by[key[0..-11]]
543
+ order = :asc
544
+ elsif key_lc.ends_with?('_d') || key_lc.ends_with?(' d')
545
+ key = sort_by[key[0..-3]]
546
+ order = :desc
547
+ elsif key_lc.ends_with?('_desc') || key_lc.ends_with?(' desc')
548
+ key = sort_by[key[0..-6]]
549
+ order = :desc
550
+ elsif key_lc.ends_with?('_descending') || key_lc.ends_with?(' descending')
551
+ key = sort_by[key[0..-12]]
552
+ order = :desc
553
+ else
554
+ order = :default
555
+ end
556
+ next if key.blank?
557
+ sort_params[key] = order
558
+ end
559
+ sort_params
560
+ end
561
+
562
+ def process_collection_includes(collection, opts = {})
563
+ klass = Utils.find_class(collection, opts)
564
+ metadata = ModelApi::Utils.filtered_ext_attrs(klass, opts[:operation] || :index, opts)
565
+ model_metadata = opts[:api_model_metadata] || ModelApi::Utils.model_metadata(klass)
566
+ includes = []
567
+ if (metadata_includes = model_metadata[:collection_includes]).is_a?(Array)
568
+ includes += metadata_includes.map(&:to_sym)
569
+ end
570
+ metadata.each do |_attr, attr_metadata|
571
+ includes << attr_metadata[:key] if attr_metadata[:type] == :association
572
+ end
573
+ includes = includes.compact.uniq
574
+ collection = collection.includes(includes) if includes.present?
575
+ collection
576
+ end
577
+
578
+ def filter_collection(collection, filter_params, opts = {})
579
+ return [collection, {}] if filter_params.blank? # Don't filter if no filter params
580
+ klass = opts[:class] || Utils.find_class(collection, opts)
581
+ assoc_values, metadata, attr_values = process_filter_params(filter_params, klass, opts)
582
+ result_filters = {}
583
+ metadata.values.each do |attr_metadata|
584
+ collection = apply_filter_param(attr_metadata, collection,
585
+ opts.merge(attr_values: attr_values, result_filters: result_filters, class: klass))
586
+ end
587
+ assoc_values.each do |assoc, assoc_filter_params|
588
+ ar_assoc = klass.reflect_on_association(assoc)
589
+ next unless ar_assoc.present?
590
+ collection = collection.joins(assoc) unless collection.joins_values.include?(assoc)
591
+ collection, assoc_result_filters = filter_collection(collection, assoc_filter_params,
592
+ opts.merge(class: ar_assoc.klass, filter_table: ar_assoc.table_name))
593
+ result_filters[assoc] = assoc_result_filters if assoc_result_filters.present?
594
+ end
595
+ [collection, result_filters]
596
+ end
597
+
598
+ def process_filter_params(filter_params, klass, opts = {})
599
+ assoc_values = {}
600
+ filter_metadata = {}
601
+ attr_values = {}
602
+ metadata = ModelApi::Utils.filtered_ext_attrs(klass, :filter, opts)
603
+ filter_params.each do |attr, value|
604
+ attr = attr.to_s
605
+ if attr.length > 1 && ['>', '<', '!', '='].include?(attr[-1])
606
+ value = "#{attr[-1]}=#{value}" # Effectively allows >= / <= / != / == in query string
607
+ attr = attr[0..-2].strip
608
+ end
609
+ if attr.include?('.')
610
+ process_filter_assoc_param(attr, metadata, assoc_values, value, opts)
611
+ else
612
+ process_filter_attr_param(attr, metadata, filter_metadata, attr_values, value, opts)
613
+ end
614
+ end
615
+ [assoc_values, filter_metadata, attr_values]
616
+ end
617
+
618
+ # rubocop:disable Metrics/ParameterLists
619
+ def process_filter_assoc_param(attr, metadata, assoc_values, value, opts)
620
+ attr_elems = attr.split('.')
621
+ assoc_name = attr_elems[0].strip.to_sym
622
+ assoc_metadata = metadata[assoc_name] ||
623
+ metadata[ModelApi::Utils.ext_query_attr(assoc_name, opts)] || {}
624
+ key = assoc_metadata[:key]
625
+ return unless key.present? && ModelApi::Utils.eval_bool(assoc_metadata[:filter], opts)
626
+ assoc_filter_params = (assoc_values[key] ||= {})
627
+ assoc_filter_params[attr_elems[1..-1].join('.')] = value
628
+ end
629
+
630
+ def process_filter_attr_param(attr, metadata, filter_metadata, attr_values, value, opts)
631
+ attr = attr.strip.to_sym
632
+ attr_metadata = metadata[attr] ||
633
+ metadata[ModelApi::Utils.ext_query_attr(attr, opts)] || {}
634
+ key = attr_metadata[:key]
635
+ return unless key.present? && ModelApi::Utils.eval_bool(attr_metadata[:filter], opts)
636
+ filter_metadata[key] = attr_metadata
637
+ attr_values[key] = value
638
+ end
639
+ # rubocop:enable Metrics/ParameterLists
640
+
641
+ def apply_filter_param(attr_metadata, collection, opts = {})
642
+ raw_value = (opts[:attr_values] || params)[attr_metadata[:key]]
643
+ filter_table = opts[:filter_table]
644
+ klass = opts[:class] || Utils.find_class(collection, opts)
645
+ if raw_value.is_a?(Hash) && raw_value.include?('0')
646
+ operator_value_pairs = filter_process_param_array(params_array(raw_value), attr_metadata,
647
+ opts)
648
+ else
649
+ operator_value_pairs = filter_process_param(raw_value, attr_metadata, opts)
650
+ end
651
+ if (column = resolve_key_to_column(klass, attr_metadata)).present?
652
+ operator_value_pairs.each do |operator, value|
653
+ if operator == '=' && filter_table.blank?
654
+ collection = collection.where(column => value)
655
+ else
656
+ table_name = (filter_table || klass.table_name).to_s.delete('`')
657
+ column = column.to_s.delete('`')
658
+ if value.is_a?(Array)
659
+ operator = 'IN'
660
+ value = value.map { |_v| format_value_for_query(column, value, klass) }
661
+ value = "(#{value.map { |v| "'#{v.to_s.gsub("'", "''")}'" }.join(',')})"
662
+ else
663
+ value = "'#{value.gsub("'", "''")}'"
664
+ end
665
+ collection = collection.where("`#{table_name}`.`#{column}` #{operator} #{value}")
666
+ end
667
+ end
668
+ elsif (key = attr_metadata[:key]).present?
669
+ opts[:result_filters][key] = operator_value_pairs if opts.include?(:result_filters)
670
+ end
671
+ collection
672
+ end
673
+
674
+ def sort_collection(collection, sort_params, opts = {})
675
+ return [collection, {}] if sort_params.blank? # Don't filter if no filter params
676
+ klass = opts[:class] || Utils.find_class(collection, opts)
677
+ assoc_sorts, attr_sorts, result_sorts = process_sort_params(sort_params, klass,
678
+ opts.merge(result_sorts: result_sorts))
679
+ sort_table = opts[:sort_table]
680
+ sort_table = sort_table.to_s.delete('`') if sort_table.present?
681
+ attr_sorts.each do |key, sort_order|
682
+ if sort_table.present?
683
+ collection = collection.order("`#{sort_table}`.`#{key.to_s.delete('`')}` " \
684
+ "#{sort_order.to_s.upcase}")
685
+ else
686
+ collection = collection.order(key => sort_order)
687
+ end
688
+ end
689
+ assoc_sorts.each do |assoc, assoc_sort_params|
690
+ ar_assoc = klass.reflect_on_association(assoc)
691
+ next unless ar_assoc.present?
692
+ collection = collection.joins(assoc) unless collection.joins_values.include?(assoc)
693
+ collection, assoc_result_sorts = sort_collection(collection, assoc_sort_params,
694
+ opts.merge(class: ar_assoc.klass, sort_table: ar_assoc.table_name))
695
+ result_sorts[assoc] = assoc_result_sorts if assoc_result_sorts.present?
696
+ end
697
+ [collection, result_sorts]
698
+ end
699
+
700
+ def process_sort_params(sort_params, klass, opts)
701
+ metadata = ModelApi::Utils.filtered_ext_attrs(klass, :sort, opts)
702
+ assoc_sorts = {}
703
+ attr_sorts = {}
704
+ result_sorts = {}
705
+ sort_params.each do |attr, sort_order|
706
+ if attr.include?('.')
707
+ process_sort_param_assoc(attr, metadata, sort_order, assoc_sorts, opts)
708
+ else
709
+ attr = attr.strip.to_sym
710
+ attr_metadata = metadata[attr] || {}
711
+ next unless ModelApi::Utils.eval_bool(attr_metadata[:sort], opts)
712
+ sort_order = sort_order.to_sym
713
+ sort_order = :default unless [:asc, :desc].include?(sort_order)
714
+ if sort_order == :default
715
+ sort_order = (attr_metadata[:default_sort_order] || :asc).to_sym
716
+ sort_order = :asc unless [:asc, :desc].include?(sort_order)
717
+ end
718
+ if (column = resolve_key_to_column(klass, attr_metadata)).present?
719
+ attr_sorts[column] = sort_order
720
+ elsif (key = attr_metadata[:key]).present?
721
+ result_sorts[key] = sort_order
722
+ end
723
+ end
724
+ end
725
+ [assoc_sorts, attr_sorts, result_sorts]
726
+ end
727
+
728
+ # Intentionally disabling parameter list length check for private / internal method
729
+ # rubocop:disable Metrics/ParameterLists
730
+ def process_sort_param_assoc(attr, metadata, sort_order, assoc_sorts, opts)
731
+ attr_elems = attr.split('.')
732
+ assoc_name = attr_elems[0].strip.to_sym
733
+ assoc_metadata = metadata[assoc_name] || {}
734
+ key = assoc_metadata[:key]
735
+ return unless key.present? && ModelApi::Utils.eval_bool(assoc_metadata[:sort], opts)
736
+ assoc_sort_params = (assoc_sorts[key] ||= {})
737
+ assoc_sort_params[attr_elems[1..-1].join('.')] = sort_order
738
+ end
739
+
740
+ # rubocop:enable Metrics/ParameterLists
741
+
742
+ def filter_process_param(raw_value, attr_metadata, opts)
743
+ raw_value = raw_value.to_s.strip
744
+ array = nil
745
+ if raw_value.starts_with?('[') && raw_value.ends_with?(']')
746
+ array = JSON.parse(raw_value) rescue nil
747
+ array = array.is_a?(Array) ? array.map(&:to_s) : nil
748
+ end
749
+ if array.nil?
750
+ if attr_metadata.include?(:filter_delimiter)
751
+ delimiter = attr_metadata[:filter_delimiter]
752
+ else
753
+ delimiter = ','
754
+ end
755
+ array = raw_value.split(delimiter) if raw_value.include?(delimiter)
756
+ end
757
+ return filter_process_param_array(array, attr_metadata, opts) unless array.nil?
758
+ operator, value = parse_filter_operator(raw_value)
759
+ [[operator, ModelApi::Utils.transform_value(value, attr_metadata[:parse], opts)]]
760
+ end
761
+
762
+ def filter_process_param_array(array, attr_metadata, opts)
763
+ operator_value_pairs = []
764
+ equals_values = []
765
+ array.map(&:strip).reject(&:blank?).each do |value|
766
+ operator, value = parse_filter_operator(value)
767
+ value = ModelApi::Utils.transform_value(value.to_s, attr_metadata[:parse], opts)
768
+ if operator == '='
769
+ equals_values << value
770
+ else
771
+ operator_value_pairs << [operator, value]
772
+ end
773
+ end
774
+ operator_value_pairs << ['=', equals_values.uniq] if equals_values.present?
775
+ operator_value_pairs
776
+ end
777
+
778
+ def parse_filter_operator(value)
779
+ value = value.to_s.strip
780
+ if (operator = value.scan(/\A(>=|<=|!=|<>)[[:space:]]*\w/).flatten.first).present?
781
+ return (operator == '<>' ? '!=' : operator), value[2..-1].strip
782
+ elsif (operator = value.scan(/\A(>|<|=)[[:space:]]*\w/).flatten.first).present?
783
+ return operator, value[1..-1].strip
784
+ end
785
+ ['=', value]
786
+ end
787
+
788
+ def format_value_for_query(column, value, klass)
789
+ return value.map { |v| format_value_for_query(column, v, klass) } if value.is_a?(Array)
790
+ column_metadata = klass.columns_hash[column.to_s]
791
+ case column_metadata.try(:type)
792
+ when :date, :datetime, :time, :timestamp
793
+ if (user_tz = current_user.try(:preference).try(:time_zone)).present?
794
+ time_zone = ActiveSupport::TimeZone.new(user_tz)
795
+ end
796
+ time_zone ||= ActiveSupport::TimeZone.new('Eastern Time (US & Canada)')
797
+ return time_zone.parse(value.to_s).try(:to_s, :db)
798
+ when :float, :decimal
799
+ return value.to_d.to_s
800
+ when :integer, :primary_key
801
+ return value.to_d.to_s.sub(/\.0\Z/, '')
802
+ when :boolean
803
+ return value ? 'true' : 'false'
804
+ end
805
+ value.to_s
806
+ end
807
+
808
+ def params_array(raw_value)
809
+ index = 0
810
+ array = []
811
+ while raw_value.include?(index.to_s)
812
+ array << raw_value[index.to_s]
813
+ index += 1
814
+ end
815
+ array
816
+ end
817
+
818
+ def paginate_collection(collection, collection_links, opts, coll_route)
819
+ collection_size = collection.count
820
+ page_size = (params[:page_size] || DEFAULT_PAGE_SIZE).to_i
821
+ page = [params[:page].to_i, 1].max
822
+ page_count = [(collection_size - 1) / page_size, 1].max
823
+ page = page_count if page > page_count
824
+ offset = (page - 1) * page_size
825
+
826
+ opts = opts.dup
827
+ opts[:count] ||= collection_size
828
+ opts[:page] ||= page
829
+ opts[:page_size] ||= page_size
830
+ opts[:page_count] ||= page_count
831
+
832
+ response.headers['X-Total-Count'] = collection_size.to_s
833
+
834
+ opts[:collection_link_options] = (opts[:collection_link_options] || {})
835
+ .reject { |k, _v| [:page].include?(k.to_sym) }
836
+ opts[:object_link_options] = (opts[:object_link_options] || {})
837
+ .reject { |k, _v| [:page, :page_size].include?(k.to_sym) }
838
+
839
+ if collection_size > page_size
840
+ opts[:collection_link_options][:page] = page
841
+ Utils.add_pagination_links(collection_links, coll_route, page, page_count)
842
+ collection = collection.limit(page_size).offset(offset)
843
+ end
844
+
845
+ [collection, collection_links, opts]
846
+ end
847
+
848
+ def resolve_key_to_column(klass, attr_metadata)
849
+ return nil unless klass.respond_to?(:columns_hash)
850
+ columns_hash = klass.columns_hash
851
+ key = attr_metadata[:key]
852
+ return key if columns_hash.include?(key.to_s)
853
+ render_method = attr_metadata[:render_method]
854
+ render_method = render_method.to_s if render_method.is_a?(Symbol)
855
+ return nil unless render_method.is_a?(String)
856
+ columns_hash.include?(render_method) ? render_method : nil
857
+ end
858
+
859
+ def add_collection_object_route(opts)
860
+ object_route = opts[:object_route]
861
+ unless object_route.present?
862
+ route_name = ModelApi::Utils.route_name(request)
863
+ if route_name.present?
864
+ if (singular_route_name = route_name.singularize) != route_name
865
+ object_route = singular_route_name
866
+ end
867
+ end
868
+ end
869
+ if object_route.present? && (object_route.is_a?(String) || object_route.is_a?(Symbol))
870
+ object_route = nil unless self.respond_to?("#{object_route}_url")
871
+ end
872
+ object_route = opts[:default_object_route] if object_route.blank?
873
+ return if object_route.blank?
874
+ opts[:object_links] = (opts[:object_links] || {}).merge(self: object_route)
875
+ end
876
+
877
+ def add_hateoas_links_for_update(opts)
878
+ object_route = opts[:object_route] || self
879
+ links = { self: object_route }.reverse_merge(common_response_links(opts))
880
+ opts[:links] = links.merge(opts[:links] || {})
881
+ end
882
+
883
+ def add_hateoas_links_for_updated_object(_operation, opts)
884
+ object_route = opts[:object_route] || self
885
+ object_links = { self: object_route }
886
+ opts[:object_links] = object_links.merge(opts[:object_links] || {})
887
+ end
888
+
889
+ def verify_update_request_body(request_body, format, opts = {})
890
+ if request.format.symbol.nil? && format.present?
891
+ opts[:format] ||= format
892
+ end
893
+
894
+ if request_body.is_a?(Array)
895
+ fail 'Expected object, but collection provided'
896
+ elsif !request_body.is_a?(Hash)
897
+ fail 'Expected object'
898
+ end
899
+ end
900
+
901
+ def filtered_by_foreign_key?(query)
902
+ fk_cache = self.class.instance_variable_get(:@foreign_key_cache)
903
+ self.class.instance_variable_set(:@foreign_key_cache, fk_cache = {}) if fk_cache.nil?
904
+ klass = query.klass
905
+ foreign_keys = (fk_cache[klass] ||= query.klass.reflections.values
906
+ .select { |a| a.macro == :belongs_to }.map { |a| a.foreign_key.to_s })
907
+ (query.values[:where] || []).select { |v| v.is_a?(Arel::Nodes::Equality) }
908
+ .map { |v| v.left.name }.each do |key|
909
+ return true if foreign_keys.include?(key)
910
+ end
911
+ false
912
+ rescue Exception => e
913
+ Rails.logger.warn "Exception encounterd determining if query is filtered: #{e.message}\n" \
914
+ "#{e.backtrace.join("\n")}"
915
+ end
916
+ end
917
+
918
+ class Utils
919
+ def self.find_class(obj, opts = {})
920
+ return nil if obj.nil?
921
+ opts[:class] || (obj.respond_to?(:klass) ? obj.klass : obj.class)
922
+ end
923
+
924
+ def self.add_pagination_links(collection_links, coll_route, page, last_page)
925
+ if page < last_page
926
+ collection_links[:next] = [coll_route, { page: (page + 1) }]
927
+ end
928
+ collection_links[:prev] = [coll_route, { page: (page - 1) }] if page > 1
929
+ collection_links[:first] = [coll_route, { page: 1 }]
930
+ collection_links[:last] = [coll_route, { page: last_page }]
931
+ end
932
+
933
+ def self.object_from_req_body(root_elem, req_body, format)
934
+ if format == :json
935
+ request_obj = req_body
936
+ else
937
+ request_obj = req_body[root_elem]
938
+ if request_obj.blank?
939
+ request_obj = req_body['obj']
940
+ if request_obj.blank? && req_body.size == 1
941
+ request_obj = req_body.values.first
942
+ end
943
+ end
944
+ end
945
+ fail 'Invalid request format' unless request_obj.present?
946
+ request_obj
947
+ end
948
+
949
+ def self.apply_updates(obj, req_obj, operation, opts = {})
950
+ opts = opts.merge(object: opts[:object] || obj)
951
+ metadata = ModelApi::Utils.filtered_ext_attrs(opts[:api_attr_metadata] ||
952
+ ModelApi::Utils.filtered_attrs(obj, operation, opts), operation, opts)
953
+ set_context_attrs(obj, opts)
954
+ req_obj.each do |attr, value|
955
+ attr = attr.to_sym
956
+ attr_metadata = metadata[attr]
957
+ unless attr_metadata.present?
958
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
959
+ next
960
+ end
961
+ update_api_attr(obj, attr, value, opts.merge(attr_metadata: attr_metadata))
962
+ end
963
+ end
964
+
965
+ def self.set_context_attrs(obj, opts = {})
966
+ klass = (obj.class < ActiveRecord::Base ? obj.class : nil)
967
+ (opts[:context] || {}).each do |key, value|
968
+ begin
969
+ setter = "#{key}="
970
+ next unless obj.respond_to?(setter)
971
+ if (column = klass.try(:columns_hash).try(:[], key.to_s)).present?
972
+ case column.type
973
+ when :integer, :primary_key then
974
+ obj.send("#{key}=", value.to_i)
975
+ when :decimal, :float then
976
+ obj.send("#{key}=", value.to_f)
977
+ else
978
+ obj.send(setter, value.to_s)
979
+ end
980
+ else
981
+ obj.send(setter, value.to_s)
982
+ end
983
+ rescue Exception => e
984
+ Rails.logger.warn "Error encountered assigning context parameter #{key} to " \
985
+ "'#{value}' (skipping): \"#{e.message}\")."
986
+ end
987
+ end
988
+ end
989
+
990
+ def self.process_updated_model_save(obj, operation, opts = {})
991
+ opts = opts.dup
992
+ opts[:operation] = operation
993
+ metadata = opts.delete(:api_attr_metadata) ||
994
+ ModelApi::Utils.filtered_attrs(obj, operation, opts)
995
+ model_metadata = opts.delete(:api_model_metadata) || ModelApi::Utils.model_metadata(obj.class)
996
+ ModelApi::Utils.invoke_callback(model_metadata[:before_validate], obj, opts.dup)
997
+ validate_operation(obj, operation, opts)
998
+ validate_preserving_existing_errors(obj)
999
+ ModelApi::Utils.invoke_callback(model_metadata[:before_save], obj, opts.dup)
1000
+ obj.instance_variable_set(:@readonly, false) if obj.instance_variable_get(:@readonly)
1001
+ successful = obj.save unless obj.errors.present?
1002
+ if successful
1003
+ suggested_response_status = :ok
1004
+ object_errors = []
1005
+ else
1006
+ suggested_response_status = :bad_request
1007
+ object_errors = extract_msgs_for_error(obj, opts.merge(api_attr_metadata: metadata))
1008
+ unless object_errors.present?
1009
+ object_errors << {
1010
+ error: 'Unspecified error',
1011
+ message: "Unspecified error processing #{operation}: " \
1012
+ 'Please contact customer service for further assistance.'
1013
+ }
1014
+ end
1015
+ end
1016
+ [suggested_response_status, object_errors]
1017
+ end
1018
+
1019
+ def self.extract_msgs_for_error(obj, opts = {})
1020
+ object_errors = []
1021
+ attr_prefix = opts[:attr_prefix] || ''
1022
+ api_metadata = opts[:api_attr_metadata] || ModelApi::Utils.api_attrs(obj.class)
1023
+ obj.errors.each do |attr, attr_errors|
1024
+ attr_errors = [attr_errors] unless attr_errors.is_a?(Array)
1025
+ attr_errors.each do |error|
1026
+ attr_metadata = api_metadata[attr] || {}
1027
+ qualified_attr = "#{attr_prefix}#{ModelApi::Utils.ext_attr(attr, attr_metadata)}"
1028
+ assoc_errors = nil
1029
+ if attr_metadata[:type] == :association
1030
+ assoc_errors = extract_assoc_error_msgs(obj, attr, opts.merge(
1031
+ attr_metadata: attr_metadata))
1032
+ end
1033
+ if assoc_errors.present?
1034
+ object_errors += assoc_errors
1035
+ else
1036
+ error_hash = {}
1037
+ error_hash[:object] = attr_prefix if attr_prefix.present?
1038
+ error_hash[:attribute] = qualified_attr unless attr == :base
1039
+ object_errors << error_hash.merge(error: error,
1040
+ message: (attr == :base ? error : "#{qualified_attr} #{error}"))
1041
+ end
1042
+ end
1043
+ end
1044
+ object_errors
1045
+ end
1046
+
1047
+ # rubocop:disable Metrics/MethodLength
1048
+ def self.extract_assoc_error_msgs(obj, attr, opts)
1049
+ object_errors = []
1050
+ attr_metadata = opts[:attr_metadata] || {}
1051
+ processed_assoc_objects = {}
1052
+ assoc = attr_metadata[:association]
1053
+ assoc_class = assoc.class_name.constantize
1054
+ external_attr = ModelApi::Utils.ext_attr(attr, attr_metadata)
1055
+ attr_metadata_create = attr_metadata_update = nil
1056
+ if assoc.macro == :has_many
1057
+ obj.send(attr).each_with_index do |assoc_obj, index|
1058
+ next if processed_assoc_objects[assoc_obj]
1059
+ processed_assoc_objects[assoc_obj] = true
1060
+ attr_prefix = "#{external_attr}[#{index}]."
1061
+ if assoc_obj.new_record?
1062
+ attr_metadata_create ||= ModelApi::Utils.filtered_attrs(assoc_class, :create, opts)
1063
+ object_errors += extract_msgs_for_error(assoc_obj, opts.merge(
1064
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_create))
1065
+ else
1066
+ attr_metadata_update ||= ModelApi::Utils.filtered_attrs(assoc_class, :update, opts)
1067
+ object_errors += extract_msgs_for_error(assoc_obj, opts.merge(
1068
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_update))
1069
+ end
1070
+ end
1071
+ else
1072
+ assoc_obj = obj.send(attr)
1073
+ return object_errors unless assoc_obj.present? && !processed_assoc_objects[assoc_obj]
1074
+ processed_assoc_objects[assoc_obj] = true
1075
+ attr_prefix = "#{external_attr}->"
1076
+ if assoc_obj.new_record?
1077
+ attr_metadata_create ||= ModelApi::Utils.filtered_attrs(assoc_class, :create, opts)
1078
+ object_errors += extract_msgs_for_error(assoc_obj, opts.merge(
1079
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_create))
1080
+ else
1081
+ attr_metadata_update ||= ModelApi::Utils.filtered_attrs(assoc_class, :update, opts)
1082
+ object_errors += extract_msgs_for_error(assoc_obj, opts.merge(
1083
+ attr_prefix: attr_prefix, api_attr_metadata: attr_metadata_update))
1084
+ end
1085
+ end
1086
+ object_errors
1087
+ end
1088
+
1089
+ # rubocop:enable Metrics/MethodLength
1090
+
1091
+ def self.process_object_destroy(obj, operation, opts)
1092
+ soft_delete = obj.errors.present? ? false : object_destroy(obj, opts)
1093
+
1094
+ if obj.errors.blank? && (soft_delete || obj.destroyed?)
1095
+ response_status = :ok
1096
+ object_errors = []
1097
+ else
1098
+ object_errors = extract_msgs_for_error(obj, opts)
1099
+ if object_errors.present?
1100
+ response_status = :bad_request
1101
+ else
1102
+ response_status = :internal_server_error
1103
+ object_errors << {
1104
+ error: 'Unspecified error',
1105
+ message: "Unspecified error processing #{operation}: " \
1106
+ 'Please contact customer service for further assistance.'
1107
+ }
1108
+ end
1109
+ end
1110
+
1111
+ [response_status, object_errors]
1112
+ end
1113
+
1114
+ def self.object_destroy(obj, opts = {})
1115
+ klass = find_class(obj)
1116
+ object_id = obj.send(opts[:id_attribute] || :id)
1117
+ obj.instance_variable_set(:@readonly, false) if obj.instance_variable_get(:@readonly)
1118
+ if (deleted_col = klass.columns_hash['deleted']).present?
1119
+ case deleted_col.type
1120
+ when :boolean
1121
+ obj.update_attribute(:deleted, true)
1122
+ return true
1123
+ when :integer, :decimal
1124
+ obj.update_attribute(:deleted, 1)
1125
+ return true
1126
+ else
1127
+ obj.destroy
1128
+ end
1129
+ else
1130
+ obj.destroy
1131
+ end
1132
+ false
1133
+ rescue Exception => e
1134
+ Rails.logger.warn "Error destroying #{klass.name} \"#{object_id}\": \"#{e.message}\")."
1135
+ false
1136
+ end
1137
+
1138
+ def self.set_api_attr(obj, attr, value, opts)
1139
+ attr_metadata = opts[:attr_metadata]
1140
+ internal_field = attr_metadata[:key] || attr
1141
+ setter = attr_metadata[:setter] || "#{(internal_field)}="
1142
+ unless obj.respond_to?(setter)
1143
+ Rails.logger.warn "Error encountered assigning API input for attribute \"#{attr}\" " \
1144
+ '(setter not found): skipping.'
1145
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
1146
+ return
1147
+ end
1148
+ obj.send(setter, value)
1149
+ end
1150
+
1151
+ def self.update_api_attr(obj, attr, value, opts = {})
1152
+ attr_metadata = opts[:attr_metadata]
1153
+ begin
1154
+ value = ModelApi::Utils.transform_value(value, attr_metadata[:parse], opts)
1155
+ rescue Exception => e
1156
+ Rails.logger.warn "Error encountered parsing API input for attribute \"#{attr}\" " \
1157
+ "(\"#{e.message}\"): \"#{value.to_s.first(1000)}\" ... using raw value instead."
1158
+ end
1159
+ begin
1160
+ if attr_metadata[:type] == :association && attr_metadata[:parse].blank?
1161
+ attr_metadata = opts[:attr_metadata]
1162
+ assoc = attr_metadata[:association]
1163
+ if assoc.macro == :has_many
1164
+ update_has_many_assoc(obj, attr, value, opts)
1165
+ elsif assoc.macro == :belongs_to
1166
+ update_belongs_to_assoc(obj, attr, value, opts)
1167
+ else
1168
+ add_ignored_field(opts[:ignored_fields], attr, value, attr_metadata)
1169
+ end
1170
+ else
1171
+ set_api_attr(obj, attr, value, opts)
1172
+ end
1173
+ rescue Exception => e
1174
+ handle_api_setter_exception(e, obj, attr_metadata, opts)
1175
+ end
1176
+ end
1177
+
1178
+ def self.update_has_many_assoc(obj, attr, value, opts = {})
1179
+ attr_metadata = opts[:attr_metadata]
1180
+ assoc = attr_metadata[:association]
1181
+ assoc_class = assoc.class_name.constantize
1182
+ model_metadata = ModelApi::Utils.model_metadata(assoc_class)
1183
+ value_array = value.to_a rescue nil
1184
+ unless value_array.is_a?(Array)
1185
+ obj.errors.add(attr, 'must be supplied as an array of objects')
1186
+ return
1187
+ end
1188
+ opts = opts.merge(model_metadata: model_metadata)
1189
+ opts[:ignored_fields] = [] if opts.include?(:ignored_fields)
1190
+ assoc_objs = []
1191
+ value_array.each_with_index do |assoc_payload, index|
1192
+ opts[:ignored_fields].clear if opts.include?(:ignored_fields)
1193
+ assoc_objs << update_has_many_assoc_obj(assoc_class, assoc_payload,
1194
+ opts.merge(model_metadata: model_metadata))
1195
+ if opts[:ignored_fields].present?
1196
+ external_attr = ModelApi::Utils.ext_attr(attr, attr_metadata)
1197
+ opts[:ignored_fields] << { "#{external_attr}[#{index}]" => opts[:ignored_fields] }
1198
+ end
1199
+ end
1200
+ set_api_attr(obj, attr, assoc_objs, opts)
1201
+ end
1202
+
1203
+ def self.update_has_many_assoc_obj(assoc_class, assoc_payload, opts = {})
1204
+ model_metadata = opts[:model_metadata] || ModelApi::Utils.model_metadata(assoc_class)
1205
+ assoc_obj = find_by_id_attrs(model_metadata[:id_attributes], assoc_class, assoc_payload)
1206
+ assoc_obj = assoc_obj.first unless assoc_obj.nil? || assoc_obj.count != 1
1207
+ assoc_obj ||= assoc_class.new
1208
+ if assoc_obj.new_record?
1209
+ assoc_oper = :create
1210
+ opts[:create_opts] ||= opts.merge(api_attr_metadata: ModelApi::Utils.filtered_attrs(
1211
+ assoc_class, :create, opts))
1212
+ assoc_opts = opts[:create_opts]
1213
+ else
1214
+ assoc_oper = :update
1215
+ opts[:update_opts] ||= opts.merge(api_attr_metadata: ModelApi::Utils.filtered_attrs(
1216
+ assoc_class, :update, opts))
1217
+ assoc_opts = opts[:update_opts]
1218
+ end
1219
+ apply_updates(assoc_obj, assoc_payload, assoc_oper, assoc_opts)
1220
+ ModelApi::Utils.invoke_callback(model_metadata[:after_initialize], assoc_obj,
1221
+ assoc_opts.merge(operation: assoc_oper).freeze)
1222
+ assoc_obj
1223
+ end
1224
+
1225
+ def self.update_belongs_to_assoc(obj, attr, value, opts = {})
1226
+ attr_metadata = opts[:attr_metadata]
1227
+ assoc = attr_metadata[:association]
1228
+ assoc_class = assoc.class_name.constantize
1229
+ assoc_opts = opts[:ignored_fields].is_a?(Array) ? opts.merge(ignored_fields: []) : opts
1230
+ model_metadata = ModelApi::Utils.model_metadata(assoc_class)
1231
+ assoc_obj = find_by_id_attrs(model_metadata[:id_attributes], assoc_class, value)
1232
+ assoc_obj = assoc_obj.first unless assoc_obj.nil? || assoc_obj.count != 1
1233
+ assoc_obj ||= assoc_class.new
1234
+ obj_oper = assoc_obj.new_record? ? :create : :update
1235
+ assoc_opts = assoc_opts.merge(
1236
+ api_attr_metadata: ModelApi::Utils.filtered_attrs(assoc_class, obj_oper, opts))
1237
+ unless value.is_a?(Hash)
1238
+ obj.errors.add(attr, 'must be supplied as an object')
1239
+ return
1240
+ end
1241
+ apply_updates(assoc_obj, value, obj_oper, assoc_opts)
1242
+ ModelApi::Utils.invoke_callback(model_metadata[:after_initialize], assoc_obj,
1243
+ opts.merge(operation: obj_oper).freeze)
1244
+ if assoc_opts[:ignored_fields].present?
1245
+ external_attr = ModelApi::Utils.ext_attr(attr, attr_metadata)
1246
+ opts[:ignored_fields] << { external_attr.to_s => assoc_opts[:ignored_fields] }
1247
+ end
1248
+ set_api_attr(obj, attr, assoc_obj, opts)
1249
+ end
1250
+
1251
+ def self.find_by_id_attrs(id_attributes, assoc_class, assoc_payload)
1252
+ return nil unless id_attributes.present?
1253
+ query = nil
1254
+ id_attributes.each do |id_attr|
1255
+ if assoc_payload.include?(id_attr.to_s)
1256
+ query = (query || assoc_class).where(id_attr => assoc_payload[id_attr.to_s])
1257
+ else
1258
+ return nil
1259
+ end
1260
+ end
1261
+ query
1262
+ end
1263
+
1264
+ def self.apply_context(query, opts = {})
1265
+ context = opts[:context]
1266
+ return query if context.nil?
1267
+ if context.respond_to?(:call)
1268
+ query = context.send(*([:call, query, opts][0..context.parameters.size]))
1269
+ elsif context.is_a?(Hash)
1270
+ context.each { |attr, value| query = query.where(attr => value) }
1271
+ end
1272
+ query
1273
+ end
1274
+
1275
+ def self.handle_api_setter_exception(e, obj, attr_metadata, opts = {})
1276
+ return unless attr_metadata.is_a?(Hash)
1277
+ on_exception = attr_metadata[:on_exception]
1278
+ fail e unless on_exception.present?
1279
+ on_exception = { Exception => on_exception } unless on_exception.is_a?(Hash)
1280
+ opts = opts.frozen? ? opts : opts.dup.freeze
1281
+ on_exception.each do |klass, handler|
1282
+ klass = klass.to_s.constantize rescue nil unless klass.is_a?(Class)
1283
+ next unless klass.is_a?(Class) && e.is_a?(klass)
1284
+ if handler.respond_to?(:call)
1285
+ ModelApi::Utils.invoke_callback(handler, obj, e, opts)
1286
+ elsif handler.present?
1287
+ # Presume handler is an error message in this case
1288
+ obj.errors.add(attr_metadata[:key], handler.to_s)
1289
+ else
1290
+ add_ignored_field(opts[:ignored_fields], nil, opts[:value], attr_metadata)
1291
+ end
1292
+ break
1293
+ end
1294
+ end
1295
+
1296
+ def self.add_ignored_field(ignored_fields, attr, value, attr_metadata)
1297
+ return unless ignored_fields.is_a?(Array)
1298
+ attr_metadata ||= {}
1299
+ external_attr = ModelApi::Utils.ext_attr(attr, attr_metadata)
1300
+ return unless external_attr.present?
1301
+ ignored_fields << { external_attr => value }
1302
+ end
1303
+
1304
+ def self.validate_operation(obj, operation, opts = {})
1305
+ klass = find_class(obj, opts)
1306
+ model_metadata = opts[:api_model_metadata] || ModelApi::Utils.model_metadata(klass)
1307
+ return nil unless operation.present?
1308
+ opts = opts.frozen? ? opts : opts.dup.freeze
1309
+ if obj.nil?
1310
+ ModelApi::Utils.invoke_callback(model_metadata[:"validate_#{operation}"], opts)
1311
+ else
1312
+ ModelApi::Utils.invoke_callback(model_metadata[:"validate_#{operation}"], obj, opts)
1313
+ end
1314
+ end
1315
+
1316
+ def self.validate_preserving_existing_errors(obj)
1317
+ if obj.errors.present?
1318
+ errors = obj.errors.messages.dup
1319
+ obj.valid?
1320
+ errors = obj.errors.messages.merge(errors)
1321
+ obj.errors.clear
1322
+ errors.each { |field, error| obj.errors.add(field, error) }
1323
+ else
1324
+ obj.valid?
1325
+ end
1326
+ end
1327
+
1328
+ def self.class_or_sti_subclass(klass, req_body, operation, opts = {})
1329
+ metadata = ModelApi::Utils.filtered_attrs(klass, :create, opts)
1330
+ if operation == :create && (attr_metadata = metadata[:type]).is_a?(Hash) &&
1331
+ req_body.is_a?(Hash)
1332
+ external_attr = ModelApi::Utils.ext_attr(:type, attr_metadata)
1333
+ type = req_body[external_attr.to_s]
1334
+ begin
1335
+ type = ModelApi::Utils.transform_value(type, attr_metadata[:parse], opts.dup)
1336
+ rescue Exception => e
1337
+ Rails.logger.warn 'Error encountered parsing API input for attribute ' \
1338
+ "\"#{external_attr}\" (\"#{e.message}\"): \"#{type.to_s.first(1000)}\" ... " \
1339
+ 'using raw value instead.'
1340
+ end
1341
+ if type.present? && (type = type.camelize) != klass.name
1342
+ Rails.application.eager_load!
1343
+ klass.subclasses.each do |subclass|
1344
+ return subclass if subclass.name == type
1345
+ end
1346
+ end
1347
+ end
1348
+ klass
1349
+ end
1350
+ end
1351
+ end
1352
+ end