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

gregschmit / rails-rest-framework / 30496619839

29 Jul 2026 10:31PM UTC coverage: 94.29% (+2.3%) from 91.975%
30496619839

Pull #40

github

gregschmit
Preserve propagated class-attribute values after local set.

A `propagate { self.x = ... }` followed by a plain `self.x = ...`
clobbered the propagated getter, since both were defined on the same
singleton class. Descendants then had no `super` to reach and fell back
to the default instead of the propagated value.

Store propagated values (and the default baseline) in a per-class module
included in the singleton class, keeping local assignments on the
singleton class itself. A local getter now supers into the module rather
than overwriting it, and descendants keep inheriting the propagated
value. `super` resolves dynamically, so a propagate that happens after an
earlier local override still reaches descendants.

Strengthen the regression test to assert the descendant still sees the
propagated value, not merely that it differs from the local one.
Pull Request #40: v2

209 of 214 new or added lines in 8 files covered. (97.66%)

1 existing line in 1 file now uncovered.

1222 of 1296 relevant lines covered (94.29%)

335.93 hits per line

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

91.67
/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
    singular: nil,
2✔
7

8
    # Options related to metadata and display.
9
    title: nil,
10
    description: nil,
11
    version: nil,
12
    inflect_acronyms: RESTFramework.config.inflect_acronyms,
13
    openapi_include_children: false,
14

15
    # Options related to models.
16
    model: nil,
17
    recordset: nil,
18

19
    # Bulk configuration.
20
    #
21
    # When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
22
    # processing (e.g., `create` for each record). When `bulk` is set to `:raw`, it enables single
23
    # SQL query behavior (e.g., `insert_all` for bulk create) which skips validations/callbacks.
24
    bulk: false,
25
    bulk_partial: false,
26
    bulk_partial_query_param: "bulk_partial".freeze,
27
    bulk_allow_mode_override: false,
28
    bulk_mode_query_param: "bulk_mode".freeze,
29
    bulk_max_size: nil,
30
    bulk_max_raw_size: nil,
31

32
    # Configuring record fields.
33
    fields: nil,
34
    field_config: nil,
35
    read_only_fields: RESTFramework.config.read_only_fields,
36
    write_only_fields: RESTFramework.config.write_only_fields,
37
    hidden_fields: nil,
38

39
    # Finding records.
40
    find_by_fields: nil,
41
    find_by_query_param: "find_by".freeze,
42

43
    # What should be included/excluded from default fields.
44
    exclude_associations: false,
45

46
    # Handling request body parameters.
47
    allowed_parameters: nil,
48

49
    # Options for the default native serializer.
50
    native_serializer_config: nil,
51
    native_serializer_singular_config: nil,
52
    native_serializer_plural_config: nil,
53
    native_serializer_only_query_param: "only".freeze,
54
    native_serializer_except_query_param: "except".freeze,
55
    native_serializer_include_query_param: "include".freeze,
56
    native_serializer_exclude_query_param: "exclude".freeze,
57
    native_serializer_associations_limit: 5,
58
    native_serializer_associations_limit_max: 10,
59
    native_serializer_associations_limit_query_param: "associations_limit".freeze,
60
    native_serializer_include_associations_count: false,
61

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

81
    # Options for association assignment.
82
    permit_id_assignment: true,
83
    permit_nested_attributes_assignment: true,
84

85
    # Option for `recordset.create` vs `Model.create` behavior.
86
    create_from_recordset: true,
87

88
    # Options related to serialization.
89
    rescue_unknown_format_with: :json,
90
    serializer_class: nil,
91
    serialize_to_json: true,
92
    serialize_to_xml: true,
93

94
    # Options related to pagination. Pagination is on by default (page-number based) with a capped
95
    # page size, so responses are bounded out of the box; set `paginator_class = nil` to disable.
96
    paginator_class: RESTFramework::PageNumberPaginator,
97
    page_size: 20,
98
    page_query_param: "page".freeze,
99
    page_size_query_param: "page_size".freeze,
100
    max_page_size: 40,
101

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

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

111
  # Exceptions to be rescued and handled by returning a reasonable error response.
112
  RRF_RESCUED_EXCEPTIONS = [
113
    RESTFramework::InvalidBulkParametersError,
2✔
114
    RESTFramework::BulkRecordErrorsError,
115
  ].freeze
116
  RRF_RESCUED_RAILS_EXCEPTIONS = [
117
    ActionController::ParameterMissing,
2✔
118
    ActionController::UnpermittedParameters,
119
    ActionDispatch::Http::Parameters::ParseError,
120
    ActiveRecord::AssociationTypeMismatch,
121
    ActiveRecord::NotNullViolation,
122
    ActiveRecord::RecordNotFound,
123
    ActiveRecord::RecordInvalid,
124
    ActiveRecord::RecordNotSaved,
125
    ActiveRecord::RecordNotDestroyed,
126
    ActiveRecord::RecordNotUnique,
127
    ActiveRecord::StatementInvalid,
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✔
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 ]
2✔
144

145
  module ClassMethods
2✔
146
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
147

148
    # Thread-local key toggled by `propagate` while its block runs.
149
    RRF_PROPAGATING_KEY = :rrf_propagating
2✔
150

151
    # Define one or more class-level configuration attributes. Assignments are **local by default**:
152
    #
153
    #   self.x = value               # applies to this controller ONLY; descendants don't inherit it
154
    #   propagate { self.x = value } # applies to this controller AND all descendants
155
    #
156
    # This gives a single, uniform rule (an assignment is local unless wrapped in `propagate`), so
157
    # there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in
158
    # closures on redefined singleton methods (the same mechanism as `class_attribute`), never in
159
    # instance variables, so there is exactly one interface for configuration: the setter.
160
    #
161
    # Only singleton (class-level) methods are defined, so config never leaks to controller
162
    # instances (which would risk colliding with action methods).
163
    def rrf_class_attribute(*names, default: nil)
2✔
164
      names.each do |name|
3,552✔
165
        # Propagating baseline: every controller sees the default until it's overridden. This lives
166
        # in the propagated module (see `rrf_propagated_module`) rather than directly on the
167
        # singleton class, so a local assignment can coexist with it via `super`.
168
        rrf_propagated_module.define_method(name) { default }
13,578✔
169

170
        # Parity with `class_attribute`, which also defines a predicate.
171
        singleton_class.define_method("#{name}?") { !!public_send(name) }
3,560✔
172

173
        singleton_class.define_method("#{name}=") do |value|
3,554✔
174
          if Thread.current[RRF_PROPAGATING_KEY]
204✔
175
            # Propagate: descendants inherit this getter via the module in the singleton chain. It's
176
            # kept separate from any local getter (defined directly on the singleton class) so a
177
            # subsequent local assignment doesn't clobber the value propagated to descendants.
178
            rrf_propagated_module.define_method(name) { value }
5,176✔
179
          else
180
            # Local: `value` for this class only; descendants fall back through `super` to the
181
            # nearest propagated ancestor value, or the default.
182
            klass = self
168✔
183
            singleton_class.define_method(name) do
168✔
184
              if equal?(klass)
7,310✔
185
                value
7,194✔
186
              elsif defined?(super)
116✔
187
                super()
116✔
188
              else
NEW
189
                default
×
190
              end
191
            end
192
          end
193
        end
194
      end
195
    end
196

197
    # The per-class module holding this class's propagated attribute getters (and the default
198
    # baseline). It's included into the singleton class so descendants inherit propagated values
199
    # through the singleton-class chain, while local assignments—defined directly on the singleton
200
    # class—take precedence for the class itself and can `super()` back into this module. Created
201
    # lazily and memoized per class (instance variables aren't inherited, so each class gets its
