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

gregschmit / rails-rest-framework / 30382952146

28 Jul 2026 05:27PM UTC coverage: 91.975% (-0.03%) from 92.009%
30382952146

push

github

gregschmit
Reject nested-hash filter params; document associations_limit_max.

Filter backends now guard against query params whose values are nested
hashes (e.g. `?field[evil]=x`, which Rack parses into a
HashWithIndifferentAccess). Previously these flowed into AR bind values
and raised `TypeError (can't quote ...)`, or broke `String#split` /
`sanitize_sql_like`. Adds a `_safe_query_value?` helper on BaseFilter,
applied in QueryFilter and OrderingFilter, and a String check in
SearchFilter. Includes regression tests for each backend.

Also documents `native_serializer_associations_limit_max` in the
serializers and performance guides: default `5` cap, clamp-to-max
semantics, and how to raise the ceiling or disable the query param.

7 of 7 new or added lines in 4 files covered. (100.0%)

42 existing lines in 4 files now uncovered.

1192 of 1296 relevant lines covered (91.98%)

316.5 hits per line

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

85.63
/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: 5,
61
    native_serializer_associations_limit_max: 5,
62
    native_serializer_associations_limit_query_param: "associations_limit".freeze,
63
    native_serializer_include_associations_count: false,
64

65
    # Options for filtering, ordering, and searching.
66
    filter_backends: [
67
      RESTFramework::QueryFilter,
68
      RESTFramework::OrderingFilter,
69
      RESTFramework::SearchFilter,
70
    ].freeze,
71
    filter_recordset_before_find: true,
72
    filter_fields: nil,
73
    ordering_fields: nil,
74
    ordering_query_param: "ordering".freeze,
75
    ordering_no_reorder: false,
76
    search_fields: nil,
77
    search_query_param: "search".freeze,
78
    search_ilike: false,
79
    ransack_options: nil,
80
    ransack_query_param: "q".freeze,
81
    ransack_distinct: true,
82
    ransack_distinct_query_param: "distinct".freeze,
83

84
    # Options for association assignment.
85
    permit_id_assignment: true,
86
    permit_nested_attributes_assignment: true,
87

88
    # Option for `recordset.create` vs `Model.create` behavior.
89
    create_from_recordset: true,
90

91
    # Options related to serialization.
92
    rescue_unknown_format_with: :json,
93
    serializer_class: nil,
94
    serialize_to_json: true,
95
    serialize_to_xml: true,
96

97
    # Options related to pagination.
98
    paginator_class: nil,
99
    page_size: 20,
100
    page_query_param: "page",
101
    page_size_query_param: "page_size",
102
    max_page_size: nil,
103

104
    # Option to disable serializer adapters by default, mainly introduced because Active Model
105
    # Serializers will do things like serialize `[]` into `{"":[]}`.
106
    disable_adapters_by_default: true,
107

108
    # Custom integrations (reduces serializer performance due to method calls).
109
    enable_action_text: false,
110
    enable_active_storage: false,
111
  }
112

113
  # Exceptions to be rescued and handled by returning a reasonable error response.
114
  RRF_RESCUED_EXCEPTIONS = [
115
    RESTFramework::InvalidBulkParametersError,
2✔
116
    RESTFramework::BulkRecordErrorsError,
117
  ].freeze
118
  RRF_RESCUED_RAILS_EXCEPTIONS = [
119
    ActionController::ParameterMissing,
2✔
120
    ActionController::UnpermittedParameters,
121
    ActionDispatch::Http::Parameters::ParseError,
122
    ActiveRecord::AssociationTypeMismatch,
123
    ActiveRecord::NotNullViolation,
124
    ActiveRecord::RecordNotFound,
125
    ActiveRecord::RecordInvalid,
126
    ActiveRecord::RecordNotSaved,
127
    ActiveRecord::RecordNotDestroyed,
128
    ActiveRecord::RecordNotUnique,
129
    ActiveModel::UnknownAttributeError,
130
  ].freeze
131

132
  # Anchored regex with non-greedy content_type match to prevent over-matching on malicious input.
133
  RRF_BASE64_REGEX = /\Adata:([^;]*);base64,(.*)\z/m
2✔
134
  RRF_BASE64_TRANSLATE = ->(field, value) {
2✔
UNCOV
135
    return value unless RRF_BASE64_REGEX.match?(value)
×
136

UNCOV
137
    _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
×
138
    {
UNCOV
139
      io: StringIO.new(Base64.decode64(payload)),
×
140
      content_type: content_type,
141
      filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
142
    }
143
  }
144
  RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
2✔
145

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

151
  module ClassMethods
2✔
152
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
153

154
    # By default, this is the name of the controller class, titleized and with any custom inflection
155
    # acronyms applied.
156
    def get_title
2✔
157
      self.title || RESTFramework::Utils.inflect(
132✔
158
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
159
        self.inflect_acronyms,
160
      )
161
    end
162

163
    # Get a label from a field/column name, titleized and inflected.
164
    def label_for(s)
2✔
165
      default_title = RESTFramework::Utils.inflect(
1,082✔
166
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
167
      )
168
      self.model&.human_attribute_name(s, default: default_title) || default_title
1,082✔
169
    end
170

171
    # Define any behavior to execute at the end of controller definition.
172
    # :nocov:
173
    def rrf_finalize
✔
174
      if RESTFramework.config.freeze_config
175
        self::RRF_BASE_CONFIG.keys.each { |k|
176
          v = self.send(k)
177
          v.freeze if v.is_a?(Hash) || v.is_a?(Array)
178
        }
179
      end
180

181
      self.setup_delegation if self.model
182
      # self.setup_channel if self.model
183
    end
184
    # :nocov:
185

186
    # Get the available fields. Fallback to this controller's model columns, or an empty array. This
187
    # should always return an array of strings.
188
    def get_fields(input_fields: nil)
2✔
189
      input_fields ||= self.fields
856✔
190

191
      # If fields is a hash, then parse it.
192
      if input_fields.is_a?(Hash)
856✔
193
        return RESTFramework::Utils.parse_fields_hash(
94✔
194
          input_fields,
195
          self.model,
196
          exclude_associations: self.exclude_associations,
197
          action_text: self.enable_action_text,
198
          active_storage: self.enable_active_storage,
199
        )
200
      elsif !input_fields
762✔
201
        # Otherwise, if fields is nil, then fallback to columns.
202
        return self.model ? RESTFramework::Utils.fields_for(
716✔
203
          self.model,
204
          exclude_associations: self.exclude_associations,
205
          action_text: self.enable_action_text,
206
          active_storage: self.enable_active_storage,
207
        ) : []
208
      elsif input_fields
46✔
209
        input_fields = input_fields.map(&:to_s)
46✔
210
      end
211

212
      input_fields
46✔
213
    end
214

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

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

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

237
        # Annotate primary key.
238
        if self.model.primary_key == f
308✔
239
          cfg[:primary_key] = true
36✔
240

241
          unless cfg.key?(:read_only)
36✔
242
            cfg[:read_only] = true
36✔
243
          end
244
        end
245

246
        # Annotate field mutability and display properties.
247
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
308✔
248
        cfg[:write_only] = true if f.in?(write_only_fields)
308✔
249
        cfg[:hidden] = true if f.in?(hidden_fields)
308✔
250

251
        # Raise warnings on some bad combinations of properties.
252
        if cfg[:write_only]
308✔
253
          if cfg[:read_only]
×
UNCOV
254
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
255
          end
256

257
          if cfg[:hidden]
×
UNCOV
258
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
259
          end
260

261
          if cfg[:hidden_from_index]
×
UNCOV
262
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
263
          end
264
        end
265

266
        # Annotate column data.
267
        if column = columns[f]
308✔
268
          cfg[:kind] = "column"
198✔
269
          cfg[:type] ||= column.type
198✔
270
          cfg[:required] = true unless column.null
198✔
271
        end
272

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

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

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

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

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

302
        # Get association metadata.
303
        if ref = reflections[f]
308✔
304
          cfg[:kind] = "association"
86✔
305

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

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

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

325
            next [ sf, v ]
166✔
326
          }.to_h.compact.presence
327

328
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
329
          # interface.
330
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
86✔
331
            cfg[:id_field] = id_field
74✔
332
          end
333

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

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

346
          cfg[:reflection] = ref
86✔
347
        end
348

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

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

360
        # Determine if this is just a method.
361
        if !cfg[:kind] && self.model.method_defined?(f)
308✔
362
          cfg[:kind] = "method"
4✔
363
          cfg[:read_only] = true if cfg[:read_only].nil?
4✔
364
        end
365

366
        # Collect validator options into a hash on their type, while also updating `required` based
367
        # on any presence validators.
368
        self.model.validators_on(f).each do |validator|
308✔
369
          kind = validator.kind
92✔
370
          options = validator.options
92✔
371

372
          # Reject validator if it includes keys like `:if` and `:unless` because those are
373
          # conditionally applied in a way that is not feasible to communicate via the API.
374
          next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
276✔
375

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

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

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

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

395
    # Only for model controllers.
396
    def setup_delegation
2✔
397
      # Delegate extra actions.
398
      self.extra_actions&.each do |action, config|
×
399
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
UNCOV
400
        next unless self.model.respond_to?(action)
×
401

402
        self.define_method(action) do
