Skip to content

Read tags without reaching into route.options - #986

Merged
numbata merged 1 commit into
ruby-grape:masterfrom
ericproulx:fix/tags-without-route-options
Sep 1, 2026
Merged

Read tags without reaching into route.options#986
numbata merged 1 commit into
ruby-grape:masterfrom
ericproulx:fix/tags-without-route-options

Conversation

@ericproulx

@ericproulx ericproulx commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

route.options.key?(:tags) was the last read of a route's raw options Hash in lib/. grep -rn "route\.options" lib now returns nothing.

It was there to tell "no tags: given" from "tags: nil", because Grape's route.tags reader answers nil for both. Rather than ask Grape for a way to expose that — a presence predicate, or a reader that carries it in the value — this drops the distinction, because it turns out nobody ever chose it.

The tags: nil behaviour was an accident

#523 added route-level tags in 2016 to override path-derived grouping (a prefix 'locations/:id' was filing every endpoint under locations). It read them with:

method[:tags] = route.options.fetch(:tags, tag_object(route))

fetch(key, default) is just the idiomatic "use the option if given" — but its default-on-absence semantics silently gave nil a meaning of its own: suppress the tag entirely. Nothing chose that:

  • The README Allow specifying custom tags at the route level #523 added documents only tags: ['tag1', 'tag2'], and still does.
  • The spec it added covers only a real list.
  • No commit in ten years of history mentions tags: nil. The first is #983, which restored the behaviour on the assumption it was intended, after #984 had switched the line to route.tags || tag_object(route, path) a day earlier.

It is also inconsistent with every sibling option, where nil means not specified. Those only look like they check presence:

def deprecated_object(route)
  route.options[:deprecated] if route.options.key?(:deprecated)
end

That guard is decorative — a single expression, so it is exactly route.options[:deprecated]. Same for security_object. tags was the only option whose presence changed the outcome.

And it is the less useful reading: someone writing tags: nil is most likely writing tags: condition ? %w[a] : nil, and would expect the default grouping back rather than the key silently dropped.

The change

-method[:tags] = route.options.key?(:tags) ? route.tags : tag_object(route, path)
+method[:tags] = route.tags.presence || tag_object(route, path)

tags: is an override, and only a non-empty list overrides anything. #presence collapses nil and [] alike, so a blank declaration means "not specified" — exactly as it does for every other desc option — and falls back to the path-derived tag.

declaration before after
unset derived tag derived tag
tags: %w[a b] ["a","b"] ["a","b"]
tags: [] "tags": [] derived tag
tags: nil key omitted derived tag

The tags: [] row is a second small fix: an empty list is valid Swagger but documents nothing, and most tooling reads it as untagged anyway — so it now says what it looks like it says, "I did not set this", rather than emitting noise into the document.

Why this shape

Grape is moving toward routes exposing readers rather than a free-form bag (ruby-grape/grape#2857 removes BaseRoute's delegate_missing_to :@options, which made every route answer every name with nil). Anything grape-swagger can express through route.tags alone is one less thing Grape has to expose to keep this gem working. Here that costs an undocumented, untested and inconsistent edge case, and buys a rule that fits the rest of desc.

Backward compatibility

Neither desc(..., tags: nil) nor desc(..., tags: []) suppresses the tag any more; both derive it from the path. There is no longer a way to declare "this operation has no tags" — that was only ever reachable through the accident above. Covered in UPGRADING, with the #523 archaeology recorded so the reasoning is not lost a third time.

Test plan

  • Read route metadata via readers instead of route.options[] #983's example rewritten rather than deleted — tags: nil now asserts the derived tag — plus a sibling asserting the same for tags: [], so both halves of the contract are pinned.
  • Full RSpec suite passes locally (531 examples, 0 failures, 2 pending).
  • RuboCop clean (152 files).
  • grep -rn "route\.options" lib returns nothing.
  • CI green.

🤖 Generated with Claude Code

https://claude.ai/code/session_019aqLx595ggku1E2djNDdxW

@ericproulx
ericproulx force-pushed the fix/tags-without-route-options branch from 09adf99 to f36e064 Compare August 22, 2026 19:39
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

Comment thread lib/grape-swagger/endpoint.rb Outdated
route.options.key?(:tags) was the last read of a route's raw options Hash.
It was there to tell "no tags: given" from "tags: nil", because Grape's
route.tags reader answers nil for both.

That distinction turns out to be an accident. ruby-grape#523 added route-level tags to
override path-derived grouping and read them with

    route.options.fetch(:tags, tag_object(route))

whose default-on-absence semantics silently gave nil a meaning of its own:
suppress the tag. Nothing chose that. The README it added documents only
tags: ['tag1', 'tag2'], the spec it added covers only a real list, and no
commit in ten years of history mentions tags: nil — the first is ruby-grape#983, which
restored the behaviour on the assumption it was intended.

It is also inconsistent. For every other desc option nil means "not
specified": deprecated_object and security_object read route.options[:x] if
route.options.key?(:x), a single expression that is exactly route.options[:x]
— the guard is decorative. tags was the only option where presence changed
the outcome.

So a blank tags: now means "not specified" here too. tags: is an override,
and only a non-empty list overrides anything: #presence collapses nil and []
alike, and both fall back to the path-derived tag. That also stops an empty
list being documented as "tags": [], which is valid Swagger but says nothing.

Both cases are now readable from route.tags alone, so no reader on Grape's
side has to expose whether an option was set, and grape-swagger no longer
touches route.options at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aqLx595ggku1E2djNDdxW
@ericproulx
ericproulx force-pushed the fix/tags-without-route-options branch from f36e064 to 435b109 Compare August 23, 2026 10:04
@ericproulx

Copy link
Copy Markdown
Contributor Author

Describing an api contains static keys and I think having dedicated readers makes more sense than always passing by options. IMOO, options is more like user defined options rather than internals.

@numbata
numbata merged commit 5e971a2 into ruby-grape:master Sep 1, 2026
30 checks passed
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