202
    # own).
203
    def rrf_propagated_module
2✔
204
      @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
3,676✔
205
    end
206

207
    # Run a block in which configuration setters (`self.x = value`) propagate to descendant
208
    # controllers instead of applying locally. Use this on a shared base controller for settings you
209
    # want every subclass to inherit:
210
    #
211
    #   propagate do
212
    #     self.paginator_class = RESTFramework::PageNumberPaginator
213
    #     self.page_size = 30
214
    #   end
215
    def propagate
2✔
216
      previous = Thread.current[RRF_PROPAGATING_KEY]
18✔
217
      Thread.current[RRF_PROPAGATING_KEY] = true
18✔
218
      yield
18✔
219
    ensure
220
      Thread.current[RRF_PROPAGATING_KEY] = previous
18✔
221
    end
222

223
    # By default, this is the name of the controller class, titleized and with any custom inflection
224
    # acronyms applied.
225
    def get_title
2✔
226
      self.title || RESTFramework::Utils.inflect(
94✔
227
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
228
        self.inflect_acronyms,
229
      )
230
    end
231

232
    # Get a label from a field/column name, titleized and inflected.
233
    def label_for(s)
2✔
234
      default_title = RESTFramework::Utils.inflect(
974✔
235
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
236
      )
237
      self.model&.human_attribute_name(s, default: default_title) || default_title
974✔
238
    end
239

240
    # Get the available fields. Fallback to this controller's model columns, or an empty array. This
241
    # should always return an array of strings.
242
    def get_fields(input_fields: nil)
2✔
243
      input_fields ||= self.fields
778✔
244

245
      # If fields is a hash, then parse it.
246
      if input_fields.is_a?(Hash)
778✔
247
        return RESTFramework::Utils.parse_fields_hash(
98✔
248
          input_fields,
249
          self.model,
250
          exclude_associations: self.exclude_associations,
251
          action_text: self.enable_action_text,
252
          active_storage: self.enable_active_storage,
253
        )
254
      elsif !input_fields
680✔
255
        # Otherwise, if fields is nil, then fallback to columns.
256
        return self.model ? RESTFramework::Utils.fields_for(
630✔
257
          self.model,
258
          exclude_associations: self.exclude_associations,
259
          action_text: self.enable_action_text,
260
          active_storage: self.enable_active_storage,
261
        ) : []
262
      elsif input_fields
50✔
263
        input_fields = input_fields.map(&:to_s)
50✔
264
      end
265

266
      input_fields
50✔
267
    end
268

269
    # Get a full field configuration, including defaults and inferred values.
270
    def field_configuration
2✔
271
      return @field_configuration if @field_configuration
3,882✔
272

273
      field_config = self.field_config&.with_indifferent_access || {}
34✔
274
      columns = self.model.columns_hash
34✔
275
      column_defaults = self.model.column_defaults
34✔
276
      reflections = self.model.reflections
34✔
277
      attributes = self.model._default_attributes
34✔
278
      readonly_attributes = self.model.readonly_attributes
34✔
279
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
34✔
280
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
34✔
281
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
34✔
282
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
34✔
283
        .collect(&:name)
284
        .select { |n| n.to_s.start_with?("rich_text_") }
42✔
285
      attachment_reflections = self.model.attachment_reflections
34✔
286

287
      @field_configuration = self.get_fields.map { |f|
34✔
288
        cfg = field_config[f]&.dup || {}
310✔
289
        cfg[:label] ||= self.label_for(f)
310✔
290

291
        # Annotate primary key.
292
        if self.model.primary_key == f
310✔
293
          cfg[:primary_key] = true
34✔
294

295
          unless cfg.key?(:read_only)
34✔
296
            cfg[:read_only] = true
34✔
297
          end
298
        end
299

300
        # Annotate field mutability and display properties.
301
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
310✔
302
        cfg[:write_only] = true if f.in?(write_only_fields)
310✔
303
        cfg[:hidden] = true if f.in?(hidden_fields)
310✔
304

305
        # Raise warnings on some bad combinations of properties.
306
        if cfg[:write_only]
310✔
307
          if cfg[:read_only]
2✔
308
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
309
          end
310

311
          if cfg[:hidden]
2✔
312
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
313
          end
314

315
          if cfg[:hidden_from_index]
2✔
316
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
317
          end
318
        end
319

320
        # Annotate column data.
321
        if column = columns[f]
310✔
322
          cfg[:kind] = "column"
204✔
323
          cfg[:type] ||= column.type
204✔
324
          cfg[:required] = true unless column.null
204✔
325
        end
326

327
        # Add default values from the model's schema.
328
        if cfg[:default].nil? && (column_default = column_defaults[f])
310✔
329
          cfg[:default] = column_default
76✔
330
        end
331

332
        # Add metadata from the model's attributes hash.
333
        if attributes.key?(f) && attribute = attributes[f]
310✔
334
          if cfg[:default].nil? && default = attribute.value_before_type_cast
204✔
335
            cfg[:default] = default
×
336
          end
337
          cfg[:kind] ||= "attribute"
204✔
338

339
          # Get any type information from the attribute.
340
          if type = attribute.type
204✔
341
            cfg[:type] ||= type.type if type.type
204✔
342

343
            # Get enum variants.
344
            if type.is_a?(ActiveRecord::Enum::EnumType)
204✔
345
              cfg[:enum_variants] = type.send(:mapping)
10✔
346

347
              # TranslateEnum Integration:
348
              translate_method = "translated_#{f.pluralize}"
10✔
349
              if self.model.respond_to?(translate_method)
10✔
350
                cfg[:enum_translations] = self.model.send(translate_method)
10✔
351
              end
352
            end
353
          end
354
        end
355

356
        # Get association metadata.
357
        if ref = reflections[f]
310✔
358
          cfg[:kind] = "association"
86✔
359

360
          # Determine sub-fields for associations.
361
          if ref.polymorphic?
86✔
362
            ref_columns = {}
×
363
          else
364
            ref_columns = ref.klass.columns_hash
86✔
365
          end
366
          cfg[:sub_fields] ||= RESTFramework::Utils.sub_fields_for(ref)
86✔
367
          cfg[:sub_fields] = cfg[:sub_fields].map(&:to_s)
86✔
368

369
          # Very basic metadata about sub-fields.
370
          cfg[:sub_fields_metadata] = cfg[:sub_fields].map { |sf|
86✔
371
            v = {}
164✔
372

373
            if ref_columns[sf]
164✔
374
              v[:kind] = "column"
164✔
375
            else
376
              v[:kind] = "method"
×
377
            end
378

379
            next [ sf, v ]
164✔
380
          }.to_h.compact.presence
381

382
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
383
          # interface.
384
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
86✔
385
            cfg[:id_field] = id_field
72✔
386
          end
387

388
          # Determine if we render nested attributes options.
389
          if self.permit_nested_attributes_assignment && (
86✔
390
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
86✔
391
          )
392
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
30✔
393
          end
394

395
          begin
396
            cfg[:association_pk] = ref.active_record_primary_key
86✔
397
          rescue ActiveRecord::UnknownPrimaryKey
398
          end
399

400
          cfg[:reflection] = ref
86✔
401
        end
402

403
        # Determine if this is an ActionText "rich text".
404
        if :"rich_text_#{f}".in?(rich_text_association_names)
310✔
405
          cfg[:kind] = "rich_text"
4✔
406
        end
407

408
        # Determine if this is an ActiveStorage attachment.
409
        if ref = attachment_reflections[f]
310✔
410
          cfg[:kind] = "attachment"
8✔
411
          cfg[:attachment_type] = ref.macro
8✔
412
        end
413

414
        # Determine if this is just a method.
415
        if !cfg[:kind] && self.model.method_defined?(f)
310✔
416
          cfg[:kind] = "method"
6✔
417
          cfg[:read_only] = true if cfg[:read_only].nil?
6✔
418
        end
419

420
        # Collect validator options into a hash on their type, while also updating `required` based
421
        # on any presence validators.
422
        self.model.validators_on(f).each do |validator|
310✔
423
          kind = validator.kind
90✔
424
          options = validator.options
90✔
425

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

430
          # Update `required` if we find a presence validator.
431
          cfg[:required] = true if kind == :presence
90✔
432

433
          # Resolve procs (and lambdas), and symbols for certain arguments.
434
          if options[:in].is_a?(Proc)
90✔
435
            options = options.merge(in: options[:in].call)
×
436
          elsif options[:in].is_a?(Symbol)
90✔
437
            options = options.merge(in: self.model.send(options[:in]))
10✔
438
          end
439

440
          cfg[:validators] ||= {}
90✔
441
          cfg[:validators][kind] ||= []
90✔
442
          cfg[:validators][kind] << options
90✔
443
        end
444

445
        next [ f, cfg ]
310✔
446
      }.to_h.compact.with_indifferent_access
447
    end
448
  end
449

450
  def self.included(base)
2✔
451
    return unless base.is_a?(Class)
60✔
452

453
    base.extend(ClassMethods)
60✔
454

455
    # By default, the layout should be set to `rest_framework`.
456
    base.layout("rest_framework")
60✔
457

458
    # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
459
    # (always inherited).
460
    RRF_BASE_CONFIG.each do |a, default|
60✔
461
      next if base.respond_to?(a)
3,780✔
462

463
      base.rrf_class_attribute(a, default: default)
3,528✔
464
    end
465

466
    # Skip CSRF since this is an API.
467
    begin
468
      base.skip_before_action(:verify_authenticity_token)
60✔
469
    rescue ArgumentError
470
      # The callback may not exist if forgery protection isn't enabled; this is expected.
471
      nil
4✔
472
    end
473

474
    # Handle exceptions.
475
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
60✔
476
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
60✔
477
  end
478

479
  def get_serializer_class
2✔
480
    self.class.serializer_class || RESTFramework::NativeSerializer
222✔
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(
204✔
486
      data, controller: self, **kwargs
487
    ).serialize
488
  end
489

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

500
    render_api(
50✔
501
      {
502
        message: e.message,
503
        errors: e.try(:record).try(:errors),
504
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
50✔
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)
74✔
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) || {}
356✔
518
    json_kwargs = kwargs.delete(:json_kwargs) || {}
356✔
519
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
356✔
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?
356✔
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)
350✔
531
      payload = self.serialize(payload)
120✔
532
    end
533

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

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

542
    begin
543
      respond_to do |format|
350✔
544
        if payload == ""
350✔
545
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
14✔
546
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
14✔
547
        else
548
          format.json {
549
            render(json: payload, **kwargs.merge(json_kwargs))
264✔
550
          } if self.class.serialize_to_json
338✔
551
          format.xml {
552
            render(xml: payload, **kwargs.merge(xml_kwargs))
20✔
553
          } if self.class.serialize_to_xml
338✔
554
          # TODO: possibly support more formats here if supported?
555
        end