×
403
          if self.class.model.method(action).parameters.last&.first == :keyrest
×
UNCOV
404
            render(api: self.class.model.send(action, **request.query_parameters.symbolize_keys))
×
405
          else
UNCOV
406
            render(api: self.class.model.send(action))
×
407
          end
408
        end
409
      end
410

411
      # Delegate extra member actions.
412
      self.extra_member_actions&.each do |action, config|
×
413
        next unless config.is_a?(Hash) && config.dig(:metadata, :delegate)
×
UNCOV
414
        next unless self.model.method_defined?(action)
×
415

416
        self.define_method(action) do
×
UNCOV
417
          record = self.get_record
×
418

419
          if record.method(action).parameters.last&.first == :keyrest
×
UNCOV
420
            render(api: record.send(action, **request.query_parameters.symbolize_keys))
×
421
          else
UNCOV
422
            render(api: record.send(action))
×
423
          end
424
        end
425
      end
426
    end
427
  end
428

429
  def self.included(base)
2✔
430
    return unless base.is_a?(Class)
18✔
431

432
    base.extend(ClassMethods)
18✔
433

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

437
    # Add class attributes unless they already exist.
438
    RRF_BASE_CONFIG.each do |a, default|
18✔
439
      next if base.respond_to?(a)
1,188✔
440

441
      # Don't leak class attributes to the instance to avoid conflicting with action methods.
442
      base.class_attribute(a, default: default, instance_accessor: false)
528✔
443
    end
444

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

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

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

463
    # Use `TracePoint` hook to automatically call `rrf_finalize`.
464
    if RESTFramework.config.auto_finalize
18✔
465
      # :nocov:
466
      TracePoint.trace(:end) do |t|
✔
467
        next if base != t.self
468

469
        base.rrf_finalize
470

471
        # It's important to disable the trace once we've found the end of the base class definition,
472
        # for performance.
473
        t.disable
474
      end
475
      # :nocov:
476
    end
477
  end
478

479
  def get_serializer_class
2✔
480
    self.class.serializer_class || RESTFramework::NativeSerializer
232✔
481
  end
482

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

490
  def rrf_error_handler(e)
2✔
491
    status = case e
56✔
492
    when ActiveRecord::RecordNotFound
493
      404
34✔
494
    when RESTFramework::BulkRecordErrorsError
495
      422
4✔
496
    else
497
      400
18✔
498
    end
499

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

510
  def route_groups
2✔
511
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
108✔
512
  end
513

514
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
515
  # support or passing custom `kwargs` to the underlying `render` calls.
516
  def render_api(payload, **kwargs)
2✔
517
    html_kwargs = kwargs.delete(:html_kwargs) || {}
372✔
518
    json_kwargs = kwargs.delete(:json_kwargs) || {}
372✔
519
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
372✔
520

521
    # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
522
    # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
523
    # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
524
    # framework to catch this error and return an appropriate error response.
525
    if payload.nil?
372✔
526
      raise RESTFramework::NilPassedToRenderAPIError
6✔
527
    end
528

529
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
530
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
366✔
531
      payload = self.serialize(payload)
150✔
532
    end
533

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

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

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

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

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

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

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

605
    @_get_allowed_parameters = self.class.allowed_parameters
90✔
606
    return @_get_allowed_parameters if @_get_allowed_parameters
90✔
607

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

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

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

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

633
      if config[:reflection]
730✔
634
        # Add `_id`/`_ids` variations for associations.
635
        if id_field = config[:id_field]
244✔
636
          if id_field.ends_with?("_ids")
226✔
637
            hash_variations[id_field] = []
156✔
638
          else
639
            variations << id_field
70✔
640
          end
641
        end
642

643
        # Add `_attributes` variations for associations.
644
        # TODO: Consider adjusting this based on `nested_attributes_options`.
645
        if self.class.permit_nested_attributes_assignment
244✔
646
          hash_variations["#{f}_attributes"] = (
244✔
647
            config[:sub_fields] + [ "_destroy" ]
244✔
648
          )
649
        end
650

651
        # Associations are not allowed to be submitted in their bare form (if they are submitted
652
        # that way, they will be translated to either id/ids or nested attributes assignment).
653
        next nil
244✔
654
      end
655

656
      next f
486✔
657
    }.compact
658
    @_get_allowed_parameters += variations
90✔
659
    @_get_allowed_parameters << hash_variations
90✔
660

661
    @_get_allowed_parameters
90✔
662
  end
663

664
  # Use strong parameters to filter the request body.
665
  def get_body_params(bulk_action: nil)
2✔
666
    data = self.request.request_parameters
90✔
667
    pk = self.class.model&.primary_key
90✔
668
    allowed_params = self.get_allowed_parameters
