Skip to content

Commit 67f619d

Browse files
ericproulxclaude
andcommitted
Make a helper reach the routes declared above it in its scope
An endpoint is built from a point-in-time copy of its scope's settings. The copy keeps the scope's own helpers as they were at the route, but links to the enclosing scopes live. So a `helpers` declared further down the same scope never reached the route, and calling it raised NameError. One declared further down an enclosing scope, or after `mount`, did reach it. The endpoint now also keeps the live settings of its scope and reads the helpers from them when it compiles, by which time the whole class body has run. A helper declared in a sibling namespace still doesn't reach the route. A helper defined twice in one scope therefore answers with its last definition in every route of that scope, including those declared between the two definitions. UPGRADING describes that change. The README no longer lists `helpers` among the declarations that must come above the routes they cover. `use`, `rescue_from` and the callbacks are unchanged. The route endpoint gains a sixth ivar. Per-request copies hold eight, so their 80-byte slot is unchanged. Fixes #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0aa1fa5 commit 67f619d

5 files changed

Lines changed: 86 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* [#3051](https://github.com/ruby-grape/grape/pull/3051): Look up the validation error format once per response instead of once per message - [@ericproulx](https://github.com/ericproulx).
1818
* [#3055](https://github.com/ruby-grape/grape/pull/3055): Stop a 405 behind `Rack::Cascade` from making the next app answer 405 too - [@ericproulx](https://github.com/ericproulx).
1919
* [#3058](https://github.com/ruby-grape/grape/pull/3058): Declare `rexml` in the test bundle instead of relying on Danger to bring it in - [@ericproulx](https://github.com/ericproulx).
20+
* [#3061](https://github.com/ruby-grape/grape/pull/3061): Make a helper reach the routes declared above it in its scope - [@ericproulx](https://github.com/ericproulx).
2021
* Your contribution here.
2122

2223
### 4.1.0 (2026-10-31)

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2874,7 +2874,7 @@ class Twitter::API < Grape::API
28742874
end
28752875
```
28762876

2877-
The same goes for `use`, `helpers` and the `before`/`after` callbacks.
2877+
The same goes for `use` and the `before`/`after` callbacks. `helpers` are the exception: they reach every route of their scope, wherever they are declared in it.
28782878

28792879
Notice that you could combine these two approaches (rescuing custom errors takes precedence). For example, it's useful for handling all exceptions except Grape validation errors.
28802880

‎UPGRADING.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ Upgrading Grape
33

44
### Upgrading to >= 4.2.0
55

6+
#### A helper reaches every route of its scope, wherever it is declared
7+
8+
A `helpers` block or module used to reach only the routes declared below it in the same scope. A route declared above it raised `NameError` when it called the helper ([#34](https://github.com/ruby-grape/grape/issues/34)). Since [#3061](https://github.com/ruby-grape/grape/pull/3061) it reaches every route of its scope, as it already did from an enclosing scope and through `mount`.
9+
10+
So a helper method defined twice in one scope now answers with its last definition in every route of that scope, including the routes declared between the two definitions:
11+
12+
```ruby
13+
helpers { def greeting = 'first' }
14+
get('/before') { greeting } # was 'first', now 'second'
15+
helpers { def greeting = 'second' }
16+
get('/after') { greeting } # 'second'
17+
```
18+
19+
To keep a different method per route, give the methods different names, or declare the routes in separate namespaces, each with its own `helpers`. `use`, `rescue_from` and the `before`/`after` callbacks still apply only to the routes declared below them.
20+
621
#### A request every route declines answers 404, however many routes declined
722

823
A route declines a request by answering with `X-Cascade: pass`, as a version declared with `cascade: true` does for a version it does not serve. As the [README](README.md#header) documents, the router then answers its own `404` if no other route takes the request. It only did so when one route had declined: with two or more routes on the path, the last one's response came back instead, and so did that of an ANY route (since 4.0.0). So a request for an unknown version answered `404` from an API with one version and `406` from an API with several.

‎lib/grape/endpoint.rb‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, r
113113
source: self.class.block_to_unbound_method(block)
114114
)
115115
@endpoints = app.endpoints if app.respond_to?(:endpoints)
116+
# The live settings of the scope the route is declared in. The copy in
117+
# the State holds that scope's helpers as they were at this line; these
118+
# also hold the ones declared further down, read once the API compiles.
119+
@scope_setting = new_settings
116120
end
117121

118122
# Update our settings from a given parent settings instance. Used when
@@ -353,7 +357,7 @@ def compile!
353357
# sets, which fits an 80-byte slot until a route block or a helper sets one
354358
# of its own; carrying the route-building state as well, it took 320. The
355359
# endpoint itself counts too when there are no helpers, both being a
356-
# Grape::Endpoint, and it holds at most five.
360+
# Grape::Endpoint, and it holds at most six.
357361
#
358362
# Helpers reach an endpoint by module inclusion, and +Object#dup+ does not
359363
# carry a singleton class over — so including them on the per-request copy
@@ -517,8 +521,10 @@ def error_middleware_options(format, content_types)
517521
}
518522
end
519523

524+
# Every helper of the route's scope, wherever it was declared in it, and
525+
# of the scopes around it.
520526
def build_helpers
521-
helpers = inheritable_setting.helpers
527+
helpers = @scope_setting.helpers
522528
return if helpers.empty?
523529

524530
Module.new { helpers.each { |mod_to_include| include mod_to_include } }

‎spec/grape/api_spec.rb‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,6 +2418,67 @@ def three
24182418
end
24192419
expect { get '/howdy' }.not_to raise_error
24202420
end
2421+
2422+
context 'when declared after a route of the same scope' do
2423+
it 'is accessible from that route' do
2424+
subject.get '/howdy' do
2425+
hello
2426+
end
2427+
2428+
subject.helpers do
2429+
def hello
2430+
'Hello, world.'
2431+
end
2432+
end
2433+
2434+
get '/howdy'
2435+
expect(last_response.body).to eql 'Hello, world.'
2436+
end
2437+
2438+
it 'is accessible from that route in a namespace' do
2439+
subject.namespace :foo do
2440+
get :howdy do
2441+
hello
2442+
end
2443+
2444+
helpers do
2445+
def hello
2446+
'Hello, world.'
2447+
end
2448+
end
2449+
end
2450+
2451+
get '/foo/howdy'
2452+
expect(last_response.body).to eql 'Hello, world.'
2453+
end
2454+
end
2455+
2456+
it 'answers every route of its scope with the last definition of a helper' do
2457+
subject.helpers do
2458+
def greeting
2459+
'first'
2460+
end
2461+
end
2462+
2463+
subject.get '/before' do
2464+
greeting
2465+
end
2466+
2467+
subject.helpers do
2468+
def greeting
2469+
'second'
2470+
end
2471+
end
2472+
2473+
subject.get '/after' do
2474+
greeting
2475+
end
2476+
2477+
get '/before'
2478+
expect(last_response.body).to eql 'second'
2479+
get '/after'
2480+
expect(last_response.body).to eql 'second'
2481+
end
24212482
end
24222483

24232484
describe '.scope' do

0 commit comments

Comments
 (0)