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