Skip to content

Commit 26906e7

Browse files
ericproulxclaude
andcommitted
Answer a route opening with an optional segment without it under a namespace
A route's path is assembled by joining its mount path, prefix, version, namespace and own path with slashes. A path opening with an optional group, such as get '(:type)' inside namespace :root, compiled to /root/(:type): the slash outside the group was required, and a request path is normalized without a trailing slash, so /root answered 404. The same went for a mount path, a prefix, a path version, and a slash written in front of the group, as in get 'users/(:id)'. The slash in front of an optional group now goes inside it, as /root(/:type), the spelling that already worked when written out in full. A slash opening the path stays where it is, so / still matches /(:type). Fixes #932. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d8329a3 commit 26906e7

5 files changed

Lines changed: 122 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
* [#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).
2020
* [#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).
2121
* [#3064](https://github.com/ruby-grape/grape/pull/3064): Cascade past a mounted Rack app that declines with a lowercase `x-cascade` - [@ericproulx](https://github.com/ericproulx).
22+
* [#XXXX](https://github.com/ruby-grape/grape/pull/XXXX): Answer a route opening with an optional segment without it under a namespace, prefix or mount path - [@ericproulx](https://github.com/ericproulx).
2223
* Your contribution here.
2324

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

‎UPGRADING.md‎

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

44
### Upgrading to >= 4.2.0
55

6+
#### A path opening with an optional segment answers without it under a namespace
7+
8+
A route path opening with an optional segment, such as `get '(:type)'` inside `namespace :root`, now also answers `/root` ([#XXXX](https://github.com/ruby-grape/grape/pull/XXXX)). Before, the slash Grape put between the namespace and the path was required, and a request path is never matched with a trailing slash, so `/root` answered `404`. The same goes for a mount path, a prefix, a path version, and a slash written in front of the segment, as in `get 'users/(:id)'`.
9+
10+
* The route's `path` and `origin` move the slash into the segment: `/root(/:type)` instead of `/root/(:type)`. Anything reading them, such as API documentation, sees the new spelling.
11+
* A route declared after it for the path itself, such as `get` in the same namespace, no longer answers that path, as the earlier route now matches it first. Declare that route first, or drop it.
12+
613
#### A request every route declines answers 404, however many routes declined
714

815
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/router/pattern/path.rb‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,22 @@ def valid_part?(part)
6464
part&.match?(/\A\S/) && not_slash?(part)
6565
end
6666

67+
# The parts are joined by slashes, so a path opening with an optional
68+
# group under a namespace, mount path, prefix or version --
69+
# +get '(:type)'+ in +namespace :root+ -- compiled to +/root/(:type)+.
70+
# The slash outside the group is required, and a request path never
71+
# ends in one, as it is normalized the way this one is: +/root+ matched
72+
# nothing. Inside the group, the slash goes with the segment it
73+
# separates, and +/root(/:type)+ answers +/root+ and +/root/x+ alike.
74+
# A slash opening the path stays where it is, for +/+ to match
75+
# +/(:type)+.
6776
class PartsCache < Grape::Util::Cache
77+
SLASH_BEFORE_OPTIONAL_GROUP = %r{(?<=.)/\(/?}
78+
6879
def initialize
6980
super
7081
@cache = Hash.new do |h, parts|
71-
h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
82+
h[parts] = Grape::Util::PathNormalizer.call(parts.join('/')).gsub(SLASH_BEFORE_OPTIONAL_GROUP, '(/')
7283
end
7384
end
7485
end

‎spec/grape/api/optional_parameters_in_route_spec.rb‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,101 @@ def app
4040
expect(last_response.body).to eq 'foo'
4141
end
4242
end
43+
44+
describe 'a path opening with an optional segment' do
45+
%w[(:type) /(:type) (/:type)].each do |spelling|
46+
context "when spelled #{spelling} in a namespace" do
47+
subject do
48+
Class.new(Grape::API) do
49+
namespace(:root) { get(spelling) { "type=#{params[:type]}" } }
50+
end
51+
end
52+
53+
it 'answers the namespace path, with or without the segment' do
54+
get '/root'
55+
expect(last_response.body).to eq('type=')
56+
57+
get '/root/x'
58+
expect(last_response.body).to eq('type=x')
59+
end
60+
end
61+
end
62+
63+
context 'when it is a nested namespace' do
64+
subject do
65+
Class.new(Grape::API) do
66+
namespace(:root) { namespace('(:type)') { get { "type=#{params[:type]}" } } }
67+
end
68+
end
69+
70+
it 'answers the outer namespace path, with or without the segment' do
71+
get '/root'
72+
expect(last_response.body).to eq('type=')
73+
74+
get '/root/x'
75+
expect(last_response.body).to eq('type=x')
76+
end
77+
end
78+
79+
context 'when it follows a prefix and a path version' do
80+
subject do
81+
Class.new(Grape::API) do
82+
prefix :api
83+
version 'v1', using: :path
84+
get('(:type)') { "type=#{params[:type]}" }
85+
end
86+
end
87+
88+
it 'answers the version path, with or without the segment' do
89+
get '/api/v1'
90+
expect(last_response.body).to eq('type=')
91+
92+
get '/api/v1/x'
93+
expect(last_response.body).to eq('type=x')
94+
end
95+
end
96+
97+
context 'when it follows a mount path' do
98+
subject do
99+
things = Class.new(Grape::API) { get('(:type)') { "type=#{params[:type]}" } }
100+
Class.new(Grape::API) { mount things => '/things' }
101+
end
102+
103+
it 'answers the mount path, with or without the segment' do
104+
get '/things'
105+
expect(last_response.body).to eq('type=')
106+
107+
get '/things/x'
108+
expect(last_response.body).to eq('type=x')
109+
end
110+
end
111+
112+
context 'when it follows a slash written in the same path' do
113+
subject do
114+
Class.new(Grape::API) { get('users/(:id)') { "id=#{params[:id]}" } }
115+
end
116+
117+
it 'answers the path before it, with or without the segment' do
118+
get '/users'
119+
expect(last_response.body).to eq('id=')
120+
121+
get '/users/5'
122+
expect(last_response.body).to eq('id=5')
123+
end
124+
end
125+
126+
context 'when it opens the path of the API' do
127+
subject do
128+
Class.new(Grape::API) { get('/(:type)') { "type=#{params[:type]}" } }
129+
end
130+
131+
it 'answers the root, with or without the segment' do
132+
get '/'
133+
expect(last_response.body).to eq('type=')
134+
135+
get '/x'
136+
expect(last_response.body).to eq('type=x')
137+
end
138+
end
139+
end
43140
end

‎spec/grape/router/pattern/path_spec.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def path_settings(**attrs)
5656

5757
expect(path.origin).to eql('/foo/hello/namespace/raw_path')
5858
end
59+
60+
it 'moves the slash in front of an optional group into it' do
61+
path = described_class.new('(:type)', 'namespace', path_settings)
62+
expect(path.origin).to eql('/namespace(/:type)')
63+
end
5964
end
6065

6166
describe '#suffix' do

0 commit comments

Comments
 (0)