Skip to content

Commit ab000ef

Browse files
ericproulxclaude
andauthored
Land #2775#2779 on master (stacked-merge recovery) (#2780)
* Pass http_methods to Grape::Endpoint as a keyword instead of via options `method` was pulled out of the endpoint's `**options` Hash, normalized in place (`@options[:method] = Array(...)`), and renamed to `http_methods` on the `Options` value object purely to dodge the `Object#method` shadow on the generated Data accessor. Expose it directly as a required `http_methods:` keyword on `Grape::Endpoint#initialize` and pass it straight into `Options`, whose field is now genuinely named `http_methods` (no rename, no shadow). The value stays in `config` so endpoint equality — `config == other.config`, which keeps same-path/different-verb endpoints distinct — is unchanged. The endpoint's public `options` Hash no longer carries the redundant `:method` entry; nothing read it, and grape-swagger reads the verb from `route.request_method`. Documented in UPGRADING. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pass path to Grape::Endpoint as a keyword instead of via options `path` was pulled from the endpoint's `**options` Hash and normalized in place (`@options[:path] = Array(...)`), duplicating the normalization `Options` already does. Expose it as a required `path:` keyword on `Grape::Endpoint#initialize` and pass it straight into `Options`, which stays the single normalized source of truth. `path` remains in `config` because it is load-bearing for route building (`config.path`) and for endpoint equality (`config == other.config`, which keeps same-path/different-verb endpoints distinct). The endpoint's public `options` Hash no longer carries `:path`. Its only reader was an internal test; `route.path` (the compiled pattern) is the public path API and is what grape-swagger already uses. Documented in UPGRADING. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * Rename Grape::Endpoint's for: keyword to api: and expose #api `for` identified the API an endpoint belongs to, but it is a reserved word: it can only reach `Options` through `**rest` and its value can't be referenced as a local. Rename it to `api:`, an explicit keyword on `Grape::Endpoint#initialize`, and drop the `**rest` workaround from `Options`. `api` stays in `config` because it is load-bearing: the endpoint reaches the mounting API's live `configuration` (used by remountable APIs and read lazily at request time) through it, so it cannot be captured at construction. `logger` and the new public `#api` reader are exposed via `Forwardable` delegation to the config value object. The owning API is no longer carried on the endpoint's public options Hash (`endpoint.options[:for]` is gone); use `endpoint.api` instead. Only in-repo reader was one spec. Documented in UPGRADING. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Promote route metadata to Route keyword arguments `version`, `namespace`, `prefix`, `requirements`, `anchor` and `settings` were computed in `Endpoint#prepare_default_route_attributes`, merged into the route options Hash, and read back through `Grape::Router::BaseRoute`'s `def_delegators :@options`. They are route metadata, not part of the user-declared options bag grape-swagger consumes. They are now passed to `Grape::Router::Route` as explicit keyword arguments and exposed as plain readers on `BaseRoute`. `Endpoint#to_routes` computes each as a local and passes `config.route_options` straight through, so `prepare_default_route_attributes` (and the intermediate `default_route_options`/`complete_route_options`) is gone. `Route` forwards the attributes to `super` via `**route_attributes` to keep its signature lean. `route.options` now carries only what was declared for the route; the `@options` delegation is down to `description` plus the documentation `DSL_METHODS`. The readers are unchanged, so grape-swagger (which reads `route.prefix` and `route.settings`) is unaffected. `requirements` and `anchor` can still be declared as route options, so any explicit value stays in `route.options`; the effective value comes from the reader. Also drop the vestigial `:format` member from the endpoint's `Options`. Nothing ever passed `format:` to `Grape::Endpoint.new`, so `config.format` was always `nil`; the format capture comes from the path suffix built by `Grape::Path`, not from `Pattern`'s `format`. `Pattern#initialize` keeps `format:` (now optional) since it remains a valid capture constraint used by external callers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9f6e8d commit ab000ef

14 files changed

