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

gregschmit / rails-rest-framework / 30662838390

31 Jul 2026 08:25PM UTC coverage: 94.982% (+0.02%) from 94.964%
30662838390

push

github

gregschmit
Add readable field helpers for query surfaces

Add readable_fields (get_fields minus write_only), readable_columns, and
readable_columns_or_associations helpers. Route find_by/search through
the column helpers and filter/ordering through columns-or-associations,
so write_only fields can't be used as lookup/filter/order keys and
virtual fields are ignored instead of hitting the DB.

Default find_by to real columns instead of every model column (a v2
enumeration regression). Also drop get_fields' unused input_fields param
and memoize it.

22 of 23 new or added lines in 4 files covered. (95.65%)

1306 of 1375 relevant lines covered (94.98%)

475.05 hits per line

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

92.22
/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 }
14,598✔
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]
302✔
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 }
5,072✔
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
264✔
199
            singleton_class.define_method(name) do
264✔
200
              if equal?(klass)
7,900✔
201
                value
7,784✔
202
              elsif defined?(super)
116✔
203
                super()
116✔
204
              else
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,172✔
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,172✔
254
    end
255

256
    # Resolve the `fields` config to an array of strings. Memoized, since `fields` and the flags it
257
    # depends on are class-level config fixed at load time.
258
    def get_fields
2✔
259
      @get_fields ||= if self.fields.is_a?(Hash)
1,016✔
260
        RESTFramework::Utils.parse_fields_hash(
10✔
261
          self.fields,
262
          self.model,
263
          exclude_associations: self.exclude_associations,
264
          action_text: self.enable_action_text,
265
          active_storage: self.enable_active_storage,
266
        )
267
      elsif self.fields
50✔
268
        self.fields.map(&:to_s)
28✔
269
      elsif self.model
22✔
270
        RESTFramework::Utils.fields_for(
22✔
271
          self.model,
272
          exclude_associations: self.exclude_associations,
273
          action_text: self.enable_action_text,
274
          active_storage: self.enable_active_storage,
275
        )
276
      else
NEW
277
        []
×
278
      end
279
    end
280

281
    # Get a full field configuration, including defaults and inferred values.
282
    def field_configuration
2✔
283
      return @field_configuration if @field_configuration
5,336✔
284

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

299
      @field_configuration = self.get_fields.map { |f|
60✔
300
        cfg = field_config[f]&.dup || {}
494✔
301
        cfg[:label] ||= self.label_for(f)
494✔
302

303
        # Annotate primary key.
304
        if self.model.primary_key == f
494✔
305
          cfg[:primary_key] = true
60✔
306

307
          unless cfg.key?(:read_only)
60✔
308
            cfg[:read_only] = true
60✔
309
          end
310
        end
311

312
        # Annotate field mutability and display properties.
313
        cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
494✔
314
        cfg[:write_only] = true if f.in?(write_only_fields)
494✔
315
        cfg[:hidden] = true if f.in?(hidden_fields)
494✔
316

317
        # Raise warnings on some bad combinations of properties.
318
        if cfg[:write_only]
494✔
319
          if cfg[:read_only]
10✔
320
            Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
×
321
          end
322

323
          if cfg[:hidden]
10✔
324
            Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
×
325
          end
326

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

332
        # Annotate column data.
333
        if column = columns[f]
494✔
334
          cfg[:kind] = "column"
336✔
335
          cfg[:type] ||= column.type
336✔
336
          cfg[:required] = true unless column.null
336✔
337
        end
338

339
        # Add default values from the model's schema.
340
        if cfg[:default].nil? && (column_default = column_defaults[f])
494✔
341
          cfg[:default] = column_default
132✔
342
        end
343

344
        # Add metadata from the model's attributes hash.
345
        if attributes.key?(f) && attribute = attributes[f]
494✔
346
          if cfg[:default].nil? && default = attribute.value_before_type_cast
336✔
347
            cfg[:default] = default
×
348
          end
349
          cfg[:kind] ||= "attribute"
336✔
350

351
          # Get any type information from the attribute.
352
          if type = attribute.type
336✔
353
            cfg[:type] ||= type.type if type.type
336✔
354

355
            # Get enum variants.
356
            if type.is_a?(ActiveRecord::Enum::EnumType)
336✔
357
              cfg[:enum_variants] = type.send(:mapping)
16✔
358

359
              # TranslateEnum Integration:
360
              translate_method = "translated_#{f.pluralize}"
16✔
361
              if self.model.respond_to?(translate_method)
16✔
362
                cfg[:enum_translations] = self.model.send(translate_method)
16✔
363
              end
364
            end
365
          end
366
        end
367

368
        # Get association metadata.
369
        if ref = reflections[f]
494✔
370
          cfg[:kind] = "association"
138✔
371

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

381
          # Strings, to match `:fields` when intersecting requested fields against the allowlist.
382
          if cfg[:requestable_fields]
138✔
383
            cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
2✔
384
          end
385

386
          # Very basic metadata about the association's fields.
387
          cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
138✔
388
            v = {}
262✔
389

390
            if ref_columns[sf]
262✔
391
              v[:kind] = "column"
262✔
392
            else
393
              v[:kind] = "method"
×
394
            end
395

396
            next [ sf, v ]
262✔
397
          }.to_h.compact.presence
398

399
          # Determine if we render id/ids fields. Unfortunately, `has_one` does not provide this
400
          # interface.
401
          if self.permit_id_assignment && id_field = RESTFramework::Utils.id_field_for(f, ref)
138✔
402
            cfg[:id_field] = id_field
118✔
403
          end
404

405
          # Determine if we render nested attributes options.
406
          if self.permit_nested_attributes_assignment && (
138✔
407
            nested_opts = self.model.nested_attributes_options[f.to_sym].presence
138✔
408
          )
409
            cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
54✔
410
          end
411

412
          begin
413
            cfg[:association_pk] = ref.active_record_primary_key
138✔
414
          rescue ActiveRecord::UnknownPrimaryKey
415
          end
416

417
          cfg[:reflection] = ref
138✔
418
        end
419

420
        # Determine if this is an ActionText "rich text".
421
        if :"rich_text_#{f}".in?(rich_text_association_names)
494✔
422
          cfg[:kind] = "rich_text"
4✔
423
        end
424

425
        # Determine if this is an ActiveStorage attachment.
426
        if ref = attachment_reflections[f]
494✔
427
          cfg[:kind] = "attachment"
8✔
428
          cfg[:attachment_type] = ref.macro
8✔
429
        end
430

431
        # Determine if this is just a method.
432
        if !cfg[:kind] && self.model.method_defined?(f)
494✔
433
          cfg[:kind] = "method"
6✔
434
          cfg[:read_only] = true if cfg[:read_only].nil?
6✔
435
        end
436

437
        # Collect validator options into a hash on their type, while also updating `required` based
438
        # on any presence validators.
439
        self.model.validators_on(f).each do |validator|
494✔
440
          kind = validator.kind
210✔
441
          options = validator.options
210✔
442

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

447
          # Update `required` if we find a presence validator.
448
          cfg[:required] = true if kind == :presence
210✔
449

450
          cfg[:validators] ||= {}
210✔
451
          cfg[:validators][kind] ||= []
210✔
452
          cfg[:validators][kind] << options
210✔
453
        end
454

455
        next [ f, cfg ]
494✔
456
      }.to_h.compact.with_indifferent_access
457

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

466
          cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
40✔
467
        end
468
      end
469

470
      @field_configuration
60✔
471
    end
472

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

481
      sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
38✔
482
      return [] unless sibling
38✔
483
      return [] if sibling.serializer_class ||
38✔
484
        sibling.native_serializer_config ||
485
        sibling.native_serializer_singular_config ||
486
        sibling.native_serializer_plural_config
487

488
      cfg = sibling.field_configuration
36✔
489
      sibling.get_fields.reject { |sf|
36✔
490
        c = cfg[sf]
388✔
491
        c.nil? || c[:write_only] || c[:kind] == "association"
388✔
492
      }
493
    end
494
  end
495

496
  def self.included(base)
2✔
497
    return unless base.is_a?(Class)
60✔
498

499
    base.extend(ClassMethods)
60✔
500

501
    # By default, the layout should be set to `rest_framework`.
502
    base.layout("rest_framework")
60✔
503

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

509
      base.rrf_class_attribute(a, default: default)
3,640✔
510
    end
511

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

520
    # Handle exceptions.
521
    base.rescue_from(*RRF_RESCUED_EXCEPTIONS, with: :rrf_error_handler)
60✔
522
    base.rescue_from(*RRF_RESCUED_RAILS_EXCEPTIONS, with: :rrf_error_handler)
60✔
523
  end
524

525
  def get_serializer_class
2✔
526
    self.class.serializer_class || RESTFramework::NativeSerializer
270✔
527
  end
528

529
  # Serialize the given data using the `serializer_class`.
530
  def serialize(data, **kwargs)
2✔
531
    RESTFramework::Utils.wrap_ams(self.get_serializer_class).new(
252✔
532
      data, controller: self, **kwargs
533
    ).serialize
534
  end
535

536
  def rrf_error_handler(e)
2✔
537
    status = case e
52✔
538
    when ActiveRecord::RecordNotFound
539
      404
30✔
540
    when RESTFramework::BulkRecordErrorsError
541
      422
4✔
542
    else
543
      400
18✔
544
    end
545

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

554
    render_api(
52✔
555
      {
556
        message: message,
557
        errors: e.try(:record).try(:errors),
558
        exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
52✔
559
      }.compact,
560
      status: status,
561
    )
562
  end
563

564
  def route_groups
2✔
565
    @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
74✔
566
  end
567

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

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

583
    # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
584
    if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
410✔
585
      payload = self.serialize(payload)
158✔
586
    end
587

588
    # Do not use any adapters by default, if configured.
589
    if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
410✔
590
      kwargs[:adapter] = nil
410✔
591
    end
592

593
    # Flag to track if we had to rescue unknown format.
594
    already_rescued_unknown_format = false
410✔
595

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

641
  def options
2✔
642
    render_api(self.openapi_document)
16✔
643
  end
644

645
  def get_fields
2✔
646
    self.class.get_fields
920✔
647
  end
648

649
  def readable_fields
2✔
650
    cfg = self.class.field_configuration
534✔
651
    self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
6,054✔
652
  end
653

654
  # `readable_fields` restricted to real columns, for query surfaces that build SQL directly
655
  # (find_by, search) and would raise on a virtual/method field.
656
  def readable_columns
2✔
657
    self.readable_fields & self.class.model.column_names
14✔
658
  end
659

660
  # `readable_fields` restricted to columns and associations, for surfaces that also resolve dotted
661
  # `association.sub_field` paths (filtering, ordering). Excludes virtual/method fields, which have
662
  # no column to order or filter by.
663
  def readable_columns_or_associations
2✔
664
    cfg = self.class.field_configuration
516✔
665
    columns = self.class.model.column_names
516✔
666
    self.readable_fields.select { |f| f.in?(columns) || cfg[f]&.[](:kind) == "association" }
5,920✔
667
  end
668

669
  # Get a hash of strong parameters for the current action.
670
  def get_allowed_parameters
2✔
671
    return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
80✔
672

673
    @_get_allowed_parameters = self.class.allowed_parameters
80✔
674
    return @_get_allowed_parameters if @_get_allowed_parameters
80✔
675

676
    # Assemble strong parameters. Read-only fields are permitted here and stripped later per-action
677
    # by `_rrf_strip_read_only_fields` (which keeps the primary key on bulk update to find records).
678
    variations = []
80✔
679
    hash_variations = {}
80✔
680
    reflections = self.class.model.reflections
80✔
681
    @_get_allowed_parameters = self.get_fields.map { |f|
80✔
682
      f = f.to_s
664✔
683
      config = self.class.field_configuration[f]
664✔
684

685
      # ActionText Integration:
686
      if self.class.enable_action_text && reflections.key?("rich_text_#{f}")
664✔
687
        next f
34✔
688
      end
689

690
      # ActiveStorage Integration: `has_one_attached`
691
      if self.class.enable_active_storage && reflections.key?("#{f}_attachment")
630✔
692
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
693
        next f
34✔
694
      end
695

696
      # ActiveStorage Integration: `has_many_attached`
697
      if self.class.enable_active_storage && reflections.key?("#{f}_attachments")
596✔
698
        hash_variations[f] = RRF_ACTIVESTORAGE_KEYS
34✔
699
        next nil
34✔
700
      end
701

702
      if config[:reflection]
562✔
703
        # Add `_id`/`_ids` variations for associations.
704
        if id_field = config[:id_field]
190✔
705
          if id_field.ends_with?("_ids")
180✔
706
            hash_variations[id_field] = []
128✔
707
          else
708
            variations << id_field
52✔
709
          end
710
        end
711

712
        # Add `_attributes` variations for associations.
713
        # TODO: Consider adjusting this based on `nested_attributes_options`.
714
        if self.class.permit_nested_attributes_assignment
190✔
715
          hash_variations["#{f}_attributes"] = (
190✔
716
            config[:fields] + [ "_destroy" ]
190✔
717
          )
718
        end
719

720
        # Associations are not allowed to be submitted in their bare form (if they are submitted
721
        # that way, they will be translated to either id/ids or nested attributes assignment).
722
        next nil
190✔
723
      end
724

725
      next f
372✔
726
    }.compact
727
    @_get_allowed_parameters += variations
80✔
728
    @_get_allowed_parameters << hash_variations
80✔
729

730
    @_get_allowed_parameters
80✔
731
  end
732

733
  # Use strong parameters to filter the request body.
734
  def get_body_params(bulk_action: nil)
2✔
735
    data = self.request.request_parameters
80✔
736
    pk = self.class.model&.primary_key
80✔
737
    allowed_params = self.get_allowed_parameters
80✔
738

739
    # Before we filter the data, dynamically dispatch association assignment to either the id/ids
740
    # assignment ActiveRecord API or the nested assignment ActiveRecord API. Note that there is no
741
    # need to check for `permit_id_assignment` or `permit_nested_attributes_assignment` here, since
742
    # that is enforced by strong parameters generated by `get_allowed_parameters`.
743
    if !bulk_action && self.class.model
80✔
744
      self.class.model.reflections.each do |name, ref|
30✔
745
        if payload = data[name]
176✔
746
          if payload.is_a?(Hash) || (payload.is_a?(Array) && payload.all? { |x| x.is_a?(Hash) })
4✔
747
            # Assume nested attributes assignment.
748
            attributes_key = "#{name}_attributes"
2✔
749
            data[attributes_key] = data.delete(name) unless data[attributes_key]
2✔
750
          elsif id_field = RESTFramework::Utils.id_field_for(name, ref)
2✔
751
            # Assume id/ids assignment.
752
            data[id_field] = data.delete(name) unless data[id_field]
2✔
753
          end
754
        end
755
      end
756
    end
757

758
    # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
759
    #
760
    # rubocop:disable Layout/LineLength
761
    #
762
    # Example base64 images (red, green, and blue squares):
763
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
764
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FAAhKDveksOjmAAAAAElFTkSuQmCC
765
    #   data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC
766
    #
767
    # rubocop:enable Layout/LineLength
768
    has_many_attached_scalar_data = {}
80✔
769
    if !bulk_action && self.class.enable_active_storage && self.class.model
80✔
770
      self.class.model.attachment_reflections.keys.each do |k|
30✔
771
        if data[k].is_a?(Array)
20✔
772
          data[k] = data[k].map { |v|
×
773
            if v.is_a?(String)
×
774
              v = RRF_BASE64_TRANSLATE.call(k, v)
×
775

776
              # Remember scalars because Rails strong params will remove it.
777
              if v.is_a?(String)
×
778
                has_many_attached_scalar_data[k] ||= []
×
779
                has_many_attached_scalar_data[k] << v
×
780
              end
781
            elsif v.is_a?(Hash)
×
782
              if v[:io].is_a?(String)
×
783
                v[:io] = StringIO.new(Base64.decode64(v[:io]))
×
784
              end
785
            end
786

787
            next v
×
788
          }
789
        elsif data[k].is_a?(Hash)
20✔
790
          if data[k][:io].is_a?(String)
×
791
            data[k][:io] = StringIO.new(Base64.decode64(data[k][:io]))
×
792
          end
793
        elsif data[k].is_a?(String)
20✔
794
          data[k] = RRF_BASE64_TRANSLATE.call(k, data[k])
×
795
        end
796
      end
797
    end
798

799
    # Filter the request body with strong params. If `bulk` is true, then we apply allowed
800
    # parameters to the `_json` key of the request body.
801
    body_params = if allowed_params == true
80✔
802
      ActionController::Parameters.new(data).permit!
×
803
    elsif bulk_action
80✔
804
      if bulk_action == :create
50✔
805
        ActionController::Parameters.new(data).permit({ _json: allowed_params })
20✔
806
      elsif bulk_action == :update
30✔
807
        ActionController::Parameters.new(data).permit({ _json: allowed_params + [ pk ] })
18✔
808
      elsif bulk_action == :destroy
12✔
809
        ActionController::Parameters.new(data).permit({ _json: [] })
12✔
810
      else
811
        raise ArgumentError, "Invalid bulk action: #{bulk_action}"
×
812
      end
813
    else
814
      ActionController::Parameters.new(data).permit(*allowed_params)
30✔
815
    end
816

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

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

834
        self._rrf_strip_read_only_fields(element, keep: keep)
66✔
835
      end
836
    else
837
      self._rrf_strip_read_only_fields(body_params)
30✔
838
    end
839

840
    body_params
80✔
841
  end
842
  alias_method :get_create_params, :get_body_params
2✔
843
  alias_method :get_update_params, :get_body_params
2✔
844
  alias_method :get_destroy_params, :get_body_params
2✔
845

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

852
      cfg = self.class.field_configuration[f]
118✔
853
      cfg && cfg[:read_only]
118✔
854
    end
855
  end
856

857
  # Get the set of records this controller has access to.
858
  def get_recordset
2✔
859
    return self.class.recordset if self.class.recordset
334✔
860

861
    # If there is a model, return that model's default scope (all records by default).
862
    if self.class.model
334✔
863
      return self.class.model.all
334✔
864
    end
865

866
    nil
867
  end
868

869
  # Filter the recordset and return records this request has access to.
870
  def get_records
2✔
871
    data = self.get_recordset
270✔
872

873
    @records ||= self.class.filter_backends&.reduce(data) { |d, filter|
270✔
874
      filter.new(controller: self).filter_data(d)
968✔
875
    } || data
876
  end
877

878
  # Get a single record by primary key or another column, if allowed.
879
  def get_record
2✔
880
    return @record if @record
90✔
881

882
    find_by_key = self.class.model.primary_key
90✔
883
    is_pk = true
90✔
884

885
    # Find by another column if it's permitted.
886
    if find_by_param = self.class.find_by_query_param.presence
90✔
887
      if find_by = request.query_parameters[find_by_param].presence
90✔
888
        # Default to readable columns: excluding write_only keeps hidden values from being used as
889
        # lookup keys, and restricting to real columns keeps virtual/method fields from reaching a
890
        # doomed `find_by(<not a column>)` (which would raise on the DB).
891
        find_by_fields = self.class.find_by_fields&.map(&:to_s) || self.readable_columns
10✔
892

893
        # A `find_by` was explicitly requested, so it must be a permitted field.
894
        raise ActiveRecord::RecordNotFound unless find_by.in?(find_by_fields)
10✔
895

896
        is_pk = false unless find_by_key == find_by
4✔
897
        find_by_key = find_by
4✔
898
      end
899
    end
900

901
    # Get the recordset, filtering if configured.
902
    collection = if self.class.filter_recordset_before_find
84✔
903
      self.get_records
80✔
904
    else
905
      self.get_recordset
4✔
906
    end
907

908
    # Return the record. Route key is always `:id` by Rails' convention.
909
    if is_pk
84✔
910
      @record = collection.find(request.path_parameters[:id])
80✔
911
    else
912
      @record = collection.find_by!(find_by_key => request.path_parameters[:id])
4✔
913
    end
914
  end
915

916
  # Determine what collection to call `create` on.
917
  def create_from
2✔
918
    if self.class.create_from_recordset
42✔
919
      # Create with any properties inherited from the recordset. We exclude any `select` clauses
920
      # in case model callbacks need to call `count` on this collection, which typically raises a
921
      # SQL `SyntaxError`.
922
      self.get_recordset.except(:select)
40✔
923
    else
924
      # Otherwise, perform a "bare" insert_all.
925
      self.class.model
2✔
926
    end
927
  end
928
end
929

930
require_relative "controller/actions"
2✔
931
require_relative "controller/bulk"
2✔
932
require_relative "controller/crud"
2✔
933
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