Module: RESTFramework::Controller::ClassMethods

Defined in:
lib/rest_framework/controller.rb,
lib/rest_framework/controller/actions.rb,
lib/rest_framework/controller/openapi.rb

Constant Summary collapse

IGNORE_VALIDATORS_WITH_KEYS =
[ :if, :unless ].freeze
RRF_PROPAGATING_KEY =

Thread-local key toggled by propagate while its block runs.

:rrf_propagating

Instance Method Summary collapse

Instance Method Details

#_rrf_action_adds(type) ⇒ Object

Per-class action deltas (internal). Public because composition reads them off ancestors.



32
33
34
35
36
37
38
# File 'lib/rest_framework/controller/actions.rb', line 32

def _rrf_action_adds(type)
  if type == :member
    @_rrf_member_action_adds ||= {}
  else
    @_rrf_collection_action_adds ||= {}
  end
end

#_rrf_action_chainObject (private)



184
185
186
# File 'lib/rest_framework/controller/actions.rb', line 184

def _rrf_action_chain
  self.ancestors.select { |a| a.is_a?(Class) && a.respond_to?(:_rrf_action_adds) }.reverse
end

#_rrf_action_removes(type) ⇒ Object



40
41
42
43
44
45
46
# File 'lib/rest_framework/controller/actions.rb', line 40

def _rrf_action_removes(type)
  if type == :member
    @_rrf_member_action_removes ||= {}
  else
    @_rrf_collection_action_removes ||= {}
  end
end

#_rrf_add_action(type, name, methods, path: nil, metadata: nil, propagate: false, **kwargs) ⇒ Object (private)



92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/rest_framework/controller/actions.rb', line 92

def _rrf_add_action(type, name, methods, path: nil, metadata: nil, propagate: false, **kwargs)
  name = name.to_sym
  spec = ActionSpec.new(
    name: name,
    type: type,
    methods: Array(methods).map(&:to_sym),
    path: (path || name).to_s,
    metadata: ,
    builtin: false,
    kwargs: kwargs,
  )
  _rrf_action_removes(type).delete(name)
  _rrf_action_adds(type)[name] = { spec: spec, propagate: _rrf_normalize_propagate(propagate) }
end

#_rrf_builtins(type) ⇒ Object (private)



144
145
146
# File 'lib/rest_framework/controller/actions.rb', line 144

def _rrf_builtins(type)
  type == :member ? RRF_BUILTIN_MEMBER_ACTIONS : RRF_BUILTIN_COLLECTION_ACTIONS
end

#_rrf_compose_actions(type) ⇒ Object (private)



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/rest_framework/controller/actions.rb', line 148

def _rrf_compose_actions(type)
  effective = {}

  # 1. Seed with the builtins whose condition holds for this controller.
  _rrf_builtins(type).each do |name, cfg|
    next unless cfg[:condition].call(self)

    effective[name] = ActionSpec.new(
      name: name,
      type: type,
      methods: cfg[:methods],
      path: "",
      metadata: nil,
      builtin: true,
      kwargs: cfg[:kwargs] || {},
    )
  end

  # 2. Walk RRF ancestors from farthest to nearest (ending at self), applying each class's
  #    removes then adds that reach us. Removes-before-adds lets a subclass re-add something an
  #    ancestor removed; later (nearer) classes win.
  _rrf_action_chain.each do |klass|
    is_self = klass.equal?(self)

    klass._rrf_action_removes(type).each do |name, remove|
      effective.delete(name) if _rrf_reaches?(remove[:propagate], is_self)
    end

    klass._rrf_action_adds(type).each do |name, add|
      effective[name] = add[:spec] if _rrf_reaches?(add[:propagate], is_self)
    end
  end

  effective
end

#_rrf_normalize_propagate(value) ⇒ Object (private)

Normalize propagate: to false (local), true (self + descendants), or :exclude_self (descendants only). Non-standard values warn: nil becomes false, anything else truthy becomes true.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/rest_framework/controller/actions.rb', line 116

def _rrf_normalize_propagate(value)
  case value
  when false
    false
  when true, :exclude_self
    value
  when nil
    Rails.logger.warn("RRF: `propagate: nil` is nonstandard; treating as `false`.")
    false
  else
    Rails.logger.warn("RRF: invalid `propagate:` value #{value.inspect}; treating as `true`.")
    true
  end
end

#_rrf_reaches?(propagate, is_self) ⇒ Boolean (private)

Whether an entry with the given propagate, declared on some class, reaches the controller we're composing for. is_self is true when that class is the controller itself.

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
# File 'lib/rest_framework/controller/actions.rb', line 133

def _rrf_reaches?(propagate, is_self)
  case propagate
  when :exclude_self
    !is_self
  when true
    true
  else # false
    is_self
  end
end

#_rrf_remove_action(type, name, propagate: false) ⇒ Object (private)



107
108
109
110
111
# File 'lib/rest_framework/controller/actions.rb', line 107

def _rrf_remove_action(type, name, propagate: false)
  name = name.to_sym
  _rrf_action_adds(type).delete(name)
  _rrf_action_removes(type)[name] = { propagate: _rrf_normalize_propagate(propagate) }
end

#_rrf_warn_action(name, methods, type, reason) ⇒ Object (private)

Build the type: warning for add_action, echoing the call so its source is easy to find.



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/rest_framework/controller/actions.rb', line 189

def _rrf_warn_action(name, methods, type, reason)
  type_arg = type ? ", type: #{type.inspect}" : ""
  sig = "add_action(#{name.inspect}, #{methods.inspect}#{type_arg})"

  detail =
    if reason == :ambiguous
      "needs an explicit `type:` (`:collection` or `:member`); collection and member route " \
        "differently on a plural model controller"
    else
      "has an unnecessary `type:`; member and collection route the same path on a singular " \
        "model controller"
    end

  Rails.logger.warn("RRF: `#{sig}` #{detail}.")
end

#actionsObject

Source of truth: the effective collection / member actions (builtins + declared, composed across the inheritance chain), as an ordered Hash{Symbol => ActionSpec}.



82
83
84
# File 'lib/rest_framework/controller/actions.rb', line 82

def actions
  _rrf_compose_actions(:collection)
end

#add_action(name, methods, type: nil, **opts) ⇒ Object

Route an action, choosing the collection/member scope. Pass type: on a plural model controller, where the scopes differ; elsewhere the scope is implied — a singular controller's sole resource is a member (so delegate targets the record), a modelless one has only a collection — and type: warns as unnecessary (unless the action is delegated).



52
53
54
55
56
57
58
59
60
61
62
# File 'lib/rest_framework/controller/actions.rb', line 52

def add_action(name, methods, type: nil, **opts)
  singular_model = self.model && self.singular

  if self.model && !self.singular
    _rrf_warn_action(name, methods, type, :ambiguous) if type.nil?
  elsif singular_model && type && !opts[:metadata]&.[](:delegate)
    _rrf_warn_action(name, methods, type, :redundant)
  end

  _rrf_add_action(type || (singular_model ? :member : :collection), name, methods, **opts)
end

#association_requestable_fields(ref) ⇒ Object

The fields a consumer may request for an association beyond its defaults, derived from the associated model's sibling controller: what that controller serializes, so the association can never expose more than its own endpoint would. Empty unless the sibling is discoverable and introspectable — a custom serializer makes its get_fields meaningless. Hidden fields are included (retrievable via ?only= there); write-only fields and nested associations aren't.



482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/rest_framework/controller.rb', line 482

def association_requestable_fields(ref)
  return [] if ref.polymorphic?

  sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
  return [] unless sibling
  return [] if sibling.serializer_class ||
    sibling.native_serializer_config ||
    sibling.native_serializer_singular_config ||
    sibling.native_serializer_plural_config

  cfg = sibling.field_configuration
  sibling.get_fields.reject { |sf|
    c = cfg[sf]
    c.nil? || c[:write_only] || c[:kind] == "association"
  }
end

#field_configurationObject

Get a full field configuration, including defaults and inferred values.



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/rest_framework/controller.rb', line 286

def field_configuration
  return @field_configuration if @field_configuration

  field_config = self.field_config&.with_indifferent_access || {}
  columns = self.model.columns_hash
  column_defaults = self.model.column_defaults
  reflections = self.model.reflections
  attributes = self.model._default_attributes
  readonly_attributes = self.model.readonly_attributes
  read_only_fields = self.read_only_fields&.map(&:to_s)&.to_set || Set[]
  write_only_fields = self.write_only_fields&.map(&:to_s)&.to_set || Set[]
  hidden_fields = self.hidden_fields&.map(&:to_s)&.to_set || Set[]
  rich_text_association_names = self.model.reflect_on_all_associations(:has_one)
    .collect(&:name)
    .select { |n| n.to_s.start_with?("rich_text_") }
  attachment_reflections = self.model.attachment_reflections

  @field_configuration = self.get_fields.map { |f|
    cfg = field_config[f]&.dup || {}
    cfg[:label] ||= self.label_for(f)

    # Annotate primary key.
    if self.model.primary_key == f
      cfg[:primary_key] = true

      unless cfg.key?(:read_only)
        cfg[:read_only] = true
      end
    end

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

    # Raise warnings on some bad combinations of properties.
    if cfg[:write_only]
      if cfg[:read_only]
        Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
      end

      if cfg[:hidden]
        Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
      end

      if cfg[:hidden_from_index]
        Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
      end
    end

    # Annotate column data.
    if column = columns[f]
      cfg[:kind] = "column"
      cfg[:type] ||= column.type
      cfg[:required] = true unless column.null
    end

    # Add default values from the model's schema.
    if cfg[:default].nil? && (column_default = column_defaults[f])
      cfg[:default] = column_default
    end

    # Add metadata from the model's attributes hash.
    if attributes.key?(f) && attribute = attributes[f]
      if cfg[:default].nil? && default = attribute.value_before_type_cast
        cfg[:default] = default
      end
      cfg[:kind] ||= "attribute"

      # Get any type information from the attribute.
      if type = attribute.type
        cfg[:type] ||= type.type if type.type

        # Get enum variants.
        if type.is_a?(ActiveRecord::Enum::EnumType)
          cfg[:enum_variants] = type.send(:mapping)

          # TranslateEnum Integration:
          translate_method = "translated_#{f.pluralize}"
          if self.model.respond_to?(translate_method)
            cfg[:enum_translations] = self.model.send(translate_method)
          end
        end
      end
    end

    # Get association metadata.
    if ref = reflections[f]
      cfg[:kind] = "association"

      # Determine the association's fields.
      if ref.polymorphic?
        ref_columns = {}
      else
        ref_columns = ref.klass.columns_hash
      end
      cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
      cfg[:fields] = cfg[:fields].map(&:to_s)

      # Strings, to match `:fields` when intersecting requested fields against the allowlist.
      if cfg[:requestable_fields]
        cfg[:requestable_fields] = cfg[:requestable_fields].map(&:to_s)
      end

      # Very basic metadata about the association's fields.
      cfg[:association_fields_metadata] = cfg[:fields].map { |sf|
        v = {}

        if ref_columns[sf]
          v[:kind] = "column"
        else
          v[:kind] = "method"
        end

        next [ sf, v ]
      }.to_h.compact.presence

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

      # Determine if we render nested attributes options.
      if self.permit_nested_attributes_assignment && (
        nested_opts = self.model.nested_attributes_options[f.to_sym].presence
      )
        cfg[:nested_attributes_options] = { field: "#{f}_attributes", **nested_opts }
      end

      begin
        cfg[:association_pk] = ref.active_record_primary_key
      rescue ActiveRecord::UnknownPrimaryKey
      end

      cfg[:reflection] = ref
    end

    # Determine if this is an ActionText "rich text".
    if :"rich_text_#{f}".in?(rich_text_association_names)
      cfg[:kind] = "rich_text"
    end

    # Determine if this is an ActiveStorage attachment.
    if ref = attachment_reflections[f]
      cfg[:kind] = "attachment"
      cfg[:attachment_type] = ref.macro
    end

    # Determine if this is just a method.
    if !cfg[:kind] && self.model.method_defined?(f)
      cfg[:kind] = "method"
      cfg[:read_only] = true if cfg[:read_only].nil?
    end

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

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

      # Update `required` if we find a presence validator.
      cfg[:required] = true if kind == :presence

      cfg[:validators] ||= {}
      cfg[:validators][kind] ||= []
      cfg[:validators][kind] << options
    end

    next [ f, cfg ]
  }.to_h.compact.with_indifferent_access

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

      cfg[:requestable_fields] ||= self.association_requestable_fields(cfg[:reflection])
    end
  end

  @field_configuration
end

#get_fields(input_fields: nil) ⇒ Object

Get the available fields. Fallback to this controller's model columns, or an empty array. This should always return an array of strings.



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/rest_framework/controller.rb', line 258

