rest_framework 2.0.0.beta3 → 2.0.0.beta5

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: 7cedfa02dac90388b712f4e15e29d07a40e45df23847bebf5da02eea03862e75
4
- data.tar.gz: 2de9c962810e9473dd5eb68c2ee42da764ed793e341049a49d9f4ca8d34fa48e
3
+ metadata.gz: e483f8e7b422c2a45545f8c6e5d6313cc8a3df9dc286e76d4a6f09b9c717d9bf
4
+ data.tar.gz: 16d95dda8928c50b01cbc9cc291778928c9023d77e05c35f51cc9c8411a4c7d0
5
5
  SHA512:
6
- metadata.gz: f52b1b1075fef181d9e771df56f1c088c73601d600606411974febb1bb544facacc7d16d83c2b34929acc0d0f44a0e518098a3a121fcdf27f7e5f12be3a5d8ca
7
- data.tar.gz: c08ffc3af87bb7d7337dd1a908c7c6199fa3e70328b06dbebd1a691a0c3f235224b0119026eda4274f555385254383d2a7a29719b339f5c487f0ee82a7b2bcd2
6
+ metadata.gz: 05ae9adf0e303bc8aa23acda8d33f8e2026de7e123ec57a1c51dc030b92b25caed62d872badf76b9c8e3c0f549c5899b59c3352b2620c767b61d4e1b1ac34672
7
+ data.tar.gz: ca6913825b5eaf6c6545359c494b8b38eae2deb87e285ed8d1e761ab38700f1f0e2be4469d3ab6c604bc98d30918b4c1c40ce0098d32027e0bed52db51c2b049
data/README.md CHANGED
@@ -137,18 +137,20 @@ end
137
137
 
138
138
  ### Routing
139
139
 
140
- Use `rest_route` to route any controller. It wraps Rails' `resource` / `resources` routers, picking
141
- `resources` for a plural model controller and `resource` otherwise, and automatically routes the
142
- controller's built-in and extra actions. A controller with a `model` gets the full CRUD set; a
143
- modelless controller is routed at its root (its `index`, which renders `index_content`).
140
+ Use `rest_resource` to route a controller (`rest_resources` routes several that share options). They
141
+ wrap Rails' `resource` / `resources` routers, picking `resources` for a plural model controller and
142
+ `resource` otherwise so plurality follows the controller's config, not the helper name. Built-in
143
+ and extra actions are routed automatically: a controller with a `model` gets the full CRUD set; a
144
+ modelless controller is routed at its root (its `index`, which renders `index_content`). A block
145
+ nests resources, and a nested recordset is auto-scoped to its parent (`/movies/:movie_id/genres` →
146
+ `Movie.find(params[:movie_id]).genres`).
144
147
 
145
148
  ```ruby
146
149
  Rails.application.routes.draw do
147
- rest_route :api # `ApiController` serves the `/api` root.
150
+ rest_resource :api # `ApiController` serves the `/api` root.
148
151
 
149
152
  namespace :api do
150
- rest_route :movies
151
- rest_route :users
153
+ rest_resources :movies, :users
152
154
  end
153
155
  end
154
156
  ```
@@ -181,9 +183,9 @@ changes; the migration checklist that follows walks through updating an existing
181
183
  - **Declarative actions** — `add_action` / `remove_action` declare extra routes with an explicit
182
184
  `member` / `collection` scope and per-declaration propagation, and can disable built-ins too,
183
185
  replacing the `extra_actions` config hashes.
184
- - **Unified routing** — one `rest_route` (accepting several names) replaces `rest_resource` /
185
- `rest_resources` / `rest_root`; a modelless controller serves the API root from its
186
- `index_content`.
186
+ - **Simpler routing** — `rest_resource` routes one controller and `rest_resources` routes several
187
+ (plurality of the *routes* follows the controller's config, not the helper name); `rest_root` and
188
+ `rest_route` are gone, and nested resources are auto-scoped to their parent.
187
189
  - **Action delegation** — mark an action `metadata: { delegate: true }` to dispatch it to a model
188
190
  class method (collection) or record method (member), passing query params through as args/kwargs.
189
191
  - **Consumer-driven association queries** (opt-in via `enable_association_queries`) — clients can
@@ -218,7 +220,8 @@ See the guide for details on each item.
218
220
  - [ ] Convert `extra_actions` / `extra_member_actions` hashes to `add_action` / `remove_action`.
219
221
  - [ ] Render custom actions with `render(api: ...)`, replacing the older `api_response(...)` /
220
222
  `render_api(...)`.
221
- - [ ] Replace `rest_resource` / `rest_resources` / `rest_root` with `rest_route`.
223
+ - [ ] Replace `rest_root` and `rest_route` with `rest_resource` (single controller) or
224
+ `rest_resources` (several) — plurality of the routes now comes from controller config.
222
225
  - [ ] Fold any dedicated root controller into the namespace's base controller, which now serves the
223
226
  root via `index_content` (the standalone `root` action and `rest_root` are gone).
224
227
  - [ ] Rename `singleton_controller` → `singular`.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0.beta3
1
+ 2.0.0.beta5
@@ -3,6 +3,7 @@
3
3
  # module rather than defining a separate submodule.
4
4
  module RESTFramework::Controller
5
5
  RRF_BASE_CONFIG = {
6
+ model: nil,
6
7
  singular: nil,
7
8
 
8
9
  # Options related to metadata and display.
@@ -12,10 +13,6 @@ module RESTFramework::Controller
12
13
  inflect_acronyms: RESTFramework.config.inflect_acronyms,
13
14
  openapi_include_children: false,
14
15
 
15
- # Options related to models.
16
- model: nil,
17
- recordset: nil,
18
-
19
16
  # Bulk configuration.
20
17
  #
21
18
  # When `bulk` is truthy, it enables the default bulk behavior (`:default`), which is per-record
@@ -98,6 +95,10 @@ module RESTFramework::Controller
98
95
  # Option for `recordset.create` vs `Model.create` behavior.
99
96
  create_from_recordset: true,
100
97
 
98
+ # Options for scoped nested routing.
99
+ scope_nested_by_parent: true,
100
+ scope_nested_through_controllers: true,
101
+
101
102
  # Options related to serialization.
102
103
  rescue_unknown_format_with: :json,
103
104
  serializer_class: nil,
@@ -651,6 +652,15 @@ module RESTFramework::Controller
651
652
  self.get_fields.reject { |f| cfg[f]&.[](:write_only) }
652
653
  end
653
654
 
655
+ # The fields a client may write (create/update): `get_fields` minus read_only fields. Excluding
656
+ # them here — before strong parameters expand associations into `_id`/`_ids`/`_attributes` keys —
657
+ # keeps a read_only association from ever producing a permitted (and otherwise unstrippable, since
658
+ # those keys don't match a field name) assignment key.
659
+ def writable_fields
660
+ cfg = self.class.field_configuration
661
+ self.get_fields.reject { |f| cfg[f]&.[](:read_only) }
662
+ end
663
+
654
664
  # `readable_fields` restricted to real columns, for query surfaces that build SQL directly
655
665
  # (find_by, search) and would raise on a virtual/method field.
656
666
  def readable_columns
@@ -673,12 +683,14 @@ module RESTFramework::Controller
673
683
  @_get_allowed_parameters = self.class.allowed_parameters
674
684
  return @_get_allowed_parameters if @_get_allowed_parameters
675
685
 
676
- # Assemble strong parameters. Read-only fields are permitted here and stripped later per-action
677
- # by `_rrf_strip_read_only_fields` (which keeps the primary key on bulk update to find records).
686
+ # Assemble strong parameters from writable fields only, so read-only fields never produce a
687
+ # permitted key including the `_id`/`_ids`/`_attributes` variations an association expands
688
+ # into, which a later key-name-based filter couldn't catch. Bulk update re-permits the primary
689
+ # key (see `get_body_params`) since it needs it to locate each record.
678
690
  variations = []
679
691
  hash_variations = {}
680
692
  reflections = self.class.model.reflections
681
- @_get_allowed_parameters = self.get_fields.map { |f|
693
+ @_get_allowed_parameters = self.writable_fields.map { |f|
682
694
  f = f.to_s
683
695
  config = self.class.field_configuration[f]
684
696
 
@@ -823,47 +835,90 @@ module RESTFramework::Controller
823
835
  body_params[k].unshift(*v)
824
836
  end
825
837
 
826
- # Filter read-only fields. For bulk actions the permitted structure is `{ _json: [...] }`, so we
827
- # strip read-only keys from each element rather than the top-level hash (whose only key is
828
- # `_json`). Bulk update keeps the primary key, which it needs to locate each record.
829
- if bulk_action
830
- keep = bulk_action == :update ? [ pk.to_s ] : []
831
- body_params[:_json]&.each do |element|
832
- next unless element.is_a?(ActionController::Parameters)
833
-
834
- self._rrf_strip_read_only_fields(element, keep: keep)
835
- end
836
- else
837
- self._rrf_strip_read_only_fields(body_params)
838
- end
839
-
840
838
  body_params
841
839
  end
842
840
  alias_method :get_create_params, :get_body_params
843
841
  alias_method :get_update_params, :get_body_params
844
842
  alias_method :get_destroy_params, :get_body_params
845
843
 
846
- # Remove read-only fields from a permitted params hash in place. `keep` lists field names to
847
- # preserve even when read-only (e.g. the primary key on bulk update, used to locate records).
848
- def _rrf_strip_read_only_fields(params, keep: [])
849
- params.delete_if do |f, _|
850
- next false if f.in?(keep)
844
+ # Get the set of records this controller has access to. Override this to scope records (e.g. to
845
+ # the current user); the default scopes to a nested parent resource when one is present in the
846
+ # path, otherwise the model's default scope (all records).
847
+ def get_recordset
848
+ return nil unless self.class.model
851
849
 
852
- cfg = self.class.field_configuration[f]
853
- cfg && cfg[:read_only]
854
- end
850
+ self._rrf_nested_parent_recordset || self.class.model.all
855
851
  end
856
852
 
857
- # Get the set of records this controller has access to.
858
- def get_recordset
859
- return self.class.recordset if self.class.recordset
853
+ # For a nested route, walk the whole parent chain in path order and return the innermost
854
+ # collection of this controller's model — e.g. `/movies/:movie_id/genres/:genre_id/tracks` becomes
855
+ # `Movie.find(movie_id).genres.find(genre_id).tracks`. Every link is enforced (a broken one raises
856
+ # `RecordNotFound` -> 404), and each association is resolved from its parent, so `belongs_to`,
857
+ # `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.
860
+ def _rrf_nested_parent_recordset
861
+ # Set on an ad-hoc parent instance below, so evaluating a parent's `get_recordset` doesn't
862
+ # recurse back into nested scoping (we want the parent's own scope, not to re-nest it).
863
+ return nil if @_rrf_scoping_parent
864
+ return nil unless self.class.scope_nested_by_parent && request
865
+
866
+ # `<name>_id` path parameters that name a model, in route order (outermost parent first).
867
+ parents = request.path_parameters.filter_map { |key, value|
868
+ key = key.to_s
869
+ next unless key.end_with?("_id")
870
+
871
+ model = key.delete_suffix("_id").classify.safe_constantize
872
+ next unless model.is_a?(Class) && model < ActiveRecord::Base
873
+
874
+ [ model, value ]
875
+ }
876
+ return nil if parents.empty?
877
+
878
+ # Find the outermost parent within its controller's scope, then descend: each next parent is
879
+ # constrained both to the previous parent's association and to its own controller's scope.
880
+ record = nil
881
+ parents.each do |model, id|
882
+ scope = _rrf_parent_recordset(model)
883
+
884
+ unless record.nil?
885
+ assoc = _rrf_collection_association_name(record.class, model)
886
+ return nil unless assoc
860
887
 
861
- # If there is a model, return that model's default scope (all records by default).
862
- if self.class.model
863
- return self.class.model.all
888
+ scope = record.public_send(assoc).merge(scope)
889
+ end
890
+
891
+ record = scope.find(id)
864
892
  end
865
893
 
866
- nil
894
+ # Finally, the innermost parent's collection of this controller's model.
895
+ assoc = _rrf_collection_association_name(record.class, self.class.model)
896
+ return nil unless assoc
897
+
898
+ record.public_send(assoc)
899
+ end
900
+
901
+ # 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.
904
+ def _rrf_parent_recordset(model)
905
+ return model.all unless self.class.scope_nested_through_controllers
906
+
907
+ controller = RESTFramework::Utils.controller_for_model(self.class, model)
908
+ return model.all unless controller
909
+
910
+ instance = controller.new
911
+ instance.request = request
912
+ instance.response = response
913
+ instance.instance_variable_set(:@_rrf_scoping_parent, true)
914
+ instance.get_recordset
915
+ end
916
+
917
+ # The name of `klass`'s collection association whose records are `target_model`, or `nil`.
918
+ def _rrf_collection_association_name(klass, target_model)
919
+ klass.reflect_on_all_associations.find { |ref|
920
+ ref.collection? && !ref.polymorphic? && ref.klass == target_model
921
+ }&.name
867
922
  end
868
923
 
869
924
  # Filter the recordset and return records this request has access to.
@@ -32,20 +32,12 @@ module ActionDispatch::Routing
32
32
  end
33
33
  end
34
34
 
35
- # Route one or more controllers from their action stores. Plural model controllers get
36
- # collection/member scopes; singular and non-model controllers route everything at the root.
37
- # Passing several names condenses simple routes into one call; per-name options (`path:`, `as:`,
38
- # `controller:`, and a block) only apply to a single name.
39
- def rest_route(*names, **kwargs, &block)
40
- if names.size > 1 && (block || (kwargs.keys & [ :path, :as, :controller ]).any?)
41
- raise ArgumentError, "rest_route: options and a block require a single name"
42
- end
43
-
44
- names.each { |name| _rrf_rest_route(name, **kwargs, &block) }
45
- end
46
-
47
- # Route a single controller from its action store.
48
- def _rrf_rest_route(name, **kwargs)
35
+ # Route a single controller from its action store. Whether it routes as a singular `resource` or
36
+ # a plural `resources` is decided by the controller's own config (`singular`, and whether it has
37
+ # a `model`) not by the method name: a plural model controller gets collection/member scopes,
38
+ # 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).
40
+ def rest_resource(name, **kwargs)
49
41
  controller = kwargs.delete(:controller) || name
50
42
  if controller.is_a?(Class)
51
43
  controller_class = controller
@@ -84,5 +76,17 @@ module ActionDispatch::Routing
84
76
  yield if block_given?
85
77
  end
86
78
  end
79
+
80
+ # Route several controllers that share the same options in one call — handy for condensing a
81
+ # namespace. Per-name options (`path:`, `as:`, `controller:`) and a block only make sense for a
82
+ # single controller, so they're rejected when several names are given. The `s` is about routing
83
+ # multiple controllers, not plural routes — a single plural controller is still `rest_resource`.
84
+ def rest_resources(*names, **kwargs, &block)
85
+ if names.size > 1 && (block || (kwargs.keys & [ :path, :as, :controller ]).any?)
86
+ raise ArgumentError, "rest_resources: per-name options and a block require a single name"
87
+ end
88
+
89
+ names.each { |name| rest_resource(name, **kwargs, &block) }
90
+ end
87
91
  end
88
92
  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.beta3
4
+ version: 2.0.0.beta5
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-01 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails