Skip to content

Refactor DSL::Routing#version: guard clause, explicit kwargs, value object#2716

Open
ericproulx wants to merge 1 commit into
masterfrom
refactor/version-guard-clause
Open

Refactor DSL::Routing#version: guard clause, explicit kwargs, value object#2716
ericproulx wants to merge 1 commit into
masterfrom
refactor/version-guard-clause

Conversation

@ericproulx
Copy link
Copy Markdown
Contributor

@ericproulx ericproulx commented May 14, 2026

Summary

Three changes to Grape::DSL::Routing#version plus the call sites and middleware that consume its output:

1. Guard clause

if args.any? ... end wrapping a 20-line body → return @versions&.last if args.empty?.

2. Explicit kwargs in place of **options

def version(*args, using: :path, cascade: true, parameter: 'apiver', strict: false, vendor: nil, &block)

Defaults live in the signature instead of being applied later via reverse_merge + the versioner's deep-merged DEFAULT_OPTIONS. The per-call reverse_merge is gone.

3. Grape::DSL::VersionOptions value object, threaded end-to-end

New file lib/grape/dsl/version_options.rb:

VersionOptions = Data.define(:using, :cascade, :parameter, :strict, :vendor)
  • #version builds and stores a VersionOptions on inheritable_setting.namespace_inheritable[:version_options].
  • Path#uses_path_versioning?, Endpoint#build_stack, and API::Instance#cascade? read fields via accessors instead of [:key].
  • The versioner middleware (Versioner::Base) now takes the VersionOptions directly, not a Hash. Forwardable.def_delegators forwards cascade / parameter / strict / vendor to it; the four ivars and per-call assignments in #initialize are dropped. DEFAULT_OPTIONS stores VersionOptions.new(...) so direct-mount callers still get safe defaults.

Public .version DSL surface is unchanged — still accepts kwargs the same way; the value object is an internal-only representation.

version_options is read for exactly these five keys across the codebase, and nothing else:

Key Read at
:using endpoint.rb, path.rb
:cascade Versioner::Base, API::Instance#cascade?
:parameter Versioner::Base
:strict Versioner::Base
:vendor Versioner::Base

Any other kwarg passed to .version was previously swallowed by **options with no effect — now ArgumentError.

Spec fixtures updated

  • spec/shared/versioning_examples.rb — 14 sites changed **macro_options**macro_options.except(:format). The :format key stays in the macro hash so versioned_helpers.rb can still build the HTTP_ACCEPT header.
  • spec/grape/exceptions/invalid_accept_header_spec.rb — dropped format: :json from 8 direct .version calls.
  • spec/grape/dsl/routing_spec.rb:version_options assertion now expects a VersionOptions instance.
  • spec/grape/path_spec.rb — literal hashes → VersionOptions.new(...).
  • spec/grape/middleware/versioner/{header,accept_version_header,param}_spec.rb — fixtures construct VersionOptions instances; mid-test @options[:version_options][:strict] = X mutations rewritten as @options[:version_options] = @options[:version_options].with(strict: X) (Data instances are immutable).

Why format: was in the specs (and why dropping it from .version is correct)

Several specs passed format: :json (or format: 'json' in macro_options) directly to .version. This was test-fixture double-duty, never a real .version feature:

  • spec/support/versioned_helpers.rb's versioned_headers builds the request HTTP_ACCEPT header from the same hash: for header versioning it produces application/vnd.#{vendor}-#{version}+#{format}. So :format exists for the test helper to construct the request's Accept header.
  • The specs reused one options hash for two unrelated jobs — configuring the API (subject.version 'v1', **macro_options) and driving the request (versioned_get '/x', 'v1', macro_options) — so :format rode into .version purely as a side effect of the splat.

.version never had a format: option. Its value lands in namespace_inheritable[:version_options], and the versioner middleware only ever reads :cascade / :parameter / :strict / :vendor from it — nothing reads version_options[:format]. For header versioning the response format is parsed out of the request Accept header (media_type.formatenv['api.format']); the API's serialization format is set independently by format / default_format. So under the old **options splat, format: on .version was silently swallowed and inert.

The fix preserves intent exactly:

  • spec/shared/versioning_examples.rb.version is called with **macro_options.except(:format) (drops the inert kwarg), but versioned_get still receives the full macro_options, so the request Accept header is still application/vnd.mycompany-v1+json — byte-identical to before. The header-versioning + format-negotiation path is genuinely still exercised.
  • spec/grape/exceptions/invalid_accept_header_spec.rbformat: :json dropped from direct .version calls; those tests build their Accept header inline, so it was equally dead there.

Verified: header-versioning shared examples 12/0; invalid_accept_header_spec.rb + all spec/grape/middleware/versioner specs 110/0. Same requests sent, same behaviour — only a silent no-op kwarg on .version is now (correctly) an explicit ArgumentError.

Behaviour change

No production behaviour change beyond .version raising ArgumentError for genuinely unknown kwargs (previously silently ignored — see the format: note above for the canonical example).

Documented in UPGRADING.md under ### Upgrading to >= 3.3 → "version now takes explicit keyword arguments", with the recognised-key list and the format: before/after.

Test plan

  • Full suite: bundle exec rspec — 2292 examples, 0 failures
  • RuboCop clean on touched files
  • CI green across Gemfile variants

🤖 Generated with Claude Code

@ericproulx ericproulx marked this pull request as draft May 14, 2026 13:04
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 14, 2026

Danger Report

No issues found.

View run

@ericproulx ericproulx force-pushed the refactor/version-guard-clause branch 2 times, most recently from cd36ee1 to bfe3768 Compare May 14, 2026 13:29
@ericproulx ericproulx changed the title Lift DSL::Routing#version body into a guard clause Refactor DSL::Routing#version: guard clause + explicit kwargs May 14, 2026
@ericproulx ericproulx force-pushed the refactor/version-guard-clause branch 2 times, most recently from d10eb26 to 5ac2bf5 Compare May 14, 2026 13:38
@ericproulx ericproulx changed the title Refactor DSL::Routing#version: guard clause + explicit kwargs Refactor DSL::Routing#version: guard clause, explicit kwargs, value object May 14, 2026
@ericproulx ericproulx force-pushed the refactor/version-guard-clause branch 5 times, most recently from 5c4f190 to 9b20ad0 Compare May 14, 2026 14:12
@ericproulx ericproulx marked this pull request as ready for review May 14, 2026 17:27
@ericproulx ericproulx requested a review from dblock May 14, 2026 17:27
Three changes that touch the same method:

1. Lift the body out of `if args.any? ... end` into a
   `return @versions&.last if args.empty?` guard.

2. Replace `**options` with explicit kwargs (`using:`, `cascade:`,
   `parameter:`, `strict:`, `vendor:`) and bake the defaults into the
   signature. The per-call `reverse_merge(using: :path)` is gone.

3. Introduce `Grape::DSL::VersionOptions` (built on `Data.define`)
   as the canonical representation of resolved version options.
   Threaded end-to-end:
   - `#version` builds and stores a `VersionOptions` on
     `inheritable_setting.namespace_inheritable[:version_options]`.
   - `Path#uses_path_versioning?`, `Endpoint#build_stack`, and
     `API::Instance#cascade?` read fields via accessors instead of
     `[:key]`.
   - The versioner middleware (`Versioner::Base`) now takes a
     `VersionOptions` directly, not a Hash. `Forwardable.def_delegators`
     forwards `cascade`/`parameter`/`strict`/`vendor` to it; the four
     ivars and per-call assignments are dropped. `DEFAULT_OPTIONS`
     stores a `VersionOptions.new(...)` so direct-mount callers still
     get safe defaults.

Public `.version` DSL surface unchanged — still accepts kwargs the same
way; the value object is an internal-only representation.

`version_options` is read for exactly these five keys across the
codebase: `:using` (`endpoint.rb`, `path.rb`), and `:cascade` /
`:parameter` / `:strict` / `:vendor` (`Versioner::Base`, plus
`:cascade` in `API::Instance#cascade?`). Any other kwarg passed to
`.version` was previously swallowed by the splat with no effect.

Spec fixtures updated:

- `spec/shared/versioning_examples.rb` (14 sites): `**macro_options`
  -> `**macro_options.except(:format)`. The `:format` key stays in
  the macro hash so the test helper (`versioned_helpers.rb`) can still
  build the `HTTP_ACCEPT` header.
- `spec/grape/exceptions/invalid_accept_header_spec.rb` (8 sites):
  dropped `format: :json` from direct `.version` calls.
- `spec/grape/dsl/routing_spec.rb`: assertion now expects a
  `VersionOptions` instance with the full default set.
- `spec/grape/path_spec.rb`: literal `version_options: { using: :path }`
  hashes replaced with `VersionOptions.new(...)` constructors.
- `spec/grape/middleware/versioner/{header,accept_version_header,param}_spec.rb`:
  fixtures construct `VersionOptions` instances; mid-test
  `@options[:version_options][:strict] = X` assignments rewritten as
  `@options[:version_options] = @options[:version_options].with(strict: X)`
  (Data instances are immutable).

No production behaviour change beyond `.version` raising
`ArgumentError` for genuinely unknown kwargs (previously silently
ignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ericproulx ericproulx force-pushed the refactor/version-guard-clause branch from ea89be3 to 526f59f Compare May 15, 2026 10:58
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.

1 participant