Skip to content
Open
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 @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a line above.


`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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be explained simpler.

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.

Example: ...


```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

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