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

gregschmit / rails-rest-framework / 30584222578

30 Jul 2026 09:38PM UTC coverage: 94.964% (+3.0%) from 91.975%
30584222578

Pull #40

github

gregschmit
Fix test matrix Rails versions.
Pull Request #40: v2

321 of 327 new or added lines in 12 files covered. (98.17%)

1 existing line in 1 file now uncovered.

1301 of 1370 relevant lines covered (94.96%)

475.87 hits per line

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

92.33
/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
    # Handling request body parameters.
44
    allowed_parameters: nil,
45

46
    # Options for the default native serializer.
47
    native_serializer_config: nil,
48
    native_serializer_singular_config: nil,
49
    native_serializer_plural_config: nil,
50
    native_serializer_only_query_param: "only".freeze,
51
    native_serializer_except_query_param: "except".freeze,
52
    native_serializer_include_query_param: "include".freeze,
53
    native_serializer_exclude_query_param: "exclude".freeze,
54

55
    # Options for including associations and collection counts.
56
    exclude_associations: false,
57
    include_association_count: false,
58

59
    # The number of records serialized per collection association, so responses are bounded out of
60
    # the box (`nil` = unlimited). With `enable_association_queries`, a client can raise it for a
61
    # given association via `?<prefix>.<name>.limit=N` or `limit=all` (`none`/`0` are aliases), both
62
    # capped at `association_limit_max` (the "all" forms yield the cap). Set the max to `nil` to let
63
    # a client request unlimited records.
64
    association_limit: 10,
65
    association_limit_max: 100,
66

67
    # Let clients request extra fields for a serialized association via
68
    # `?<prefix>.<association>.fields=a,b,c`. The allowlist keeps an association from ever exposing
69
    # more than its own endpoint would: an explicit per-association `requestable_fields` in
70
    # `field_config`, else the fields the associated model's sibling controller serializes.
71
    # Off/secure by default.
72
    enable_association_queries: false,
73
    association_query_prefix: "associations".freeze,
74

75
    # Options for filtering, ordering, and searching.
76
    filter_backends: [
77
      RESTFramework::QueryFilter,
78
      RESTFramework::OrderingFilter,
79
      RESTFramework::SearchFilter,
80
    ].freeze,
81
    filter_recordset_before_find: true,
82
    filter_fields: nil,
83
    ordering_fields: nil,
84
    ordering_query_param: "ordering".freeze,
85
    ordering_no_reorder: false,
86
    search_fields: nil,
87
    search_query_param: "search".freeze,
88
    search_ilike: false,
89
    ransack_options: nil,
90
    ransack_query_param: "q".freeze,
91
    ransack_distinct: true,
92
    ransack_distinct_query_param: "distinct".freeze,
93

94
    # Options for association assignment.
95
    permit_id_assignment: true,
96
    permit_nested_attributes_assignment: true,
97

98
    # Option for `recordset.create` vs `Model.create` behavior.
99
    create_from_recordset: true,
100

101
    # Options related to serialization.
102
    rescue_unknown_format_with: :json,
103
    serializer_class: nil,
104
    serialize_to_json: true,
105
    serialize_to_xml: true,
106

107
    # Options related to pagination. Pagination is on by default (page-number based) with a capped
108
    # page size, so responses are bounded out of the box; set `paginator_class = nil` to disable.
109
    paginator_class: RESTFramework::PageNumberPaginator,
110
    page_size: 20,
111
    page_query_param: "page".freeze,
112
    page_size_query_param: "page_size".freeze,
113
    max_page_size: 40,
114
    # Whether the page-number paginator computes the total record count to report `count` and
115
    # `total_pages`. Set to `false` on large tables to skip that query.
116
    page_total_count: true,
117

118
    # Option to disable serializer adapters by default, mainly introduced because Active Model
119
    # Serializers will do things like serialize `[]` into `{"":[]}`.
120
    disable_adapters_by_default: true,
121

122
    # Custom integrations (reduces serializer performance due to method calls).
123
    enable_action_text: false,
124
    enable_active_storage: false,
125
  }
126

127
  # Exceptions to be rescued and handled by returning a reasonable error response.
128
  RRF_RESCUED_EXCEPTIONS = [
129
    RESTFramework::InvalidBulkParametersError,
2✔
130
    RESTFramework::BulkRecordErrorsError,
131
  ].freeze
132
  RRF_RESCUED_RAILS_EXCEPTIONS = [
133
    ActionController::ParameterMissing,
2✔
134
    ActionController::UnpermittedParameters,
135
    ActionDispatch::Http::Parameters::ParseError,
136
    ActiveRecord::AssociationTypeMismatch,
137
    ActiveRecord::NotNullViolation,
138
    ActiveRecord::RecordNotFound,
139
    ActiveRecord::RecordInvalid,
140
    ActiveRecord::RecordNotSaved,
141
    ActiveRecord::RecordNotDestroyed,
142
    ActiveRecord::RecordNotUnique,
143
    ActiveRecord::StatementInvalid,
144
    ActiveModel::UnknownAttributeError,
145
  ].freeze
146

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

152
    _, content_type, payload = value.match(RRF_BASE64_REGEX).to_a
×
153
    {
154
      io: StringIO.new(Base64.decode64(payload)),
×
155
      content_type: content_type,
156
      filename: "file_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
157
    }
158
  }
159
  RRF_ACTIVESTORAGE_KEYS = [ :io, :content_type, :filename, :identify, :key ]
2✔
160

161
  module ClassMethods
2✔
162
    IGNORE_VALIDATORS_WITH_KEYS = [ :if, :unless ].freeze
2✔
163

164
    # Thread-local key toggled by `propagate` while its block runs.
165
    RRF_PROPAGATING_KEY = :rrf_propagating
2✔
166

167
    # Define one or more class-level configuration attributes. Assignments are **local by default**:
168
    #
169
    #   self.x = value               # applies to this controller ONLY; descendants don't inherit it
170
    #   propagate { self.x = value } # applies to this controller AND all descendants
171
    #
172
    # This gives a single, uniform rule (an assignment is local unless wrapped in `propagate`), so
173
    # there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in
174
    # closures on redefined singleton methods (the same mechanism as `class_attribute`), never in
175
    # instance variables, so there is exactly one interface for configuration: the setter.
176
    #
177
    # Only singleton (class-level) methods are defined, so config never leaks to controller
178
    # instances (which would risk colliding with action methods).
179
    def rrf_class_attribute(*names, default: nil)
2✔
180
      names.each do |name|
3,664✔
181
        # Propagating baseline: every controller sees the default until it's overridden. This lives
182
        # in the propagated module (see `rrf_propagated_module`) rather than directly on the
183
        # singleton class, so a local assignment can coexist with it via `super`.
184
        rrf_propagated_module.define_method(name) { default }
16,462✔
185

186
        # Parity with `class_attribute`, which also defines a predicate.
187
        singleton_class.define_method("#{name}?") { !!public_send(name) }
3,672✔
188

189
        singleton_class.define_method("#{name}=") do |value|
3,666✔
190
          if Thread.current[RRF_PROPAGATING_KEY]
288✔
191
            # Propagate: descendants inherit this getter via the module in the singleton chain. It's
192
            # kept separate from any local getter (defined directly on the singleton class) so a
193
            # subsequent local assignment doesn't clobber the value propagated to descendants.
194
            rrf_propagated_module.define_method(name) { value }
6,576✔
195
          else
196
            # Local: `value` for this class only; descendants fall back through `super` to the
197
            # nearest propagated ancestor value, or the default.
198
            klass = self
250✔
199
            singleton_class.define_method(name) do
250✔
200
              if equal?(klass)
8,768✔
201
                value
8,652✔
202
              elsif defined?(super)
116✔
203
                super()
116✔
204
              else
NEW
205
                default
×
206
              end
207
            end
208
          end
209
        end
210
      end
