Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 20 additions & 6 deletions lib/grape/middleware/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to expose these as methods? Do they need to be private?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're already private — defined below the private on line 80, which sits just above this hunk so it doesn't show in the diff view (private_method_defined? confirms it for both).

Each is used exactly once (options_data_class in #initialize, default_options_constant in #merge_default_options). They're extracted only to scope rescue NameError to the constant lookup itself:

  • the inline rescue modifier (self.class::Options rescue nil) would swallow every StandardError, not just NameError, and trips Style/RescueModifier;
  • a single parametric helper isn't possible here since :: is literal-only, and its dynamic equivalent const_get reintroduces the top-level ::Options-on-Object fallback this PR is removing.

Happy to inline them into begin/rescue blocks if you'd prefer fewer methods.

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)
Expand Down
68 changes: 68 additions & 0 deletions spec/grape/middleware/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading