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)

Reply via email to