diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce5face0..c83660b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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). +* [#2773](https://github.com/ruby-grape/grape/pull/2773): Fix middleware build crash when a top-level `::Options` constant is in scope - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.1 (2026-06-28) diff --git a/lib/grape/middleware/base.rb b/lib/grape/middleware/base.rb index f47ec752e..975208952 100644 --- a/lib/grape/middleware/base.rb +++ b/lib/grape/middleware/base.rb @@ -16,10 +16,8 @@ class Base # subclass's `DEFAULT_OPTIONS` Hash (legacy path) and frozen. def initialize(app, **options) @app = app - if self.class.const_defined?(:Options) - # Search ancestors so subclasses (e.g. Versioner::Path → Versioner::Base) - # inherit their parent's Options Data class without redeclaring it. - @config = self.class::Options.new(**options) + if (config_class = options_data_class) + @config = config_class.new(**options) @options = @config.to_h.freeze else @options = merge_default_options(options).freeze @@ -92,9 +90,25 @@ def merge_headers(response) def merge_default_options(options) return default_options.deep_merge(options) if respond_to?(:default_options) - return self.class::DEFAULT_OPTIONS.deep_merge(options) if self.class.const_defined?(:DEFAULT_OPTIONS) - options + default_options_constant&.deep_merge(options) || options + end + + # self.class::Options honours middleware inheritance (e.g. Versioner::Path + # → Versioner::Base) and, unlike const_defined?, never resolves a + # top-level ::Options on Object. Absent an own/inherited Options class, + # the lookup raises NameError and we fall back to the legacy path. + def options_data_class + self.class::Options + rescue NameError + nil + end + + # Same idea as {#options_data_class} for the legacy DEFAULT_OPTIONS Hash. + def default_options_constant + self.class::DEFAULT_OPTIONS + rescue NameError + nil end def try_scrub(obj) diff --git a/spec/grape/middleware/base_spec.rb b/spec/grape/middleware/base_spec.rb index 0884f50ab..8bd9f59e8 100644 --- a/spec/grape/middleware/base_spec.rb +++ b/spec/grape/middleware/base_spec.rb @@ -153,6 +153,74 @@ expect(example_ware.new(blank_app, monkey: false).options[:monkey]).to be false end end + + context 'when an unrelated top-level ::Options constant is in scope' do + # The `options` gem (a transitive dep of e.g. progress_bar) defines a + # global ::Options. const_defined?(:Options) reaches it through Object, + # but `self.class::Options` does not, so the naive guard raised NameError. + before { stub_const('Options', Module.new) } + + let(:example_ware) do + Class.new(Grape::Middleware::Base) do + const_set(:DEFAULT_OPTIONS, { monkey: true }.freeze) + end + end + + it 'builds without resolving the global ::Options' do + expect { example_ware.new(blank_app) }.not_to raise_error + end + + it 'falls back to the legacy DEFAULT_OPTIONS path' do + expect(example_ware.new(blank_app).options[:monkey]).to be true + end + + it 'does not expose a config object' do + expect(example_ware.new(blank_app).config).to be_nil + end + end + + context 'when an unrelated top-level ::DEFAULT_OPTIONS constant is in scope' do + before { stub_const('DEFAULT_OPTIONS', { monkey: true }.freeze) } + + let(:example_ware) { Class.new(described_class) } + + it 'ignores the global ::DEFAULT_OPTIONS' do + expect(example_ware.new(blank_app).options).not_to have_key(:monkey) + end + end + + context 'when a middleware declares its own Options Data class' do + let(:example_ware) do + Class.new(Grape::Middleware::Base) do + self::Options = Data.define(:monkey) + end + end + + it 'routes options through the Options value object' do + instance = example_ware.new(blank_app, monkey: true) + expect(instance.config.monkey).to be true + expect(instance.options[:monkey]).to be true + end + + it 'still resolves its own Options when a global ::Options is in scope' do + stub_const('Options', Module.new) + expect(example_ware.new(blank_app, monkey: true).config.monkey).to be true + end + end + + context 'when a middleware inherits its parent Options Data class' do + let(:parent_ware) do + Class.new(Grape::Middleware::Base) do + self::Options = Data.define(:monkey) + end + end + + let(:child_ware) { Class.new(parent_ware) } + + it 'inherits the ancestor Options without redeclaring it' do + expect(child_ware.new(blank_app, monkey: true).config.monkey).to be true + end + end end context 'header' do