90✔
669

670
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
671
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
672
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
673
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
674
    if !bulk_action && self.class.model
90✔
675
      self.class.model.reflections.each do |name, ref|
46✔
676
        if payload = data[name]
288✔
677
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
4✔
678
            # Assume nested attributes assignment.
679
            attributes_key = "#{name}_attributes"
2✔
680
            data[attributes_key] = data.delete(name) unless data[attributes_key]
2✔
681
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
2✔
682
            # Assume id/ids assignment.
683
            data[id_field] = data.delete(name) unless data[id_field]
2✔
684
          end
685
        end
686
      end
687
    end
688

689
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
690
    #
691
    # rubocop:disable Layout/LineLength
692
    #
693
    # Example base64 images (red, green, and blue squares):
694
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
695
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
696
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
697
    #
698
    # rubocop:enable Layout/LineLength
699
    has_many_attached_scalar_data = {}
90✔
700
    if !bulk_action && self.class.enable_active_storage && self.class.model
90✔
701
      self.class.model.attachment_reflections.keys.each do |k|
30✔
702
        if data[k].is_a?(Array)
20✔
703
          data[k] = data[k].map { |v|
×
704
            if v.is_a?(String)
×
UNCOV
705
              v = RRF_BASE64_TRANSLATE.call(k, v)
×
706

707
              # Remember scalars because Rails strong params will remove it.
708
              if v.is_a?(String)
×
709
                has_many_attached_scalar_data[k] ||= []
×
UNCOV
710
                has_many_attached_scalar_data[k] << v
×
711
              end
712
            elsif v.is_a?(Hash)
×
713
              if v[:io].is_a?(String)
×
UNCOV
714
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
715
              end
716
            end
717

UNCOV
718
            next v
×
719
          }
720
        elsif data[k].is_a?(Hash)
20✔
721
          if data[k][:io].is_a?(String)
×
UNCOV
722
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
723
          end
724
        elsif data[k].is_a?(String)
20✔
UNCOV
725
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
726
        end
727
      end
728
    end
729

730
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
731
    # parameters to the `_json` key of the request body.
732
    body_params = if allowed_params == true
90✔
UNCOV
733
      ActionController::Parameters.new(data).permit!
×
734
    elsif bulk_action
90✔
735
      if bulk_action == :create
44✔
736
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
18✔
737
      elsif bulk_action == :update
26✔
738
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
14✔
739
      elsif bulk_action == :destroy
12✔
740
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
741
      else
UNCOV
742
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
743
      end
744
    else
745
      ActionController::Parameters.new(data).permit(*allowed_params)
46✔
746
    end
747

748
    # ActiveStorage Integration: Workaround for Rails strong params not allowing you to permit an
749
    # array containing a mix of scalars and hashes. This is needed for `has_many_attached`, because
750
    # API consumers must be able to provide scalar `signed_id` values for existing attachments along
751
    # with hashes for new attachments. It's worth mentioning that base64 scalars are converted to
752
    # hashes that conform to the ActiveStorage API.
753
    has_many_attached_scalar_data.each do |k, v|
90✔
UNCOV
754
      body_params[k].unshift(*v)
×
755
    end
756

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

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

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

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

778
    nil
779
  end
780

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

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

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

794
    find_by_key = self.class.model.primary_key
96✔
795
    is_pk = true
96✔
796

797
    # Find by another column if it's permitted.
798
    if find_by_param = self.class.find_by_query_param.presence
96✔
799
      if find_by = request.query_parameters[find_by_param].presence
96✔
800
        find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.get_fields
4✔
801

802
        if find_by.in?(find_by_fields)
4✔
803
          is_pk = false unless find_by_key == find_by
4✔
804
          find_by_key = find_by
4✔
805
        end
806
      end
807
    end
808

809
    # Get the recordset, filtering if configured.
810
    collection = if self.class.filter_recordset_before_find
96✔
811
      self.get_records
92✔
812
    else
813
      self.get_recordset
4✔
814
    end
815

816
    # Return the record. Route key is always `:id` by Rails' convention.
817
    if is_pk
96✔
818
      @record = collection.find(request.path_parameters[:id])
92✔
819
    else
820
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
821
    end
822
  end
823

824
  # Determine what collection to call `create` on.
825
  def create_from
2✔
826
    if self.class.create_from_recordset
52✔
827
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
828
      # in case model callbacks need to call `count` on this collection, which typically raises a
829
      # SQL `SyntaxError`.
830
      self.get_recordset.except(:select)
50✔
831
    else
832
      # Otherwise, perform a "bare" insert_all.
833
      self.class.model
2✔
834
    end
835
  end
836
end
837

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

© 2026 Coveralls, Inc