diff --git a/content/router.xql b/content/router.xql
index e0d86fc..9530e2f 100644
--- a/content/router.xql
+++ b/content/router.xql
@@ -465,6 +465,8 @@ declare %private function router:write-response ($default-code as xs:integer, $r
router:get-content-type-for-code($config, $code, "application/xml")
))
+ let $method := router:method-for-content-type($content-type)
+
return (
response:set-status-code($code),
router:set-additional-headers($response?($router:RESPONSE_HEADERS)),
@@ -472,8 +474,8 @@ declare %private function router:write-response ($default-code as xs:integer, $r
()
else
(
- response:set-header("Content-Type", $content-type),
- util:declare-option("output:method", router:method-for-content-type($content-type)),
+ response:set-header("Content-Type", router:content-type-header($content-type, $method)),
+ util:declare-option("output:method", $method),
$response?($router:RESPONSE_BODY)
)
)
@@ -497,6 +499,38 @@ declare %private function router:safe-set-header ($header as xs:string, $value a
else response:set-header($header, $value)
};
+(:~
+ : Build the actual Content-Type header value for a response.
+ :
+ : eXist-db's serializer always writes text-based output as UTF-8 unless a
+ : route explicitly overrides it - there is no per-route encoding to track,
+ : so a response's charset isn't something routes.json should have to
+ : declare. Derive it from the already-computed serialization $method
+ : instead, for every text-ish type, so it's never missing or hand-wired
+ : (a missing charset leaves the client to guess the encoding, which is how
+ : non-Latin scripts end up mangled into mojibake).
+ : A type that already carries parameters (the operation explicitly asked
+ : for something specific) is left untouched. application/json is left
+ : alone too - RFC 8259 says senders shouldn't add a charset parameter to
+ : it, since JSON text is always UTF-8 by definition.
+ :
+ : $method "text" is ambiguous: router:method-for-content-type also returns it
+ : as a passthrough for binary types (e.g. application/octet-stream, image/png)
+ : that aren't otherwise recognized, so it can't be trusted alone - a charset
+ : is only added for it when $type itself is actually a text/* media type.
+ :)
+declare %private function router:content-type-header ($type as xs:string, $method as xs:string) as xs:string {
+ if (contains($type, ";")) then
+ $type
+ else if (
+ $method = ("html5", "xhtml", "xml") or
+ ($method = "text" and starts-with($type, "text/"))
+ ) then
+ $type || "; charset=UTF-8"
+ else
+ $type
+};
+
(:~
: Q: binary types?
: XSLT default values: "xml", "xhtml", "html", "text", "json", "adaptive"
diff --git a/test/app/api.json b/test/app/api.json
index 66ee3fa..e45909b 100644
--- a/test/app/api.json
+++ b/test/app/api.json
@@ -1035,6 +1035,31 @@
}
}
},
+ "/api/encoding-test": {
+ "get": {
+ "summary": "Echo an arbitrary Content-Type back through roaster:response",
+ "description": "Used to test how the router derives the actual Content-Type response header (e.g. charset handling) for a given media type.",
+ "operationId": "api:encoding-test",
+ "tags": [
+ "query"
+ ],
+ "parameters": [
+ {
+ "name": "type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Body sent with the requested Content-Type"
+ }
+ }
+ }
+ },
"/api/errors/handle": {
"get": {
"summary": "Test error handler",
diff --git a/test/app/modules/api.xql b/test/app/modules/api.xql
index 7887b83..04dcb68 100644
--- a/test/app/modules/api.xql
+++ b/test/app/modules/api.xql
@@ -115,6 +115,10 @@ declare function api:avatar ($request as map(*)) {
};
+declare function api:encoding-test ($request as map(*)) {
+ roaster:response(200, ($request?parameters?type, 'text/html')[1] ,
café 💩
)
+};
+
(:~
: A route handler that returns all parsed parameter values
:)
diff --git a/test/charset.test.js b/test/charset.test.js
new file mode 100644
index 0000000..5c70d27
--- /dev/null
+++ b/test/charset.test.js
@@ -0,0 +1,66 @@
+const util = require('./util.js')
+const chai = require('chai')
+const expect = chai.expect
+
+// Regression test for missing response charsets.
+describe('Content-Type header charset handling', function () {
+ const phrase = 'café 💩'
+
+ function fetchAs (type) {
+ return util.axios.get('api/encoding-test', {
+ params: { type },
+ responseType: 'arraybuffer'
+ })
+ }
+
+ describe('text-ish types get an explicit UTF-8 charset', function () {
+ const cases = [
+ ['application/xml', 'application/xml; charset=UTF-8'],
+ ['application/xhtml+xml', 'application/xhtml+xml; charset=UTF-8'],
+ ['image/svg+xml', 'image/svg+xml; charset=UTF-8'],
+ // eXist-db's servlet recognizes text/* as textual and re-normalizes
+ // the header when it sets the response's character encoding -
+ // still charset=utf-8, just reformatted (no space, lowercase).
+ ['text/html', 'text/html;charset=utf-8'],
+ ['text/plain', 'text/plain;charset=utf-8']
+ ]
+
+ cases.forEach(([type, expectedHeader]) => {
+ it(`${type} -> ${expectedHeader}`, async function () {
+ const res = await fetchAs(type)
+ expect(res.headers['content-type']).to.equal(expectedHeader)
+ expect(Buffer.from(res.data).toString('utf-8')).to.include(phrase)
+ })
+ })
+ })
+
+ describe('types that must not get a charset appended', function () {
+ // application/json is handled separately: it correctly serializes the
+ // string as a quoted JSON value, so its bytes aren't the raw phrase.
+ it('application/json is returned without a charset parameter', async function () {
+ const res = await fetchAs('application/json')
+ expect(res.headers['content-type']).to.equal('application/json')
+ expect(Buffer.from(res.data).toString('utf-8')).to.include(phrase)
+ })
+
+ const cases = [
+ 'application/octet-stream', // binary passthrough via the "text" method
+ 'image/png', // binary passthrough via the "text" method
+ 'audio/mpeg' // binary passthrough via the "text" method
+ ]
+
+ cases.forEach((type) => {
+ it(`${type} is returned without a charset parameter`, async function () {
+ const res = await fetchAs(type)
+ expect(res.headers['content-type']).to.equal(type)
+ expect(Buffer.from(res.data).toString('utf-8')).to.include(phrase)
+ })
+ })
+ })
+
+ it('leaves a type that already declares its own parameters untouched', async function () {
+ const res = await fetchAs('application/xml; charset=us-ascii')
+ expect(res.headers['content-type']).to.equal('application/xml; charset=us-ascii')
+ expect(Buffer.from(res.data).toString('utf-8')).to.include('café 💩')
+ })
+})
diff --git a/test/error.test.js b/test/error.test.js
index 0c3f75a..63bd9ff 100644
--- a/test/error.test.js
+++ b/test/error.test.js
@@ -25,7 +25,8 @@ describe('Error reporting', function() {
return util.axios.delete('api/errors')
.catch(function(error) {
expect(error.response.status).to.equal(403)
- expect(error.response.headers['content-type']).to.equal('application/xml')
+ // xml responses carry an explicit UTF-8 charset (see charset.test.js)
+ expect(error.response.headers['content-type']).to.equal('application/xml; charset=UTF-8')
expect(error.response.data).to.equal('')
})
})
@@ -34,7 +35,10 @@ describe('Error reporting', function() {
return util.axios.get('api/errors/handle')
.catch(function(error) {
expect(error.response.status).to.equal(500)
- expect(error.response.headers['content-type']).to.equal('text/html')
+ // html responses carry an explicit UTF-8 charset too, though
+ // eXist-db's servlet re-normalizes the header it is given
+ // (no space, lowercase) when it sets the character encoding
+ expect(error.response.headers['content-type']).to.equal('text/html;charset=utf-8')
expect(error.response.data).to.contain('$undefined')
})
})
diff --git a/test/mediatype.test.js b/test/mediatype.test.js
index c706a67..5faedbb 100644
--- a/test/mediatype.test.js
+++ b/test/mediatype.test.js
@@ -753,7 +753,9 @@ describe("Retrieving an SVG image", function () {
expect(response.status).to.equal(200)
})
it("was sent with the correct Content-Type header", function () {
- expect(response.headers['content-type']).to.equal('image/svg+xml')
+ // image/svg+xml is XML-flavored text, so it gets an explicit UTF-8
+ // charset just like application/xml (see charset.test.js).
+ expect(response.headers['content-type']).to.equal('image/svg+xml; charset=UTF-8')
})
it("is pretty printed", function () {
expect(response.data).to.equal(avatarImage)