Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions avaje-jex-file-upload/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.avaje</groupId>
<artifactId>avaje-jex-parent</artifactId>
<version>3.3-RC4</version>
</parent>
<artifactId>avaje-jex-file-upload</artifactId>
<name>avaje-jex-file-upload</name>

<dependencies>
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-jex</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-jex-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.avaje.jex.file.upload;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import io.avaje.jex.http.Context;

final class DFileUploadService implements FileUploadService {

private final MultipartConfig multipartConfig;
private final Context ctx;
private Map<String, List<MultiPart>> uploadedFilesMap;

DFileUploadService(MultipartConfig multipartConfig, Context ctx) {
this.multipartConfig = multipartConfig;
this.ctx = ctx;
}

private void ensureParsed() {
if (uploadedFilesMap == null) {
var contentType = ctx.contentType();
if (contentType == null || !contentType.startsWith("multipart/form-data")) {
uploadedFilesMap = Map.of();
return;
}
try {
uploadedFilesMap = MultipartFormParser.parse(charset(), contentType, ctx, multipartConfig);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

@Override
public MultiPart uploadedFile(String fileName) {
ensureParsed();
var files = uploadedFilesMap.get(fileName);
return files != null && !files.isEmpty() ? files.get(0) : null;
}

@Override
public List<MultiPart> uploadedFiles(String fileName) {
ensureParsed();
var files = uploadedFilesMap.get(fileName);
return files != null ? files : java.util.Collections.emptyList();
}

@Override
public List<MultiPart> uploadedFiles() {
ensureParsed();
List<MultiPart> all = new ArrayList<>();
for (List<MultiPart> parts : uploadedFilesMap.values()) {
all.addAll(parts);
}
return all;
}

@Override
public Map<String, List<MultiPart>> uploadedFileMap() {
ensureParsed();
return uploadedFilesMap;
}

private Charset charset() {
return parseCharset(ctx.header("Content-type"));
}

private static Charset parseCharset(String header) {
if (header != null) {
for (String val : header.split(";")) {
val = val.trim();
if (val.regionMatches(true, 0, "charset", 0, "charset".length())) {
return Charset.forName(val.split("=")[1].trim());
}
}
}
return StandardCharsets.ISO_8859_1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.avaje.jex.file.upload;

import java.util.function.Consumer;

import io.avaje.jex.Jex;
import io.avaje.jex.spi.JexPlugin;

/**
* A plugin for handling file uploads within the Jex framework.
*
* <p>This plugin sets up a {@link FileUploadService} accessible via the request context, which
* simplifies the process of handling multipart form data.
*
* @see MultipartConfig
* @see FileUploadService
*/
public final class FileUploadPlugin implements JexPlugin {

private final MultipartConfig multipartConfig;

private FileUploadPlugin(MultipartConfig multipartConfig) {
this.multipartConfig = multipartConfig;
multipartConfig.cacheDirectory().toFile().mkdirs();
}

/**
* Creates and configures a new FileUploadPlugin using a consumer.
*
* @param consumer A consumer to configure the {@link MultipartConfig}.
* @return A new FileUploadPlugin instance.
*/
public static FileUploadPlugin create(Consumer<MultipartConfig> consumer) {
var config = new MultipartConfig();
consumer.accept(config);
return new FileUploadPlugin(config);
}

/**
* Creates a new FileUploadPlugin with default settings.
*
* @return A new FileUploadPlugin instance with default configuration.
*/
public static FileUploadPlugin create() {
return new FileUploadPlugin(new MultipartConfig());
}

/**
* Applies the plugin to the Jex instance.
*
* <p>This method registers a 'before' handler that creates and adds a {@link FileUploadService}
* instance to the request attributes for each incoming request.
*
* @param jex The Jex instance to which the plugin is being applied.
*/
@Override
public void apply(Jex jex) {
jex.before(
ctx ->
ctx.attribute(FileUploadService.class, new DFileUploadService(multipartConfig, ctx)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.avaje.jex.file.upload;

import java.util.List;
import java.util.Map;

/** Provides methods for accessing uploaded files from a multipart HTTP request. */
public interface FileUploadService {

/**
* Retrieves the first uploaded file with the specified form field name.
*
* <p>This is useful for form fields that are expected to contain a single file.
*
* @param fileName The name of the form field associated with the uploaded file.
* @return The {@link MultiPart} object representing the first file, or {@code null} if no file
* with that name is found.
*/
MultiPart uploadedFile(String fileName);

/**
* Retrieves a list of all uploaded files with the specified form field name.
*
* @param fileName The name of the form field associated with the uploaded files.
* @return A {@link List} of {@link MultiPart} objects, or an empty list if no files with that
* name are found.
*/
List<MultiPart> uploadedFiles(String fileName);

/**
* Retrieves a list of all uploaded files from the request, regardless of their form field name.
*
* @return A {@link List} of all {@link MultiPart} objects that are files, or an empty list if no
* files were uploaded.
*/
List<MultiPart> uploadedFiles();

/**
* Retrieves a map of all uploaded files, grouped by their form field name.
*
* <p>The map's key is the name of the form field, and the value is a list of {@link MultiPart}
* objects uploaded under that field name. If the request is not a multipart request, this method
* will return an empty map.
*
* @return A {@link Map} where keys are form field names and values are lists of {@link MultiPart}
* files. Returns an empty map for non-multipart requests.
*/
Map<String, List<MultiPart>> uploadedFileMap();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.avaje.jex.file.upload;

import java.io.File;
import java.nio.file.Files;

/**
* A multipart part. Closing deletes the uploaded file
*
* <p>either data or file will be non-null, but not both.
*
* @param contentType the content type of the data
* @param filename the form provided filename
* @param file points to the uploaded file data (the name may differ from filename). This file is
* marked as delete on exit.
* @param data if contains the part data as a String.
*/
public record MultiPart(String contentType, String filename, String data, File file)
implements AutoCloseable {

/** Delete the file */
@Override
public void close() throws Exception {
if (file != null) {
Files.delete(file.toPath());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package io.avaje.jex.file.upload;

import java.nio.file.Path;

/**
* Configuration settings for handling multipart file uploads.
*
* <p>This class allows you to customize various aspects of file upload behavior, such as file size
* limits and the location where temporary files are stored.
*/
public final class MultipartConfig {

private String cacheDirectory = System.getProperty("java.io.tmpdir");
private long maxFileSize = -1;
private long maxRequestSize = -1;
private int maxInMemoryFileSize = 1;

MultipartConfig() {}

/**
* Sets the directory where uploaded files exceeding the in-memory size limit will be cached.
*
* <p>If not set, the java's default temporary directory will be used.
*
* @param path The absolute path to the cache directory.
* @see #maxInMemoryFileSize(int, FileSize)
*/
public MultipartConfig cacheDirectory(String path) {
this.cacheDirectory = path;
return this;
}

/**
* Sets the maximum allowed size for a single uploaded file.
*
* <p>A value of -1 indicates no limit.
*
* @param size The maximum size of the file.
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB, GB).
*/
public MultipartConfig maxFileSize(long size, FileSize sizeUnit) {
this.maxFileSize = size * sizeUnit.multiplier();
return this;
}

/**
* Sets the maximum size a file can be before it is written to disk.
*
* <p>A value of -1 indicates no limit.
*
* <p>Files smaller than this size will be kept in memory, which can be faster for small uploads
* but consumes more memory. Files larger than this size will be written to the {@link
* #cacheDirectory(String)}. A value of 0 means all files are written to disk.
*
* @param size The maximum in-memory size of the file.
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB).
*/
public MultipartConfig maxInMemoryFileSize(int size, FileSize sizeUnit) {
this.maxInMemoryFileSize = size * sizeUnit.multiplier();
return this;
}

/**
* Sets the maximum total size for a multipart request, including all files and form data.
*
* <p>A value of -1 indicates no limit.
*
* @param size The maximum size of the entire request.
* @param sizeUnit The unit of measurement for the size (e.g., KB, MB, GB).
*/
public MultipartConfig maxRequestSize(long size, FileSize sizeUnit) {
this.maxRequestSize = size * sizeUnit.multiplier();
return this;
}

/** Represents standard file size units for use in configuration. */
public enum FileSize {
BYTES(1),
KB(1024),
MB(1024 * 1024),
GB(1024 * 1024 * 1024);

private final int multiplier;

FileSize(int multiplier) {
this.multiplier = multiplier;
}

int multiplier() {
return multiplier;
}
}

Path cacheDirectory() {
return Path.of(cacheDirectory);
}

long maxFileSize() {
return maxFileSize;
}

long maxRequestSize() {
return maxRequestSize;
}

int maxInMemoryFileSize() {
return maxInMemoryFileSize;
}
}
Loading