From 4e7d9d5c0255855e5660f219fd77a863176ded18 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sat, 1 Aug 2026 13:46:16 +0200 Subject: [PATCH] Render a redirect message as the plain text it claims to be #redirect announces its message as text/plain and has done since it was introduced in 2015 ("Redirect as plain text with optional message override"), but it only set the header. The body was still handed to the API's own formatter, so on a JSON API the sentence came back JSON-encoded: format :json get('/r') { redirect '/there' } Content-Type: text/plain "This resource has been moved temporarily to /there." quotes included -- neither valid plain text nor something a client reading the content type would expect. The existing specs missed it because they run on the default :txt format, where the formatter is a no-op. Set api.format alongside the header, the same lever an endpoint already has via #api_format, so the message is rendered by the txt formatter whatever the API declares. It is per-request env, so other routes on the same API are untouched. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/dsl/inside_route.rb | 5 +++++ spec/grape/endpoint_spec.rb | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0049465b9..7cfddfd1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ * [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx). * [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx). * [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx). +* [#2845](https://github.com/ruby-grape/grape/pull/2845): Render a `redirect` message as the plain text its content type announces, instead of letting the API's formatter re-encode it - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.5 (2026-07-30) diff --git a/lib/grape/dsl/inside_route.rb b/lib/grape/dsl/inside_route.rb index 2a48fed36..b46ff99b9 100644 --- a/lib/grape/dsl/inside_route.rb +++ b/lib/grape/dsl/inside_route.rb @@ -52,7 +52,12 @@ def redirect(url, permanent: false, body: nil) body_message ||= "This resource has been moved temporarily to #{url}." end header 'Location', url + # The message is plain text, so say so and render it as such. Setting + # only the header left the body to the API's own formatter, which on a + # JSON API returned the sentence wrapped in quotes under a text/plain + # content type. content_type 'text/plain' + api_format :txt body body_message end diff --git a/spec/grape/endpoint_spec.rb b/spec/grape/endpoint_spec.rb index 17cadc2d3..6535b918b 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -667,6 +667,38 @@ def handle_argument_error get '/hey' expect(last_response.body).to eq 'test body' end + + # The message is announced as text/plain, so it has to be rendered as such + # whatever the API's own format is. Left to the JSON formatter it came back + # as a quoted JSON string under a text/plain content type. + context 'when the API declares a format of its own' do + before do + subject.format :json + subject.get('/hey') { redirect '/ha' } + end + + it 'renders the message as plain text' do + get '/hey' + + expect(last_response.headers[Rack::CONTENT_TYPE]).to eq('text/plain') + expect(last_response.body).to eq 'This resource has been moved temporarily to /ha.' + end + + it 'renders an overridden body as plain text too' do + subject.get('/there') { redirect '/ha', body: 'go away' } + + get '/there' + expect(last_response.body).to eq 'go away' + end + + it 'leaves the format of other routes alone' do + subject.get('/plain') { { a: 1 } } + + get '/plain' + expect(last_response.headers[Rack::CONTENT_TYPE]).to eq('application/json') + expect(last_response.body).to eq({ a: 1 }.to_json) + end + end end describe 'NameError' do