• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gregschmit / rails-rest-framework / 24747641499

21 Apr 2026 09:31PM UTC coverage: 87.783% (+0.2%) from 87.619%
24747641499

push

github

gregschmit
Improve bulk operations.

Expand bulk operations to support regular activerecord operations and
single-query operations.
Various adjustments and improvements to code organization.
Remove disable_rescue_from as there are easy alternatives.

122 of 143 new or added lines in 4 files covered. (85.31%)

1 existing line in 1 file now uncovered.

1200 of 1367 relevant lines covered (87.78%)

215.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.19
/lib/rest_framework/controller.rb
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
2✔
5
  RRF_BASE_CONFIG = {
6
    extra_actions: nil,
2✔
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,
2✔
115
    RESTFramework::BulkRecordErrorsError,
116
  ].freeze
117
  RRF_RESCUED_RAILS_EXCEPTIONS = [
118
    ActionController::ParameterMissing,
2✔
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
2✔
133
  RRF_BASE64_TRANSLATE = ->(field, value) {
2✔
NEW
134
    return value unless RRF_BASE64_REGEX.match?(value)
×
135

NEW
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 ]
2✔
144

145
  # Default action for API root.
146
  def root
2✔
147
    render(api: { message: "This is the API root." })
10✔
148
  end
149

150
  module ClassMethods
2✔
151
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
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
2✔
156
      self.title || RESTFramework::Utils.inflect(
80✔
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)
2✔
164
      default_title = RESTFramework::Utils.inflect(
714✔
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
714✔
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)
2✔
188
      input_fields ||= self.fields
758✔
189

190
      # If fields is a hash, then parse it.
191
      if input_fields.is_a?(Hash)
758✔
192
        return RESTFramework::Utils.parse_fields_hash(
94✔
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
664✔
200
        # Otherwise, if fields is nil, then fallback to columns.
201
        return self.model ? RESTFramework::Utils.fields_for(
638✔
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
26✔
208
        input_fields = input_fields.map(&:to_s)
26✔
209
      end
210

211
      input_fields
26✔
212
    end
213

214
    # Get a full field configuration, including defaults and inferred values.
215
    def field_configuration
2✔
216
      return @field_configuration if @field_configuration
4,096✔
217

218
      field_config = self.field_config&.with_indifferent_access || {}
28✔
219
      columns = self.model.columns_hash
28✔
220
      column_defaults = self.model.column_defaults
28✔
221
      reflections = self.model.reflections
28✔
222
      attributes = self.model._default_attributes
28✔
223
      readonly_attributes = self.model.readonly_attributes
28✔
224
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
28✔
225
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
28✔
226
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
28✔
227
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
28✔
228
        .collect(&:name)
229
        .select { |n| n.to_s.start_with?("rich_text_") }
42✔
230
      attachment_reflections = self.model.attachment_reflections
28✔
231

232
      @field_configuration = self.get_fields.map { |f|
28✔
233
        cfg = field_config[f]&.dup || {}
262✔
234
        cfg[:label] ||= self.label_for(f)
262✔
235

236
        # Annotate primary key.
237
        if self.model.primary_key == f
262✔
238
          cfg[:primary_key] = true
28✔
239

240
          unless cfg.key?(:read_only)
28✔
241
            cfg[:read_only] = true
28✔
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)
262✔
247
        cfg[:write_only] = true if f.in?(write_only_fields)
262✔
248
        cfg[:hidden] = true if f.in?(hidden_fields)
262✔
249

250
        # Raise warnings on some bad combinations of properties.
251
        if cfg[:write_only]
262✔
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]
262✔
267
          cfg[:kind] = "column"
170✔
268
          cfg[:type] ||= column.type
170✔
269
          cfg[:required] = true unless column.null
170✔
270
        end
271

272
        # Add default values from the model's schema.
273
        if cfg[:default].nil? && (column_default = column_defaults[f])
262✔
274
          cfg[:default] = column_default
62✔
275
        end
276

277
        # Add metadata from the model's attributes hash.
278
        if attributes.key?(f) && attribute = attributes[f]
262✔
279
          if cfg[:default].nil? && default = attribute.value_before_type_cast
170✔
280
            cfg[:default] = default
×
281
          end
282
          cfg[:kind] ||= "attribute"
170✔
283

284
          # Get any type information from the attribute.
285
          if type = attribute.type
170✔
286
            cfg[:type] ||= type.type if type.type
170✔
287

288
            # Get enum variants.
289
            if type.is_a?(ActiveRecord::Enum::EnumType)
170✔
290
              cfg[:enum_variants] = type.send(:mapping)
8✔
291

292
              # TranslateEnum Integration:
293
              translate_method = "translated_#{f.pluralize}"
8✔
294
              if self.model.respond_to?(translate_method)
8✔
295
                cfg[:enum_translations] = self.model.send(translate_method)
8✔
296
              end
297
            end
298
          end
299
        end
300

301
        # Get association metadata.
302
        if ref = reflections[f]
262✔
303
          cfg[:kind] = "association"
74✔
304

305
          # Determine sub-fields for associations.
306
          if ref.polymorphic?
74✔
307
            ref_columns = {}
×
308
          else
309
            ref_columns = ref.klass.columns_hash
74✔
310
          end
311
          cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
74✔
312
          cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
74✔
313

314
          # Very basic metadata about sub-fields.
315
          cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
74✔
316
            v = {}
142✔
317

318
            if ref_columns[sf]
142✔
319
              v[:kind] = "column"
142✔
320
            else
321
              v[:kind] = "method"
×
322
            end
323

324
            next [ sf, v ]
142✔
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)
74✔
330
            cfg[:id_field] = id_field
64✔
331
          end
332

333
          # Determine if we render nested attributes options.
334
          if self.permit_nested_attributes_assignment && (
74✔
335
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
74✔
336
          )
337
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
24✔
338
          end
339

340
          begin
341
            cfg[:association_pk] = ref.active_record_primary_key
74✔
342
          rescue ActiveRecord::UnknownPrimaryKey
343
          end
344

345
          cfg[:reflection] = ref
74✔
346
        end
347

348
        # Determine if this is an ActionText "rich text".
349
        if :"rich_text_#{f}".in?(rich_text_association_names)
262✔
350
          cfg[:kind] = "rich_text"
4✔
351
        end
352

353
        # Determine if this is an ActiveStorage attachment.
354
        if ref = attachment_reflections[f]
262✔
355
          cfg[:kind] = "attachment"
8✔
356
          cfg[:attachment_type] = ref.macro
8✔
357
        end
358

359
        # Determine if this is just a method.
360
        if !cfg[:kind] && self.model.method_defined?(f)
262✔
361
          cfg[:kind] = "method"
4✔
362
          cfg[:read_only] = true if cfg[:read_only].nil?
4✔
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|
262✔
368
          kind = validator.kind
78✔
369
          options = validator.options
78✔
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) }
234✔
374

375
          # Update `required` if we find a presence validator.
376
          cfg[:required] = true if kind == :presence
78✔
377

378
          # Resolve procs (and lambdas), and symbols for certain arguments.
379
          if options[:in].is_a?(Proc)
78✔
380
            options = options.merge(in: options[:in].call)
×
381
          elsif options[:in].is_a?(Symbol)
78✔
382
            options = options.merge(in: self.model.send(options[:in]))
8✔
383
          end
384

385
          cfg[:validators] ||= {}
78✔
386
          cfg[:validators][kind] ||= []
78✔
387
          cfg[:validators][kind] << options
78✔
388
        end
389

390
        next [ f, cfg ]
262✔
391
      }.to_h.compact.with_indifferent_access
392
    end
393

394
    # Only for model controllers.
395
    def setup_delegation
2✔
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)
2✔
429
    return unless base.is_a?(Class)
18✔
430

431
    base.extend(ClassMethods)
18✔
432

433
    # By default, the layout should be set to `rest_framework`.
434
    base.layout("rest_framework")
18✔
435

436
    # Add class attributes unless they already exist.
437
    RRF_BASE_CONFIG.each do |a, default|
18✔
438
      next if base.respond_to?(a)
1,170✔
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)
520✔
442
    end
443

444
    # Alias `extra_actions` to `extra_collection_actions`.
445
    unless base.respond_to?(:extra_collection_actions)
18✔
446
      base.singleton_class.alias_method(:extra_collection_actions, :extra_actions)
8✔
447
      base.singleton_class.alias_method(:extra_collection_actions=, :extra_actions=)
8✔
448
    end
449

450
    # Skip CSRF since this is an API.
451
    begin
452
      base.skip_before_action(:verify_authenticity_token)
18✔
453
    rescue ArgumentError
454
      # The callback may not exist if forgery protection isn't enabled; this is expected.
455
      nil
