Stop Grape::Endpoint::Options from appending to the caller's path Array - #2907
Merged
Conversation
ericproulx
force-pushed
the
endpoint-options-path-aliasing
branch
from
September 5, 2026 17:59
c1c8304 to
502ac38
Compare
Danger ReportNo issues found. |
ericproulx
marked this pull request as ready for review
September 5, 2026 18:05
ericproulx
force-pushed
the
endpoint-options-path-aliasing
branch
from
September 5, 2026 18:11
502ac38 to
289c44f
Compare
ericproulx
added a commit
that referenced
this pull request
Sep 5, 2026
A parameter reassigned partway through a method means the name documents one thing in the signature and holds another below it. Every site in `lib` now binds the derived value to its own local, so a parameter keeps what the caller passed for the whole method. Two of them were reassigned to hide a mutation of the argument itself, and those stop writing into what they were given: * `Middleware::Formatter#ensure_content_type` merged a Content-Type into the response headers Hash in place, then returned it. * `ParamsScope#process_oneof!` wrote the collected variants back into the options Hash from the `requires`/`optional` call site. Now `#collected_oneof` returns them and the caller merges. `Endpoint::Options` is left alone; it is fixed in #2907. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
added a commit
that referenced
this pull request
Sep 6, 2026
A parameter reassigned partway through a method means the name documents one thing in the signature and holds another below it. Every site in `lib` now binds the derived value to its own local, so a parameter keeps what the caller passed for the whole method. Two of them were reassigned to hide a mutation of the argument itself, and those stop writing into what they were given: * `Middleware::Formatter#ensure_content_type` merged a Content-Type into the response headers Hash in place, then returned it. * `ParamsScope#process_oneof!` wrote the collected variants back into the options Hash from the `requires`/`optional` call site. Now `#collected_oneof` returns them and the caller merges. `Endpoint::Options` is left alone; it is fixed in #2907. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
added a commit
that referenced
this pull request
Sep 6, 2026
A parameter reassigned partway through a method means the name documents one thing in the signature and holds another below it. Every site in `lib` now binds the derived value to its own local, so a parameter keeps what the caller passed for the whole method. Two of them were reassigned to hide a mutation of the argument itself, and those stop writing into what they were given: * `Middleware::Formatter#ensure_content_type` merged a Content-Type into the response headers Hash in place, then returned it. * `ParamsScope#process_oneof!` wrote the collected variants back into the options Hash from the `requires`/`optional` call site. Now `#collected_oneof` returns them and the caller merges. `Endpoint::Options` is left alone; it is fixed in #2907. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`Array(ary)` hands back the very Array it was given, so defaulting an
empty path by appending grew the Array the caller passed in.
A route declared inside a `namespace`, `resource`, `group` or
`route_param` block reaches `Grape::Endpoint` directly, so this is
visible from the public DSL — and a frozen Array raises at boot:
PATHS = [].freeze
class API < Grape::API
namespace :v1 do
get(PATHS) { 'ok' } # FrozenError: can't modify frozen Array: []
end
end
A top-level route is shielded, but only incidentally: `Grape::API`
replays recorded setup steps through `evaluate_arguments`, which rebuilds
Array arguments with `map`, so the append lands on that copy instead.
Nested scopes run their block against the instance and skip it.
Build a new Array instead. Calling `super` with explicit keywords rather
than zsuper is what removes the need to assign back over the parameter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
endpoint-options-path-aliasing
branch
from
September 6, 2026 12:15
289c44f to
d802fd0
Compare
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.
Grape::Endpoint::Options#initializenormalizespathinto an Array and defaults an empty one to['/']— by appending:Array()is not a copy. Given an Array it returns the very object it was handed:so
path << '/'grows the Array the caller passed in.What a user sees
A route declared inside a
namespace,resource,grouporroute_paramblock reachesGrape::Endpointdirectly, so an empty Array of paths is mutated in place:and a frozen one — which is what
Style/MutableConstantasks for, and what an Array built from configuration is likely to be — takes the class definition down at boot, not on the first request:namespace,resource,groupandroute_paramall reproduce it.Only the empty-Array case is affected. A String path (
get '/users') goes throughArray('/users'), which builds a fresh Array, and a non-empty Array never reaches the<<.Why a top-level route does not reproduce it
get(PATHS)written directly in the class body is fine, and that is the only reason this has gone unnoticed. It is not a guard, though —Grape::APIrecords DSL calls and replays them onto its instance, andreplay_step_onrebuilds the argument list on the way through:evaluate_argumentsrecurses into an Array argument withevaluate_arguments(configuration, *argument), whose body is amap— so the endpoint receives a copy and the append lands on that copy. That machinery exists to resolveGrape::Util::Lazy::Basearguments when an API is re-mounted; shielding this is a side effect of it.A nested scope's block is executed against the instance rather than being replayed argument-by-argument, so it never passes through
evaluate_argumentsand the endpoint gets the caller's Array. Same forGrape::API::Instancesubclasses and directGrape::Endpoint/Grape::Endpoint::Optionsconstruction.The fix
Two things worth calling out:
paths.presence || ['/']allocates the default only when it is needed, and never touches what the caller holds.super(...)rather than zsuper. The mutate-in-place shape existed because of the baresuper: it forwards whatever the parameters currently hold, so normalizing an input meant assigning back over the parameter, and once you are assigning overpathanyway,<<looks like the natural way to add the default. Naming the keywords at the call tosuperremoves that pressure — the normalization is an expression now, and neitherpathnorhttp_methodsis reassigned.Scope
The non-empty case still hands the caller's Array straight to the
Dataobject, so a caller who mutates it afterwards would still be observed by the endpoint. That is unchanged behavior and the harmless direction — this PR closes the direction where Grape writes into the caller's argument. Copying every path Array on the way in is a separate call if it is wanted.Tests
spec/grape/endpoint/options_spec.rbis new:pathnormalization (String, non-empty Array, empty Array, frozen empty Array),http_methodsnormalization, and the namespace-nested definition through the public DSL, including the frozen case. Reverting the change fails four of them:Full suite green (2714 examples), rubocop clean.
No UPGRADING entry: nothing that worked before stops working, and the previous behavior was not something an API could have depended on deliberately.
🤖 Generated with Claude Code