211
    end
212

213
    # The per-class module holding this class's propagated attribute getters (and the default
214
    # baseline). It's included into the singleton class so descendants inherit propagated values
215
    # through the singleton-class chain, while local assignments—defined directly on the singleton
216
    # class—take precedence for the class itself and can `super()` back into this module. Created
217
    # lazily and memoized per class (instance variables aren't inherited, so each class gets its
218
    # own).
219
    def rrf_propagated_module
2✔
220
      @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
3,790✔
221
    end
222

223
    # Run a block in which configuration setters (`self.x = value`) propagate to descendant
224
    # controllers instead of applying locally. Use this on a shared base controller for settings you
225
    # want every subclass to inherit:
226
    #
227
    #   propagate do
228
    #     self.paginator_class = RESTFramework::PageNumberPaginator
229
    #     self.page_size = 30
230
    #   end
231
    def propagate
2✔
232
      previous = Thread.current[RRF_PROPAGATING_KEY]
18✔
233
      Thread.current[RRF_PROPAGATING_KEY] = true
18✔
234
      yield
18✔
235
    ensure
236
      Thread.current[RRF_PROPAGATING_KEY] = previous
18✔
237
    end
238

239
    # By default, this is the name of the controller class, titleized and with any custom inflection
240
    # acronyms applied.
241
    def get_title
2✔
242
      self.title || RESTFramework::Utils.inflect(
94✔
243
        self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
244
        self.inflect_acronyms,
245
      )
246
    end
247

248
    # Get a label from a field/column name, titleized and inflected.
249
    def label_for(s)
2✔
250
      default_title = RESTFramework::Utils.inflect(
1,120✔
251
        s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
252
      )
253
      self.model&.human_attribute_name(s, default: default_title) || default_title
1,120✔
254
    end
255

256
    # Get the available fields. Fallback to this controller's model columns, or an empty array. This
257
    # should always return an array of strings.
258
    def get_fields(input_fields: nil)
2✔
259
      input_fields ||= self.fields
988✔
260

261
      # If fields is a hash, then parse it.
262
      if input_fields.is_a?(Hash)
988✔
263
        return RESTFramework::Utils.parse_fields_hash(
98✔
264
          input_fields,
265
          self.model,
266
          exclude_associations: self.exclude_associations,
267
          action_text: self.enable_action_text,
268
          active_storage: self.enable_active_storage,
269
        )
270
      elsif !input_fields
890✔
271
        # Otherwise, if fields is nil, then fallback to columns.
272
        return self.model ? RESTFramework::Utils.fields_for(
686✔
273
          self.model,
274
          exclude_associations: self.exclude_associations,
275
          action_text: self.enable_action_text,
276
          active_storage: self.enable_active_storage,
277
        ) : []
278
      elsif input_fields
204✔
279
        input_fields = input_fields.map(&:to_s)
204✔
280
      end
281

282
      input_fields
204✔
283
    end
284

285
    # Get a full field configuration, including defaults and inferred values.
286
    def field_configuration
2✔
287
      return @field_configuration if @field_configuration
4,246✔
288

289
      field_config = self.field_config&.with_indifferent_access || {}
54✔
290
      columns = self.model.columns_hash
54✔
291
      column_defaults = self.model.column_defaults
54✔
292
      reflections = self.model.reflections
54✔
293
      attributes = self.model._default_attributes
54✔
294
      readonly_attributes = self.model.readonly_attributes
54✔
295
      read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
54✔
296
      write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
54✔
297
      hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
54✔
298
      rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
54✔
299
        .collect(&:name)
300
        .select { |n| n.to_s.start_with?("rich_text_") }
66✔
301
      attachment_reflections = self.model.attachment_reflections
54✔
302

