Spring Framework Logo

Upload, resize and compress JPG images with Spring and ImgScalr

JPG is a common lossy compression format for digital images.
The format is very suitable for web applications (unless you need transparency, then you should use PNG).

It is a common task for a web application to process images, for example to allow users to upload their profile picture.
Image processing is not a trivial task, we will delegate the work to the Java library ImgSclar.

Summary

  1. Set up the project
  2. Define the image sources
  3. Define the image formats
  4. Compress the image
  5. Set up the HTTP endpoints
  6. Test the application
  7. Conclusion

Setup the project

We will use:

Here is our Maven pom.xml file:

<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 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.blebail.blog.sample</groupId>
    <artifactId>java-spring-jpg-upload-resize</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>
    <name>java-spring-jpg-upload-resize</name>
    <url>https://github.com/baptistelebail/samples/tree/master/java-spring-jpg-upload-resize</url>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.5.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Spring -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <!-- Img Sclar -->
        <dependency>
            <groupId>org.imgscalr</groupId>
            <artifactId>imgscalr-lib</artifactId>
            <version>4.2</version>
        </dependency>

        <!-- JUnit -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.6.0</version>
            <scope>test</scope>
        </dependency>

        <!-- AssertJ -->
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.15.0</version>
            <scope>test</scope>
        </dependency>

        <!-- Spring Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

And here is our main Application class (which we annotate with @SpringBootApplication, to bootstrap the application):

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Define the image sources

Usually, we want the user to be able to upload images from two sources:

  • a file upload, from her computer
  • a URL, where the image resides

In both cases, we will want to transfer the data to a temporary java File, to be processed later.

We create the ImageSource interface in the image.source package:

import java.io.File;
import java.io.IOException;

public interface ImageSource {

    File asFile() throws IOException;
}

With Spring, an upload can be handled with a MultipartFile, so this is the type we will expect in the case of a file upload. It has a transferTo() method to do just what we want.

We create the ImageMultipart class in the image.source package:

import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Objects;

public final class ImageMultipart implements ImageSource {

    private final MultipartFile multipartFile;

    public ImageMultipart(MultipartFile multipartFile) {
        this.multipartFile = Objects.requireNonNull(multipartFile);
    }

    @Override
    public File asFile() throws IOException {
        File imageFile = Files.createTempFile("image_upload_", ".tmp")
                .toFile();

        multipartFile.transferTo(imageFile);

        return imageFile;
    }
}


In Java there is a URL type, from which we can open an InputStream and transfer its data to a file via a FileOutputStream.

We create the ImageUrl class in the image.source package:

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.util.Objects;

public final class ImageUrl implements ImageSource {

    private final URL url;

    public ImageUrl(URL url) {
        this.url = Objects.requireNonNull(url);
    }

    @Override
    public File asFile() throws IOException {
        File imageFile = Files.createTempFile("image_url_", ".tmp")
                .toFile();

        FileOutputStream fileOutputStream = new FileOutputStream(imageFile);
        ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream());

        fileOutputStream.getChannel()
                .transferFrom(readableByteChannel, 0, Long.MAX_VALUE);

        return imageFile;
    }
}

Define the image format

We will potentially want to save our images with different sizes and compression levels, so we will create an interface for this purpose.

We create the ImageFormat interface in the image.format package:

public interface ImageFormat {

    int width();

    int height();

    float compression();
}

Methods width() and height() are in pixels. The compression() method returns a float, from 0.0 (not compressed) to 1.0 (highly compressed).

Let’s say we want our images to be square (common for a profile picture) and reasonably compressed (because quality is not that important compared to the file size in the context of a profile picture for a web application).

We create the SquareCompressed class in the image.format package:

public final class SquareCompressed implements ImageFormat {

    private final int size;

    public SquareCompressed(int size) {
        this.size = size;
    }

    @Override
    public int width() {
        return size;
    }

    @Override
    public int height() {
        return size;
    }

    @Override
    public float compression() {
        return 0.50f;
    }
}

Compress the image

What we need is a class, named JpgImage, which:

  • takes a source File, the source image
  • takes the desired ImageFormat
  • can compress the source image to a target File, the final compressed image

The method compressTo(File target) will first resize the source image and force it to fit our ImageFormat.width() and ImageFormat.height(), with Scalr.resize() and the option Scalr.Mode.FIT_EXACT.
And then compress it with an ImageWriter which will take our ImageFormat.compression().

We create the JpgImage class in the image.compression package:

import com.blebail.blog.sample.image.format.ImageFormat;
import org.imgscalr.Scalr;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.FileImageOutputStream;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;

public final class JpgImage {

    public static final String EXTENSION = "jpg";

    private final File source;

    private final ImageFormat format;

    public JpgImage(File source, ImageFormat format) {
        this.source = Objects.requireNonNull(source);
        this.format = Objects.requireNonNull(format);
    }

    public void compressTo(File target) throws IOException {
        FileImageOutputStream targetOutputStream = new FileImageOutputStream(target);
        BufferedImage resizedImage = resize(source);

        ImageWriter writer = getWriter();
        ImageWriteParam writerSettings = getWriterSettings(writer);

        try {
            writer.setOutput(targetOutputStream);
            writer.write(null, new IIOImage(resizedImage, null, null), writerSettings);
        } finally {
            writer.dispose();
            targetOutputStream.close();
            resizedImage.flush();
        }
    }

    private BufferedImage resize(File imageFile) throws IOException {
        BufferedImage sourceImage = ImageIO.read(imageFile);

        return Scalr.resize(sourceImage, Scalr.Mode.FIT_EXACT, format.width(), format.height());
    }

    private ImageWriter getWriter() {
        Iterator<ImageWriter> imageWritersIterator =
                ImageIO.getImageWritersByFormatName(EXTENSION);

        if (!imageWritersIterator.hasNext()) {
            throw new NoSuchElementException(
                    String.format("Could not find an image writer for %s format", EXTENSION));
        }

        return imageWritersIterator.next();
    }

    private ImageWriteParam getWriterSettings(ImageWriter imageWriter) {
        ImageWriteParam imageWriteParams = imageWriter.getDefaultWriteParam();

        imageWriteParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
        imageWriteParams.setCompressionQuality(format.compression());

        return imageWriteParams;
    }
}


Now let’s define a Spring @Component which will wire everything together, by:

  1. taking our ImageSource with a specific name
  2. transfer the image to a temporary file
  3. resize and compress the image according to a specific ImageFormat

We create the ImageCompression interface in the image.compression package:

public interface ImageCompression {

    void compress(ImageSource imageSource, String imageName);
}

Our Spring component will use the src/main/resources/application.properties configuration file, in which we will define a path for our compressed images:

images.path=/tmp/blebail-img-compress

The images.path configuration property will be injected into our component via the @Value annotation.

We create the JpgImageCompression component in the image.compression package:

import com.blebail.blog.sample.image.format.SquareHighlyCompressed;
import com.blebail.blog.sample.image.source.ImageSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

@Component
public class JpgImageCompression implements ImageCompression {

    private String imagesPathAsString;

    @Value("${images.path}" )
    public void setImagesPathAsString(String imagesPathAsString) {
        this.imagesPathAsString = imagesPathAsString;
    }