556
        format.html {
350✔
557
          @payload = payload
58✔
558
          if payload == ""
58✔
559
            @json_payload = "" if self.class.serialize_to_json
8✔
560
            @xml_payload = "" if self.class.serialize_to_xml
8✔
561
          else
562
            @json_payload = payload.to_json if self.class.serialize_to_json
50✔
563
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
50✔
564
          end
565
          @title ||= self.class.get_title
58✔
566
          @description ||= self.class.description
58✔
567
          self.route_groups
58✔
568
          begin
569
            render(**kwargs.merge(html_kwargs))
58✔
570
          rescue ActionView::MissingTemplate
571
            # A view is not required, so just use `html: ""`.
572
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
58✔
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
  def options
2✔
588
    render_api(self.openapi_document)
16✔
589
  end
590

591
  def get_fields
2✔
592
    self.class.get_fields(input_fields: self.class.fields)
744✔
593
  end
594

595
  # Get a hash of strong parameters for the current action.
596
  def get_allowed_parameters
2✔
597
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
80✔
598

599
    @_get_allowed_parameters = self.class.allowed_parameters
80✔
600
    return @_get_allowed_parameters if @_get_allowed_parameters
80✔
601

602
    # Assemble strong parameters.
603
    variations = []
80✔
604
    hash_variations = {}
80✔
605
    reflections = self.class.model.reflections
80✔
606
    @_get_allowed_parameters = self.get_fields.map { |f|
80✔
607
      f = f.to_s
664✔
608
      config = self.class.field_configuration[f]
664✔
609

610
      # ActionText Integration:
611
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
664✔
612
        next f
34✔
613
      end
614

615
      # ActiveStorage Integration: `has_one_attached`
616
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
630✔
617
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
618
        next f
34✔
619
      end
620

621
      # ActiveStorage Integration: `has_many_attached`
622
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
596✔
623
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
624
        next nil
34✔
625
      end
626

627
      if config[:reflection]
562✔
628
        # Add `_id`/`_ids` variations for associations.
629
        if id_field = config[:id_field]
190✔
630
          if id_field.ends_with?("_ids")
180✔
631
            hash_variations[id_field] = []
128✔
632
          else
633
            variations << id_field
52✔
634
          end
635
        end
636

637
        # Add `_attributes` variations for associations.
638
        # TODO: Consider adjusting this based on `nested_attributes_options`.
639
        if self.class.permit_nested_attributes_assignment
190✔
640
          hash_variations["#{f}_attributes"] = (
190✔
641
            config[:sub_fields] + [ "_destroy" ]
190✔
642
          )
643
        end
644

645
        # Associations are not allowed to be submitted in their bare form (if they are submitted
646
        # that way, they will be translated to either id/ids or nested attributes assignment).
647
        next nil
190✔
648
      end
649

650
      next f
372✔
651
    }.compact
652
    @_get_allowed_parameters += variations
80✔
653
    @_get_allowed_parameters << hash_variations
80✔
654

655
    @_get_allowed_parameters
80✔
656
  end
657

658
  # Use strong parameters to filter the request body.
659
  def get_body_params(bulk_action: nil)
2✔
660
    data = self.request.request_parameters
80✔
661
    pk = self.class.model&.primary_key
80✔
662
    allowed_params = self.get_allowed_parameters
80✔
663

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

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

701
              # Remember scalars because Rails strong params will remove it.
702
              if v.is_a?(String)
×
703
                has_many_attached_scalar_data[k] ||= []
×
704
                has_many_attached_scalar_data[k] << v
×
705
              end
706
            elsif v.is_a?(Hash)
×
707
              if v[:io].is_a?(String)
×
708
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
709
              end
710
            end
711

712
            next v
×
713
          }
714
        elsif data[k].is_a?(Hash)
20✔
715
          if data[k][:io].is_a?(String)
×
716
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
717
          end
718
        elsif data[k].is_a?(String)
20✔
719
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
720
        end
721
      end
722
    end
723

724
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
725
    # parameters to the `_json` key of the request body.
726
    body_params = if allowed_params == true
80✔
727
      ActionController::Parameters.new(data).permit!
×
728
    elsif bulk_action
80✔
729
      if bulk_action == :create
50✔
730
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
20✔
731
      elsif bulk_action == :update
30✔
732
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
18✔
733
      elsif bulk_action == :destroy
12✔
734
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
735
      else
736
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
737
      end
738
    else
