Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ericproulx marked this conversation as resolved.

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.
Expand Down
6 changes: 6 additions & 0 deletions lib/grape/dsl/inside_route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions spec/grape/endpoint_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading