Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Uploading files

We want to attach a picture to a blog post. A file does not fit into JSON, so it is sent with a special kind of request — multipart/form-data. The body of such a request is split into parts: one holds the file, others hold ordinary fields. This is how a browser sends a form with an <input type="file"> field, and curl can do the same. Spring collects the file part into a MultipartFile object.

Where to keep files

We save pictures to an uploads folder next to the project and put its path into the settings:

# src/main/resources/application.yaml
spring:
  servlet:
    multipart:
      max-file-size: 2MB
      max-request-size: 2MB
 
blog:
  upload-dir: uploads

max-file-size limits a single file, max-request-size limits the whole request. Without these lines the Spring Boot defaults apply: 1 MB per file and 10 MB per request. Add the uploads/ folder to .gitignore.

A service for files

Disk work lives in a service, as before:

// src/main/java/com/example/blog/ImageStorage.java
package com.example.blog;
 
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Set;
import java.util.UUID;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
 
@Service
public class ImageStorage {
 
    private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg");
 
    private final Path dir;
 
    public ImageStorage(@Value("${blog.upload-dir}") Path dir) throws IOException {
        this.dir = dir;
        Files.createDirectories(dir);
    }
 
    public String save(MultipartFile file) throws IOException {
        if (file.isEmpty() || !ALLOWED.contains(file.getContentType())) {
            throw new IllegalArgumentException("A PNG or JPEG image is required");
        }
        String extension = file.getContentType().equals("image/png") ? ".png" : ".jpg";
        String name = UUID.randomUUID() + extension;
        try (InputStream in = file.getInputStream()) {
            Files.copy(in, dir.resolve(name), StandardCopyOption.REPLACE_EXISTING);
        }
        return name;
    }
 
    public Path find(String name) {
        Path path = dir.resolve(name).normalize();
        if (!path.startsWith(dir) || !Files.isRegularFile(path)) {
            throw new ImageNotFoundException(name);
        }
        return path;
    }
}

We do not use the file name sent by the client: it may contain ../ or someone else's name. Instead the service makes up a random one with UUID. The startsWith(dir) check in find keeps requests inside the uploads folder. ImageNotFoundException is built like PostNotFoundException, and we add two methods to the error handler:

// src/main/java/com/example/blog/ApiExceptionHandler.java
@ExceptionHandler(ImageNotFoundException.class)
public ProblemDetail handleImageNotFound(ImageNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
 
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}

Now a missing file gives a 404 response and an unsuitable one gives 400.

The controller

// src/main/java/com/example/blog/ImageController.java
package com.example.blog;
 
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.Map;
 
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
 
@RestController
@RequestMapping("/api/images")
public class ImageController {
 
    private final ImageStorage storage;
 
    public ImageController(ImageStorage storage) {
        this.storage = storage;
    }
 
    @PostMapping
    public ResponseEntity<Map<String, String>> upload(@RequestParam("file") MultipartFile file) throws IOException {
        String name = storage.save(file);
        String url = "/api/images/" + name;
        return ResponseEntity.created(URI.create(url)).body(Map.of("url", url));
    }
 
    @GetMapping("/{name}")
    public ResponseEntity<Resource> download(@PathVariable String name) {
        Path path = storage.find(name);
        MediaType type = MediaTypeFactory.getMediaType(name).orElse(MediaType.APPLICATION_OCTET_STREAM);
        return ResponseEntity.ok().contentType(type).body(new FileSystemResource(path));
    }
}

@RequestParam("file") takes the part named file from the request. The download method returns a Resource — Spring reads the file itself and sends its bytes. MediaTypeFactory picks the type from the extension, so the browser shows the picture instead of offering to download it.

Checking it

The -F option makes curl send a multipart/form-data request, and @ before a name attaches a file:

curl -i -F "file=@cat.png" http://localhost:8080/api/images
HTTP/1.1 201
Location: /api/images/9cd77aa2-9863-4416-a5bf-62ed5ca41327.png
Content-Type: application/json
 
{"url":"/api/images/9cd77aa2-9863-4416-a5bf-62ed5ca41327.png"}

The file appeared in the uploads folder. Open the address from the response in a browser and you will see the picture. The GET response carries Content-Type: image/png and a Content-Length with the file size.

What the client gets on errors

RequestResponse
A text file instead of a picture400, "detail":"A PNG or JPEG image is required"
A file over 2 MB413 (Payload Too Large) with no body
An unknown name in GET404, "detail":"File nope.png not found"

For a file that is too large the log shows MaxUploadSizeExceededException: Maximum upload size exceeded.

Common mistake

The part name in the request must match the name in @RequestParam. If you send -F "image=@cat.png" while the method expects file, the response is 400, and the log says:

Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required part 'file' is not present.]

The same goes for an HTML form: the field needs name="file", and the form itself needs enctype="multipart/form-data", otherwise the file is not sent.

Thanks for reading!

Donate to the site — we work hard for you.