Uploading Files With Spring Boot

Hi folks! In this post I decided to create a complete flow of a Spring Boot application that demonstrates an upload and a display of an image on the home page. In fact there are dozens of ways to save an uploaded file on a hosting server and handle the names of the uploaded files. Here we are going to adopt a special kind of naming procedure. First of all we will remove non-alphanumeric characters from the file names except those of dots and parenthesis. The reason we want to keep the parenthesis is because we want to use them in case a file with a similar name already exists. Say if a file with a name "example.jpg" exists, we want to keep it as "example(1).jpg". If "example(1).jpg" also exists we will keep it as "example(2).jpg" and so on. But say if the name of the file we want to upload is "example(4).jpg" the app is not going to save it as "example(4).jpg" even if that file name has not been taken yet, instead it will look whether a file with a name "example.jpg" exists or not. If it already exists it will try to save it as "example(1).jpg", but if that name is also occupied then as "example(2).jpg" and so on until the smallest free integer naming number is found.

 Do not expect to see a mind-boggling design, but I promise to carry out all steps to stay consistent and avoid  the possible misconceptions. Before starting to follow this tutorial, I refer you to the last two posts (1, 2) I made to create a basic Spring Boot application and to configure it for MySQL database connection. After doing so we can start by creating an "uploads" directory for storing our uploaded files. Right-click on /demo-app/src/main/webapp and create the "uploads" folder. Actually, we could have chosen any path we please to create the "uploads" folder, even outside the project. Let us just define the absolute path of the upload-directory and the allowed file extensions inside the "application.properties" file. So if at some point we decide to change the allowed extensions or the upload directory we will only have to change the corresponding entry values inside application.properties. My demo-app project folder is inside /home/ara/eclipse-workspace/, so the absolute path of your "uploads" folder might not be the same as mine. You just have to figure out yours.

src/main/resources/application.properties

spring.mvc.view.prefix=/WEB-INF/
spring.mvc.view.suffix=.jsp

spring.datasource.url=jdbc:mysql://localhost:3306/demo_db
spring.datasource.username=root
spring.datasource.password=aralmighty
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=true
spring.jpa.show-sql=true

upload.file.directory=/home/ara/eclipse-workspace/demo-app/src/main/webapp/uploads
upload.file.extensions=jpg,jpeg,gif,png

Creating File entity and its DAO classes.

To store the locations of our uploaded files in the demo_db database that we created in the previous post, we simply create a File class in the com.aralmighty.models package to be mapped to "files" table in the database. For the mapping to take place we of course need to annotate our File class with @Entity annotation and define the fields with their corresponding annotations to be mapped to the columns of  the "files" table. The only field we will not map to the database is the file's base name, which is the file's name without its extension (e.g. "example" for example.jpg). So we will annotate the field with @Transient.

src/main/java/com/aralmighty/models/File.java

package com.aralmighty.models;

import java.nio.file.Path;
import java.nio.file.Paths;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Transient;

@Entity
@Table(name="files")
public class File {
  @Id
  @GeneratedValue(strategy=GenerationType.AUTO)
  @Column(name="id")
  private Long id;
	
  @Column(name="file_directory", length=100)
  private String fileDirectory;
	
  @Column(name="file_name", length=100)
  private String fileName;
	
  @Column(name="file_extension", length=5)
  private String fileExtension;
	
  @Transient
  private String fileBaseName;
	
  public File() {
		
  }

  public File(
    String fileDirectory, String fileName, 
    String fileExtension, String fileBaseName
  ) {
    this.fileDirectory = fileDirectory;
    this.fileName = fileName;
    this.fileExtension = fileExtension;
    this.fileBaseName = fileBaseName;
  }

  public Long getId() {
    return id;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public String getFileDirectory() {
    return fileDirectory;
  }

  public void setFileDirectory(String fileDirectory) {
    this.fileDirectory = fileDirectory;
  }

  public String getFileName() {
    return fileName;
  }

  public void setFileName(String fileName) {
    this.fileName = fileName;
  }

  public String getFileExtension() {
    return fileExtension;
  }

  public void setFileExtension(String fileExtension) {
    this.fileExtension = fileExtension;
  }

  public String getFileBaseName() {
    return fileBaseName;
  }

  public void setFileBaseName(String fileBaseName) {
    this.fileBaseName = fileBaseName;
  }
	
  public Path getFilePath() {
    if (fileName == null || fileDirectory == null) {
      return null;
    }
		
    return Paths.get(fileDirectory, fileName);
  }
}

Why a default empty constructor above? Good question (self-praise) ! Actually, that's because the persistence framework uses it to create an instance through reflection. If there were no the second constructor with the arguments, we would not need to define a no-argument constructor explicitly, since the JVM would provide it by default. Besides generating  the getters and setters for the fields, we also defined the getFilePath() method in the end that returns the path of the file.

In the previous post we added the spring-boot-starter-data-jpa Maven dependency. In order to carry out the persistent related actions to and from the database the Spring Data JPA provides support for creating JPA repositories by extending the Spring Data repository interfaces. This extra layer of abstraction comes with a pleasing tradeoff. We do not need to make our own implementation of the extended interface in order to use Spring Data JPA. The interface we are going to extend here is the CrudRepository<File, Long>  that declares the methods that are used for generic CRUD operations on files . So let's create a FileDao interface and extend it.

models/FileDao.java

package com.aralmighty.models;

import org.springframework.data.repository.CrudRepository;

public interface FileDao extends CrudRepository<File, Long> {
  File findFirstByOrderByIdDesc();
}

Spring Data JPA will implement this interface and will provide us with CRUD methods. By some magic it will also implement the findFirstByOrderByIdDesc method that on the backstage is transformed into a database query returning the last recorded file in the "files" table. Actually the magic is related to the way we declared the method name. I find this query creation by method names to be very handy by the way. There are some useful operation names that can be concatenated within method names in different flexible orders to create database queries in the underlying database. One can find more explanation in the official docs how to work with Spring Data Repositories.

Before we precede let's create an InvalidFileException exception class to be thrown say if the uploaded file does not have a valid format specified in application.properties. 

exceptions/InvalidFileException.java

package com.aralmighty.exceptions;

public class InvalidFileException extends Exception {
	
  private static final long serialVersionUID = 1L;
	
  public InvalidFileException(String message) {
    super(message);
  }
}

Creating the service layer for uploading files.

I guess the best place to write the code for uploading files and handling the file names uploaded on the server is the FileService class annotated with the @Service annotation from org.springframework.stereotype.Service. We are going to inject the comma-separated list of "upload.file.extensions" from application.properties file into the FileService class through the @Value("${upload.file.extensions}") annotation from the org.springframework.beans.factory.annotation.Value. We can also autowire the instance of the FileDao interface that we defined above through the @Autowired annotation.

services/FileService.java

package com.aralmighty.services;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.aralmighty.models.FileDao;
import com.aralmighty.models.File;
import com.aralmighty.exceptions.InvalidFileException;

@Service
public class FileService {

  @Value("${upload.file.extensions}")
  private String validExtensions;
	
  @Autowired
  private FileDao fileDao;
	
  String getFileExtension(String fileName) {
    int dotIndex = fileName.lastIndexOf(".");
    if(dotIndex < 0) {
      return null;
    }
    return fileName.substring(dotIndex+1);
  }
	
  boolean isValidExtension(String fileName) 
  throws InvalidFileException {
    String fileExtension = getFileExtension(fileName);
		
    if (fileExtension == null) {
      throw new InvalidFileException("No File Extension");
    }
		
    fileExtension = fileExtension.toLowerCase();
		
    for (String validExtension : validExtensions.split(",")) {
      if (fileExtension.equals(validExtension)) {
        return true;
      }
    }
    return false;
  }
	
  private int getOpenParenthesisIndex(String baseFileName) {
    int openParIndex = baseFileName.lastIndexOf("(");
    int closeParIndex = baseFileName.lastIndexOf(")");
    boolean isParenthesis = openParIndex > 0 && 
                            closeParIndex == baseFileName.length()-1;
		
    if (
      isParenthesis && 
      baseFileName.
      substring(openParIndex+1, closeParIndex).
      matches("[1-9][0-9]*")
    ) {
      return openParIndex;
    } else {
      return -1;
    }
  }
	
  String handleFileName(String fileName, String uploadDirectory) 
  throws InvalidFileException {
		
    String cleanFileName = fileName.replaceAll("[^A-Za-z0-9.()]", "");		
    String extension = getFileExtension(cleanFileName);
		
    if(!isValidExtension(cleanFileName)) {
      throw new InvalidFileException("Invalid File Extension");
    };
		
    String base = cleanFileName.substring(
      0, 
      cleanFileName.length()-extension.length()-1
    );
		
    int openParIndex = getOpenParenthesisIndex(base);
		
    if (openParIndex > 0) {
      base = base.substring(0, openParIndex);
      cleanFileName =  base + "." + extension;
    }
		
    if (Files.exists(Paths.get(uploadDirectory, cleanFileName))) {
      cleanFileName =  base + "(1)." + extension;
    }
		
    while (Files.exists(Paths.get(uploadDirectory, cleanFileName))) {
      String nString = cleanFileName.substring(
        base.length()+1, 
        cleanFileName.length()-extension.length()-2
      );
      int n = Integer.parseInt(nString) + 1;
      cleanFileName =  base + "(" + n + ")." + extension;
    }		
		
    return cleanFileName;
  }
	
  public File uploadFile(MultipartFile file, String uploadDirectory) 
  throws InvalidFileException, IOException {		
    String fileName = handleFileName(file.getOriginalFilename(), uploadDirectory);
    Path path = Paths.get(uploadDirectory, fileName);
    Files.copy(file.getInputStream(), path);
		
    String extension = getFileExtension(fileName);
    String fileBaseName = fileName.substring(
      0, 
      fileName.length()-extension.length()-1
    );
    return new File(uploadDirectory, fileName, extension, fileBaseName);
  }
	
  public void save(File uploadedFile) {
    fileDao.save(uploadedFile);
  }
	
  public File findLastFile() {
    return fileDao.findFirstByOrderByIdDesc();
  }
}

I would like to draw your attention to the save CRUD method that we did not implement when we created the FileDao interface, but here we just used it on fileDao. I hope that the code above for the service layer is fairly self-explanatory. So we just jump to the next step of creating the upload form inside our home.jsp left from previous Hello World Demo App tutorial. No doubt, hello world is nice, but it does not allow us to upload files. 

src/main/webapp/WEB-INF/home.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix = "c" uri = "http://java.sun.com/jsp/jstl/core" %>

<html>
<head>
<title>Upload File</title>
</head>
<body>
  <c:url value="/upload" var="upload" />
  <c:url value="/file" var="filePath" />
	
  <form action="${upload}" enctype="multipart/form-data"	method="post">
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> 
    Select Photo: <input type="file" accept="image/*" name="file" /> 
    <input type="submit" value="upload" />
  </form>
  <br>
  <img src="${filePath}" height="100" width="100">

</body>
</html>

Here we used JavaServer Pages Standard Tag Library (JSTL) to define two paths (remember we added the maven dependency for it inside Hello World Demo App tutorial)

  1. "/upload"  path to what we submit our form by POST method.
  2. "/file" path from where we provide the image to the src attribute of <img> tag to display the image on our home page.

There is just one thing I would like to comment on. The form above contains a hidden <input /> tag to protect our application from CSRF attacks. For those who are not familiar with the Cross Site Request Forgery (CSRF) I refer to this link for a nice explanation.

Creating the Controller Methods

In the Hello World Demo App tutorial we created a HomeController that looked very simple

controllers/HomeController.java

package com.aralmighty.controllers;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class HomeController {
	
  @RequestMapping("/")
  String home() {	
    return "home";
  }
	
}

We are going to create two more methods inside our HomeController to be mapped to the  "/upload" - POST and "/file" - GET requests. In order to do that we will autowire the FileService object and also inject the upload.file.directory parameter from application.properties file into the HomeController.

controllers/HomeController.java

package com.aralmighty.controllers;

import java.io.IOException;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import com.aralmighty.services.FileService;
import com.aralmighty.exceptions.InvalidFileException;
import com.aralmighty.models.File;

@Controller
public class HomeController {
	
  @Value("${upload.file.directory}")
  private String uploadDirectory;
	
  @Autowired
  private FileService fileService;
	
  @RequestMapping("/")
  String home() {	
    return "home";
  }
	
  @RequestMapping(value="/upload", method=RequestMethod.POST)
  String fileUploads(Model model, @RequestParam("file") MultipartFile file) {
		
    try {
      File uploadedFile = fileService.uploadFile(file, uploadDirectory);
      fileService.save(uploadedFile);
    } catch (InvalidFileException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
    } catch (IOException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
    }
		
    return "redirect:/";
  }
	
  @RequestMapping(value="/file", method=RequestMethod.GET)
  @ResponseBody
  ResponseEntity<InputStreamResource> uploadedFile() throws IOException {
    Path filePath = fileService.findLastFile().getFilePath();
    return ResponseEntity
      .ok()
      .contentLength(Files.size(filePath))
      .contentType(
        MediaType.parseMediaType(
          URLConnection.guessContentTypeFromName(filePath.toString())
        )
      )
      .body(new InputStreamResource(
        Files.newInputStream(filePath, StandardOpenOption.READ))
      );
  }
}

Now let's just run the application by right clicking on App.java > Run As > Java Application

run as

After the Run the "files" table should be created in the "demo_db" database. It is still empty, because we have nothing uploaded yet.

$ mysql -u root -p
Enter password: 

mysql> use demo_db

Database changed
mysql> show tables;
+-------------------+
| Tables_in_demo_db |
+-------------------+
| files             |
+-------------------+
1 row in set (0.00 sec)

mysql> select * from files;
Empty set (0.00 sec)

Now let's visit the home page in our browser by typing http://localhost:8080/.

home page

We do not see any pictures displayed, because the files table is empty and what is more we got a java.lang.NullPointerException  on the first query line of the uploadedFile() method inside the HomeController.

controllers/HomeController.java

Path filePath = fileService.findLastFile().getFilePath();

Well, we could have made a default picture, but let's not be too picky for this tutorial. Let's just upload a picture having a size less than 1MB, because maximum file size by default is configured to be 1 MB.

aralmighty file upload

And now if we check the "files" table in "demo_db" we see

mysql> select file_name from files;
+----------------+
| file_name      |
+----------------+
| aralmighty.jpg |
+----------------+
1 row in set (0.00 sec)

Now what if we upload a picture greater than 1 MB

max-file-size

We get the MultipartException with a message saying the field file exceeds its maximum permitted size of 1048576 bytes. We can easily handle this exception by adding the following controller method that returns any error message we want. But we also have to annotate the HomeController with the @ControllerAdvice. In aspect oriented programming (AOP) terminology "advice" is a piece of code invoked during the program execution. So the exception handler method will be invoked whenever the exception occurs.

controllers/HomeController.java

@Controller
@ControllerAdvice
public class HomeController {
  .
  .
  .
 
  @ExceptionHandler(MultipartException.class)
  @ResponseBody
  String permittedSizeException(Exception e) {
    e.printStackTrace();
    return "<h1>The file exceeds its maximum permitted size of 1 MB.</h1>";
  }
}

We can also change the configuration of maximum file size by adding the following line inside application.properties file.

src/main/resources/application.properties

spring.http.multipart.max-file-size=2048KB

so now the maximum file size is 2 MB.