Lines changed: 190 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
* [#2768](https://github.com/ruby-grape/grape/pull/2768): Remove Guard - [@ericproulx](https://github.com/ericproulx).
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).
7+
* [#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).
8+
* [#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).
10+
* [#2778](https://github.com/ruby-grape/grape/pull/2778): Rename `Grape::Endpoint`'s `for:` keyword to `api:` and expose `#api` - [@ericproulx](https://github.com/ericproulx).
11+
* [#2779](https://github.com/ruby-grape/grape/pull/2779): Promote route metadata (`version`, `namespace`, `prefix`, `requirements`, `anchor`, `settings`) to `Grape::Router::Route` keyword arguments - [@ericproulx](https://github.com/ericproulx).
712
* Your contribution here.
813

914
#### Fixes

UPGRADING.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,51 @@ Upgrading Grape
99

1010
As a result it is no longer readable through `route.options[:forward_match]` or the `route.forward_match` reader — both previously returned the flag. Nothing in Grape consumed either, so this only affects code that introspected routes directly; there is no replacement, as the value is now purely internal.
1111

12+
#### `Grape::Endpoint.new` takes `http_methods:` instead of `method:`
13+
14+
`Grape::Endpoint.new` now receives the HTTP verb(s) under the `http_methods:` keyword instead of `method:`, matching the name used everywhere else. If you build endpoints directly (uncommon — this is an internal API normally driven by the routing DSL), rename the keyword:
15+
16+
```ruby
17+
# before
18+
Grape::Endpoint.new(settings, method: :get, path: '/foo', for: self)
19+
20+
# after
21+
Grape::Endpoint.new(settings, http_methods: :get, path: '/foo', for: self)
22+
```
23+
24+
Relatedly, an endpoint's public `options` Hash no longer carries `:method` or `:path`. Nothing in Grape read `:method`, and the only reader of `:path` was an internal test; both values are available from the route instead — `route.request_method` for the verb and `route.path` for the (compiled) path, which is what grape-swagger and other introspection already use. The raw definition-time path array is no longer exposed on the endpoint.
25+
26+
#### `Grape::Endpoint.new` takes `api:` instead of `for:`
27+
28+
The keyword identifying the API an endpoint belongs to has been renamed from `for:` — a reserved word whose value can't be referenced as a local — to `api:`:
29+
30+
```ruby
31+
# before
32+
Grape::Endpoint.new(settings, http_methods: :get, path: '/foo', for: my_api)
33+
34+
# after
35+
Grape::Endpoint.new(settings, http_methods: :get, path: '/foo', api: my_api)
36+
```
37+
38+
The owning API is no longer carried on the endpoint's public `options` Hash — `endpoint.options[:for]` is gone. Use the new `endpoint.api` reader instead.
39+
40+
#### Route metadata is exposed through readers, not the `options` Hash
41+
42+
A route's computed metadata — `version`, `namespace`, `prefix`, `requirements`, `anchor` and `settings` — is now passed to `Grape::Router::Route` as explicit keyword arguments and exposed as plain readers, instead of being merged into the route's `options` Hash.
43+
44+
The readers are unchanged — keep using them:
45+
46+
```ruby
47+
route.version # => 'v1'
48+
route.namespace # => '/things'
49+
route.prefix # => 'api'
50+
route.requirements # => {}
51+
route.anchor # => true
52+
route.settings # => { ... }
53+
```
54+
55+
What changed is the raw bag. `route.options` now holds only what was declared for the route, so it no longer carries the *computed* values for these keys — `route.options[:version]`, `[:namespace]`, `[:prefix]` and `[:settings]` return `nil`. Nothing in Grape or grape-swagger read them that way (grape-swagger uses the `route.prefix` and `route.settings` readers). For `requirements` and `anchor`, which can be supplied as route options (e.g. a mount's `anchor: false`), any explicitly declared value still appears in `route.options`; the effective value always comes from the reader.
56+
1257
### Upgrading to >= 3.3
1358

1459
#### Minimum required Ruby is now 3.3

lib/grape/dsl/inside_route.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def version
1515
end
1616

1717
def configuration
18-
config.for.configuration.evaluate
18+
config.api.configuration.evaluate
1919
end
2020

2121
# End the request and display an error to the

lib/grape/dsl/routing.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,17 +150,17 @@ 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

157157
endpoints << Grape::Endpoint.new(
158158
in_setting,
159-
method: :any,
159+
http_methods: :any,
160160
path:,
161161
app:,
162162
route_options: { anchor: false },
163-
for: self
163+
api: self
164164
)
165165
end
166166
end
@@ -178,7 +178,7 @@ def mount(mounts, *opts)
178178
# end
179179
# end
180180
def route(methods, paths = ['/'], route_options = {}, &)
181-
method = methods == :any ? '*' : methods
181+
http_methods = methods == :any ? '*' : methods
182182
endpoint_params = inheritable_setting.namespace_stackable_with_hash(:params) || {}
183183
endpoint_description = inheritable_setting.route[:description]
184184
all_route_options = { params: endpoint_params }
@@ -187,9 +187,9 @@ def route(methods, paths = ['/'], route_options = {}, &)
187187

188188
new_endpoint = Grape::Endpoint.new(
189189
inheritable_setting,
190-
method:,
190+
http_methods:,
191191
path: paths,
192-
for: self,
192+
api: self,
193193
route_options: all_route_options,
194194
&
195195
)

lib/grape/endpoint.rb

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@ class Endpoint
1717
def_delegators :request, :params, :headers, :cookies
1818
def_delegator :cookies, :response_cookies
1919

20+
# The API (a +Grape::API+ instance) this endpoint belongs to.
21+
def_delegator :@config, :api
22+
2023
# The logger configured on the API this endpoint belongs to. Available
2124
# inside route handlers, +before+/+after+/+after_validation+/+finally+
2225
# filters, and +rescue_from+ blocks.
23-
def logger
24-
config.for.logger
26+
def_delegator :api, :logger
27+
28+
# The Rack app or Grape API mounted at this endpoint, or +nil+ for a plain
29+
# block endpoint. Prefer this over +options[:app]+, which is retained only
30+
# for backwards compatibility.
31+
def mounted_app
32+
config.app
2533
end
2634

2735
class << self
@@ -38,17 +46,21 @@ def block_to_unbound_method(block)
3846
# Create a new endpoint.
3947
# @param new_settings [InheritableSetting] settings to determine the params,
4048
# validations, and other properties from.
49+
# @param http_methods [String or Array] which HTTP method(s) can be used to
50+
# reach this endpoint.
51+
# @param path [String or Array] the path to this endpoint, within the
52+
# current scope.
53+
# @param api [Grape::API] the API this endpoint belongs to. Exposed as
54+
# {#api}.
55+
# @param app [#call, nil] the Rack app or Grape API mounted at this
56+
# endpoint; +nil+ for a plain block endpoint. Exposed as {#mounted_app}.
4157
# @param options [Hash] attributes of this endpoint, normalized into a
4258
# +Grape::Endpoint::Options+ value object.
43-
# @option options path [String or Array] the path to this endpoint, within
44-
# the current scope.
45-
# @option options method [String or Array] which HTTP method(s) can be used
46-
# to reach this endpoint.
4759
# @option options route_options [Hash]
4860
# @note This happens at the time of API definition, so in this context the
4961
# endpoint does not know if it will be mounted under a different endpoint.
5062
# @yield a block defining what your API should do when this endpoint is hit
51-
def initialize(new_settings, **options, &block)
63+
def initialize(new_settings, http_methods:, path:, api:, app: nil, **options, &block)
5264
self.inheritable_setting = new_settings.point_in_time_copy
5365

5466
# now +namespace_stackable(:declared_params)+ contains all params defined for
@@ -61,10 +73,10 @@ def initialize(new_settings, **options, &block)
6173
inheritable_setting.namespace_inheritable[:default_error_status] ||= 500
6274

6375
@options = options
64-
@options[:path] = Array(@options[:path])
65-
@options[:path] << '/' if @options[:path].empty?
66-
@options[:method] = Array(@options[:method])
67-
@config = Options.new(**options)
76+
@config = Options.new(http_methods:, path:, api:, app:, **options)
77+
# +:app+ is still surfaced on the public options Hash for backwards
78+
# compatibility (e.g. grape-swagger); prefer the +mounted_app+ reader.
79+
@options[:app] = app if app
6880

6981
@status = nil
7082
@stream = nil
@@ -273,39 +285,30 @@ def compile!
273285

274286
def to_routes
275287
route_options = config.route_options
276-
default_route_options = prepare_default_route_attributes(route_options)
277-
complete_route_options = route_options.merge(default_route_options)
278288
path_settings = prepare_default_path_settings
279289
forward_match = config.app && !config.app.respond_to?(:inheritable_setting)
290+
version = prepare_version(inheritable_setting.namespace_inheritable[:version])
291+
prefix = inheritable_setting.namespace_inheritable[:root_prefix]
292+
requirements = prepare_routes_requirements(route_options[:requirements])
293+
anchor = route_options.fetch(:anchor, true)
294+
settings = inheritable_setting.route.except(:declared_params, :saved_validations)
280295

281296
config.http_methods.flat_map do |method|
282297
config.path.map do |path|
283-
prepared_path = Path.new(path, default_route_options[:namespace], path_settings)
298+
prepared_path = Path.new(path, namespace, path_settings)
284299
pattern = Grape::Router::Pattern.new(
285300
origin: prepared_path.origin,
286301
suffix: prepared_path.suffix,
287-
anchor: default_route_options[:anchor],
302+
anchor:,
288303
params: route_options[:params],
289-
format: config.format,
290-
version: default_route_options[:version],
291-
requirements: default_route_options[:requirements]
304+
version:,
305+
requirements:
292306
)
293-
Grape::Router::Route.new(self, method, pattern, complete_route_options, forward_match:)
307+
Grape::Router::Route.new(self, method, pattern, route_options, forward_match:, version:, namespace:, prefix:, requirements:, anchor:, settings:)
294308
end
295309
end
296310
end
297311

298-
def prepare_default_route_attributes(route_options)
299-
{
300-
namespace:,
301-
version: prepare_version(inheritable_setting.namespace_inheritable[:version]),
302-
requirements: prepare_routes_requirements(route_options[:requirements]),
303-
prefix: inheritable_setting.namespace_inheritable[:root_prefix],
304-
anchor: route_options.fetch(:anchor, true),
305-
settings: inheritable_setting.route.except(:declared_params, :saved_validations)
306-
}
307-
end
308-
309312
def prepare_default_path_settings
310313
namespace_stackable_hash = inheritable_setting.namespace_stackable.to_hash
311314
namespace_inheritable_hash = inheritable_setting.namespace_inheritable.to_hash

lib/grape/endpoint/options.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ class Endpoint
66
# +Grape::Endpoint.new+. Internal to {Grape::Endpoint}, which builds it
77
# from the +**options+ Hash in #initialize so the public +options+ reader
88
# stays a plain Hash for downstream gems (e.g. grape-swagger).
9-
# +:method+ is renamed to +:http_methods+ on the value object to avoid
10-
# shadowing +Object#method+ via the generated Data accessor.
11-
Options = Data.define(:path, :http_methods, :for, :route_options, :app, :format) do
12-
def initialize(path:, method:, route_options: {}, app: nil, format: nil, **rest)
9+
Options = Data.define(:path, :http_methods, :api, :route_options, :app) do
10+
def initialize(path:, http_methods:, api:, route_options: {}, app: nil)
1311
path = Array(path)
1412
path << '/' if path.empty?
15-
super(path:, http_methods: Array(method), route_options:, app:, format:, **rest)
13+
http_methods = Array(http_methods)
14+
super
1615
end
1716
end
1817
end

lib/grape/router/base_route.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@ class BaseRoute
77

88
delegate_missing_to :@options
99

10-
attr_reader :options, :pattern
10+
attr_reader :options, :pattern, :version, :prefix, :requirements, :anchor, :settings, :namespace
1111

1212
def_delegators :@pattern, :path, :origin
13-
def_delegators :@options, :description, :version, :requirements, :prefix, :anchor, :settings, *Grape::Util::ApiDescription::DSL_METHODS
13+
def_delegators :@options, :description, *Grape::Util::ApiDescription::DSL_METHODS
1414

15-
def initialize(pattern, options = {})
15+
def initialize(pattern, options = {}, version: nil, namespace: nil, prefix: nil, requirements: nil, anchor: nil, settings: nil)
1616
@pattern = pattern
1717
@options = options.is_a?(ActiveSupport::OrderedOptions) ? options : ActiveSupport::OrderedOptions.new.update(options)
18-
end
19-
20-
# see https://github.com/ruby-grape/grape/issues/1348
21-
def namespace
22-
@namespace ||= @options[:namespace]
18+
@version = version
19+
@namespace = namespace
20+
@prefix = prefix
21+
@requirements = requirements
22+
@anchor = anchor
23+
@settings = settings
2324
end
2425

2526
def regexp_capture_index

lib/grape/router/pattern.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class Pattern
1313
def_delegators :to_regexp, :===
1414
alias match? ===
1515

16-
def initialize(origin:, suffix:, anchor:, params:, format:, version:, requirements:)
16+
def initialize(origin:, suffix:, anchor:, params:, version:, requirements:, format: nil)
1717
@origin = origin
1818
@path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
1919
@pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(format, version, requirements))

lib/grape/router/route.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ class Route < BaseRoute
1212

1313
def_delegators :@app, :call
1414

15-
def initialize(endpoint, method, pattern, options, forward_match:)
16-
super(pattern, options)
15+
def initialize(endpoint, method, pattern, options, forward_match:, **route_attributes)
16+
super(pattern, options, **route_attributes)
1717
@app = endpoint
1818
@request_method = upcase_method(method)
1919
@match_function = forward_match ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD

spec/grape/api_spec.rb

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3133,6 +3133,14 @@ def self.call(object, _env)
31333133
it 'sets prefix' do
31343134
expect(subject.routes[1].prefix).to eq('p')
31353135
end
3136+
3137+
it 'does not carry computed attributes in the route options Hash' do
3138+
route = subject.routes[1]
3139+
expect(route.options).not_to have_key(:version)
3140+
expect(route.options).not_to have_key(:namespace)
3141+
expect(route.options).not_to have_key(:prefix)
3142+
expect(route.options).not_to have_key(:settings)
3143+
end
31363144
end
31373145

31383146
describe 'api structure with additional parameters' do
@@ -3571,6 +3579,39 @@ def self.call(object, _env)
35713579
end
35723580
end
35733581

3582+
describe 'the mounted endpoint' do
3583+
it 'exposes the mounted app through #mounted_app' do
3584+
subject.mount mounted_app => '/mounty'
3585+
expect(subject.endpoints.last.mounted_app).to be(mounted_app)
3586+
end
3587+
3588+
it 'reports nil #mounted_app for a plain block endpoint' do
3589+
subject.get('/plain') { 'hi' }
3590+
expect(subject.endpoints.last.mounted_app).to be_nil
3591+
end
3592+
3593+
it 'still surfaces the mounted app on the options Hash' do
3594+
subject.mount mounted_app => '/mounty'
3595+
expect(subject.endpoints.last.options[:app]).to be(mounted_app)
3596+
end
3597+
end
3598+
3599+
context 'with refresh_already_mounted' do
3600+
let(:app) { Class.new(described_class) { get('/x') { 'x' } } }
3601+
3602+
it 'replaces a previously mounted app instead of duplicating it' do
3603+
subject.mount app => '/thing'
3604+
expect { subject.mount({ app => '/thing' }, refresh_already_mounted: true) }
3605+
.not_to(change { subject.endpoints.count })
3606+
end
3607+
3608+
it 'duplicates the endpoint without the flag' do
3609+
subject.mount app => '/thing'
3610+
expect { subject.mount app => '/thing' }
3611+
.to change { subject.endpoints.count }.by(1)
3612+
end
3613+
end
3614+
35743615
context 'mounting an API' do
35753616
it 'applies the settings of the mounting api' do
35763617
subject.version 'v1', using: :path
@@ -3982,7 +4023,7 @@ def my_method
39824023
subject.format :json
39834024
subject.get '/endpoint/options' do
39844025
{
3985-
path: options[:path],
4026+
path: route.path,
39864027
source_location: source.source_location
39874028
}
39884029
end
@@ -3991,7 +4032,7 @@ def my_method
39914032
it 'path' do
39924033
get '/endpoint/options'
39934034
options = Grape::Json.parse(last_response.body)
3994-
expect(options['path']).to eq(['/endpoint/options'])
4035+
expect(options['path']).to eq('/endpoint/options(.json)')
39954036
expect(options['source_location'][0]).to include 'api_spec.rb'
39964037
expect(options['source_location'][1].to_i).to be > 0
39974038
end

0 commit comments

Comments
 (0)