From 2e5bed08ad1b3552299540af1e218a0879b8d602 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 | 35 ++++++++++++++++++++ lib/grape/dsl/inside_route.rb | 6 ++++ spec/grape/endpoint_spec.rb | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc00e85b9..8a851f58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ * [#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). * [#2846](https://github.com/ruby-grape/grape/pull/2846): Keep a `:version` path capture in `params` when the API declares no version, instead of always dropping it as Grape's own - [@ericproulx](https://github.com/ericproulx). * [#2839](https://github.com/ruby-grape/grape/pull/2839): Tag path params as UTF-8 instead of leaving them ASCII-8BIT, so they compare equal to the non-ASCII literals an API declares (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 9be905bcb..f6e1240b5 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -38,6 +38,41 @@ 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 + +The `redirect` API set the content type to `text/plain`, but delegated body rendering to the formatter. With an API defaulting to JSON, a `redirect` would render the body as JSON with a `text/plain` content-type header. + +```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 the message it generates 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. On a JSON API the `Location` header, the status and the `Content-Type` are unchanged, so a client that follows the redirect is unaffected. + +On an API whose formatter cannot serialize a String, such as `format :xml`, `redirect` did not work at all: the formatter raised, and the response was a `500` carrying an error document and no `Location` header. Those APIs now get the `302` and the `Location` they always should have. + +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 +``` + #### Path params are tagged UTF-8 instead of ASCII-8BIT Params captured from the request path — `route_param`, `:id`-style segments, splats — now come back tagged `UTF-8`. They used to carry the `ASCII-8BIT` encoding of Rack's `PATH_INFO`, because Mustermann decodes the path against that raw string and nothing re-tagged the result. Query and body params were already `UTF-8`, since Rack tags those itself. 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..5c6521c2f 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -667,6 +667,66 @@ 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 + + # The XML formatter cannot serialize a String, so it raised and the redirect + # came back as a 500 carrying an error document and no Location header. + context 'when the API format cannot serialize a string' do + before do + subject.format :xml + subject.get('/hey') { redirect '/ha' } + end + + it 'still redirects' do + get '/hey' + + expect(last_response.status).to eq 302 + expect(last_response.headers['Location']).to eq '/ha' + 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 + end end describe 'NameError' do