Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions lib/grape/endpoint/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions spec/grape/endpoint/options_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading