rest_framework 1.2.0 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,12 +4,19 @@
4
4
  class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators::BasePaginator
5
5
  def initialize(**kwargs)
6
6
  super
7
- # Exclude any `select` clauses since that would cause `count` to fail with a SQL `SyntaxError`.
8
- @count = @data.except(:select).count
9
7
  @page_size = self._page_size
8
+ @total_count = @controller.class.page_total_count
10
9
 
11
- @total_pages = @count / @page_size
12
- @total_pages += 1 if @count % @page_size != 0
10
+ # Compute the total count (and total pages) unless disabled. On large tables `page_total_count`
11
+ # can be set to `false` to skip this `COUNT(*)` over the whole filtered set; `next` is then
12
+ # derived by fetching one extra record in `get_page`.
13
+ if @total_count
14
+ # Exclude any `select` clauses, since that would cause `count` to fail with a SQL
15
+ # `SyntaxError`.
16
+ @count = @data.except(:select).count
17
+ @total_pages = @count / @page_size
18
+ @total_pages += 1 if @count % @page_size != 0
19
+ end
13
20
  end
14
21
 
15
22
  def _page_size
@@ -17,7 +24,7 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
17
24
 
18
25
  # Get from query param, if allowed.
19
26
  if param = @controller.class.page_size_query_param
20
- if raw = @controller.params[param].presence
27
+ if raw = @controller.request.query_parameters[param].presence
21
28
  parsed = raw.to_i
22
29
  page_size = parsed if parsed > 0
23
30
  end
@@ -38,9 +45,9 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
38
45
 
39
46
  # Get the page and return it so the caller can serialize it.
40
47
  def get_page(page_number = nil)
41
- # If page number isn't provided, infer from the params or use 1 as a fallback value.
48
+ # If page number isn't provided, infer from the query params or use 1 as a fallback value.
42
49
  unless page_number
43
- page_number = @controller&.params&.[](@controller.class.page_query_param&.to_sym)
50
+ page_number = @controller&.request&.query_parameters&.[](@controller.class.page_query_param)
44
51
  if page_number.blank?
45
52
  page_number = 1
46
53
  else
@@ -54,14 +61,23 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
54
61
 
55
62
  # Get the data page and return it so the caller can serialize the data in the proper format.
56
63
  page_index = @page_number - 1
57
- @data.limit(@page_size).offset(page_index * @page_size)
64
+ offset = page_index * @page_size
65
+
66
+ # Without a total count we can't derive `next` from `total_pages`, so detect whether a further
67
+ # page exists with a cheap existence check (a `LIMIT 1` past this page) instead of a full count.
68
+ unless @total_count
69
+ @has_next = @data.except(:select).offset(offset + @page_size).exists?
70
+ end
71
+
72
+ @data.limit(@page_size).offset(offset)
58
73
  end
59
74
 
60
75
  # Wrap the serialized page with appropriate metadata.
61
76
  def get_paginated_response(serialized_page)
62
77
  page_query_param = @controller.class.page_query_param
63
78
  base_params = @controller.request.query_parameters.symbolize_keys
64
- next_url = if @page_number < @total_pages
79
+ has_next = @total_count ? @page_number < @total_pages : @has_next
80
+ next_url = if has_next
65
81
  @controller.url_for({ **base_params, page_query_param => @page_number + 1 })
66
82
  end
67
83
  previous_url = if @page_number > 1
@@ -2,221 +2,85 @@ require "action_dispatch/routing/mapper"
2
2
 
3
3
  module ActionDispatch::Routing
4
4
  class Mapper
