From d861d6e0bb26b879740c3e7021a8cad4d977f0ab 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 + UPGRADING.md | 32 ++++++++++++++++++++++++++ lib/grape/dsl/inside_route.rb | 6 +++++ spec/grape/endpoint_spec.rb | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e54cdff8..08d298662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ * [#2853](https://github.com/ruby-grape/grape/pull/2853): Restore, behind a deprecation warning, the trailing positional options Hash of `requires`, `optional` and `use`, which #2618 turned into a parameter name - [@ericproulx](https://github.com/ericproulx). * [#2856](https://github.com/ruby-grape/grape/pull/2856): Update simplecov - [@ericproulx](https://github.com/ericproulx). * [#2841](https://github.com/ruby-grape/grape/pull/2841): Stop `use`, `helpers`, `rescue_from` and other registrations declared below a route from reaching it when an earlier registration had seeded the same key (see UPGRADING) - [@ericproulx](https://github.com/ericproulx). +* [#2845](https://github.com/ruby-grape/grape/pull/2845): Render `redirect`'s default message as the plain text its content type announces, instead of letting the API's formatter re-encode it (see UPGRADING) - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 3.3.5 (2026-07-30) diff --git a/UPGRADING.md b/UPGRADING.md index a9f6e0059..aa39fc379 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -37,6 +37,38 @@ end ``` Nothing changes for the ordinary arrangement — registrations declared before a route, or inherited from an enclosing namespace or a mounting API, still apply exactly as before, including values an enclosing scope gains after the nested scope was created. +#### `redirect` renders its default message as plain text + +`redirect` has announced its body as `text/plain` since it was introduced in 0.14.0, but it only ever set the header — the message itself was still handed to the API's own formatter. On an API declaring a format other than `:txt`, the sentence came back encoded by that formatter under a `text/plain` content type: + +```ruby +class API < Grape::API + format :json + get('/r') { redirect '/there' } +end +``` + +``` +Content-Type: text/plain + +"This resource has been moved temporarily to /there." +``` + +Grape now renders that message with the txt formatter, so the body is the plain sentence the content type claims: + +``` +Content-Type: text/plain + +This resource has been moved temporarily to /there. +``` + +**What can break.** Code that parses a redirect body — `JSON.parse(response.body)` on a redirect succeeded before and now raises — or a test asserting on the encoded form. The `Location` header, the status and the `Content-Type` are unchanged, so a client that follows the redirect is unaffected. + +This applies only to the message Grape generates. A body you pass yourself is still rendered by the API's formatter, unchanged: + +```ruby +redirect '/there', body: { message: 'moved' } # still {"message":"moved"} on a JSON API +``` #### `Array`/`Set` of an unsupported type is rejected when the API is defined diff --git a/lib/grape/dsl/inside_route.rb b/lib/grape/dsl/inside_route.rb index 2a48fed36..acdf5f82b 100644 --- a/lib/grape/dsl/inside_route.rb +++ b/lib/grape/dsl/inside_route.rb @@ -53,6 +53,12 @@ def redirect(url, permanent: false, body: nil) end header 'Location', url content_type 'text/plain' + # Render the message Grape generated as the plain text it is. Setting + # only the header left it to the API's own formatter, which on a JSON + # API returned the sentence wrapped in quotes under a text/plain content + # type. A caller-supplied body keeps the API's format: it may be + # structured, and the txt formatter would render a Hash through `to_s`. + api_format :txt unless body body body_message end diff --git a/spec/grape/endpoint_spec.rb b/spec/grape/endpoint_spec.rb index 17cadc2d3..8dfb09c44 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -667,6 +667,48 @@ def handle_argument_error get '/hey' expect(last_response.body).to eq 'test body' end + + # The generated 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 + + # Only the message Grape generates is known to be text. A body the caller + # passed keeps the API's format, so a structured one stays parseable + # rather than being rendered through the txt formatter's `to_s`. + it 'leaves a structured body to the API format' do + subject.get('/there') { redirect '/ha', body: { message: 'go away' } } + + get '/there' + expect(last_response.body).to eq({ message: 'go away' }.to_json) + end + + it 'leaves a string body to the API format' 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