739
      ActionController::Parameters.new(data).permit(*allowed_params)
30✔
740
    end
741

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

751
    # Filter read-only fields. For bulk actions the permitted structure is `{ _json: [...] }`, so we
752
    # strip read-only keys from each element rather than the top-level hash (whose only key is
753
    # `_json`). Bulk update keeps the primary key, which it needs to locate each record.
754
    if bulk_action
80✔
755
      keep = bulk_action == :update ? [ pk.to_s ] : []
50✔
756
      body_params[:_json]&.each do |element|
50✔
757
        next unless element.is_a?(ActionController::Parameters)
86✔
758

759
        self._rrf_strip_read_only_fields(element, keep: keep)
66✔
760
      end
761
    else
762
      self._rrf_strip_read_only_fields(body_params)
30✔
763
    end
764

765
    body_params
80✔
766
  end
767
  alias_method :get_create_params, :get_body_params
2✔
768
  alias_method :get_update_params, :get_body_params
2✔
769
  alias_method :get_destroy_params, :get_body_params
2✔
770

771
  # Remove read-only fields from a permitted params hash in place. `keep` lists field names to
772
  # preserve even when read-only (e.g. the primary key on bulk update, used to locate records).
773
  def _rrf_strip_read_only_fields(params, keep: [])
2✔
774
    params.delete_if do |f, _|
96✔
775
      next false if f.in?(keep)
144✔
776

777
      cfg = self.class.field_configuration[f]
118✔
778
      cfg && cfg[:read_only]
118✔
779
    end
780
  end
781

782
  # Get the set of records this controller has access to.
783
  def get_recordset
2✔
784
    return self.class.recordset if self.class.recordset
272✔
785

786
    # If there is a model, return that model's default scope (all records by default).
787
    if self.class.model
272✔
788
      return self.class.model.all
272✔
789
    end
790

791
    nil
792
  end
793

794
  # Filter the recordset and return records this request has access to.
795
  def get_records
2✔
796
    data = self.get_recordset
208✔
797

798
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
208✔
799
      filter.new(controller: self).filter_data(d)
782✔
800
    } || data
801
  end
802

803
  # Get a single record by primary key or another column, if allowed.
804
  def get_record
2✔
805
    return @record if @record
70✔
806

807
    find_by_key = self.class.model.primary_key
70✔
808
    is_pk = true
70✔
809

810
    # Find by another column if it's permitted.
811
    if find_by_param = self.class.find_by_query_param.presence
70✔
812
      if find_by = request.query_parameters[find_by_param].presence
70✔
813
        find_by_fields = (
814
          self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
6✔
815
        )
816

817
        # A `find_by` was explicitly requested, so it must be a permitted field.
818
        raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
6✔
819

820
        is_pk = false unless find_by_key == find_by
4✔
821
        find_by_key = find_by
4✔
822
      end
823
    end
824

825
    # Get the recordset, filtering if configured.
826
    collection = if self.class.filter_recordset_before_find
68✔
827
      self.get_records
64✔
828
    else
829
      self.get_recordset
4✔
830
    end
831

832
    # Return the record. Route key is always `:id` by Rails' convention.
833
    if is_pk
68✔
834
      @record = collection.find(request.path_parameters[:id])
64✔
835
    else
836
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
837
    end
838
  end
839

840
  # Determine what collection to call `create` on.
841
  def create_from
2✔
842
    if self.class.create_from_recordset
42✔
843
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
844
      # in case model callbacks need to call `count` on this collection, which typically raises a
845
      # SQL `SyntaxError`.
846
      self.get_recordset.except(:select)
40✔
847
    else
848
      # Otherwise, perform a "bare" insert_all.
849
      self.class.model
2✔
850
    end
851
  end
852
end
853

854
require_relative "controller/actions"
2✔
855
require_relative "controller/bulk"
2✔
856
require_relative "controller/crud"
2✔
857
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