Skip to content

Commit a6a74c3

Browse files
SentryManrbygrave
andauthored
Add a FileUpload Plugin (#303)
* start * more test * Create module-info.java * final * cleanup * Update pom.xml * Update module-info.java * Update module-info.java * Update MultipartConfig.java * create cache directories * Update MultipartConfig.java * Update MultipartConfig.java * Update MultipartFormParser.java * autocloseable * Update MultiPart.java * Remove headers.clear(); as it is always empty * final fields and modifiers * final NoSyncBufferedOutputStream, so protected fields can be private * SwapStream - use final field for file * Update MultipartFormParser.java --------- Co-authored-by: Rob Bygrave <robin.bygrave@gmail.com>
1 parent 12740ae commit a6a74c3

File tree

13 files changed

+1122
-3
lines changed

13 files changed

+1122
-3
lines changed

avaje-jex-file-upload/pom.xml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0"
2+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>io.avaje</groupId>
7+
<artifactId>avaje-jex-parent</artifactId>
8+
<version>3.3-RC4</version>
9+
</parent>
10+
<artifactId>avaje-jex-file-upload</artifactId>
11+
<name>avaje-jex-file-upload</name>
12+
13+
<dependencies>
14+
<dependency>
15+
<groupId>io.avaje</groupId>
16+
<artifactId>avaje-jex</artifactId>
17+
<version>${project.version}</version>
18+
</dependency>
19+
20+
<dependency>
21+
<groupId>io.avaje</groupId>
22+
<artifactId>avaje-jex-test</artifactId>
23+
<version>${project.version}</version>
24+
<scope>test</scope>
25+
</dependency>
26+
</dependencies>
27+
28+
</project>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.avaje.jex.file.upload;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
import java.nio.charset.Charset;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import io.avaje.jex.http.Context;
12+
13+
final class DFileUploadService implements FileUploadService {
14+
15+
private final MultipartConfig multipartConfig;
16+
private final Context ctx;
17+
private Map<String, List<MultiPart>> uploadedFilesMap;
18+
19+
DFileUploadService(MultipartConfig multipartConfig, Context ctx) {
20+
this.multipartConfig = multipartConfig;
21+
this.ctx = ctx;
22+
}
23+
24+
private void ensureParsed() {
25+
if (uploadedFilesMap == null) {
26+
var contentType = ctx.contentType();
27+
if (contentType == null || !contentType.startsWith("multipart/form-data")) {
28+
uploadedFilesMap = Map.of();
29+
return;
30+
}
31+
try {
32+
uploadedFilesMap = MultipartFormParser.parse(charset(), contentType, ctx, multipartConfig);
33+
} catch (IOException e) {
34+
throw new UncheckedIOException(e);
35+
}
36+
}
37+
}
38+
39+
@Override
40+
public MultiPart uploadedFile(String fileName) {
41+
ensureParsed();
42+
var files = uploadedFilesMap.get(fileName);
43+
return files != null && !files.isEmpty() ? files.get(0) : null;
44+
}
45+
46+
@Override
47+
public List<MultiPart> uploadedFiles(String fileName) {
48+
ensureParsed();
49+
var files = uploadedFilesMap.get(fileName);
50+
return files != null ? files : java.util.Collections.emptyList();
51+
}
52+
53+
@Override
54+
public List<MultiPart> uploadedFiles() {
55+
ensureParsed();
56+
List<MultiPart> all = new ArrayList<>();
57+
for (List<MultiPart> parts : uploadedFilesMap.values()) {
58+
all.addAll(parts);
59+
}
60+
return all;
61+
}
62+
63+
@Override
64+
public Map<String, List<MultiPart>> uploadedFileMap() {
65+
ensureParsed();
66+
return uploadedFilesMap;
67+
}
68+
69+
private Charset charset() {
70+
return parseCharset(ctx.header("Content-type"));
71+
}
72+
73+
private static Charset parseCharset(String header) {
74+
if (header != null) {
75+
for (String val : header.split(";")) {
76+
val = val.trim();
77+
if (val.regionMatches(true, 0, "charset", 0, "charset".length())) {
78+
return Charset.forName(val.split("=")[1].trim());
79+
}
80+
}
81+
}
82+
return StandardCharsets.ISO_8859_1;
83+
}
84+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.avaje.jex.file.upload;
2+
3+
import java.util.function.Consumer;
4+
5+
import io.avaje.jex.Jex;
6+
import io.avaje.jex.spi.JexPlugin;
7+
8+
/**
9+
* A plugin for handling file uploads within the Jex framework.
10+
*
11+
* <p>This plugin sets up a {@link FileUploadService} accessible via the request context, which
12+
* simplifies the process of handling multipart form data.
13+
*
14+
* @see MultipartConfig
15+
* @see FileUploadService
16+
*/
17+
public final class FileUploadPlugin implements JexPlugin {
18+
19+
private final MultipartConfig multipartConfig;
20+
21+
private FileUploadPlugin(MultipartConfig multipartConfig) {
22+
this.multipartConfig = multipartConfig;
23+
multipartConfig.cacheDirectory().toFile().mkdirs();
24+
}
25+
26+
/**
27+
* Creates and configures a new FileUploadPlugin using a consumer.
28+
*
29+
* @param consumer A consumer to configure the {@link MultipartConfig}.
30+
* @return A new FileUploadPlugin instance.
31+
*/
32+
public static FileUploadPlugin create(Consumer<MultipartConfig> consumer) {
33+
var config = new MultipartConfig();
34+
consumer.accept(config);
35+
return new FileUploadPlugin(config);
36+
}
37+
38+
/**
39+
* Creates a new FileUploadPlugin with default settings.
40+
*
41+
* @return A new FileUploadPlugin instance with default configuration.
42+
*/
43+
public static FileUploadPlugin create() {
44+
return new FileUploadPlugin(new MultipartConfig());
45+
}
46+
47+
/**
48+
* Applies the plugin to the Jex instance.
49+
*
50+
* <p>This method registers a 'before' handler that creates and adds a {@link FileUploadService}
51+
* instance to the request attributes for each incoming request.
52+
*
53+
* @param jex The Jex instance to which the plugin is being applied.
54+
*/
55+
@Override
56+
public void apply(Jex jex) {
57+
jex.before(
58+
ctx ->
59+
ctx.attribute(FileUploadService.class, new DFileUploadService(multipartConfig, ctx)));
60+
}
61+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.avaje.jex.file.upload;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
/** Provides methods for accessing uploaded files from a multipart HTTP request. */
7+
public interface FileUploadService {
8+
9+
/**
10+
* Retrieves the first uploaded file with the specified form field name.
11+
*
12+
* <p>This is useful for form fields that are expected to contain a single file.
13+
*
14+
* @param fileName The name of the form field associated with the uploaded file.
15+
* @return The {@link MultiPart} object representing the first file, or {@code null} if no file
16+
* with that name is found.
17+
*/
18+
MultiPart uploadedFile(String fileName);
19+
20+
/**
21+
* Retrieves a list of all uploaded files with the specified form field name.
22+
*
23+
* @param fileName The name of the form field associated with the uploaded files.
24+
* @return A {@link List} of {@link MultiPart} objects, or an empty list if no files with that
25+
* name are found.
26+
*/
27+
List<MultiPart> uploadedFiles(String fileName);
28+
29+
/**
30+
* Retrieves a list of all uploaded files from the request, regardless of their form field name.
31+
*
32+
* @return A {@link List} of all {@link MultiPart} objects that are files, or an empty list if no
33+
* files were uploaded.
34+
*/
35+
List<MultiPart> uploadedFiles();
36+
37+
/**
38+
* Retrieves a map of all uploaded files, grouped by their form field name.
39+
*
40+
* <p>The map's key is the name of the form field, and the value is a list of {@link MultiPart}
41+
* objects uploaded under that field name. If the request is not a multipart request, this method
42+
* will return an empty map.
43+
*
44+
* @return A {@link Map} where keys are form field names and values are lists of {@link MultiPart}
45+
* files. Returns an empty map for non-multipart requests.
46+
*/
47+
Map<String, List<MultiPart>> uploadedFileMap();
48+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.avaje.jex.file.upload;
2+
3+
import java.io.File;
4+
import java.nio.file.Files;
5+
6+
/**
7+
* A multipart part. Closing deletes the uploaded file
8+
*
9+
* <p>either data or file will be non-null, but not both.
10+
*
11+
* @param contentType the content type of the data
12+
* @param filename the form provided filename
13+
* @param file points to the uploaded file data (the name may differ from filename). This file is
14+
* marked as delete on exit.
15+
* @param data if contains the part data as a String.
16+
*/
17+
public record MultiPart(String contentType, String filename, String data, File file)
18+
implements AutoCloseable {
19+
20+
/** Delete the file */
21+
@Override
22+
public void close() throws Exception {
23+
if (file != null) {
24+
Files.delete(file.toPath());
25+
}
26+
}
27+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package io.avaje.jex.file.upload;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* Configuration settings for handling multipart file uploads.
7+
*
8+
* <p>This class allows you to customize various aspects of file upload behavior, such as file size
9+
* limits and the location where temporary files are stored.
10+
*/
11+
public final class MultipartConfig {
12+
13+
private String cacheDirectory = System.getProperty("java.io.tmpdir");
14+
private long maxFileSize = -1;
15+
private long maxRequestSize = -1;
16+
private int maxInMemoryFileSize = 1;
17+
18+
MultipartConfig() {}
19+
20+
/**
21+
* Sets the directory where uploaded files exceeding the in-memory size limit will be cached.
22+
*
23+
* <p>If not set, the java's default temporary directory will be used.
24+
*
25+
* @param path The absolute path to the cache directory.
26+
* @see #maxInMemoryFileSize(int, FileSize)
27+
*/
28+
public MultipartConfig cacheDirectory(String path) {
29+
this.cacheDirectory = path;
30+
return this;
31+
}
32+
33+
/**
34+
* Sets the maximum allowed size for a single uploaded file.
35+
*
36+
* <p>A value of -1 indicates no limit.
37+
*
38+
* @param size The maximum size of the file.
39+
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB, GB).
40+
*/
41+
public MultipartConfig maxFileSize(long size, FileSize sizeUnit) {
42+
this.maxFileSize = size * sizeUnit.multiplier();
43+
return this;
44+
}
45+
46+
/**
47+
* Sets the maximum size a file can be before it is written to disk.
48+
*
49+
* <p>A value of -1 indicates no limit.
50+
*
51+
* <p>Files smaller than this size will be kept in memory, which can be faster for small uploads
52+
* but consumes more memory. Files larger than this size will be written to the {@link
53+
* #cacheDirectory(String)}. A value of 0 means all files are written to disk.
54+
*
55+
* @param size The maximum in-memory size of the file.
56+
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB).
57+
*/
58+
public MultipartConfig maxInMemoryFileSize(int size, FileSize sizeUnit) {
59+
this.maxInMemoryFileSize = size * sizeUnit.multiplier();
60+
return this;
61+
}
62+
63+
/**
64+
* Sets the maximum total size for a multipart request, including all files and form data.
65+
*
66+
* <p>A value of -1 indicates no limit.
67+
*
68+
* @param size The maximum size of the entire request.
69+
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB, GB).
70+
*/
71+
public MultipartConfig maxRequestSize(long size, FileSize sizeUnit) {
72+
this.maxRequestSize = size * sizeUnit.multiplier();
73+
return this;
74+
}
75+
76+
/** Represents standard file size units for use in configuration. */
77+
public enum FileSize {
78+
BYTES(1),
79+
KB(1024),
80+
MB(1024 * 1024),
81+
GB(1024 * 1024 * 1024);
82+
83+
private final int multiplier;
84+
85+
FileSize(int multiplier) {
86+
this.multiplier = multiplier;
87+
}
88+
89+
int multiplier() {
90+
return multiplier;
91+
}
92+
}
93+
94+
Path cacheDirectory() {
95+
return Path.of(cacheDirectory);
96+
}
97+
98+
long maxFileSize() {
99+
return maxFileSize;
100+
}
101+
102+
long maxRequestSize() {
103+
return maxRequestSize;
104+
}
105+
106+
int maxInMemoryFileSize() {
107+
return maxInMemoryFileSize;
108+
}
109+
}

0 commit comments

Comments
 (0)