def get_fields(input_fields: nil)
  input_fields ||= self.fields

  # If fields is a hash, then parse it.
  if input_fields.is_a?(Hash)
    return RESTFramework::Utils.parse_fields_hash(
      input_fields,
      self.model,
      exclude_associations: self.exclude_associations,
      action_text: self.enable_action_text,
      active_storage: self.enable_active_storage,
    )
  elsif !input_fields
    # Otherwise, if fields is nil, then fallback to columns.
    return self.model ? RESTFramework::Utils.fields_for(
      self.model,
      exclude_associations: self.exclude_associations,
      action_text: self.enable_action_text,
      active_storage: self.enable_active_storage,
    ) : []
  elsif input_fields
    input_fields = input_fields.map(&:to_s)
  end

  input_fields
end

#get_titleObject

By default, this is the name of the controller class, titleized and with any custom inflection acronyms applied.



241
242
243
244
245
246
# File 'lib/rest_framework/controller.rb', line 241

def get_title
  self.title || RESTFramework::Utils.inflect(
    self.name.demodulize.chomp("Controller").titleize(keep_id_suffix: true),
    self.inflect_acronyms,
  )
end

#label_for(s) ⇒ Object

Get a label from a field/column name, titleized and inflected.



249
250
251
252
253
254
# File 'lib/rest_framework/controller.rb', line 249

def label_for(s)
  default_title = RESTFramework::Utils.inflect(
    s.to_s.titleize(keep_id_suffix: true), self.inflect_acronyms
  )
  self.model&.human_attribute_name(s, default: default_title) || default_title
end

#member_actionsObject



86
87
88
# File 'lib/rest_framework/controller/actions.rb', line 86

def member_actions
  _rrf_compose_actions(:member)
end

#openapi_document(request, route_group_name, routes) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rest_framework/controller/openapi.rb', line 102

def openapi_document(request, route_group_name, routes)
  server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")

  {
    openapi: "3.1.1",
    info: {
      title: self.get_title,
      description: self.description,
      version: self.version.to_s,
    }.compact,
    servers: [ { url: server } ],
    paths: self.openapi_paths(routes, route_group_name),
    tags: [ { name: route_group_name, description: self.description }.compact ],
    components: {
      schemas: {
        "Error" => {
          type: "object",
          required: [ "message" ],
          properties: {
            message: { type: "string" },
            errors: { type: "object" },
            exception: { type: "string" },
          },
        },
      }.merge(self.model ? { self.openapi_schema_name => self.openapi_schema } : {}),
      responses: {
        "BadRequest": {
          description: "Bad Request",
          content: self.openapi_response_content_types.map { |ct|
            [
              ct,
              ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
            ]
          }.to_h,
        },
        "NotFound": {
          description: "Not Found",
          content: self.openapi_response_content_types.map { |ct|
            [
              ct,
              ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
            ]
          }.to_h,
        },
      },
    },
  }.merge(self.model ? {
    "x-rrf-primary_key" => self.model.primary_key,
    "x-rrf-callbacks" => self._process_action_callbacks.as_json,

    # While bulk update/destroy are obvious because they create new router endpoints, bulk
    # create overloads the existing collection `POST` endpoint, so we add a special key to the
    # OpenAPI metadata to indicate bulk create is supported.
    "x-rrf-bulk-create": self.bulk,
  } : {}).compact
end

#openapi_paths(routes, tag) ⇒ Object



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/rest_framework/controller/openapi.rb', line 19

def openapi_paths(routes, tag)
  resp_cts = self.openapi_response_content_types
  req_cts = self.openapi_request_content_types
  schema_name = self.openapi_schema_name if self.model

  routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
    [
      concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
      routes.map { |route|
         = RESTFramework::ROUTE_METADATA[route[:path]] || {}
        summary = .delete(:label).presence || self.label_for(route[:action])
        description = .delete(:description).presence
        extra_action = RESTFramework::EXTRA_ACTION_ROUTES.include?(route[:path])
        error_response = { "$ref" => "#/components/responses/BadRequest" }
        not_found_response = { "$ref" => "#/components/responses/NotFound" }
        spec = { tags: [ tag ], summary: summary, description: description }.compact

        # All routes should have a successful response.
        success_code = if !extra_action
          if route[:action] == "create"
            201
          elsif route[:action] == "destroy"
            204
          else
            200
          end
        else
          200
        end
        spec[:responses] = {
          success_code => {
            content: resp_cts.map { |ct|
              [
                ct,
                (self.model && !extra_action && route[:verb] != "OPTIONS") ? {
                  schema: { "$ref" => "#/components/schemas/#{schema_name}" },
                } : {},
              ]
            }.to_h,
            description: "Success",
          },
        }

        # Builtin POST, PUT, PATCH, and DELETE should have a 400 and 404 response.
        if route[:verb].in?([ "POST", "PUT", "PATCH", "DELETE" ]) && !extra_action
          spec[:responses][400] = error_response
          spec[:responses][404] = not_found_response
        end

        # All POST, PUT, PATCH should have a request body.
        if route[:verb].in?([ "POST", "PUT", "PATCH" ])
          spec[:requestBody] ||= {
            content: req_cts.map { |ct|
              [
                ct,
                (self.model && !extra_action) ? {
                  schema: { "$ref" => "#/components/schemas/#{schema_name}" },
                } : {},
              ]
            }.to_h,
          }
        end

        # Add remaining metadata as an extension.
        spec["x-rrf-metadata"] =  if .present?

        next route[:verb].downcase, spec
      }.to_h.merge(
        {
          parameters: routes.first[:route].required_parts.map { |p|
            {
              name: p,
              in: "path",
              required: true,
              schema: { type: "integer" },
            }
          },
        },
      ),
    ]
  }.to_h
end

#openapi_request_content_typesObject



11
12
13
14
15
16
17
# File 'lib/rest_framework/controller/openapi.rb', line 11

def openapi_request_content_types
  @openapi_request_content_types ||= [
    "application/json",
    "application/x-www-form-urlencoded",
    "multipart/form-data",
  ]
end

#openapi_response_content_typesObject



3
4
5
6
7
8
9
# File 'lib/rest_framework/controller/openapi.rb', line 3

def openapi_response_content_types
  @openapi_response_content_types ||= [
    "text/html",
    self.serialize_to_json ? "application/json" : nil,
    self.serialize_to_xml ? "application/xml" : nil,
  ].compact
end

#openapi_schemaObject

Only for model controllers.



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/rest_framework/controller/openapi.rb', line 160

def openapi_schema
  return @openapi_schema if @openapi_schema

  field_configuration = self.field_configuration
  @openapi_schema = {
    required: field_configuration.select { |_, cfg| cfg[:required] }.keys,
    type: "object",
    properties: field_configuration.map { |f, cfg|
      v = { title: cfg[:label] }

      if cfg[:kind] == "association"
        v[:type] = cfg[:reflection].collection? ? "array" : "object"
      elsif cfg[:kind] == "rich_text"
        v[:type] = "string"
        v[:"x-rrf-rich_text"] = true
      elsif cfg[:kind] == "attachment"
        v[:type] = "string"
        v[:"x-rrf-attachment"] = cfg[:attachment_type]
      else
        v[:type] = cfg[:type]
      end

      v[:readOnly] = true if cfg[:read_only]
      v[:writeOnly] = true if cfg[:write_only]
      v[:default] = cfg[:default] if cfg.key?(:default)

      if enum_variants = cfg[:enum_variants]
        v[:enum] = enum_variants.keys
        v[:"x-rrf-enum_variants"] = enum_variants
      end

      if validators = cfg[:validators]
        v[:"x-rrf-validators"] = validators
      end

      v[:"x-rrf-kind"] = cfg[:kind] if cfg[:kind]

      if cfg[:reflection]
        ref = cfg[:reflection]
        v[:"x-rrf-reflection"] = {
          class_name: ref.respond_to?(:class_name) ? ref.class_name : nil,
          foreign_key: ref.respond_to?(:foreign_key) ? ref.foreign_key : nil,
          association_foreign_key: ref.respond_to?(:association_foreign_key) ?
            ref.association_foreign_key : nil,
          association_primary_key: ref.respond_to?(:association_primary_key) ?
            ref.association_primary_key : nil,
          inverse_of: ref.respond_to?(:inverse_of) ? ref.inverse_of&.name : nil,
          join_table: ref.respond_to?(:join_table) ? ref.join_table : nil,
        }.compact
        v[:"x-rrf-association_pk"] = cfg[:association_pk]
        v[:"x-rrf-association_fields"] = cfg[:fields]
        v[:"x-rrf-association_fields_metadata"] = cfg[:association_fields_metadata]
        v[:"x-rrf-id_field"] = cfg[:id_field]
        v[:"x-rrf-nested_attributes_options"] = cfg[:nested_attributes_options]
      end

      next [ f, v ]
    }.to_h,
  }

  @openapi_schema
end

#openapi_schema_nameObject

Only for model controllers.



224
225
226
# File 'lib/rest_framework/controller/openapi.rb', line 224

def openapi_schema_name
  @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
end

#propagateObject

Run a block in which configuration setters (self.x = value) propagate to descendant controllers instead of applying locally. Use this on a shared base controller for settings you want every subclass to inherit:

propagate do
self.paginator_class = RESTFramework::PageNumberPaginator
self.page_size = 30
end


231
232
233
234
235
236
237
# File 'lib/rest_framework/controller.rb', line 231

def propagate
  previous = Thread.current[RRF_PROPAGATING_KEY]
  Thread.current[RRF_PROPAGATING_KEY] = true
  yield
ensure
  Thread.current[RRF_PROPAGATING_KEY] = previous
end

#remove_action(name, type: nil, propagate: false) ⇒ Object

Remove an action from routing (including builtins). With no type:, both scopes are removed — keeping removal simple, and letting propagate: carry it to model descendants where member scope matters. Pass type: to target a single scope.



67
68
69
70
71
72
73
74
# File 'lib/rest_framework/controller/actions.rb', line 67

def remove_action(name, type: nil, propagate: false)
  if type
    _rrf_remove_action(type, name, propagate: propagate)
  else
    _rrf_remove_action(:collection, name, propagate: propagate)
    _rrf_remove_action(:member, name, propagate: propagate)
  end
end

#remove_actions(*names, type: nil, propagate: false) ⇒ Object



76
77
78
# File 'lib/rest_framework/controller/actions.rb', line 76

def remove_actions(*names, type: nil, propagate: false)
  names.each { |name| remove_action(name, type: type, propagate: propagate) }
end

#rrf_class_attribute(*names, default: nil) ⇒ Object

Define one or more class-level configuration attributes. Assignments are local by default:

self.x = value               # applies to this controller ONLY; descendants don't inherit it
propagate { self.x = value } # applies to this controller AND all descendants

This gives a single, uniform rule (an assignment is local unless wrapped in propagate), so there's no per-attribute "does this inherit?" knowledge to carry around. Values are stored in closures on redefined singleton methods (the same mechanism as class_attribute), never in instance variables, so there is exactly one interface for configuration: the setter.

Only singleton (class-level) methods are defined, so config never leaks to controller instances (which would risk colliding with action methods).



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/rest_framework/controller.rb', line 179

def rrf_class_attribute(*names, default: nil)
  names.each do |name|
    # Propagating baseline: every controller sees the default until it's overridden. This lives
    # in the propagated module (see `rrf_propagated_module`) rather than directly on the
    # singleton class, so a local assignment can coexist with it via `super`.
    rrf_propagated_module.define_method(name) { default }

    # Parity with `class_attribute`, which also defines a predicate.
    singleton_class.define_method("#{name}?") { !!public_send(name) }

    singleton_class.define_method("#{name}=") do |value|
      if Thread.current[RRF_PROPAGATING_KEY]
        # Propagate: descendants inherit this getter via the module in the singleton chain. It's
        # kept separate from any local getter (defined directly on the singleton class) so a
        # subsequent local assignment doesn't clobber the value propagated to descendants.
        rrf_propagated_module.define_method(name) { value }
      else
        # Local: `value` for this class only; descendants fall back through `super` to the
        # nearest propagated ancestor value, or the default.
        klass = self
        singleton_class.define_method(name) do
          if equal?(klass)
            value
          elsif defined?(super)
            super()
          else
            default
          end
        end
      end
    end
  end
end

#rrf_propagated_moduleObject

The per-class module holding this class's propagated attribute getters (and the default baseline). It's included into the singleton class so descendants inherit propagated values through the singleton-class chain, while local assignments—defined directly on the singleton class—take precedence for the class itself and can super() back into this module. Created lazily and memoized per class (instance variables aren't inherited, so each class gets its own).



219
220
221
# File 'lib/rest_framework/controller.rb', line 219

def rrf_propagated_module
  @rrf_propagated_module ||= Module.new.tap { |mod| singleton_class.include(mod) }
end