5
- # Internal interface to get the controller class from the name and current scope.
6
- def _get_controller_class(name, pluralize: true, fallback_reverse_pluralization: true)
7
- # Get class name.
8
- name = name.to_s.camelize # Camelize to leave plural names plural.
9
- name = name.pluralize if pluralize
10
- if name == name.pluralize
11
- name_reverse = name.singularize
12
- else
13
- name_reverse = name.pluralize
14
- end
15
- name += "Controller"
16
- name_reverse += "Controller"
17
-
18
- # Get scope for the class.
19
- if @scope[:module]
20
- mod = @scope[:module].to_s.camelize.constantize
21
- else
22
- mod = Object
23
- end
24
-
25
- # Convert class name to class.
26
- begin
27
- controller = mod.const_get(name)
28
- rescue NameError
29
- if fallback_reverse_pluralization
30
- reraise = false
31
-
32
- begin
33
- controller = mod.const_get(name_reverse)
34
- rescue NameError
35
- reraise = true
36
- end
37
-
38
- if reraise
39
- raise
40
- end
41
- else
42
- raise
43
- end
44
- end
45
-
46
- controller
5
+ # Resolve a controller class from a route name and the current scope. The name must match the
6
+ # controller exactly (camelized, plus `Controller`) there is no pluralization fallback.
7
+ def _rrf_controller_class(name)
8
+ mod = @scope[:module] ? @scope[:module].to_s.camelize.constantize : Object
9
+ mod.const_get("#{name.to_s.camelize}Controller")
47
10
  end
48
11
 
49
- # Internal interface for routing extra actions.
50
- def _route_extra_actions(actions, &block)
51
- parsed_actions = RESTFramework::Utils.parse_extra_actions(actions)
52
-
53
- parsed_actions.each do |action, config|
54
- config[:methods].each do |m|
55
- public_send(m, config[:path], action: action, **config[:kwargs])
12
+ # Route each action from a controller's action store.
13
+ def _rrf_route_actions(actions)
14
+ actions.each_value do |spec|
15
+ # Delegated actions keep their declared action name (so routing and OpenAPI show the real
16
+ # name); `method_for_action` redirects dispatch to `rrf_delegate`, which needs the scope.
17
+ kwargs = spec.kwargs
18
+ if !spec.builtin && spec.metadata&.[](:delegate)
19
+ kwargs = kwargs.merge(rrf_delegate_scope: spec.type)
56
20
  end
57
21
 
58
- # Record that this route is an extra action and any metadata associated with it.
59
- metadata = config[:metadata]
60
- key = "#{@scope[:path]}/#{config[:path]}"
61
- RESTFramework::EXTRA_ACTION_ROUTES.add(key)
62
- RESTFramework::ROUTE_METADATA[key] = metadata if metadata
63
-
64
- yield if block_given?
65
- end
66
- end
67
-
68
- # Internal core implementation of the `rest_resource(s)` router, both singular and plural.
69
- # @param default_singular [Boolean] the default plurality of the resource if the plurality is
70
- # not otherwise defined by the controller
71
- # @param name [Symbol] the resource name, from which path and controller are deduced by default
72
- def _rest_resources(default_singular, name, **kwargs, &block)
73
- controller = kwargs.delete(:controller) || name
74
- if controller.is_a?(Class)
75
- controller_class = controller
76
- else
77
- controller_class = self._get_controller_class(controller, pluralize: !default_singular)
78
- end
79
-
80
- # Set controller if it's not explicitly set.
81
- kwargs[:controller] = name unless kwargs[:controller]
82
-
83
- # Passing `unscoped: true` will prevent a nested resource from being scoped.
84
- unscoped = kwargs.delete(:unscoped)
85
-
86
- # Determine plural/singular resource.
87
- if !controller_class.singleton_controller.nil?
88
- singular = controller_class.singleton_controller
89
- else
90
- singular = default_singular
91
- end
92
- resource_method = singular ? :resource : :resources
93
-
94
- # Call either `resource` or `resources`, passing appropriate modifiers.
95
- skip = RESTFramework::Utils.get_skipped_builtin_actions(controller_class, singular)
96
- public_send(resource_method, name, except: skip, **kwargs) do
97
- if controller_class.respond_to?(:extra_member_actions)
98
- member do
99
- self._route_extra_actions(controller_class.extra_member_actions)
100
- end
22
+ spec.methods.each do |m|
23
+ public_send(m, spec.path, action: spec.name, **kwargs)
101
24
  end
102
25
 
103
- collection do
104
- # Route extra controller-defined actions.
105
- self._route_extra_actions(controller_class.extra_actions)
106
-
107
- # Route extra RRF-defined actions.
108
- RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
109
- next unless controller_class.method_defined?(action)
110
-
111
- [ methods ].flatten.each do |m|
112
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
113
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
114
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
115
- end
116
- end
117
-
118
- # Route bulk actions, if configured. These require a model and are gated by the `bulk`
119
- # attribute, and may be individually excluded via `excluded_actions`.
120
- if controller_class.model && controller_class.bulk
121
- bulk_exclude = controller_class.excluded_actions&.to_set || Set.new
122
- RESTFramework::RRF_BUILTIN_BULK_ACTIONS.each do |action, methods|
123
- next unless controller_class.method_defined?(action)
124
- next if bulk_exclude.include?(action)
26
+ # Record non-builtin (extra) actions and their metadata for the browsable API / OpenAPI.
27
+ next if spec.builtin
125
28
 
126
- [ methods ].flatten.each do |m|
127
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
128
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
129
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
130
- end
131
- end
132
- end
133
- end
134
-
135
- if unscoped
136
- yield if block_given?
137
- else
138
- scope(module: name, as: name) do
139
- yield if block_given?
140
- end
141
- end
29
+ key = "#{@scope[:path]}/#{spec.path}"
30
+ RESTFramework::EXTRA_ACTION_ROUTES.add(key)
31
+ RESTFramework::ROUTE_METADATA[key] = spec.metadata if spec.metadata
142
32
  end
143
33
  end
144
34
 
145
- # Public interface for creating singular RESTful resource routes.
146
- def rest_resource(*names, **kwargs, &block)
147
- names.each do |n|
148
- self._rest_resources(true, n, **kwargs, &block)
35
+ # Route one or more controllers from their action stores. Plural model controllers get
36
+ # collection/member scopes; singular and non-model controllers route everything at the root.
37
+ # Passing several names condenses simple routes into one call; per-name options (`path:`, `as:`,
38
+ # `controller:`, and a block) only apply to a single name.
39
+ def rest_route(*names, **kwargs, &block)
40
+ if names.size > 1 && (block || (kwargs.keys & [ :path, :as, :controller ]).any?)
41
+ raise ArgumentError, "rest_route: options and a block require a single name"
149
42
  end
150
- end
151
43
 
152
- # Public interface for creating plural RESTful resource routes.
153
- def rest_resources(*names, **kwargs, &block)
154
- names.each do |n|
155
- self._rest_resources(false, n, **kwargs, &block)
156
- end
44
+ names.each { |name| _rrf_rest_route(name, **kwargs, &block) }
157
45
  end
158
46
 
159
- # Route a controller without the default resourceful paths.
160
- def rest_route(name = nil, **kwargs, &block)
47
+ # Route a single controller from its action store.
48
+ def _rrf_rest_route(name, **kwargs)
161
49
  controller = kwargs.delete(:controller) || name
162
- route_root_to = kwargs.delete(:route_root_to)
163
50
  if controller.is_a?(Class)
164
51
  controller_class = controller
165
52
  else
166
- controller_class = self._get_controller_class(controller, pluralize: false)
53
+ controller_class = self._rrf_controller_class(controller)
167
54
  end
168
55
 
169
56
  # Set controller if it's not explicitly set.
170
57
  kwargs[:controller] = name unless kwargs[:controller]
171
58
 