10✔
456
    end
457

458
    # Handle exceptions.
459
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
18✔
460
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
18✔
461

462
    # Use `TracePoint` hook to automatically call `rrf_finalize`.
463
    if RESTFramework.config.auto_finalize
18✔
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
2✔
479
    self.class.serializer_class || RESTFramework::NativeSerializer
202✔
480
  end
481

482
  # Serialize the given data using the `serializer_class`.
483
  def serialize(data, **kwargs)
2✔
484
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
190✔
485
      data, controller: self, **kwargs
486
    ).serialize
487
  end
488

489
  def rrf_error_handler(e)
2✔
490
    status = case e
44✔
491
    when ActiveRecord::RecordNotFound
492
      404
32✔
493
    when RESTFramework::BulkRecordErrorsError
494
      422
4✔
495
    else
496
      400
8✔
497
    end
498

499
    render(
44✔
500
      api: {
501
        message: e.message,
502
        errors: e.try(:record).try(:errors),
503
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
44✔
504
      }.compact,
505
      status: status,
506
    )
507
  end
508

509
  def route_groups
2✔
510
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
80✔
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)
2✔
516
    html_kwargs = kwargs.delete(:html_kwargs) || {}
314✔
517
    json_kwargs = kwargs.delete(:json_kwargs) || {}
314✔
518
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
314✔
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?
314✔
525
      raise RESTFramework::NilPassedToRenderAPIError
6✔
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)
308✔
530
      payload = self.serialize(payload)
144✔
531
    end
532

533
    # Do not use any adapters by default, if configured.
534
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
308✔
535
      kwargs[:adapter] = nil
308✔
536
    end
537

538
    # Flag to track if we had to rescue unknown format.
539
    already_rescued_unknown_format = false
308✔
540

541
    begin
542
      respond_to do |format|
310✔
543
        if payload == ""
310✔
544
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
18✔
545
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
18✔
546
        else
547
          format.json {
548
            render(json: payload, **kwargs.merge(json_kwargs))
198✔
549
          } if self.class.serialize_to_json
294✔
550
          format.xml {
551
            render(xml: payload, **kwargs.merge(xml_kwargs))
34✔
552
          } if self.class.serialize_to_xml
294✔
553
          # TODO: possibly support more formats here if supported?
554
        end
555
        format.html {
310✔
556
          @payload = payload
70✔
557
          if payload == ""
70✔
558
            @json_payload = "" if self.class.serialize_to_json
12✔
559
            @xml_payload = "" if self.class.serialize_to_xml
12✔
560
          else
561
            @json_payload = payload.to_json if self.class.serialize_to_json
58✔
562
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
58✔
563
          end
564
          @title ||= self.class.get_title
70✔
565
          @description ||= self.class.description
70✔
566
          self.route_groups
70✔
567
          begin
568
            render(**kwargs.merge(html_kwargs))
70✔
569
          rescue ActionView::MissingTemplate
570
            # A view is not required, so just use `html: ""`.
571
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
70✔
572
          end
573
        }
574
      end
575
    rescue ActionController::UnknownFormat
4✔
576
      if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
4✔
577
        request.format = rescue_format
2✔
578
        already_rescued_unknown_format = true
2✔
579
        retry
2✔
580
      else
581
        raise
2✔
582
      end
583
    end
584
  end
585

586
  # Deprecated alias for `render_api`.
587
  def api_response(*args, **kwargs)
2✔
588
    RESTFramework.deprecator.warn("`api_response` is deprecated; use `render_api` instead.")
×
589
    render_api(*args, **kwargs)
×
590
  end
591

592
  def options
2✔
593
    render(api: self.openapi_document)
10✔
594
  end
595

596
  def get_fields
2✔
597
    self.class.get_fields(input_fields: self.class.fields)
730✔
598
  end
599

600
  # Get a hash of strong parameters for the current action.
601
  def get_allowed_parameters
2✔
602
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
76✔
603

604
    @_get_allowed_parameters = self.class.allowed_parameters
76✔
605
    return @_get_allowed_parameters if @_get_allowed_parameters
76✔
606

607
    # Assemble strong parameters.
608
    variations = []
76✔
609
    hash_variations = {}
76✔
610
    reflections = self.class.model.reflections
76✔
611
    @_get_allowed_parameters = self.get_fields.map { |f|
76✔
612
      f = f.to_s
786✔
613
      config = self.class.field_configuration[f]
786✔
614

615
      # ActionText Integration:
616
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
786✔
617
        next f
28✔
618
      end
619

620
      # ActiveStorage Integration: `has_one_attached`
621
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
758✔
622
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
28✔
623
        next f
28✔
624
      end
625

626
      # ActiveStorage Integration: `has_many_attached`
627
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
730✔
628
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
28✔
629
        next nil
28✔
630
      end
631

632
      if config[:reflection]
702✔
633
        # Add `_id`/`_ids` variations for associations.
634
        if id_field = config[:id_field]
244✔
635
          if id_field.ends_with?("_ids")
226✔
636
            hash_variations[id_field] = []
156✔
637
          else
638
            variations << id_field
70✔
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
244✔
645
          hash_variations["#{f}_attributes"] = (
244✔
646
            config[:sub_fields] + [ "_destroy" ]
244✔
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
244✔
653
      end
654

655
      next f
458✔
656
    }.compact
657
    @_get_allowed_parameters += variations
76✔
658
    @_get_allowed_parameters << hash_variations
76✔
659

660
    @_get_allowed_parameters
76✔
661
  end
662

663
  # Use strong parameters to filter the request body.
664
  def get_body_params(bulk_action: nil)
2✔
665
    data = self.request.request_parameters
76✔
666
    pk = self.class.model&.primary_key
76✔
667
    allowed_params = self.get_allowed_parameters
76✔
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
76✔
674
      self.class.model.reflections.each do |name, ref|
42✔
675
        if payload = data[name]
264✔
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 = {}
76✔
699
    if !bulk_action && self.class.enable_active_storage && self.class.model
76✔
700
      self.class.model.attachment_reflections.keys.each do |k|
26✔
701
        if data[k].is_a?(Array)
20✔
702
          data[k] = data[k].map { |v|
×
703
            if v.is_a?(String)
×
NEW
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)
20✔
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)
20✔
NEW
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
76✔
732
      ActionController::Parameters.new(data).permit!
×
733
    elsif bulk_action
76✔
734
      if bulk_action == :create
34✔
735
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
18✔
736
      elsif bulk_action == :update
16✔
737
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
8✔
738
      elsif bulk_action == :destroy
8✔
739
        ActionController::Parameters.new(data).permit({ _json: [] })
8✔
740
      else
741
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
742
      end
743
    else
744
      ActionController::Parameters.new(data).permit(*allowed_params)
42✔
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|
76✔
753
      body_params[k].unshift(*v)
×
754
    end
755

756
    # Filter read-only fields.
757
    body_params.delete_if do |f, _|
76✔
758
      cfg = self.class.field_configuration[f]
96✔
759
      cfg && cfg[:read_only]
96✔
760
    end
761

762
    body_params
76✔
763
  end
764
  alias_method :get_create_params, :get_body_params
2✔
765
  alias_method :get_update_params, :get_body_params
2✔
766
  alias_method :get_destroy_params, :get_body_params
2✔
767

768
  # Get the set of records this controller has access to.
769
  def get_recordset
2✔
770
    return self.class.recordset if self.class.recordset
258✔
771

772
    # If there is a model, return that model's default scope (all records by default).
773
    if self.class.model
258✔
774
      return self.class.model.all
258✔
775
    end
776

777
    nil
778
  end
779

780
  # Filter the recordset and return records this request has access to.
781
  def get_records
2✔
782
    data = self.get_recordset
200✔
783

784
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
200✔
785
      filter.new(controller: self).filter_data(d)
712✔
786
    } || data
787
  end
788

789
  # Get a single record by primary key or another column, if allowed.
790
  def get_record
2✔
791
    return @record if @record
92✔
792

793
    find_by_key = self.class.model.primary_key
92✔
794
    is_pk = true
92✔
795

796
    # Find by another column if it's permitted.
797
    if find_by_param = self.class.find_by_query_param.presence
92✔
798
      if find_by = request.query_parameters[find_by_param].presence
92✔
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
92✔
810
      self.get_records
92✔
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
92✔
817
      @record = collection.find(request.path_parameters[:id])
92✔
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
2✔
825
    if self.class.create_from_recordset
48✔
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)
46✔
830
    else
831
      # Otherwise, perform a "bare" insert_all.
832
      self.class.model
2✔
833
    end
834
  end
835
end
836

837
require_relative "controller/bulk"
2✔
838
require_relative "controller/crud"
2✔
839
require_relative "controller/openapi"
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc