Render a redirect message as the plain text it claims to be - #2845
Conversation
63cf9a3 to
4e7d9d5
Compare
Danger ReportNo issues found. |
dblock
left a comment
There was a problem hiding this comment.
This is a backwards incompatible change, right? Needs UPGRADING?
dblock
left a comment
There was a problem hiding this comment.
This is a backwards incompatible change, right? Needs UPGRADING?
e2a1c9e to
02da8bb
Compare
|
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 Checking the blast radius also turned up something the first version got wrong. Setting redirect '/there', body: { message: 'moved' }
# before: {"message":"moved"}
# then: {message: "moved"} # Hash#to_s — neither JSON nor useful textSo it's now scoped to the message Grape generates, which is the one body known to be plain text: api_format :txt unless bodyThe trade-off is that |
02da8bb to
d861d6e
Compare
d861d6e to
7dbdbad
Compare
#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>
7dbdbad to
2e5bed0
Compare
|
Went back over the description claim by claim and found one I had wrong — the 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 with no That also made "the Also swapped the |
Summary
#redirectannounces its message astext/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: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.
format :json302,text/plain+"…moved temporarily to /there."(quoted)302,text/plain+…moved temporarily to /there.format :txtformat :xml500,application/xml, noLocationheader302,Location: /there,text/plain+ the messageThe
:xmlrow is worse than it first looked: the XML formatter cannot serialize a String, so it raised and the error path took over.redirecton an XML API has never redirected at all — it answered500with<error><message>cannot convert String to xml</message></error>and noLocation. There is a spec pinning the302now.The existing
#redirectspecs missed it because they run on the default:txtformat, where the formatter is a no-op.Approach
Set
api.formatalongside the header for the generated message — the same lever an endpoint already has via#api_format(spec'd inapi_spec.rbas 'can be overwritten with an explicit api_format') — so the message is rendered by the txt formatter whatever the API declares.:txtis always resolvable:Grape::Formatter.formatter_forfalls back to the built-in registry, which is not narrowed by the API'sformat.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 theLocationheader, the status and theContent-Typeare 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-TypeandLocationall change — from a500with noLocationto the302that 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 :txtunconditionally, which also caught a body the caller passed. That was worse than the bug on a JSON API:So the format is now only forced for the message Grape generates, which is the one body known to be plain text:
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 undertext/plain— the same contradiction, for a body Grape cannot assume anything about. Narrowing it further (say, rendering anyStringbody as text) is a judgement call I left out; happy to widen it if you'd prefer.Test plan
endpoint_spec.rbunder#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 a302+Locationon an XML API. Verified the XML one fails without thelib/change (expected: 302).🤖 Generated with Claude Code