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.
- checksums.yaml +4 -4
- data/README.md +107 -41
- data/VERSION +1 -1
- data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
- data/lib/rest_framework/controller/actions.rb +256 -0
- data/lib/rest_framework/controller/bulk.rb +247 -32
- data/lib/rest_framework/controller/crud.rb +13 -9
- data/lib/rest_framework/controller/openapi.rb +12 -8
- data/lib/rest_framework/controller.rb +275 -164
- data/lib/rest_framework/errors.rb +70 -3
- data/lib/rest_framework/filters/base_filter.rb +10 -0
- data/lib/rest_framework/filters/ordering_filter.rb +41 -23
- data/lib/rest_framework/filters/query_filter.rb +24 -5
- data/lib/rest_framework/filters/search_filter.rb +8 -4
- data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
- data/lib/rest_framework/routers.rb +52 -182
- data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
- data/lib/rest_framework/serializers/base_serializer.rb +2 -2
- data/lib/rest_framework/serializers/native_serializer.rb +78 -24
- data/lib/rest_framework/utils.rb +39 -70
- data/lib/rest_framework/version.rb +8 -5
- data/lib/rest_framework.rb +7 -41
- metadata +5 -12
- data/lib/rest_framework/errors/base_error.rb +0 -5
- data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
- data/lib/rest_framework/generators/controller_generator.rb +0 -64
- data/lib/rest_framework/generators.rb +0 -4
- data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
- data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
- data/lib/rest_framework/mixins.rb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6551a744b8574c83f8b5a482e32337e2ea554e4c572baf7c207af3bcda26fb38
|
|
4
|
+
data.tar.gz: d26a407bcf834d76a6f1d63e7ca22eafe05cd90197a50d53b016e178a2d92dd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b66138a6abd154cc8df980b6a533273dc5f6e5fd07bcb6d381e73254018cc065a1a2b01c6ae086f0eb6274a11501624a80f56ffd0f58657aa4f7c62145b74f7c
|
|
7
|
+
data.tar.gz: 8222dd29fb6de099e6f555438dcf1328a251a28f20076837176938ebc555a4c1f3d52df0acc77507150f6217058ce8f9dfa53f003641c1ab0e3914d515505348
|
data/README.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/rest_framework)
|
|
4
4
|
[](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml)
|
|
5
5
|
[](https://coveralls.io/github/gregschmit/rails-rest-framework?branch=master)
|
|
6
|
-
[](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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
70
|
+
### Serving the Root API Index
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`
|
|
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
|
-
#
|
|
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
|
-
|
|
148
|
-
|
|
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
|
+
2.0.0.beta1
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
scope: "",
|
|
19
19
|
local: true,
|
|
20
20
|
}.compact) do |form| %>
|
|
21
|
-
<% controller.get_fields.
|
|
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
|