rest_framework 1.1.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -41
  3. data/VERSION +1 -1
  4. data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
  5. data/lib/rest_framework/controller/actions.rb +256 -0
  6. data/lib/rest_framework/controller/bulk.rb +247 -32
  7. data/lib/rest_framework/controller/crud.rb +13 -9
  8. data/lib/rest_framework/controller/openapi.rb +12 -8
  9. data/lib/rest_framework/controller.rb +275 -164
  10. data/lib/rest_framework/errors.rb +70 -3
  11. data/lib/rest_framework/filters/base_filter.rb +10 -0
  12. data/lib/rest_framework/filters/ordering_filter.rb +41 -23
  13. data/lib/rest_framework/filters/query_filter.rb +24 -5
  14. data/lib/rest_framework/filters/search_filter.rb +8 -4
  15. data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
  16. data/lib/rest_framework/routers.rb +52 -182
  17. data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
  18. data/lib/rest_framework/serializers/base_serializer.rb +2 -2
  19. data/lib/rest_framework/serializers/native_serializer.rb +78 -24
  20. data/lib/rest_framework/utils.rb +39 -70
  21. data/lib/rest_framework/version.rb +8 -5
  22. data/lib/rest_framework.rb +7 -41
  23. metadata +5 -12
  24. data/lib/rest_framework/errors/base_error.rb +0 -5
  25. data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
  26. data/lib/rest_framework/generators/controller_generator.rb +0 -64
  27. data/lib/rest_framework/generators.rb +0 -4
  28. data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
  29. data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
  30. data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
  31. data/lib/rest_framework/mixins.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb2a69a51a24192200122bf40a9218e15e1f0d696bfdd63bb8f3aed9f43f82c
4
- data.tar.gz: 3b630e5e5f8ec28096e245bc8ced22d5a63423d52799983543c1616b636999d8
3
+ metadata.gz: 6551a744b8574c83f8b5a482e32337e2ea554e4c572baf7c207af3bcda26fb38
4
+ data.tar.gz: d26a407bcf834d76a6f1d63e7ca22eafe05cd90197a50d53b016e178a2d92dd4
5
5
  SHA512:
