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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 827c588ec16bebf728f1bc186d0b716488be2a13d7d653fec82ff3c1bd3d7a75
4
- data.tar.gz: f1447d4d393e9ab18fc82a2edeb1b366d7236ad46a00aa5e1adedb294262f3a9
3
+ metadata.gz: 358f030b86e0232e68278653fae211e71cf1efe419c7050119118d36169b1e2b
4
+ data.tar.gz: 7b28aa42477c3a4eb9aad33dbdc78220e12fe5a4f359b701cee0dde999e1aa84
5
5
  SHA512:
6
- metadata.gz: 818d459d7b6897e777edb7d403e23087c19d2a18c8002cb8fc343381c514b7aa49da625f460d0fd06a181d76fe55308ec2642f493f4627aef82d98d341775014
7
- data.tar.gz: 50226b18ddd51c61a88952c2637c3ea0c622ef59385ddcaff8b231dc0b2aabcacbe6fb5dd9a7db5d4d49bc67600c1d543781e9065cb37faa2344998186df3018
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`; the `?associations_limit=N` param is gone.
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 enum_variants = cfg[:enum_variants]
187
- v[:enum] = enum_variants.keys
188
- v[:"x-rrf-enum_variants"] = enum_variants
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
- # Options for the default native serializer.
44
- native_serializer_config: nil,
45
- native_serializer_singular_config: nil,
46
- native_serializer_plural_config: nil,
47
- native_serializer_only_query_param: "only".freeze,
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
- # The number of records serialized per collection association, so responses are bounded out of
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.field_config&.with_indifferent_access || {}
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[:enum_variants] = type.send(:mapping)
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
- cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
369
- cfg[:fields] = cfg[:fields].map(&:to_s)
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 serializer makes its `get_fields` meaningless. Hidden fields are
483
- # included (retrievable via `?only=` there); write-only fields and nested associations aren't.
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 them
690
- # (e.g. `?favorite.name=x` or `?ordering=favorite.name`) would raise. This method is the
691
- # safe field surface for those query features.
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 (see `scope_nested_through_controllers`), so per-level access scoping
910
- # is enforced. Returns `nil` when there is no nested parent, or a `<name>_id` param can't connect.
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 self.class.scope_nested_by_parent && request
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 the feature is off or no sibling
954
- # controller is found. The ad-hoc instance shares this request and skips its own nested scoping.
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.native_serializer_only_query_param
48
- except_param = @controller.class.native_serializer_except_query_param
49
- include_param = @controller.class.native_serializer_include_query_param
50
- exclude_param = @controller.class.native_serializer_exclude_query_param
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
- controller = @controller&.class
106
+ klass = @controller&.class
120
107
 
121
- default = field_config.key?(:limit) ?
122
- field_config[:limit] : controller&.association_limit
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 `field_config[:field_config]`. Otherwise it falls through to a method and serializes as
191
- # before (its full `as_json`), so deeper nesting is opt-in and never narrows the default output.
192
- def _build_association_config(model, fields, field_config)
193
- nested = field_config[:field_config] || {}
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
- sub_fields = sub_field_config[:fields]&.map(&:to_s) ||
206
- RESTFramework::Utils.association_fields_for(ref)
207
- includes[sf] = self._build_association_config(ref.klass, sub_fields, sub_field_config)
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 always emits the `type` (from the `*_type` column) alongside the id, plus a
246
- # label when the target has one. Filtering/ordering skip polymorphic associations (see
247
- # `readable_columns_or_associations`), so consumer-driven field/limit requests don't apply.
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(ref.klass, effective_fields, field_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
@@ -96,27 +96,50 @@ module RESTFramework::Utils
96
96
  s
97
97
  end
98
98
 
99
- # Parse fields hashes.
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
- parsed_fields = h[:only] || (
102
- model ? self.fields_for(
103
- model,
104
- exclude_associations: exclude_associations,
105
- action_text: action_text,
106
- active_storage: active_storage,
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 - [ :only, :except, :include, :exclude ]).each do |k|
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
- # We should always return strings, not symbols.
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
- def self.serialize_polymorphic(target, type)
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
@@ -1,3 +1,3 @@
1
1
  module RESTFramework
2
- VERSION = "2.0.0.rc1"
2
+ VERSION = "2.0.0.rc2"
3
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.rc1
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-12 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails