diff --git a/CHANGELOG.md b/CHANGELOG.md index 89766e950..e8a391dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,7 @@ * [#2908](https://github.com/ruby-grape/grape/pull/2908): Stop reassigning method parameters across `lib`, so a parameter keeps the value its caller passed for the whole method; the `oneof` collection in `Grape::Validations::ParamsScope` no longer writes into the Hash it was given - [@ericproulx](https://github.com/ericproulx). * [#2910](https://github.com/ruby-grape/grape/pull/2910): Restore `Grape::Middleware::Formatter`'s in-place content-type negotiation as `ensure_content_type!`, which #2908 had turned into a copy of the response headers on every response - [@ericproulx](https://github.com/ericproulx). * [#2911](https://github.com/ruby-grape/grape/pull/2911): Copy response headers with `merge!` instead of `merge` in `Grape::API::Instance#call` and `Grape::Middleware::Error`, which allocated a `Grape::Util::Header` only to discard it - [@ericproulx](https://github.com/ericproulx). +* [#2907](https://github.com/ruby-grape/grape/pull/2907): Stop `Grape::Endpoint::Options` from appending the default `'/'` into the path Array it was given, so declaring a route with an empty Array of paths inside a `namespace`, `resource`, `group` or `route_param` block no longer mutates the caller's Array — and raises `FrozenError` at boot when it is frozen - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.5 (2026-07-30) diff --git a/lib/grape/endpoint/options.rb b/lib/grape/endpoint/options.rb index 689f565c9..0146f913f 100644 --- a/lib/grape/endpoint/options.rb +++ b/lib/grape/endpoint/options.rb @@ -8,10 +8,17 @@ class Endpoint # stays a plain Hash for downstream gems (e.g. grape-swagger). 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 + # +Array()+ hands back the very Array it was given, so defaulting an + # empty one by appending would append to the caller's Array — and raise + # FrozenError on a frozen one. Build a new Array instead of growing + # theirs: nothing about constructing an endpoint should be visible in + # the path the caller passed in. + paths = Array(path) + super( + path: paths.presence || ['/'], + http_methods: Array(http_methods), + api:, route_options:, app:, params:, requirements:, anchor: + ) end end end diff --git a/spec/grape/endpoint/options_spec.rb b/spec/grape/endpoint/options_spec.rb new file mode 100644 index 000000000..e503ba739 --- /dev/null +++ b/spec/grape/endpoint/options_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +describe Grape::Endpoint::Options do + describe '#path' do + subject { described_class.new(path: paths, http_methods: :get, api: nil).path } + + context 'when a String' do + let(:paths) { '/users' } + + it { is_expected.to eq(['/users']) } + end + + context 'when a non-empty Array' do + let(:paths) { ['/users', '/people'] } + + it { is_expected.to eq(['/users', '/people']) } + + it 'does not modify the Array it was given' do + subject + expect(paths).to eq(['/users', '/people']) + end + end + + context 'when an empty Array' do + let(:paths) { [] } + + it { is_expected.to eq(['/']) } + + it 'does not append the default to the Array it was given' do + subject + expect(paths).to be_empty + end + end + + context 'when a frozen empty Array' do + let(:paths) { [].freeze } + + it { is_expected.to eq(['/']) } + end + end + + describe '#http_methods' do + subject { described_class.new(path: '/', http_methods: methods, api: nil).http_methods } + + context 'when a Symbol' do + let(:methods) { :get } + + it { is_expected.to eq([:get]) } + end + + context 'when an Array' do + let(:methods) { %i[get post] } + + it { is_expected.to eq(%i[get post]) } + end + end + + # The reachable path through the public DSL: a route nested in a namespace, + # resource, group or route_param block reaches Grape::Endpoint directly. A + # top-level route does not, because Grape::API replays recorded setup steps + # through `evaluate_arguments`, which rebuilds Array arguments on the way in. + context 'when a route is defined inside a namespace' do + let(:paths) { [] } + let(:app) do + route_paths = paths + Class.new(Grape::API) do + format :txt + namespace :v1 do + get(route_paths) { 'ok' } + end + end + end + + it 'routes the default path' do + get '/v1' + expect(last_response.body).to eq('ok') + end + + it "leaves the caller's Array untouched" do + app.routes + expect(paths).to be_empty + end + + context 'when the Array is frozen' do + let(:paths) { [].freeze } + + it 'defines the API without raising' do + expect { app.routes }.not_to raise_error + end + end + end +end