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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8119cab13c69b27ebfa36906e6bcd577688f2ba63b136e0833f4cfdeae3ca5c3
4
- data.tar.gz: 149343b13dfdf94b5804beb502d2296d95f487774d1cb3a1dd30dd63f4ab2afc
3
+ metadata.gz: 639dcc597e17e5148cde3f7511637913be7b431899f59a5cc7f504533969233e
4
+ data.tar.gz: 0c732ab6c80f9c06b29e4a30e29087ff4fa65206df15b7826ffd9f0d2781b774
5
5
  SHA512:
6
- metadata.gz: 17d7143b9ac7a9a3f01fab29a369a083750d26f5923bea7d6b9b9dad407efe25bc30321de0024e1d1f3883aa86838bb250b2ee8c4c749e159386b42d5038f5c8
7
- data.tar.gz: b1794ca103409bc69f235d72603bd8c9ecd91f674cf4e41c8072ec4c35b4e0d5ca26058f6903554df404183ee7c9624dac9b8f33b67fe212874e98bc4a600355
6
+ metadata.gz: 656e302db2252a27a30508e1f5c1d2caf2d603288d4ca3e2ec0995355a4a6501b18bc8320463a17113568a562ead98c3896c949a00aa03cc83a6c71f2f62d738
7
+ data.tar.gz: f760d0140360ca1856dca0ab87b8de1903e09fd7cc442c1b40c9150b7abaf312f43ae67a52a5d0881af9e1f98489a074a82ebaef87b527c99e6e8f7175c67740
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,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
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 render(api: { message: "Bulk create successful.", result: result })
98
+ return render_api({ message: "Bulk create successful.", result: result })
99
99
  end
100
100
 
101
101
  records = self.create_all_default!
102
- render(
103
- api: { message: "Bulk create successful.", records: self._bulk_serialize(records) },
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 render(api: { message: "Bulk update successful.", result: result })
149
+ return render_api({ message: "Bulk update successful.", result: result })
150
150
  end
151
151
 
152
152
  records = self.update_all_default!
153
- render(api: { message: "Bulk update successful.", records: self._bulk_serialize(records) })
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
- data_ids = data.map { |r| r[pk] }.uniq
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
- data_ids = data.map { |r| r[pk] }.uniq
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 render(api: { message: "Bulk destroy successful.", result: deleted })
237
+ return render_api({ message: "Bulk destroy successful.", result: deleted })
233
238
  end
234
239
 
235
240
  records = self.destroy_all_default!
236
- render(api: { message: "Bulk destroy successful.", records: self._bulk_serialize(records) })
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
- render(api: self.create!, status: :created)
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
- render(api: self.index!)
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
- render(api: self.get_record)
47
+ render_api(self.get_record)
43
48
  end
44
49
 
45
50
  def update
46
- render(api: self.update!)
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
- render(api: "")
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-sub_fields"] = cfg[:sub_fields]
210
- v[:"x-rrf-sub_fields_metadata"] = cfg[:sub_fields_metadata]
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