From 096fc676607c8c649ef7752a0fec5f372db3abcd Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 24 Oct 2026 10:05:30 +0200 Subject: [PATCH] Wrap the router in Rack::Head instead of each endpoint's stack Rack::Head was the first middleware in each endpoint's stack, so it only saw responses that went through one. A Rack app mounted with `mount` is called by the router directly and skips `build_stack`, and a middleware inserted with `insert 0` sat outside Rack::Head: a HEAD request to either got the full body back. The router's own 404 had to strip its body itself (#2958) for the same reason. A single Rack::Head around the router, inside Rack::Lint, now strips the body of every HEAD response the API gives, and the router's 404 special case goes away. A request that cascades through several routes goes through one Rack::Head instead of one per route tried. Since an endpoint's stack no longer contains Rack::Head, `insert_before`/`insert_after Rack::Head` raise when the API compiles, and a middleware inserted by index lands one place further in. Both are covered in UPGRADING. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + UPGRADING.md | 10 ++++++++++ lib/grape/api/instance.rb | 7 ++++++- lib/grape/endpoint.rb | 1 - lib/grape/router.rb | 16 +++++++--------- spec/grape/api_spec.rb | 20 ++++++++++++++++++++ spec/grape/router_spec.rb | 2 -- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8283e3ed..3c55dc1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/UPGRADING.md b/UPGRADING.md index dfe1e6912..2f5c7dfb2 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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: diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index 1c84209d2..e73ac6818 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -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 diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index fd68663f2..1bdecd9e3 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -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 diff --git a/lib/grape/router.rb b/lib/grape/router.rb index de02e027a..ab87a5475 100644 --- a/lib/grape/router.rb +++ b/lib/grape/router.rb @@ -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) @@ -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 diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 16ca087de..086cb2b31 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -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 diff --git a/spec/grape/router_spec.rb b/spec/grape/router_spec.rb index adbcc59f7..57919df12 100644 --- a/spec/grape/router_spec.rb +++ b/spec/grape/router_spec.rb @@ -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)