303
      @field_configuration = self.get_fields.map { |f|
54✔
304
        cfg = field_config[f]&.dup || {}
442✔
305
        cfg[:label] ||= self.label_for(f)
442✔
306

307
        # Annotate primary key.
308
        if self.model.primary_key == f
442✔
309
          cfg[:primary_key] = true
54✔
310

311
          unless cfg.key?(:read_only)
54✔
312
            cfg[:read_only] = true
54✔
313
          end
314
        end
315

316
        # Annotate field mutability and display properties.
317
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
442✔
318
        cfg[:write_only] = true if f.in?(write_only_fields)
442✔
319
        cfg[:hidden] = true if f.in?(hidden_fields)
442✔
320

321
        # Raise warnings on some bad combinations of properties.
322
        if cfg[:write_only]
442✔
323
          if cfg[:read_only]
4✔
324
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
325
          end
326

327
          if cfg[:hidden]
4✔
328
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
329
          end
330

331
          if cfg[:hidden_from_index]
4✔
332
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
×
333
          end
334
        end
335

336
        # Annotate column data.
337
        if column = columns[f]
442✔
338
          cfg[:kind] = "column"
296✔
339
          cfg[:type] ||= column.type
296✔
340
          cfg[:required] = true unless column.null
296✔
341
        end
342

343
        # Add default values from the model's schema.
344
        if cfg[:default].nil? && (column_default = column_defaults[f])
442✔
345
          cfg[:default] = column_default
112✔
346
        end
347

348
        # Add metadata from the model's attributes hash.
349
        if attributes.key?(f) && attribute = attributes[f]
442✔
350
          if cfg[:default].nil? && default = attribute.value_before_type_cast
296✔
351
            cfg[:default] = default
×
352
          end
353
          cfg[:kind] ||= "attribute"
296✔
354

355
          # Get any type information from the attribute.
356
          if type = attribute.type
296✔
357
            cfg[:type] ||= type.type if type.type
296✔
358

359
            # Get enum variants.
360
            if type.is_a?(ActiveRecord::Enum::EnumType)
296✔
361
              cfg[:enum_variants] = type.send(:mapping)
14✔
362

363
              # TranslateEnum Integration:
364
              translate_method = "translated_#{f.pluralize}"
14✔
365
              if self.model.respond_to?(translate_method)
14✔
366
                cfg[:enum_translations] = self.model.send(translate_method)
14✔
367
              end
368
            end
369
          end
370
        end
371

372
        # Get association metadata.
373
        if ref = reflections[f]
442✔
374
          cfg[:kind] = "association"
126✔
375

376
          # Determine the association's fields.
377
          if ref.polymorphic?
126✔
378
            ref_columns = {}
×
379
          else
380
            ref_columns = ref.klass.columns_hash
126✔
381
          end
382
          cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
126✔
383
          cfg[:fields] = cfg[:fields].map(&:to_s)
126✔
384

385
          # Strings, to match `:fields` when intersecting requested fields against the allowlist.
386
          if cfg[:requestable_fields]
126✔
387
            cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
2✔
388
          end
389

390
          # Very basic metadata about the association's fields.
391
          cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
126✔
392
            v = {}
240✔
393

394
            if ref_columns[sf]
240✔
395
              v[:kind] = "column"
240✔
396
            else
397
              v[:kind] = "method"
×
398
            end
399

400
            next [ sf, v ]
240✔
401
          }.to_h.compact.presence
402

403
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
404
          # interface.
405
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
126✔
406
            cfg[:id_field] = id_field
108✔
407
          end
408

409
          # Determine if we render nested attributes options.
410
          if self.permit_nested_attributes_assignment && (
126✔
411
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
126✔
412
          )
413
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
48✔
414
          end
415

416
          begin
417
            cfg[:association_pk] = ref.active_record_primary_key
126✔
418
          rescue ActiveRecord::UnknownPrimaryKey
419
          end
420

421
          cfg[:reflection] = ref
126✔
422
        end
423

424
        # Determine if this is an ActionText "rich text".
425
        if :"rich_text_#{f}".in?(rich_text_association_names)
442✔
426
          cfg[:kind] = "rich_text"
4✔
427
        end
428

429
        # Determine if this is an ActiveStorage attachment.
430
        if ref = attachment_reflections[f]
442✔
431
          cfg[:kind] = "attachment"
8✔
432
          cfg[:attachment_type] = ref.macro
8✔
433
        end
434

435
        # Determine if this is just a method.
436
        if !cfg[:kind] && self.model.method_defined?(f)
442✔
437
          cfg[:kind] = "method"
6✔
438
          cfg[:read_only] = true if cfg[:read_only].nil?
6✔
439
        end
440

441
        # Collect validator options into a hash on their type, while also updating `required` based
442
        # on any presence validators.
443
        self.model.validators_on(f).each do |validator|
442✔
444
          kind = validator.kind
186✔
445
          options = validator.options
186✔
446

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

451
          # Update `required` if we find a presence validator.
452
          cfg[:required] = true if kind == :presence
186✔
453

454
          cfg[:validators] ||= {}
186✔
455
          cfg[:validators][kind] ||= []
186✔
456
          cfg[:validators][kind] << options
186✔
457
        end
458

459
        next [ f, cfg ]
442✔
460
      }.to_h.compact.with_indifferent_access
461

462
      # Compile each association's requestable-fields allowlist once (see
463
      # `enable_association_queries`). This runs as a second pass, after `@field_configuration` is
464
      # memoized, because resolving a sibling's fields reads its `field_configuration` — and a
465
      # self-referential or mutual association would otherwise recurse into this build.
466
      if self.enable_association_queries
54✔
467
        @field_configuration.each do |_f, cfg|
22✔
468
          next unless cfg[:kind] == "association"
128✔
469

470
          cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
40✔
471
        end
472
      end
473

474
      @field_configuration
54✔
475
    end
476

477
    # The fields a consumer may request for an association beyond its defaults, derived from the
478
    # associated model's sibling controller: what that controller serializes, so the association can
479
    # never expose more than its own endpoint would. Empty unless the sibling is discoverable and
480
    # introspectable — a custom serializer makes its `get_fields` meaningless. Hidden fields are
481
    # included (retrievable via `?only=` there); write-only fields and nested associations aren't.
482
    def association_requestable_fields(ref)
2✔
483
      return [] if ref.polymorphic?
38✔
484

485
      sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
38✔
486
      return [] unless sibling
38✔
487
      return [] if sibling.serializer_class ||
38✔
488
        sibling.native_serializer_config ||
489
        sibling.native_serializer_singular_config ||
490
        sibling.native_serializer_plural_config
491

492
      cfg = sibling.field_configuration
36✔
493
      sibling.get_fields.reject { |sf|
36✔
494
        c = cfg[sf]
388✔
495
        c.nil? || c[:write_only] || c[:kind] == "association"
388✔
496
      }
497
    end
498
  end
499

500
  def self.included(base)
2✔
501
    return unless base.is_a?(Class)
60✔
502

503
    base.extend(ClassMethods)
60✔
504

505
    # By default, the layout should be set to `rest_framework`.
506
    base.layout("rest_framework")
60✔
507

508
    # Materialize config with `rrf_class_attribute` (local by default) rather than `class_attribute`
509
    # (always inherited).
510
    RRF_BASE_CONFIG.each do |a, default|
60✔
511
      next if base.respond_to?(a)
3,900✔
512

513
      base.rrf_class_attribute(a, default: default)
3,640✔
514
    end
515

516
    # Skip CSRF since this is an API.
517
    begin
518
      base.skip_before_action(:verify_authenticity_token)
60✔
519
    rescue ArgumentError
520
      # The callback may not exist if forgery protection isn't enabled; this is expected.
521
      nil
4✔
522
    end
523

524
    # Handle exceptions.
525
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
60✔
526
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
60✔
527
  end
528

529
  def get_serializer_class
2✔
530
    self.class.serializer_class || RESTFramework::NativeSerializer
270✔
531
  end
532

533
  # Serialize the given data using the `serializer_class`.
534
  def serialize(data, **kwargs)
