Skip to content

Render a redirect message as the plain text it claims to be - #2845

Merged
ericproulx merged 1 commit into
masterfrom
fix/redirect-plain-text-body
Aug 22, 2026
Merged

Render a redirect message as the plain text it claims to be#2845
ericproulx merged 1 commit into
masterfrom
fix/redirect-plain-text-body

Conversation

@ericproulx

@ericproulx ericproulx commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

#redirect announces its message as text/plain — and has since it was introduced in 2015, in a commit literally titled "Redirect as plain text with optional message override" — but it only ever set the header. The body was still handed to the API's own formatter:

class API < Grape::API
  format :json
  get('/r') { redirect '/there' }
end
HTTP/1.1 302 Found
Location: /there
Content-Type: text/plain

"This resource has been moved temporarily to /there."

Quotes included. That is neither valid plain text nor what a client reading the content type would expect, and the header and body contradict each other.

API format before after
format :json 302, text/plain + "…moved temporarily to /there." (quoted) 302, text/plain + …moved temporarily to /there.
format :txt correct already unchanged
format :xml 500, application/xml, no Location header 302, Location: /there, text/plain + the message

The :xml row is worse than it first looked: the XML formatter cannot serialize a String, so it raised and the error path took over. redirect on an XML API has never redirected at all — it answered 500 with <error><message>cannot convert String to xml</message></error> and no Location. There is a spec pinning the 302 now.

The existing #redirect specs missed it because they run on the default :txt format, where the formatter is a no-op.

Approach

Set api.format alongside the header for the generated message — the same lever an endpoint already has via #api_format (spec'd in api_spec.rb as 'can be overwritten with an explicit api_format') — so the message is rendered by the txt formatter whatever the API declares. :txt is always resolvable: Grape::Formatter.formatter_for falls back to the built-in registry, which is not narrowed by the API's format.

It is per-request rack env, so other routes on the same API are unaffected — there is a spec pinning that.

Backward compatibility

UPGRADING entry added@dblock is right that this is a breaking change, and the original description of it here was wrong.

The body of a redirect changes on any API whose format is not :txt. Concretely, JSON.parse(response.body) on a redirect succeeded before and now raises. On a JSON API the Location header, the status and the Content-Type are unchanged, so a client that follows the redirect is unaffected — but code that reads the body is.

On a format that could not serialize the message at all, such as :xml, the status, Content-Type and Location all change — from a 500 with no Location to the 302 that was always intended.

Longstanding rather than a regression: present since v0.14.0 (d1bba79d, 2015) and identical in 3.3.4.

Scoped to the generated message

The first version of this PR set api_format :txt unconditionally, which also caught a body the caller passed. That was worse than the bug on a JSON API:

redirect '/there', body: { message: 'moved' }
# before:  {"message":"moved"}
# with the unconditional version:  {message: "moved"}   # Hash#to_s — neither JSON nor useful text

So the format is now only forced for the message Grape generates, which is the one body known to be plain text:

api_format :txt unless body

A caller-supplied body keeps the API's format and is untouched by this PR. The trade-off is that redirect url, body: 'go away' on a JSON API still returns "go away" quoted under text/plain — the same contradiction, for a body Grape cannot assume anything about. Narrowing it further (say, rendering any String body as text) is a judgement call I left out; happy to widen it if you'd prefer.

Test plan

  • 5 new examples in endpoint_spec.rb under #redirect: plain-text body on a JSON API, a structured and a string caller-supplied body both keeping the API's format, a guard that other routes keep it too, and a 302 + Location on an XML API. Verified the XML one fails without the lib/ change (expected: 302).
  • Full RSpec suite passes locally (2596 examples, 0 failures).
  • RuboCop clean.
  • CI green.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the fix/redirect-plain-text-body branch from 63cf9a3 to 4e7d9d5 Compare August 1, 2026 11:46
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@dblock dblock left a comment

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.

This is a backwards incompatible change, right? Needs UPGRADING?

@dblock dblock left a comment

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.

This is a backwards incompatible change, right? Needs UPGRADING?

@ericproulx
ericproulx force-pushed the fix/redirect-plain-text-body branch 2 times, most recently from e2a1c9e to 02da8bb Compare August 20, 2026 07:02
@ericproulx

Copy link
Copy Markdown
Contributor Author

You're right on both counts — thanks, my "no UPGRADING entry" claim was wrong.

The body of a redirect changes on any API whose format isn't :txt. JSON.parse(response.body) on a redirect succeeded before and now raises. Added an UPGRADING entry saying so, and corrected the description above.

Checking the blast radius also turned up something the first version got wrong. Setting api_format :txt unconditionally caught a body the caller passed, which on a JSON API was worse than the bug:

redirect '/there', body: { message: 'moved' }
# before: {"message":"moved"}
# then:   {message: "moved"}    # Hash#to_s — neither JSON nor useful text

So it's now scoped to the message Grape generates, which is the one body known to be plain text:

api_format :txt unless body

The trade-off is that redirect url, body: 'go away' on a JSON API still comes back as "go away" quoted under text/plain. I left that alone rather than guessing at a caller's intent, but rendering any String body as text would be a one-line change if you'd rather have it.

@ericproulx
ericproulx force-pushed the fix/redirect-plain-text-body branch from 02da8bb to d861d6e Compare August 20, 2026 07:04
@ericproulx
ericproulx requested a review from dblock August 20, 2026 07:23
Comment thread UPGRADING.md
Comment thread UPGRADING.md Outdated
@ericproulx
ericproulx force-pushed the fix/redirect-plain-text-body branch from d861d6e to 7dbdbad Compare August 22, 2026 15:39
#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 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix/redirect-plain-text-body branch from 7dbdbad to 2e5bed0 Compare August 22, 2026 15:45
@ericproulx

Copy link
Copy Markdown
Contributor Author

Went back over the description claim by claim and found one I had wrong — the format :xml row.

I had it as "quoted/encoded by the XML formatter". It is worse than that: the XML formatter cannot serialize a String, so it raises and the error path takes over. On master a redirect from an XML API answers

500 Internal Server Error
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<error>
  <message>cannot convert String to xml</message>
</error>

with no Location header at all — redirect has never redirected on an XML API. With this PR it is a 302 with Location: /there and the plain message. Added a spec pinning that, and confirmed it fails without the change (expected: 302).

That also made "the Location header, the status and the Content-Type are all unchanged" wrong as an absolute — it holds for JSON, not for a format that could not serialize the message. Corrected in both the description and the UPGRADING entry, which now notes the XML case in one sentence.

Also swapped the api_spec.rb:4494 reference for the spec name, since the line number drifts on every rebase; it had already gone stale.

@ericproulx
ericproulx merged commit ff1a236 into master Aug 22, 2026
70 checks passed
@ericproulx
ericproulx deleted the fix/redirect-plain-text-body branch August 22, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants