Read tags without reaching into route.options - #986
Open
ericproulx wants to merge 1 commit into
Open
Conversation
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
force-pushed
the
fix/tags-without-route-options
branch
from
August 22, 2026 19:39
09adf99 to
f36e064
Compare
Danger ReportNo issues found. |
numbata
reviewed
Aug 22, 2026
| 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) |
Contributor
There was a problem hiding this comment.
What’s the reason for using .presence here? If route.tags is absent, won’t the code go to the else branch anyway? 🤔
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
route.options.key?(:tags)was the last read of a route's raw options Hash inlib/.grep -rn "route\.options" libnow returns nothing.It was there to tell "no
tags:given" from "tags: nil", because Grape'sroute.tagsreader answersnilfor 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: nilbehaviour was an accident#523 added route-level tags in 2016 to override path-derived grouping (a
prefix 'locations/:id'was filing every endpoint underlocations). It read them with:fetch(key, default)is just the idiomatic "use the option if given" — but its default-on-absence semantics silently gavenila meaning of its own: suppress the tag entirely. Nothing chose that:tags: ['tag1', 'tag2'], and still does.tags: nil. The first is #983, which restored the behaviour on the assumption it was intended, after #984 had switched the line toroute.tags || tag_object(route, path)a day earlier.It is also inconsistent with every sibling option, where
nilmeans not specified. Those only look like they check presence:That guard is decorative — a single expression, so it is exactly
route.options[:deprecated]. Same forsecurity_object.tagswas the only option whose presence changed the outcome.And it is the less useful reading: someone writing
tags: nilis most likely writingtags: condition ? %w[a] : nil, and would expect the default grouping back rather than the key silently dropped.The change
nilmeans "not specified" like everywhere else, and an empty Array is the explicit "no tags":[]is truthy,#presenceturns it intonil, andmethod_object's existingdelete_if { |_, value| value.nil? }drops the key.tags: %w[a b]["a","b"]["a","b"]tags: []"tags": []tags: nilThe
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'sdelegate_missing_to :@options, which made every route answer every name withnil). Anything grape-swagger can express throughroute.tagsalone 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 documentstags: []alongside the override form.Test plan
tags: nilnow asserts the derived tag — plus a new sibling assertingtags: []omits the key, so both halves of the contract are pinned.grep -rn "route\.options" libreturns nothing.🤖 Generated with Claude Code