2✔
535
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
252✔
536
      data, controller: self, **kwargs
537
    ).serialize
538
  end
539

540
  def rrf_error_handler(e)
2✔
541
    status = case e
50✔
542
    when ActiveRecord::RecordNotFound
543
      404
26✔
544
    when RESTFramework::BulkRecordErrorsError
545
      422
4✔
546
    else
547
      400
20✔
548
    end
549

550
    # `StatementInvalid` messages commonly embed SQL fragments and schema details, so don't leak
551
    # them to clients unless backtraces are explicitly enabled.
552
    message = if e.is_a?(ActiveRecord::StatementInvalid) && !RESTFramework.config.show_backtrace
50✔
553
      "Invalid query."
4✔
554
    else
555
      e.message
46✔
556
    end
557

558
    render_api(
50✔
559
      {
560
        message: message,
561
        errors: e.try(:record).try(:errors),
562
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
50✔
563
      }.compact,
564
      status: status,
565
    )
566
  end
567

568
  def route_groups
2✔
569
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
74✔
570
  end
571

572
  # Render a browsable API for `html` format, along with basic `json`/`xml` formats, and with
573
  # support or passing custom `kwargs` to the underlying `render` calls.
574
  def render_api(payload, **kwargs)
2✔
575
    html_kwargs = kwargs.delete(:html_kwargs) || {}
414✔
576
    json_kwargs = kwargs.delete(:json_kwargs) || {}
414✔
577
    xml_kwargs = kwargs.delete(:xml_kwargs) || {}
414✔
578

579
    # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
580
    # when passing something like `User.find_by(id: some_id)` to `render_api`). The caller should
581
    # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
582
    # framework to catch this error and return an appropriate error response.
583
    if payload.nil?
414✔
584
      raise RESTFramework::NilPassedToRenderAPIError
6✔
585
    end
586

587
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
588
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
408✔
589
      payload = self.serialize(payload)
158✔
590
    end
591

592
    # Do not use any adapters by default, if configured.
593
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
406✔
594
      kwargs[:adapter] = nil
406✔
595
    end
596

597
    # Flag to track if we had to rescue unknown format.
598
    already_rescued_unknown_format = false
406✔
599

600
    begin
601
      respond_to do |format|
408✔
602
        if payload == ""
408✔
603
          format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
14✔
604
          format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
14✔
605
        else
606
          format.json {
607
            render(json: payload, **kwargs.merge(json_kwargs))
322✔
608
          } if self.class.serialize_to_json
396✔
609
          format.xml {
610
            render(xml: payload, **kwargs.merge(xml_kwargs))
20✔
611
          } if self.class.serialize_to_xml
396✔
612
          # TODO: possibly support more formats here if supported?
613
        end
614
        format.html {
408✔
615
          @payload = payload
58✔
616
          if payload == ""
58✔
617
            @json_payload = "" if self.class.serialize_to_json
8✔
618
            @xml_payload = "" if self.class.serialize_to_xml
8✔
619
          else
620
            @json_payload = payload.to_json if self.class.serialize_to_json
50✔
621
            @xml_payload = payload.to_xml if self.class.serialize_to_xml
50✔
622
          end
623
          @title ||= self.class.get_title
58✔
624
          @description ||= self.class.description
58✔
625
          self.route_groups
58✔
626
          begin
627
            render(**kwargs.merge(html_kwargs))
58✔
628
          rescue ActionView::MissingTemplate
629
            # A view is not required, so just use `html: ""`.
630
            render(html: "", layout: true, **kwargs.merge(html_kwargs))
58✔
631
          end
632
        }
633
      end
634
    rescue ActionController::UnknownFormat
4✔
635
      if !already_rescued_unknown_format && rescue_format = self.class.rescue_unknown_format_with
4✔
636
        request.format = rescue_format
2✔
637
        already_rescued_unknown_format = true
2✔
638
        retry
2✔
639
      else
640
        raise
2✔
641
      end
642
    end
643
  end
644

645
  def options
