Skip to content

Raise ArgumentError on a version that is not a String, Symbol or Integer - #3060

Merged
ericproulx merged 1 commit into
masterfrom
fix/version-positional-options
Nov 9, 2026
Merged

ericproulx merged 1 commit into
masterfrom
fix/version-positional-options

Conversation

@ericproulx

@ericproulx ericproulx commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Since #2618 (3.0.0) replaced extract_options! with keyword arguments, a trailing braced options Hash passed to version lands in the splat. Ruby 3 only turns a brace-less trailing Hash into keywords:

class API < Grape::API
  version 'v1', { using: :header, vendor: 'acme' }
  get('x') { 'ok' }
end

API.version                                 # => "{using: :header, vendor: \"acme\"}"
API.routes.map(&:path)                      # => ["/:version/x(.:format)"]
GET /x  Accept: application/vnd.acme-v1     # => 404
GET /v1/x                                   # => 200

The Hash became a version name, and the API silently stayed on the default :path strategy, with no warning. I found it while triaging #1801: its spec passes version_options this way, and on Ruby 3 every non-path context failed for this reason alone.

The Hash wasn't the only case. Each version was passed through to_s, whatever its type:

version … registered outcome
1 "1" /1/foo → 200 (works)
1.10 "1.1" /1.10/foo → 404
nil, '', :'' "" every path → 404
true "true" /true/foo → 200
Object.new "#<Object:0x…>" every path → 404
[] nothing every path → 404
['v1', { using: :header }] "v1", "{using: :header}" passed the Hash check, which ran before flatten

Fix

version flattens its arguments first and then checks every version. A Hash keeps its own message, since the options are what the caller meant:

ArgumentError: `version` takes its options as keyword arguments, not as a positional Hash

Anything else that isn't a non-empty String, Symbol or Integer raises:

ArgumentError: `version` takes non-empty String, Symbol or Integer versions, not `1.1`

An empty list raises `version` needs at least one version.

Integer is accepted because it round-trips exactly through the path, header and param strategies (version 2 serves /2/...). A code search found no numeric versions in about 200 Grape APIs, but they work today, and rejecting them would break working code for nothing. Float is rejected because it doesn't round-trip: version '1.10' is the fix.

This is how auth, http_basic and desc have handled a positional Hash since 4.0.0. I first tried redispatching the Hash as keyword arguments behind a deprecation warning, as #2853 did for requires, optional and use. That would quietly move an API that has served /v1/... since 3.0 onto header versioning in a minor release. Raising stops it at boot and points at the line instead.

The README now names the accepted types. UPGRADING (4.2.0) describes the break and how to keep path versioning when clients have been using /v1/...: version 'v1', or using: :path.

Tests

New examples under Grape::API .version:

  • positional Hash: given with a version, with a block, alone, or inside an Array of versions. Each raises, and no version or route is registered.
  • accepted types: String, Symbol and Integer versions, nested Arrays included, are all registered, and an Integer version is served at /2/hello.
  • rejected types: nil, true, a Float, an empty String, an empty Symbol and an arbitrary object each raise, both as an argument and nested in an Array.
  • empty list: version [[]] raises.

Each rule was mutation-checked by reverting it: flatten-then-validate (9 failures), Float rejected (2), empty name rejected (4), empty list rejected (1), Hash message (4). bundle exec rake: 379 files with no offenses, 3203 examples, 0 failures.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix/version-positional-options branch from 6490de5 to fc2c719 Compare November 6, 2026 23:09
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx force-pushed the fix/version-positional-options branch from fc2c719 to 7e76ca7 Compare November 6, 2026 23:16
@ericproulx ericproulx changed the title Deprecate the positional options Hash in version Raise ArgumentError on a positional options Hash in version Nov 6, 2026
@dblock

dblock commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

I’m an AI posting on behalf of @dblock.

Should we define which types are valid version names rather than only rejecting Hash? nil, booleans, and arbitrary objects still get stringified. String and Symbol seem natural, but we should check whether numeric versions are supported existing usage. Could we validate against an explicit set of supported types, with tests for those cases?

Separately, the operations are currently out of order: validation happens before flattening, so a Hash nested in an array bypasses the check, e.g. version ["v1", { using: :header }]. Flatten first, then validate the resulting arguments, with a test covering this case.

@ericproulx
ericproulx force-pushed the fix/version-positional-options branch from 7e76ca7 to e07080d Compare November 7, 2026 15:49
@ericproulx ericproulx changed the title Raise ArgumentError on a positional options Hash in version Raise ArgumentError on a version that is not a String, Symbol or Integer Nov 7, 2026
@ericproulx
ericproulx force-pushed the fix/version-positional-options branch 2 times, most recently from 79ee4ab to 83a5a29 Compare November 8, 2026 20:15
Since #2618 replaced extract_options! with keyword arguments (3.0.0), a
trailing braced options Hash such as

    version 'v1', { using: :header, vendor: 'acme' }

lands in the splat. Grape registered a version literally named
`{using: :header, vendor: "acme"}` and kept the API on the default :path
strategy, with no warning.

Raise ArgumentError instead, as auth, http_basic and desc have done
since 4.0.0. Redispatching the Hash as keywords, as #2853 did for
requires, optional and use, would quietly move an API that has been
serving /v1/... since 3.0 onto header versioning in a minor release.
Raising stops it at boot and points at the line.

Every other version was stringified too, so check them all against an
explicit set: a non-empty String, Symbol or Integer. A Float drops its
trailing zeros (1.10 registered "1.1"), and nil, an empty name or any
other object registered a version no request could reach. The
arguments are flattened before the check, so a Hash or nil inside an
Array of versions raises as well, as does an empty Array.

UPGRADING notes the break and how to keep path versioning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/version-positional-options branch from 83a5a29 to d610d5e Compare November 9, 2026 09:09
@ericproulx
ericproulx merged commit 842c137 into master Nov 9, 2026
51 checks passed
@ericproulx
ericproulx deleted the fix/version-positional-options branch November 9, 2026 09:11
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