From 1fca2f92a09012445e763b8156ec829b331a46ba Mon Sep 17 00:00:00 2001 From: Juri Leino Date: Wed, 2 Sep 2026 11:43:40 +0200 Subject: [PATCH] feat(body)!: deliver array-typed and repeating form fields as arrays Form-data and urlencoded body properties declared as type "array" in the requestBody schema, and repeating additional (schemaless) fields, are now wrapped in array(*) instead of being passed to handlers as XQuery sequences. This aligns $request?body with $request?parameters (parameters:cast-array already returns array(*) for multi-value parameters) and with JSON bodies, and makes the body map serializable with the JSON output method on eXist 7, which raises err:SERE0023 for sequences of more than one item as the serialization spec requires. BREAKING CHANGE: handlers reading multi-value form-data or urlencoded body fields must unpack an array now, e.g. $request?body?file?* instead of $request?body?file. Closes #144 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L13cpTBpxHDuvVSd78BDQw --- content/body.xqm | 12 +++++++++++- doc/file-upload.md | 4 ++-- test/app/modules/upload.xqm | 2 +- test/mediatype.test.js | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/content/body.xqm b/content/body.xqm index fe9fe31..a6fc275 100644 --- a/content/body.xqm +++ b/content/body.xqm @@ -163,7 +163,13 @@ function body:get-form-data-value ($name as xs:string, $format as xs:string?) as declare %private function body:additional-property ($name as xs:string) as map(*) { - map { $name : request:get-parameter($name, ()) } + (: a repeating field is wrapped in an array - a sequence of more than one + : item cannot be serialized with the JSON output method (err:SERE0023) + : and multi-value parameters are arrays in $request?parameters as well :) + let $values := request:get-parameter($name, ()) + return map { + $name : if (count($values) > 1) then array { $values } else $values + } }; declare %private @@ -189,6 +195,10 @@ function body:validate-value ($schema as map(*)) as function(*) { then error($errors:BAD_REQUEST, 'Property "' || $name || '" is required!') else if (count($value) > 1 and not($is-array)) then error($errors:BAD_REQUEST, 'Property "' || $name || '" only allows one item. Got ' || count($value), $value) + (: properties declared as type "array" are wrapped like + : parameters:cast-array does for multi-value parameters :) + else if ($is-array and exists($value)) + then map:entry($name, array { $value }) else map:entry($name, $value) } }; diff --git a/doc/file-upload.md b/doc/file-upload.md index a565d50..fa9b9b0 100644 --- a/doc/file-upload.md +++ b/doc/file-upload.md @@ -133,7 +133,7 @@ In order to allow batch uploads only very few modifications to the above example } ``` 2. `` -3. iterate over all files in the body `for $file in $request?body?file` +3. iterate over all files in the body `for $file in $request?body?file?*` (properties declared as type "array" are array values) 4. return array of uploaded resources in response ```html @@ -178,7 +178,7 @@ In order to allow batch uploads only very few modifications to the above example declare function upload:batch ($request as map(*)) { let $stored := array{ - for $file in $request?body?file + for $file in $request?body?file?* return xmldb:store( "/db/apps/roasted/uploads", $file?name, $file?data) } diff --git a/test/app/modules/upload.xqm b/test/app/modules/upload.xqm index 3fff8ca..0027344 100644 --- a/test/app/modules/upload.xqm +++ b/test/app/modules/upload.xqm @@ -26,7 +26,7 @@ declare function upload:single ($request as map(*)) { declare function upload:batch ($request as map(*)) { try { let $download-links as xs:string+ := - for $file in $request?body?file + for $file in $request?body?file?* let $stored := xmldb:store($upload:collection, $file?name, $file?data) return $upload:download-path || $file?name diff --git a/test/mediatype.test.js b/test/mediatype.test.js index c706a67..0ef8283 100644 --- a/test/mediatype.test.js +++ b/test/mediatype.test.js @@ -540,6 +540,42 @@ test. }) + describe("with one file posted to the batch route", function () { + let uploadResponse + const data = new FormData() + + const fileName = 'only-file.txt' + const fileContent = 'the only text' + data.append('file', fileContent, { + knownLength: fileContent.length, + filename: fileName, + contentType: 'text/plain' + }) + const headers = data.getHeaders(); + + before(function () { + return util.axios.post( + 'upload/batch', + data, + { headers } + ) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response ) + }) + // a property declared as type "array" is an array even for a single value + it("was uploaded", function () { + expect(uploadResponse.status).to.equal(201) + expect(uploadResponse.data.uploaded).to.deep.equal([ + downloadApiEndpoint + fileName + ]) + }) + it('can be retrieved', async function () { + const res = await util.axios.get(uploadResponse.data.uploaded[0], { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data.toString()).to.eql(fileContent, 'File content differs') + }) + }) + describe("with two files", function () { let uploadResponse const data = new FormData()