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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7be6812ae478ca58b5f352bf561a57384d309e2a91037d25cf1ad3dbe64ff09
4
- data.tar.gz: a2295b59abd1f6e15fa0736b3e5255f00492a0cdc794583b87b85634533cb003
3
+ metadata.gz: 358f030b86e0232e68278653fae211e71cf1efe419c7050119118d36169b1e2b
4
+ data.tar.gz: 7b28aa42477c3a4eb9aad33dbdc78220e12fe5a4f359b701cee0dde999e1aa84
5
5
  SHA512:
6
- metadata.gz: 6a92158c48314c5fb62470cde91d5b21d264e60b8d3d367315b20014965cceaee9bef5ff019c32cc3ef75fb9a45a410cc63373d2dc9c00fa295f6626ec279a6b
7
- data.tar.gz: a332502f06c34126962890d6e4b395d5788c8dd1c07d5bd0cfbbcb33d49ec209efad4986d99f1f69ec0f86569b175bb902a11aee76cb21f4942546e256e0239b
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 Root API Index
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`; 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`.
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), or `:exclude_self`
116
- # (descendants only). Non-standard values warn: `nil` becomes `false`, anything else truthy
117
- # becomes `true`.
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 controller
134
- # we're composing for. `is_self` is true when that class is the controller itself.
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
- else # false
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 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]
@@ -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
- # 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
@@ -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
- cfg[:read_only] = true if f.in?(readonly_attributes) || f.in?(read_only_fields)
315
- cfg[:write_only] = true if f.in?(write_only_fields)
316
- cfg[:hidden] = true if f.in?(hidden_fields)
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[:enum_variants] = type.send(:mapping)
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
- cfg[:fields] ||= RESTFramework::Utils.association_fields_for(ref)
380
- 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]
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
- cfg[:read_only] = true if cfg[:read_only].nil?
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 serializer makes its `get_fields` meaningless. Hidden fields are
478
- # 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.
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
- cfg = self.class.field_configuration
652
- self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
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
- cfg = self.class.field_configuration
661
- self.get_fields.reject { |f| cfg[f]&.[](:read_only) }
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
- cfg = self.class.field_configuration
675
- columns = self.class.model.column_names
676
- self.readable_fields.select { |f| f.in?(columns) || cfg[f]&.[](:kind) == "association" }
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 (see `scope_nested_through_controllers`), so per-level access scoping
859
- # 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.
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 self.class.scope_nested_by_parent && request
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 the feature is off or no sibling
903
- # 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.
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
- class Not
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
- def _rrf_route_actions(actions)
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.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
@@ -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
- effective_fields = self._effective_association_fields(f, ref, field_config)
245
- sub_config = if ref.polymorphic?
246
- # No single target class to introspect, so serialize every field as a method.
247
- { only: [], methods: effective_fields }
248
- else
249
- self._build_association_config(ref.klass, effective_fields, field_config)
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
@@ -96,34 +96,63 @@ 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
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
- foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
126
- base_fields = model.column_names.reject { |c| c.in?(foreign_keys) }
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
- [ "id", "name" ]
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
- # Do not use Rails-specific helper methods here (e.g., `blank?`) so the module can run standalone.
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.beta6
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-04 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
@@ -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