6
- metadata.gz: d0eae7105c8fe37a710ac56c9ba0c4df9b0f0663883d117123af6e778735c63117ca58d82e60e260e353aefcc9536e996848341a6537ca963d764b498dfba979
7
- data.tar.gz: 72e217a7d2c9bed976d6801f333be3a97ebdedf1998b07fa4a4594c48256919dcd1cd4b48030582fc75bdd9e2e6d002dc29df931e217ab78cd97b87872f65c78
6
+ metadata.gz: b66138a6abd154cc8df980b6a533273dc5f6e5fd07bcb6d381e73254018cc065a1a2b01c6ae086f0eb6274a11501624a80f56ffd0f58657aa4f7c62145b74f7c
7
+ data.tar.gz: 8222dd29fb6de099e6f555438dcf1328a251a28f20076837176938ebc555a4c1f3d52df0acc77507150f6217058ce8f9dfa53f003641c1ab0e3914d515505348
data/README.md CHANGED
@@ -3,7 +3,6 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/rest_framework.svg)](https://badge.fury.io/rb/rest_framework)
4
4
  [![Pipeline](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml/badge.svg)](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml)
5
5
  [![Coverage](https://coveralls.io/repos/github/gregschmit/rails-rest-framework/badge.svg?branch=master)](https://coveralls.io/github/gregschmit/rails-rest-framework?branch=master)
6
- [![Maintainability](https://api.codeclimate.com/v1/badges/ba5df7706cb544d78555/maintainability)](https://codeclimate.com/github/gregschmit/rails-rest-framework/maintainability)
7
6
 
8
7
  A framework for DRY RESTful APIs in Ruby on Rails.
9
8
 
@@ -44,49 +43,52 @@ To add REST framework features to a controller, include the `Controller` module:
44
43
  class ApiController < ApplicationController
45
44
  include RESTFramework::Controller
46
45
 
47
- # Here is where you can set configuration class attributes that will propagate to child
48
- # controllers.
49
-
50
- # Setting up a paginator class here makes more sense than defining it on every child controller.
51
- self.paginator_class = RESTFramework::PageNumberPaginator
52
- self.page_size = 30
46
+ # Assignments are local by default; wrap shared config in `propagate` so child controllers inherit
47
+ # it. Settings you want every resource to share belong here rather than on each child controller.
48
+ propagate do
49
+ self.page_size = 30
50
+ self.max_page_size = 100
51
+ end
53
52
  end
54
53
  ```
55
54
 
55
+ > **Note:** Configuration assignments are **local by default** — `self.x = value` sets `x` on that
56
+ > controller alone and does not propagate to subclasses. To share a setting with every descendant
57
+ > (pagination, filter backends, serializer config, and so on), wrap the assignment in a
58
+ > `propagate` block on a base controller.
59
+
56
60
  Here is what the directory structure might look like for resource controllers:
57
61
 
58
62
  ```text
59
63
  controllers/
60
64
  ├─ api_controller.rb
61
65
  └─ api/
62
- ├─ root_controller.rb
63
66
  ├─ movies_controller.rb
64
67
  └─ users_controller.rb
65
68
  ```
66
69
 
67
- ### Root Controller
70
+ ### Serving the Root API Index
68
71
 
69
- It is typically a good pattern for the root of your API to have a dedicated `Api::RootController`
70
- outside the inheritance chain of your other API controllers, so that you can define actions on the
71
- root without them propagating to child controllers, and so you can set global configuration on the
72
- `ApiController`.
72
+ A controller without a `model` renders its `index_content` at its index path, which serves as the
73
+ API root. Because declared actions are local by default (they don't propagate to subclasses), you
74
+ can serve the index and any root-specific extra actions straight from your API's base
75
+ controller:
73
76
 
74
77
  ```ruby
75
- class Api::RootController < ApiController
76
- self.extra_actions = {test: :get}
77
-
78
- # The root action is routed by `rest_root`.
79
- def root
80
- render(
81
- api: {
82
- message: "Welcome to the API.",
83
- how_to_authenticate: <<~END.lines.map(&:strip).join(" "),
84
- You can use this API with your normal login session. Otherwise, you can insert your API
85
- key into a Bearer Authorization header, or into the URL parameters with the name
86
- `api_key`.
87
- END
88
- },
89
- )
78
+ class ApiController < ApplicationController
79
+ include RESTFramework::Controller
80
+
81
+ add_action(:test, :get)
82
+
83
+ # Rendered at the `/api` root. Defaults to the controller's `description`.
84
+ def index_content
85
+ {
86
+ message: "Welcome to the API.",
87
+ how_to_authenticate: <<~END.lines.map(&:strip).join(" "),
88
+ You can use this API with your normal login session. Otherwise, you can insert your API key
89
+ into a Bearer Authorization header, or into the URL parameters with the name `api_key`.
90
+ END
91
+ }
90
92
  end
91
93
 
92
94
  def test
@@ -97,14 +99,15 @@ end
97
99
 
98
100
  ### Resource Controllers
99
101
 
100
- Other API controllers can be associated to a resource/model by setting the `model` class attribute.
102
+ Other API controllers can be associated to a resource/model by setting `model`, e.g.
103
+ `self.model = Movie`.
101
104
 
102
105
  ```ruby
103
106
  class Api::MoviesController < ApiController
104
107
  self.model = Movie # Automatically routes the standard CRUD actions for this controller.
105
108
  self.bulk = true # Enables bulk create/update/destroy actions for this controller.
106
109
  self.fields = [:id, :name, :release_date, :enabled]
107
- self.extra_member_actions = {first: :get}
110
+ add_action(:first, :get, type: :member)
108
111
 
109
112
  def first
110
113
  # Always use bang methods, since the framework will rescue `RecordNotFound` and return a
@@ -123,30 +126,29 @@ to include or exclude fields rather than defining them manually:
123
126
 
124
127
  ```ruby
125
128
  class Api::UsersController < ApiController
126
- self.fields = {include: [:calculated_popularity], exclude: [:impersonation_token]}
129
+ # Include a method `popularity` and exclude the `impersonation_token` column.
130
+ self.fields = {include: [:popularity], exclude: [:impersonation_token]}
127
131
 
128
132
  # You can even disable some of the builtin actions. For example, this effectively makes the
129
133
  # resource read-only:
130
- self.excluded_actions = [:create, :update, :destroy, :update_all, :destroy_all]
134
+ remove_actions(:create, :update, :destroy, :update_all, :destroy_all)
131
135
  end
132
136
  ```
133
137
 
134
138
  ### Routing
135
139
 
136
- Use `rest_route` for non-resourceful controllers, or `rest_resource` / `rest_resources` resourceful
137
- routers. These routers add some features to the Rails builtin `resource`/`resources` routers, such
138
- as automatically routing extra actions defined on the controller. To route the root, use
139
- `rest_root`.
140
+ Use `rest_route` to route any controller. It wraps Rails' `resource` / `resources` routers, picking
141
+ `resources` for a plural model controller and `resource` otherwise, and automatically routes the
142
+ controller's built-in and extra actions. A controller with a `model` gets the full CRUD set; a
143
+ modelless controller is routed at its root (its `index`, which renders `index_content`).
140
144
 
141
145
  ```ruby
142
146
  Rails.application.routes.draw do
143
- # If you wanted to route actions from the `ApiController`, then you would use this:
144
- # rest_root :api # Will find `api_controller` and route the `root` action to '/api'.
147
+ rest_route :api # `ApiController` serves the `/api` root.
145
148
 
146
149
  namespace :api do
147
- rest_root # Will route `Api::RootController#root` to '/' in this namespace ('/api').
148
- rest_resources :movies
149
- rest_resources :users
150
+ rest_route :movies
151
+ rest_route :users
150
152
  end
151
153
  end
152
154
  ```
@@ -163,3 +165,67 @@ web server and the job queue, which serves the test app and coverage/brakeman re
163
165
  - Test App: [http://127.0.0.1:3000](http://127.0.0.1:3000)
164
166
  - API: [http://127.0.0.1:3000/api](http://127.0.0.1:3000/api)
165
167
  - Reports: [http://127.0.0.1:3000/reports](http://127.0.0.1:3000/reports)
168
+
169
+ ## Version 2
170
+
171
+ Version 2 is a substantial overhaul. The highlights below cover the major additions and behavior
172
+ changes; the migration checklist that follows walks through updating an existing app.
173
+
174
+ ### New Features & Improvements
175
+
176
+ - **Simpler setup** — a single `include RESTFramework::Controller` replaces the per-type `*Mixin`
177
+ modules.
178
+ - **Local-by-default configuration** — assignments stay on the controller they're set on; wrap
179
+ shared settings in a `propagate` block to hand them down. Config no longer leaks silently onto
180
+ every resource.
181
+ - **Declarative actions** — `add_action` / `remove_action` declare extra routes with an explicit
182
+ `member` / `collection` scope and per-declaration propagation, and can disable built-ins too,
183
+ replacing the `extra_actions` config hashes.
184
+ - **Unified routing** — one `rest_route` (accepting several names) replaces `rest_resource` /
185
+ `rest_resources` / `rest_root`; a modelless controller serves the API root from its
186
+ `index_content`.
187
+ - **Action delegation** — mark an action `metadata: { delegate: true }` to dispatch it to a model
188
+ class method (collection) or record method (member), passing query params through as args/kwargs.
189
+ - **Consumer-driven association queries** (opt-in via `enable_association_queries`) — clients can
190
+ request extra fields for a serialized association (`?associations.<name>.fields=a,b,c`) and raise
191
+ its per-request record limit (`?associations.<name>.limit=N` or `all`), both bounded by a
192
+ per-association allowlist so an association never exposes more than its own endpoint would.
193
+ - **`page_total_count`** — skip the `COUNT` query so pagination stays fast on very large tables.
194
+
195
+ ### Behavior Changes
196
+
197
+ - **Pagination is on by default** (`PageNumberPaginator`, page size 20), so `index` responses are
198
+ bounded out of the box; opt out per controller with `paginator_class = nil`.
199
+ - **Ordering and pagination read from the query string only**, never the request body.
200
+ - **A `find_by` on a non-permitted field returns `404`** rather than matching a virtual or
201
+ serialized field.
202
+ - **Delegated actions wrap their result under a `return` key**, and raise on a missing or non-public
203
+ target instead of silently 404-ing.
204
+ - **Removed** `rrf_finalize` and the `auto_finalize` / `freeze_config` hooks.
205
+ - **Security hardening** — per-element read-only stripping on bulk writes, an ordering-oracle fix,
206
+ sanitized `StatementInvalid` messages, and safer query-filter parsing.
207
+
208
+ ### Migrating from Older Versions
209
+
210
+ See the guide for details on each item.
211
+
212
+ - [ ] Replace the `*Mixin` modules with `include RESTFramework::Controller` on the core API
213
+ controller.
214
+ - [ ] Set `self.model = ...` on every resource controller (it's no longer inferred from the name).
215
+ - [ ] Wrap inherited config in a `propagate` block — assignments are now local by default.
216
+ - [ ] Convert `extra_actions` / `extra_member_actions` hashes to `add_action` / `remove_action`.
217
+ - [ ] Render custom actions with `render(api: ...)`, replacing the older `api_response(...)` /
218
+ `render_api(...)`.
219
+ - [ ] Replace `rest_resource` / `rest_resources` / `rest_root` with `rest_route`.
220
+ - Fold any dedicated root controller into the namespace's base controller, which now serves the
221
+ root via `index_content` (the standalone `root` action and `rest_root` are gone).
222
+ - [ ] Rename `singleton_controller` → `singular`.
223
+ - [ ] Remove `rrf_finalize` calls and the `auto_finalize` / `freeze_config` config (all gone).
224
+ - [ ] Expect paginated `index` responses by default (`self.paginator_class = nil` restores a bare
225
+ array).
226
+ - [ ] Rename config: `sub_fields` → `fields`, `native_serializer_associations_limit[_max]` →
227
+ `association_limit[_max]`, `native_serializer_include_associations_count` →
228
+ `include_association_count`; the `?associations_limit=N` param is gone.
229
+ - [ ] Note client-visible behavior changes: delegated actions wrap their result under a `return`
230
+ key; a non-permitted `find_by` returns `404`; `update_all` / `destroy_all` are plural-only;
231
+ ordering/pagination read from the query string only.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 2.0.0.beta1
@@ -18,7 +18,7 @@
18
18
  scope: "",
19
19
  local: true,
20
20
  }.compact) do |form| %>
21
- <% controller.get_fields.map(&:to_s).each do |f| %>
21
+ <% controller.get_fields.each do |f| %>
22
22
  <%
23
23
  # Don't provide form fields for associations or read-only fields.
24
24
  cfg = controller.class.field_configuration[f]
@@ -0,0 +1,256 @@
1
+ module RESTFramework::Controller
2
+ # Value object describing a routed action (builtin or user-declared).
3
+ ActionSpec = Struct.new(
4
+ :name, :type, :methods, :path, :metadata, :builtin, :kwargs, keyword_init: true
5
+ )
6
+
7
+ # Builtin actions keyed by name, each gated by a `condition` so only applicable ones surface in
8
+ # `actions` / `member_actions`. They route at the base path (`""`) of their scope.
9
+ RRF_BUILTIN_COLLECTION_ACTIONS = {
10
+ index: { methods: [ :get ], condition: ->(c) { !c.singular }, kwargs: { as: "" } },
11
+ create: { methods: [ :post ], condition: ->(c) { c.model } },
12
+ update_all: {
13
+ methods: [ :put, :patch ],
14
+ condition: ->(c) { c.model && c.bulk && !c.singular },
15
+ kwargs: { anchor: true },
16
+ },
17
+ destroy_all: {
18
+ methods: [ :delete ],
19
+ condition: ->(c) { c.model && c.bulk && !c.singular },
20
+ kwargs: { anchor: true },
21
+ },
22
+ options: { methods: [ :options ], condition: ->(_c) { true }, kwargs: { anchor: true } },
23
+ }.freeze
24
+ RRF_BUILTIN_MEMBER_ACTIONS = {
25
+ show: { methods: [ :get ], condition: ->(c) { c.model } },
26
+ update: { methods: [ :put, :patch ], condition: ->(c) { c.model } },
27
+ destroy: { methods: [ :delete ], condition: ->(c) { c.model } },
28
+ }.freeze
29
+
30
+ module ClassMethods
31
+ # Per-class action deltas (internal). Public because composition reads them off ancestors.
32
+ def _rrf_action_adds(type)
33
+ if type == :member
34
+ @_rrf_member_action_adds ||= {}
35
+ else
36
+ @_rrf_collection_action_adds ||= {}
37
+ end
38
+ end
39
+
40
+ def _rrf_action_removes(type)
41
+ if type == :member
42
+ @_rrf_member_action_removes ||= {}
43
+ else
44
+ @_rrf_collection_action_removes ||= {}
45
+ end
46
+ end
47
+
48
+ # Route an action, choosing the collection/member scope. Pass `type:` on a plural model
49
+ # controller, where the scopes differ; elsewhere the scope is implied — a singular controller's
50
+ # sole resource is a member (so `delegate` targets the record), a modelless one has only a
51
+ # collection — and `type:` warns as unnecessary (unless the action is delegated).
52
+ def add_action(name, methods, type: nil, **opts)
53
+ singular_model = self.model && self.singular
54
+
55
+ if self.model && !self.singular
56
+ _rrf_warn_action(name, methods, type, :ambiguous) if type.nil?
57
+ elsif singular_model && type && !opts[:metadata]&.[](:delegate)
58
+ _rrf_warn_action(name, methods, type, :redundant)
59
+ end
60
+
61
+ _rrf_add_action(type || (singular_model ? :member : :collection), name, methods, **opts)
62
+ end
63
+
64
+ # Remove an action from routing (including builtins). With no `type:`, both scopes are removed —
65
+ # keeping removal simple, and letting `propagate:` carry it to model descendants where member
66
+ # scope matters. Pass `type:` to target a single scope.
67
+ def remove_action(name, type: nil, propagate: false)
68
+ if type
69
+ _rrf_remove_action(type, name, propagate: propagate)
70
+ else
71
+ _rrf_remove_action(:collection, name, propagate: propagate)
72
+ _rrf_remove_action(:member, name, propagate: propagate)
73
+ end
74
+ end
75
+
76
+ def remove_actions(*names, type: nil, propagate: false)
77
+ names.each { |name| remove_action(name, type: type, propagate: propagate) }
78
+ end
79
+
80
+ # Source of truth: the effective collection / member actions (builtins + declared, composed
81
+ # across the inheritance chain), as an ordered `Hash{Symbol => ActionSpec}`.
82
+ def actions
83
+ _rrf_compose_actions(:collection)
84
+ end
85
+
86
+ def member_actions
87
+ _rrf_compose_actions(:member)
88
+ end
89
+
90
+ private
91
+
92
+ def _rrf_add_action(type, name, methods, path: nil, metadata: nil, propagate: false, **kwargs)
93
+ name = name.to_sym
94
+ spec = ActionSpec.new(
95
+ name: name,
96
+ type: type,
97
+ methods: Array(methods).map(&:to_sym),
98
+ path: (path || name).to_s,
99
+ metadata: metadata,
100
+ builtin: false,
101
+ kwargs: kwargs,
102
+ )
103
+ _rrf_action_removes(type).delete(name)
104
+ _rrf_action_adds(type)[name] = { spec: spec, propagate: _rrf_normalize_propagate(propagate) }
105
+ end
106
+
107
+ def _rrf_remove_action(type, name, propagate: false)
108
+ name = name.to_sym
109
+ _rrf_action_adds(type).delete(name)
110
+ _rrf_action_removes(type)[name] = { propagate: _rrf_normalize_propagate(propagate) }
111
+ end
112
+
113
+ # Normalize `propagate:` to `false` (local), `true` (self + descendants), or `:exclude_self`
114
+ # (descendants only). Non-standard values warn: `nil` becomes `false`, anything else truthy
115
+ # becomes `true`.
116
+ def _rrf_normalize_propagate(value)
117
+ case value
118
+ when false
119
+ false
120
+ when true, :exclude_self
121
+ value
122
+ when nil
123
+ Rails.logger.warn("RRF: `propagate: nil` is nonstandard; treating as `false`.")
124
+ false
125
+ else
126
+ Rails.logger.warn("RRF: invalid `propagate:` value #{value.inspect}; treating as `true`.")
127
+ true
128
+ end
129
+ end
130
+
131
+ # Whether an entry with the given `propagate`, declared on some class, reaches the controller
132
+ # we're composing for. `is_self` is true when that class is the controller itself.
133
+ def _rrf_reaches?(propagate, is_self)
134
+ case propagate
135
+ when :exclude_self
136
+ !is_self
137
+ when true
138
+ true
139
+ else # false
140
+ is_self
141
+ end
142
+ end
143
+
144
+ def _rrf_builtins(type)
145
+ type == :member ? RRF_BUILTIN_MEMBER_ACTIONS : RRF_BUILTIN_COLLECTION_ACTIONS
146
+ end
147
+
148
+ def _rrf_compose_actions(type)
149
+ effective = {}
150
+
151
+ # 1. Seed with the builtins whose condition holds for this controller.
152
+ _rrf_builtins(type).each do |name, cfg|
153
+ next unless cfg[:condition].call(self)
154
+
155
+ effective[name] = ActionSpec.new(
156
+ name: name,
157
+ type: type,
158
+ methods: cfg[:methods],
159
+ path: "",
160
+ metadata: nil,
161
+ builtin: true,
162
+ kwargs: cfg[:kwargs] || {},
163
+ )
164
+ end
165
+
166
+ # 2. Walk RRF ancestors from farthest to nearest (ending at self), applying each class's
167
+ # removes then adds that reach us. Removes-before-adds lets a subclass re-add something an
168
+ # ancestor removed; later (nearer) classes win.
169
+ _rrf_action_chain.each do |klass|
170
+ is_self = klass.equal?(self)
171
+
172
+ klass._rrf_action_removes(type).each do |name, remove|
173
+ effective.delete(name) if _rrf_reaches?(remove[:propagate], is_self)
174
+ end
175
+
176
+ klass._rrf_action_adds(type).each do |name, add|
177
+ effective[name] = add[:spec] if _rrf_reaches?(add[:propagate], is_self)
178
+ end
179
+ end
180
+
181
+ effective
182
+ end
183
+
184
+ def _rrf_action_chain
185
+ self.ancestors.select { |a| a.is_a?(Class) && a.respond_to?(:_rrf_action_adds) }.reverse
186
+ end
187
+
188
+ # Build the `type:` warning for `add_action`, echoing the call so its source is easy to find.
189
+ def _rrf_warn_action(name, methods, type, reason)
190
+ type_arg = type ? ", type: #{type.inspect}" : ""
191
+ sig = "add_action(#{name.inspect}, #{methods.inspect}#{type_arg})"
192
+
193
+ detail =
194
+ if reason == :ambiguous
195
+ "needs an explicit `type:` (`:collection` or `:member`); collection and member route " \
196
+ "differently on a plural model controller"
197
+ else
198
+ "has an unnecessary `type:`; member and collection route the same path on a singular " \
199
+ "model controller"
200
+ end
201
+
202
+ Rails.logger.warn("RRF: `#{sig}` #{detail}.")
203
+ end
204
+ end
205
+
206
+ # Delegated actions (`metadata: { delegate: true }`) dispatch through `rrf_delegate` while keeping
207
+ # their declared name for routing and introspection. The route marks itself delegated with the
208
+ # `rrf_delegate_scope` path parameter, so dispatch is route-based and never collides with a
209
+ # same-named method.
210
+ def method_for_action(action_name)
211
+ request&.path_parameters&.key?(:rrf_delegate_scope) ? "rrf_delegate" : super
212
+ end
213
+
214
+ # Dispatch a delegated action to the model class (collection) or the record (member). Its declared
215
+ # name is `action_name`; its scope comes from the route.
216
+ def rrf_delegate
217
+ target = self.action_name.to_sym
218
+ member = request.path_parameters[:rrf_delegate_scope].to_s == "member"
219
+ receiver = member ? self.get_record : self.class.model
220
+
221
+ # Delegation targets must be public methods. Anything else — a private/protected method, or a
222
+ # name that resolves to nothing — is developer misconfiguration (a typo, a missing method, or a
223
+ # method that should be public), so raise loudly rather than dispatch or mask it as a 404.
224
+ unless receiver.respond_to?(target)
225
+ raise RESTFramework::DelegatedMethodError.new(receiver, target)
226
+ end
227
+
228
+ parameters = receiver.method(target).parameters
229
+ query = request.query_parameters
230
+
231
+ # Positional arguments come from the reserved `args` param: a scalar becomes a single
232
+ # positional, an array is splatted as all of them.
233
+ args = query.key?("args") ? Array.wrap(query["args"]) : []
234
+
235
+ # Remaining query params splat as kwargs only when the method accepts arbitrary keywords
236
+ # (`**opts`); scan all params so a trailing block (`&blk`) doesn't hide the `:keyrest`.
237
+ kwargs = if parameters.any? { |type, _| type == :keyrest }
238
+ query.except("args").symbolize_keys
239
+ else
240
+ {}
241
+ end
242
+
243
+ result = receiver.public_send(target, *args, **kwargs)
244
+
245
+ # Serialize Active Record return values through the framework serializer (honoring field
246
+ # exclusions); `render_api` only does this for top-level payloads, and we nest under `return`.
247
+ if result.is_a?(ActiveRecord::Base) || result.is_a?(ActiveRecord::Relation)
248
+ result = self.serialize(result)
249
+ end
250
+
251
+ # Wrap the result under a `return` key rather than rendering it at the top level: the method may
252
+ # return `nil` (which `render_api` rejects) or a bare scalar/array that isn't a good serializer
253
+ # root, and this leaves room to attach metadata alongside it later.
254
+ render_api({ return: result })
255
+ end
256
+ end