172
- # Passing `unscoped: true` will prevent a nested resource from being scoped.
173
- unscoped = kwargs.delete(:unscoped)
174
-
175
- # Route actions using the resourceful router, but skip all builtin actions.
176
- public_send(:resource, name, only: [], **kwargs) do
177
- # Route a root for this resource.
178
- if route_root_to
179
- get("", action: route_root_to, as: "")
180
- end
181
-
182
- collection do
183
- # Route extra controller-defined actions.
184
- self._route_extra_actions(controller_class.extra_actions)
185
-
186
- # Route extra RRF-defined actions.
187
- RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
188
- next unless controller_class.method_defined?(action)
189
-
190
- [ methods ].flatten.each do |m|
191
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
192
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
193
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
194
- end
59
+ has_model = !!controller_class.model
60
+ singular = controller_class.singular
61
+ actions = controller_class.actions
62
+ member_actions = controller_class.member_actions
63
+
64
+ # Use `resources` (plural) for plural model controllers to get the member `:id` scope; use
65
+ # `resource` (singular) for everything else.
66
+ resource_method = (has_model && !singular) ? :resources : :resource
67
+
68
+ public_send(resource_method, name, only: [], **kwargs) do
69
+ if has_model
70
+ if singular
71
+ # Singular model controller: actions and member actions are the same.
72
+ self._rrf_route_actions(actions)
73
+ self._rrf_route_actions(member_actions)
74
+ else
75
+ # Plural model controller: route collection/member actions separately.
76
+ collection { self._rrf_route_actions(actions) }
77
+ member { self._rrf_route_actions(member_actions) }
195
78
  end
196
- end
197
-
198
- if unscoped
199
- yield if block_given?
200
79
  else
201
- scope(module: name, as: name) do
202
- yield if block_given?
203
- end
80
+ # Non-model controller: only actions (there is no member `:id` scope).
81
+ self._rrf_route_actions(actions)
204
82
  end
205
- end
206
- end
207
-
208
- # Route a controller's `#root` to '/' in the current scope/namespace, along with other actions.
209
- def rest_root(name = nil, **kwargs, &block)
210
- # By default, use RootController#root.
211
- root_action = kwargs.delete(:action) || :root
212
- controller = kwargs.delete(:controller) || name || :root
213
-
214
- # Remove path if name is nil (routing to the root of current namespace).
215
- unless name
216
- kwargs[:path] = ""
217
- end
218
83
 
219
- rest_route(controller, route_root_to: root_action, **kwargs) do
220
84
  yield if block_given?
221
85
  end
222
86
  end
@@ -1,6 +1,6 @@
1
1
  # This is a helper factory to wrap an ActiveModelSerializer to provide a `serialize` method which
2
2
  # accepts both collections and individual records. Use `.for` to build adapters.
3
- # :nocov:
3
+ # simplecov:disable
4
4
  class RESTFramework::Serializers::ActiveModelSerializerAdapterFactory
5
5
  def self.for(active_model_serializer)
6
6
  Class.new(active_model_serializer) do
@@ -14,7 +14,7 @@ class RESTFramework::Serializers::ActiveModelSerializerAdapterFactory
14
14
  end
15
15
  end
16
16
  end
17
- # :nocov:
17
+ # simplecov:enable
18
18
 
19
19
  # Alias for convenience.
20
20
  # rubocop:disable Layout/LineLength
@@ -16,7 +16,7 @@ class RESTFramework::Serializers::BaseSerializer
16
16
  end
17
17
 
18
18
  # Synonym for `serialize` for compatibility with `active_model_serializers`.
19
- # :nocov:
19
+ # simplecov:disable
20
20
  def serializable_hash(*args)
21
21
  self.serialize(*args)
22
22
  end
@@ -35,7 +35,7 @@ class RESTFramework::Serializers::BaseSerializer
35
35
  def associations(*args, **kwargs)
36
36
  []
37
37
  end
38
- # :nocov:
38
+ # simplecov:enable
39
39
  end
40
40
 
41
41
  # Alias for convenience.
@@ -111,25 +111,77 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
111
111
  controller_serializer || @controller.class.native_serializer_config
112
112
  end
113
113
 
114
- # Get the associations limit from the controller.
115
- def _associations_limit
116
- return @_associations_limit if defined?(@_associations_limit)
117
-
118
- limit = @controller&.class&.native_serializer_associations_limit
119
-
120
- # Extract the limit from the query parameters if it's set.
121
- if query_param = @controller&.class&.native_serializer_associations_limit_query_param
122
- if @controller.request.query_parameters.key?(query_param)
123
- query_limit = @controller.request.query_parameters[query_param].to_i
124
- if query_limit > 0
125
- limit = query_limit
126
- else
127
- limit = nil
128
- end
129
- end
130
- end
114
+ # The record cap for a collection association (`nil` = unlimited). The default is applied even
115
+ # when the feature is off, so responses are always bounded. `key?` (not `||`) reads the
116
+ # `field_config` override so an explicit `nil` there means unlimited/uncapped rather than falling
117
+ # back to the controller default.
118
+ def _effective_association_limit(association_name, field_config)
119
+ controller = @controller&.class
120
+
121
+ default = field_config.key?(:limit) ?
122
+ field_config[:limit] : controller&.association_limit
123
+ return default unless controller&.enable_association_queries
124
+
125
+ requested = self._requested_association_limit(association_name)
126
+ return default if requested.nil?
127
+
128
+ max = field_config.key?(:limit_max) ?
129
+ field_config[:limit_max] : controller.association_limit_max
130
+
131
+ # `all` means "as many as allowed" — the cap, or unlimited when the cap is `nil`.
132
+ return max if requested == :all
133
+
134
+ max ? [ requested, max ].min : requested
135
+ end
136
+
137
+ # `all`, `none`, and `0` all mean "no limit" — `0` is free to reuse as a sentinel since an
138
+ # association is dropped via `except`, not `limit=0`. Anything else must be a plain positive
139
+ # integer, so a nested/array param or junk can't drive the query.
140
+ def _requested_association_limit(association_name)
141
+ return nil unless prefix = @controller.class.association_query_prefix.presence
142
+
143
+ raw = @controller.request&.query_parameters&.[]("#{prefix}.#{association_name}.limit")
144
+ return nil unless raw.is_a?(String)
145
+
146
+ raw = raw.strip
147
+ return :all if raw.in?(%w[all none])
148
+ return nil unless raw.match?(/\A\d+\z/)
149
+
150
+ # The regex guarantees a non-negative integer, so anything but zero is a positive limit.
151
+ limit = raw.to_i
152
+ limit.zero? ? :all : limit
153
+ end
154
+
155
+ # The fields to serialize for association `f`, honoring a consumer's `?<prefix>.<f>.fields=`
156
+ # request bounded by the allowlist. Only active when the feature is enabled.
157
+ def _effective_association_fields(association_name, ref, field_config)
158
+ default_fields = field_config[:fields]
159
+ return default_fields unless @controller&.class&.enable_association_queries
160
+ return default_fields if ref.polymorphic? # no single target class to bound the request
161
+
162
+ requested = self._requested_association_fields(association_name)
163
+ return default_fields if requested.blank?
164
+
165
+ # The primary key is always kept so records stay identifiable. `_valid_association_field?` is
166
+ # the last line of defense against leaking a non-serializable or nested-association field.
167
+ allowed = (default_fields + (field_config[:requestable_fields] || [])).uniq
168
+ valid = (requested & allowed).select { |sf| self._valid_association_field?(ref, sf) }
169
+ (Array(ref.klass.primary_key).map(&:to_s) + valid).uniq
170
+ end
171
+
172
+ # Only a plain scalar string is honored, so a nested-hash/array param can't reach `where`/`split`.
173
+ def _requested_association_fields(association_name)
174
+ return nil unless prefix = @controller.class.association_query_prefix.presence
175
+
176
+ raw = @controller.request&.query_parameters&.[]("#{prefix}.#{association_name}.fields")
177
+ return nil unless raw.is_a?(String)
178
+
179
+ raw.split(",").map { |x| x.strip.presence }.compact
180
+ end
131
181
 
132
- @_associations_limit = limit
182
+ def _valid_association_field?(ref, field)
183
+ field.in?(ref.klass.column_names) ||
184
+ (ref.klass.method_defined?(field) && !ref.klass.reflect_on_association(field.to_sym))
133
185
  end
134
186
 
135
187
  # Get a serializer configuration from the controller. `@controller` and `@model` must be set.
@@ -158,7 +210,7 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
158
210
  elsif ref = reflections[f]
159
211
  sub_columns = []
160
212
  sub_methods = []
161
- field_config[:sub_fields].each do |sf|
213
+ self._effective_association_fields(f, ref, field_config).each do |sf|
162
214
  if !ref.polymorphic? && sf.in?(ref.klass.column_names)
163
215
  sub_columns << sf
164
216
  else
@@ -169,9 +221,9 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
169
221
 
170
222
  # Apply certain rules regarding collection associations.
171
223
  if ref.collection?
172
- # If we need to limit the number of serialized association records, then dynamically add a
173
- # serializer method to do so.
174
- if limit = self._associations_limit
224
+ # A finite limit needs a per-record `.limit` query, since eager `includes` can't cap rows
225
+ # per parent; an unlimited (`nil`) association falls through to `includes` preloading.
226
+ if limit = self._effective_association_limit(f, field_config)
175
227
  serializer_methods[f] = f
176
228
  self.define_singleton_method(f) do |record|
177
229
  next record.send(f).limit(limit).as_json(**sub_config)
@@ -181,7 +233,7 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
181
233
  #
182
234
  # # Even though we use a serializer method, if the count will later be added, then put
183
235
  # # this field into the includes_map.
184
- # if @controller.class.native_serializer_include_associations_count
236
+ # if @controller.class.include_association_count
185
237
  # includes_map[f] = f.to_sym
186
238
  # end
187
239
  else
@@ -190,7 +242,7 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
190
242
  end
191
243
 
192
244
  # If we need to include the association count, then add it here.
193
- if @controller.class.native_serializer_include_associations_count
245
+ if @controller.class.include_association_count
194
246
  method_name = "#{f}.count"
195
247
  serializer_methods[method_name] = method_name
196
248
  self.define_singleton_method(method_name) do |record|
@@ -1,59 +1,6 @@
1
1
  module RESTFramework::Utils
2
2
  HTTP_VERB_ORDERING = %w[GET POST PUT PATCH DELETE OPTIONS HEAD]
3
3
 
4
- # Convert `extra_actions` hash to a consistent format: `{path:, methods:, metadata:, kwargs:}`.
5
- def self.parse_extra_actions(extra_actions)
6
- (extra_actions || {}).map { |k, v|
7
- path = k
8
- kwargs = {}
9
-
10
- # Convert structure to path/methods/kwargs.
11
- if v.is_a?(Hash)
12
- # Symbolize keys (which also makes a copy so we don't mutate the original).
13
- v = v.symbolize_keys
14
-
15
- # Cast method/methods to an array.
16
- methods = [ v.delete(:methods), v.delete(:method) ].flatten.compact
17
-
18
- # Override path if it's provided.
19
- if v.key?(:path)
20
- path = v.delete(:path)
21
- end
22
-
23
- # Extract metadata, if provided.
24
- metadata = v.delete(:metadata).presence
25
-
26
- # Pass any further kwargs to the underlying Rails interface.
27
- kwargs = v
28
- else
29
- methods = [ v ].flatten
30
- end
31
-
32
- next [
33
- k,
34
- {
35
- path: path,
36
- methods: methods,
37
- metadata: metadata,
38
- kwargs: kwargs,
39
- }.compact,
40
- ]
41
- }.to_h
42
- end
43
-
44
- def self.get_skipped_builtin_actions(controller_class, singular)
45
- candidates = (
46
- RESTFramework::BUILTIN_ACTIONS.keys - (singular ? [ :index ] : [])
47
- ) + RESTFramework::BUILTIN_MEMBER_ACTIONS.keys
48
-
49
- return candidates unless controller_class.model
50
-
51
- exclude = controller_class.excluded_actions&.to_set || Set.new
52
- candidates.reject do |action|
53
- controller_class.method_defined?(action) && !exclude.include?(action)
54
- end
55
- end
56
-
57
4
  # Get the first route pattern which matches the given request.
58
5
  def self.get_request_route(application_routes, request)
59
6
  # Prefer the route already resolved by the router to avoid an expensive `recognize` call. This
@@ -172,9 +119,8 @@ module RESTFramework::Utils
172
119
  parsed_fields.map(&:to_s)
173
120
  end
174
121
 
175
- # Get the fields for a given model, including not just columns (which includes
176
- # foreign keys), but also associations. Note that we always return an array of
177
- # strings, not symbols.
122
+ # Get the fields for a given model, including not just columns (which includes foreign keys), but
123
+ # also associations. Note that we always return an array of strings, not symbols.
178
124
  def self.fields_for(model, exclude_associations:, action_text:, active_storage:)
179
125
  foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
180
126
  base_fields = model.column_names.reject { |c| c.in?(foreign_keys) }
@@ -208,23 +154,23 @@ module RESTFramework::Utils
208
154
  base_fields + associations + atf + asf
209
155
  end
210
156
 
211
- # Get the sub-fields that may be serialized and filtered/ordered for a reflection.
212
- def self.sub_fields_for(ref)
157
+ # Get the association's fields that may be serialized and filtered/ordered for a reflection.
158
+ def self.association_fields_for(ref)
213
159
  if !ref.polymorphic? && model = ref.klass
214
- sub_fields = [ model.primary_key ].flatten.compact
160
+ fields = [ model.primary_key ].flatten.compact
215
161
  label_fields = RESTFramework.config.label_fields
216
162
 
217
163
  # Preferably find a database column to use as label.
218
164
  if match = label_fields.find { |f| f.in?(model.column_names) }
219
- return sub_fields + [ match ]
165
+ return fields + [ match ]
220
166
  end
221
167
 
222
168
  # Otherwise, find a method.
223
169
  if match = label_fields.find { |f| model.method_defined?(f) }
224
- return sub_fields + [ match ]
170
+ return fields + [ match ]
225
171
  end
226
172
 
227
- return sub_fields
173
+ return fields
228
174
  end
229
175
 
230
176
  [ "id", "name" ]
@@ -243,6 +189,30 @@ module RESTFramework::Utils
243
189
  nil
244
190
  end
245
191
 
192
+ # Find the REST controller for `model` at the same namespace level as `current_controller`, e.g.
193
+ # `Api::Demo::MoviesController` + `Genre` => `Api::Demo::GenresController`, or `nil` if none. The
194
+ # `model` match guards against trusting a same-named controller for a different model.
195
+ def self.controller_for_model(current_controller, model)
196
+ return nil unless model && (base_name = current_controller.name)
197
+
198
+ namespace = base_name.deconstantize
199
+ model_name = model.model_name
200
+
201
+ # Plural for a collection controller, singular for a singular-resource one.
202
+ [ model_name.plural, model_name.singular ].each do |name|
203
+ candidate_name = "#{name.camelize}Controller"
204
+ candidate_name = "#{namespace}::#{candidate_name}" if namespace.present?
205
+
206
+ candidate = candidate_name.safe_constantize
207
+ next unless candidate.is_a?(Class) && candidate.include?(RESTFramework::Controller)
208
+ next unless candidate.model == model
209
+
210
+ return candidate
211
+ end
212
+
213
+ nil
214
+ end
215
+
246
216
  # Wrap a serializer with an adapter if it is an ActiveModel::Serializer.
247
217
  def self.wrap_ams(s)
248
218
  if defined?(ActiveModel::Serializer) && (s < ActiveModel::Serializer)
@@ -251,12 +221,4 @@ module RESTFramework::Utils
251
221
 
252
222
  s
253
223
  end
254
-
255
- # Used for deprecated mixins that rely on model being determined from the controller name.
256
- def self.get_model(controller_class)
257
- begin
258
- controller_class.name.demodulize.chomp("Controller").singularize.constantize
259
- rescue NameError
260
- end
261
- end
262
224
  end