Skip to content

Fix middleware build crash when a top-level ::Options constant is in scope#2773

Merged
ericproulx merged 1 commit into
masterfrom
fix-options-const-defined-object-fallback
Jun 29, 2026
Merged

Fix middleware build crash when a top-level ::Options constant is in scope#2773
ericproulx merged 1 commit into
masterfrom
fix-options-const-defined-object-fallback

Conversation

@ericproulx

@ericproulx ericproulx commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #2772.

Problem

Since 3.3.0, Grape::Middleware::Base#initialize routes options through a per-class Options Data value object when the subclass declares one:

if self.class.const_defined?(:Options)
  @config = self.class::Options.new(**options)
  ...

const_defined?(:Options) defaults to inherit: true, so the lookup also walks up to top-level constants on Object. If anything in the process defines a global ::Options, the guard returns true for every middleware subclass — even ones that never declare their own Options. The subsequent self.class::Options then raises NameError, because the :: resolution operator does not fall back to Object. The whole middleware stack fails to build.

This is not hypothetical: the options gem defines a top-level ::Options module on load and is a transitive dependency of common gems (e.g. progress_bar). With it loaded, every third-party middleware that subclasses Grape::Middleware::Base without its own Options (e.g. grape_logging's RequestLogger) crashes:

NameError: uninitialized constant GrapeLogging::Middleware::RequestLogger::Options

The DEFAULT_OPTIONS path in merge_default_options had the identical defect (self.class.const_defined?(:DEFAULT_OPTIONS)self.class::DEFAULT_OPTIONS), so a global ::DEFAULT_OPTIONS would crash the same way.

Fix

Drop the const_defined? guard entirely and resolve through self.class::Options directly:

def options_data_class
  self.class::Options
rescue NameError
  nil
end

The :: resolution operator already does exactly what we need: it honours middleware inheritance (e.g. Versioner::PathVersioner::Base) and, unlike const_defined?, it never falls back to a top-level ::Options on Object. So a global ::Options is no longer matched, and a middleware that declares none cleanly yields the legacy DEFAULT_OPTIONS path. The same treatment is applied to the DEFAULT_OPTIONS lookup.

This came down to a quirk of Ruby's reflection API: no predicate method matches the :: operator's "inherit through ancestors but skip Object" semantics — const_defined?(:X)/const_get(:X) include Object (the bug), while their inherit: false forms drop inheritance (which would break Versioner::Path). Letting :: raise and rescuing is the most direct way to mirror its behaviour.

Tests

Added regression specs to spec/grape/middleware/base_spec.rb (using stub_const for the global constant, auto-cleaned per example):

  • A middleware without its own Options builds and takes the legacy DEFAULT_OPTIONS path even with a global ::Options in scope (the crash repro), and exposes no config.
  • A global ::DEFAULT_OPTIONS is ignored by the legacy merge path.
  • A middleware that declares its own Options still routes through it, including when a global ::Options is present.
  • A subclass still inherits its parent's Options Data class without redeclaring it.

Benchmark

#initialize runs once per middleware at app-build time, not per request (call does dup.call!, which doesn't re-run initialize), so this is a boot-path concern only. Comparing the ::-and-rescue approach against the alternative (walking self.class.ancestors with const_defined?(name, false)), measured on Ruby 4.0.5 with benchmark-ips:

middleware shape :: + rescue ancestor walk
declares own Options (Formatter, Error, Versioner::Base) 49 ns 212 ns rescue 4.3× faster
inherits Options (Versioner::Path) 50 ns 306 ns rescue 6.1× faster
no Options (most third-party middleware) 549 ns 297 ns walk 1.85× faster

::-and-rescue is faster in exactly the shapes Grape ships (everything internal declares or inherits Options); it is slower only for no-Options middleware, where it pays the NameError cost — and that ~250 ns delta is boot-only and dwarfed by the rest of construction (a full ThirdParty.new with no Options is ~1.33 µs, still faster than Formatter.new at ~2.02 µs).

Verification

  • bundle exec rspec spec/grape/middleware/ — all green.
  • bundle exec rubocop lib/grape/middleware/base.rb spec/grape/middleware/base_spec.rb — no offenses.

🤖 Generated with Claude Code

@ericproulx ericproulx force-pushed the fix-options-const-defined-object-fallback branch from 1bac8b4 to cb4b958 Compare June 29, 2026 14:11
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx ericproulx requested a review from dblock June 29, 2026 14:11
Grape::Middleware::Base#initialize used self.class.const_defined?(:Options),
whose default inherit: true reaches top-level constants on Object. When any
loaded gem defines a global ::Options (e.g. the `options` gem, a transitive
dependency of progress_bar), the guard returned true for every middleware
subclass, but `self.class::Options` then raised NameError because the `::`
resolution operator does not fall back to Object. This took down the whole
middleware stack for third-party middleware that does not declare its own
Options (e.g. grape_logging's RequestLogger).

Resolve the constant through self.class::Options directly: `::` already
honours middleware inheritance (e.g. Versioner::Path -> Versioner::Base) and
never falls back to Object, so a global ::Options is no longer matched and an
absent one cleanly yields the legacy DEFAULT_OPTIONS path. Apply the same
treatment to the DEFAULT_OPTIONS lookup, which had the identical defect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ericproulx ericproulx force-pushed the fix-options-const-defined-object-fallback branch from cb4b958 to 47271c8 Compare June 29, 2026 14:20
# → 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.

@ericproulx ericproulx merged commit 927feec into master Jun 29, 2026
136 of 138 checks passed
@ericproulx ericproulx deleted the fix-options-const-defined-object-fallback branch June 29, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Middleware build crashes when a top-level ::Options constant is in scope (const_defined?(:Options) reaches Object)

2 participants