This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 9f09aebf93 Add RestResponse.downloadAs convenience method
9f09aebf93 is described below
commit 9f09aebf93160bff8d2d8b24b88b632802d5ba42
Author: James Bognar <[email protected]>
AuthorDate: Fri Mar 20 10:50:24 2026 -0400
Add RestResponse.downloadAs convenience method
---
.../java/org/apache/juneau/http/HttpHeaders.java | 15 ++++++++
.../juneau/http/header/ContentDisposition.java | 43 +++++++++++++++++++++
.../java/org/apache/juneau/rest/RestResponse.java | 24 ++++++++++++
.../http/header/ContentDisposition_Test.java | 25 ++++++++++++
.../juneau/rest/RestResponse_DownloadAs_Test.java | 45 ++++++++++++++++++++++
todo/TODO.md | 2 -
6 files changed, 152 insertions(+), 2 deletions(-)
diff --git
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/HttpHeaders.java
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/HttpHeaders.java
index df8a68b20a..a38dd50752 100644
---
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/HttpHeaders.java
+++
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/HttpHeaders.java
@@ -676,6 +676,21 @@ public class HttpHeaders {
return ContentDisposition.of(value);
}
+ /**
+ * Creates a {@link ContentDisposition} header for a response
attachment with a quoted {@code filename} parameter.
+ *
+ * <p>
+ * Equivalent to {@link ContentDisposition#attachment(String)}.
+ * </p>
+ *
+ * @param filename Suggested download name. Must not be <jk>null</jk>,
blank, or contain CR/LF.
+ * @return A non-<jk>null</jk> header.
+ * @throws IllegalArgumentException If {@code filename} is invalid.
+ */
+ public static final ContentDisposition
contentDispositionAttachment(String filename) {
+ return ContentDisposition.attachment(filename);
+ }
+
/**
* Creates a new {@link ContentEncoding} header.
*
diff --git
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/ContentDisposition.java
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/ContentDisposition.java
index 3f427f9c78..a413f9bb2e 100644
---
a/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/ContentDisposition.java
+++
b/juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/http/header/ContentDisposition.java
@@ -16,6 +16,9 @@
*/
package org.apache.juneau.http.header;
+import static org.apache.juneau.commons.utils.StringUtils.isBlank;
+import static org.apache.juneau.commons.utils.ThrowableUtils.illegalArg;
+
import java.util.function.*;
import org.apache.juneau.*;
@@ -62,6 +65,46 @@ public class ContentDisposition extends
BasicStringRangesHeader {
private static final Cache<String,ContentDisposition> CACHE =
Cache.of(String.class, ContentDisposition.class).build();
+ /**
+ * Creates a <l>Content-Disposition</l> header for a response
attachment (browser download / save-as).
+ *
+ * <p>
+ * Produces <code>attachment; filename="..."</code> with the filename
escaped for use inside an HTTP quoted-string
+ * ({@code \} and {@code "} backslash-escaped). CR and LF are rejected
to reduce response-splitting risk.
+ * </p>
+ *
+ * <p>
+ * For non-ASCII filenames, RFC 5987 {@code filename*} is not set;
callers needing full Unicode support may build
+ * a header value manually or extend this API later.
+ * </p>
+ *
+ * @param filename Suggested download name. Must not be <jk>null</jk>,
blank, or contain CR/LF.
+ * @return A non-<jk>null</jk> header.
+ * @throws IllegalArgumentException If {@code filename} is null, blank,
or contains line breaks.
+ */
+ public static ContentDisposition attachment(String filename) {
+ if (filename == null || isBlank(filename))
+ throw illegalArg("Attachment filename must not be null
or blank.");
+ for (var i = 0; i < filename.length(); i++) {
+ var c = filename.charAt(i);
+ if (c == '\r' || c == '\n')
+ throw illegalArg("Attachment filename must not
contain CR or LF characters.");
+ }
+ var escaped = escapeFilenameForQuotedString(filename);
+ return ContentDisposition.of("attachment; filename=\"" +
escaped + "\"");
+ }
+
+ private static String escapeFilenameForQuotedString(String filename) {
+ var sb = new StringBuilder(filename.length() + 8);
+ for (var i = 0; i < filename.length(); i++) {
+ var c = filename.charAt(i);
+ if (c == '\\' || c == '"')
+ sb.append('\\');
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
/**
* Static creator.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
index ee3f2149a9..bc51e40e1c 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestResponse.java
@@ -77,6 +77,7 @@ import jakarta.servlet.http.*;
* <li class='jm'>{@link
RestResponse#setHeader(String,String) setHeader(String,String)}
* <li class='jm'>{@link
RestResponse#setMaxHeaderLength(int) setMaxHeaderLength(int)}
* <li class='jm'>{@link RestResponse#setSafeHeaders()
setSafeHeaders()}
+ * <li class='jm'>{@link RestResponse#downloadAs(String)
downloadAs(String)}
* </ul>
* <li>Methods for setting response bodies:
* <ul class='javatreec'>
@@ -713,6 +714,29 @@ public class RestResponse extends
HttpServletResponseWrapper {
return this;
}
+ /**
+ * Sets <l>Content-Disposition</l> to <js>attachment</js> so browsers
typically download the body, using the given
+ * suggested file name (quoted {@code filename} parameter).
+ *
+ * <p>
+ * Shorthand for {@link #setHeader(Header) setHeader}({@link
ContentDisposition#attachment(String)
+ * ContentDisposition.attachment(filename)}).
+ * </p>
+ *
+ * <h5 class='figure'>Example</h5>
+ * <p class='bjava'>
+ *
<jv>res</jv>.downloadAs(<js>"example.pdf"</js>).setContent(<jv>pdfBytes</jv>);
+ * </p>
+ *
+ * @param filename Suggested download name. Must not be <jk>null</jk>,
blank, or contain CR/LF.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code filename} is invalid.
+ * @see ContentDisposition#attachment(String)
+ */
+ public RestResponse downloadAs(String filename) {
+ return setHeader(contentDispositionAttachment(filename));
+ }
+
/**
* Sets a header on the request.
*
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/http/header/ContentDisposition_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/http/header/ContentDisposition_Test.java
index ae63eb4a7a..16fa0a1407 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/http/header/ContentDisposition_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/http/header/ContentDisposition_Test.java
@@ -19,6 +19,7 @@ package org.apache.juneau.http.header;
import static org.apache.juneau.TestUtils.*;
import static org.apache.juneau.commons.utils.StringUtils.*;
import static org.apache.juneau.http.HttpHeaders.*;
+import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.util.function.*;
@@ -64,6 +65,30 @@ class ContentDisposition_Test extends TestBase {
c.get().header(contentDisposition(()->null)).run().assertContent().isEmpty();
}
+
//------------------------------------------------------------------------------------------------------------------
+ // ContentDisposition.attachment / contentDispositionAttachment
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void b01_attachment_simpleFilename() {
+ assertEquals("attachment; filename=\"example.pdf\"",
ContentDisposition.attachment("example.pdf").getValue());
+ assertEquals("attachment; filename=\"example.pdf\"",
contentDispositionAttachment("example.pdf").getValue());
+ }
+
+ @Test
+ void b02_attachment_escapesQuotesAndBackslashes() {
+ assertEquals("attachment; filename=\"a\\\\b\\\"c.pdf\"",
ContentDisposition.attachment("a\\b\"c.pdf").getValue());
+ }
+
+ @Test
+ void b03_attachment_rejectsNullBlankAndNewlines() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "null
or blank", () -> ContentDisposition.attachment(null));
+ assertThrowsWithMessage(IllegalArgumentException.class, "null
or blank", () -> ContentDisposition.attachment(""));
+ assertThrowsWithMessage(IllegalArgumentException.class, "null
or blank", () -> ContentDisposition.attachment(" "));
+ assertThrowsWithMessage(IllegalArgumentException.class, "CR or
LF", () -> ContentDisposition.attachment("a\nb"));
+ assertThrowsWithMessage(IllegalArgumentException.class, "CR or
LF", () -> ContentDisposition.attachment("a\rb"));
+ }
+
//------------------------------------------------------------------------------------------------------------------
// Helper methods.
//------------------------------------------------------------------------------------------------------------------
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_DownloadAs_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_DownloadAs_Test.java
new file mode 100644
index 0000000000..0c4f436ecb
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/RestResponse_DownloadAs_Test.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest;
+
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link RestResponse#downloadAs(String)}.
+ */
+class RestResponse_DownloadAs_Test extends org.apache.juneau.TestBase {
+
+ @Rest(serializers = JsonSerializer.class)
+ public static class A {
+ @RestGet("/file")
+ public void file(RestResponse res) {
+ res.downloadAs("my report.pdf");
+ res.setContent("OK");
+ }
+ }
+
+ @Test
+ void a01_setsContentDispositionAttachment() throws Exception {
+ var c = MockRestClient.buildLax(A.class);
+ c.get("/file").run()
+ .assertStatus(200)
+
.assertHeader("Content-Disposition").isContains("attachment", "filename=", "my
report.pdf");
+ }
+}
diff --git a/todo/TODO.md b/todo/TODO.md
index a1aba2c607..32578c468a 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -2,8 +2,6 @@
- Update REST server API to use new BeanStore2.
-- Need an easier way to specify this header: Content-Disposition: attachment;
filename="example.pdf"
-
- A comprehensive plan for handling large data sets using Suppliers and
Consumers?
- CSV format supports property names in headers (make sure it can work with
Suppliers and Consumers above)