2✔
646
    render_api(self.openapi_document)
16✔
647
  end
648

649
  def get_fields
2✔
650
    self.class.get_fields(input_fields: self.class.fields)
898✔
651
  end
652

653
  # Get a hash of strong parameters for the current action.
654
  def get_allowed_parameters
2✔
655
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
80✔
656

657
    @_get_allowed_parameters = self.class.allowed_parameters
80✔
658
    return @_get_allowed_parameters if @_get_allowed_parameters
80✔
659

660
    # Assemble strong parameters.
661
    variations = []
80✔
662
    hash_variations = {}
80✔
663
    reflections = self.class.model.reflections
80✔
664
    @_get_allowed_parameters = self.get_fields.map { |f|
80✔
665
      f = f.to_s
664✔
666
      config = self.class.field_configuration[f]
664✔
667

668
      # ActionText Integration:
669
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
664✔
670
        next f
34✔
671
      end
672

673
      # ActiveStorage Integration: `has_one_attached`
674
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
630✔
675
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
676
        next f
34✔
677
      end
678

679
      # ActiveStorage Integration: `has_many_attached`
680
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
596✔
681
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
682
        next nil
34✔
683
      end
684

685
      if config[:reflection]
562✔
686
        # Add `_id`/`_ids` variations for associations.
687
        if id_field = config[:id_field]
190✔
688
          if id_field.ends_with?("_ids")
180✔
689
            hash_variations[id_field] = []
128✔
690
          else
691
            variations << id_field
52✔
692
          end
693
        end
694

695
        # Add `_attributes` variations for associations.
696
        # TODO: Consider adjusting this based on `nested_attributes_options`.
697
        if self.class.permit_nested_attributes_assignment
190✔
698
          hash_variations["#{f}_attributes"] = (
190✔
699
            config[:fields] + [ "_destroy" ]
190✔
700
          )
701
        end
702

703
        # Associations are not allowed to be submitted in their bare form (if they are submitted
704
        # that way, they will be translated to either id/ids or nested attributes assignment).
705
        next nil
190✔
706
      end
707

708
      next f
372✔
709
    }.compact
710
    @_get_allowed_parameters += variations
80✔
711
    @_get_allowed_parameters << hash_variations
80✔
712

713
    @_get_allowed_parameters
80✔
714
  end
715

716
  # Use strong parameters to filter the request body.
717
  def get_body_params(bulk_action: nil)
2✔
718
    data = self.request.request_parameters
80✔
719
    pk = self.class.model&.primary_key
80✔
720
    allowed_params = self.get_allowed_parameters
80✔
721

722
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
723
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
724
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
725
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
726
    if !bulk_action && self.class.model
80✔
727
      self.class.model.reflections.each do |name, ref|
30✔
728
        if payload = data[name]
176✔
729
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
4✔
730
            # Assume nested attributes assignment.
731
            attributes_key = "#{name}_attributes"
2✔
732
            data[attributes_key] = data.delete(name) unless data[attributes_key]
2✔
733
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
2✔
734
            # Assume id/ids assignment.
735
            data[id_field] = data.delete(name) unless data[id_field]
2✔
736
          end
737
        end
738
      end
739
    end
740

741
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
742
    #
743
    # rubocop:disable Layout/LineLength
744
    #
745
    # Example base64 images (red, green, and blue squares):
746
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
747
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
748
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
749
    #
750
    # rubocop:enable Layout/LineLength
751
    has_many_attached_scalar_data = {}
80✔
752
    if !bulk_action && self.class.enable_active_storage && self.class.model
80✔
753
      self.class.model.attachment_reflections.keys.each do |k|
30✔
754
        if data[k].is_a?(Array)
20✔
755
          data[k] = data[k].map { |v|
×
756
            if v.is_a?(String)
×
757
              v = RRF_BASE64_TRANSLATE.call(k, v)
×
758

759
              # Remember scalars because Rails strong params will remove it.
760
              if v.is_a?(String)
×
761
                has_many_attached_scalar_data[k] ||= []
×
762
                has_many_attached_scalar_data[k] << v
×
763
              end
764
            elsif v.is_a?(Hash)
×
765
              if v[:io].is_a?(String)
×
766
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
767
              end
768
            end
769

770
            next v
×
771
          }
772
        elsif data[k].is_a?(Hash)
20✔
773
          if data[k][:io].is_a?(String)
×
774
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
775
          end
776
        elsif data[k].is_a?(String)
20✔
777
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
778
        end
779
      end
780
    end
781

782
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
783
    # parameters to the `_json` key of the request body.
784
    body_params = if allowed_params == true
80✔
785
      ActionController::Parameters.new(data).permit!
×
786
    elsif bulk_action
80✔
787
      if bulk_action == :create
50✔
788
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
20✔
789
      elsif bulk_action == :update
30✔
790
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
18✔
791
      elsif bulk_action == :destroy
12✔
792
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
793
      else
794
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
795
      end
796
    else
797
      ActionController::Parameters.new(data).permit(*allowed_params)
30✔
798
    end
799

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

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

817
        self._rrf_strip_read_only_fields(element, keep: keep)
66✔
818
      end
819
    else
820
      self._rrf_strip_read_only_fields(body_params)
30✔
821
    end
822

823
    body_params
80✔
824
  end
825
  alias_method :get_create_params, :get_body_params
2✔
826
  alias_method :get_update_params, :get_body_params
2✔
827
  alias_method :get_destroy_params, :get_body_params
2✔
828

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

835
      cfg = self.class.field_configuration[f]
118✔
836
      cfg && cfg[:read_only]
118✔
837
    end
838
  end
839

840
  # Get the set of records this controller has access to.
841
  def get_recordset
2✔
842
    return self.class.recordset if self.class.recordset
334✔
843

844
    # If there is a model, return that model's default scope (all records by default).
845
    if self.class.model
334✔
846
      return self.class.model.all
334✔
847
    end
848

849
    nil
850
  end
851

852
  # Filter the recordset and return records this request has access to.
853
  def get_records
2✔
854
    data = self.get_recordset
270✔
855

856
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
270✔
857
      filter.new(controller: self).filter_data(d)
968✔
858
    } || data
859
  end
860

861
  # Get a single record by primary key or another column, if allowed.
862
  def get_record
2✔
863
    return @record if @record
86✔
864

865
    find_by_key = self.class.model.primary_key
86✔
866
    is_pk = true
86✔
867

868
    # Find by another column if it's permitted.
869
    if find_by_param = self.class.find_by_query_param.presence
86✔
870
      if find_by = request.query_parameters[find_by_param].presence
86✔
871
        find_by_fields = (
872
          self.class.find_by_fields&.map(&:to_s) || self.class.model.columns_hash.keys
6✔
873
        )
874

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

878
        is_pk = false unless find_by_key == find_by
4✔
879
        find_by_key = find_by
4✔
880
      end
881
    end
882

883
    # Get the recordset, filtering if configured.
884
    collection = if self.class.filter_recordset_before_find
84✔
885
      self.get_records
80✔
886
    else
887
      self.get_recordset
4✔
888
    end
889

890
    # Return the record. Route key is always `:id` by Rails' convention.
891
    if is_pk
84✔
892
      @record = collection.find(request.path_parameters[:id])
80✔
893
    else
894
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
895
    end
896
  end
897

898
  # Determine what collection to call `create` on.
899
  def create_from
2✔
900
    if self.class.create_from_recordset
42✔
901
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
902
      # in case model callbacks need to call `count` on this collection, which typically raises a
903
      # SQL `SyntaxError`.
904
      self.get_recordset.except(:select)
40✔
905
    else
906
      # Otherwise, perform a "bare" insert_all.
907
      self.class.model
2✔
908
    end
909
  end
910
end
911

912
require_relative "controller/actions"
2✔
913
require_relative "controller/bulk"
2✔
914
require_relative "controller/crud"
2✔
915
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