Skip to content

Read tags without reaching into route.options - #986

Open
ericproulx wants to merge 1 commit into
ruby-grape:masterfrom
ericproulx:fix/tags-without-route-options
Open

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

Conversation

@ericproulx

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 ? route.tags.presence : tag_object(route, path)

nil means "not specified" like everywhere else, and an empty Array is the explicit "no tags": [] is truthy, #presence turns it into nil, and method_object's existing delete_if { |_, value| value.nil? } drops the key.

declaration before after
unset derived tag derived tag
tags: %w[a b] ["a","b"] ["a","b"]
tags: [] "tags": [] key omitted
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 — the same thing omitting the key says, without the noise.

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 documented one in its place.

Backward compatibility

desc(..., tags: nil) stops suppressing the tag; tags: [] does it instead. Covered in UPGRADING, with the #523 archaeology recorded so the reasoning is not lost a third time. The README now documents tags: [] alongside the override form.

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 new sibling asserting tags: [] omits the key, 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

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 nil now means "not specified" here too, and an empty Array is the explicit
"no tags": [] is truthy, #presence turns it into nil, and method_object's
existing delete_if drops the key. 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>
@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

method[:security] = security_object(route)
method[:responses] = response_object(route, options)
method[:tags] = route.options.key?(:tags) ? route.tags : tag_object(route, path)
method[:tags] = route.tags ? route.tags.presence : tag_object(route, path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s the reason for using .presence here? If route.tags is absent, won’t the code go to the else branch anyway? 🤔

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