Skip to content

Commit ab97320

Browse files
ericproulxclaude
andcommitted
Add mounted_app reader and app: keyword to Grape::Endpoint
`app` (the Rack app or Grape API mounted at an endpoint) was reachable only by digging into the public options bag as `endpoint.options[:app]`. Introduce a first-class `mounted_app` reader (returning `config.app`, `nil` for a plain block endpoint) and accept `app:` as an explicit keyword on `Grape::Endpoint#initialize`. Grape's own mount-refresh dedup now reads `endpoint.mounted_app` instead of `options[:app]`. This is additive and non-breaking: `options[:app]` is still populated exactly as before for downstream consumers (e.g. grape-swagger), which can migrate to `mounted_app` before it is eventually dropped from the options Hash. Also adds the previously-missing coverage for the `refresh_already_mounted` dedup path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0641a11 commit ab97320

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* [#2774](https://github.com/ruby-grape/grape/pull/2774): Make `forward_match` an internal kwarg instead of a route option - [@ericproulx](https://github.com/ericproulx).
77
* [#2775](https://github.com/ruby-grape/grape/pull/2775): Pass `http_methods` to `Grape::Endpoint` as a keyword instead of via options - [@ericproulx](https://github.com/ericproulx).
88
* [#2776](https://github.com/ruby-grape/grape/pull/2776): Pass `path` to `Grape::Endpoint` as a keyword instead of via options - [@ericproulx](https://github.com/ericproulx).
9+
* [#2777](https://github.com/ruby-grape/grape/pull/2777): Add `Grape::Endpoint#mounted_app` reader and `app:` keyword - [@ericproulx](https://github.com/ericproulx).
910
* Your contribution here.
1011

1112
#### Fixes

lib/grape/dsl/routing.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def mount(mounts, *opts)
150150
refresh_already_mounted = opts.any? ? opts.first[:refresh_already_mounted] : false
151151
if refresh_already_mounted && !endpoints.empty?
152152
endpoints.delete_if do |endpoint|
153-
endpoint.options[:app].to_s == app.to_s
153+
endpoint.mounted_app.to_s == app.to_s
154154
end
155155
end
156156

lib/grape/endpoint.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def logger
2424
config.for.logger
2525
end
2626

27+
# The Rack app or Grape API mounted at this endpoint, or +nil+ for a plain
28+
# block endpoint. Prefer this over +options[:app]+, which is retained only
29+
# for backwards compatibility.
30+
def mounted_app
31+
config.app
32+
end
33+
2734
class << self
2835
def block_to_unbound_method(block)
2936
return unless block
@@ -42,13 +49,15 @@ def block_to_unbound_method(block)
4249
# reach this endpoint.
4350
# @param path [String or Array] the path to this endpoint, within the
4451
# current scope.
52+
# @param app [#call, nil] the Rack app or Grape API mounted at this
53+
# endpoint; +nil+ for a plain block endpoint. Exposed as {#mounted_app}.
4554
# @param options [Hash] attributes of this endpoint, normalized into a
4655
# +Grape::Endpoint::Options+ value object.
4756
# @option options route_options [Hash]
4857
# @note This happens at the time of API definition, so in this context the
4958
# endpoint does not know if it will be mounted under a different endpoint.
5059
# @yield a block defining what your API should do when this endpoint is hit
51-
def initialize(new_settings, http_methods:, path:, **options, &block)
60+
def initialize(new_settings, http_methods:, path:, app: nil, **options, &block)
5261
self.inheritable_setting = new_settings.point_in_time_copy
5362

5463
# now +namespace_stackable(:declared_params)+ contains all params defined for
@@ -61,7 +70,10 @@ def initialize(new_settings, http_methods:, path:, **options, &block)
6170
inheritable_setting.namespace_inheritable[:default_error_status] ||= 500
6271

6372
@options = options
64-
@config = Options.new(http_methods:, path:, **options)
73+
@config = Options.new(http_methods:, path:, app:, **options)
74+
# +:app+ is still surfaced on the public options Hash for backwards
75+
# compatibility (e.g. grape-swagger); prefer the +mounted_app+ reader.
76+
@options[:app] = app if app
6577

6678
@status = nil
6779
@stream = nil

spec/grape/api_spec.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3571,6 +3571,39 @@ def self.call(object, _env)
35713571
end
35723572
end
35733573

3574+
describe 'the mounted endpoint' do
3575+
it 'exposes the mounted app through #mounted_app' do
3576+
subject.mount mounted_app => '/mounty'
3577+
expect(subject.endpoints.last.mounted_app).to be(mounted_app)
3578+
end
3579+
3580+
it 'reports nil #mounted_app for a plain block endpoint' do
3581+
subject.get('/plain') { 'hi' }
3582+
expect(subject.endpoints.last.mounted_app).to be_nil
3583+
end
3584+
3585+
it 'still surfaces the mounted app on the options Hash' do
3586+
subject.mount mounted_app => '/mounty'
3587+
expect(subject.endpoints.last.options[:app]).to be(mounted_app)
3588+
end
3589+
end
3590+
3591+
context 'with refresh_already_mounted' do
3592+
let(:app) { Class.new(described_class) { get('/x') { 'x' } } }
3593+
3594+
it 'replaces a previously mounted app instead of duplicating it' do
3595+
subject.mount app => '/thing'
3596+
expect { subject.mount({ app => '/thing' }, refresh_already_mounted: true) }
3597+
.not_to(change { subject.endpoints.count })
3598+
end
3599+
3600+
it 'duplicates the endpoint without the flag' do
3601+
subject.mount app => '/thing'
3602+
expect { subject.mount app => '/thing' }
3603+
.to change { subject.endpoints.count }.by(1)
3604+
end
3605+
end
3606+
35743607
context 'mounting an API' do
35753608
it 'applies the settings of the mounting api' do
35763609
subject.version 'v1', using: :path

0 commit comments

Comments
 (0)