Skip to content

Set api.version when the version is the only path segment#2826

Open
ericproulx wants to merge 2 commits into
masterfrom
fix-path-versioner-root-version
Open

Set api.version when the version is the only path segment#2826
ericproulx wants to merge 2 commits into
masterfrom
fix-path-versioner-root-version

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Problem

Grape::Middleware::Versioner::Path extracts the version from the first path segment — but only when a second slash follows it:

slash_position = path_info.index('/', 1) # omit the first one
return unless slash_position

For the root route of a path-versioned API there is no second segment, so the middleware returned early and never set env['api.version']:

class API < Grape::API
  version 'v1', using: :path
  get '/' do
    { version_seen: version }
  end
end

GET /v1200 with {"version_seen":null}. The route matches (the version lives in the compiled route pattern), but everything that reads env['api.version'] sees nothing: the version helper, Grape::DSL::Entity#entity_representation_for (entities lose their version: embed, breaking conditional exposures), and ErrorFormatter::Base#present. The same happens with a format suffix (GET /v1.json) and behind a prefix (GET /api/v1).

Only the versioner middlewares write api.version, so nothing downstream recovers it from the route's version capture either. The logic is identical at least as far back as v2.4.0 — long-standing, not a regression.

Fix

When the (prefix-stripped) 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 still work:

  • /v1v1
  • /v1.jsonv1 (suffix trimmed)
  • /v1.2.json with version 'v1.2'v1.2 (rightmost dot trimmed first)
  • /awesome → no version, no 404 — the segment is left for the router to resolve

The single-segment branch deliberately never raises version_not_found!: with no following segment there is nothing to disambiguate a version from a plain path, so an unknown segment falls through to the router's 404 exactly as before. Multi-segment behavior (/v3/foo → 404 with X-Cascade: pass for an undeclared version) is unchanged.

Tests

  • Middleware-level cases for all of the above in versioner/path_spec.rb.
  • A sets the API version for the root route example added to the shared versioning examples, so the root route is now covered end-to-end for all four strategies (:path — previously failing — plus :param, :header, :accept_version_header).

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix-path-versioner-root-version branch from 604d65c to cf8d5fe Compare July 26, 2026 19:35
@ericproulx
ericproulx requested a review from dblock July 26, 2026 19:36
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Comment thread spec/grape/middleware/versioner/path_spec.rb Outdated
@ericproulx
ericproulx force-pushed the fix-path-versioner-root-version branch 3 times, most recently from 5676a76 to a875aa3 Compare July 27, 2026 05:35
@ericproulx
ericproulx requested a review from dblock July 27, 2026 05:37
@ericproulx
ericproulx force-pushed the fix-path-versioner-root-version branch from a875aa3 to 106cd9d Compare July 27, 2026 05:38
ericproulx and others added 2 commits July 27, 2026 07:49
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix-path-versioner-root-version branch from 106cd9d to 031124d Compare July 27, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants