In this chapter, we will enable our user to upload a profile picture. We will also see how to handle errors in Spring MVC.
We will now make it possible for our user to upload a profile picture. This will be available from the profile page later on, but for now, we will simplify things and create a new page in the templates directory under profile/uploadPage.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorator="layout/default">
<head lang="en">
<title>Profile Picture Upload</title>
</head>
<body>
<div class="row" layout:fragment="content">
<h2 class="indigo-text center">Upload</h2>
<form th:action="@{/upload}" method="post" enctype="multipart/form-data" class="col m8 s12 offset-m2">
<div class="input-field col s6">
<input type="file" id="file" name="file"/>
</div>
<div class="col s6 center">
<button class="btn indigo waves-effect waves-light" type="submit" name="save" th:text="#{submit}">Submit
<i class="mdi-content-send right"></i>
</button>
</div>
</form>
</div>
</body>
</html>
Not much to see besides the enctype
attribute on the form. The file will be sent by the POST
method to the upload
URL. We will now create the corresponding controller right beside ProfileController
in the profile
package:
package masterSpringMvc.profile; import org.apache.tomcat.util.http.fileupload.IOUtils; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @Controller public class PictureUploadController { public static final Resource PICTURES_DIR = new FileSystemResource("./pictures"); @RequestMapping("upload") public String uploadPage() { return "profile/uploadPage"; } @RequestMapping(value = "/upload", method = RequestMethod.POST) public String onUpload(MultipartFile file) throws IOException { String filename = file.getOriginalFilename(); File tempFile = File.createTempFile("pic", getFileExtension(filename), PICTURES_DIR.getFile()); try (InputStream in = file.getInputStream(); OutputStream out = new FileOutputStream(tempFile)) { IOUtils.copy(in, out); } return "profile/uploadPage"; } private static String getFileExtension(String name) { return name.substring(name.lastIndexOf(".")); } }
The first thing this code will do is create a temporary file in the pictures
directory, which can be found inside the project's root folder; so, ensure that it exists. In Java, a temporary file is just a commodity to obtain a unique file identifier on the filesystem. It is up to the user to optionally delete it.
Create a pictures directory at the root of the project and add an empty file called .gitkeep
to ensure that you can commit it in Git.
The file uploaded by the user will be injected as a MultipartFile
interface in our controller. This interface provides several methods to get the name of the file, its size, and its contents.
The method that particularly interests us here is getInputStream()
. We will indeed copy this stream to a fileOutputStream
method, thanks to the IOUtils.copy
method. The code to write an input stream to an output stream is pretty boring, so it's handy to have the Apache Utils in the classpath (it is part of the tomcat-embedded-core.jar
file).
We make heavy use of the pretty cool Spring and Java 7 NIO features:
try…with
block will automatically close our streams even in the case of an exception, removing the boilerplate of writing a finally
blockWith the preceding code, any file uploaded by the user will be copied into the pictures
directory.
There are a handful of properties available in Spring Boot to customize file upload. Take a look at the MultipartProperties
class.
The most interesting ones are:
multipart.maxFileSize
: This defines the maximum file size allowed for the uploaded files. Trying to upload a bigger one will result in a MultipartException
class. The default value is 1Mb
.multipart.maxRequestSize
: This defines the maximum size of the multipart request. The default value is 10Mb
.The defaults are good enough for our application. After a few uploads, our picture directory will look like this:
Wait! Somebody uploaded a ZIP file! I cannot believe it. We better add some checks in our controller to ensure that the uploaded files are real images:
package masterSpringMvc.profile; import org.apache.tomcat.util.http.fileupload.IOUtils; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import java.io.*; @Controller public class PictureUploadController { public static final Resource PICTURES_DIR = new FileSystemResource("./pictures"); @RequestMapping("upload") public String uploadPage() { return "profile/uploadPage"; } @RequestMapping(value = "/upload", method = RequestMethod.POST) public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs) throws IOException { if (file.isEmpty() || !isImage(file)) { redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture."); return "redirect:/upload"; } copyFileToPictures(file); return "profile/uploadPage"; } private Resource copyFileToPictures(MultipartFile file) throws IOException { String fileExtension = getFileExtension(file.getOriginalFilename()); File tempFile = File.createTempFile("pic", fileExtension, PICTURES_DIR.getFile()); try (InputStream in = file.getInputStream(); OutputStream out = new FileOutputStream(tempFile)) { IOUtils.copy(in, out); } return new FileSystemResource(tempFile); } private boolean isImage(MultipartFile file) { return file.getContentType().startsWith("image"); } private static String getFileExtension(String name) { return name.substring(name.lastIndexOf(".")); } }
Pretty easy! The getContentType()
method returns the Multipurpose Internet Mail Extensions (MIME) type of the file. It will be image/png
, image/jpg
, and so on. So we just have to check if the MIME type starts with "image".
We added an error message to the form so we should add something in our web page to display it. Place the following code just under the title in the uploadPage
:
<div class="col s12 center red-text" th:text="${error}" th:if="${error}"> Error during upload </div>
The next time you try to upload a ZIP file, you will get an error! This is shown in the following screenshot:
The uploaded images are not served from the static directories. We will need to take special measures to display them in our web page.
Let's add the following lines to our upload page, just above the form:
<div class="col m8 s12 offset-m2"> <img th:src="@{/uploadedPicture}" width="100" height="100"/> </div>
This will try and get the image from our controller. Let's add the corresponding method to the PictureUploadController
class:
@RequestMapping(value = "/uploadedPicture") public void getUploadedPicture(HttpServletResponse response) throws IOException { ClassPathResource classPathResource = new ClassPathResource("/images/anonymous.png"); response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(classPathResource.getFilename())); IOUtils.copy(classPathResource.getInputStream(), response.getOutputStream()); }
This code will write an image found in the src/main/resources/images/anonymous.png
directory directly to the response! How exciting!
If we go to our page again, we will see the following image:
I found the anonymous user image on iconmonstr (http://iconmonstr.com/user-icon) and downloaded it as a 128 x 128 PNG file.
A good thing to do at this point is to allow the configuration of the upload directory and the path to the anonymous user image through the application.properties
file.
Let's create a PicturesUploadProperties
class inside a newly created config
package:
package masterSpringMvc.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import java.io.IOException; @ConfigurationProperties(prefix = "upload.pictures") public class PicturesUploadProperties { private Resource uploadPath; private Resource anonymousPicture; public Resource getAnonymousPicture() { return anonymousPicture; } public void setAnonymousPicture(String anonymousPicture) { this.anonymousPicture = new DefaultResourceLoader().getResource(anonymousPicture); } public Resource getUploadPath() { return uploadPath; } public void setUploadPath(String uploadPath) { this.uploadPath = new DefaultResourceLoader().getResource(uploadPath); } }
In this class, we make use of the Spring Boot ConfigurationProperties
. This will tell Spring Boot to automatically map properties found in the classpath (by default, in the application.properties
file) in a type-safe fashion.
Notice that we defined setters taking 'String's as arguments but are at liberty to let the getters return any type is the most useful.
We now need to add the PicturesUploadProperties
class to our configuration:
@SpringBootApplication @EnableConfigurationProperties({PictureUploadProperties.class}) public class MasterSpringMvc4Application extends WebMvcConfigurerAdapter { // code omitted }
We can now add the properties' values inside the application.properties
file:
upload.pictures.uploadPath=file:./pictures upload.pictures.anonymousPicture=classpath:/images/anonymous.png
Because we use Spring's DefaultResourceLoader
class, we can use prefixes such as file:
or classpath:
to specify where our resources can be found.
This would be the equivalent of creating a FileSystemResource
class or a ClassPathResource
class.
This approach also has the advantage of documenting the code. We can easily see that the picture directory will be found in the application root, whereas the anonymous picture will be found in the classpath.
That's it. We can now use our properties inside our controller. The following are the relevant parts of the PictureUploadController
class:
package masterSpringMvc.profile; import masterSpringMvc.config.PictureUploadProperties; import org.apache.tomcat.util.http.fileupload.IOUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.net.URLConnection; @Controller public class PictureUploadController { private final Resource picturesDir; private final Resource anonymousPicture; @Autowired public PictureUploadController(PictureUploadProperties uploadProperties) { picturesDir = uploadProperties.getUploadPath(); anonymousPicture = uploadProperties.getAnonymousPicture(); } @RequestMapping(value = "/uploadedPicture") public void getUploadedPicture(HttpServletResponse response) throws IOException { response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(anonymousPicture.getFilename())); IOUtils.copy(anonymousPicture.getInputStream(), response.getOutputStream()); } private Resource copyFileToPictures(MultipartFile file) throws IOException { String fileExtension = getFileExtension(file.getOriginalFilename()); File tempFile = File.createTempFile("pic", fileExtension, picturesDir.getFile()); try (InputStream in = file.getInputStream(); OutputStream out = new FileOutputStream(tempFile)) { IOUtils.copy(in, out); } return new FileSystemResource(tempFile); } // The rest of the code remains the same }
At this point, if you launch your application again, you will see that the result hasn't changed. The anonymous picture is still displayed and the pictures uploaded by our users still end up in the pictures
directory at the project root.
It would be nice to display the user's picture now, wouldn't it? To do this, we will add a model attribute to our PictureUploadController
class:
@ModelAttribute("picturePath") public Resource picturePath() { return anonymousPicture; }
We can now inject it to retrieve its value when we serve the uploaded picture:
@RequestMapping(value = "/uploadedPicture")
public void getUploadedPicture(HttpServletResponse response, @ModelAttribute("picturePath") Path picturePath) throws IOException {
response.setHeader("Content-Type", URLConnection.guessContentTypeFromName(picturePath.toString()));
Files.copy(picturePath, response.getOutputStream());
}
The @ModelAttribute
annotation is a handy way to create model attributes with an annotated method. They can then be injected with the same annotation into controller methods. With this code, a picturePath
parameter will be available in the model as long as we are not redirected to another page. Its default value is the anonymous picture we defined in our properties.
We need to update this value when the file is uploaded. Update the onUpload
method:
@RequestMapping(value = "/upload", method = RequestMethod.POST) public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs, Model model) throws IOException { if (file.isEmpty() || !isImage(file)) { redirectAttrs.addFlashAttribute("error", "Incorrect file. Please upload a picture."); return "redirect:/upload"; } Resource picturePath = copyFileToPictures(file); model.addAttribute("picturePath", picturePath); return "profile/uploadPage"; }
By injecting the model, we can update the picturePath
parameter after the upload is complete.
Now, the problem is that our two methods, onUpload
and getUploadedPicture
, will occur in different requests. Unfortunately, the model attributes will be reset between each.
That's why we will define the picturePath
parameter as a session attribute. We can do this by adding another annotation to our controller class:
@Controller
@SessionAttributes("picturePath")
public class PictureUploadController {
}
Phew! That's a lot of annotations just to handle a simple session attribute. You will get the following output:
This approach makes code composition really easy. Plus, we didn't use HttpServletRequest
or HttpSession
directly. Moreover, our object can be typed easily.
It must have certainly occurred to my attentive readers that our code is susceptible to throw two kinds of exceptions:
IOException
: This error is thrown if something bad happens while writing the file to disk.MultipartException
: This error is thrown if an error occurs while uploading the file. For instance, when the maximum file size is exceeded.This will give us a good opportunity to look at two ways of handling exceptions in Spring:
@ExceptionHandler
annotation locally in a controller methodLet's handle IOException
with the @ExceptionHandler
annotation inside our PictureUploadController
class by adding the following method:
@ExceptionHandler(IOException.class) public ModelAndView handleIOException(IOException exception) { ModelAndView modelAndView = new ModelAndView("profile/uploadPage"); modelAndView.addObject("error", exception.getMessage()); return modelAndView; }
This is a simple yet powerful approach. This method will be called every time an IOException
is thrown in our controller.
In order to test the exception handler, since making the Java IO code throw an exception can be tricky, just replace the onUpload
method body during the test:
@RequestMapping(value = "/upload", method = RequestMethod.POST) public String onUpload(MultipartFile file, RedirectAttributes redirectAttrs, Model model) throws IOException { throw new IOException("Some message"); }
After this change, if we try to upload a picture, we will see the error message of this exception displayed on the upload page:
Now, we will handle the MultipartException
. This needs to happen at the Servlet container level (that is, at the Tomcat level), as this exception is not thrown directly by our controller.
We will need to add a new EmbeddedServletContainerCustomizer
bean to our configuration. Add this method to the WebConfiguration
class:
@Bean public EmbeddedServletContainerCustomizer containerCustomizer() { EmbeddedServletContainerCustomizer embeddedServletContainerCustomizer = new EmbeddedServletContainerCustomizer() { @Override public void customize(ConfigurableEmbeddedServletContainer container) { container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError")); } }; return embeddedServletContainerCustomizer; }
This is a little verbose. Note that EmbeddedServletContainerCustomizer
is an interface that contains a single method; it can therefore be replaced by a lambda expression:
@Bean public EmbeddedServletContainerCustomizer containerCustomizer() { EmbeddedServletContainerCustomizer embeddedServletContainerCustomizer = container -> container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError")); return embeddedServletContainerCustomizer; }
So, let's just write the following:
@Bean public EmbeddedServletContainerCustomizer containerCustomizer() { return container -> container.addErrorPages(new ErrorPage(MultipartException.class, "/uploadError")); }
This code creates a new error page, which will be called when a MultipartException
happens. It can also be mapped to an HTTP status. The EmbeddedServletContainerCustomizer
interface has many other features that will allow the customization of the Servlet container in which our application runs. Visit http://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-web-applications.html#boot-features-customizing-embedded-containers for more information.
We now need to handle this uploadError
URL in our PictureUploadController
class:
@RequestMapping("uploadError") public ModelAndView onUploadError(HttpServletRequest request) { ModelAndView modelAndView = new ModelAndView("uploadPage"); modelAndView.addObject("error", request.getAttribute(WebUtils.ERROR_MESSAGE_ATTRIBUTE)); return modelAndView; }
The error pages defined in a Servlet environment contain a number of interesting attributes that will help debug the error:
Attribute |
Description |
---|---|
|
This is the HTTP status code of the error. |
|
This is the exception class. |
|
This is the message of the exception thrown. |
|
This is the URI on which the exception occurred. |
|
This is the actual exception. |
|
This is the name of the Servlet that caught the exception. |
All these attributes are conveniently accessible on the WebUtils
class of Spring Web.
If someone tries to upload too big a file, they will get a very clear error message.
You can now test that the error is handled correctly by uploading a really big file (> 1Mb) or setting the multipart.maxFileSize
property to a lower value: 1kb for instance: