rest_framework 1.2.0 → 2.0.0.beta2
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 +109 -41
- data/VERSION +1 -1
- data/lib/rest_framework/controller/actions.rb +256 -0
- data/lib/rest_framework/controller/bulk.rb +15 -10
- data/lib/rest_framework/controller/crud.rb +10 -5
- data/lib/rest_framework/controller/openapi.rb +3 -2
- data/lib/rest_framework/controller.rb +231 -137
- data/lib/rest_framework/errors.rb +18 -1
- data/lib/rest_framework/filters/base_filter.rb +10 -0
- data/lib/rest_framework/filters/ordering_filter.rb +43 -23
- data/lib/rest_framework/filters/query_filter.rb +19 -5
- data/lib/rest_framework/filters/search_filter.rb +8 -8
- data/lib/rest_framework/paginators/page_number_paginator.rb +25 -9
- data/lib/rest_framework/routers.rb +52 -188
- 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 +76 -24
- data/lib/rest_framework/utils.rb +32 -70
- data/lib/rest_framework/version.rb +8 -5
- data/lib/rest_framework.rb +7 -38
- metadata +5 -8
- 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: 639dcc597e17e5148cde3f7511637913be7b431899f59a5cc7f504533969233e
|
|
4
|
+
data.tar.gz: 0c732ab6c80f9c06b29e4a30e29087ff4fa65206df15b7826ffd9f0d2781b774
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 656e302db2252a27a30508e1f5c1d2caf2d603288d4ca3e2ec0995355a4a6501b18bc8320463a17113568a562ead98c3896c949a00aa03cc83a6c71f2f62d738
|
|
7
|
+
data.tar.gz: f760d0140360ca1856dca0ab87b8de1903e09fd7cc442c1b40c9150b7abaf312f43ae67a52a5d0881af9e1f98489a074a82ebaef87b527c99e6e8f7175c67740
|
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,69 @@ 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** — `find_by`, filtering, ordering, and search are scoped to serialized,
|
|
206
|
+
non-`write_only` fields (so hidden/secret columns can't be used as lookup/enumeration keys), plus
|
|
207
|
+
per-element read-only stripping on bulk writes, an ordering-oracle fix, sanitized
|
|
208
|
+
`StatementInvalid` messages, and safer query-filter parsing.
|
|
209
|
+
|
|
210
|
+
### Migrating from Older Versions
|
|
211
|
+
|
|
212
|
+
See the guide for details on each item.
|
|
213
|
+
|
|
214
|
+
- [ ] Replace the `*Mixin` modules with `include RESTFramework::Controller` on the core API
|
|
215
|
+
controller.
|
|
216
|
+
- [ ] Set `self.model = ...` on every resource controller (it's no longer inferred from the name).
|
|
217
|
+
- [ ] Wrap inherited config in a `propagate` block — assignments are now local by default.
|
|
218
|
+
- [ ] Convert `extra_actions` / `extra_member_actions` hashes to `add_action` / `remove_action`.
|
|
219
|
+
- [ ] Render custom actions with `render(api: ...)`, replacing the older `api_response(...)` /
|
|
220
|
+
`render_api(...)`.
|
|
221
|
+
- [ ] Replace `rest_resource` / `rest_resources` / `rest_root` with `rest_route`.
|
|
222
|
+
- [ ] Fold any dedicated root controller into the namespace's base controller, which now serves the
|
|
223
|
+
root via `index_content` (the standalone `root` action and `rest_root` are gone).
|
|
224
|
+
- [ ] Rename `singleton_controller` → `singular`.
|
|
225
|
+
- [ ] Remove `rrf_finalize` calls and the `auto_finalize` / `freeze_config` config (all gone).
|
|
226
|
+
- [ ] Expect paginated `index` responses by default (`self.paginator_class = nil` restores a bare
|
|
227
|
+
array).
|
|
228
|
+
- [ ] Rename config: `sub_fields` → `fields`, `native_serializer_associations_limit[_max]` →
|
|
229
|
+
`association_limit[_max]`, `native_serializer_include_associations_count` →
|
|
230
|
+
`include_association_count`; the `?associations_limit=N` param is gone.
|
|
231
|
+
- [ ] Note client-visible behavior changes: delegated actions wrap their result under a `return`
|
|
232
|
+
key; a non-permitted `find_by` returns `404`; `update_all` / `destroy_all` are plural-only;
|
|
233
|
+
ordering/pagination read from the query string only.
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
2.0.0.beta2
|
|
@@ -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
|
|
@@ -95,12 +95,12 @@ module RESTFramework::Controller
|
|
|
95
95
|
def create_all
|
|
96
96
|
if self._bulk_mode == :raw
|
|
97
97
|
result = self.create_all_raw!
|
|
98
|
-
return
|
|
98
|
+
return render_api({ message: "Bulk create successful.", result: result })
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
records = self.create_all_default!
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
render_api(
|
|
103
|
+
{ message: "Bulk create successful.", records: self._bulk_serialize(records) },
|
|
104
104
|
status: :created,
|
|
105
105
|
)
|
|
106
106
|
end
|
|
@@ -146,18 +146,20 @@ module RESTFramework::Controller
|
|
|
146
146
|
def update_all
|
|
147
147
|
if self._bulk_mode == :raw
|
|
148
148
|
result = self.update_all_raw!
|
|
149
|
-
return
|
|
149
|
+
return render_api({ message: "Bulk update successful.", result: result })
|
|
150
150
|
end
|
|
151
151
|
|
|
152
152
|
records = self.update_all_default!
|
|
153
|
-
|
|
153
|
+
render_api({ message: "Bulk update successful.", records: self._bulk_serialize(records) })
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def update_all_raw!
|
|
157
157
|
pk = self.class.model.primary_key
|
|
158
|
+
pk_type = self.class.model.type_for_attribute(pk)
|
|
158
159
|
data = self._bulk_object_data(:update, :raw)
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
# Cast ids like `update_all_default!` so the existence check compares matching types.
|
|
162
|
+
data_ids = data.map { |r| pk_type.cast(r[pk]) }.uniq
|
|
161
163
|
if data_ids.include?(nil)
|
|
162
164
|
raise RESTFramework::InvalidBulkParametersError.new(
|
|
163
165
|
"Bulk update requires the primary key (#{pk}) for all records.",
|
|
@@ -183,9 +185,12 @@ module RESTFramework::Controller
|
|
|
183
185
|
|
|
184
186
|
def update_all_default!
|
|
185
187
|
pk = self.class.model.primary_key
|
|
188
|
+
pk_type = self.class.model.type_for_attribute(pk)
|
|
186
189
|
data = self._bulk_object_data(:update, :default)
|
|
187
190
|
|
|
188
|
-
|
|
191
|
+
# Cast ids to the pk's type so they match records fetched from the DB; JSON clients often send
|
|
192
|
+
# ids as strings, which otherwise never match the type-cast keys of `existing` below.
|
|
193
|
+
data_ids = data.map { |r| pk_type.cast(r[pk]) }.uniq
|
|
189
194
|
if data_ids.include?(nil)
|
|
190
195
|
raise RESTFramework::InvalidBulkParametersError.new(
|
|
191
196
|
"Bulk update requires the primary key (#{pk}) for all records.",
|
|
@@ -201,7 +206,7 @@ module RESTFramework::Controller
|
|
|
201
206
|
|
|
202
207
|
# Assign attributes to each record.
|
|
203
208
|
records = data.map { |attrs|
|
|
204
|
-
record = existing[attrs[pk]]
|
|
209
|
+
record = existing[pk_type.cast(attrs[pk])]
|
|
205
210
|
record.assign_attributes(attrs.except(pk))
|
|
206
211
|
record
|
|
207
212
|
}
|
|
@@ -229,11 +234,11 @@ module RESTFramework::Controller
|
|
|
229
234
|
def destroy_all
|
|
230
235
|
if self._bulk_mode == :raw
|
|
231
236
|
deleted = self.destroy_all_raw!
|
|
232
|
-
return
|
|
237
|
+
return render_api({ message: "Bulk destroy successful.", result: deleted })
|
|
233
238
|
end
|
|
234
239
|
|
|
235
240
|
records = self.destroy_all_default!
|
|
236
|
-
|
|
241
|
+
render_api({ message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
def destroy_all_raw!
|
|
@@ -5,7 +5,7 @@ module RESTFramework::Controller
|
|
|
5
5
|
return self.create_all
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
render_api(self.create!, status: :created)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
# Perform the `create!` call and return the created record.
|
|
@@ -14,7 +14,7 @@ module RESTFramework::Controller
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def index
|
|
17
|
-
|
|
17
|
+
render_api(self.class.model ? self.index! : self.index_content)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Get records with both filtering and pagination applied.
|
|
@@ -38,12 +38,17 @@ module RESTFramework::Controller
|
|
|
38
38
|
records
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# The payload rendered at a non-model controller's index (e.g. an API root). Override this.
|
|
42
|
+
def index_content
|
|
43
|
+
{ message: self.class.description.presence || "This is the API root." }
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
def show
|
|
42
|
-
|
|
47
|
+
render_api(self.get_record)
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
def update
|
|
46
|
-
|
|
51
|
+
render_api(self.update!)
|
|
47
52
|
end
|
|
48
53
|
|
|
49
54
|
# Perform the `update!` call and return the updated record.
|
|
@@ -55,7 +60,7 @@ module RESTFramework::Controller
|
|
|
55
60
|
|
|
56
61
|
def destroy
|
|
57
62
|
self.destroy!
|
|
58
|
-
|
|
63
|
+
render_api("")
|
|
59
64
|
end
|
|
60
65
|
|
|
61
66
|
# Perform the `destroy!` call and return the destroyed (and frozen) record.
|
|
@@ -180,6 +180,7 @@ module RESTFramework::Controller
|
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
v[:readOnly] = true if cfg[:read_only]
|
|
183
|
+
v[:writeOnly] = true if cfg[:write_only]
|
|
183
184
|
v[:default] = cfg[:default] if cfg.key?(:default)
|
|
184
185
|
|
|
185
186
|
if enum_variants = cfg[:enum_variants]
|
|
@@ -206,8 +207,8 @@ module RESTFramework::Controller
|
|
|
206
207
|
join_table: ref.respond_to?(:join_table) ? ref.join_table : nil,
|
|
207
208
|
}.compact
|
|
208
209
|
v[:"x-rrf-association_pk"] = cfg[:association_pk]
|
|
209
|
-
v[:"x-rrf-
|
|
210
|
-
v[:"x-rrf-
|
|
210
|
+
v[:"x-rrf-association_fields"] = cfg[:fields]
|
|
211
|
+
v[:"x-rrf-association_fields_metadata"] = cfg[:association_fields_metadata]
|
|
211
212
|
v[:"x-rrf-id_field"] = cfg[:id_field]
|
|
212
213
|
v[:"x-rrf-nested_attributes_options"] = cfg[:nested_attributes_options]
|
|
213
214
|
end
|