    @Override
    public void compress(ImageSource imageSource, String imageName) {
        try {
            Path imagesPath = Files.createDirectories(Paths.get(imagesPathAsString));
            String compressedImageFileName = imageName + "." + JpgImage.EXTENSION;
            File compressedImageFile = imagesPath.resolve(compressedImageFileName)
                    .toFile();

            File imageSourceFile = imageSource.asFile();

            new JpgImage(imageSourceFile, new SquareCompressed(200))
                    .compressTo(compressedImageFile);

            imageSourceFile.delete();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

Set up the HTTP endpoints

Now, let’s set up the two HTTP endpoints we need to expose our JPG image compression API.

We need a Spring @Controller which:

  • exposes a POST image/upload/file/{name} endpoint to upload a JPG image, from a file, with a specific name
  • exposes a POST image/upload/url/{name} endpoint to upload a JPG image, from a URL, with a specific name
  • uses our JpgCompression component to compress our JPG images

We create the ImageUpload controller in the image package:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;

import java.net.MalformedURLException;
import java.net.URL;

@Controller
public class ImageUpload {

    private final ImageCompression imageCompression;

    @Autowired
    public ImageUpload(ImageCompression imageCompression) {
        this.imageCompression = imageCompression;
    }

    @PostMapping(value = "image/upload/file/{name}")
    @ResponseStatus(HttpStatus.OK)
    public void uploadJpgImageFile(
            @PathVariable("name") String name,
            @RequestPart(value = "file") MultipartFile multipartFile) {
        imageCompression.compress(new ImageMultipart(multipartFile), name);
    }

    @PostMapping(value = "image/upload/url/{name}")
    @ResponseStatus(HttpStatus.OK)
    public void uploadJpgImageUrl(
            @PathVariable("name") String name,
            @RequestBody String urlAsString) {
        URL url;

        try {
            url = new URL(urlAsString);
            url.toURI();
        } catch (MalformedURLException| URISyntaxException e) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }

        imageCompression.compress(new ImageUrl(url), name);
    }
}

In the POST image/upload/url/{name} endpoint, we check that the URL is actually valid (using Java standard APIs only, validating URLs are not the subject of this post).

Doing new URL(…) in Java will throw a MalformedURLException if the URL is malformed and URL.toURI() will throw a URISyntaxException if the URL does not comply with RFC 2396.

In these specific cases we would like to return a HTTP 400 BAD REQUEST. To do that all we have to do is throw a Spring ResponseStatusException which takes a HttpStatus and automatically maps the HTTP response code.

Test the application

Let’s write a few tests. We want to :

  • launch our Spring application in a JUnit 5 test, which is what @SpringBootTest(classes = Application.class) and the SpringExtension will do for us
  • be able to simulate our HTTP requests, @AutoConfigureMockMvc will mock the Spring web layer and configure a MockMvc object we will use to peform our requests
  • use a unique cross-platform compatible folder to save our compressed images in our tests for them to be fully reproductible, we will use JUnit 5 @TempDir, and call manually our jpgImageCompression.setImagesPathAsString() method

We create ImageUploadTest in src/test/java/:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(SpringExtension.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = Application.class)
public class ImageUploadTest {

    @TempDir
    public Path tempDir;

    @Autowired
    public MockMvc mockMvc;

    @Autowired
    public JpgImageCompression jpgImageCompression;

    @BeforeEach
    void setUp() {
        jpgImageCompression.setImagesPathAsString(tempDir.toString());
    }

    @Test
    public void shouldUploadResizeAndCompressJpgFile() throws Exception {
        Path sourceImagePath = Paths.get(getClass().getResource("/image.jpg").toURI());
        MockMultipartFile sourceImageMultipartFile =
                new MockMultipartFile("file", "image.jpg", "image/jpeg",
                        Files.readAllBytes(sourceImagePath));

        mockMvc.perform(multipart("/image/upload/file/myimage")
                .file(sourceImageMultipartFile))
                .andExpect(status().isOk());

        Path compressedImagePath = tempDir.resolve("myimage.jpg");

        assertThat(Files.exists(compressedImagePath))
                .isTrue();

        assertThat(Files.size(compressedImagePath))
                .isLessThan(Files.size(sourceImagePath));
    }

    @Test
    public void shouldUploadResizeAndCompressJpgFromUrl() throws Exception {
        Path sourceImagePath = Paths.get(getClass().getResource("/image.jpg").toURI());
        URL sourceImageUrl = sourceImagePath.toUri().toURL();

        mockMvc.perform(post("/image/upload/url/myimage")
                .content(sourceImageUrl.toString()))
                .andExpect(status().isOk());

        Path compressedImagePath = tempDir.resolve("myimage.jpg");

        assertThat(Files.exists(compressedImagePath))
                .isTrue();

        assertThat(Files.size(compressedImagePath))
                .isLessThan(Files.size(sourceImagePath));
    }

    @Test
    public void shouldReturnHttpBadRequestWhenURLIsNotValid() throws Exception {
        String invalidUrl = "thisIsNotAValidUrl";

        mockMvc.perform(post("/image/upload/url/myimage")
                .content(invalidUrl))
                .andExpect(status().isBadRequest());
    }
}

Summary

Spring and ImgScalr make it very easy to implement image upload and manipulation.

(The whole project sources are available here)