From 536498ae3071cccbcd5bb1e1916dc0ccc3ccd261 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Thu, 16 Jul 2026 18:08:12 -0400 Subject: [PATCH 1/5] [feature] request: add request:get-uploaded-file-headers() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds request:get-uploaded-file-headers($name), which returns the part headers of each uploaded file submitted under a parameter name in a multipart request, as one map(xs:string, xs:string) per uploaded file — aligned with the sequence returned by request:get-uploaded-file-name. This exposes per-part information that was previously discarded, such as a file part's own Content-Type. Header names are keyed as submitted; the empty sequence is returned when the request is not multipart or the parameter is not a file part. Closes https://github.com/eXist-db/exist/issues/6578 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/servlets/HttpRequestWrapper.java | 23 ++++ .../exist/http/servlets/RequestWrapper.java | 15 +++ .../request/GetUploadedFileHeaders.java | 85 +++++++++++++ .../functions/request/RequestModule.java | 1 + .../request/GetUploadedFileHeadersTest.java | 116 ++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java index 587fa300eb1..959acd404e0 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java @@ -604,6 +604,29 @@ public List getUploadedFileName(final String name) { return files; } + @Override + public List> getUploadedFileHeaders(final String name) { + if (!isFormDataParsed) { + return null; + } + + final Object o = params.get(name); + if (o == null) { + return null; + } + + final List parts = getFileItem(o); + final List> fileHeaders = new ArrayList<>(parts.size()); + for (final Part part : parts) { + final Map headers = new LinkedHashMap<>(); + for (final String headerName : part.getHeaderNames()) { + headers.put(headerName, part.getHeader(headerName)); + } + fileHeaders.add(headers); + } + return fileHeaders; + } + /** * @see jakarta.servlet.http.HttpServletRequest#getParameterNames() */ diff --git a/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java index d11eaf45334..9249dba38b1 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/RequestWrapper.java @@ -30,6 +30,7 @@ import java.security.Principal; import java.util.Enumeration; import java.util.List; +import java.util.Map; public interface RequestWrapper { @@ -578,4 +579,18 @@ public interface RequestWrapper { * @return the list of file names. */ List getUploadedFileName(String name); + + /** + * Get the part headers of each uploaded file submitted under a parameter name. + * + * There is one map per uploaded file (in submission order), aligned with the entries + * returned by {@link #getUploadedFileName(String)} and {@link #getFileUploadParam(String)}. + * Each map holds the file part's own headers (for example {@code Content-Type} and + * {@code Content-Disposition}), keyed by header name as submitted. + * + * @param name the parameter name + * + * @return the list of header maps, one per uploaded file. + */ + List> getUploadedFileHeaders(String name); } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java b/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java new file mode 100644 index 00000000000..5c7a277e05f --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/request/GetUploadedFileHeaders.java @@ -0,0 +1,85 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.request; + +import java.util.List; +import java.util.Map; + +import org.exist.dom.QName; +import org.exist.http.servlets.RequestWrapper; +import org.exist.xquery.Cardinality; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.value.FunctionParameterSequenceType; +import org.exist.xquery.value.FunctionReturnSequenceType; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; + +import javax.annotation.Nonnull; + +/** + * Retrieve the part headers of each uploaded file in a multi-part request. + */ +public class GetUploadedFileHeaders extends StrictRequestFunction { + + public final static FunctionSignature signature = + new FunctionSignature( + new QName("get-uploaded-file-headers", RequestModule.NAMESPACE_URI, RequestModule.PREFIX), + "Retrieve the part headers of each uploaded file submitted under a parameter name in a " + + "multi-part request. Returns one map (header name to header value) per uploaded file, in " + + "submission order and aligned with request:get-uploaded-file-name. Header names are keyed " + + "as submitted. Returns the empty sequence if the request is not a multi-part request or the " + + "parameter name does not point to a file part.", + new SequenceType[] { + new FunctionParameterSequenceType("upload-param-name", Type.STRING, Cardinality.EXACTLY_ONE, "The parameter name") + }, + new FunctionReturnSequenceType(Type.MAP_ITEM, Cardinality.ZERO_OR_MORE, "one map of header name to header value per uploaded file")); + + public GetUploadedFileHeaders(final XQueryContext context) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, @Nonnull final RequestWrapper request) + throws XPathException { + final String uploadParamName = args[0].getStringValue(); + final List> fileHeaders = request.getUploadedFileHeaders(uploadParamName); + if (fileHeaders == null || fileHeaders.isEmpty()) { + return Sequence.EMPTY_SEQUENCE; + } + + final ValueSequence result = new ValueSequence(); + for (final Map headers : fileHeaders) { + final MapType map = new MapType(this, context); + for (final Map.Entry header : headers.entrySet()) { + map.add(new StringValue(this, header.getKey()), new StringValue(this, header.getValue())); + } + result.add(map); + } + return result; + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java b/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java index dd48c8027d5..b2099c6cb7d 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/request/RequestModule.java @@ -62,6 +62,7 @@ public class RequestModule extends AbstractInternalModule { new FunctionDef(GetQueryString.signature, GetQueryString.class), new FunctionDef(GetUploadedFile.signatures[0], GetUploadedFile.class), new FunctionDef(GetUploadedFileName.signature, GetUploadedFileName.class), + new FunctionDef(GetUploadedFileHeaders.signature, GetUploadedFileHeaders.class), new FunctionDef(GetUploadedFileSize.signature, GetUploadedFileSize.class), new FunctionDef(GetURI.signatures[0], GetURI.class), new FunctionDef(GetURI.signatures[1], GetURI.class), diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java new file mode 100644 index 00000000000..bda3ea787dd --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java @@ -0,0 +1,116 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.request; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; + +import com.github.mizosoft.methanol.MediaType; +import com.github.mizosoft.methanol.MoreBodyPublishers; +import com.github.mizosoft.methanol.MultipartBodyPublisher; +import org.exist.http.AbstractHttpTest.HttpResponseResult; +import org.exist.http.RESTTest; +import org.exist.xmldb.EXistResource; +import org.exist.xmldb.UserManagementService; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.xmldb.api.DatabaseManager; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.BinaryResource; + +/** + * Tests request:get-uploaded-file-headers() — the file part headers of a multipart upload. + */ +public class GetUploadedFileHeadersTest extends RESTTest { + + private static final String XQUERY = + "xquery version \"3.1\";\n" + + "let $file-headers := request:get-uploaded-file-headers(\"fileUpload\")\n" + + "let $field-headers := request:get-uploaded-file-headers(\"param1\")\n" + + "return string-join((\n" + + " \"file-count=\" || count($file-headers),\n" + + " \"field-count=\" || count($field-headers),\n" + + " for $m in $file-headers\n" + + " for $k in map:keys($m)\n" + + " return \"header:\" || $k || \"=\" || $m($k)\n" + + "), \"|\")"; + private static final String XQUERY_FILENAME = "test-get-uploaded-file-headers.xql"; + + private static final String TEST_FILE_NAME = "helloworld.txt"; + private static final String TEST_FILE_CONTENT = "hello world"; + + private static Collection root; + + @BeforeClass + public static void beforeClass() throws XMLDBException { + root = DatabaseManager.getCollection("xmldb:exist://localhost:" + existWebServer.getPort() + "/xmlrpc/db", "admin", ""); + final BinaryResource res = root.createResource(XQUERY_FILENAME, BinaryResource.class); + ((EXistResource) res).setMimeType("application/xquery"); + res.setContent(XQUERY); + root.storeResource(res); + final UserManagementService ums = root.getService(UserManagementService.class); + ums.chmod(res, 0777); + } + + @AfterClass + public static void afterClass() throws XMLDBException { + final BinaryResource res = (BinaryResource) root.getResource(XQUERY_FILENAME); + root.removeResource(res); + } + + @Test + public void fileHeadersAreExposedAndFieldsHaveNone() throws IOException { + final MultipartBodyPublisher multipart = MultipartBodyPublisher.newBuilder() + .textPart("param1", "value1") + .formPart("fileUpload", TEST_FILE_NAME, + MoreBodyPublishers.ofMediaType( + HttpRequest.BodyPublishers.ofByteArray(TEST_FILE_CONTENT.getBytes(UTF_8)), + MediaType.TEXT_PLAIN)) + .build(); + + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .header("Content-Type", multipart.mediaType().toString()) + .POST(multipart) + .build(); + + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, post)); + assertEquals(200, result.statusCode()); + final String body = result.body(); + + // one map for the single uploaded file, none for the plain form field + assertTrue("expected one file header map: " + body, body.contains("file-count=1")); + assertTrue("plain form field must not report file headers: " + body, body.contains("field-count=0")); + // the file part's own Content-Type is exposed + assertTrue("file part Content-Type should be exposed: " + body, + body.toLowerCase().contains("header:content-type=text/plain")); + // the Content-Disposition (carrying the filename) is exposed + assertTrue("file part Content-Disposition should be exposed: " + body, + body.toLowerCase().contains("header:content-disposition=") && body.contains(TEST_FILE_NAME)); + } +} From 9b1fc1788aaae51a432078b00fcb9daf9330c140 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 17 Jul 2026 12:57:16 -0400 Subject: [PATCH 2/5] [test] request: use a text block for the get-uploaded-file-headers test query Address review feedback: convert the embedded XQuery from string concatenation to a Java text block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../request/GetUploadedFileHeadersTest.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java index bda3ea787dd..b6076068335 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java @@ -50,16 +50,17 @@ public class GetUploadedFileHeadersTest extends RESTTest { private static final String XQUERY = - "xquery version \"3.1\";\n" - + "let $file-headers := request:get-uploaded-file-headers(\"fileUpload\")\n" - + "let $field-headers := request:get-uploaded-file-headers(\"param1\")\n" - + "return string-join((\n" - + " \"file-count=\" || count($file-headers),\n" - + " \"field-count=\" || count($field-headers),\n" - + " for $m in $file-headers\n" - + " for $k in map:keys($m)\n" - + " return \"header:\" || $k || \"=\" || $m($k)\n" - + "), \"|\")"; + """ + xquery version "3.1"; + let $file-headers := request:get-uploaded-file-headers("fileUpload") + let $field-headers := request:get-uploaded-file-headers("param1") + return string-join(( + "file-count=" || count($file-headers), + "field-count=" || count($field-headers), + for $m in $file-headers + for $k in map:keys($m) + return "header:" || $k || "=" || $m($k) + ), "|")"""; private static final String XQUERY_FILENAME = "test-get-uploaded-file-headers.xql"; private static final String TEST_FILE_NAME = "helloworld.txt"; From 710228f93e4f578bd2956f943a391696e3c16953 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 17 Jul 2026 12:57:17 -0400 Subject: [PATCH 3/5] [refactor] Replace HttpRequestWrapper finalize() with a Cleaner Address review feedback: the deprecated finalize() that deleted the temporary copies of uploaded file parts is replaced with a java.lang.ref.Cleaner. The cleaning action holds only the map of temporary files, never the wrapper, so it cannot keep the wrapper from being garbage collected. The temporary-file cache is now eagerly created and final, which removes the previous lazy null-checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../http/servlets/HttpRequestWrapper.java | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java index 959acd404e0..f25e706e251 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; +import java.lang.ref.Cleaner; import java.net.URLDecoder; import java.nio.file.Files; import java.nio.file.Path; @@ -68,6 +69,8 @@ public class HttpRequestWrapper implements RequestWrapper { // to handlers not written to expect a body. private static final Set METHODS_WITH_REQUEST_BODY = Set.of("POST", "PUT", "PATCH"); + private static final Cleaner CLEANER = Cleaner.create(); + private static final Path TMP_DIR; static { try { @@ -93,7 +96,7 @@ public class HttpRequestWrapper implements RequestWrapper { // flag to indicate whether multipart form data was processed private final boolean isFormDataParsed; - @Nullable private Map temporaryUploadedFilesPathCache = null; + private final Map temporaryUploadedFilesPathCache = new HashMap<>(); private boolean parsedQueryString = false; @Nullable private Map queryStringParameters = null; @@ -170,6 +173,10 @@ public HttpRequestWrapper(final HttpServletRequest servletRequest, } LOG.debug("Retrieved {} parameters.", params.size()); + + // Delete any temporary files created for uploaded file parts once this wrapper is + // unreachable. The cleaning action holds only the map, never `this`. + CLEANER.register(this, new TemporaryUploadedFilesCleaner(temporaryUploadedFilesPathCache)); } @Override @@ -554,10 +561,7 @@ public List getFileUploadParam(final String name) { final List parts = getFileItem(o); final List files = new ArrayList<>(parts.size()); for (final Part part : parts) { - Path temporaryUploadedFilePath = null; - if (temporaryUploadedFilesPathCache != null) { - temporaryUploadedFilePath = temporaryUploadedFilesPathCache.get(part); - } + Path temporaryUploadedFilePath = temporaryUploadedFilesPathCache.get(part); if (temporaryUploadedFilePath == null) { try { @@ -570,10 +574,6 @@ public List getFileUploadParam(final String name) { continue; } - if (temporaryUploadedFilesPathCache == null) { - temporaryUploadedFilesPathCache = new HashMap<>(); - } - temporaryUploadedFilesPathCache.put(part, temporaryUploadedFilePath); } @@ -961,22 +961,30 @@ public RequestDispatcher getRequestDispatcher(final String path) { return servletRequest.getRequestDispatcher(path); } - @Override - protected void finalize() { - if (temporaryUploadedFilesPathCache == null) { - return; + /** + * Deletes the temporary files created for uploaded file parts once the owning request + * wrapper is no longer reachable. Registered with a {@link Cleaner}; holds only the map of + * temporary files (never the wrapper), so it does not keep the wrapper alive. + */ + private static final class TemporaryUploadedFilesCleaner implements Runnable { + private final Map temporaryUploadedFiles; + + private TemporaryUploadedFilesCleaner(final Map temporaryUploadedFiles) { + this.temporaryUploadedFiles = temporaryUploadedFiles; } - for (final Map.Entry temporaryUploadedFilePathCache : temporaryUploadedFilesPathCache.entrySet()) { - final Part part = temporaryUploadedFilePathCache.getKey(); - try { - part.delete(); - } catch (final IOException e) { - LOG.error("Unable to delete: {}", part.getSubmittedFileName(), e); - } + @Override + public void run() { + for (final Map.Entry entry : temporaryUploadedFiles.entrySet()) { + final Part part = entry.getKey(); + try { + part.delete(); + } catch (final IOException e) { + LOG.error("Unable to delete: {}", part.getSubmittedFileName(), e); + } - final Path temporaryFile = temporaryUploadedFilePathCache.getValue(); - FileUtils.deleteQuietly(temporaryFile); + FileUtils.deleteQuietly(entry.getValue()); + } } } } From 4781e91c36277e350666f1bee1038bc48ad50e8d Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 18 Jul 2026 12:20:57 -0400 Subject: [PATCH 4/5] [test] request: cover multi-file, missing-param and non-multipart cases Address review remarks on get-uploaded-file-headers: add tests for multiple files under one parameter name (verifying one header map per file, positionally aligned with request:get-uploaded-file-name), an unknown parameter name, and a non-multipart request (which exercises the isFormDataParsed guard). The stored query is parameterized by an "inspect" URL parameter so the cases share one fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../request/GetUploadedFileHeadersTest.java | 115 +++++++++++++----- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java index b6076068335..17c654a5eca 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetUploadedFileHeadersTest.java @@ -46,26 +46,32 @@ /** * Tests request:get-uploaded-file-headers() — the file part headers of a multipart upload. + * + *

The stored query inspects the upload parameter named by the {@code inspect} URL query + * parameter (defaulting to {@code fileUpload}) and reports, for that name: the number of + * uploaded files, the file names (from request:get-uploaded-file-name, to check positional + * alignment), and each file's Content-Type / Content-Disposition (looked up case-insensitively, + * since header-name casing is the servlet container's to decide).

*/ public class GetUploadedFileHeadersTest extends RESTTest { private static final String XQUERY = """ xquery version "3.1"; - let $file-headers := request:get-uploaded-file-headers("fileUpload") - let $field-headers := request:get-uploaded-file-headers("param1") + let $name := request:get-parameter("inspect", "fileUpload") + let $headers := request:get-uploaded-file-headers($name) + let $names := request:get-uploaded-file-name($name) return string-join(( - "file-count=" || count($file-headers), - "field-count=" || count($field-headers), - for $m in $file-headers - for $k in map:keys($m) - return "header:" || $k || "=" || $m($k) + "count=" || count($headers), + "names=[" || string-join($names, ",") || "]", + for $i in 1 to count($headers) + let $m := $headers[$i] + let $ct := $m(map:keys($m)[lower-case(.) = "content-type"]) + let $cd := $m(map:keys($m)[lower-case(.) = "content-disposition"]) + return "file" || $i || ":ct=" || ($ct, "")[1] || ":cd=" || ($cd, "")[1] ), "|")"""; private static final String XQUERY_FILENAME = "test-get-uploaded-file-headers.xql"; - private static final String TEST_FILE_NAME = "helloworld.txt"; - private static final String TEST_FILE_CONTENT = "hello world"; - private static Collection root; @BeforeClass @@ -86,32 +92,79 @@ public static void afterClass() throws XMLDBException { } @Test - public void fileHeadersAreExposedAndFieldsHaveNone() throws IOException { - final MultipartBodyPublisher multipart = MultipartBodyPublisher.newBuilder() + public void singleFileExposesItsHeaders() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() .textPart("param1", "value1") - .formPart("fileUpload", TEST_FILE_NAME, - MoreBodyPublishers.ofMediaType( - HttpRequest.BodyPublishers.ofByteArray(TEST_FILE_CONTENT.getBytes(UTF_8)), - MediaType.TEXT_PLAIN)) + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) + .build(); + + final String result = post(body, "fileUpload"); + assertTrue("one header map for the single uploaded file: " + result, result.contains("count=1")); + assertTrue("file names aligned: " + result, result.contains("names=[helloworld.txt]")); + assertTrue("Content-Type exposed: " + result, result.contains("file1:ct=text/plain:")); + assertTrue("Content-Disposition exposed with filename: " + result, result.contains("filename=\"helloworld.txt\"")); + } + + @Test + public void plainFormFieldHasNoFileHeaders() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .textPart("param1", "value1") + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) + .build(); + + final String result = post(body, "param1"); + assertTrue("a plain form field is not a file part, so no header maps: " + result, result.contains("count=0")); + } + + @Test + public void multipleFilesEachHaveHeadersPositionallyAligned() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .formPart("fileUpload", "first.xml", filePart("", MediaType.APPLICATION_XML)) + .formPart("fileUpload", "second.json", filePart("{}", MediaType.APPLICATION_JSON)) .build(); - final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) - .header("Content-Type", multipart.mediaType().toString()) - .POST(multipart) + final String result = post(body, "fileUpload"); + assertTrue("one header map per uploaded file: " + result, result.contains("count=2")); + assertTrue("both file names present and ordered: " + result, result.contains("names=[first.xml,second.json]")); + // header map i aligns with file name i + assertTrue("first file's headers align with first.xml: " + result, + result.contains("file1:ct=application/xml:") && result.contains("filename=\"first.xml\"")); + assertTrue("second file's headers align with second.json: " + result, + result.contains("file2:ct=application/json:") && result.contains("filename=\"second.json\"")); + } + + @Test + public void nonExistentParameterReturnsEmpty() throws IOException { + final MultipartBodyPublisher body = MultipartBodyPublisher.newBuilder() + .formPart("fileUpload", "helloworld.txt", filePart("hello world", MediaType.TEXT_PLAIN)) .build(); - final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, post)); + final String result = post(body, "doesNotExist"); + assertTrue("an unknown parameter yields the empty sequence: " + result, result.contains("count=0")); + } + + @Test + public void nonMultipartRequestReturnsEmpty() throws IOException { + // A plain GET is not a multipart request, so request:get-uploaded-file-headers() must be empty. + final HttpRequest get = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + "?inspect=fileUpload")) + .GET() + .build(); + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, get)); + assertEquals(200, result.statusCode()); + assertTrue("a non-multipart request yields the empty sequence: " + result.body(), result.body().contains("count=0")); + } + + private static HttpRequest.BodyPublisher filePart(final String content, final MediaType mediaType) { + return MoreBodyPublishers.ofMediaType(HttpRequest.BodyPublishers.ofByteArray(content.getBytes(UTF_8)), mediaType); + } + + private String post(final MultipartBodyPublisher body, final String inspect) throws IOException { + final HttpRequest request = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + "?inspect=" + inspect)) + .header("Content-Type", body.mediaType().toString()) + .POST(body) + .build(); + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, request)); assertEquals(200, result.statusCode()); - final String body = result.body(); - - // one map for the single uploaded file, none for the plain form field - assertTrue("expected one file header map: " + body, body.contains("file-count=1")); - assertTrue("plain form field must not report file headers: " + body, body.contains("field-count=0")); - // the file part's own Content-Type is exposed - assertTrue("file part Content-Type should be exposed: " + body, - body.toLowerCase().contains("header:content-type=text/plain")); - // the Content-Disposition (carrying the filename) is exposed - assertTrue("file part Content-Disposition should be exposed: " + body, - body.toLowerCase().contains("header:content-disposition=") && body.contains(TEST_FILE_NAME)); + return result.body(); } } From 5f4a7ce139069c2f234aedd8752a712706568910 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Wed, 22 Jul 2026 22:20:58 -0400 Subject: [PATCH 5/5] [feature] Convert TemporaryUploadedFilesCleaner to a record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleaner holds a single field (the map of uploaded-file parts to their temporary copies) and implements Runnable, so a record expresses it more concisely — the canonical constructor and field replace the hand-written equivalents, with no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../java/org/exist/http/servlets/HttpRequestWrapper.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java index f25e706e251..2ec2623de07 100644 --- a/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java +++ b/exist-core/src/main/java/org/exist/http/servlets/HttpRequestWrapper.java @@ -966,12 +966,7 @@ public RequestDispatcher getRequestDispatcher(final String path) { * wrapper is no longer reachable. Registered with a {@link Cleaner}; holds only the map of * temporary files (never the wrapper), so it does not keep the wrapper alive. */ - private static final class TemporaryUploadedFilesCleaner implements Runnable { - private final Map temporaryUploadedFiles; - - private TemporaryUploadedFilesCleaner(final Map temporaryUploadedFiles) { - this.temporaryUploadedFiles = temporaryUploadedFiles; - } + private record TemporaryUploadedFilesCleaner(Map temporaryUploadedFiles) implements Runnable { @Override public void run() {