Skip to content

Stop Grape::Endpoint::Options from appending to the caller's path Array - #2907

Merged
ericproulx merged 1 commit into
masterfrom
endpoint-options-path-aliasing
Sep 6, 2026
Merged

Stop Grape::Endpoint::Options from appending to the caller's path Array#2907
ericproulx merged 1 commit into
masterfrom
endpoint-options-path-aliasing

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Grape::Endpoint::Options#initialize normalizes path into an Array and defaults an empty one to ['/'] — by appending:

Options = Data.define(:path, :http_methods, :api, :route_options, :app, :params, :requirements, :anchor) do
  def initialize(path:, http_methods:, api:, route_options: {}, app: nil, params: {}, requirements: nil, anchor: true)
    path = Array(path)
    path << '/' if path.empty?
    http_methods = Array(http_methods)
    super
  end
end

Array() is not a copy. Given an Array it returns the very object it was handed:

a = []
Array(a).equal?(a) # => true

so path << '/' grows the Array the caller passed in.

What a user sees

A route declared inside a namespace, resource, group or route_param block reaches Grape::Endpoint directly, so an empty Array of paths is mutated in place:

PATHS = []

class API < Grape::API
  namespace :v1 do
    get(PATHS) { 'ok' }
  end
end

PATHS # => ["/"]

and a frozen one — which is what Style/MutableConstant asks for, and what an Array built from configuration is likely to be — takes the class definition down at boot, not on the first request:

PATHS = [].freeze

class API < Grape::API
  namespace :v1 do
    get(PATHS) { 'ok' }
  end
end
# FrozenError: can't modify frozen Array: []
#   lib/grape/endpoint/options.rb:12:in 'Grape::Endpoint::Options#initialize'
#   lib/grape/endpoint.rb:73:in 'Grape::Endpoint#initialize'
#   lib/grape/dsl/routing.rb:193:in 'Class#new'

namespace, resource, group and route_param all reproduce it.

Only the empty-Array case is affected. A String path (get '/users') goes through Array('/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::API records DSL calls and replays them onto its instance, and replay_step_on rebuilds the argument list on the way through:

def replay_step_on(instance, method:, args:, kwargs:, block:)
  return if skip_immediate_run?(instance, args, kwargs)

  eval_args = evaluate_arguments(instance.configuration, *args)

evaluate_arguments recurses into an Array argument with evaluate_arguments(configuration, *argument), whose body is a map — so the endpoint receives a copy and the append lands on that copy. That machinery exists to resolve Grape::Util::Lazy::Base arguments 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_arguments and the endpoint gets the caller's Array. Same for Grape::API::Instance subclasses and direct Grape::Endpoint / Grape::Endpoint::Options construction.

The fix

paths = Array(path)
super(
  path: paths.presence || ['/'],
  http_methods: Array(http_methods),
  api:, route_options:, app:, params:, requirements:, anchor:
)

Two things worth calling out:

  • A new Array rather than a bigger one. paths.presence || ['/'] allocates the default only when it is needed, and never touches what the caller holds.
  • An explicit super(...) rather than zsuper. The mutate-in-place shape existed because of the bare super: it forwards whatever the parameters currently hold, so normalizing an input meant assigning back over the parameter, and once you are assigning over path anyway, << looks like the natural way to add the default. Naming the keywords at the call to super removes that pressure — the normalization is an expression now, and neither path nor http_methods is reassigned.

Scope

The non-empty case still hands the caller's Array straight to the Data object, 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.rb is new: path normalization (String, non-empty Array, empty Array, frozen empty Array), http_methods normalization, and the namespace-nested definition through the public DSL, including the frozen case. Reverting the change fails four of them:

rspec ./spec/grape/endpoint/options_spec.rb:29 # ...when an empty Array does not append the default to the Array it was given
rspec ./spec/grape/endpoint/options_spec.rb:38 # ...when a frozen empty Array is expected to eq ["/"]
rspec ./spec/grape/endpoint/options_spec.rb:79 # ...inside a namespace leaves the caller's Array untouched
rspec ./spec/grape/endpoint/options_spec.rb:87 # ...inside a namespace when the Array is frozen defines the API without raising

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

@ericproulx
ericproulx force-pushed the endpoint-options-path-aliasing branch from c1c8304 to 502ac38 Compare September 5, 2026 17:59
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx marked this pull request as ready for review September 5, 2026 18:05
@ericproulx
ericproulx force-pushed the endpoint-options-path-aliasing branch from 502ac38 to 289c44f Compare September 5, 2026 18:11
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
ericproulx force-pushed the endpoint-options-path-aliasing branch from 289c44f to d802fd0 Compare September 6, 2026 12:15
@ericproulx
ericproulx merged commit d1e6a28 into master Sep 6, 2026
69 checks passed
@ericproulx
ericproulx deleted the endpoint-options-path-aliasing branch September 6, 2026 12:20
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