rest_framework 1.0.1 → 1.1.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.
@@ -0,0 +1,804 @@
1
+ # This module provides the common functionality for all REST controllers. The implementation is
2
+ # split across several files under `controller/` for readability; each of those files reopens this
3
+ # module rather than defining a separate submodule.
4
+ module RESTFramework::Controller
5
+ RRF_BASE_CONFIG = {
6
+ extra_actions: nil,
7
+ extra_member_actions: nil,
8
+ singleton_controller: nil,
9
+
10
+ # Options related to metadata and display.
11
+ title: nil,
12
+ description: nil,
13
+ version: nil,
14
+ inflect_acronyms: RESTFramework.config.inflect_acronyms,
15
+ openapi_include_children: false,
16
+
17
+ # Core attributes related to models.
18
+ model: nil,
19
+ recordset: nil,
20
+ excluded_actions: nil,
21
+ bulk: false,
22
+
23
+ # Attributes for configuring record fields.
24
+ fields: nil,
25
+ field_config: nil,
26
+ read_only_fields: RESTFramework.config.read_only_fields,
27
+ write_only_fields: RESTFramework.config.write_only_fields,
28
+ hidden_fields: nil,
29
+
30
+ # Attributes for finding records.
31
+ find_by_fields: nil,
32
+ find_by_query_param: "find_by".freeze,
33
+
34
+ # Options for what should be included/excluded from default fields.
35
+ exclude_associations: false,
36
+
37
+ # Options for handling request body parameters.
38
+ allowed_parameters: nil,
39
+
40
+ # Attributes for the default native serializer.
41
+ native_serializer_config: nil,
42
+ native_serializer_singular_config: nil,
43
+ native_serializer_plural_config: nil,
44
+ native_serializer_only_query_param: "only".freeze,
45
+ native_serializer_except_query_param: "except".freeze,
46
+ native_serializer_include_query_param: "include".freeze,
47
+ native_serializer_exclude_query_param: "exclude".freeze,
48
+ native_serializer_associations_limit: nil,
49
+ native_serializer_associations_limit_query_param: "associations_limit".freeze,
50
+ native_serializer_include_associations_count: false,
51
+
52
+ # Attributes for filtering, ordering, and searching.
53
+ filter_backends: [
54
+ RESTFramework::QueryFilter,
55
+ RESTFramework::OrderingFilter,
56
+ RESTFramework::SearchFilter,
57
+ ].freeze,
58
+ filter_recordset_before_find: true,
59
+ filter_fields: nil,
60
+ ordering_fields: nil,
61
+ ordering_query_param: "ordering".freeze,
62
+ ordering_no_reorder: false,
63
+ search_fields: nil,
64
+ search_query_param: "search".freeze,
65
+ search_ilike: false,
66
+ ransack_options: nil,
67
+ ransack_query_param: "q".freeze,
68
+ ransack_distinct: true,
69
+ ransack_distinct_query_param: "distinct".freeze,
70
+
71
+ # Options for association assignment.
72
+ permit_id_assignment: true,
73
+ permit_nested_attributes_assignment: true,
74
+
75
+ # Option for `recordset.create` vs `Model.create` behavior.
76
+ create_from_recordset: true,
77
+
78
+ # Options related to serialization.
79
+ rescue_unknown_format_with: :json,
80
+ serializer_class: nil,
81
+ serialize_to_json: true,
82
+ serialize_to_xml: true,
83
+
84
+ # Options related to pagination.
85
+ paginator_class: nil,
86
+ page_size: 20,
87
+ page_query_param: "page",
88
+ page_size_query_param: "page_size",
89
+ max_page_size: nil,
90
+
91
+ # Option to disable serializer adapters by default, mainly introduced because Active Model
92
+ # Serializers will do things like serialize `[]` into `{"":[]}`.
93
+ disable_adapters_by_default: true,
94
+
95
+ # Custom integrations (reduces serializer performance due to method calls).
96
+ enable_action_text: false,
97
+ enable_active_storage: false,
98
+ }
99
+ BASE64_REGEX = /data:(.*);base64,(.*)/
100
+ BASE64_TRANSLATE = ->(field, value) {
101
+ return value unless BASE64_REGEX.match?(value)
102
+
103
+ _, content_type, payload = value.match(BASE64_REGEX).to_a
104
+ {
105
+ io: StringIO.new(Base64.decode64(payload)),
106
+ content_type: content_type,
107
+ filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
108
+ }
109
+ }
110
+ ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
111
+
112
+ # Default action for API root.
113
+ def root
114
+ render(api: { message: "This is the API root." })
115
+ end
116
+
117
+ module ClassMethods
118
+ IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
119
+
120
+ # By default, this is the name of the controller class, titleized and with any custom inflection
121
+ # acronyms applied.
122
+ def get_title
123
+ self.title || RESTFramework::Utils.inflect(
124
+ self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
125
+ self.inflect_acronyms,
126
+ )
127
+ end
128
+
129
+ # Get a label from a field/column name, titleized and inflected.
130
+ def label_for(s)
131
+ default_title = RESTFramework::Utils.inflect(
132
+ s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
133
+ )
134
+ self.model&.human_attribute_name(s, default: default_title) || default_title
135
+ end
136
+
137
+ # Define any behavior to execute at the end of controller definition.
138
+ # :nocov:
139
+ def rrf_finalize
140
+ if RESTFramework.config.freeze_config
141
+ self::RRF_BASE_CONFIG.keys.each { |k|
142
+ v = self.send(k)
143
+ v.freeze if v.is_a?(Hash) || v.is_a?(Array)
144
+ }
145
+ end
146
+
147
+ self.setup_delegation if self.model
148
+ # self.setup_channel if self.model
149
+ end
150
+ # :nocov:
151
+
152
+ # Get the available fields. Fallback to this controller's model columns, or an empty array. This
153
+ # should always return an array of strings.
154
+ def get_fields(input_fields: nil)
155
+ input_fields ||= self.fields
156
+
157
+ # If fields is a hash, then parse it.
158
+ if input_fields.is_a?(Hash)
159
+ return RESTFramework::Utils.parse_fields_hash(
160
+ input_fields,
161
+ self.model,
162
+ exclude_associations: self.exclude_associations,
163
+ action_text: self.enable_action_text,
164
+ active_storage: self.enable_active_storage,
165
+ )
166
+ elsif !input_fields
167
+ # Otherwise, if fields is nil, then fallback to columns.
168
+ return self.model ? RESTFramework::Utils.fields_for(
169
+ self.model,
170
+ exclude_associations: self.exclude_associations,
171
+ action_text: self.enable_action_text,
172
+ active_storage: self.enable_active_storage,
173
+ ) : []
174
+ elsif input_fields
175
+ input_fields = input_fields.map(&:to_s)
176
+ end
177
+
178
+ input_fields
179
+ end
180
+
181
+ # Get a full field configuration, including defaults and inferred values.
182
+ def field_configuration
183
+ return @field_configuration if @field_configuration
184
+
185
+ field_config = self.field_config&.with_indifferent_access || {}
186
+ columns = self.model.columns_hash
187
+ column_defaults = self.model.column_defaults
188
+ reflections = self.model.reflections
189
+ attributes = self.model._default_attributes
190
+ readonly_attributes = self.model.readonly_attributes
191
+ read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
192
+ write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
193
+ hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
194
+ rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
195
+ .collect(&:name)
196
+ .select { |n| n.to_s.start_with?("rich_text_") }
197
+ attachment_reflections = self.model.attachment_reflections
198
+
199
+ @field_configuration = self.get_fields.map { |f|
200
+ cfg = field_config[f]&.dup || {}
201
+ cfg[:label] ||= self.label_for(f)
202
+
203
+ # Annotate primary key.
204
+ if self.model.primary_key == f
205
+ cfg[:primary_key] = true
206
+
207
+ unless cfg.key?(:read_only)
208
+ cfg[:read_only] = true
209
+ end
210
+ end
211
+
212
+ # Annotate field mutability and display properties.
213
+ cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
214
+ cfg[:write_only] = true if f.in?(write_only_fields)
215
+ cfg[:hidden] = true if f.in?(hidden_fields)
216
+
217
+ # Raise warnings on some bad combinations of properties.
218
+ if cfg[:write_only]
219
+ if cfg[:read_only]
220
+ Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
221
+ end
222
+
223
+ if cfg[:hidden]
224
+ Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
225
+ end
226
+
227
+ if cfg[:hidden_from_index]
228
+ Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
229
+ end
230
+ end
231
+
232
+ # Annotate column data.
233
+ if column = columns[f]
234
+ cfg[:kind] = "column"
235
+ cfg[:type] ||= column.type
236
+ cfg[:required] = true unless column.null
237
+ end
238
+
239
+ # Add default values from the model's schema.
240
+ if cfg[:default].nil? && (column_default = column_defaults[f])
241
+ cfg[:default] = column_default
242
+ end
243
+
244
+ # Add metadata from the model's attributes hash.
245
+ if attributes.key?(f) && attribute = attributes[f]
246
+ if cfg[:default].nil? && default = attribute.value_before_type_cast
247
+ cfg[:default] = default
248
+ end
249
+ cfg[:kind] ||= "attribute"
250
+
251
+ # Get any type information from the attribute.
252
+ if type = attribute.type
253
+ cfg[:type] ||= type.type if type.type
254
+
255
+ # Get enum variants.
256
+ if type.is_a?(ActiveRecord::Enum::EnumType)
257
+ cfg[:enum_variants] = type.send(:mapping)
258
+
259
+ # TranslateEnum Integration:
260
+ translate_method = "translated_#{f.pluralize}"
261
+ if self.model.respond_to?(translate_method)
262
+ cfg[:enum_translations] = self.model.send(translate_method)
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ # Get association metadata.
269
+ if ref = reflections[f]
270
+ cfg[:kind] = "association"
271
+
272
+ # Determine sub-fields for associations.
273
+ if ref.polymorphic?
274
+ ref_columns = {}
275
+ else
276
+ ref_columns = ref.klass.columns_hash
277
+ end
278
+ cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
279
+ cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
280
+
281
+ # Very basic metadata about sub-fields.
282
+ cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
283
+ v = {}
284
+
285
+ if ref_columns[sf]
286
+ v[:kind] = "column"
287
+ else
288
+ v[:kind] = "method"
289
+ end
290
+
291
+ next [ sf, v ]
292
+ }.to_h.compact.presence
293
+
294
+ # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
295
+ # interface.
296
+ if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
297
+ cfg[:id_field] = id_field
298
+ end
299
+
300
+ # Determine if we render nested attributes options.
301
+ if self.permit_nested_attributes_assignment && (
302
+ nested_opts = self.model.nested_attributes_options[f.to_sym].presence
303
+ )
304
+ cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
305
+ end
306
+
307
+ begin
308
+ cfg[:association_pk] = ref.active_record_primary_key
309
+ rescue ActiveRecord::UnknownPrimaryKey
310
+ end
311
+
312
+ cfg[:reflection] = ref
313
+ end
314
+
315
+ # Determine if this is an ActionText "rich text".
316
+ if :"rich_text_#{f}".in?(rich_text_association_names)
317
+ cfg[:kind] = "rich_text"
318
+ end
319
+
320
+ # Determine if this is an ActiveStorage attachment.
321
+ if ref = attachment_reflections[f]
322
+ cfg[:kind] = "attachment"
323
+ cfg[:attachment_type] = ref.macro
324
+ end
325
+
326
+ # Determine if this is just a method.
327
+ if !cfg[:kind] && self.model.method_defined?(f)
328
+ cfg[:kind] = "method"
329
+ cfg[:read_only] = true if cfg[:read_only].nil?
330
+ end
331
+
332
+ # Collect validator options into a hash on their type, while also updating `required` based
333
+ # on any presence validators.
334
+ self.model.validators_on(f).each do |validator|
335
+ kind = validator.kind
336
+ options = validator.options
337
+
338
+ # Reject validator if it includes keys like `:if` and `:unless` because those are
339
+ # conditionally applied in a way that is not feasible to communicate via the API.
340
+ next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
341
+
342
+ # Update `required` if we find a presence validator.
343
+ cfg[:required] = true if kind == :presence
344
+
345
+ # Resolve procs (and lambdas), and symbols for certain arguments.
346
+ if options[:in].is_a?(Proc)
347
+ options = options.merge(in: options[:in].call)
348
+ elsif options[:in].is_a?(Symbol)
349
+ options = options.merge(in: self.model.send(options[:in]))
350
+ end
351
+
352
+ cfg[:validators] ||= {}
353
+ cfg[:validators][kind] ||= []
354
+ cfg[:validators][kind] << options
355
+ end
356
+
357
+ next [ f, cfg ]
358
+ }.to_h.compact.with_indifferent_access
359
+ end
360
+
361
+ # Only for model controllers.
362
+ def setup_delegation
363
+ # Delegate extra actions.
364
+ self.extra_actions&.each do |action, config|
365
+ next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
366
+ next unless self.model.respond_to?(action)
367
+
368
+ self.define_method(action) do
369
+ if self.model.method(action).parameters.last&.first == :keyrest
370
+ render(api: self.model.send(action, **params))
371
+ else
372
+ render(api: self.model.send(action))
373
+ end
374
+ end
375
+ end
376
+
377
+ # Delegate extra member actions.
378
+ self.extra_member_actions&.each do |action, config|
379
+ next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
380
+ next unless self.model.method_defined?(action)
381
+
382
+ self.define_method(action) do
383
+ record = self.get_record
384
+
385
+ if record.method(action).parameters.last&.first == :keyrest
386
+ render(api: record.send(action, **params))
387
+ else
388
+ render(api: record.send(action))
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
394
+
395
+ def self.included(base)
396
+ return unless base.is_a?(Class)
397
+
398
+ base.extend(ClassMethods)
399
+
400
+ # By default, the layout should be set to `rest_framework`.
401
+ base.layout("rest_framework")
402
+
403
+ # Add class attributes unless they already exist.
404
+ RRF_BASE_CONFIG.each do |a, default|
405
+ next if base.respond_to?(a)
406
+
407
+ # Don't leak class attributes to the instance to avoid conflicting with action methods.
408
+ base.class_attribute(a, default: default, instance_accessor: false)
409
+ end
410
+
411
+ # Alias `extra_actions` to `extra_collection_actions`.
412
+ unless base.respond_to?(:extra_collection_actions)
413
+ base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
414
+ base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
415
+ end
416
+
417
+ # Skip CSRF since this is an API.
418
+ begin
419
+ base.skip_before_action(:verify_authenticity_token)
420
+ rescue
421
+ nil
422
+ end
423
+
424
+ # Handle some common exceptions.
425
+ unless RESTFramework.config.disable_rescue_from
426
+ base.rescue_from(
427
+ ActionController::ParameterMissing,
428
+ ActionController::UnpermittedParameters,
429
+ ActionDispatch::Http::Parameters::ParseError,
430
+ ActiveRecord::AssociationTypeMismatch,
431
+ ActiveRecord::NotNullViolation,
432
+ ActiveRecord::RecordNotFound,
433
+ ActiveRecord::RecordInvalid,
434
+ ActiveRecord::RecordNotSaved,
435
+ ActiveRecord::RecordNotDestroyed,
436
+ ActiveRecord::RecordNotUnique,
437
+ ActiveModel::UnknownAttributeError,
438
+ with: :rrf_error_handler,
439
+ )
440
+ end
441
+
442
+ # Use `TracePoint` hook to automatically call `rrf_finalize`.
443
+ if RESTFramework.config.auto_finalize
444
+ # :nocov:
445
+ TracePoint.trace(:end) do |t|
446
+ next if base != t.self
447
+
448
+ base.rrf_finalize
449
+
450
+ # It's important to disable the trace once we've found the end of the base class definition,
451
+ # for performance.
452
+ t.disable
453
+ end
454
+ # :nocov:
455
+ end
456
+ end
457
+
458
+ def get_serializer_class
459
+ self.class.serializer_class || RESTFramework::NativeSerializer
460
+ end
461
+
462
+ # Serialize the given data using the `serializer_class`.
463
+ def serialize(data, **kwargs)
464
+ RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
465
+ data, controller: self, **kwargs
466
+ ).serialize
467
+ end
468
+
469
+ def rrf_error_handler(e)
470
+ status = case e
471
+ when ActiveRecord::RecordNotFound
472
+ 404
473
+ else
474
+ 400
475
+ end
476
+
477
+ render(
478
+ api: {
479
+ message: e.message,
480
+ errors: e.try(:record).try(:errors),
481
+ exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
482
+ }.compact,
483
+ status: status,
484
+ )
485
+ end
486
+
487
+ def route_groups
488
+ @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
489
+ end
490
+
491
+ # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
492
+ # support or passing custom `kwargs` to the underlying `render` calls.
493
+ def render_api(payload, **kwargs)
494
+ html_kwargs = kwargs.delete(:html_kwargs) || {}
495
+ json_kwargs = kwargs.delete(:json_kwargs) || {}
496
+ xml_kwargs = kwargs.delete(:xml_kwargs) || {}
497
+
498
+ # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
499
+ # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
500
+ # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
501
+ # framework to catch this error and return an appropriate error response.
502
+ if payload.nil?
503
+ raise RESTFramework::NilPassedToRenderAPIError
504
+ end
505
+
506
+ # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
507
+ if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
508
+ payload = self.serialize(payload)
509
+ end
510
+
511
+ # Do not use any adapters by default, if configured.
512
+ if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
513
+ kwargs[:adapter] = nil
514
+ end
515
+
516
+ # Flag to track if we had to rescue unknown format.
517
+ already_rescued_unknown_format = false
518
+
519
+ begin
520
+ respond_to do |format|
521
+ if payload == ""
522
+ format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
523
+ format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
524
+ else
525
+ format.json {
526
+ render(json: payload, **kwargs.merge(json_kwargs))
527
+ } if self.class.serialize_to_json
528
+ format.xml {
529
+ render(xml: payload, **kwargs.merge(xml_kwargs))
530
+ } if self.class.serialize_to_xml
531
+ # TODO: possibly support more formats here if supported?
532
+ end
533
+ format.html {
534
+ @payload = payload
535
+ if payload == ""
536
+ @json_payload = "" if self.class.serialize_to_json
537
+ @xml_payload = "" if self.class.serialize_to_xml
538
+ else
539
+ @json_payload = payload.to_json if self.class.serialize_to_json
540
+ @xml_payload = payload.to_xml if self.class.serialize_to_xml
541
+ end
542
+ @title ||= self.class.get_title
543
+ @description ||= self.class.description
544
+ self.route_groups
545
+ begin
546
+ render(**kwargs.merge(html_kwargs))
547
+ rescue ActionView::MissingTemplate
548
+ # A view is not required, so just use `html: ""`.
549
+ render(html: "", layout: true, **kwargs.merge(html_kwargs))
550
+ end
551
+ }
552
+ end
553
+ rescue ActionController::UnknownFormat
554
+ if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
555
+ request.format = rescue_format
556
+ already_rescued_unknown_format = true
557
+ retry
558
+ else
559
+ raise
560
+ end
561
+ end
562
+ end
563
+
564
+ # Compatibility alias for deprecated `api_response`.
565
+ alias_method :api_response, :render_api
566
+
567
+ def options
568
+ render(api: self.openapi_document)
569
+ end
570
+
571
+ def get_fields
572
+ self.class.get_fields(input_fields: self.class.fields)
573
+ end
574
+
575
+ # Get a hash of strong parameters for the current action.
576
+ def get_allowed_parameters
577
+ return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
578
+
579
+ @_get_allowed_parameters = self.class.allowed_parameters
580
+ return @_get_allowed_parameters if @_get_allowed_parameters
581
+
582
+ # Assemble strong parameters.
583
+ variations = []
584
+ hash_variations = {}
585
+ reflections = self.class.model.reflections
586
+ @_get_allowed_parameters = self.get_fields.map { |f|
587
+ f = f.to_s
588
+ config = self.class.field_configuration[f]
589
+
590
+ # ActionText Integration:
591
+ if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
592
+ next f
593
+ end
594
+
595
+ # ActiveStorage Integration: `has_one_attached`
596
+ if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
597
+ hash_variations[f] = ACTIVESTORAGE_KEYS
598
+ next f
599
+ end
600
+
601
+ # ActiveStorage Integration: `has_many_attached`
602
+ if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
603
+ hash_variations[f] = ACTIVESTORAGE_KEYS
604
+ next nil
605
+ end
606
+
607
+ if config[:reflection]
608
+ # Add `_id`/`_ids` variations for associations.
609
+ if id_field = config[:id_field]
610
+ if id_field.ends_with?("_ids")
611
+ hash_variations[id_field] = []
612
+ else
613
+ variations << id_field
614
+ end
615
+ end
616
+
617
+ # Add `_attributes` variations for associations.
618
+ # TODO: Consider adjusting this based on `nested_attributes_options`.
619
+ if self.class.permit_nested_attributes_assignment
620
+ hash_variations["#{f}_attributes"] = (
621
+ config[:sub_fields] + [ "_destroy" ]
622
+ )
623
+ end
624
+
625
+ # Associations are not allowed to be submitted in their bare form (if they are submitted
626
+ # that way, they will be translated to either id/ids or nested attributes assignment).
627
+ next nil
628
+ end
629
+
630
+ next f
631
+ }.compact
632
+ @_get_allowed_parameters += variations
633
+ @_get_allowed_parameters << hash_variations
634
+
635
+ @_get_allowed_parameters
636
+ end
637
+
638
+ # Use strong parameters to filter the request body.
639
+ def get_body_params(bulk_mode: nil)
640
+ data = self.request.request_parameters
641
+ pk = self.class.model&.primary_key
642
+ allowed_params = self.get_allowed_parameters
643
+
644
+ # Before we filter the data, dynamically dispatch association assignment to either the id/ids
645
+ # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
646
+ # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
647
+ # that is enforced by strong parameters generated by `get_allowed_parameters`.
648
+ self.class.model.reflections.each do |name, ref|
649
+ if payload = data[name]
650
+ if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
651
+ # Assume nested attributes assignment.
652
+ attributes_key = "#{name}_attributes"
653
+ data[attributes_key] = data.delete(name) unless data[attributes_key]
654
+ elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
655
+ # Assume id/ids assignment.
656
+ data[id_field] = data.delete(name) unless data[id_field]
657
+ end
658
+ end
659
+ end
660
+
661
+ # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
662
+ #
663
+ # rubocop:disable Layout/LineLength
664
+ #
665
+ # Example base64 images (red, green, and blue squares):
666
+ # data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
667
+ # data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
668
+ # data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
669
+ #
670
+ # rubocop:enable Layout/LineLength
671
+ has_many_attached_scalar_data = {}
672
+ if self.class.enable_active_storage
673
+ self.class.model.attachment_reflections.keys.each do |k|
674
+ if data[k].is_a?(Array)
675
+ data[k] = data[k].map { |v|
676
+ if v.is_a?(String)
677
+ v = BASE64_TRANSLATE.call(k, v)
678
+
679
+ # Remember scalars because Rails strong params will remove it.
680
+ if v.is_a?(String)
681
+ has_many_attached_scalar_data[k] ||= []
682
+ has_many_attached_scalar_data[k] << v
683
+ end
684
+ elsif v.is_a?(Hash)
685
+ if v[:io].is_a?(String)
686
+ v[:io] = StringIO.new(Base64.decode64(v[:io]))
687
+ end
688
+ end
689
+
690
+ next v
691
+ }
692
+ elsif data[k].is_a?(Hash)
693
+ if data[k][:io].is_a?(String)
694
+ data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
695
+ end
696
+ elsif data[k].is_a?(String)
697
+ data[k] = BASE64_TRANSLATE.call(k, data[k])
698
+ end
699
+ end
700
+ end
701
+
702
+ # Filter the request body with strong params. If `bulk` is true, then we apply allowed
703
+ # parameters to the `_json` key of the request body.
704
+ body_params = if allowed_params == true
705
+ ActionController::Parameters.new(data).permit!
706
+ elsif bulk_mode
707
+ pk = bulk_mode == :update ? [ pk ] : []
708
+ ActionController::Parameters.new(data).permit({ _json: allowed_params + pk })
709
+ else
710
+ ActionController::Parameters.new(data).permit(*allowed_params)
711
+ end
712
+
713
+ # ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
714
+ # array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
715
+ # API consumers must be able to provide scalar `signed_id` values for existing attachments along
716
+ # with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
717
+ # hashes that conform to the ActiveStorage API.
718
+ has_many_attached_scalar_data.each do |k, v|
719
+ body_params[k].unshift(*v)
720
+ end
721
+
722
+ # Filter read-only fields.
723
+ body_params.delete_if do |f, _|
724
+ cfg = self.class.field_configuration[f]
725
+ cfg && cfg[:read_only]
726
+ end
727
+
728
+ body_params
729
+ end
730
+ alias_method :get_create_params, :get_body_params
731
+ alias_method :get_update_params, :get_body_params
732
+
733
+ # Get the set of records this controller has access to.
734
+ def get_recordset
735
+ return self.class.recordset if self.class.recordset
736
+
737
+ # If there is a model, return that model's default scope (all records by default).
738
+ if self.class.model
739
+ return self.class.model.all
740
+ end
741
+
742
+ nil
743
+ end
744
+
745
+ # Filter the recordset and return records this request has access to.
746
+ def get_records
747
+ data = self.get_recordset
748
+
749
+ @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
750
+ filter.new(controller: self).filter_data(d)
751
+ } || data
752
+ end
753
+
754
+ # Get a single record by primary key or another column, if allowed.
755
+ def get_record
756
+ return @record if @record
757
+
758
+ find_by_key = self.class.model.primary_key
759
+ is_pk = true
760
+
761
+ # Find by another column if it's permitted.
762
+ if find_by_param = self.class.find_by_query_param.presence
763
+ if find_by = params[find_by_param].presence
764
+ find_by_fields = self.class.find_by_fields&.map(&:to_s)
765
+
766
+ if !find_by_fields || find_by.in?(find_by_fields)
767
+ is_pk = false unless find_by_key == find_by
768
+ find_by_key = find_by
769
+ end
770
+ end
771
+ end
772
+
773
+ # Get the recordset, filtering if configured.
774
+ collection = if self.class.filter_recordset_before_find
775
+ self.get_records
776
+ else
777
+ self.get_recordset
778
+ end
779
+
780
+ # Return the record. Route key is always `:id` by Rails' convention.
781
+ if is_pk
782
+ @record = collection.find(request.path_parameters[:id])
783
+ else
784
+ @record = collection.find_by!(find_by_key => request.path_parameters[:id])
785
+ end
786
+ end
787
+
788
+ # Determine what collection to call `create` on.
789
+ def create_from
790
+ if self.class.create_from_recordset
791
+ # Create with any properties inherited from the recordset. We exclude any `select` clauses
792
+ # in case model callbacks need to call `count` on this collection, which typically raises a
793
+ # SQL `SyntaxError`.
794
+ self.get_recordset.except(:select)
795
+ else
796
+ # Otherwise, perform a "bare" insert_all.
797
+ self.class.model
798
+ end
799
+ end
800
+ end
801
+
802
+ require_relative "controller/bulk"
803
+ require_relative "controller/crud"
804
+ require_relative "controller/openapi"