@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