From 200db6f11ac2a3b33bdcacb0bac206e65aad8fdf Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Fri, 18 Sep 2026 16:36:49 +0200 Subject: [PATCH] Send a content type for an api_format the API does not declare `api_format` picks the format a response is rendered in, and the media type it is labelled with comes from the formats the API declares. A format the API does not declare has none there, so on a `format :json` API get('/plain') { api_format :txt; 'plain' } rendered `plain` under `content-type: nil`. Rack forbids a nil header value and Rack::Lint raises on it; a server writes an empty header, or none. The same goes for `:xml` and `:binary`. The only spec for `api_format :txt` declares `content_type :txt, 'text/plain'` on its API, so the suite's `Grape.config.lint` never met the nil. An error raised after it went wrong the other way: the error middleware falls back to text/html, and HTML-escapes a body it labels so, so `error!('a < b', 400)` answered `a < b` as text/html while rendering it with the txt error formatter. PrecomputedContentTypes, which both middlewares share, now answers #media_type_for: the media type the API declares for a format, or else the one Grape registers for it by default -- text/plain, application/xml, application/octet-stream. The formatter labels a response with it and leaves the header out for a format with no media type anywhere, a custom formatter's; the error middleware labels an error with it before falling back to text/html. #content_type_for is left as it is: negotiation, format extensions and the 415 check ask it whether the API supports a format at all, and a fallback there would accept formats the API never declared. Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/middleware/formatter.rb | 6 +++- .../middleware/precomputed_content_types.rb | 16 +++++++-- spec/grape/api_spec.rb | 35 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0f47df8..bfdadc9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ * [#2953](https://github.com/ruby-grape/grape/pull/2953): Answer an `error!` String message with its own status instead of a `500` when the route names a `failure` entity for that status - [@ericproulx](https://github.com/ericproulx). * [#2955](https://github.com/ruby-grape/grape/pull/2955): Reject a value that is not an Array, and a member of none of the types, in a collection with multiple member types instead of passing them on as `nil` and `InvalidValue` - [@ericproulx](https://github.com/ericproulx). * [#2957](https://github.com/ruby-grape/grape/pull/2957): Reject an element that is not a Hash in an Array param given a block, and stop `declared` raising on a value that is not a Hash where nested params are declared - [@ericproulx](https://github.com/ericproulx). +* [#2959](https://github.com/ruby-grape/grape/pull/2959): Send a content type for an `api_format` the API does not declare instead of a `nil` header - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 4.0.1 (2026-09-15) diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 47e074672..90bf8b316 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -147,12 +147,16 @@ def fetch_formatter(headers) # this runs on every response, and copying the hash costs an allocation # per request for a header the caller is about to send anyway. # + # A format with no media type at all (see #media_type_for) leaves the + # header out: Rack forbids a nil header value. + # # @param headers [Hash] the response headers, mutated in place # @return [void] def ensure_content_type!(headers) return if headers[Rack::CONTENT_TYPE] - headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT]) + content_type = media_type_for(env[Grape::Env::API_FORMAT]) + headers[Rack::CONTENT_TYPE] = content_type if content_type end def read_body_input diff --git a/lib/grape/middleware/precomputed_content_types.rb b/lib/grape/middleware/precomputed_content_types.rb index 1ce79e0e3..191a940e8 100644 --- a/lib/grape/middleware/precomputed_content_types.rb +++ b/lib/grape/middleware/precomputed_content_types.rb @@ -4,7 +4,7 @@ module Grape module Middleware # Include in a middleware subclass that needs content-type negotiation. # Provides +content_types+ / +mime_types+ / +content_type_for+ / - # +content_type+ resolved from +config.content_types+ and + # +media_type_for+ / +content_type+ resolved from +config.content_types+ and # +config.format+ — so the consuming middleware's +Options+ Data class # must declare both fields. Warms those caches on the parent instance # at initialization so per-request +dup+s inherit them rather than @@ -19,6 +19,9 @@ module Middleware # Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need # content-type-aware helpers don't pay for them. module PrecomputedContentTypes + # Grape's own media type for each built-in format, under both spellings. + DEFAULT_CONTENT_TYPES = Grape::ContentTypes.lookup_for(Grape::ContentTypes::DEFAULTS) + def initialize(app, **options) super content_types @@ -37,8 +40,17 @@ def content_type_for(format) content_types_lookup[format] end + # The media type to label a response rendered in +format+ with: the one + # this API declares for it, or else Grape's own -- +api_format :txt+ in + # a +format :json+ API renders text/plain. #content_type_for stays nil + # for a format the API does not declare, since negotiation asks it + # whether the API supports the format at all. + def media_type_for(format) + content_type_for(format) || DEFAULT_CONTENT_TYPES[format] + end + def content_type - content_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html' + media_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html' end private diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 850fb3f42..4378a533d 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -4775,6 +4775,41 @@ def before end end + context ':json only' do + before { subject.format :json } + + it "sends Grape's media type for an explicit api_format the API does not declare" do + subject.get '/plain' do + api_format :txt + 'plain' + end + get '/plain' + expect(last_response.headers['Content-Type']).to eq('text/plain') + expect(last_response.body).to eq('plain') + end + + it 'leaves the content type out for an explicit api_format with no media type' do + subject.formatter :csv, ->(object, _env) { object.to_s } + subject.get '/csv' do + api_format :csv + 'a,b' + end + get '/csv' + expect(last_response.headers).not_to have_key('Content-Type') + expect(last_response.body).to eq('a,b') + end + + it 'renders an error after an explicit api_format the API does not declare in its media type' do + subject.get '/plain_error' do + api_format :txt + error!('a < b', 400) + end + get '/plain_error' + expect(last_response.headers['Content-Type']).to eq('text/plain') + expect(last_response.body).to eq('a < b') + end + end + context ':serializable_hash' do before do stub_const(