@ControllerAdvice improvements in Spring 4
Among many new features in Spring 4 I found @ControllerAdvice improvements. @ControllerAdvice is a specialization of a @Component that is used to define @ExceptionHandler, @InitBinder, and @ModelAttribute methods that apply to all @RequestMapping methods. Prior to Spring 4, @ControllerAdvice assisted all controllers in the same Dispatcher Servlet. With Spring 4 it has changed. As of Spring 4 @ControllerAdvice may be configured to support defined subset of controllers, whereas the default behavior can be still utilized.
@ControllerAdvice assisting all controllers
Let's assume we want to create an error handler that will print application errors to the user. Let's assume this is a basic Spring MVC application with Thymeleaf as a view engine and we have an ArticleController with the following @RequestMapping method:
package pl.codeleak.t.articles;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("article")
class ArticleController {
@RequestMapping("{articleId}")
String getArticle(@PathVariable Long articleId) {
throw new IllegalArgumentException("Getting article problem.");
}
}
Our method throws an imaginary exception, as we can see. Let's now create an exception handler using @ControllerAdvice. (this is not only possible method in Spring to deal with exceptions).
package pl.codeleak.t.support.web.error;
import com.google.common.base.Throwables;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.ModelAndView;
@ControllerAdvice
class ExceptionHandlerAdvice {
@ExceptionHandler(value = Exception.class)
public ModelAndView exception(Exception exception, WebRequest request) {
ModelAndView modelAndView = new ModelAndView("error/general");
modelAndView.addObject("errorMessage", Throwables.getRootCause(exception));
return modelAndView;
}
}
The class is not public, as it does not to be. We added @ExceptionHandler method that will handle all types of Exceptions and it will return the "error/general" view:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<title>Error page</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<link href="../../../resources/css/bootstrap.min.css" rel="stylesheet" media="screen" th:href="@{/resources/css/bootstrap.min.css}"/>
<link href="../../../resources/css/core.css" rel="stylesheet" media="screen" th:href="@{/resources/css/core.css}"/>
</head>
<body>
<div class="container" th:fragment="content">
<div th:replace="fragments/alert :: alert (type='danger', message=${errorMessage})"> </div>
</div>
</body>
</html>
To test the solution we can either run the server or (preferably) create a test with Spring MVC Test module. Thanks to the fact that we use Thymeleaf, we can verify the rendered view:
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {RootConfig.class, WebMvcConfig.class})
@ActiveProfiles("test")
public class ErrorHandlingIntegrationTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void before() {
this.mockMvc = webAppContextSetup(this.wac).build();
}
@Test
public void shouldReturnErrorView() throws Exception {
mockMvc.perform(get("/article/1"))
.andDo(print())
.andExpect(content().contentType("text/html;charset=ISO-8859-1"))
.andExpect(content().string(containsString("<span>java.lang.IllegalArgumentException: Getting article problem.</span>")));
}
}
We expect the content type is text/html and the view contains the HTML fragment with an error message. Not really user friendly, though. But the test is green.
Using the above solution we provide a general mechanism for handling errors of all our controllers. As mentioned earlier, we can do much more with @ControllerAdvice:. E.g:
@ControllerAdvice
class Advice {
@ModelAttribute
public void addAttributes(Model model) {
model.addAttribute("attr1", "value1");
model.addAttribute("attr2", "value2");
}
@InitBinder
public void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setBindEmptyMultipartFiles(false);
}
}
@ControllerAdvice assisting selected subset of controllers
As of Spring 4, @ControllerAdvice can be customized through annotations(), basePackageClasses(), basePackages() methods to select a subset of controllers to assist. I will demonstrate a simple case how to utilize this new feature.
Let's assume we want to add an API to expose articles via JSON. So we can define a new controller like this:
@Controller
@RequestMapping("/api/article")
class ArticleApiController {
@RequestMapping(value = "{articleId}", produces = "application/json")
@ResponseStatus(value = HttpStatus.OK)
@ResponseBody
Article getArticle(@PathVariable Long articleId) {
throw new IllegalArgumentException("[API] Getting article problem.");
}
}
Our controller is not very sophisticated. It returns an Article as a response body, as @ResponseBody annotation indicates. Of course, we want to deal with exceptions. And we don't want to return an error as text/html but as application/json. Let's create a test then:
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {RootConfig.class, WebMvcConfig.class})
@ActiveProfiles("test")
public class ErrorHandlingIntegrationTest {
@Autowired
private WebApplicationContext wac;
private MockMvc mockMvc;
@Before
public void before() {
this.mockMvc = webAppContextSetup(this.wac).build();
}
@Test
public void shouldReturnErrorJson() throws Exception {
mockMvc.perform(get("/api/article/1"))
.andDo(print())
.andExpect(status().isInternalServerError())
.andExpect(content().contentType("application/json"))
.andExpect(content().string(containsString("{\"errorMessage\":\"[API] Getting article problem.\"}")));
}
}
The test is red. What we can do to make it green? We need to make another advice, this time targeting only our Api controller. For that, we will use @ControllerAdvice annotations() selector. In order to do it we need to either create a customer or use existing annotation. We will use @RestController predefined annotation. Controllers annotated with @RestController assume @ResponseBody semantic by default. We may slighlty modify our controller by replacing @Controller with @RestController and removing @ResponseBody from the handler's method:
@RestController
@RequestMapping("/api/article")
class ArticleApiController {
@RequestMapping(value = "{articleId}", produces = "application/json")
@ResponseStatus(value = HttpStatus.OK)
Article getArticle(@PathVariable Long articleId) {
throw new IllegalArgumentException("[API] Getting article problem.");
}
}
We also need to create another advice that will return ApiError (simple POJO):
@ControllerAdvice(annotations = RestController.class)
class ApiExceptionHandlerAdvice {
/**
* Handle exceptions thrown by handlers.
*/
@ExceptionHandler(value = Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseBody
public ApiError exception(Exception exception, WebRequest request) {
return new ApiError(Throwables.getRootCause(exception).getMessage());
}
}
This time when we run our test suite, both tests are green meaning that ExceptionHandlerAdvice assisted "standard" ArticleController whereas ApiExceptionHandlerAdvice assisted ArticleApiController.
Summary
In the above scenario I demonstrated how easily we can utilize new configuration capabilities of @ControllerAdvice annotation and I hope you like the change as I do.
References
