Skip to content

Commit 78e853b

Browse files
committed
Implement file attachment (a.k.a file download)
1 parent 62ff5ea commit 78e853b

File tree

8 files changed

+196
-3
lines changed

8 files changed

+196
-3
lines changed

TODO

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
* api doc
33
* tests and coverage
44
* checkstyle for public API
5+
6+
* cookie (request and response)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package io.jooby;
2+
3+
import org.apache.commons.io.FilenameUtils;
4+
5+
import java.io.FileInputStream;
6+
import java.io.IOException;
7+
import java.io.InputStream;
8+
import java.io.UnsupportedEncodingException;
9+
import java.net.URLEncoder;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
13+
public class AttachedFile {
14+
15+
private static final String CONTENT_DISPOSITION = "attachment;filename=\"%s\"";
16+
private static final String FILENAME_STAR = ";filename*=%s''%s";
17+
18+
private static final String CHARSET = "UTF-8";
19+
20+
private final long length;
21+
private final MediaType contentType;
22+
23+
private String filename;
24+
25+
private String contentDisposition;
26+
27+
private InputStream content;
28+
29+
public AttachedFile(String filename, InputStream content, long length) {
30+
try {
31+
this.filename = FilenameUtils.getName(filename);
32+
this.contentType = MediaType.byFile(this.filename);
33+
String filenameStar = URLEncoder.encode(this.filename, CHARSET).replaceAll("\\+", "%20");
34+
if (this.filename.equals(filenameStar)) {
35+
this.contentDisposition = String.format(CONTENT_DISPOSITION, this.filename);
36+
} else {
37+
this.contentDisposition = String.format(CONTENT_DISPOSITION, this.filename) + String
38+
.format(FILENAME_STAR, CHARSET, filenameStar);
39+
}
40+
this.content = content;
41+
this.length = length;
42+
} catch (UnsupportedEncodingException x) {
43+
throw Throwing.sneakyThrow(x);
44+
}
45+
}
46+
47+
public AttachedFile(String filename, InputStream content) {
48+
this(filename, content, -1);
49+
}
50+
51+
public AttachedFile(String filename, Path file) throws IOException {
52+
this(filename, new FileInputStream(file.toFile()), Files.size(file));
53+
}
54+
55+
public AttachedFile(Path file) throws IOException {
56+
this(file.getFileName().toString(), file);
57+
}
58+
59+
public long length() {
60+
return length;
61+
}
62+
63+
public MediaType contentType() {
64+
return contentType;
65+
}
66+
67+
public String filename() {
68+
return filename;
69+
}
70+
71+
public String contentDisposition() {
72+
return contentDisposition;
73+
}
74+
75+
public InputStream content() {
76+
return content;
77+
}
78+
79+
@Override public String toString() {
80+
return filename;
81+
}
82+
}

