Spring Boot
File Download API
Spring Boot serves file downloads through ResponseEntity<Resource>. The Resource abstraction covers local disk files, classpath resources, byte arrays, and S3 objects. This entry covers inline vs attachment delivery, range requests, streaming large files, security, and content-type detection.
Serving a File from Disk
Return ResponseEntity<Resource> with a UrlResource or FileSystemResource pointing to the file path. Set Content-Disposition to attachment to trigger a browser download, or inline to render the file in the browser. Always set Content-Type explicitly — do not rely on the browser to guess it.
Java
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor
public class FileDownloadController {
@Value("${app.storage.location:./uploads}")
private String storageLocation;
// ── Download as attachment (triggers Save As dialog) ──────────────
@GetMapping("/{filename}")
public ResponseEntity<Resource> download(
@PathVariable String filename) throws IOException {
Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
// Security: ensure path stays within storage root
if (!filePath.startsWith(Paths.get(storageLocation).toAbsolutePath())) {
throw new AccessDeniedException("Access denied");
}
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new ResourceNotFoundException("File not found: " + filename);
}
String contentType = Files.probeContentType(filePath);
if (contentType == null) contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.contentLength(resource.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build().toString())
.body(resource);
}
// ── Inline display (PDF, image rendered in browser) ───────────────
@GetMapping("/{filename}/preview")
public ResponseEntity<Resource> preview(
@PathVariable String filename) throws IOException {
Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
Resource resource = new UrlResource(filePath.toUri());
String contentType = Files.probeContentType(filePath);
if (contentType == null) contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline()
.filename(filename, StandardCharsets.UTF_8)
.build().toString())
.body(resource);
}
}Streaming Large Files
For large files, avoid loading the entire content into memory. Return an InputStreamResource and let Spring stream the bytes. For very large or slow downloads, use a StreamingResponseBody to write directly to the servlet output stream on a separate thread without blocking a Tomcat worker.
Java
// ── InputStreamResource — streams without buffering ──────────────────
@GetMapping("/stream/{filename}")
public ResponseEntity<InputStreamResource> stream(
@PathVariable String filename) throws IOException {
Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
long fileSize = Files.size(filePath);
String contentType = Files.probeContentType(filePath);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
contentType != null ? contentType
: MediaType.APPLICATION_OCTET_STREAM_VALUE))
.contentLength(fileSize)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build().toString())
.body(new InputStreamResource(Files.newInputStream(filePath)));
}
// ── StreamingResponseBody — async, non-blocking large file ───────────
@GetMapping("/async/{filename}")
public ResponseEntity<StreamingResponseBody> streamAsync(
@PathVariable String filename) throws IOException {
Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
String contentType = Files.probeContentType(filePath);
StreamingResponseBody body = outputStream -> {
try (InputStream is = Files.newInputStream(filePath)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
outputStream.flush();
}
}
};
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
contentType != null ? contentType
: MediaType.APPLICATION_OCTET_STREAM_VALUE))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build().toString())
.body(body);
}Downloading from S3
Streaming from S3 follows the same pattern — obtain an InputStream from the S3 GetObject response and wrap it in an InputStreamResource. Set Content-Length from the object's metadata to allow browsers and download managers to display progress.
Java
@Service
@RequiredArgsConstructor
public class S3DownloadService {
private final S3Client s3Client;
@Value("${app.storage.bucket}")
private String bucket;
public ResponseEntity<InputStreamResource> download(String key) {
try {
GetObjectRequest request = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
ResponseInputStream<GetObjectResponse> s3Object =
s3Client.getObject(request);
GetObjectResponse metadata = s3Object.response();
String filename = key.substring(key.lastIndexOf('/') + 1);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
metadata.contentType() != null
? metadata.contentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE))
.contentLength(metadata.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build().toString())
.body(new InputStreamResource(s3Object));
} catch (NoSuchKeyException e) {
throw new ResourceNotFoundException("File not found: " + key);
}
}
// ── Pre-signed URL (redirect client directly to S3) ───────────────
public String generatePresignedUrl(String key, Duration expiry) {
try (S3Presigner presigner = S3Presigner.create()) {
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(expiry)
.getObjectRequest(b -> b.bucket(bucket).key(key))
.build();
return presigner.presignGetObject(presignRequest)
.url().toString();
}
}
}Range Requests (Resumable Downloads)
Range requests let clients download a specific byte range — essential for resumable downloads and video streaming. Spring's ResourceHttpRequestHandler handles Range headers automatically when you serve a FileSystemResource or UrlResource. For manual control, parse the Range header and return 206 Partial Content.
Java
// ── Automatic range support via ResourceHttpRequestHandler ───────────
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:./uploads/")
.resourceChain(true);
// Spring automatically handles Range requests for these resources
}
}
// ── Manual range handling in a REST controller ─────────────────────────
@GetMapping("/range/{filename}")
public ResponseEntity<byte[]> downloadRange(
@PathVariable String filename,
@RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader)
throws IOException {
Path filePath = Paths.get(storageLocation).resolve(filename).normalize();
long fileSize = Files.size(filePath);
if (rangeHeader == null) {
// No Range header — return the full file
return ResponseEntity.ok()
.contentLength(fileSize)
.body(Files.readAllBytes(filePath));
}
// Parse "bytes=start-end"
String[] range = rangeHeader.replace("bytes=", "").split("-");
long start = Long.parseLong(range[0]);
long end = range.length > 1 && !range[1].isEmpty()
? Long.parseLong(range[1])
: fileSize - 1;
long length = end - start + 1;
byte[] data = new byte[(int) length];
try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) {
raf.seek(start);
raf.readFully(data);
}
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header(HttpHeaders.CONTENT_RANGE,
"bytes " + start + "-" + end + "/" + fileSize)
.contentLength(length)
.body(data);
}Generating Files On-the-Fly
Some downloads — CSV exports, PDF reports, ZIP archives — are generated at request time rather than retrieved from storage. Build the content in memory for small outputs or stream it for large ones. Use ByteArrayResource for in-memory content.
Java
@RestController
@RequestMapping("/api/v1/exports")
@RequiredArgsConstructor
public class ExportController {
private final UserService userService;
private final ReportService reportService;
// ── CSV export from database query ────────────────────────────────
@GetMapping(value = "/users.csv",
produces = "text/csv")
public ResponseEntity<StreamingResponseBody> exportUsersCsv() {
StreamingResponseBody body = outputStream -> {
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.println("id,name,email,createdAt");
userService.streamAll().forEach(user ->
writer.printf("%d,%s,%s,%s%n",
user.id(), user.name(),
user.email(), user.createdAt()));
writer.flush();
}
};
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("text/csv"))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename("users-" + LocalDate.now() + ".csv",
StandardCharsets.UTF_8)
.build().toString())
.body(body);
}
// ── ZIP archive of multiple files ──────────────────────────────────
@GetMapping("/archive")
public ResponseEntity<StreamingResponseBody> downloadArchive(
@RequestParam List<String> ids) {
StreamingResponseBody body = outputStream -> {
try (ZipOutputStream zip = new ZipOutputStream(outputStream)) {
for (String id : ids) {
byte[] content = reportService.generatePdf(id);
zip.putNextEntry(new ZipEntry("report-" + id + ".pdf"));
zip.write(content);
zip.closeEntry();
}
}
};
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename("reports.zip", StandardCharsets.UTF_8)
.build().toString())
.body(body);
}
}Security and Access Control
Never serve files based on a raw user-supplied path. Always resolve the path against a trusted root and verify the result stays within that root (path traversal check). Enforce authentication and per-file authorization before streaming bytes.
Java
@Service
@RequiredArgsConstructor
public class SecureFileDownloadService {
private final FileMetadataRepository metadataRepo;
@Value("${app.storage.location}")
private String storageRoot;
// ── Resolve by opaque ID, not by filename ─────────────────────────
public ResponseEntity<Resource> download(String fileId,
Authentication auth) throws IOException {
// 1. Look up metadata by opaque ID
FileMetadata meta = metadataRepo.findById(fileId)
.orElseThrow(() -> new ResourceNotFoundException("File not found"));
// 2. Authorisation check
if (!meta.ownerId().equals(auth.getName()) &&
!auth.getAuthorities().contains(
new SimpleGrantedAuthority("ROLE_ADMIN"))) {
throw new AccessDeniedException("You do not have access to this file");
}
// 3. Path traversal guard
Path root = Paths.get(storageRoot).toAbsolutePath().normalize();
Path filePath = root.resolve(meta.storedFilename()).normalize();
if (!filePath.startsWith(root)) {
throw new AccessDeniedException("Invalid file path");
}
// 4. Stream the file
Resource resource = new UrlResource(filePath.toUri());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(meta.contentType()))
.contentLength(resource.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(meta.originalFilename(), StandardCharsets.UTF_8)
.build().toString())
.body(resource);
}
}