diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e383979..fc4b0f5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ #### Fixes * [#2767](https://github.com/ruby-grape/grape/pull/2767): Update rubocop to 1.88.0 and rubocop-rspec to 3.10.2 - [@ericproulx](https://github.com/ericproulx). +* [#2791](https://github.com/ruby-grape/grape/pull/2791): Build HEAD/OPTIONS/405 helper routes from a copy of the inheritable settings instead of temporarily mutating the shared class-level settings during compilation - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.2 (2026-07-05) diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index fcf012ccc..5e9e5615d 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -150,14 +150,19 @@ def add_head_not_allowed_methods_and_options_methods # contain already versioning information when using path versioning. all_routes = self.class.endpoints.flat_map(&:routes) - # Disable versioning so adding a route won't prepend versioning - # informations again. - without_root_prefix_and_versioning { collect_route_config_per_pattern(all_routes) } + # Read the settings the helper routes need from a *copy* with the + # root-prefix/versioning keys stripped, so adding these routes won't + # prepend versioning information again. This used to delete those keys + # from the shared class-level settings and restore them in an ensure; + # a request served concurrently on another instance during a runtime + # recompile could observe the missing keys (e.g. via #cascade?). A + # local copy keeps that mutation off the shared object. + namespace_inheritable = self.class.inheritable_setting.namespace_inheritable.to_hash.except(*ROOT_PREFIX_VERSIONING_KEYS) + collect_route_config_per_pattern(all_routes, namespace_inheritable) end - def collect_route_config_per_pattern(all_routes) + def collect_route_config_per_pattern(all_routes, namespace_inheritable) routes_by_regexp = all_routes.group_by(&:pattern_regexp) - namespace_inheritable = self.class.inheritable_setting.namespace_inheritable # Build the configuration based on the first endpoint and the collection of methods supported. routes_by_regexp.each_value do |routes| @@ -177,18 +182,6 @@ def collect_route_config_per_pattern(all_routes) ROOT_PREFIX_VERSIONING_KEYS = %i[version version_options root_prefix].freeze private_constant :ROOT_PREFIX_VERSIONING_KEYS - - # Allows definition of endpoints that ignore the versioning configuration - # used by the rest of your API. - def without_root_prefix_and_versioning - inheritable_setting = self.class.inheritable_setting - deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEYS) - yield - ensure - ROOT_PREFIX_VERSIONING_KEYS.zip(deleted_values) do |key, value| - inheritable_setting.namespace_inheritable[key] = value - end - end end end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 0bb42eb7f..fcab87d18 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -7,6 +7,27 @@ let(:app) { subject } + describe 'thread-safe (re)compilation' do + # Regression: building the HEAD/OPTIONS/405 helper routes used to delete the + # root-prefix/versioning keys from the shared class-level settings and + # restore them in an ensure. A request served concurrently on another + # instance during a runtime recompile could observe the missing keys (e.g. + # via #cascade?). Compilation must not mutate the shared settings. + it 'does not mutate the shared inheritable settings while (re)compiling' do + subject.version 'v1', using: :path, cascade: false + subject.get('/x') { 'x' } + subject.compile! + + shared = subject.base_instance.inheritable_setting.namespace_inheritable + expect(shared).not_to receive(:delete) + + subject.change! + subject.compile! + + expect(shared[:version_options]).to be_present + end + end + describe '.prefix' do it 'routes root through with the prefix' do subject.prefix 'awesome/sauce'