Repository: jooby-project/jooby
Pull request #1936 (2020-08-22 13:17:46)

Allow to generate inline file downloads
This PR makes it possible to generate file downloads with `inline` disposition beside the existing `attachment` option. It provides a convenient API while maintains backward compatibility.


Patch:
diff --git a/docs/asciidoc/responses.adoc b/docs/asciidoc/responses.adoc
index 401d78df0..64a4d4050 100644
--- a/docs/asciidoc/responses.adoc
+++ b/docs/asciidoc/responses.adoc
@@ -118,8 +118,9 @@ automatically close the writer/stream:
 
 === File download
 
-The javadoc:AttachedFile[] is used to generate file downloads, i.e. responses with 
-`Content-Disposition` header.
+The javadoc:FileDownload[] is used to generate file downloads, i.e. responses with 
+`Content-Disposition` header. You can use the convenience subclasses javadoc:AttachedFile[]
+or javadoc:InlineFile[] to set the header value to `attachment` or `inline` respectively.
 
 .File download example
 [source,java,role="primary"]
@@ -130,9 +131,9 @@ The javadoc:AttachedFile[] is used to generate file downloads, i.e. responses wi
     return new AttachedFile(source);               // <1>
   });
   
-  get("/download-stream", ctx -> {
+  get("/view-stream", ctx -> {
     InputStream source = ...;
-    return new AttachedFile("myfile.txt", source); // <2>
+    return new InlineFile("myfile.txt", source); // <2>
   });
 }
 ----
@@ -146,9 +147,9 @@ The javadoc:AttachedFile[] is used to generate file downloads, i.e. responses wi
     AttachedFile(source)                // <1>
   }
 
-  get("/download-stream") {
+  get("/view-stream") {
     val source = ...
-    AttachedFile("myfile.txt", source)  // <2>
+    InlineFile("myfile.txt", source)  // <2>
   }
 }
 ----
@@ -156,6 +157,39 @@ The javadoc:AttachedFile[] is used to generate file downloads, i.e. responses wi
 <1> Send a download from an `InputStream`
 <2> Send a download from a `File`
 
+Another possibility is to use one of the static builder methods of `FileDownload` and specify
+the download type (attachment or inline) later.
+
+.File download with builder method
+[source,java,role="primary"]
+----
+FileDownload.Builder produceDownload(Context ctx) {
+  return FileDownload.build(...);
+}
+
+{
+  get("/view", ctx -> produceDownload(ctx).inline());
+  
+  get("/download", ctx -> produceDownload(ctx).attachment());
+}
+----
+
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+fun produceDownload(ctx: Context) = FileDownload.build(...)
+
+{
+  get("/view") {
+    produceDownload(it).inline()
+  }
+
+  get("/download") {
+    produceDownload(it).attachment()
+  }
+}
+----
+
 === NonBlocking
 
 Non-blocking responses are a new feature of Jooby 2.x.
@@ -646,5 +680,5 @@ Family of send methods include:
 - javadoc:Context[send, java.nio.file.Path]
 - javadoc:Context[send, java.io.File]
 - javadoc:Context[send, java.nio.channels.FileChannel]
-- javadoc:Context[send, io.jooby.AttachedFile]
+- javadoc:Context[send, io.jooby.FileDownload]
 - javadoc:Context[send, io.jooby.StatusCode]
diff --git a/jooby/src/main/java/io/jooby/AttachedFile.java b/jooby/src/main/java/io/jooby/AttachedFile.java
index 9252aeed8..a19767d26 100644
--- a/jooby/src/main/java/io/jooby/AttachedFile.java
+++ b/jooby/src/main/java/io/jooby/AttachedFile.java
@@ -5,39 +5,18 @@
  */
 package io.jooby;
 
-import org.apache.commons.io.FilenameUtils;
-
 import javax.annotation.Nonnull;
-import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.nio.file.Files;
 import java.nio.file.Path;
 
 /**
- * Represent a file attachment response.
+ * Represents a file attachment response.
  *
  * @author edgar
  * @since 2.0.0
  */
-public class AttachedFile {
-
-  private static final String CONTENT_DISPOSITION = "attachment;filename=\"%s\"";
-  private static final String FILENAME_STAR = ";filename*=%s''%s";
-
-  private static final String CHARSET = "UTF-8";
-
-  private final long fileSize;
-
-  private final MediaType contentType;
-
-  private final String fileName;
-
-  private final String contentDisposition;
-
-  private final InputStream content;
+public class AttachedFile extends FileDownload {
 
   /**
    * Creates a new file attachment.
@@ -47,21 +26,7 @@ public class AttachedFile {
    * @param fileSize File size or <code>-1</code> if unknown.
    */
   public AttachedFile(@Nonnull InputStream content, @Nonnull String fileName, long fileSize) {
-    try {
-      this.fileName = FilenameUtils.getName(fileName);
-      this.contentType = MediaType.byFile(this.fileName);
-      String filenameStar = URLEncoder.encode(this.fileName, CHARSET).replaceAll("\\+", "%20");
-      if (this.fileName.equals(filenameStar)) {
-        this.contentDisposition = String.format(CONTENT_DISPOSITION, this.fileName);
-      } else {
-        this.contentDisposition = String.format(CONTENT_DISPOSITION, this.fileName) + String
-            .format(FILENAME_STAR, CHARSET, filenameStar);
-      }
-      this.content = content;
-      this.fileSize = fileSize;
-    } catch (UnsupportedEncodingException x) {
-      throw SneakyThrows.propagate(x);
-    }
+    super(Mode.ATTACHMENT, content, fileName, fileSize);
   }
 
   /**
@@ -71,7 +36,7 @@ public class AttachedFile {
    * @param fileName Filename.
    */
   public AttachedFile(@Nonnull InputStream content, @Nonnull String fileName) {
-    this(content, fileName, -1);
+    super(Mode.ATTACHMENT, content, fileName);
   }
 
   /**
@@ -82,7 +47,7 @@ public class AttachedFile {
    * @throws IOException For IO exception while reading file.
    */
   public AttachedFile(@Nonnull Path file, @Nonnull String fileName) throws IOException {
-    this(new FileInputStream(file.toFile()), fileName, Files.size(file));
+    super(Mode.ATTACHMENT, file, fileName);
   }
 
   /**
@@ -92,54 +57,6 @@ public class AttachedFile {
    * @throws IOException For IO exception while reading file.
    */
   public AttachedFile(@Nonnull Path file) throws IOException {
-    this(file, file.getFileName().toString());
-  }
-
-  /**
-   * File size or <code>-1</code> if unknown.
-   *
-   * @return File size or <code>-1</code> if unknown.
-   */
-  public long getFileSize() {
-    return fileSize;
-  }
-
-  /**
-   * File content type.
-   *
-   * @return File content type.
-   */
-  public MediaType getContentType() {
-    return contentType;
-  }
-
-  /**
-   * File name.
-   * @return File name.
-   */
-  public String getFileName() {
-    return fileName;
-  }
-
-  /**
-   * Content disposition header.
-   *
-   * @return Content disposition header.
-   */
-  public String getContentDisposition() {
-    return contentDisposition;
-  }
-
-  /**
-   * File content.
-   *
-   * @return File content.
-   */
-  public InputStream stream() {
-    return content;
-  }
-
-  @Override public String toString() {
-    return fileName;
+    super(Mode.ATTACHMENT, file);
   }
 }
diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java
index 2bae63a54..bc01c134e 100644
--- a/jooby/src/main/java/io/jooby/Context.java
+++ b/jooby/src/main/java/io/jooby/Context.java
@@ -1203,12 +1203,12 @@ public interface Context extends Registry {
   @Nonnull Context send(@Nonnull InputStream input);
 
   /**
-   * Send a file attached response.
+   * Send a file download response.
    *
-   * @param file Attached file.
+   * @param file File download.
    * @return This context.
    */
-  @Nonnull Context send(@Nonnull AttachedFile file);
+  @Nonnull Context send(@Nonnull FileDownload file);
 
   /**
    * Send a file response.
diff --git a/jooby/src/main/java/io/jooby/DefaultContext.java b/jooby/src/main/java/io/jooby/DefaultContext.java
index 7617b4bdf..3ac09ebd8 100644
--- a/jooby/src/main/java/io/jooby/DefaultContext.java
+++ b/jooby/src/main/java/io/jooby/DefaultContext.java
@@ -506,7 +506,7 @@ public interface DefaultContext extends Context {
     return send(data, StandardCharsets.UTF_8);
   }
 
-  @Override default @Nonnull Context send(@Nonnull AttachedFile file) {
+  @Override default @Nonnull Context send(@Nonnull FileDownload file) {
     setResponseHeader("Content-Disposition", file.getContentDisposition());
     InputStream content = file.stream();
     long length = file.getFileSize();
diff --git a/jooby/src/main/java/io/jooby/FileDownload.java b/jooby/src/main/java/io/jooby/FileDownload.java
new file mode 100644
index 000000000..adea4e6f3
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/FileDownload.java
@@ -0,0 +1,266 @@
+/**
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby;
+
+import org.apache.commons.io.FilenameUtils;
+
+import javax.annotation.Nonnull;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Represents a file download.
+ *
+ * @author edgar
+ * @author imeszaros
+ * @since 2.9.0
+ */
+public class FileDownload {
+
+  /**
+   * Download mode.
+   */
+  public enum Mode {
+
+    /** Value indicating that the file can be displayed inside the Web page, or as the Web page. */
+    INLINE("inline"),
+
+    /** Value indicating that the file should be downloaded; most browsers present a 'Save as' dialog. */
+    ATTACHMENT("attachment");
+
+    final String value;
+
+    Mode(String value) {
+      this.value = value;
+    }
+  }
+
+  private static final String CONTENT_DISPOSITION = "%s;filename=\"%s\"";
+  private static final String FILENAME_STAR = ";filename*=%s''%s";
+
+  private static final String CHARSET = "UTF-8";
+
+  private final long fileSize;
+
+  private final MediaType contentType;
+
+  private final String fileName;
+
+  private final String contentDisposition;
+
+  private final InputStream content;
+
+  /**
+   * Creates a new file attachment.
+   *
+   * @param mode Download mode.
+   * @param content File content.
+   * @param fileName Filename.
+   * @param fileSize File size or <code>-1</code> if unknown.
+   */
+  public FileDownload(Mode mode, @Nonnull InputStream content, @Nonnull String fileName, long fileSize) {
+    try {
+      this.fileName = FilenameUtils.getName(fileName);
+      this.contentType = MediaType.byFile(this.fileName);
+      String filenameStar = URLEncoder.encode(this.fileName, CHARSET).replaceAll("\\+", "%20");
+      if (this.fileName.equals(filenameStar)) {
+        this.contentDisposition = String.format(CONTENT_DISPOSITION, mode.value, this.fileName);
+      } else {
+        this.contentDisposition = String.format(CONTENT_DISPOSITION, mode.value, this.fileName) + String
+            .format(FILENAME_STAR, CHARSET, filenameStar);
+      }
+      this.content = content;
+      this.fileSize = fileSize;
+    } catch (UnsupportedEncodingException x) {
+      throw SneakyThrows.propagate(x);
+    }
+  }
+
+  /**
+   * Creates a new file attachment.
+   *
+   * @param mode Download mode.
+   * @param content File content.
+   * @param fileName Filename.
+   */
+  public FileDownload(Mode mode, @Nonnull InputStream content, @Nonnull String fileName) {
+    this(mode, content, fileName, -1);
+  }
+
+  /**
+   * Creates a new file attachment.
+   *
+   * @param mode Download mode.
+   * @param file File content.
+   * @param fileName Filename.
+   * @throws IOException For IO exception while reading file.
+   */
+  public FileDownload(Mode mode, @Nonnull Path file, @Nonnull String fileName) throws IOException {
+    this(mode, new FileInputStream(file.toFile()), fileName, Files.size(file));
+  }
+
+  /**
+   * Creates a new file attachment.
+   *
+   * @param mode Download mode.
+   * @param file File content.
+   * @throws IOException For IO exception while reading file.
+   */
+  public FileDownload(Mode mode, @Nonnull Path file) throws IOException {
+    this(mode, file, file.getFileName().toString());
+  }
+
+  /**
+   * File size or <code>-1</code> if unknown.
+   *
+   * @return File size or <code>-1</code> if unknown.
+   */
+  public long getFileSize() {
+    return fileSize;
+  }
+
+  /**
+   * File content type.
+   *
+   * @return File content type.
+   */
+  public MediaType getContentType() {
+    return contentType;
+  }
+
+  /**
+   * File name.
+   * @return File name.
+   */
+  public String getFileName() {
+    return fileName;
+  }
+
+  /**
+   * Content disposition header.
+   *
+   * @return Content disposition header.
+   */
+  public String getContentDisposition() {
+    return contentDisposition;
+  }
+
+  /**
+   * File content.
+   *
+   * @return File content.
+   */
+  public InputStream stream() {
+    return content;
+  }
+
+  @Override public String toString() {
+    return fileName;
+  }
+
+  /**
+   * Allows creating a {@link FileDownload} with
+   * the specified {@link Mode}.
+   */
+  public interface Builder {
+
+    /**
+     * Creates a {@link FileDownload} with
+     * the specified {@link Mode}.
+     *
+     * @param mode the {@link Mode}
+     * @return a {@link FileDownload} with the specified mode
+     */
+    FileDownload build(Mode mode);
+
+    /**
+     * Creates an attached {@link FileDownload}.
+     *
+     * @return a {@link FileDownload} with {@link Mode#ATTACHMENT}
+     */
+    default FileDownload attachment() {
+      return build(Mode.ATTACHMENT);
+    }
+
+    /**
+     * Creates an inline {@link FileDownload}.
+     *
+     * @return a {@link FileDownload} with {@link Mode#INLINE}
+     */
+    default FileDownload inline() {
+      return build(Mode.INLINE);
+    }
+  }
+
+  /**
+   * Creates a builder with the specified content which can be used to create
+   * either a {@link FileDownload} with any {@link Mode}.
+   *
+   * @param content File content.
+   * @param fileName Filename.
+   * @param fileSize File size or <code>-1</code> if unknown.
+   *
+   * @return a {@link Builder} with the specified content
+   */
+  public static Builder build(@Nonnull InputStream content, @Nonnull String fileName, long fileSize) {
+    return mode -> new FileDownload(mode, content, fileName, fileSize);
+  }
+
+  /**
+   * Creates a builder with the specified content which can be used to create
+   * either a {@link FileDownload} with any {@link Mode}.
+   *
+   * @param content File content.
+   * @param fileName Filename.
+   *
+   * @return a {@link Builder} with the specified content
+   */
+  public static Builder build(@Nonnull InputStream content, @Nonnull String fileName) {
+    return mode -> new FileDownload(mode, content, fileName);
+  }
+
+  /**
+   * Creates a builder with the specified content which can be used to create
+   * either a {@link FileDownload} with any {@link Mode}.
+   *
+   * @param file File content.
+   * @param fileName Filename.
+   *
+   * @return a {@link Builder} with the specified content
+   */
+  public static Builder build(@Nonnull Path file, @Nonnull String fileName) {
+    return mode -> {
+      try {
+        return new FileDownload(mode, file, fileName);
+      } catch (IOException e) {
+        throw SneakyThrows.propagate(e);
+      }
+    };
+  }
+
+  /**
+   * Creates a builder with the specified content which can be used to create
+   * either a {@link FileDownload} with any {@link Mode}.
+   *
+   * @param file File content.
+   *
+   * @return a {@link Builder} with the specified content
+   */
+  public static Builder build(@Nonnull Path file) {
+    return mode -> {
+      try {
+        return new FileDownload(mode, file);
+      } catch (IOException e) {
+        throw SneakyThrows.propagate(e);
+      }
+    };
+  }
+}
diff --git a/jooby/src/main/java/io/jooby/ForwardingContext.java b/jooby/src/main/java/io/jooby/ForwardingContext.java
index bed12842c..5b2363c28 100644
--- a/jooby/src/main/java/io/jooby/ForwardingContext.java
+++ b/jooby/src/main/java/io/jooby/ForwardingContext.java
@@ -578,7 +578,7 @@ public class ForwardingContext implements Context {
     return this;
   }
 
-  @Nonnull @Override public Context send(@Nonnull AttachedFile file) {
+  @Nonnull @Override public Context send(@Nonnull FileDownload file) {
     ctx.send(file);
     return this;
   }
diff --git a/jooby/src/main/java/io/jooby/InlineFile.java b/jooby/src/main/java/io/jooby/InlineFile.java
new file mode 100644
index 000000000..7e9d02a9c
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/InlineFile.java
@@ -0,0 +1,62 @@
+/**
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby;
+
+import javax.annotation.Nonnull;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+
+/**
+ * Represents an inline file response.
+ *
+ * @author edgar
+ * @since 2.0.0
+ */
+public class InlineFile extends FileDownload {
+
+  /**
+   * Creates a new inline file.
+   *
+   * @param content File content.
+   * @param fileName Filename.
+   * @param fileSize File size or <code>-1</code> if unknown.
+   */
+  public InlineFile(@Nonnull InputStream content, @Nonnull String fileName, long fileSize) {
+    super(Mode.INLINE, content, fileName, fileSize);
+  }
+
+  /**
+   * Creates a new inline file.
+   *
+   * @param content File content.
+   * @param fileName Filename.
+   */
+  public InlineFile(@Nonnull InputStream content, @Nonnull String fileName) {
+    super(Mode.INLINE, content, fileName);
+  }
+
+  /**
+   * Creates a new inline file.
+   *
+   * @param file File content.
+   * @param fileName Filename.
+   * @throws IOException For IO exception while reading file.
+   */
+  public InlineFile(@Nonnull Path file, @Nonnull String fileName) throws IOException {
+    super(Mode.INLINE, file, fileName);
+  }
+
+  /**
+   * Creates a new inline file.
+   *
+   * @param file File content.
+   * @throws IOException For IO exception while reading file.
+   */
+  public InlineFile(@Nonnull Path file) throws IOException {
+    super(Mode.INLINE, file);
+  }
+}
diff --git a/jooby/src/main/java/io/jooby/internal/HeadContext.java b/jooby/src/main/java/io/jooby/internal/HeadContext.java
index 5563f5383..3010381d7 100644
--- a/jooby/src/main/java/io/jooby/internal/HeadContext.java
+++ b/jooby/src/main/java/io/jooby/internal/HeadContext.java
@@ -5,8 +5,8 @@
  */
 package io.jooby.internal;
 
-import io.jooby.AttachedFile;
 import io.jooby.Context;
+import io.jooby.FileDownload;
 import io.jooby.ForwardingContext;
 import io.jooby.MediaType;
 import io.jooby.MessageEncoder;
@@ -80,7 +80,7 @@ public class HeadContext extends ForwardingContext {
     }
   }
 
-  @Nonnull @Override public Context send(@Nonnull AttachedFile file) {
+  @Nonnull @Override public Context send(@Nonnull FileDownload file) {
     ctx.setResponseLength(file.getFileSize());
     ctx.setResponseType(file.getContentType());
     checkSizeHeaders();
diff --git a/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java
index d2539cf9a..c4fe4b64e 100644
--- a/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java
+++ b/jooby/src/main/java/io/jooby/internal/HttpMessageEncoder.java
@@ -5,8 +5,8 @@
  */
 package io.jooby.internal;
 
-import io.jooby.AttachedFile;
 import io.jooby.Context;
+import io.jooby.FileDownload;
 import io.jooby.MessageEncoder;
 import io.jooby.ModelAndView;
 import io.jooby.StatusCode;
@@ -71,9 +71,9 @@ public class HttpMessageEncoder implements MessageEncoder {
       ctx.send((Path) value);
       return null;
     }
-    /** Attached file: */
-    if (value instanceof AttachedFile) {
-      ctx.send((AttachedFile) value);
+    /** FileDownload: */
+    if (value instanceof FileDownload) {
+      ctx.send((FileDownload) value);
       return null;
     }
     /** Strings: */
diff --git a/jooby/src/main/java/io/jooby/internal/Pipeline.java b/jooby/src/main/java/io/jooby/internal/Pipeline.java
index f1468bcac..ec44b0397 100644
--- a/jooby/src/main/java/io/jooby/internal/Pipeline.java
+++ b/jooby/src/main/java/io/jooby/internal/Pipeline.java
@@ -5,9 +5,9 @@
  */
 package io.jooby.internal;
 
-import io.jooby.AttachedFile;
 import io.jooby.Context;
 import io.jooby.ExecutionMode;
+import io.jooby.FileDownload;
 import io.jooby.Reified;
 import io.jooby.ResponseHandler;
 import io.jooby.Route;
@@ -147,8 +147,8 @@ public class Pipeline {
         .isAssignableFrom(type)) {
       return next(mode, executor, decorate(route, new SendFileChannel(route.getPipeline())), true);
     }
-    /** Attached file: */
-    if (AttachedFile.class.isAssignableFrom(type)) {
+    /** FileDownload: */
+    if (FileDownload.class.isAssignableFrom(type)) {
       return next(mode, executor, decorate(route, new SendAttachment(route.getPipeline())), true);
     }
     /** Strings: */
diff --git a/jooby/src/main/java/io/jooby/internal/ReadOnlyContext.java b/jooby/src/main/java/io/jooby/internal/ReadOnlyContext.java
index b34b5e82c..75dceacfd 100644
--- a/jooby/src/main/java/io/jooby/internal/ReadOnlyContext.java
+++ b/jooby/src/main/java/io/jooby/internal/ReadOnlyContext.java
@@ -5,9 +5,9 @@
  */
 package io.jooby.internal;
 
-import io.jooby.AttachedFile;
 import io.jooby.Context;
 import io.jooby.Cookie;
+import io.jooby.FileDownload;
 import io.jooby.ForwardingContext;
 import io.jooby.MediaType;
 import io.jooby.Sender;
@@ -58,7 +58,7 @@ public class ReadOnlyContext extends ForwardingContext {
     throw new IllegalStateException(MESSAGE);
   }
 
-  @Nonnull @Override public Context send(@Nonnull AttachedFile file) {
+  @Nonnull @Override public Context send(@Nonnull FileDownload file) {
     throw new IllegalStateException(MESSAGE);
   }
 
diff --git a/jooby/src/main/java/io/jooby/internal/handler/SendAttachment.java b/jooby/src/main/java/io/jooby/internal/handler/SendAttachment.java
index 60ce5eb01..c04b0d52c 100644
--- a/jooby/src/main/java/io/jooby/internal/handler/SendAttachment.java
+++ b/jooby/src/main/java/io/jooby/internal/handler/SendAttachment.java
@@ -5,8 +5,8 @@
  */
 package io.jooby.internal.handler;
 
-import io.jooby.AttachedFile;
 import io.jooby.Context;
+import io.jooby.FileDownload;
 import io.jooby.Route;
 
 import javax.annotation.Nonnull;
@@ -24,7 +24,7 @@ public class SendAttachment implements LinkedHandler {
       if (ctx.isResponseStarted()) {
         return result;
       }
-      return ctx.send(((AttachedFile) result));
+      return ctx.send(((FileDownload) result));
     } catch (Throwable x) {
       return ctx.sendError(x);
     }


Test patch:
diff --git a/modules/jooby-test/src/main/java/io/jooby/MockContext.java b/modules/jooby-test/src/main/java/io/jooby/MockContext.java
index ba00dccb1..c5b139e5f 100644
--- a/modules/jooby-test/src/main/java/io/jooby/MockContext.java
+++ b/modules/jooby-test/src/main/java/io/jooby/MockContext.java
@@ -604,7 +604,7 @@ public class MockContext implements DefaultContext {
     return this;
   }
 
-  @Nonnull @Override public Context send(@Nonnull AttachedFile file) {
+  @Nonnull @Override public Context send(@Nonnull FileDownload file) {
     responseStarted = true;
     this.response.setResult(file);
     listeners.run(this);
diff --git a/tests/src/test/java/io/jooby/FeaturedTest.java b/tests/src/test/java/io/jooby/FeaturedTest.java
index 0ff609285..ce4c9a5d7 100644
--- a/tests/src/test/java/io/jooby/FeaturedTest.java
+++ b/tests/src/test/java/io/jooby/FeaturedTest.java
@@ -1526,6 +1526,18 @@ public class FeaturedTest {
         Path file = userdir("src", "test", "resources", "files", "19kb.txt");
         return new AttachedFile(file, ctx.query("name").value(file.getFileName().toString()));
       });
+      app.get("/inline", ctx -> {
+        Path file = userdir("src", "test", "resources", "files", "19kb.txt");
+        return new InlineFile(file, ctx.query("name").value(file.getFileName().toString()));
+      });
+      app.get("/attachment-builder", ctx -> {
+        Path file = userdir("src", "test", "resources", "files", "19kb.txt");
+        return FileDownload.build(file, ctx.query("name").value(file.getFileName().toString())).attachment();
+      });
+      app.get("/inline-builder", ctx -> {
+        Path file = userdir("src", "test", "resources", "files", "19kb.txt");
+        return FileDownload.build(file, ctx.query("name").value(file.getFileName().toString())).inline();
+      });
     }).ready(client -> {
       client.get("/filechannel", rsp -> {
         assertEquals(null, rsp.header("transfer-encoding"));
@@ -1562,6 +1574,36 @@ public class FeaturedTest {
         assertEquals(_19kb, rsp.body().string());
       });
 
+      client.get("/inline", rsp -> {
+        assertEquals(null, rsp.header("transfer-encoding"));
+        assertEquals(Integer.toString(_19kb.length()),
+            rsp.header("content-length").toLowerCase());
+        assertEquals("text/plain;charset=utf-8", rsp.header("content-type").toLowerCase());
+        assertEquals("inline;filename=\"19kb.txt\"",
+            rsp.header("content-disposition").toLowerCase());
+        assertEquals(_19kb, rsp.body().string());
+      });
+
+      client.get("/attachment-builder", rsp -> {
+        assertEquals(null, rsp.header("transfer-encoding"));
+        assertEquals(Integer.toString(_19kb.length()),
+            rsp.header("content-length").toLowerCase());
+        assertEquals("text/plain;charset=utf-8", rsp.header("content-type").toLowerCase());
+        assertEquals("attachment;filename=\"19kb.txt\"",
+            rsp.header("content-disposition").toLowerCase());
+        assertEquals(_19kb, rsp.body().string());
+      });
+
+      client.get("/inline-builder", rsp -> {
+        assertEquals(null, rsp.header("transfer-encoding"));
+        assertEquals(Integer.toString(_19kb.length()),
+            rsp.header("content-length").toLowerCase());
+        assertEquals("text/plain;charset=utf-8", rsp.header("content-type").toLowerCase());
+        assertEquals("inline;filename=\"19kb.txt\"",
+            rsp.header("content-disposition").toLowerCase());
+        assertEquals(_19kb, rsp.body().string());
+      });
+
       client.get("/attachment?name=foo+bar.txt", rsp -> {
         assertEquals(null, rsp.header("transfer-encoding"));
         assertEquals(Integer.toString(_19kb.length()),