From 0ab7ab5fe665565b0f08b7d8836db8e91f2699ce Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sun, 26 Jul 2026 21:35:13 +0200 Subject: [PATCH 1/2] Set api.version when the version is the only path segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path versioner only extracted the version when a second slash followed it (path_info.index('/', 1)), so the root route of a path-versioned API (GET /v1, or /v1.json with a format suffix) matched and ran without env['api.version'] — the `version` helper returned nil and entity `version:` embeds were omitted. When the path is a single segment, record the version on an exact match against the declared versions, trimming a trailing .format suffix (right-to-left, so dotted versions like v1.2 still match). An unmatched segment is left for the router to resolve — never a 404 from here. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + lib/grape/middleware/versioner/path.rb | 23 +++++++++++++++- spec/grape/middleware/versioner/path_spec.rb | 29 ++++++++++++++++++++ spec/shared/versioning_examples.rb | 10 +++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb3e1473..6c29c80f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ * [#2825](https://github.com/ruby-grape/grape/pull/2825): Stop `present` entity autodetection from picking up a top-level `::Entity` constant - [@ericproulx](https://github.com/ericproulx). * [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx). * [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx). +* [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.4 (2026-07-25) diff --git a/lib/grape/middleware/versioner/path.rb b/lib/grape/middleware/versioner/path.rb index 2129a6b31..c310c2dfc 100644 --- a/lib/grape/middleware/versioner/path.rb +++ b/lib/grape/middleware/versioner/path.rb @@ -31,14 +31,35 @@ def before end slash_position = path_info.index('/', 1) # omit the first one - return unless slash_position + return version_from_first_segment(path_info, slash_position) if slash_position + version_from_only_segment(path_info) + end + + private + + def version_from_first_segment(path_info, slash_position) potential_version = path_info[1..(slash_position - 1)] return unless potential_version.match?(pattern) version_not_found! unless potential_version_match?(potential_version) env[Grape::Env::API_VERSION] = potential_version end + + # The path is a single segment (e.g. `GET /v1` — the root route of a + # path-versioned API). Nothing follows to disambiguate a version from a + # plain path or from a `.format` suffix (`/v1.json`), so the version is + # only recorded on an exact match against the declared versions, and an + # unmatched segment is left for the router to resolve — never a 404. + def version_from_only_segment(path_info) + candidate = path_info[1..] + candidate = candidate[0...candidate.rindex('.')] while candidate.include?('.') && !declared_version?(candidate) + env[Grape::Env::API_VERSION] = candidate if declared_version?(candidate) + end + + def declared_version?(candidate) + versions.present? && versions.include?(candidate) + end end end end diff --git a/spec/grape/middleware/versioner/path_spec.rb b/spec/grape/middleware/versioner/path_spec.rb index c91d316a8..ee0cefa3b 100644 --- a/spec/grape/middleware/versioner/path_spec.rb +++ b/spec/grape/middleware/versioner/path_spec.rb @@ -59,4 +59,33 @@ expect(subject.call(Rack::PATH_INFO => '/mounted/v1/foo').last).to eq('v1') end end + + # Regression: the version was only extracted when a second slash followed it, + # so the root route of a path-versioned API (`GET /v1`) ran without + # env['api.version'] being set. + context 'when the version is the only path segment' do + let(:options) { { versions: %w[v1] } } + + it 'sets the version' do + expect(subject.call(Rack::PATH_INFO => '/v1').last).to eq('v1') + end + + it 'sets the version when a format suffix follows' do + expect(subject.call(Rack::PATH_INFO => '/v1.json').last).to eq('v1') + end + + it 'sets a dotted version when a format suffix follows' do + options[:versions] = %w[v1.2] + expect(subject.call(Rack::PATH_INFO => '/v1.2.json').last).to eq('v1.2') + end + + it 'sets the version behind a prefix' do + options[:prefix] = '/api' + expect(subject.call(Rack::PATH_INFO => '/api/v1').last).to eq('v1') + end + + it 'leaves an unknown segment for the router instead of failing with 404' do + expect(subject.call(Rack::PATH_INFO => '/awesome').last).to be_nil + end + end end diff --git a/spec/shared/versioning_examples.rb b/spec/shared/versioning_examples.rb index 8d794f4e2..ef7a8f227 100644 --- a/spec/shared/versioning_examples.rb +++ b/spec/shared/versioning_examples.rb @@ -11,6 +11,16 @@ expect(last_response.body).to eql 'Version: v1' end + it 'sets the API version for the root route' do + subject.format :txt + subject.version 'v1', **macro_options.except(:format) + subject.get do + "Version: #{request.env[Grape::Env::API_VERSION]}" + end + versioned_get '/', 'v1', macro_options + expect(last_response.body).to eql 'Version: v1' + end + it 'adds the prefix before the API version' do subject.format :txt subject.prefix 'api' From 031124dfcd0333c4ffab6d60b8dd329fb4e00a51 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Mon, 27 Jul 2026 07:34:50 +0200 Subject: [PATCH 2/2] Clarify dotted version and prefix contexts with nested RSpec contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the dotted version and prefix test cases into separate nested contexts to make it explicit that they test different scenarios (different options overrides). This answers the question: why do we need to override options[:versions] or options[:prefix] mid-context? Answer: we don't — each scenario gets its own context with its own let(:options). Co-Authored-By: Claude Haiku 4.5 --- spec/grape/middleware/versioner/path_spec.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/spec/grape/middleware/versioner/path_spec.rb b/spec/grape/middleware/versioner/path_spec.rb index ee0cefa3b..2a3cc3415 100644 --- a/spec/grape/middleware/versioner/path_spec.rb +++ b/spec/grape/middleware/versioner/path_spec.rb @@ -74,14 +74,20 @@ expect(subject.call(Rack::PATH_INFO => '/v1.json').last).to eq('v1') end - it 'sets a dotted version when a format suffix follows' do - options[:versions] = %w[v1.2] - expect(subject.call(Rack::PATH_INFO => '/v1.2.json').last).to eq('v1.2') + context 'with a dotted version' do + let(:options) { { versions: %w[v1.2] } } + + it 'sets the version when a format suffix follows' do + expect(subject.call(Rack::PATH_INFO => '/v1.2.json').last).to eq('v1.2') + end end - it 'sets the version behind a prefix' do - options[:prefix] = '/api' - expect(subject.call(Rack::PATH_INFO => '/api/v1').last).to eq('v1') + context 'behind a prefix' do + let(:options) { { versions: %w[v1], prefix: '/api' } } + + it 'sets the version' do + expect(subject.call(Rack::PATH_INFO => '/api/v1').last).to eq('v1') + end end it 'leaves an unknown segment for the router instead of failing with 404' do