jooby/src/main/java/io/jooby/Context.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import javax.annotation.Nonnull;
2222
import javax.annotation.Nullable;
23+
import java.io.FileInputStream;
2324
import java.io.IOException;
2425
import java.io.InputStream;
2526
import java.io.OutputStream;
@@ -455,6 +456,22 @@ default long requestLength() {
455456

456457
@Nonnull Context sendStream(@Nonnull InputStream input);
457458

459+
default Context sendAttachment(AttachedFile file) {
460+
header("Content-Disposition", file.contentDisposition());
461+
InputStream content = file.content();
462+
long length = file.length();
463+
if (length > 0) {
464+
responseLength(length);
465+
}
466+
defaultResponseType(file.contentType());
467+
if (content instanceof FileInputStream) {
468+
sendFile(((FileInputStream) content).getChannel());
469+
} else {
470+
sendStream(content);
471+
}
472+
return this;
473+
}
474+
458475
default @Nonnull Context sendFile(@Nonnull Path file) {
459476
try {
460477
return sendFile(FileChannel.open(file));

jooby/src/main/java/io/jooby/internal/Pipeline.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
*/
1616
package io.jooby.internal;
1717

18+
import io.jooby.AttachedFile;
1819
import io.jooby.Context;
1920
import io.jooby.ExecutionMode;
2021
import io.jooby.Reified;
2122
import io.jooby.Route;
2223
import io.jooby.Route.Handler;
2324
import io.jooby.internal.handler.KotlinJobHandler;
25+
import io.jooby.internal.handler.SendAttachment;
2426
import io.jooby.internal.handler.SendByteArray;
2527
import io.jooby.internal.handler.SendByteBuf;
2628
import io.jooby.internal.handler.SendByteBuffer;
@@ -150,6 +152,10 @@ public static Handler compute(ClassLoader loader, Route route, ExecutionMode mod
150152
.isAssignableFrom(type)) {
151153
return next(mode, executor, new SendFileChannel(route.pipeline()), true);
152154
}
155+
/** Attached file: */
156+
if (AttachedFile.class.isAssignableFrom(type)) {
157+
return next(mode, executor, new SendAttachment(route.pipeline()), true);
158+
}
153159
/** Strings: */
154160
if (CharSequence.class.isAssignableFrom(type)) {
155161
return next(mode, executor, new SendCharSequence(route.pipeline()), true);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*
14+
* Copyright 2014 Edgar Espina
15+
*/
16+
package io.jooby.internal.handler;
17+
18+
import io.jooby.AttachedFile;
19+
import io.jooby.Context;
20+
import io.jooby.Route;
21+
22+
import javax.annotation.Nonnull;
23+
24+
public class SendAttachment implements NextHandler {
25+
private Route.Handler next;
26+
27+
public SendAttachment(Route.Handler next) {
28+
this.next = next;
29+
}
30+
31+
@Nonnull @Override public Object apply(@Nonnull Context ctx) {
32+
try {
33+
AttachedFile file = (AttachedFile) next.apply(ctx);
34+
return ctx.sendAttachment(file);
35+
} catch (Throwable x) {
36+
return ctx.sendError(x);
37+
}
38+
}
39+
40+
@Override public Route.Handler next() {
41+
return next;
42+
}
43+
}

tests/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
<groupId>org.ow2.asm</groupId>
4343
<artifactId>asm-util</artifactId>
4444
</dependency>
45+
<dependency>
46+
<groupId>commons-io</groupId>
47+
<artifactId>commons-io</artifactId>
48+
</dependency>
4549
<dependency>
4650
<groupId>io.reactivex.rxjava2</groupId>
4751
<artifactId>rxjava</artifactId>

tests/src/test/java/io/jooby/FeaturedTest.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,28 +1340,55 @@ public void sendFile() {
13401340
app.get("/filenotfound", ctx ->
13411341
userdir("src", "test", "resources", "files", "notfound.txt")
13421342
);
1343+
app.get("/attachment", ctx -> {
1344+
Path file = userdir("src", "test", "resources", "files", "19kb.txt");
1345+
return new AttachedFile(ctx.query("name").value(file.getFileName().toString()), file);
1346+
});
13431347
}).ready(client -> {
13441348
client.get("/filechannel", rsp -> {
13451349
assertEquals(null, rsp.header("transfer-encoding"));
1346-
assertEquals(Integer.toString(_19kb.length()), rsp.header("content-length").toLowerCase());
1350+
assertEquals(Integer.toString(_19kb.length()),
1351+
rsp.header("content-length").toLowerCase());
13471352
assertEquals(_19kb, rsp.body().string());
13481353
});
13491354

13501355
client.get("/path", rsp -> {
13511356
assertEquals(null, rsp.header("transfer-encoding"));
1352-
assertEquals(Integer.toString(_19kb.length()), rsp.header("content-length").toLowerCase());
1357+
assertEquals(Integer.toString(_19kb.length()),
1358+
rsp.header("content-length").toLowerCase());
13531359
assertEquals(_19kb, rsp.body().string());
13541360
});
13551361

13561362
client.get("/file", rsp -> {
13571363
assertEquals(null, rsp.header("transfer-encoding"));
1358-
assertEquals(Integer.toString(_19kb.length()), rsp.header("content-length").toLowerCase());
1364+
assertEquals(Integer.toString(_19kb.length()),
1365+
rsp.header("content-length").toLowerCase());
13591366
assertEquals(_19kb, rsp.body().string());
13601367
});
13611368

13621369
client.get("/filenotfound", rsp -> {
13631370
assertEquals(404, rsp.code());
13641371
});
1372+
1373+
client.get("/attachment", rsp -> {
1374+
assertEquals(null, rsp.header("transfer-encoding"));
1375+
assertEquals(Integer.toString(_19kb.length()),
1376+
rsp.header("content-length").toLowerCase());
1377+
assertEquals("text/plain;charset=utf-8", rsp.header("content-type").toLowerCase());
1378+
assertEquals("attachment;filename=\"19kb.txt\"",
1379+
rsp.header("content-disposition").toLowerCase());
1380+
assertEquals(_19kb, rsp.body().string());
1381+
});
1382+
1383+
client.get("/attachment?name=foo+bar.txt", rsp -> {
1384+
assertEquals(null, rsp.header("transfer-encoding"));
1385+
assertEquals(Integer.toString(_19kb.length()),
1386+
rsp.header("content-length").toLowerCase());
1387+
assertEquals("text/plain;charset=utf-8", rsp.header("content-type").toLowerCase());
1388+
assertEquals("attachment;filename=\"foo bar.txt\";filename*=utf-8''foo%20bar.txt",
1389+
rsp.header("content-disposition").toLowerCase());
1390+
assertEquals(_19kb, rsp.body().string());
1391+
});
13651392
});
13661393
}
13671394

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<configuration>
3+
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
4+
<encoder>
5+
<pattern>%-5p [%d{ISO8601}] [%thread] %msg%n</pattern>
6+
</encoder>
7+
</appender>
8+
9+
<root level="INFO">
10+
<appender-ref ref="STDOUT"/>
11+
</root>
12+
</configuration>

0 commit comments

Comments
 (0)