File upload is one of those features that sounds simple until you actually implement it — multipart boundaries, streaming vs buffering, size limits, and MIME-type validation all add up quickly. In this tutorial you will build a complete file-upload REST endpoint using Jersey 3.x (JAX-RS 3.1), test it with an HTML form and with curl, enforce a file-size limit, and save uploads to a configurable directory on disk.
Maven Dependencies
<!-- Jersey core -->
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-servlet</artifactId>
<version>3.1.5</version>
</dependency>
<!-- Multipart support -->
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-multipart</artifactId>
<version>3.1.5</version>
</dependency>
<!-- Jackson JSON -->
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>3.1.5</version>
</dependency>
Register the Multipart Feature
import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.server.ResourceConfig;
import jakarta.ws.rs.ApplicationPath;
@ApplicationPath("/api")
public class AppConfig extends ResourceConfig {
public AppConfig() {
packages("com.ankurm");
register(MultiPartFeature.class); // required for multipart/form-data
}
}
Single-File Upload Endpoint
package com.ankurm.upload;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.glassfish.jersey.media.multipart.*;
import java.io.*;
import java.nio.file.*;
import java.util.UUID;
@Path("/files")
public class FileUploadResource {
/** Directory where uploaded files are saved */
private static final Path UPLOAD_DIR = Paths.get(System.getProperty("java.io.tmpdir"), "uploads");
/** Maximum allowed file size: 10 MB */
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024L;
// Ensure upload directory exists at startup
static {
try { Files.createDirectories(UPLOAD_DIR); }
catch (IOException e) { throw new ExceptionInInitializerError(e); }
}
/**
* POST /api/files/upload
* Accepts: multipart/form-data with a "file" part
*/
@POST
@Path("/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
public Response upload(
@FormDataParam("file") InputStream fileStream,
@FormDataParam("file") FormDataContentDisposition fileInfo,
@FormDataParam("description") String description) throws IOException {
if (fileStream == null || fileInfo == null) {
return Response.status(400)
.entity("{ "error": "No file part found in request" }")
.build();
}
// Read entire input into a byte array so we can check size
byte[] bytes = fileStream.readAllBytes();
if (bytes.length > MAX_FILE_SIZE) {
return Response.status(413)
.entity("{ "error": "File exceeds 10 MB limit" }")
.build();
}
// Generate a unique stored filename to avoid collisions
String originalName = fileInfo.getFileName();
String extension = originalName.contains(".") ?
originalName.substring(originalName.lastIndexOf('.')) : "";
String storedName = UUID.randomUUID() + extension;
Path destination = UPLOAD_DIR.resolve(storedName);
Files.write(destination, bytes);
String json = String.format(
"{ "originalName": "%s", "storedAs": "%s"," +
" "sizeBytes": %d, "description": "%s" }",
originalName, storedName, bytes.length,
description != null ? description : ""
);
return Response.status(201).entity(json).build();
}
}
Multiple Files Upload Endpoint
@POST
@Path("/upload-multiple")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
public Response uploadMultiple(FormDataMultiPart multiPart) throws IOException {
var parts = multiPart.getFields("files");
if (parts == null || parts.isEmpty()) {
return Response.status(400)
.entity("{ "error": "No 'files' parts found" }")
.build();
}
StringBuilder results = new StringBuilder("[ ");
for (FormDataBodyPart part : parts) {
FormDataContentDisposition info = part.getFormDataContentDisposition();
byte[] bytes = part.getEntityAs(byte[].class);
String original = info.getFileName();
String ext = original.contains(".") ? original.substring(original.lastIndexOf('.')) : "";
String stored = UUID.randomUUID() + ext;
Files.write(UPLOAD_DIR.resolve(stored), bytes);
results.append(String.format(
"{ "original": "%s", "stored": "%s", "bytes": %d }, ",
original, stored, bytes.length));
}
// Remove trailing comma
if (results.length() > 2) results.setLength(results.length() - 2);
results.append(" ]");
return Response.status(201).entity(results.toString()).build();
}
File Download Endpoint
@GET
@Path("/download/{filename}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public Response download(@PathParam("filename") String filename) throws IOException {
// Sanitise: prevent path traversal
if (filename.contains("..") || filename.contains("/")) {
return Response.status(400).entity("Invalid filename").build();
}
Path file = UPLOAD_DIR.resolve(filename);
if (!Files.exists(file)) {
return Response.status(404).entity("File not found").build();
}
return Response.ok(Files.newInputStream(file))
.header("Content-Disposition", "attachment; filename="" + filename + """)
.build();
}
HTML Form for Testing
<!DOCTYPE html>
<html>
<body>
<h2>Upload a File</h2>
<form action="http://localhost:8080/api/files/upload"
method="post" enctype="multipart/form-data">
<label>File: <input type="file" name="file" required></label><br><br>
<label>Description: <input type="text" name="description"></label><br><br>
<button type="submit">Upload</button>
</form>
</body>
</html>
Testing with curl
# Single file upload
curl -X POST http://localhost:8080/api/files/upload
-F "file=@/path/to/report.pdf"
-F "description=Monthly report"
# Multiple files
curl -X POST http://localhost:8080/api/files/upload-multiple
-F "[email protected]"
-F "[email protected]"
# Download
curl -O http://localhost:8080/api/files/download/<stored-filename>
Sample Response
HTTP/1.1 201 Created
Content-Type: application/json
{
"originalName": "report.pdf",
"storedAs": "3f7a1b2c-...uuid...-a9.pdf",
"sizeBytes": 204800,
"description": "Monthly report"
}
Production Considerations
- Stream large files —
readAllBytes()loads the entire file into heap. For files > 50 MB, stream directly to disk usingFiles.copy(inputStream, destination). - Validate MIME type — check
part.getMediaType()or use Apache Tika to sniff the actual content type; do not trust the client-supplied Content-Type. - Use cloud storage for scalability — in a multi-instance environment save files to AWS S3, Google Cloud Storage, or Azure Blob instead of the local filesystem.
- Scan for malware — pass uploaded files through a virus scanner (e.g. ClamAV) before serving them to other users.
- Enforce authentication — combine with the JWT filter from the Jersey Security Guide to protect the upload endpoint.
See Also
- Secure Your Jersey REST APIs – Authentication & Authorization Guide
- Handling Exceptions in JAX-RS Jersey with ExceptionMapper
- Building a REST API with Spring Boot
Conclusion
Building a file-upload endpoint in Jersey requires registering MultiPartFeature, using @FormDataParam to bind the stream and content-disposition, and generating a UUID-based stored filename to avoid collisions. The same pattern scales from single to multiple files with minimal code changes. For production, swap local disk storage for a cloud object store, enforce strict MIME-type validation, and always authenticate upload requests.