rest_framework 2.0.0.rc1 → 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 +10 -1
- data/lib/rest_framework/controller/openapi.rb +5 -3
- data/lib/rest_framework/controller.rb +54 -46
- 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 +9 -0
- data/lib/rest_framework/serializers/native_serializer.rb +25 -40
- data/lib/rest_framework/utils.rb +51 -17
- data/lib/rest_framework/version.rb +1 -1
- metadata +2 -2
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
|
@@ -253,7 +253,16 @@ See the guide for details on each item.
|
|
|
253
253
|
array).
|
|
254
254
|
- [ ] Rename config: `sub_fields` → `fields`, `native_serializer_associations_limit[_max]` →
|
|
255
255
|
`association_limit[_max]`, `native_serializer_include_associations_count` →
|
|
256
|
-
`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`.
|
|
257
266
|
- [ ] Note client-visible behavior changes: delegated actions wrap their result under a `return`
|
|
258
267
|
key; a non-permitted `find_by` returns `404`; `update_all` / `destroy_all` are plural-only;
|
|
259
268
|
ordering/pagination read from the query string only.
|
|
@@ -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]
|
|
@@ -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
|
|
@@ -342,9 +327,9 @@ module RESTFramework::Controller
|
|
|
342
327
|
if type = attribute.type
|
|
343
328
|
cfg[:type] ||= type.type if type.type
|
|
344
329
|
|
|
345
|
-
# Get enum variants.
|
|
346
330
|
if type.is_a?(ActiveRecord::Enum::EnumType)
|
|
347
|
-
cfg[:
|
|
331
|
+
cfg[:enum] = true
|
|
332
|
+
cfg[:options] ||= type.send(:mapping).invert
|
|
348
333
|
|
|
349
334
|
# TranslateEnum Integration:
|
|
350
335
|
translate_method = "translated_#{f.pluralize}"
|
|
@@ -365,8 +350,15 @@ module RESTFramework::Controller
|
|
|
365
350
|
else
|
|
366
351
|
ref_columns = ref.klass.columns_hash
|
|
367
352
|
end
|
|
368
|
-
|
|
369
|
-
|
|
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]
|
|
370
362
|
|
|
371
363
|
# Strings, to match `:fields` when intersecting requested fields against the allowlist.
|
|
372
364
|
if cfg[:requestable_fields]
|
|
@@ -479,17 +471,14 @@ module RESTFramework::Controller
|
|
|
479
471
|
# The fields a consumer may request for an association beyond its defaults, derived from the
|
|
480
472
|
# associated model's sibling controller: what that controller serializes, so the association can
|
|
481
473
|
# never expose more than its own endpoint would. Empty unless the sibling is discoverable and
|
|
482
|
-
# introspectable — a custom
|
|
483
|
-
# 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.
|
|
484
476
|
def association_requestable_fields(ref)
|
|
485
477
|
return [] if ref.polymorphic?
|
|
486
478
|
|
|
487
479
|
sibling = RESTFramework::Utils.controller_for_model(self, ref.klass)
|
|
488
480
|
return [] unless sibling
|
|
489
|
-
return [] if sibling.serializer_class
|
|
490
|
-
sibling.native_serializer_config ||
|
|
491
|
-
sibling.native_serializer_singular_config ||
|
|
492
|
-
sibling.native_serializer_plural_config
|
|
481
|
+
return [] if sibling.serializer_class
|
|
493
482
|
|
|
494
483
|
cfg = sibling.field_configuration
|
|
495
484
|
sibling.get_fields.reject { |sf|
|
|
@@ -686,15 +675,35 @@ module RESTFramework::Controller
|
|
|
686
675
|
self.readable_fields.select do |f|
|
|
687
676
|
next true if f.in?(columns)
|
|
688
677
|
|
|
689
|
-
# Skip polymorphic associations: they can't be JOINed, so filtering or ordering through
|
|
690
|
-
# (e.g. `?favorite.name=x`
|
|
691
|
-
#
|
|
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`.
|
|
692
681
|
field = cfg[f]
|
|
693
682
|
field&.[](:kind) == "association" && !field[:reflection]&.polymorphic?
|
|
694
683
|
end
|
|
695
684
|
end
|
|
696
685
|
end
|
|
697
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
|
|
705
|
+
end
|
|
706
|
+
|
|
698
707
|
# Get a hash of strong parameters for the current action.
|
|
699
708
|
def get_allowed_parameters
|
|
700
709
|
return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
|
|
@@ -906,13 +915,14 @@ module RESTFramework::Controller
|
|
|
906
915
|
# `Movie.find(movie_id).genres.find(genre_id).tracks`. Every link is enforced (a broken one raises
|
|
907
916
|
# `RecordNotFound` -> 404), and each association is resolved from its parent, so `belongs_to`,
|
|
908
917
|
# `has_many`, and `has_and_belongs_to_many` children all work. Each parent is looked up via its
|
|
909
|
-
# own controller's recordset
|
|
910
|
-
#
|
|
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.
|
|
911
921
|
def _rrf_nested_parent_recordset
|
|
912
922
|
# Set on an ad-hoc parent instance below, so evaluating a parent's `get_recordset` doesn't
|
|
913
923
|
# recurse back into nested scoping (we want the parent's own scope, not to re-nest it).
|
|
914
924
|
return nil if @_rrf_scoping_parent
|
|
915
|
-
return nil unless
|
|
925
|
+
return nil unless request
|
|
916
926
|
|
|
917
927
|
# `<name>_id` path parameters that name a model, in route order (outermost parent first).
|
|
918
928
|
parents = request.path_parameters.filter_map { |key, value|
|
|
@@ -950,11 +960,9 @@ module RESTFramework::Controller
|
|
|
950
960
|
end
|
|
951
961
|
|
|
952
962
|
# A parent's recordset for the nested-scope walk: its own controller's `get_recordset` (so that
|
|
953
|
-
# controller's access scoping is reused), or the bare model when
|
|
954
|
-
#
|
|
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.
|
|
955
965
|
def _rrf_parent_recordset(model)
|
|
956
|
-
return model.all unless self.class.scope_nested_through_controllers
|
|
957
|
-
|
|
958
966
|
controller = RESTFramework::Utils.controller_for_model(self.class, model)
|
|
959
967
|
return model.all unless controller
|
|
960
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.
|
|
@@ -57,6 +57,7 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
57
57
|
# query config in the form of: `[base_query, pred_queries, includes]`.
|
|
58
58
|
def _get_query_config
|
|
59
59
|
fields = self._get_fields
|
|
60
|
+
poly_columns = self._polymorphic_columns(@controller.class.filter_fields)
|
|
60
61
|
includes = []
|
|
61
62
|
|
|
62
63
|
# Predicate queries must be added to a separate list because multiple predicates can be used.
|
|
@@ -75,10 +76,18 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
|
|
|
75
76
|
next [ field, v ]
|
|
76
77
|
end
|
|
77
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
|
+
|
|
78
85
|
# First, try to parse a simple predicate and check if it is filterable.
|
|
79
86
|
pred_field, predicate = self.parse_predicate(field)
|
|
80
87
|
if predicate && pred_field.in?(fields)
|
|
81
88
|
field = pred_field
|
|
89
|
+
elsif predicate && (column = poly_columns[pred_field])
|
|
90
|
+
field = column
|
|
82
91
|
else
|
|
83
92
|
# Last, try to parse a sub-field or sub-field w/predicate.
|
|
84
93
|
root_field, sub_field = field.split(".", 2)
|
|
@@ -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
|
|
@@ -242,24 +229,27 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
242
229
|
columns << f
|
|
243
230
|
elsif ref = reflections[f]
|
|
244
231
|
# A polymorphic `belongs_to` has no single target class to introspect, so serialize it via a
|
|
245
|
-
# method that
|
|
246
|
-
#
|
|
247
|
-
#
|
|
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).
|
|
248
235
|
if ref.polymorphic?
|
|
249
236
|
foreign_type = ref.foreign_type
|
|
237
|
+
fields = field_config[:fields]
|
|
250
238
|
serializer_methods[f] = f
|
|
251
239
|
includes_map[f] = f.to_sym
|
|
252
240
|
self.define_singleton_method(f) do |record|
|
|
253
241
|
next nil unless target = record.send(f)
|
|
254
242
|
|
|
255
|
-
RESTFramework::Utils.serialize_polymorphic(target, record.send(foreign_type))
|
|
243
|
+
RESTFramework::Utils.serialize_polymorphic(target, record.send(foreign_type), fields)
|
|
256
244
|
end
|
|
257
245
|
|
|
258
246
|
next
|
|
259
247
|
end
|
|
260
248
|
|
|
261
249
|
effective_fields = self._effective_association_fields(f, ref, field_config)
|
|
262
|
-
sub_config = self._build_association_config(
|
|
250
|
+
sub_config = self._build_association_config(
|
|
251
|
+
ref.klass, effective_fields, field_config[:field_config]
|
|
252
|
+
)
|
|
263
253
|
|
|
264
254
|
# Apply certain rules regarding collection associations.
|
|
265
255
|
if ref.collection?
|
|
@@ -355,11 +345,6 @@ class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers:
|
|
|
355
345
|
return local_config.deep_dup
|
|
356
346
|
end
|
|
357
347
|
|
|
358
|
-
# Return a serializer config if one is defined on the controller.
|
|
359
|
-
if serializer_config = self.get_controller_native_serializer_config
|
|
360
|
-
return serializer_config.deep_dup
|
|
361
|
-
end
|
|
362
|
-
|
|
363
348
|
# If the config wasn't determined, build a serializer config from controller fields.
|
|
364
349
|
if @model && self.fields
|
|
365
350
|
return self._get_controller_serializer_config
|
data/lib/rest_framework/utils.rb
CHANGED
|
@@ -96,27 +96,50 @@ 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
|
|
@@ -188,13 +211,24 @@ module RESTFramework::Utils
|
|
|
188
211
|
# Serialize a polymorphic association's target as `{<pk> => id, "type" => type}`, plus a label
|
|
189
212
|
# entry when the target responds to one of the configured `label_fields`. The type comes from the
|
|
190
213
|
# parent's `*_type` column, so it matches exactly what is stored (and what a reverse lookup uses).
|
|
191
|
-
|
|
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)
|
|
192
219
|
result = {}
|
|
193
220
|
Array(target.class.primary_key).each { |pk| result[pk] = target.public_send(pk) }
|
|
194
221
|
result["type"] = type
|
|
195
222
|
|
|
196
223
|
if label = RESTFramework.config.label_fields.find { |f| target.respond_to?(f) }
|
|
197
|
-
result[label] = target.public_send(label)
|
|
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)
|
|
198
232
|
end
|
|
199
233
|
|
|
200
234
|
result
|
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
|