rest_framework 2.0.0.beta6 → 2.0.0.rc2
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 +34 -2
- data/lib/rest_framework/controller/actions.rb +18 -10
- data/lib/rest_framework/controller/openapi.rb +13 -5
- data/lib/rest_framework/controller.rb +132 -73
- data/lib/rest_framework/filters/base_filter.rb +10 -0
- data/lib/rest_framework/filters/ordering_filter.rb +8 -0
- data/lib/rest_framework/filters/query_filter.rb +10 -7
- data/lib/rest_framework/routers.rb +15 -8
- data/lib/rest_framework/serializers/native_serializer.rb +38 -41
- data/lib/rest_framework/utils.rb +76 -18
- data/lib/rest_framework/version.rb +1 -39
- metadata +2 -3
- data/VERSION +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 358f030b86e0232e68278653fae211e71cf1efe419c7050119118d36169b1e2b
|
|
4
|
+
data.tar.gz: 7b28aa42477c3a4eb9aad33dbdc78220e12fe5a4f359b701cee0dde999e1aa84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69483cd5c46acd3e01e64af5336ce9e3b230448d66e159804fe3af706d1670d6ec32d60c03bf9dde682e846759aa0769898cb12e46b8b73e25167765fb739a80
|
|
7
|
+
data.tar.gz: fb65b66a849de488b55ea19e085cc33d7e06ca5bffc70c0cc24788970fbbe9921e017f1bd196bc374b51a904cf4df22b5ba3f4e9b97abb8a59010e401bf07b57
|
data/README.md
CHANGED
|
@@ -67,7 +67,7 @@ controllers/
|
|
|
67
67
|
└─ users_controller.rb
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
### Serving the
|
|
70
|
+
### Serving the Base API Index
|
|
71
71
|
|
|
72
72
|
A controller without a `model` renders its `index_content` at its index path, which serves as the
|
|
73
73
|
API root. Because declared actions are local by default (they don't propagate to subclasses), you
|
|
@@ -168,6 +168,29 @@ web server and the job queue, which serves the test app and coverage/brakeman re
|
|
|
168
168
|
- API: [http://127.0.0.1:3000/api](http://127.0.0.1:3000/api)
|
|
169
169
|
- Reports: [http://127.0.0.1:3000/reports](http://127.0.0.1:3000/reports)
|
|
170
170
|
|
|
171
|
+
### Releasing
|
|
172
|
+
|
|
173
|
+
The gem version is in `lib/rest_framework/version.rb`. Cutting a release means bumping that
|
|
174
|
+
constant, tagging, and pushing; the pipeline builds the gem from the constant and pushes it to
|
|
175
|
+
RubyGems when it sees the tag.
|
|
176
|
+
|
|
177
|
+
Use `bin/release` with the exact version. It bumps the version constant, refreshes `Gemfile.lock`,
|
|
178
|
+
folds both into a single commit, and creates the annotated tag `v<version>` (via the
|
|
179
|
+
[gem-release](https://github.com/svenfuchs/gem-release) gem). The lock refresh matters because
|
|
180
|
+
`rest_framework` is a path gem: if the tagged commit's lock doesn't match the bumped gemspec, CI's
|
|
181
|
+
frozen `bundle install` fails with "the gemspecs for path gems changed". Run it from the `master`
|
|
182
|
+
branch with a clean working tree:
|
|
183
|
+
|
|
184
|
+
```shell
|
|
185
|
+
bin/release 2.0.0.rc1 # cut a release candidate
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Review, then push to publish:
|
|
189
|
+
|
|
190
|
+
```shell
|
|
191
|
+
git push origin master --follow-tags
|
|
192
|
+
```
|
|
193
|
+
|
|
171
194
|
## Version 2
|
|
172
195
|
|
|
173
196
|
Version 2 is a substantial overhaul. The highlights below cover the major additions and behavior
|
|
@@ -230,7 +253,16 @@ See the guide for details on each item.
|
|
|
230
253
|
array).
|
|
231
254
|
- [ ] Rename config: `sub_fields` → `fields`, `native_serializer_associations_limit[_max]` →
|
|
232
255
|
`association_limit[_max]`, `native_serializer_include_associations_count` →
|
|
233
|
-
`include_association_count
|
|
256
|
+
`include_association_count`, `native_serializer_{only,except,include,exclude}_query_param` →
|
|
257
|
+
`{only,except,include,exclude}_query_param`; the `?associations_limit=N` param is gone.
|
|
258
|
+
- [ ] Move `field_config` into `fields`: it's now the `config:` key of the `fields` hash
|
|
259
|
+
(`self.fields = { only: [...], config: { email: { label: "Email Address" } } }`). A field
|
|
260
|
+
named in `config:` is implicitly part of the set. An association's `fields:` takes the same
|
|
261
|
+
spec form (an array, or an `only:`/`include:`/`exclude:`/`config:` hash), replacing the old
|
|
262
|
+
nested `field_config:` key.
|
|
263
|
+
- [ ] Replace the `native_serializer_config` / `native_serializer_singular_config` /
|
|
264
|
+
`native_serializer_plural_config` attributes with a custom `serializer_class` — a
|
|
265
|
+
`NativeSerializer` subclass carrying `config` / `singular_config` / `plural_config`.
|
|
234
266
|
- [ ] Note client-visible behavior changes: delegated actions wrap their result under a `return`
|
|
235
267
|
key; a non-permitted `find_by` returns `404`; `update_all` / `destroy_all` are plural-only;
|
|
236
268
|
ordering/pagination read from the query string only.
|
|
@@ -50,7 +50,10 @@ module RESTFramework::Controller
|
|
|
50
50
|
# Route an action, choosing the collection/member scope. Pass `type:` on a plural model
|
|
51
51
|
# controller, where the scopes differ; elsewhere the scope is implied — a singular controller's
|
|
52
52
|
# sole resource is a member (so `delegate` targets the record), a modelless one has only a
|
|
53
|
-
# collection — and `type:` warns as unnecessary (unless the action is delegated).
|
|
53
|
+
# collection — and `type:` warns as unnecessary (unless the action is delegated). `propagate:`
|
|
54
|
+
# also accepts a `->(controller) { ... }` predicate to gate the action: it applies to this
|
|
55
|
+
# controller and its descendants wherever the predicate holds — e.g. `->(c) { c.model }` routes
|
|
56
|
+
# it only on controllers with a model.
|
|
54
57
|
def add_action(name, methods, type: nil, **opts)
|
|
55
58
|
singular_model = self.model && self.singular
|
|
56
59
|
|
|
@@ -112,9 +115,10 @@ module RESTFramework::Controller
|
|
|
112
115
|
_rrf_action_removes(type)[name] = { propagate: _rrf_normalize_propagate(propagate) }
|
|
113
116
|
end
|
|
114
117
|
|
|
115
|
-
# Normalize `propagate:` to `false` (local), `true` (self + descendants),
|
|
116
|
-
# (descendants only)
|
|
117
|
-
# becomes `
|
|
118
|
+
# Normalize `propagate:` to `false` (local), `true` (self + descendants), `:exclude_self`
|
|
119
|
+
# (descendants only), or a `->(controller) { ... }` predicate (self + descendants, wherever it
|
|
120
|
+
# returns truthy). Non-standard values warn: `nil` becomes `false`, anything else truthy becomes
|
|
121
|
+
# `true`.
|
|
118
122
|
def _rrf_normalize_propagate(value)
|
|
119
123
|
case value
|
|
120
124
|
when false
|
|
@@ -125,21 +129,25 @@ module RESTFramework::Controller
|
|
|
125
129
|
Rails.logger.warn("RRF: `propagate: nil` is nonstandard; treating as `false`.")
|
|
126
130
|
false
|
|
127
131
|
else
|
|
132
|
+
return value if value.respond_to?(:call)
|
|
133
|
+
|
|
128
134
|
Rails.logger.warn("RRF: invalid `propagate:` value #{value.inspect}; treating as `true`.")
|
|
129
135
|
true
|
|
130
136
|
end
|
|
131
137
|
end
|
|
132
138
|
|
|
133
|
-
# Whether an entry with the given `propagate`, declared on some class, reaches the
|
|
134
|
-
# we're composing for. `is_self` is true when
|
|
135
|
-
def _rrf_reaches?(propagate, is_self)
|
|
139
|
+
# Whether an entry with the given `propagate`, declared on some class, reaches `controller` (the
|
|
140
|
+
# class we're composing for). `is_self` is true when the declaring class is `controller` itself.
|
|
141
|
+
def _rrf_reaches?(propagate, is_self, controller)
|
|
136
142
|
case propagate
|
|
137
143
|
when :exclude_self
|
|
138
144
|
!is_self
|
|
139
145
|
when true
|
|
140
146
|
true
|
|
141
|
-
|
|
147
|
+
when false
|
|
142
148
|
is_self
|
|
149
|
+
else # a `->(controller) { ... }` predicate: applies wherever it holds (self and descendants)
|
|
150
|
+
propagate.call(controller)
|
|
143
151
|
end
|
|
144
152
|
end
|
|
145
153
|
|
|
@@ -172,11 +180,11 @@ module RESTFramework::Controller
|
|
|
172
180
|
is_self = klass.equal?(self)
|
|
173
181
|
|
|
174
182
|
klass._rrf_action_removes(type).each do |name, remove|
|
|
175
|
-
effective.delete(name) if _rrf_reaches?(remove[:propagate], is_self)
|
|
183
|
+
effective.delete(name) if _rrf_reaches?(remove[:propagate], is_self, self)
|
|
176
184
|
end
|
|
177
185
|
|
|
178
186
|
klass._rrf_action_adds(type).each do |name, add|
|
|
179
|
-
effective[name] = add[:spec] if _rrf_reaches?(add[:propagate], is_self)
|
|
187
|
+
effective[name] = add[:spec] if _rrf_reaches?(add[:propagate], is_self, self)
|
|
180
188
|
end
|
|
181
189
|
end
|
|
182
190
|
|
|
@@ -183,9 +183,11 @@ module RESTFramework::Controller
|
|
|
183
183
|
v[:writeOnly] = true if cfg[:write_only]
|
|
184
184
|
v[:default] = cfg[:default] if cfg.key?(:default)
|
|
185
185
|
|
|
186
|
-
if
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
if (options = cfg[:options]).present?
|
|
187
|
+
# Emit `oneOf` for options, but also emit `enum` for true ActiveRecord enums, since some
|
|
188
|
+
# older tooling reads `enum` but not `oneOf`.
|
|
189
|
+
v[:oneOf] = options.map { |value, label| { const: value, title: label } }
|
|
190
|
+
v[:enum] = options.keys if cfg[:enum]
|
|
189
191
|
end
|
|
190
192
|
|
|
191
193
|
if validators = cfg[:validators]
|
|
@@ -196,15 +198,21 @@ module RESTFramework::Controller
|
|
|
196
198
|
|
|
197
199
|
if cfg[:reflection]
|
|
198
200
|
ref = cfg[:reflection]
|
|
201
|
+
|
|
202
|
+
# A polymorphic `belongs_to` has no single target class, so class-derived properties
|
|
203
|
+
# (`association_primary_key`, `join_table`) raise; expose the `*_type` column instead.
|
|
204
|
+
polymorphic = ref.respond_to?(:polymorphic?) && ref.polymorphic?
|
|
199
205
|
v[:"x-rrf-reflection"] = {
|
|
206
|
+
polymorphic: polymorphic || nil,
|
|
200
207
|
class_name: ref.respond_to?(:class_name) ? ref.class_name : nil,
|
|
201
208
|
foreign_key: ref.respond_to?(:foreign_key) ? ref.foreign_key : nil,
|
|
209
|
+
foreign_type: polymorphic ? ref.foreign_type : nil,
|
|
202
210
|
association_foreign_key: ref.respond_to?(:association_foreign_key) ?
|
|
203
211
|
ref.association_foreign_key : nil,
|
|
204
|
-
association_primary_key: ref.respond_to?(:association_primary_key) ?
|
|
212
|
+
association_primary_key: !polymorphic && ref.respond_to?(:association_primary_key) ?
|
|
205
213
|
ref.association_primary_key : nil,
|
|
206
214
|
inverse_of: ref.respond_to?(:inverse_of) ? ref.inverse_of&.name : nil,
|
|
207
|
-
join_table: ref.respond_to?(:join_table) ? ref.join_table : nil,
|
|
215
|
+
join_table: !polymorphic && ref.respond_to?(:join_table) ? ref.join_table : nil,
|
|
208
216
|
}.compact
|
|
209
217
|
v[:"x-rrf-association_pk"] = cfg[:association_pk]
|
|
210
218
|
v[:"x-rrf-association_fields"] = cfg[:fields]
|
|
@@ -26,9 +26,10 @@ module RESTFramework::Controller
|
|
|
26
26
|
bulk_max_size: nil,
|
|
27
27
|
bulk_max_raw_size: nil,
|
|
28
28
|
|
|
29
|
-
# Configuring record fields.
|
|
29
|
+
# Configuring record fields. `fields` is the single source of truth: an Array (sugar for
|
|
30
|
+
# `only:`), or a Hash of `only:`/`include:`/`exclude:`/`except:` (set membership) plus `config:`
|
|
31
|
+
# (per-field configuration, keyed by field name).
|
|
30
32
|
fields: nil,
|
|
31
|
-
field_config: nil,
|
|
32
33
|
read_only_fields: RESTFramework.config.read_only_fields,
|
|
33
34
|
write_only_fields: RESTFramework.config.write_only_fields,
|
|
34
35
|
hidden_fields: nil,
|
|
@@ -40,32 +41,19 @@ module RESTFramework::Controller
|
|
|
40
41
|
# Handling request body parameters.
|
|
41
42
|
allowed_parameters: nil,
|
|
42
43
|
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
native_serializer_except_query_param: "except".freeze,
|
|
49
|
-
native_serializer_include_query_param: "include".freeze,
|
|
50
|
-
native_serializer_exclude_query_param: "exclude".freeze,
|
|
44
|
+
# Query params for the default native serializer's field selection.
|
|
45
|
+
only_query_param: "only".freeze,
|
|
46
|
+
except_query_param: "except".freeze,
|
|
47
|
+
include_query_param: "include".freeze,
|
|
48
|
+
exclude_query_param: "exclude".freeze,
|
|
51
49
|
|
|
52
50
|
# Options for including associations and collection counts.
|
|
53
51
|
exclude_associations: false,
|
|
54
52
|
include_association_count: false,
|
|
55
53
|
|
|
56
|
-
#
|
|
57
|
-
# the box (`nil` = unlimited). With `enable_association_queries`, a client can raise it for a
|
|
58
|
-
# given association via `?<prefix>.<name>.limit=N` or `limit=all` (`none`/`0` are aliases), both
|
|
59
|
-
# capped at `association_limit_max` (the "all" forms yield the cap). Set the max to `nil` to let
|
|
60
|
-
# a client request unlimited records.
|
|
54
|
+
# Options for association serialization.
|
|
61
55
|
association_limit: 10,
|
|
62
56
|
association_limit_max: 100,
|
|
63
|
-
|
|
64
|
-
# Let clients request extra fields for a serialized association via
|
|
65
|
-
# `?<prefix>.<association>.fields=a,b,c`. The allowlist keeps an association from ever exposing
|
|
66
|
-
# more than its own endpoint would: an explicit per-association `requestable_fields` in
|
|
67
|
-
# `field_config`, else the fields the associated model's sibling controller serializes.
|
|
68
|
-
# Off/secure by default.
|
|
69
57
|
enable_association_queries: false,
|
|
70
58
|
association_query_prefix: "associations".freeze,
|
|
71
59
|
|
|
@@ -95,10 +83,6 @@ module RESTFramework::Controller
|
|
|
95
83
|
# Option for `recordset.create` vs `Model.create` behavior.
|
|
96
84
|
create_from_recordset: true,
|
|
97
85
|
|
|
98
|
-
# Options for scoped nested routing.
|
|
99
|
-
scope_nested_by_parent: true,
|
|
100
|
-
scope_nested_through_controllers: true,
|
|
101
|
-
|
|
102
86
|
# Options related to serialization.
|
|
103
87
|
rescue_unknown_format_with: :json,
|
|
104
88
|
serializer_class: nil,
|
|
@@ -283,7 +267,8 @@ module RESTFramework::Controller
|
|
|
283
267
|
def field_configuration
|
|
284
268
|
return @field_configuration if @field_configuration
|
|
285
269
|
|
|
286
|
-
field_config = self.
|
|
270
|
+
field_config = (self.fields.is_a?(Hash) ? self.fields[:config] : nil)
|
|
271
|
+
&.with_indifferent_access || {}
|
|
287
272
|
columns = self.model.columns_hash
|
|
288
273
|
column_defaults = self.model.column_defaults
|
|
289
274
|
reflections = self.model.reflections
|
|
@@ -301,34 +286,23 @@ module RESTFramework::Controller
|
|
|
301
286
|
cfg = field_config[f]&.dup || {}
|
|
302
287
|
cfg[:label] ||= self.label_for(f)
|
|
303
288
|
|
|
289
|
+
# An explicit `read_only`/`write_only` in `field_config` wins over every framework default
|
|
290
|
+
# below (primary key, readonly attributes, the read/write-only config lists, and the
|
|
291
|
+
# method-field default), so those only apply when the developer set neither.
|
|
292
|
+
read_write_only_set = cfg.key?(:read_only) || cfg.key?(:write_only)
|
|
293
|
+
|
|
304
294
|
# Annotate primary key.
|
|
305
295
|
if self.model.primary_key == f
|
|
306
296
|
cfg[:primary_key] = true
|
|
307
|
-
|
|
308
|
-
unless cfg.key?(:read_only)
|
|
309
|
-
cfg[:read_only] = true
|
|
310
|
-
end
|
|
297
|
+
cfg[:read_only] = true unless read_write_only_set
|
|
311
298
|
end
|
|
312
299
|
|
|
313
300
|
# Annotate field mutability and display properties.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
# Raise warnings on some bad combinations of properties.
|
|
319
|
-
if cfg[:write_only]
|
|
320
|
-
if cfg[:read_only]
|
|
321
|
-
Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
if cfg[:hidden]
|
|
325
|
-
Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
if cfg[:hidden_from_index]
|
|
329
|
-
Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
|
|
330
|
-
end
|
|
301
|
+
unless read_write_only_set
|
|
302
|
+
cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
|
|
303
|
+
cfg[:write_only] = true if f.in?(write_only_fields)
|
|
331
304
|
end
|
|
305
|
+
cfg[:hidden] = true if f.in?(hidden_fields)
|
|
332
306
|
|
|
333
307
|
# Annotate column data.
|
|
334
308
|
if column = columns[f]
|
|
@@ -353,9 +327,9 @@ module RESTFramework::Controller
|
|
|
353
327
|
if type = attribute.type
|
|
354
328
|
cfg[:type] ||= type.type if type.type
|
|
355
329
|
|
|
356
|
-
# Get enum variants.
|
|
357
330
|
if type.is_a?(ActiveRecord::Enum::EnumType)
|
|
358
|
-
cfg[:
|
|
331
|
+
cfg[:enum] = true
|
|
332
|
+
cfg[:options] ||= type.send(:mapping).invert
|
|
359
333
|
|
|
360
334
|
# TranslateEnum Integration:
|
|
361
335
|
translate_method = "translated_#{f.pluralize}"
|
|
@@ -376,8 +350,15 @@ module RESTFramework::Controller
|
|
|
376
350
|
else
|
|
377
351
|
ref_columns = ref.klass.columns_hash
|
|
378
352
|
end
|
|
379
|
-
|
|
380
|
-
|
|
353
|
+
# The association's `fields` config is itself a spec (Array or `only:`/`include:`/
|
|
354
|
+
# `exclude:`/`config:` Hash). Resolve its membership to a name array (consumed by the
|
|
355
|
+
# serializer, filters, and OpenAPI) and stash any nested `config:` for the serializer's
|
|
356
|
+
# recursion.
|
|
357
|
+
spec = RESTFramework::Utils.normalize_field_spec(cfg[:fields])
|
|
358
|
+
cfg[:fields] = RESTFramework::Utils.resolve_field_names(
|
|
359
|
+
spec, RESTFramework::Utils.association_fields_for(ref)
|
|
360
|
+
)
|
|
361
|
+
cfg[:field_config] = spec[:config] if spec[:config]
|
|
381
362
|
|
|
382
363
|
# Strings, to match `:fields` when intersecting requested fields against the allowlist.
|
|
383
364
|
if cfg[:requestable_fields]
|
|
@@ -432,7 +413,8 @@ module RESTFramework::Controller
|
|
|
432
413
|
# Determine if this is just a method.
|
|
433
414
|
if !cfg[:kind] && self.model.method_defined?(f)
|
|
434
415
|
cfg[:kind] = "method"
|
|
435
|
-
|
|
416
|
+
# Methods are read-only by default, unless the field was marked read/write-only.
|
|
417
|
+
cfg[:read_only] = true unless read_write_only_set || cfg[:write_only]
|
|
436
418
|
end
|
|
437
419
|
|
|
438
420
|
# Collect validator options into a hash on their type, while also updating `required` based
|
|
@@ -453,6 +435,21 @@ module RESTFramework::Controller
|
|
|
453
435
|
cfg[:validators][kind] << options
|
|
454
436
|
end
|
|
455
437
|
|
|
438
|
+
# Warn on bad combinations, once every property has been resolved.
|
|
439
|
+
if cfg[:write_only]
|
|
440
|
+
if cfg[:read_only]
|
|
441
|
+
Rails.logger.warn("RRF: `#{f}` write_only conflicts with read_only.")
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
if cfg[:hidden]
|
|
445
|
+
Rails.logger.warn("RRF: `#{f}` write_only implies hidden.")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
if cfg[:hidden_from_index]
|
|
449
|
+
Rails.logger.warn("RRF: `#{f}` write_only implies hidden_from_index.")
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
456
453
|
next [ f, cfg ]
|
|
457
454
|
}.to_h.compact.with_indifferent_access
|
|
458
455
|
|
|
@@ -474,17 +471,14 @@ module RESTFramework::Controller
|
|
|
474
471
|
# The fields a consumer may request for an association beyond its defaults, derived from the
|
|
475
472
|
# associated model's sibling controller: what that controller serializes, so the association can
|
|
476
473
|
# never expose more than its own endpoint would. Empty unless the sibling is discoverable and
|
|
477
|
-
# introspectable — a custom
|
|
478
|
-
# included (retrievable via `?only=` there); write-only
|
|
474
|
+
# introspectable — a custom `serializer_class` makes its `get_fields` meaningless. Hidden fields
|
|
475
|
+
# are included (retrievable via `?only=` there); write-only and nested associations aren't.
|
|
479
476
|
def association_requestable_fields(ref)
|
|
480
477
|
return [] if ref.polymorphic?
|
|
481
478
|
|
|
482
479
|
sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
|
|
483
480
|
return [] unless sibling
|
|
484
|
-
return [] if sibling.serializer_class
|
|
485
|
-
sibling.native_serializer_config ||
|
|
486
|
-
sibling.native_serializer_singular_config ||
|
|
487
|
-
sibling.native_serializer_plural_config
|
|
481
|
+
return [] if sibling.serializer_class
|
|
488
482
|
|
|
489
483
|
cfg = sibling.field_configuration
|
|
490
484
|
sibling.get_fields.reject { |sf|
|
|
@@ -648,8 +642,10 @@ module RESTFramework::Controller
|
|
|
648
642
|
end
|
|
649
643
|
|
|
650
644
|
def readable_fields
|
|
651
|
-
|
|
652
|
-
|
|
645
|
+
@_readable_fields ||= begin
|
|
646
|
+
cfg = self.class.field_configuration
|
|
647
|
+
self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
|
|
648
|
+
end
|
|
653
649
|
end
|
|
654
650
|
|
|
655
651
|
# The fields a client may write (create/update): `get_fields` minus read_only fields. Excluding
|
|
@@ -657,23 +653,55 @@ module RESTFramework::Controller
|
|
|
657
653
|
# keeps a read_only association from ever producing a permitted (and otherwise unstrippable, since
|
|
658
654
|
# those keys don't match a field name) assignment key.
|
|
659
655
|
def writable_fields
|
|
660
|
-
|
|
661
|
-
|
|
656
|
+
@_writable_fields ||= begin
|
|
657
|
+
cfg = self.class.field_configuration
|
|
658
|
+
self.get_fields.reject { |f| cfg[f]&.[](:read_only) }
|
|
659
|
+
end
|
|
662
660
|
end
|
|
663
661
|
|
|
664
662
|
# `readable_fields` restricted to real columns, for query surfaces that build SQL directly
|
|
665
663
|
# (find_by, search) and would raise on a virtual/method field.
|
|
666
664
|
def readable_columns
|
|
667
|
-
self.readable_fields & self.class.model.column_names
|
|
665
|
+
@_readable_columns ||= self.readable_fields & self.class.model.column_names
|
|
668
666
|
end
|
|
669
667
|
|
|
670
668
|
# `readable_fields` restricted to columns and associations, for surfaces that also resolve dotted
|
|
671
669
|
# `association.sub_field` paths (filtering, ordering). Excludes virtual/method fields, which have
|
|
672
670
|
# no column to order or filter by.
|
|
673
671
|
def readable_columns_or_associations
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
672
|
+
@_readable_columns_or_associations ||= begin
|
|
673
|
+
cfg = self.class.field_configuration
|
|
674
|
+
columns = self.class.model.column_names
|
|
675
|
+
self.readable_fields.select do |f|
|
|
676
|
+
next true if f.in?(columns)
|
|
677
|
+
|
|
678
|
+
# Skip polymorphic associations: they can't be JOINed, so filtering or ordering *through*
|
|
679
|
+
# them (e.g. `?favorite.name=x`) would raise. Their backing `*_id`/`*_type` columns are
|
|
680
|
+
# still filterable/orderable via `readable_polymorphic_columns`.
|
|
681
|
+
field = cfg[f]
|
|
682
|
+
field&.[](:kind) == "association" && !field[:reflection]&.polymorphic?
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Map each readable polymorphic `belongs_to`'s dotted `<name>.id`/`<name>.type` path to its
|
|
688
|
+
# backing `*_id`/`*_type` column. These columns live on the base table, so — unlike the
|
|
689
|
+
# association itself, which can't be JOINed — they filter and order like any other column. The
|
|
690
|
+
# dotted path mirrors the serialized shape, so clients filter with `?favorite.type=Genre`.
|
|
691
|
+
def readable_polymorphic_columns
|
|
692
|
+
@_readable_polymorphic_columns ||= begin
|
|
693
|
+
cfg = self.class.field_configuration
|
|
694
|
+
self.readable_fields.each_with_object({}) do |f, map|
|
|
695
|
+
field = cfg[f]
|
|
696
|
+
next unless field&.[](:kind) == "association"
|
|
697
|
+
|
|
698
|
+
ref = field[:reflection]
|
|
699
|
+
next unless ref&.polymorphic?
|
|
700
|
+
|
|
701
|
+
map["#{f}.id"] = ref.foreign_key
|
|
702
|
+
map["#{f}.type"] = ref.foreign_type
|
|
703
|
+
end
|
|
704
|
+
end
|
|
677
705
|
end
|
|
678
706
|
|
|
679
707
|
# Get a hash of strong parameters for the current action.
|
|
@@ -711,6 +739,14 @@ module RESTFramework::Controller
|
|
|
711
739
|
next nil
|
|
712
740
|
end
|
|
713
741
|
|
|
742
|
+
# JSON/JSONB columns hold opaque structured data, so permit hash (and nested) values here.
|
|
743
|
+
# Scalar and array values can't share this slot in strong params, so `get_body_params`
|
|
744
|
+
# re-injects them after filtering.
|
|
745
|
+
if config[:type].in?(%i[json jsonb])
|
|
746
|
+
hash_variations[f] = {}
|
|
747
|
+
next nil
|
|
748
|
+
end
|
|
749
|
+
|
|
714
750
|
if config[:reflection]
|
|
715
751
|
# Add `_id`/`_ids` variations for associations.
|
|
716
752
|
if id_field = config[:id_field]
|
|
@@ -742,6 +778,15 @@ module RESTFramework::Controller
|
|
|
742
778
|
@_get_allowed_parameters
|
|
743
779
|
end
|
|
744
780
|
|
|
781
|
+
# Writable JSON/JSONB columns, whose values are opaque and may arrive as any JSON type.
|
|
782
|
+
def get_json_columns
|
|
783
|
+
return @_get_json_columns if defined?(@_get_json_columns)
|
|
784
|
+
|
|
785
|
+
@_get_json_columns = self.writable_fields.map(&:to_s).select { |f|
|
|
786
|
+
self.class.field_configuration[f]&.[](:type).in?(%i[json jsonb])
|
|
787
|
+
}
|
|
788
|
+
end
|
|
789
|
+
|
|
745
790
|
# Use strong parameters to filter the request body.
|
|
746
791
|
def get_body_params(bulk_action: nil)
|
|
747
792
|
data = self.request.request_parameters
|
|
@@ -808,6 +853,16 @@ module RESTFramework::Controller
|
|
|
808
853
|
end
|
|
809
854
|
end
|
|
810
855
|
|
|
856
|
+
# JSON/JSONB columns accept any JSON value. Strong params permit a hash for such a key (via
|
|
857
|
+
# `key: {}`; see `get_allowed_parameters`), but can't also accept a scalar or array in that
|
|
858
|
+
# slot, so remember non-hash values now and re-inject them after filtering.
|
|
859
|
+
json_scalar_or_array_data = {}
|
|
860
|
+
if !bulk_action && self.class.model
|
|
861
|
+
self.get_json_columns.each do |f|
|
|
862
|
+
json_scalar_or_array_data[f] = data[f] if data.key?(f) && !data[f].is_a?(Hash)
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
|
|
811
866
|
# Filter the request body with strong params. If `bulk` is true, then we apply allowed
|
|
812
867
|
# parameters to the `_json` key of the request body.
|
|
813
868
|
body_params = if allowed_params == true
|
|
@@ -835,6 +890,11 @@ module RESTFramework::Controller
|
|
|
835
890
|
body_params[k].unshift(*v)
|
|
836
891
|
end
|
|
837
892
|
|
|
893
|
+
# Re-inject scalar/array JSON column values that strong params dropped (see above).
|
|
894
|
+
json_scalar_or_array_data.each do |k, v|
|
|
895
|
+
body_params[k] = v
|
|
896
|
+
end
|
|
897
|
+
|
|
838
898
|
body_params
|
|
839
899
|
end
|
|
840
900
|
alias_method :get_create_params, :get_body_params
|
|
@@ -855,13 +915,14 @@ module RESTFramework::Controller
|
|
|
855
915
|
# `Movie.find(movie_id).genres.find(genre_id).tracks`. Every link is enforced (a broken one raises
|
|
856
916
|
# `RecordNotFound` -> 404), and each association is resolved from its parent, so `belongs_to`,
|
|
857
917
|
# `has_many`, and `has_and_belongs_to_many` children all work. Each parent is looked up via its
|
|
858
|
-
# own controller's recordset
|
|
859
|
-
#
|
|
918
|
+
# own controller's recordset, so per-level access scoping is enforced. Returns `nil` when there is
|
|
919
|
+
# no nested parent, or a `<name>_id` param can't connect. Override `get_recordset` to scope
|
|
920
|
+
# differently.
|
|
860
921
|
def _rrf_nested_parent_recordset
|
|
861
922
|
# Set on an ad-hoc parent instance below, so evaluating a parent's `get_recordset` doesn't
|
|
862
923
|
# recurse back into nested scoping (we want the parent's own scope, not to re-nest it).
|
|
863
924
|
return nil if @_rrf_scoping_parent
|
|
864
|
-
return nil unless
|
|
925
|
+
return nil unless request
|
|
865
926
|
|
|
866
927
|
# `<name>_id` path parameters that name a model, in route order (outermost parent first).
|
|
867
928
|
parents = request.path_parameters.filter_map { |key, value|
|
|
@@ -899,11 +960,9 @@ module RESTFramework::Controller
|
|
|
899
960
|
end
|
|
900
961
|
|
|
901
962
|
# A parent's recordset for the nested-scope walk: its own controller's `get_recordset` (so that
|
|
902
|
-
# controller's access scoping is reused), or the bare model when
|
|
903
|
-
#
|
|
963
|
+
# controller's access scoping is reused), or the bare model when no sibling controller is found.
|
|
964
|
+
# The ad-hoc instance shares this request and skips its own nested scoping.
|
|
904
965
|
def _rrf_parent_recordset(model)
|
|
905
|
-
return model.all unless self.class.scope_nested_through_controllers
|
|
906
|
-
|
|
907
966
|
controller = RESTFramework::Utils.controller_for_model(self.class, model)
|
|
908
967
|
return model.all unless controller
|
|
909
968
|
|
|
@@ -7,6 +7,16 @@ class RESTFramework::Filters::BaseFilter
|
|
|
7
7
|
raise NotImplementedError
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# The controller's polymorphic `<assoc>.id`/`<assoc>.type` → backing-column map, gated by a custom
|
|
11
|
+
# field allowlist when the subclass defines one (`filter_fields`/`ordering_fields`). This keeps a
|
|
12
|
+
# restricted allowlist restrictive: a polymorphic path is honored only if it is also listed there.
|
|
13
|
+
def _polymorphic_columns(custom_fields)
|
|
14
|
+
map = @controller.readable_polymorphic_columns
|
|
15
|
+
return map unless custom_fields
|
|
16
|
+
|
|
17
|
+
map.slice(*custom_fields.map(&:to_s))
|
|
18
|
+
end
|
|
19
|
+
|
|
10
20
|
# True when `v` is a query-parameter value safe to feed into `where`, string
|
|
11
21
|
# operations, or `split` — i.e. a String or an Array of Strings. Guards against
|
|
12
22
|
# nested-hash inputs like `?field[evil]=x`, which Rack parses into a Hash and
|
|
@@ -12,6 +12,7 @@ class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilte
|
|
|
12
12
|
|
|
13
13
|
# Ensure ordering_fields are strings since the split param will be strings.
|
|
14
14
|
fields = self._get_fields
|
|
15
|
+
poly_columns = self._polymorphic_columns(@controller.class.ordering_fields)
|
|
15
16
|
order_string = @controller.request.query_parameters[param]
|
|
16
17
|
|
|
17
18
|
# Reject nested-hash inputs like `?ordering[evil]=x` (Rack parses these into
|
|
@@ -38,6 +39,13 @@ class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilte
|
|
|
38
39
|
next
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# A polymorphic association's `<name>.id`/`<name>.type` maps to a backing column on the base
|
|
43
|
+
# table, so it orders directly with no JOIN (unlike other sub-fields).
|
|
44
|
+
if real_column = poly_columns[column]
|
|
45
|
+
ordering[real_column] = direction
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
# A dotted `association.sub_field` token. The root must be an allowlisted association field,
|
|
42
50
|
# and the sub-field must be one of that association's allowlisted fields. Otherwise a client
|
|
43
51
|
# could order by (and infer, via an ordering oracle) a column that is never serialized.
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
# A simple filtering backend that supports filtering a recordset based on query parameters.
|
|
2
2
|
class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
3
3
|
# Wrapper to indicate a type of query that must be negated with `where.not(...)`.
|
|
4
|
-
|
|
5
|
-
attr_reader :q
|
|
6
|
-
|
|
7
|
-
def initialize(q)
|
|
8
|
-
@q = q
|
|
9
|
-
end
|
|
10
|
-
end
|
|
4
|
+
Not = Struct.new(:q)
|
|
11
5
|
|
|
12
6
|
PREDICATES = {
|
|
13
7
|
true: true,
|
|
@@ -63,6 +57,7 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
63
57
|
# query config in the form of: `[base_query, pred_queries, includes]`.
|
|
64
58
|
def _get_query_config
|
|
65
59
|
fields = self._get_fields
|
|
60
|
+
poly_columns = self._polymorphic_columns(@controller.class.filter_fields)
|
|
66
61
|
includes = []
|
|
67
62
|
|
|
68
63
|
# Predicate queries must be added to a separate list because multiple predicates can be used.
|
|
@@ -81,10 +76,18 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
81
76
|
next [ field, v ]
|
|
82
77
|
end
|
|
83
78
|
|
|
79
|
+
# A polymorphic association's `<name>.id`/`<name>.type` maps to a backing column on the base
|
|
80
|
+
# table, so it filters directly with no JOIN (unlike other sub-fields).
|
|
81
|
+
if column = poly_columns[field]
|
|
82
|
+
next [ column, v ]
|
|
83
|
+
end
|
|
84
|
+
|
|
84
85
|
# First, try to parse a simple predicate and check if it is filterable.
|
|
85
86
|
pred_field, predicate = self.parse_predicate(field)
|
|
86
87
|
if predicate && pred_field.in?(fields)
|
|
87
88
|
field = pred_field
|
|
89
|
+
elsif predicate && (column = poly_columns[pred_field])
|
|
90
|
+
field = column
|
|
88
91
|
else
|
|
89
92
|
# Last, try to parse a sub-field or sub-field w/predicate.
|
|
90
93
|
root_field, sub_field = field.split(".", 2)
|
|
@@ -9,8 +9,9 @@ module ActionDispatch::Routing
|
|
|
9
9
|
mod.const_get("#{name.to_s.camelize}Controller")
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
# Route each action from a controller's action store.
|
|
13
|
-
|
|
12
|
+
# Route each action from a controller's action store. `helpers: false` routes every action
|
|
13
|
+
# unnamed (`as: nil`), so the resource contributes no URL/path helpers.
|
|
14
|
+
def _rrf_route_actions(actions, helpers: true)
|
|
14
15
|
actions.each_value do |spec|
|
|
15
16
|
# Delegated actions keep their declared action name (so routing and OpenAPI show the real
|
|
16
17
|
# name); `method_for_action` redirects dispatch to `rrf_delegate`, which needs the scope.
|
|
@@ -18,6 +19,7 @@ module ActionDispatch::Routing
|
|
|
18
19
|
if !spec.builtin && spec.metadata&.[](:delegate)
|
|
19
20
|
kwargs = kwargs.merge(rrf_delegate_scope: spec.type)
|
|
20
21
|
end
|
|
22
|
+
kwargs = kwargs.merge(as: nil) unless helpers
|
|
21
23
|
|
|
22
24
|
spec.methods.each do |m|
|
|
23
25
|
public_send(m, spec.path, action: spec.name, **kwargs)
|
|
@@ -36,7 +38,9 @@ module ActionDispatch::Routing
|
|
|
36
38
|
# a plural `resources` is decided by the controller's own config (`singular`, and whether it has
|
|
37
39
|
# a `model`) — not by the method name: a plural model controller gets collection/member scopes,
|
|
38
40
|
# while singular and non-model controllers route everything at the root. Pass a block to nest
|
|
39
|
-
# resources like Rails' `resources` (the nested controller resolves in the current scope).
|
|
41
|
+
# resources like Rails' `resources` (the nested controller resolves in the current scope). Pass
|
|
42
|
+
# `helpers: false` to route the resource without URL/path helpers — handy when a singular and a
|
|
43
|
+
# plural resource of the same model would otherwise claim the same helper name.
|
|
40
44
|
def rest_resource(name, **kwargs)
|
|
41
45
|
controller = kwargs.delete(:controller) || name
|
|
42
46
|
if controller.is_a?(Class)
|
|
@@ -48,6 +52,9 @@ module ActionDispatch::Routing
|
|
|
48
52
|
# Set controller if it's not explicitly set.
|
|
49
53
|
kwargs[:controller] = name unless kwargs[:controller]
|
|
50
54
|
|
|
55
|
+
# `helpers:` is ours, not Rails' — pull it out before forwarding the rest to the router.
|
|
56
|
+
helpers = kwargs.delete(:helpers) != false
|
|
57
|
+
|
|
51
58
|
has_model = !!controller_class.model
|
|
52
59
|
singular = controller_class.singular
|
|
53
60
|
actions = controller_class.actions
|
|
@@ -61,16 +68,16 @@ module ActionDispatch::Routing
|
|
|
61
68
|
if has_model
|
|
62
69
|
if singular
|
|
63
70
|
# Singular model controller: actions and member actions are the same.
|
|
64
|
-
self._rrf_route_actions(actions)
|
|
65
|
-
self._rrf_route_actions(member_actions)
|
|
71
|
+
self._rrf_route_actions(actions, helpers: helpers)
|
|
72
|
+
self._rrf_route_actions(member_actions, helpers: helpers)
|
|
66
73
|
else
|
|
67
74
|
# Plural model controller: route collection/member actions separately.
|
|
68
|
-
collection { self._rrf_route_actions(actions) }
|
|
69
|
-
member { self._rrf_route_actions(member_actions) }
|
|
75
|
+
collection { self._rrf_route_actions(actions, helpers: helpers) }
|
|
76
|
+
member { self._rrf_route_actions(member_actions, helpers: helpers) }
|
|
70
77
|
end
|
|
71
78
|
else
|
|
72
79
|
# Non-model controller: only actions (there is no member `:id` scope).
|
|
73
|
-
self._rrf_route_actions(actions)
|
|
80
|
+
self._rrf_route_actions(actions, helpers: helpers)
|
|
74
81
|
end
|
|
75
82
|
|
|
76
83
|
yield if block_given?
|
|
@@ -44,10 +44,10 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
44
44
|
return @fields if defined?(@fields)
|
|
45
45
|
return nil unless base_fields = @controller&.get_fields
|
|
46
46
|
|
|
47
|
-
only_param = @controller.class.
|
|
48
|
-
except_param = @controller.class.
|
|
49
|
-
include_param = @controller.class.
|
|
50
|
-
exclude_param = @controller.class.
|
|
47
|
+
only_param = @controller.class.only_query_param
|
|
48
|
+
except_param = @controller.class.except_query_param
|
|
49
|
+
include_param = @controller.class.include_query_param
|
|
50
|
+
exclude_param = @controller.class.exclude_query_param
|
|
51
51
|
|
|
52
52
|
only = EXTRACT_FROM_QUERY.call(only_param, @controller)
|
|
53
53
|
except = EXTRACT_FROM_QUERY.call(except_param, @controller)
|
|
@@ -98,35 +98,20 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
98
98
|
self.config
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
# Get a native serializer configuration from the controller.
|
|
102
|
-
def get_controller_native_serializer_config
|
|
103
|
-
return nil unless @controller
|
|
104
|
-
|
|
105
|
-
if @many == true
|
|
106
|
-
controller_serializer = @controller.class.native_serializer_plural_config
|
|
107
|
-
elsif @many == false
|
|
108
|
-
controller_serializer = @controller.class.native_serializer_singular_config
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
controller_serializer || @controller.class.native_serializer_config
|
|
112
|
-
end
|
|
113
|
-
|
|
114
101
|
# The record cap for a collection association (`nil` = unlimited). The default is applied even
|
|
115
102
|
# when the feature is off, so responses are always bounded. `key?` (not `||`) reads the
|
|
116
103
|
# `field_config` override so an explicit `nil` there means unlimited/uncapped rather than falling
|
|
117
104
|
# back to the controller default.
|
|
118
105
|
def _effective_association_limit(association_name, field_config)
|
|
119
|
-
|
|
106
|
+
klass = @controller&.class
|
|
120
107
|
|
|
121
|
-
default = field_config.key?(:limit) ?
|
|
122
|
-
|
|
123
|
-
return default unless controller&.enable_association_queries
|
|
108
|
+
default = field_config.key?(:limit) ? field_config[:limit] : klass&.association_limit
|
|
109
|
+
return default unless klass&.enable_association_queries
|
|
124
110
|
|
|
125
111
|
requested = self._requested_association_limit(association_name)
|
|
126
112
|
return default if requested.nil?
|
|
127
113
|
|
|
128
|
-
max = field_config.key?(:limit_max) ?
|
|
129
|
-
field_config[:limit_max] : controller.association_limit_max
|
|
114
|
+
max = field_config.key?(:limit_max) ? field_config[:limit_max] : klass.association_limit_max
|
|
130
115
|
|
|
131
116
|
# `all` means "as many as allowed" — the cap, or unlimited when the cap is `nil`.
|
|
132
117
|
return max if requested == :all
|
|
@@ -187,10 +172,10 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
187
172
|
# Recursively translate an association's fields into a `serializable_hash` config
|
|
188
173
|
# (`only`/`methods`/`include`). Columns go to `only` and plain methods to `methods`; a field that
|
|
189
174
|
# is itself an association is recursed into (as a nested `include`) only when it has its own entry
|
|
190
|
-
# in `
|
|
191
|
-
# before (its full `as_json`), so deeper nesting is opt-in and never narrows the
|
|
192
|
-
def _build_association_config(model, fields,
|
|
193
|
-
nested
|
|
175
|
+
# in `nested` (the per-field `config:` map). Otherwise it falls through to a method and serializes
|
|
176
|
+
# as before (its full `as_json`), so deeper nesting is opt-in and never narrows the output.
|
|
177
|
+
def _build_association_config(model, fields, nested)
|
|
178
|
+
nested ||= {}
|
|
194
179
|
only = []
|
|
195
180
|
methods = []
|
|
196
181
|
includes = {}
|
|
@@ -202,9 +187,11 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
202
187
|
if sf.in?(model.column_names)
|
|
203
188
|
only << sf
|
|
204
189
|
elsif sub_field_config && (ref = model.reflect_on_association(sf.to_sym)) && !ref.polymorphic?
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
190
|
+
sub_spec = RESTFramework::Utils.normalize_field_spec(sub_field_config[:fields])
|
|
191
|
+
sub_fields = RESTFramework::Utils.resolve_field_names(
|
|
192
|
+
sub_spec, RESTFramework::Utils.association_fields_for(ref)
|
|
193
|
+
)
|
|
194
|
+
includes[sf] = self._build_association_config(ref.klass, sub_fields, sub_spec[:config])
|
|
208
195
|
elsif model.method_defined?(sf)
|
|
209
196
|
methods << sf
|
|
210
197
|
else
|
|
@@ -241,14 +228,29 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
241
228
|
if f.in?(column_names)
|
|
242
229
|
columns << f
|
|
243
230
|
elsif ref = reflections[f]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
231
|
+
# A polymorphic `belongs_to` has no single target class to introspect, so serialize it via a
|
|
232
|
+
# method that emits the `type` (from the `*_type` column) and id, a label when the target
|
|
233
|
+
# has one, and any other configured `fields` the target responds to. Consumer-driven
|
|
234
|
+
# field/limit requests don't apply (they need one target class; see the serializer method).
|
|
235
|
+
if ref.polymorphic?
|
|
236
|
+
foreign_type = ref.foreign_type
|
|
237
|
+
fields = field_config[:fields]
|
|
238
|
+
serializer_methods[f] = f
|
|
239
|
+
includes_map[f] = f.to_sym
|
|
240
|
+
self.define_singleton_method(f) do |record|
|
|
241
|
+
next nil unless target = record.send(f)
|
|
242
|
+
|
|
243
|
+
RESTFramework::Utils.serialize_polymorphic(target, record.send(foreign_type), fields)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
next
|
|
250
247
|
end
|
|
251
248
|
|
|
249
|
+
effective_fields = self._effective_association_fields(f, ref, field_config)
|
|
250
|
+
sub_config = self._build_association_config(
|
|
251
|
+
ref.klass, effective_fields, field_config[:field_config]
|
|
252
|
+
)
|
|
253
|
+
|
|
252
254
|
# Apply certain rules regarding collection associations.
|
|
253
255
|
if ref.collection?
|
|
254
256
|
# A finite limit needs a per-record `.limit` query, since eager `includes` can't cap rows
|
|
@@ -343,11 +345,6 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
343
345
|
return local_config.deep_dup
|
|
344
346
|
end
|
|
345
347
|
|
|
346
|
-
# Return a serializer config if one is defined on the controller.
|
|
347
|
-
if serializer_config = self.get_controller_native_serializer_config
|
|
348
|
-
return serializer_config.deep_dup
|
|
349
|
-
end
|
|
350
|
-
|
|
351
348
|
# If the config wasn't determined, build a serializer config from controller fields.
|
|
352
349
|
if @model && self.fields
|
|
353
350
|
return self._get_controller_serializer_config
|
data/lib/rest_framework/utils.rb
CHANGED
|
@@ -96,34 +96,63 @@ module RESTFramework::Utils
|
|
|
96
96
|
s
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
#
|
|
99
|
+
# A `fields` spec's structural (non-`config`) keys, i.e. those that shape set membership.
|
|
100
|
+
FIELD_SPEC_KEYS = [ :only, :except, :include, :exclude, :config ].freeze
|
|
101
|
+
|
|
102
|
+
# Normalize a field spec — an Array, a Hash, or nil — into a canonical
|
|
103
|
+
# `{only:, include:, exclude:, config:}` Hash containing only the keys that are present. A plain
|
|
104
|
+
# Array is sugar for `only:`. `except:` is an alias of `exclude:`; the two are merged.
|
|
105
|
+
def self.normalize_field_spec(spec)
|
|
106
|
+
return {} if spec.nil?
|
|
107
|
+
return { only: spec } unless spec.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
exclude = (Array(spec[:exclude]) + Array(spec[:except])).presence
|
|
110
|
+
{ only: spec[:only], include: spec[:include], exclude: exclude, config: spec[:config] }.compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Resolve a field spec (Array | Hash | nil) to an ordered array of string field names: start from
|
|
114
|
+
# `base` unless `only:` replaces it, then apply `include:`. Any field named in `config:` is
|
|
115
|
+
# implicitly part of the set (so a configured field never needs to be listed twice). `exclude:` is
|
|
116
|
+
# applied last, so it can still drop a field that `config:`/`include:` would otherwise add.
|
|
117
|
+
def self.resolve_field_names(spec, base)
|
|
118
|
+
spec = self.normalize_field_spec(spec)
|
|
119
|
+
names = (spec[:only] || base).map(&:to_s)
|
|
120
|
+
names += spec[:include].map(&:to_s) if spec[:include]
|
|
121
|
+
names |= spec[:config].keys.map(&:to_s) if spec[:config]
|
|
122
|
+
names -= spec[:exclude].map(&:to_s) if spec[:exclude]
|
|
123
|
+
names
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resolve a top-level `fields` hash to an ordered array of string field names, using the model's
|
|
127
|
+
# default fields as the base. The `config:` key carries per-field configuration and is ignored for
|
|
128
|
+
# membership.
|
|
100
129
|
def self.parse_fields_hash(h, model, exclude_associations:, action_text:, active_storage:)
|
|
101
|
-
|
|
102
|
-
model
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
) : []
|
|
108
|
-
)
|
|
109
|
-
parsed_fields += h[:include].map(&:to_s) if h[:include]
|
|
110
|
-
parsed_fields -= h[:exclude].map(&:to_s) if h[:exclude]
|
|
111
|
-
parsed_fields -= h[:except].map(&:to_s) if h[:except]
|
|
130
|
+
base = model ? self.fields_for(
|
|
131
|
+
model,
|
|
132
|
+
exclude_associations: exclude_associations,
|
|
133
|
+
action_text: action_text,
|
|
134
|
+
active_storage: active_storage,
|
|
135
|
+
) : []
|
|
112
136
|
|
|
113
137
|
# Warn for any unknown keys.
|
|
114
|
-
(h.keys -
|
|
138
|
+
(h.keys.map(&:to_sym) - FIELD_SPEC_KEYS).each do |k|
|
|
115
139
|
Rails.logger.warn("RRF: Unknown key in fields hash: #{k}.")
|
|
116
140
|
end
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
parsed_fields.map(&:to_s)
|
|
142
|
+
self.resolve_field_names(h, base)
|
|
120
143
|
end
|
|
121
144
|
|
|
122
145
|
# Get the fields for a given model, including not just columns (which includes foreign keys), but
|
|
123
146
|
# also associations. Note that we always return an array of strings, not symbols.
|
|
124
147
|
def self.fields_for(model, exclude_associations:, action_text:, active_storage:)
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
belongs_to = model.reflect_on_all_associations(:belongs_to)
|
|
149
|
+
|
|
150
|
+
# A polymorphic `belongs_to` is backed by both a foreign key and a `*_type` column; the
|
|
151
|
+
# association represents both, so drop them from the plain column fields (as we already do for
|
|
152
|
+
# every foreign key).
|
|
153
|
+
excluded_columns = belongs_to.map(&:foreign_key)
|
|
154
|
+
excluded_columns += belongs_to.select(&:polymorphic?).map(&:foreign_type)
|
|
155
|
+
base_fields = model.column_names.reject { |c| c.in?(excluded_columns) }
|
|
127
156
|
|
|
128
157
|
return base_fields if exclude_associations
|
|
129
158
|
|
|
@@ -173,7 +202,36 @@ module RESTFramework::Utils
|
|
|
173
202
|
return fields
|
|
174
203
|
end
|
|
175
204
|
|
|
176
|
-
|
|
205
|
+
# A polymorphic association has no single target class, so we can't resolve a label column ahead
|
|
206
|
+
# of time. The id and type together identify the record; a per-record label is added at
|
|
207
|
+
# serialization time when the target has one (see `serialize_polymorphic`).
|
|
208
|
+
[ "id", "type" ]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Serialize a polymorphic association's target as `{<pk> => id, "type" => type}`, plus a label
|
|
212
|
+
# entry when the target responds to one of the configured `label_fields`. The type comes from the
|
|
213
|
+
# parent's `*_type` column, so it matches exactly what is stored (and what a reverse lookup uses).
|
|
214
|
+
#
|
|
215
|
+
# `fields` are the association's configured fields. `id`/`type` are always emitted above; any
|
|
216
|
+
# other field is resolved against the target and included when it responds to it, so a field
|
|
217
|
+
# absent on a target class (e.g. `price` on a `Genre`) is omitted rather than serialized as nil.
|
|
218
|
+
def self.serialize_polymorphic(target, type, fields = nil)
|
|
219
|
+
result = {}
|
|
220
|
+
Array(target.class.primary_key).each { |pk| result[pk] = target.public_send(pk) }
|
|
221
|
+
result["type"] = type
|
|
222
|
+
|
|
223
|
+
if label = RESTFramework.config.label_fields.find { |f| target.respond_to?(f) }
|
|
224
|
+
result[label.to_s] = target.public_send(label)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
Array(fields).each do |f|
|
|
228
|
+
f = f.to_s
|
|
229
|
+
next if f.in?(%w[id type]) || result.key?(f)
|
|
230
|
+
|
|
231
|
+
result[f] = target.public_send(f) if target.respond_to?(f)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
result
|
|
177
235
|
end
|
|
178
236
|
|
|
179
237
|
# Get a field's id/ids variation.
|
|
@@ -1,41 +1,3 @@
|
|
|
1
1
|
module RESTFramework
|
|
2
|
-
|
|
3
|
-
module Version
|
|
4
|
-
VERSION_FILEPATH = File.expand_path("../../VERSION", __dir__)
|
|
5
|
-
UNKNOWN = "0-unknown"
|
|
6
|
-
|
|
7
|
-
def self.get_version(skip_git: false)
|
|
8
|
-
# First, attempt to get the version from git.
|
|
9
|
-
unless skip_git
|
|
10
|
-
version = `git describe --dirty 2>/dev/null`&.strip
|
|
11
|
-
return version unless !version || version.empty?
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Git failed or was skipped, so try to find a VERSION file.
|
|
15
|
-
begin
|
|
16
|
-
version = File.read(VERSION_FILEPATH)&.strip
|
|
17
|
-
return version unless !version || version.empty?
|
|
18
|
-
rescue SystemCallError
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# No VERSION file, so version is unknown.
|
|
22
|
-
UNKNOWN
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.stamp_version(version = nil)
|
|
26
|
-
# Stamp the given version into the VERSION file, deriving it from git when none is provided.
|
|
27
|
-
# Returns the stamped version so callers don't rely on the (require-time) `VERSION` constant.
|
|
28
|
-
version ||= self.get_version
|
|
29
|
-
if version != UNKNOWN
|
|
30
|
-
File.write(VERSION_FILEPATH, version)
|
|
31
|
-
end
|
|
32
|
-
version
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.unstamp_version
|
|
36
|
-
File.delete(VERSION_FILEPATH) if File.exist?(VERSION_FILEPATH)
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
VERSION = Version.get_version(skip_git: true)
|
|
2
|
+
VERSION = "2.0.0.rc2"
|
|
41
3
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rest_framework
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.0.
|
|
4
|
+
version: 2.0.0.rc2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gregory N. Schmit
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -34,7 +34,6 @@ files:
|
|
|
34
34
|
- ".yardopts"
|
|
35
35
|
- LICENSE
|
|
36
36
|
- README.md
|
|
37
|
-
- VERSION
|
|
38
37
|
- app/views/layouts/rest_framework.html.erb
|
|
39
38
|
- app/views/rest_framework/_breadcrumbs.html.erb
|
|
40
39
|
- app/views/rest_framework/_head.html.erb
|
data/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2.0.0.beta6
|