From 4aab28050d448a4408424c0fb070611587e30eba Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:00:07 -0400 Subject: [PATCH 01/10] Snapshot path settings through InheritableSetting#path_settings Router::Pattern::Path reads exactly six values; the merged dump of both full stores in Endpoint#prepare_default_path_settings carried everything else as dead weight. The nil-tolerant guards in Path replace the key?-presence checks (absent and nil are equivalent under the always- present-keys snapshot). Co-Authored-By: Claude Fable 5 --- .rubocop.yml | 2 +- lib/grape/endpoint.rb | 8 +------- lib/grape/router/pattern/path.rb | 15 ++++++--------- lib/grape/util/inheritable_setting.rb | 17 +++++++++++++++++ 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 114b2cab7..c726c6f30 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -52,7 +52,7 @@ Metrics/BlockLength: - spec/**/*_spec.rb Metrics/ClassLength: - Max: 320 + Max: 330 Metrics/CyclomaticComplexity: Max: 15 diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index d6388910a..754044515 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -282,7 +282,7 @@ def compile! def to_routes route_options = config.route_options params = config.params - path_settings = prepare_default_path_settings + path_settings = inheritable_setting.path_settings forward_match = bare_rack_app? version = prepare_version(inheritable_setting.version) prefix = inheritable_setting.root_prefix @@ -313,12 +313,6 @@ def bare_rack_app? config.app && !config.app.is_a?(Grape::Mountable) end - def prepare_default_path_settings - namespace_stackable_hash = inheritable_setting.namespace_stackable.to_hash - namespace_inheritable_hash = inheritable_setting.namespace_inheritable.to_hash - namespace_stackable_hash.merge!(namespace_inheritable_hash) - end - def prepare_routes_requirements(route_options_requirements) namespace_requirements = inheritable_setting.namespace_requirements namespace_requirements << route_options_requirements if route_options_requirements.present? diff --git a/lib/grape/router/pattern/path.rb b/lib/grape/router/pattern/path.rb index ff2e97083..3c1ea8b77 100644 --- a/lib/grape/router/pattern/path.rb +++ b/lib/grape/router/pattern/path.rb @@ -4,10 +4,11 @@ module Grape class Router class Pattern # Assembles the path template a {Pattern} compiles into a matcher. It turns - # a raw path plus the API's inheritable settings (mount path, root prefix, - # path-versioning and format) into an +origin+ (the route prefix) and a - # +suffix+ (the format segment). {Pattern.build} is the entry point that - # wires this into pattern construction. + # a raw path plus the path-settings snapshot taken by + # {Grape::Util::InheritableSetting#path_settings} (mount paths, root + # prefix, path-versioning and format) into an +origin+ (the route prefix) + # and a +suffix+ (the format segment). {Pattern.build} is the entry point + # that wires this into pattern construction. class Path DEFAULT_FORMAT_SEGMENT = '(/.:format)' NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)' @@ -52,15 +53,11 @@ def not_slash?(value) end def uses_specific_format?(settings) - return false unless settings.key?(:format) && settings.key?(:content_types) - settings[:format] && Array(settings[:content_types]).size == 1 end def uses_path_versioning?(settings) - return false unless settings.key?(:version) && settings[:version_options] - - settings[:version] && settings[:version_options].using == :path + settings[:version] && settings[:version_options]&.using == :path end def valid_part?(part) diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index ba1bc7b05..ee489b7d0 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -518,6 +518,23 @@ def auth=(auth_options) namespace_inheritable[:auth] = auth_options end + # Everything Router::Pattern::Path reads to assemble a route's origin + # and suffix (see Endpoint#to_routes). +mount_path+ is the full stack — + # one entry per mount level, outermost first — unlike #mount_path, + # which returns only the outermost entry; +content_types+ is the raw + # registration stack, because Path counts registrations rather than + # distinct formats. Keys are always present; unset values are nil. + def path_settings + { + mount_path: namespace_stackable[:mount_path].presence, + root_prefix:, + format:, + content_types: namespace_stackable[:content_types].presence, + version:, + version_options: + } + end + # Rescue-handler maps registered by +rescue_from+, keyed by exception # class and merged so a nested scope's handler wins. Record them with # #add_rescue_handlers; the backing store is an internal detail. From 41d16aa76710ee3f2f783db922662970bf2ac547 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:01:15 -0400 Subject: [PATCH 02/10] Make the InheritableSetting stores protected and the deep-merge helper private namespace_stackable and namespace_inheritable are no longer public API: every semantic key is reachable only through its dedicated accessor. inherit_from's peer access continues to work through protected. The storage-engine specs reach in explicitly via __send__. Co-Authored-By: Claude Fable 5 --- lib/grape/util/inheritable_setting.rb | 24 ++++++---- spec/grape/api_spec.rb | 2 +- spec/grape/dsl/inside_route_spec.rb | 2 +- spec/grape/dsl/settings_spec.rb | 48 +++++++++---------- spec/grape/util/inheritable_setting_spec.rb | 52 ++++++++++----------- 5 files changed, 68 insertions(+), 60 deletions(-) diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index ee489b7d0..30a0688d5 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -15,7 +15,7 @@ class InheritableSetting finally: :finallies }.freeze - attr_reader :route, :namespace, :namespace_inheritable, :namespace_stackable, :parent + attr_reader :route, :namespace, :parent # Retrieve global settings. def self.global @@ -112,13 +112,6 @@ def ==(other) end alias eql? == - def namespace_stackable_with_hash(key) - data = namespace_stackable[key] - return if data.blank? - - data.each_with_object({}) { |value, result| result.deep_merge!(value) } - end - # Validator instances registered by +params+ and +contract+ blocks, # outermost scope first. Record them with #add_validation; the backing # store is an internal detail. @@ -556,6 +549,10 @@ def add_rescue_handlers(mapping, subclasses:) protected + # The raw stores. Internal: every semantic key has a dedicated public + # accessor above — reach these only from InheritableSetting itself. + attr_reader :namespace_inheritable, :namespace_stackable + # This scope's own +rescue_from+ registrations, before inheritance: # {rescue_handlers: {klass => handler}, base_only_rescue_handlers: {...}}. attr_reader :rescue_handler_maps @@ -580,6 +577,17 @@ def copy_state_from(source) @rescue_handler_maps = source.rescue_handler_maps&.dup @route = source.route.clone end + + private + + # Deep-merges a stackable key's registrations into one Hash, nearest + # scope winning; nil when nothing is registered. + def namespace_stackable_with_hash(key) + data = namespace_stackable[key] + return if data.blank? + + data.each_with_object({}) { |value, result| result.deep_merge!(value) } + end end end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index b2cfb1ef4..e1f2d476e 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -18,7 +18,7 @@ subject.get('/x') { 'x' } subject.compile! - shared = subject.base_instance.inheritable_setting.namespace_inheritable + shared = subject.base_instance.inheritable_setting.__send__(:namespace_inheritable) expect(shared).not_to receive(:delete) subject.change! diff --git a/spec/grape/dsl/inside_route_spec.rb b/spec/grape/dsl/inside_route_spec.rb index 48e0bef77..7597c2d4a 100644 --- a/spec/grape/dsl/inside_route_spec.rb +++ b/spec/grape/dsl/inside_route_spec.rb @@ -13,7 +13,7 @@ def initialize @env = {} @header = {} - @new_settings = { namespace_inheritable: inheritable_setting.namespace_inheritable, namespace_stackable: inheritable_setting.namespace_stackable } + @new_settings = { namespace_inheritable: inheritable_setting.__send__(:namespace_inheritable), namespace_stackable: inheritable_setting.__send__(:namespace_stackable) } end def header(key = nil, val = nil) diff --git a/spec/grape/dsl/settings_spec.rb b/spec/grape/dsl/settings_spec.rb index fbe881ea2..aad56f2bd 100644 --- a/spec/grape/dsl/settings_spec.rb +++ b/spec/grape/dsl/settings_spec.rb @@ -62,14 +62,14 @@ def reset_validations!; end describe '#namespace_inheritable' do it 'inherits values from surrounding namespace' do subject.with_namespace do - subject.inheritable_setting.namespace_inheritable[:some_thing] = :foo_bar - expect(subject.inheritable_setting.namespace_inheritable[:some_thing]).to eq :foo_bar + subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing] = :foo_bar + expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar subject.with_namespace do - expect(subject.inheritable_setting.namespace_inheritable[:some_thing]).to eq :foo_bar - subject.inheritable_setting.namespace_inheritable[:some_thing] = :foo_bar_2 - expect(subject.inheritable_setting.namespace_inheritable[:some_thing]).to eq :foo_bar_2 + expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar + subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing] = :foo_bar_2 + expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar_2 end - expect(subject.inheritable_setting.namespace_inheritable[:some_thing]).to eq :foo_bar + expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar end end end @@ -77,13 +77,13 @@ def reset_validations!; end describe '#namespace_stackable' do it 'stacks values from surrounding namespace' do subject.with_namespace do - subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] + subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :foo_bar + expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:foo_bar] subject.with_namespace do - subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar_2 - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq %i[foo_bar foo_bar_2] + subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :foo_bar_2 + expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq %i[foo_bar foo_bar_2] end - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] + expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:foo_bar] end end end @@ -99,36 +99,36 @@ def reset_validations!; end obj3_copy = nil obj1.with_namespace do - obj1.inheritable_setting.namespace_stackable[:some_thing] = :obj1 - expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj1] + obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj1 + expect(obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj1] obj1_copy = obj1.inheritable_setting.point_in_time_copy end - expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj1_copy.namespace_stackable[:some_thing]).to eq [:obj1] + expect(obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] + expect(obj1_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj1] obj2.with_namespace do - obj2.inheritable_setting.namespace_stackable[:some_thing] = :obj2 - expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj2] + obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj2 + expect(obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj2] obj2_copy = obj2.inheritable_setting.point_in_time_copy end - expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj2_copy.namespace_stackable[:some_thing]).to eq [:obj2] + expect(obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] + expect(obj2_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj2] obj3.with_namespace do - obj3.inheritable_setting.namespace_stackable[:some_thing] = :obj3 - expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj3] + obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj3 + expect(obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj3] obj3_copy = obj3.inheritable_setting.point_in_time_copy end - expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj3_copy.namespace_stackable[:some_thing]).to eq [:obj3] + expect(obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] + expect(obj3_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj3] # obj1.top_level_setting.inherit_from obj2_copy.point_in_time_copy # obj2.top_level_setting.inherit_from obj3_copy.point_in_time_copy - # expect(obj1_copy.namespace_stackable[:some_thing]).to eq %i[obj3 obj2 obj1] + # expect(obj1_copy.__send__(:namespace_stackable)[:some_thing]).to eq %i[obj3 obj2 obj1] end end end diff --git a/spec/grape/util/inheritable_setting_spec.rb b/spec/grape/util/inheritable_setting_spec.rb index 02f0f041b..13c154ff8 100644 --- a/spec/grape/util/inheritable_setting_spec.rb +++ b/spec/grape/util/inheritable_setting_spec.rb @@ -10,8 +10,8 @@ described_class.new.tap do |settings| settings.global[:global_thing] = :global_foo_bar settings.namespace[:namespace_thing] = :namespace_foo_bar - settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar + settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + settings.__send__(:namespace_stackable)[:namespace_stackable_thing] = :namespace_stackable_foo_bar settings.route[:route_thing] = :route_foo_bar end end @@ -19,8 +19,8 @@ let(:other_parent) do described_class.new.tap do |settings| settings.namespace[:namespace_thing] = :namespace_foo_bar_other - settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other + settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other + settings.__send__(:namespace_stackable)[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other settings.route[:route_thing] = :route_foo_bar_other end end @@ -68,39 +68,39 @@ describe '#namespace_inheritable' do it 'works with inheritable values' do - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar end it 'handles different parents' do - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar subject.inherit_from other_parent - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar_other + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar_other subject.inherit_from parent - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar subject.inherit_from other_parent - subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing + subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_thing - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing subject.inherit_from parent - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing end end describe '#namespace_stackable' do it 'works with stackable values' do - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] subject.inherit_from other_parent - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] + expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] end end @@ -164,24 +164,24 @@ end it 'decouples namespace inheritable values' do - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_thing + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - cloned_obj.namespace_inheritable[:namespace_inheritable_thing] = :my_cloned_thing - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_cloned_thing - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_cloned_thing + expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_cloned_thing + expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing end it 'decouples namespace stackable values' do - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(cloned_obj.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] - subject.namespace_stackable[:namespace_stackable_thing] = :other_thing - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + subject.__send__(:namespace_stackable)[:namespace_stackable_thing] = :other_thing + expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] + expect(cloned_obj.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] end it 'decouples route values' do @@ -200,8 +200,8 @@ it 'return all settings as a hash' do subject.global[:global_thing] = :global_foo_bar subject.namespace[:namespace_thing] = :namespace_foo_bar - subject.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] + subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + subject.__send__(:namespace_stackable)[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true) subject.route[:route_thing] = :route_foo_bar expect(subject.to_hash).to match( From 506dda53af3675626b64c95c823671f53a43a544 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:02:04 -0400 Subject: [PATCH 03/10] Regroup the InheritableSetting accessor catalog and refresh the class doc Co-Authored-By: Claude Fable 5 --- lib/grape/util/inheritable_setting.rb | 52 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index 30a0688d5..703611b68 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -2,8 +2,18 @@ module Grape module Util - # A branchable, inheritable settings object which can store both stackable - # and inheritable values (see InheritableValues and StackableValues). + # The per-scope settings registry behind the Grape DSL. The semantic + # accessors below — grouped by concern — are the supported API: +add_*+ + # writers stack one registration per call (read back outermost scope + # first), plain +=+ writers are nearest-wins scalar overrides, and + # +!+/+?+ pairs are scope flags. Deep-merged readers return nil when + # nothing is registered; plain stack readers return a frozen empty + # Array. The backing stores (InheritableValues, StackableValues and the + # per-scope Hashes) and their keys are internal. + # + # Settings instances form a chain: a scope inherits its parent's values + # (see #inherit_from), and endpoints snapshot the chain with + # #point_in_time_copy_for_endpoint. class InheritableSetting # Maps the callbacks DSL method names to their pluralized # namespace-stackable storage keys (see #callbacks / #add_callback). @@ -233,6 +243,25 @@ def internal_grape_exceptions_rescue_handler namespace_inheritable[:internal_grape_exceptions_rescue_handler] end + # Rescue-handler maps registered by +rescue_from+, keyed by exception + # class and merged so a nested scope's handler wins. Record them with + # #add_rescue_handlers; the backing store is an internal detail. + def rescue_handlers + merged_rescue_handlers(:rescue_handlers) + end + + def base_only_rescue_handlers + merged_rescue_handlers(:base_only_rescue_handlers) + end + + # An exception class registered twice in the same scope keeps its first + # handler, and keeps the position it was first registered at. + def add_rescue_handlers(mapping, subclasses:) + @rescue_handler_maps ||= {} + own = (@rescue_handler_maps[subclasses ? :rescue_handlers : :base_only_rescue_handlers] ||= {}) + own.merge!(mapping) { |_klass, registered, _new| registered } + end + # Content negotiation registries recorded by the request/response DSL # (see DSL::RequestResponse): the content-type registry (+content_type+ # and +format+), and the formatter, parser and error-formatter handler @@ -528,25 +557,6 @@ def path_settings } end - # Rescue-handler maps registered by +rescue_from+, keyed by exception - # class and merged so a nested scope's handler wins. Record them with - # #add_rescue_handlers; the backing store is an internal detail. - def rescue_handlers - merged_rescue_handlers(:rescue_handlers) - end - - def base_only_rescue_handlers - merged_rescue_handlers(:base_only_rescue_handlers) - end - - # An exception class registered twice in the same scope keeps its first - # handler, and keeps the position it was first registered at. - def add_rescue_handlers(mapping, subclasses:) - @rescue_handler_maps ||= {} - own = (@rescue_handler_maps[subclasses ? :rescue_handlers : :base_only_rescue_handlers] ||= {}) - own.merge!(mapping) { |_klass, registered, _new| registered } - end - protected # The raw stores. Internal: every semantic key has a dedicated public From 42c831c46f11c3cf3029922c76f6039b2053bab3 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:04:11 -0400 Subject: [PATCH 04/10] Add mount_paths, document the store visibility change and grape-swagger impact Co-Authored-By: Claude Fable 5 --- .rubocop.yml | 2 +- CHANGELOG.md | 1 + UPGRADING.md | 15 +++++++++++++++ lib/grape/util/inheritable_setting.rb | 9 ++++++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index c726c6f30..cf3c032e4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -52,7 +52,7 @@ Metrics/BlockLength: - spec/**/*_spec.rb Metrics/ClassLength: - Max: 330 + Max: 340 Metrics/CyclomaticComplexity: Max: 15 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8677c4562..feca3c791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * [#2786](https://github.com/ruby-grape/grape/pull/2786): Make route `requirements` and `anchor` explicit keyword arguments and first-class endpoint inputs instead of opaque `route_options` keys - [@ericproulx](https://github.com/ericproulx). * [#2788](https://github.com/ruby-grape/grape/pull/2788): Compare the base API instead of `to_s` when refreshing a mounted app - [@ericproulx](https://github.com/ericproulx). * [#2796](https://github.com/ruby-grape/grape/pull/2796): Remove `Grape::Util::ReverseStackableValues`, storing rescue handlers in plain per-scope hashes merged over `InheritableSetting#parent` - [@ericproulx](https://github.com/ericproulx). +* [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal: `namespace_stackable` / `namespace_inheritable` are now protected and `namespace_stackable_with_hash` private; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). * [#2797](https://github.com/ruby-grape/grape/pull/2797): Internalize params state (validations, declared params, param documentation, named params) behind `Grape::Util::InheritableSetting` accessors, and remove the unused `api_class` / `point_in_time_copies` readers - [@ericproulx](https://github.com/ericproulx). * [#2798](https://github.com/ruby-grape/grape/pull/2798): Internalize callback filters behind `Grape::Util::InheritableSetting` accessors (`callbacks` / `add_callback`) - [@ericproulx](https://github.com/ericproulx). * [#2799](https://github.com/ruby-grape/grape/pull/2799): Internalize the remaining rescue configuration (`rescue_options`, meta-selector handlers and flags) behind `Grape::Util::InheritableSetting` accessors - [@ericproulx](https://github.com/ericproulx). diff --git a/UPGRADING.md b/UPGRADING.md index 926d3e657..c5c0086ee 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -141,6 +141,21 @@ The flags flipped by `do_not_route_head!`, `do_not_route_options!`, `do_not_docu The params-builder strategy written by `build_with` (both the API-level and the params-block DSL) and the authentication configuration written by the `auth` DSL are now recorded and read through dedicated accessors on `Grape::Util::InheritableSetting` (`build_params_with` / `build_params_with=`, `auth` / `auth=`) instead of raw `namespace_inheritable` keys, completing the per-key `namespace_inheritable` cleanup. The keys' storage is unchanged for now, so `namespace_inheritable[:build_params_with]` and `namespace_inheritable[:auth]` still return the same values, but they should be considered internal. +#### `InheritableSetting`'s raw stores are no longer public + +The per-key encapsulation documented in the sections above is now enforced: `Grape::Util::InheritableSetting#namespace_stackable` and `#namespace_inheritable` are protected, and `#namespace_stackable_with_hash` is private. Every semantic key is reachable only through its dedicated accessor. The `route`, `namespace` and `global` scratch stores (backing `route_setting` / `namespace_setting` / `global_setting`) remain public and unchanged. + +Two supporting changes: + +* `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot, which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. +* `InheritableSetting#mount_paths` is added, exposing the full mount-path stack (one entry per mount level, outermost first), complementing `#mount_path`, which returns only the outermost entry. + +**Ecosystem note — grape-swagger (as of its current master) reaches into `namespace_stackable` and must be updated for this release:** + +* `lib/grape-swagger/swagger_documentation_adder.rb` reads `namespace_stackable[:namespace]` and `namespace_stackable[:mount_path]` — migrate to `inheritable_setting.namespaces.last` and `inheritable_setting.mount_paths.join('/')`. +* `lib/grape-swagger/token_owner_resolver.rb` reads `namespace_stackable[:helpers]` behind a `respond_to?(:namespace_stackable)` guard — it degrades gracefully (protected methods make `respond_to?` return false, silently disabling token-owner resolution) but should migrate to `inheritable_setting.helpers`. +* `lib/grape-swagger/request_param_parsers/route.rb` walks the `StackableValues#inherited_values` chain manually to collect per-scope params; it needs a real rework against the public accessors (or a purpose-built read on `InheritableSetting`), not a mechanical rename. + ### Upgrading to >= 3.3 #### Minimum required Ruby is now 3.3 diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index 703611b68..dd428050f 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -375,6 +375,13 @@ def add_mount_path(mount_path) namespace_stackable[:mount_path] = mount_path end + # The full mount-path stack — one entry per mount level, outermost + # first; what Router::Pattern::Path joins into a route's origin (see + # #path_settings). + def mount_paths + namespace_stackable[:mount_path] + end + # Dry::Schema key maps registered by +contract+ blocks (see # Validations::ContractScope), one per contract, outermost scope first; # +declared+ uses them to write coerced params back under their @@ -548,7 +555,7 @@ def auth=(auth_options) # distinct formats. Keys are always present; unset values are nil. def path_settings { - mount_path: namespace_stackable[:mount_path].presence, + mount_path: mount_paths.presence, root_prefix:, format:, content_types: namespace_stackable[:content_types].presence, From ded803707e2f0e156de25ec57694130c460da315 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:06:06 -0400 Subject: [PATCH 05/10] Keep namespace_stackable public for grape-swagger compatibility grape-swagger reads the stackable store directly (documentation adder, token owner resolver, and the route param parser that walks the StackableValues chain), so only namespace_inheritable narrows to protected; namespace_stackable stays public, documented as read-only with the dedicated accessors preferred. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- UPGRADING.md | 12 +++---- lib/grape/util/inheritable_setting.rb | 12 +++++-- spec/grape/dsl/inside_route_spec.rb | 2 +- spec/grape/dsl/settings_spec.rb | 36 ++++++++++----------- spec/grape/util/inheritable_setting_spec.rb | 18 +++++------ 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feca3c791..91b262751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ * [#2786](https://github.com/ruby-grape/grape/pull/2786): Make route `requirements` and `anchor` explicit keyword arguments and first-class endpoint inputs instead of opaque `route_options` keys - [@ericproulx](https://github.com/ericproulx). * [#2788](https://github.com/ruby-grape/grape/pull/2788): Compare the base API instead of `to_s` when refreshing a mounted app - [@ericproulx](https://github.com/ericproulx). * [#2796](https://github.com/ruby-grape/grape/pull/2796): Remove `Grape::Util::ReverseStackableValues`, storing rescue handlers in plain per-scope hashes merged over `InheritableSetting#parent` - [@ericproulx](https://github.com/ericproulx). -* [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal: `namespace_stackable` / `namespace_inheritable` are now protected and `namespace_stackable_with_hash` private; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). +* [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal where the ecosystem allows: `namespace_inheritable` is now protected and `namespace_stackable_with_hash` private, while `namespace_stackable` stays public for grape-swagger; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). * [#2797](https://github.com/ruby-grape/grape/pull/2797): Internalize params state (validations, declared params, param documentation, named params) behind `Grape::Util::InheritableSetting` accessors, and remove the unused `api_class` / `point_in_time_copies` readers - [@ericproulx](https://github.com/ericproulx). * [#2798](https://github.com/ruby-grape/grape/pull/2798): Internalize callback filters behind `Grape::Util::InheritableSetting` accessors (`callbacks` / `add_callback`) - [@ericproulx](https://github.com/ericproulx). * [#2799](https://github.com/ruby-grape/grape/pull/2799): Internalize the remaining rescue configuration (`rescue_options`, meta-selector handlers and flags) behind `Grape::Util::InheritableSetting` accessors - [@ericproulx](https://github.com/ericproulx). diff --git a/UPGRADING.md b/UPGRADING.md index c5c0086ee..ed5071cfc 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -141,21 +141,17 @@ The flags flipped by `do_not_route_head!`, `do_not_route_options!`, `do_not_docu The params-builder strategy written by `build_with` (both the API-level and the params-block DSL) and the authentication configuration written by the `auth` DSL are now recorded and read through dedicated accessors on `Grape::Util::InheritableSetting` (`build_params_with` / `build_params_with=`, `auth` / `auth=`) instead of raw `namespace_inheritable` keys, completing the per-key `namespace_inheritable` cleanup. The keys' storage is unchanged for now, so `namespace_inheritable[:build_params_with]` and `namespace_inheritable[:auth]` still return the same values, but they should be considered internal. -#### `InheritableSetting`'s raw stores are no longer public +#### `InheritableSetting`'s raw stores are internal, except `namespace_stackable` -The per-key encapsulation documented in the sections above is now enforced: `Grape::Util::InheritableSetting#namespace_stackable` and `#namespace_inheritable` are protected, and `#namespace_stackable_with_hash` is private. Every semantic key is reachable only through its dedicated accessor. The `route`, `namespace` and `global` scratch stores (backing `route_setting` / `namespace_setting` / `global_setting`) remain public and unchanged. +The per-key encapsulation documented in the sections above is now enforced where the ecosystem allows: `Grape::Util::InheritableSetting#namespace_inheritable` is protected and `#namespace_stackable_with_hash` is private — every `namespace_inheritable` key is reachable only through its dedicated accessor. + +`#namespace_stackable` deliberately **remains public**: grape-swagger reads it directly (`swagger_documentation_adder.rb` reads `[:namespace]` and `[:mount_path]`, `token_owner_resolver.rb` reads `[:helpers]`, and `request_param_parsers/route.rb` walks the `StackableValues#inherited_values` chain to collect per-scope params). Treat it as read-only from outside Grape and prefer the dedicated accessors (`namespaces`, `mount_paths`, `helpers`, …) for everything they cover; the reader may be narrowed in a future major once grape-swagger has migrated. The `route`, `namespace` and `global` scratch stores (backing `route_setting` / `namespace_setting` / `global_setting`) also remain public and unchanged. Two supporting changes: * `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot, which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. * `InheritableSetting#mount_paths` is added, exposing the full mount-path stack (one entry per mount level, outermost first), complementing `#mount_path`, which returns only the outermost entry. -**Ecosystem note — grape-swagger (as of its current master) reaches into `namespace_stackable` and must be updated for this release:** - -* `lib/grape-swagger/swagger_documentation_adder.rb` reads `namespace_stackable[:namespace]` and `namespace_stackable[:mount_path]` — migrate to `inheritable_setting.namespaces.last` and `inheritable_setting.mount_paths.join('/')`. -* `lib/grape-swagger/token_owner_resolver.rb` reads `namespace_stackable[:helpers]` behind a `respond_to?(:namespace_stackable)` guard — it degrades gracefully (protected methods make `respond_to?` return false, silently disabling token-owner resolution) but should migrate to `inheritable_setting.helpers`. -* `lib/grape-swagger/request_param_parsers/route.rb` walks the `StackableValues#inherited_values` chain manually to collect per-scope params; it needs a real rework against the public accessors (or a purpose-built read on `InheritableSetting`), not a mechanical rename. - ### Upgrading to >= 3.3 #### Minimum required Ruby is now 3.3 diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index dd428050f..4def3eeeb 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -27,6 +27,12 @@ class InheritableSetting attr_reader :route, :namespace, :parent + # The stackable store. Public for ecosystem compatibility — grape-swagger + # reads it directly (and walks its inherited_values chain) — but treat it + # as read-only from the outside: every semantic key has a dedicated + # accessor below, and new code should use those. + attr_reader :namespace_stackable + # Retrieve global settings. def self.global @global ||= {} @@ -566,9 +572,9 @@ def path_settings protected - # The raw stores. Internal: every semantic key has a dedicated public - # accessor above — reach these only from InheritableSetting itself. - attr_reader :namespace_inheritable, :namespace_stackable + # The inheritable store. Internal: every semantic key has a dedicated + # public accessor above — reach this only from InheritableSetting itself. + attr_reader :namespace_inheritable # This scope's own +rescue_from+ registrations, before inheritance: # {rescue_handlers: {klass => handler}, base_only_rescue_handlers: {...}}. diff --git a/spec/grape/dsl/inside_route_spec.rb b/spec/grape/dsl/inside_route_spec.rb index 7597c2d4a..bf39e3d6f 100644 --- a/spec/grape/dsl/inside_route_spec.rb +++ b/spec/grape/dsl/inside_route_spec.rb @@ -13,7 +13,7 @@ def initialize @env = {} @header = {} - @new_settings = { namespace_inheritable: inheritable_setting.__send__(:namespace_inheritable), namespace_stackable: inheritable_setting.__send__(:namespace_stackable) } + @new_settings = { namespace_inheritable: inheritable_setting.__send__(:namespace_inheritable), namespace_stackable: inheritable_setting.namespace_stackable } end def header(key = nil, val = nil) diff --git a/spec/grape/dsl/settings_spec.rb b/spec/grape/dsl/settings_spec.rb index aad56f2bd..8aee6ea5a 100644 --- a/spec/grape/dsl/settings_spec.rb +++ b/spec/grape/dsl/settings_spec.rb @@ -77,13 +77,13 @@ def reset_validations!; end describe '#namespace_stackable' do it 'stacks values from surrounding namespace' do subject.with_namespace do - subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :foo_bar - expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:foo_bar] + subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar + expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] subject.with_namespace do - subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :foo_bar_2 - expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq %i[foo_bar foo_bar_2] + subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar_2 + expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq %i[foo_bar foo_bar_2] end - expect(subject.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:foo_bar] + expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] end end end @@ -99,36 +99,36 @@ def reset_validations!; end obj3_copy = nil obj1.with_namespace do - obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj1 - expect(obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj1] + obj1.inheritable_setting.namespace_stackable[:some_thing] = :obj1 + expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj1] obj1_copy = obj1.inheritable_setting.point_in_time_copy end - expect(obj1.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] - expect(obj1_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj1] + expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [] + expect(obj1_copy.namespace_stackable[:some_thing]).to eq [:obj1] obj2.with_namespace do - obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj2 - expect(obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj2] + obj2.inheritable_setting.namespace_stackable[:some_thing] = :obj2 + expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj2] obj2_copy = obj2.inheritable_setting.point_in_time_copy end - expect(obj2.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] - expect(obj2_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj2] + expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [] + expect(obj2_copy.namespace_stackable[:some_thing]).to eq [:obj2] obj3.with_namespace do - obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing] = :obj3 - expect(obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [:obj3] + obj3.inheritable_setting.namespace_stackable[:some_thing] = :obj3 + expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj3] obj3_copy = obj3.inheritable_setting.point_in_time_copy end - expect(obj3.inheritable_setting.__send__(:namespace_stackable)[:some_thing]).to eq [] - expect(obj3_copy.__send__(:namespace_stackable)[:some_thing]).to eq [:obj3] + expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [] + expect(obj3_copy.namespace_stackable[:some_thing]).to eq [:obj3] # obj1.top_level_setting.inherit_from obj2_copy.point_in_time_copy # obj2.top_level_setting.inherit_from obj3_copy.point_in_time_copy - # expect(obj1_copy.__send__(:namespace_stackable)[:some_thing]).to eq %i[obj3 obj2 obj1] + # expect(obj1_copy.namespace_stackable[:some_thing]).to eq %i[obj3 obj2 obj1] end end end diff --git a/spec/grape/util/inheritable_setting_spec.rb b/spec/grape/util/inheritable_setting_spec.rb index 13c154ff8..9fa8491fa 100644 --- a/spec/grape/util/inheritable_setting_spec.rb +++ b/spec/grape/util/inheritable_setting_spec.rb @@ -11,7 +11,7 @@ settings.global[:global_thing] = :global_foo_bar settings.namespace[:namespace_thing] = :namespace_foo_bar settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - settings.__send__(:namespace_stackable)[:namespace_stackable_thing] = :namespace_stackable_foo_bar + settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar settings.route[:route_thing] = :route_foo_bar end end @@ -20,7 +20,7 @@ described_class.new.tap do |settings| settings.namespace[:namespace_thing] = :namespace_foo_bar_other settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other - settings.__send__(:namespace_stackable)[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other + settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other settings.route[:route_thing] = :route_foo_bar_other end end @@ -96,11 +96,11 @@ describe '#namespace_stackable' do it 'works with stackable values' do - expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] subject.inherit_from other_parent - expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] end end @@ -177,11 +177,11 @@ end it 'decouples namespace stackable values' do - expect(cloned_obj.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] - subject.__send__(:namespace_stackable)[:namespace_stackable_thing] = :other_thing - expect(subject.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] - expect(cloned_obj.__send__(:namespace_stackable)[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + subject.namespace_stackable[:namespace_stackable_thing] = :other_thing + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] + expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] end it 'decouples route values' do @@ -201,7 +201,7 @@ subject.global[:global_thing] = :global_foo_bar subject.namespace[:namespace_thing] = :namespace_foo_bar subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - subject.__send__(:namespace_stackable)[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] + subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true) subject.route[:route_thing] = :route_foo_bar expect(subject.to_hash).to match( From 393f30756fe4f823ddd819bc2faaf91afdfcf10f Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:10:48 -0400 Subject: [PATCH 06/10] Test the inheritable store through public accessors instead of __send__ root_prefix and format stand in for the arbitrary keys the storage specs used; the api_spec recompile regression keeps its intent through the version_options reader; inside_route_spec's captured settings hash was never consumed and is removed. Co-Authored-By: Claude Fable 5 --- spec/grape/api_spec.rb | 5 ++- spec/grape/dsl/inside_route_spec.rb | 3 +- spec/grape/dsl/settings_spec.rb | 14 ++++---- spec/grape/util/inheritable_setting_spec.rb | 36 ++++++++++----------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index e1f2d476e..b990ca83e 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -18,13 +18,12 @@ subject.get('/x') { 'x' } subject.compile! - shared = subject.base_instance.inheritable_setting.__send__(:namespace_inheritable) - expect(shared).not_to receive(:delete) + shared = subject.base_instance.inheritable_setting subject.change! subject.compile! - expect(shared[:version_options]).to be_present + expect(shared.version_options).to be_present end end diff --git a/spec/grape/dsl/inside_route_spec.rb b/spec/grape/dsl/inside_route_spec.rb index bf39e3d6f..acfe417f7 100644 --- a/spec/grape/dsl/inside_route_spec.rb +++ b/spec/grape/dsl/inside_route_spec.rb @@ -8,12 +8,11 @@ include Grape::DSL::InsideRoute include Grape::DSL::Settings - attr_reader :env, :request, :new_settings + attr_reader :env, :request def initialize @env = {} @header = {} - @new_settings = { namespace_inheritable: inheritable_setting.__send__(:namespace_inheritable), namespace_stackable: inheritable_setting.namespace_stackable } end def header(key = nil, val = nil) diff --git a/spec/grape/dsl/settings_spec.rb b/spec/grape/dsl/settings_spec.rb index 8aee6ea5a..9e5dccb6f 100644 --- a/spec/grape/dsl/settings_spec.rb +++ b/spec/grape/dsl/settings_spec.rb @@ -59,17 +59,17 @@ def reset_validations!; end end end - describe '#namespace_inheritable' do + describe 'inheritable scalar accessors' do it 'inherits values from surrounding namespace' do subject.with_namespace do - subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing] = :foo_bar - expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar + subject.inheritable_setting.format = :foo_bar + expect(subject.inheritable_setting.format).to eq :foo_bar subject.with_namespace do - expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar - subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing] = :foo_bar_2 - expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar_2 + expect(subject.inheritable_setting.format).to eq :foo_bar + subject.inheritable_setting.format = :foo_bar_2 + expect(subject.inheritable_setting.format).to eq :foo_bar_2 end - expect(subject.inheritable_setting.__send__(:namespace_inheritable)[:some_thing]).to eq :foo_bar + expect(subject.inheritable_setting.format).to eq :foo_bar end end end diff --git a/spec/grape/util/inheritable_setting_spec.rb b/spec/grape/util/inheritable_setting_spec.rb index 9fa8491fa..639313e95 100644 --- a/spec/grape/util/inheritable_setting_spec.rb +++ b/spec/grape/util/inheritable_setting_spec.rb @@ -10,7 +10,7 @@ described_class.new.tap do |settings| settings.global[:global_thing] = :global_foo_bar settings.namespace[:namespace_thing] = :namespace_foo_bar - settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + settings.root_prefix = :namespace_inheritable_foo_bar settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar settings.route[:route_thing] = :route_foo_bar end @@ -19,7 +19,7 @@ let(:other_parent) do described_class.new.tap do |settings| settings.namespace[:namespace_thing] = :namespace_foo_bar_other - settings.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other + settings.root_prefix = :namespace_inheritable_foo_bar_other settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other settings.route[:route_thing] = :route_foo_bar_other end @@ -68,29 +68,29 @@ describe '#namespace_inheritable' do it 'works with inheritable values' do - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.root_prefix).to eq :namespace_inheritable_foo_bar end it 'handles different parents' do - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.root_prefix).to eq :namespace_inheritable_foo_bar subject.inherit_from other_parent - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar_other + expect(subject.root_prefix).to eq :namespace_inheritable_foo_bar_other subject.inherit_from parent - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(subject.root_prefix).to eq :namespace_inheritable_foo_bar subject.inherit_from other_parent - subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_thing + subject.root_prefix = :my_thing - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing + expect(subject.root_prefix).to eq :my_thing subject.inherit_from parent - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing + expect(subject.root_prefix).to eq :my_thing end end @@ -164,16 +164,16 @@ end it 'decouples namespace inheritable values' do - expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(cloned_obj.root_prefix).to eq :namespace_inheritable_foo_bar - subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_thing - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing + subject.root_prefix = :my_thing + expect(subject.root_prefix).to eq :my_thing - expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + expect(cloned_obj.root_prefix).to eq :namespace_inheritable_foo_bar - cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :my_cloned_thing - expect(cloned_obj.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_cloned_thing - expect(subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing]).to eq :my_thing + cloned_obj.root_prefix = :my_cloned_thing + expect(cloned_obj.root_prefix).to eq :my_cloned_thing + expect(subject.root_prefix).to eq :my_thing end it 'decouples namespace stackable values' do @@ -200,7 +200,7 @@ it 'return all settings as a hash' do subject.global[:global_thing] = :global_foo_bar subject.namespace[:namespace_thing] = :namespace_foo_bar - subject.__send__(:namespace_inheritable)[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + subject.root_prefix = :namespace_inheritable_foo_bar subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true) subject.route[:route_thing] = :route_foo_bar @@ -208,7 +208,7 @@ global: { global_thing: :global_foo_bar }, namespace: { namespace_thing: :namespace_foo_bar }, namespace_inheritable: { - namespace_inheritable_thing: :namespace_inheritable_foo_bar + root_prefix: :namespace_inheritable_foo_bar }, namespace_stackable: { namespace_stackable_thing: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] }, rescue_handlers: { StandardError => :handler }, From 11ed616d914dd9a667953f3baad5ab1b72ce13c5 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:16:10 -0400 Subject: [PATCH 07/10] Deprecate the public namespace_stackable reader via Grape.deprecator External reads warn with a pointer to the semantic accessors so grape-swagger gets a migration signal; Grape itself reads the ivar (with a protected raw reader for inherit_from/copy_state_from peer access) and never warns. Storage specs move to add_helper/helpers. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- UPGRADING.md | 2 +- lib/grape/util/inheritable_setting.rb | 83 ++++++++++++--------- spec/grape/dsl/settings_spec.rb | 38 +++++----- spec/grape/util/inheritable_setting_spec.rb | 28 ++++--- 5 files changed, 85 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b262751..53cf6bbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ * [#2786](https://github.com/ruby-grape/grape/pull/2786): Make route `requirements` and `anchor` explicit keyword arguments and first-class endpoint inputs instead of opaque `route_options` keys - [@ericproulx](https://github.com/ericproulx). * [#2788](https://github.com/ruby-grape/grape/pull/2788): Compare the base API instead of `to_s` when refreshing a mounted app - [@ericproulx](https://github.com/ericproulx). * [#2796](https://github.com/ruby-grape/grape/pull/2796): Remove `Grape::Util::ReverseStackableValues`, storing rescue handlers in plain per-scope hashes merged over `InheritableSetting#parent` - [@ericproulx](https://github.com/ericproulx). -* [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal where the ecosystem allows: `namespace_inheritable` is now protected and `namespace_stackable_with_hash` private, while `namespace_stackable` stays public for grape-swagger; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). +* [#2810](https://github.com/ruby-grape/grape/pull/2810): Make `Grape::Util::InheritableSetting`'s raw stores internal where the ecosystem allows: `namespace_inheritable` is now protected and `namespace_stackable_with_hash` private, while `namespace_stackable` stays public for grape-swagger but is deprecated via `Grape.deprecator`; `Router::Pattern::Path` reads a dedicated `path_settings` snapshot, and `mount_paths` exposes the full mount-path stack - [@ericproulx](https://github.com/ericproulx). * [#2797](https://github.com/ruby-grape/grape/pull/2797): Internalize params state (validations, declared params, param documentation, named params) behind `Grape::Util::InheritableSetting` accessors, and remove the unused `api_class` / `point_in_time_copies` readers - [@ericproulx](https://github.com/ericproulx). * [#2798](https://github.com/ruby-grape/grape/pull/2798): Internalize callback filters behind `Grape::Util::InheritableSetting` accessors (`callbacks` / `add_callback`) - [@ericproulx](https://github.com/ericproulx). * [#2799](https://github.com/ruby-grape/grape/pull/2799): Internalize the remaining rescue configuration (`rescue_options`, meta-selector handlers and flags) behind `Grape::Util::InheritableSetting` accessors - [@ericproulx](https://github.com/ericproulx). diff --git a/UPGRADING.md b/UPGRADING.md index ed5071cfc..dc6bd5b96 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -145,7 +145,7 @@ The params-builder strategy written by `build_with` (both the API-level and the The per-key encapsulation documented in the sections above is now enforced where the ecosystem allows: `Grape::Util::InheritableSetting#namespace_inheritable` is protected and `#namespace_stackable_with_hash` is private — every `namespace_inheritable` key is reachable only through its dedicated accessor. -`#namespace_stackable` deliberately **remains public**: grape-swagger reads it directly (`swagger_documentation_adder.rb` reads `[:namespace]` and `[:mount_path]`, `token_owner_resolver.rb` reads `[:helpers]`, and `request_param_parsers/route.rb` walks the `StackableValues#inherited_values` chain to collect per-scope params). Treat it as read-only from outside Grape and prefer the dedicated accessors (`namespaces`, `mount_paths`, `helpers`, …) for everything they cover; the reader may be narrowed in a future major once grape-swagger has migrated. The `route`, `namespace` and `global` scratch stores (backing `route_setting` / `namespace_setting` / `global_setting`) also remain public and unchanged. +`#namespace_stackable` deliberately **remains public but is deprecated** — reading it warns through `Grape.deprecator` (Grape itself reads the underlying store directly and never warns): grape-swagger reads it directly (`swagger_documentation_adder.rb` reads `[:namespace]` and `[:mount_path]`, `token_owner_resolver.rb` reads `[:helpers]`, and `request_param_parsers/route.rb` walks the `StackableValues#inherited_values` chain to collect per-scope params). Migrate to the dedicated accessors (`namespaces`, `mount_paths`, `helpers`, …) for everything they cover; the reader will be removed or narrowed in a future major once grape-swagger has migrated. The `route`, `namespace` and `global` scratch stores (backing `route_setting` / `namespace_setting` / `global_setting`) also remain public and unchanged. Two supporting changes: diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index 4def3eeeb..a60700c7d 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -27,11 +27,14 @@ class InheritableSetting attr_reader :route, :namespace, :parent - # The stackable store. Public for ecosystem compatibility — grape-swagger - # reads it directly (and walks its inherited_values chain) — but treat it - # as read-only from the outside: every semantic key has a dedicated - # accessor below, and new code should use those. - attr_reader :namespace_stackable + # @deprecated Kept public for ecosystem compatibility — grape-swagger + # reads it directly (and walks its inherited_values chain) — but every + # semantic key has a dedicated accessor below; use those instead. + # Internal code reads the ivar and never trips this warning. + def namespace_stackable + Grape.deprecator.warn('Grape::Util::InheritableSetting#@namespace_stackable is deprecated; use the dedicated semantic accessors (#namespaces, #mount_paths, #helpers, ...) instead.') + @namespace_stackable + end # Retrieve global settings. def self.global @@ -73,7 +76,7 @@ def inherit_from(parent) @parent = parent namespace_inheritable.inherited_values = parent.namespace_inheritable - namespace_stackable.inherited_values = parent.namespace_stackable + @namespace_stackable.inherited_values = parent.raw_namespace_stackable @route = parent.route.merge(route) @point_in_time_copies&.each { |cloned_one| cloned_one.inherit_from parent } @@ -117,7 +120,7 @@ def to_hash route: route.clone, namespace: namespace.to_hash, namespace_inheritable: namespace_inheritable.to_hash, - namespace_stackable: namespace_stackable.to_hash, + namespace_stackable: @namespace_stackable.to_hash, rescue_handlers:, base_only_rescue_handlers: } @@ -132,22 +135,22 @@ def ==(other) # outermost scope first. Record them with #add_validation; the backing # store is an internal detail. def validations - namespace_stackable[:validations] + @namespace_stackable[:validations] end def add_validation(validator) - namespace_stackable[:validations] = validator + @namespace_stackable[:validations] = validator end # Declared-params entries registered by +params+ blocks, one Array per # scope, outermost scope first. Record them with #add_declared_params; # the backing store is an internal detail. def declared_params - namespace_stackable[:declared_params] + @namespace_stackable[:declared_params] end def add_declared_params(params) - namespace_stackable[:declared_params] = params + @namespace_stackable[:declared_params] = params end # Param documentation recorded by +params+ blocks (see @@ -160,14 +163,14 @@ def params_documentation end def add_params_documentation(documented_attrs) - namespace_stackable[:params] = documented_attrs + @namespace_stackable[:params] = documented_attrs end # Drops this scope's own validations, declared params and params # documentation once an endpoint has consumed them (see # +reset_validations!+ in DSL::Validations). Inherited entries are kept. def reset_validations! - namespace_stackable.delete(:declared_params, :params, :validations) + @namespace_stackable.delete(:declared_params, :params, :validations) end # Reusable +params :name do ... end+ blocks defined in helpers, as one @@ -179,7 +182,7 @@ def named_params end def add_named_params(named_params) - namespace_stackable[:named_params] = named_params + @namespace_stackable[:named_params] = named_params end # Filter blocks registered by the callbacks DSL (see DSL::Callbacks), @@ -188,11 +191,11 @@ def add_named_params(named_params) # +:finally+), outermost scope first. Record them with #add_callback; # the backing store is an internal detail. def callbacks - CALLBACK_STORE_KEYS.transform_values { |store_key| namespace_stackable[store_key] } + CALLBACK_STORE_KEYS.transform_values { |store_key| @namespace_stackable[store_key] } end def add_callback(callback_name, block) - namespace_stackable[CALLBACK_STORE_KEYS.fetch(callback_name)] = block + @namespace_stackable[CALLBACK_STORE_KEYS.fetch(callback_name)] = block end # Response-shaping options recorded by +rescue_from+ (see @@ -201,11 +204,11 @@ def add_callback(callback_name, block) # +rescue_from+ was never called. Record them with #add_rescue_options; # the backing store is an internal detail. def rescue_options - namespace_stackable[:rescue_options].last + @namespace_stackable[:rescue_options].last end def add_rescue_options(options) - namespace_stackable[:rescue_options] = options + @namespace_stackable[:rescue_options] = options end # Meta-selector registrations from +rescue_from :all+, @@ -280,7 +283,7 @@ def content_types end def add_content_type(format, content_type) - namespace_stackable[:content_types] = { format => content_type } + @namespace_stackable[:content_types] = { format => content_type } end def formatters @@ -288,7 +291,7 @@ def formatters end def add_formatter(content_type, formatter) - namespace_stackable[:formatters] = { content_type => formatter } + @namespace_stackable[:formatters] = { content_type => formatter } end def parsers @@ -296,7 +299,7 @@ def parsers end def add_parser(content_type, parser) - namespace_stackable[:parsers] = { content_type => parser } + @namespace_stackable[:parsers] = { content_type => parser } end def error_formatters @@ -304,7 +307,7 @@ def error_formatters end def add_error_formatter(format, formatter) - namespace_stackable[:error_formatters] = { format => formatter } + @namespace_stackable[:error_formatters] = { format => formatter } end # Model-class => entity-class registrations from +represent+ (see @@ -317,7 +320,7 @@ def representations end def add_representation(model_class, entity_class) - namespace_stackable[:representations] = { model_class => entity_class } + @namespace_stackable[:representations] = { model_class => entity_class } end # Middleware specs recorded by the middleware DSL (+use+, +insert+, @@ -326,22 +329,22 @@ def add_representation(model_class, entity_class) # first. Record them with #add_middleware; the backing store is an # internal detail. def middleware - namespace_stackable[:middleware] + @namespace_stackable[:middleware] end def add_middleware(operation_with_arguments) - namespace_stackable[:middleware] = operation_with_arguments + @namespace_stackable[:middleware] = operation_with_arguments end # Helper modules registered by +helpers+ blocks and modules (see # DSL::Helpers), outermost scope first. Record them with #add_helper; # the backing store is an internal detail. def helpers - namespace_stackable[:helpers] + @namespace_stackable[:helpers] end def add_helper(mod) - namespace_stackable[:helpers] = mod + @namespace_stackable[:helpers] = mod end # Grape::Namespace objects registered by the +namespace+ DSL and its @@ -350,11 +353,11 @@ def add_helper(mod) # store. Record them with #add_namespace; the backing store is an # internal detail. def namespaces - namespace_stackable[:namespace] + @namespace_stackable[:namespace] end def add_namespace(namespace) - namespace_stackable[:namespace] = namespace + @namespace_stackable[:namespace] = namespace end # The normalized path prefix formed by joining every registered @@ -374,18 +377,18 @@ def namespace_requirements # outermost mount path — nil when the API is not mounted; the backing # store is an internal detail. def mount_path - namespace_stackable[:mount_path].first + @namespace_stackable[:mount_path].first end def add_mount_path(mount_path) - namespace_stackable[:mount_path] = mount_path + @namespace_stackable[:mount_path] = mount_path end # The full mount-path stack — one entry per mount level, outermost # first; what Router::Pattern::Path joins into a route's origin (see # #path_settings). def mount_paths - namespace_stackable[:mount_path] + @namespace_stackable[:mount_path] end # Dry::Schema key maps registered by +contract+ blocks (see @@ -394,11 +397,11 @@ def mount_paths # declared keys. Record them with #add_contract_key_map; the backing # store is an internal detail. def contract_key_maps - namespace_stackable[:contract_key_map] + @namespace_stackable[:contract_key_map] end def add_contract_key_map(key_map) - namespace_stackable[:contract_key_map] = key_map + @namespace_stackable[:contract_key_map] = key_map end # Serialization and error-response defaults recorded by the @@ -564,7 +567,7 @@ def path_settings mount_path: mount_paths.presence, root_prefix:, format:, - content_types: namespace_stackable[:content_types].presence, + content_types: @namespace_stackable[:content_types].presence, version:, version_options: } @@ -576,6 +579,12 @@ def path_settings # public accessor above — reach this only from InheritableSetting itself. attr_reader :namespace_inheritable + # Peer access to the stackable store for #inherit_from and + # #copy_state_from, bypassing the deprecated public reader. + def raw_namespace_stackable + @namespace_stackable + end + # This scope's own +rescue_from+ registrations, before inheritance: # {rescue_handlers: {klass => handler}, base_only_rescue_handlers: {...}}. attr_reader :rescue_handler_maps @@ -596,7 +605,7 @@ def merged_rescue_handlers(key) def copy_state_from(source) @namespace = source.namespace.clone @namespace_inheritable = source.namespace_inheritable.clone - @namespace_stackable = source.namespace_stackable.clone + @namespace_stackable = source.raw_namespace_stackable.clone @rescue_handler_maps = source.rescue_handler_maps&.dup @route = source.route.clone end @@ -606,7 +615,7 @@ def copy_state_from(source) # Deep-merges a stackable key's registrations into one Hash, nearest # scope winning; nil when nothing is registered. def namespace_stackable_with_hash(key) - data = namespace_stackable[key] + data = @namespace_stackable[key] return if data.blank? data.each_with_object({}) { |value, result| result.deep_merge!(value) } diff --git a/spec/grape/dsl/settings_spec.rb b/spec/grape/dsl/settings_spec.rb index 9e5dccb6f..7311d466f 100644 --- a/spec/grape/dsl/settings_spec.rb +++ b/spec/grape/dsl/settings_spec.rb @@ -74,16 +74,16 @@ def reset_validations!; end end end - describe '#namespace_stackable' do + describe 'stackable accessors' do it 'stacks values from surrounding namespace' do subject.with_namespace do - subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] + subject.inheritable_setting.add_helper(:foo_bar) + expect(subject.inheritable_setting.helpers).to eq [:foo_bar] subject.with_namespace do - subject.inheritable_setting.namespace_stackable[:some_thing] = :foo_bar_2 - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq %i[foo_bar foo_bar_2] + subject.inheritable_setting.add_helper(:foo_bar_2) + expect(subject.inheritable_setting.helpers).to eq %i[foo_bar foo_bar_2] end - expect(subject.inheritable_setting.namespace_stackable[:some_thing]).to eq [:foo_bar] + expect(subject.inheritable_setting.helpers).to eq [:foo_bar] end end end @@ -99,36 +99,36 @@ def reset_validations!; end obj3_copy = nil obj1.with_namespace do - obj1.inheritable_setting.namespace_stackable[:some_thing] = :obj1 - expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj1] + obj1.inheritable_setting.add_helper(:obj1) + expect(obj1.inheritable_setting.helpers).to eq [:obj1] obj1_copy = obj1.inheritable_setting.point_in_time_copy end - expect(obj1.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj1_copy.namespace_stackable[:some_thing]).to eq [:obj1] + expect(obj1.inheritable_setting.helpers).to eq [] + expect(obj1_copy.helpers).to eq [:obj1] obj2.with_namespace do - obj2.inheritable_setting.namespace_stackable[:some_thing] = :obj2 - expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj2] + obj2.inheritable_setting.add_helper(:obj2) + expect(obj2.inheritable_setting.helpers).to eq [:obj2] obj2_copy = obj2.inheritable_setting.point_in_time_copy end - expect(obj2.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj2_copy.namespace_stackable[:some_thing]).to eq [:obj2] + expect(obj2.inheritable_setting.helpers).to eq [] + expect(obj2_copy.helpers).to eq [:obj2] obj3.with_namespace do - obj3.inheritable_setting.namespace_stackable[:some_thing] = :obj3 - expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [:obj3] + obj3.inheritable_setting.add_helper(:obj3) + expect(obj3.inheritable_setting.helpers).to eq [:obj3] obj3_copy = obj3.inheritable_setting.point_in_time_copy end - expect(obj3.inheritable_setting.namespace_stackable[:some_thing]).to eq [] - expect(obj3_copy.namespace_stackable[:some_thing]).to eq [:obj3] + expect(obj3.inheritable_setting.helpers).to eq [] + expect(obj3_copy.helpers).to eq [:obj3] # obj1.top_level_setting.inherit_from obj2_copy.point_in_time_copy # obj2.top_level_setting.inherit_from obj3_copy.point_in_time_copy - # expect(obj1_copy.namespace_stackable[:some_thing]).to eq %i[obj3 obj2 obj1] + # expect(obj1_copy.helpers).to eq %i[obj3 obj2 obj1] end end end diff --git a/spec/grape/util/inheritable_setting_spec.rb b/spec/grape/util/inheritable_setting_spec.rb index 639313e95..e5496b446 100644 --- a/spec/grape/util/inheritable_setting_spec.rb +++ b/spec/grape/util/inheritable_setting_spec.rb @@ -11,7 +11,7 @@ settings.global[:global_thing] = :global_foo_bar settings.namespace[:namespace_thing] = :namespace_foo_bar settings.root_prefix = :namespace_inheritable_foo_bar - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar + settings.add_helper(:namespace_stackable_foo_bar) settings.route[:route_thing] = :route_foo_bar end end @@ -20,7 +20,7 @@ described_class.new.tap do |settings| settings.namespace[:namespace_thing] = :namespace_foo_bar_other settings.root_prefix = :namespace_inheritable_foo_bar_other - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other + settings.add_helper(:namespace_stackable_foo_bar_other) settings.route[:route_thing] = :route_foo_bar_other end end @@ -96,11 +96,11 @@ describe '#namespace_stackable' do it 'works with stackable values' do - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(subject.helpers).to eq [:namespace_stackable_foo_bar] subject.inherit_from other_parent - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] + expect(subject.helpers).to eq [:namespace_stackable_foo_bar_other] end end @@ -177,11 +177,11 @@ end it 'decouples namespace stackable values' do - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(cloned_obj.helpers).to eq [:namespace_stackable_foo_bar] - subject.namespace_stackable[:namespace_stackable_thing] = :other_thing - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + subject.add_helper(:other_thing) + expect(subject.helpers).to eq %i[namespace_stackable_foo_bar other_thing] + expect(cloned_obj.helpers).to eq [:namespace_stackable_foo_bar] end it 'decouples route values' do @@ -196,12 +196,20 @@ end end + describe '#namespace_stackable' do + it 'is deprecated in favor of the semantic accessors' do + expect { subject.namespace_stackable }.to raise_error( + ActiveSupport::DeprecationException, /namespace_stackable is deprecated/ + ) + end + end + describe '#to_hash' do it 'return all settings as a hash' do subject.global[:global_thing] = :global_foo_bar subject.namespace[:namespace_thing] = :namespace_foo_bar subject.root_prefix = :namespace_inheritable_foo_bar - subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] + subject.add_helper([:namespace_stackable_foo_bar]) subject.add_rescue_handlers({ StandardError => :handler }, subclasses: true) subject.route[:route_thing] = :route_foo_bar expect(subject.to_hash).to match( @@ -210,7 +218,7 @@ namespace_inheritable: { root_prefix: :namespace_inheritable_foo_bar }, - namespace_stackable: { namespace_stackable_thing: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] }, + namespace_stackable: { helpers: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] }, rescue_handlers: { StandardError => :handler }, base_only_rescue_handlers: nil, route: { route_thing: :route_foo_bar } From 054afccc010f17384705c6a7ddbe9ce0d006d748 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:19:54 -0400 Subject: [PATCH 08/10] Read the inheritable store through its ivar internally Matches the stackable side: internal access reads the ivar, the protected reader exists purely for inherit_from/copy_state_from peer access. Also repairs the deprecation message, which an earlier conversion had mangled into Setting#@namespace_stackable. Co-Authored-By: Claude Fable 5 --- lib/grape/util/inheritable_setting.rb | 90 +++++++++++++-------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index a60700c7d..1f06f05d6 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -32,7 +32,7 @@ class InheritableSetting # semantic key has a dedicated accessor below; use those instead. # Internal code reads the ivar and never trips this warning. def namespace_stackable - Grape.deprecator.warn('Grape::Util::InheritableSetting#@namespace_stackable is deprecated; use the dedicated semantic accessors (#namespaces, #mount_paths, #helpers, ...) instead.') + Grape.deprecator.warn('Grape::Util::InheritableSetting#namespace_stackable is deprecated; use the dedicated semantic accessors (#namespaces, #mount_paths, #helpers, ...) instead.') @namespace_stackable end @@ -75,7 +75,7 @@ def inherit_from(parent) @parent = parent - namespace_inheritable.inherited_values = parent.namespace_inheritable + @namespace_inheritable.inherited_values = parent.namespace_inheritable @namespace_stackable.inherited_values = parent.raw_namespace_stackable @route = parent.route.merge(route) @@ -119,7 +119,7 @@ def to_hash global: global.clone, route: route.clone, namespace: namespace.to_hash, - namespace_inheritable: namespace_inheritable.to_hash, + namespace_inheritable: @namespace_inheritable.to_hash, namespace_stackable: @namespace_stackable.to_hash, rescue_handlers:, base_only_rescue_handlers: @@ -218,38 +218,38 @@ def add_rescue_options(options) # through #rescue_all? / #rescue_grape_exceptions?; the backing store # is an internal detail. def add_all_rescue_handler(handler) - namespace_inheritable[:rescue_all] = true - namespace_inheritable[:all_rescue_handler] = handler + @namespace_inheritable[:rescue_all] = true + @namespace_inheritable[:all_rescue_handler] = handler end def add_grape_exceptions_rescue_handler(handler) - namespace_inheritable[:rescue_all] = true - namespace_inheritable[:rescue_grape_exceptions] = true - namespace_inheritable[:grape_exceptions_rescue_handler] = handler + @namespace_inheritable[:rescue_all] = true + @namespace_inheritable[:rescue_grape_exceptions] = true + @namespace_inheritable[:grape_exceptions_rescue_handler] = handler end def add_internal_grape_exceptions_rescue_handler(handler) - namespace_inheritable[:internal_grape_exceptions_rescue_handler] = handler + @namespace_inheritable[:internal_grape_exceptions_rescue_handler] = handler end def rescue_all? - namespace_inheritable[:rescue_all] == true + @namespace_inheritable[:rescue_all] == true end def rescue_grape_exceptions? - namespace_inheritable[:rescue_grape_exceptions] == true + @namespace_inheritable[:rescue_grape_exceptions] == true end def all_rescue_handler - namespace_inheritable[:all_rescue_handler] + @namespace_inheritable[:all_rescue_handler] end def grape_exceptions_rescue_handler - namespace_inheritable[:grape_exceptions_rescue_handler] + @namespace_inheritable[:grape_exceptions_rescue_handler] end def internal_grape_exceptions_rescue_handler - namespace_inheritable[:internal_grape_exceptions_rescue_handler] + @namespace_inheritable[:internal_grape_exceptions_rescue_handler] end # Rescue-handler maps registered by +rescue_from+, keyed by exception @@ -415,35 +415,35 @@ def add_contract_key_map(key_map) # when never set (Endpoint applies the request-serving fallbacks); the # backing store is an internal detail. def format - namespace_inheritable[:format] + @namespace_inheritable[:format] end def format=(format) - namespace_inheritable[:format] = format + @namespace_inheritable[:format] = format end def default_format - namespace_inheritable[:default_format] + @namespace_inheritable[:default_format] end def default_format=(default_format) - namespace_inheritable[:default_format] = default_format + @namespace_inheritable[:default_format] = default_format end def default_error_formatter - namespace_inheritable[:default_error_formatter] + @namespace_inheritable[:default_error_formatter] end def default_error_formatter=(formatter) - namespace_inheritable[:default_error_formatter] = formatter + @namespace_inheritable[:default_error_formatter] = formatter end def default_error_status - namespace_inheritable[:default_error_status] + @namespace_inheritable[:default_error_status] end def default_error_status=(status) - namespace_inheritable[:default_error_status] = status + @namespace_inheritable[:default_error_status] = status end # Versioning state recorded by the routing DSL (see DSL::Routing): @@ -453,27 +453,27 @@ def default_error_status=(status) # Nearest-wins scalars with plain += writers; readers return nil when # never set; the backing store is an internal detail. def version - namespace_inheritable[:version] + @namespace_inheritable[:version] end def version=(versions) - namespace_inheritable[:version] = versions + @namespace_inheritable[:version] = versions end def version_options - namespace_inheritable[:version_options] + @namespace_inheritable[:version_options] end def version_options=(options) - namespace_inheritable[:version_options] = options + @namespace_inheritable[:version_options] = options end def root_prefix - namespace_inheritable[:root_prefix] + @namespace_inheritable[:root_prefix] end def root_prefix=(prefix) - namespace_inheritable[:root_prefix] = prefix + @namespace_inheritable[:root_prefix] = prefix end # Cascade flag assigned by the +cascade+ DSL. An explicit nil is @@ -482,15 +482,15 @@ def root_prefix=(prefix) # assigned it — Grape::API::Instance#cascade? falls back to the # version options' cascade, then to true, when it was never assigned. def cascade - namespace_inheritable[:cascade] + @namespace_inheritable[:cascade] end def cascade=(value) - namespace_inheritable[:cascade] = value + @namespace_inheritable[:cascade] = value end def cascade_defined? - namespace_inheritable.key?(:cascade) + @namespace_inheritable.key?(:cascade) end # Scope flags flipped by the routing DSL's bang methods (see @@ -499,35 +499,35 @@ def cascade_defined? # and everything nested under it. Readers return false when never set; # the backing store is an internal detail. def do_not_route_head! - namespace_inheritable[:do_not_route_head] = true + @namespace_inheritable[:do_not_route_head] = true end def do_not_route_head? - namespace_inheritable[:do_not_route_head] == true + @namespace_inheritable[:do_not_route_head] == true end def do_not_route_options! - namespace_inheritable[:do_not_route_options] = true + @namespace_inheritable[:do_not_route_options] = true end def do_not_route_options? - namespace_inheritable[:do_not_route_options] == true + @namespace_inheritable[:do_not_route_options] == true end def do_not_document! - namespace_inheritable[:do_not_document] = true + @namespace_inheritable[:do_not_document] = true end def do_not_document? - namespace_inheritable[:do_not_document] == true + @namespace_inheritable[:do_not_document] == true end def lint! - namespace_inheritable[:lint] = true + @namespace_inheritable[:lint] = true end def lint? - namespace_inheritable[:lint] == true + @namespace_inheritable[:lint] == true end # The params-builder strategy set by +build_with+ (both the @@ -536,11 +536,11 @@ def lint? # builds its Grape::Request. Nearest-wins scalar; nil when never set; # the backing store is an internal detail. def build_params_with - namespace_inheritable[:build_params_with] + @namespace_inheritable[:build_params_with] end def build_params_with=(strategy) - namespace_inheritable[:build_params_with] = strategy + @namespace_inheritable[:build_params_with] = strategy end # The authentication configuration Hash recorded by the +auth+ DSL @@ -549,11 +549,11 @@ def build_params_with=(strategy) # to warn about unauthenticated bare Rack mounts; the backing store is # an internal detail. def auth - namespace_inheritable[:auth] + @namespace_inheritable[:auth] end def auth=(auth_options) - namespace_inheritable[:auth] = auth_options + @namespace_inheritable[:auth] = auth_options end # Everything Router::Pattern::Path reads to assemble a route's origin @@ -575,8 +575,8 @@ def path_settings protected - # The inheritable store. Internal: every semantic key has a dedicated - # public accessor above — reach this only from InheritableSetting itself. + # Peer access to the inheritable store for #inherit_from and + # #copy_state_from; internal code reads the ivar directly. attr_reader :namespace_inheritable # Peer access to the stackable store for #inherit_from and From 23a62603d64d5447ad9e924c4a72e68941109359 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:23:29 -0400 Subject: [PATCH 09/10] Make path_settings a Router::Pattern::PathSettings value object Data.define with nil defaults, following the RescueOptions / VersionOptions precedent: Path reads typed members instead of hash keys, so a mistyped member fails loudly instead of returning nil. Co-Authored-By: Claude Fable 5 --- UPGRADING.md | 2 +- lib/grape/router/pattern/path.rb | 10 ++++---- lib/grape/router/pattern/path_settings.rb | 19 +++++++++++++++ lib/grape/util/inheritable_setting.rb | 17 ++++++------- spec/grape/router/pattern/path_spec.rb | 29 ++++++++++++----------- spec/grape/router/pattern_spec.rb | 2 +- 6 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 lib/grape/router/pattern/path_settings.rb diff --git a/UPGRADING.md b/UPGRADING.md index dc6bd5b96..04a53d516 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -149,7 +149,7 @@ The per-key encapsulation documented in the sections above is now enforced where Two supporting changes: -* `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot, which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. +* `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot — a `Grape::Router::Pattern::PathSettings` value object (`Data`) — which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. * `InheritableSetting#mount_paths` is added, exposing the full mount-path stack (one entry per mount level, outermost first), complementing `#mount_path`, which returns only the outermost entry. ### Upgrading to >= 3.3 diff --git a/lib/grape/router/pattern/path.rb b/lib/grape/router/pattern/path.rb index 3c1ea8b77..e34ef2f7c 100644 --- a/lib/grape/router/pattern/path.rb +++ b/lib/grape/router/pattern/path.rb @@ -28,7 +28,7 @@ def to_s private def build_suffix(raw_path, raw_namespace, settings) - return "(.#{settings[:format]})" if uses_specific_format?(settings) + return "(.#{settings.format})" if uses_specific_format?(settings) return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path) DEFAULT_FORMAT_SEGMENT @@ -36,8 +36,8 @@ def build_suffix(raw_path, raw_namespace, settings) def build_parts(raw_path, raw_namespace, settings) parts = [] - add_part(parts, settings[:mount_path]) - add_part(parts, settings[:root_prefix]) + add_part(parts, settings.mount_path) + add_part(parts, settings.root_prefix) parts << VERSION_SEGMENT if uses_path_versioning?(settings) add_part(parts, raw_namespace) add_part(parts, raw_path) @@ -53,11 +53,11 @@ def not_slash?(value) end def uses_specific_format?(settings) - settings[:format] && Array(settings[:content_types]).size == 1 + settings.format && Array(settings.content_types).size == 1 end def uses_path_versioning?(settings) - settings[:version] && settings[:version_options]&.using == :path + settings.version && settings.version_options&.using == :path end def valid_part?(part) diff --git a/lib/grape/router/pattern/path_settings.rb b/lib/grape/router/pattern/path_settings.rb new file mode 100644 index 000000000..c78f1fc93 --- /dev/null +++ b/lib/grape/router/pattern/path_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Grape + class Router + class Pattern + # Immutable snapshot of the settings {Path} reads to assemble a route's + # origin and suffix, built by + # +Grape::Util::InheritableSetting#path_settings+. +mount_path+ carries + # the full mount-path stack (one entry per mount level, outermost + # first) and +content_types+ the raw registration stack — {Path} counts + # registrations, not distinct formats. Unset members are nil. + PathSettings = Data.define(:mount_path, :root_prefix, :format, :content_types, :version, :version_options) do + def initialize(mount_path: nil, root_prefix: nil, format: nil, content_types: nil, version: nil, version_options: nil) + super + end + end + end + end +end diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index 1f06f05d6..cee03c6a2 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -556,21 +556,22 @@ def auth=(auth_options) @namespace_inheritable[:auth] = auth_options end - # Everything Router::Pattern::Path reads to assemble a route's origin - # and suffix (see Endpoint#to_routes). +mount_path+ is the full stack — - # one entry per mount level, outermost first — unlike #mount_path, - # which returns only the outermost entry; +content_types+ is the raw - # registration stack, because Path counts registrations rather than - # distinct formats. Keys are always present; unset values are nil. + # The Router::Pattern::PathSettings snapshot Router::Pattern::Path + # reads to assemble a route's origin and suffix (see + # Endpoint#to_routes). +mount_path+ is the full stack — one entry per + # mount level, outermost first — unlike #mount_path, which returns + # only the outermost entry; +content_types+ is the raw registration + # stack, because Path counts registrations rather than distinct + # formats. Unset members are nil. def path_settings - { + Grape::Router::Pattern::PathSettings.new( mount_path: mount_paths.presence, root_prefix:, format:, content_types: @namespace_stackable[:content_types].presence, version:, version_options: - } + ) end protected diff --git a/spec/grape/router/pattern/path_spec.rb b/spec/grape/router/pattern/path_spec.rb index 5ab9e9239..16429fd8b 100644 --- a/spec/grape/router/pattern/path_spec.rb +++ b/spec/grape/router/pattern/path_spec.rb @@ -1,22 +1,26 @@ # frozen_string_literal: true RSpec.describe Grape::Router::Pattern::Path do + def path_settings(**attrs) + Grape::Router::Pattern::PathSettings.new(**attrs) + end + describe '#origin' do context 'mount_path' do it 'is not included when it is nil' do - path = described_class.new(nil, nil, mount_path: '/foo/bar') + path = described_class.new(nil, nil, path_settings(mount_path: '/foo/bar')) expect(path.origin).to eql '/foo/bar' end it 'is included when it is not nil' do - path = described_class.new(nil, nil, {}) + path = described_class.new(nil, nil, path_settings) expect(path.origin).to eql('/') end end context 'root_prefix' do it 'is not included when it is nil' do - path = described_class.new(nil, nil, {}) + path = described_class.new(nil, nil, path_settings) expect(path.origin).to eql('/') end @@ -24,8 +28,7 @@ path = described_class.new( nil, nil, - mount_path: '/foo', - root_prefix: '/hello' + path_settings(mount_path: '/foo', root_prefix: '/hello') ) expect(path.origin).to eql('/foo/hello') @@ -36,8 +39,7 @@ path = described_class.new( nil, 'namespace', - mount_path: '/foo', - root_prefix: '/hello' + path_settings(mount_path: '/foo', root_prefix: '/hello') ) expect(path.origin).to eql('/foo/hello/namespace') @@ -47,8 +49,7 @@ path = described_class.new( 'raw_path', 'namespace', - mount_path: '/foo', - root_prefix: '/hello' + path_settings(mount_path: '/foo', root_prefix: '/hello') ) expect(path.origin).to eql('/foo/hello/namespace/raw_path') @@ -58,31 +59,31 @@ describe '#suffix' do context 'when using a specific format' do it 'accepts specified format' do - path = described_class.new(nil, nil, format: 'json', content_types: 'application/json') + path = described_class.new(nil, nil, path_settings(format: 'json', content_types: 'application/json')) expect(path.suffix).to eql('(.json)') end end context 'when path versioning is used' do it "includes a '/'" do - path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new) + path = described_class.new(nil, nil, path_settings(version: :v1, version_options: Grape::DSL::VersionOptions.new)) expect(path.suffix).to eql('(/.:format)') end end context 'when path versioning is not used' do it "does not include a '/' when the path has a namespace" do - path = described_class.new(nil, 'namespace', {}) + path = described_class.new(nil, 'namespace', path_settings) expect(path.suffix).to eql('(.:format)') end it "does not include a '/' when the path has a path" do - path = described_class.new('/path', nil, version: :v1, version_options: Grape::DSL::VersionOptions.new) + path = described_class.new('/path', nil, path_settings(version: :v1, version_options: Grape::DSL::VersionOptions.new)) expect(path.suffix).to eql('(.:format)') end it "includes a '/' otherwise" do - path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new) + path = described_class.new(nil, nil, path_settings(version: :v1, version_options: Grape::DSL::VersionOptions.new)) expect(path.suffix).to eql('(/.:format)') end end diff --git a/spec/grape/router/pattern_spec.rb b/spec/grape/router/pattern_spec.rb index b11914fcc..87c40359f 100644 --- a/spec/grape/router/pattern_spec.rb +++ b/spec/grape/router/pattern_spec.rb @@ -15,7 +15,7 @@ describe '.build' do subject(:pattern) do - described_class.build(path: '/foo', namespace: 'ns', settings: { root_prefix: '/api' }, anchor: true, params: {}, version: nil, requirements: {}) + described_class.build(path: '/foo', namespace: 'ns', settings: Grape::Router::Pattern::PathSettings.new(root_prefix: '/api'), anchor: true, params: {}, version: nil, requirements: {}) end it 'assembles origin/suffix from the path, namespace and settings via Path' do From b008c25be92556e06e343760efc6c1dfae8569a2 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 18 Jul 2026 22:26:17 -0400 Subject: [PATCH 10/10] Move PathSettings under Grape::Util::InheritableSetting Producer-side placement following the RescueOptions / VersionOptions precedent, and it fixes the dependency direction: Util no longer constructs a Router-namespace class; Router::Pattern::Path depending on Util is the normal direction. Building the snapshot must stay on InheritableSetting regardless, since only it may touch the raw stacks. Co-Authored-By: Claude Fable 5 --- UPGRADING.md | 2 +- lib/grape/util/inheritable_setting.rb | 7 +++---- .../inheritable_setting}/path_settings.rb | 14 +++++++------- spec/grape/router/pattern/path_spec.rb | 2 +- spec/grape/router/pattern_spec.rb | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) rename lib/grape/{router/pattern => util/inheritable_setting}/path_settings.rb (51%) diff --git a/UPGRADING.md b/UPGRADING.md index 04a53d516..f9d1cddd4 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -149,7 +149,7 @@ The per-key encapsulation documented in the sections above is now enforced where Two supporting changes: -* `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot — a `Grape::Router::Pattern::PathSettings` value object (`Data`) — which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. +* `Router::Pattern::Path` no longer receives a merged dump of both stores; `Endpoint#to_routes` passes the new `InheritableSetting#path_settings` snapshot — a `Grape::Util::InheritableSetting::PathSettings` value object (`Data`) — which carries exactly the six values `Path` reads (full mount-path stack, root prefix, format, raw content-types stack, version, version options) with always-present keys. `Path`'s `key?`-presence guards became nil-tolerant truthiness checks — equivalent under the snapshot. `Endpoint#prepare_default_path_settings` is removed. * `InheritableSetting#mount_paths` is added, exposing the full mount-path stack (one entry per mount level, outermost first), complementing `#mount_path`, which returns only the outermost entry. ### Upgrading to >= 3.3 diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index cee03c6a2..74ed6d723 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -556,15 +556,14 @@ def auth=(auth_options) @namespace_inheritable[:auth] = auth_options end - # The Router::Pattern::PathSettings snapshot Router::Pattern::Path - # reads to assemble a route's origin and suffix (see - # Endpoint#to_routes). +mount_path+ is the full stack — one entry per + # The PathSettings snapshot Router::Pattern::Path reads to assemble a + # route's origin and suffix (see Endpoint#to_routes). +mount_path+ is the full stack — one entry per # mount level, outermost first — unlike #mount_path, which returns # only the outermost entry; +content_types+ is the raw registration # stack, because Path counts registrations rather than distinct # formats. Unset members are nil. def path_settings - Grape::Router::Pattern::PathSettings.new( + PathSettings.new( mount_path: mount_paths.presence, root_prefix:, format:, diff --git a/lib/grape/router/pattern/path_settings.rb b/lib/grape/util/inheritable_setting/path_settings.rb similarity index 51% rename from lib/grape/router/pattern/path_settings.rb rename to lib/grape/util/inheritable_setting/path_settings.rb index c78f1fc93..350776a2c 100644 --- a/lib/grape/router/pattern/path_settings.rb +++ b/lib/grape/util/inheritable_setting/path_settings.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true module Grape - class Router - class Pattern - # Immutable snapshot of the settings {Path} reads to assemble a route's - # origin and suffix, built by - # +Grape::Util::InheritableSetting#path_settings+. +mount_path+ carries - # the full mount-path stack (one entry per mount level, outermost - # first) and +content_types+ the raw registration stack — {Path} counts + module Util + class InheritableSetting + # Immutable snapshot of the settings Router::Pattern::Path reads to + # assemble a route's origin and suffix, built by + # InheritableSetting#path_settings. +mount_path+ carries the full + # mount-path stack (one entry per mount level, outermost first) and + # +content_types+ the raw registration stack — Path counts # registrations, not distinct formats. Unset members are nil. PathSettings = Data.define(:mount_path, :root_prefix, :format, :content_types, :version, :version_options) do def initialize(mount_path: nil, root_prefix: nil, format: nil, content_types: nil, version: nil, version_options: nil) diff --git a/spec/grape/router/pattern/path_spec.rb b/spec/grape/router/pattern/path_spec.rb index 16429fd8b..91d52f6bf 100644 --- a/spec/grape/router/pattern/path_spec.rb +++ b/spec/grape/router/pattern/path_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Grape::Router::Pattern::Path do def path_settings(**attrs) - Grape::Router::Pattern::PathSettings.new(**attrs) + Grape::Util::InheritableSetting::PathSettings.new(**attrs) end describe '#origin' do diff --git a/spec/grape/router/pattern_spec.rb b/spec/grape/router/pattern_spec.rb index 87c40359f..87ccde017 100644 --- a/spec/grape/router/pattern_spec.rb +++ b/spec/grape/router/pattern_spec.rb @@ -15,7 +15,7 @@ describe '.build' do subject(:pattern) do - described_class.build(path: '/foo', namespace: 'ns', settings: Grape::Router::Pattern::PathSettings.new(root_prefix: '/api'), anchor: true, params: {}, version: nil, requirements: {}) + described_class.build(path: '/foo', namespace: 'ns', settings: Grape::Util::InheritableSetting::PathSettings.new(root_prefix: '/api'), anchor: true, params: {}, version: nil, requirements: {}) end it 'assembles origin/suffix from the path, namespace and settings via Path' do