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