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 @@ -115,6 +115,7 @@
* [#2905](https://github.com/ruby-grape/grape/pull/2905): Nest any `route_param` requirement under the param name, not just a Regexp, and reject the `requirements` shapes that have no param to attach to where they are written rather than on the first request - [@ericproulx](https://github.com/ericproulx).
* [#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).
* Your contribution here.

### 3.3.5 (2026-07-30)
Expand Down
7 changes: 6 additions & 1 deletion lib/grape/api/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ def initialize
def call(env)
status, headers, response = @router.call(env)
unless @cascade
headers = Grape::Util::Header.new.merge(headers)
# +merge!+, not +merge+: the latter is a `dup` plus a `merge!`, so the
# Header built on this line would be allocated only to be discarded.
# The copy stays because +headers+ can come from a mounted Rack app,
# which is free to hand back a frozen or shared Hash that the delete
# below must not reach into.
headers = Grape::Util::Header.new.merge!(headers)
headers.delete('X-Cascade')
end

Expand Down
2 changes: 1 addition & 1 deletion lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def call!(env)

def rack_response(status, headers, message)
body = html_content_type?(headers[Rack::CONTENT_TYPE]) ? Rack::Utils.escape_html(message) : message
Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), Grape::Util::Header.new.merge!(headers))
end

# Escaping must key off the media type only, case-insensitively. Comparing
Expand Down
25 changes: 25 additions & 0 deletions spec/grape/api/instance_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,29 @@ def app
expect(an_instance.compile!.cascade?).to be(true)
end
end

describe '#call' do
context 'when cascade is false' do
let(:rack_headers) { { 'content-type' => 'text/plain', 'x-cascade' => 'pass' }.freeze }
let(:root_api) do
headers = rack_headers
Class.new(Grape::API::Instance) do
cascade false
mount ->(_env) { [200, headers, ['from rack']] } => '/rack'
end
end

it 'strips X-Cascade from the response' do
get '/rack'
expect(last_response.headers).not_to have_key('x-cascade')
end

# The mounted app owns the Hash it returned and is free to hand back a
# frozen or shared one, so removing X-Cascade has to happen on a copy.
it 'does not write into the headers the mounted app returned' do
get '/rack'
expect(rack_headers).to eq('content-type' => 'text/plain', 'x-cascade' => 'pass')
end
end
end
end
Loading