Skip to content
Open
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 @@ -99,6 +99,7 @@
* [#3039](https://github.com/ruby-grape/grape/pull/3039): Hold every module to RuboCop's default length - [@ericproulx](https://github.com/ericproulx).
* [#3031](https://github.com/ruby-grape/grape/pull/3031): Define `InheritableSetting`'s scalar settings and scope flags by key - [@ericproulx](https://github.com/ericproulx).
* [#3032](https://github.com/ruby-grape/grape/pull/3032): Keep each API's content type order when negotiating a browser's `*/*` - [@ericproulx](https://github.com/ericproulx).
* [#3042](https://github.com/ruby-grape/grape/pull/3042): Answer `HEAD` without a body from a mounted Rack app by wrapping the router in `Rack::Head` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 4.0.1 (2026-09-15)
Expand Down
10 changes: 10 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ Upgrading Grape

### Upgrading to >= 4.1.0

#### `Rack::Head` wraps the router instead of each endpoint's stack

`Rack::Head` used to be the first middleware in each endpoint's stack. That missed a Rack app mounted with `mount`, which the router calls directly, and a middleware inserted ahead of it with `insert 0`: a `HEAD` request to either got the full body back. A single `Rack::Head` around the router now strips the body of every `HEAD` response the API gives ([#3042](https://github.com/ruby-grape/grape/pull/3042)).

An endpoint's stack now starts with `Grape::Middleware::Error`, and three things follow from that:

* `insert_before Rack::Head` and `insert_after Rack::Head` raise `No such middleware to insert before: Rack::Head` (or `after`) when the API compiles. Use `insert 0` to put a middleware first.
* A middleware inserted by index lands one place further in. `insert 1` used to put it between `Rack::Head` and `Grape::Middleware::Error`; it now puts it inside `Grape::Middleware::Error`, so `rescue_from` handles what it raises. Use `insert 0` to keep it outside.
* A middleware inserted with `insert 0` now sees the full body of a `HEAD` response, as one added with `use` always has.

#### A `:jsonl` format you declared now gets Grape's JSON Lines formatter, parser and error formatter

Grape now renders, parses and reports errors in JSON Lines under `:jsonl` ([#3029](https://github.com/ruby-grape/grape/pull/3029)). An API that declared `content_type :jsonl, ...` itself, with nothing of its own registered for it, gets Grape's:
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 @@ -112,7 +112,12 @@ def initialize

@router.compile!
@router.freeze
@app = lint? ? Rack::Lint.new(@router) : @router
# One Rack::Head around the router strips the body of every HEAD
# response the API gives, including the router's own 404 and a mounted
# Rack app's, which go through no endpoint's stack. Rack::Lint goes
# outside it, where it sees the response that is served.
@app = Rack::Head.new(@router)
@app = Rack::Lint.new(@app) if lint?
@cascade = self.class.cascade
end

Expand Down
1 change: 0 additions & 1 deletion lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,6 @@ def build_stack
content_types = setting.content_types
format = setting.format

stack.use Rack::Head
stack.use Grape::Middleware::Error, **error_middleware_options(format, content_types)

stack.concat setting.middleware
Expand Down
16 changes: 7 additions & 9 deletions lib/grape/router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def associate_routes(greedy_route)

def call(env)
request_method = env[Rack::REQUEST_METHOD]
with_optimization(request_method) do
with_optimization do
input = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
if @utf8_patterns && !input.ascii_only?
input = utf8_path(input)
Expand Down Expand Up @@ -316,17 +316,15 @@ def resolve_capture_groups(union, routes)
union
end

def with_optimization(request_method = nil)
def with_optimization
compile!
yield || default_response(request_method)
yield || default_response
end

# The 404 answered when nothing matched. A HEAD request gets it without a
# body, as Rack requires: every endpoint strips its own with Rack::Head,
# but no endpoint answers a path nothing matched.
def default_response(request_method = nil)
body = request_method == Rack::HEAD ? [] : DEFAULT_RESPONSE_BODY.dup
[404, DEFAULT_RESPONSE_HEADERS.dup, body]
# The 404 answered when nothing matched. The Rack::Head the API wraps
# around the router strips its body from a HEAD request.
def default_response
[404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
end

# Which alternative of the union matched is answered by scanning one group
Expand Down
20 changes: 20 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,26 @@ def query_request(body, content_type: 'application/json')
head '/example'
expect(last_response).to be_bad_request
end

it 'answers HEAD to a mounted Rack app without a body' do
subject.mount ->(_env) { [200, { 'content-type' => 'text/plain' }, ['mounted']] } => '/rack'
head '/rack'
expect(last_response).to be_successful
expect(last_response.body).to be_empty
end

it 'answers HEAD without the body a middleware inserted ahead of the others gives' do
overwriter = Class.new(Grape::Middleware::Base) do
def after
[200, { 'content-type' => 'text/plain' }, ['Overwritten.']]
end
end
subject.insert 0, overwriter
subject.get('example') { 'example' }
head '/example'
expect(last_response).to be_successful
expect(last_response.body).to be_empty
end
end

context 'do_not_route_head!' do
Expand Down
2 changes: 0 additions & 2 deletions spec/grape/router_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,6 @@ def request_path(path)
expect(last_response.status).to eq(404)
end

# No endpoint's stack answers a path nothing matched, so none of their
# Rack::Head middlewares is there to strip the body.
it 'answers HEAD to a path no route matches without a body' do
head '/nothing/here'
expect(last_response.status).to eq(404)
Expand Down
Loading