Thursday, November 14, 2013

@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: 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("java.lang.IllegalArgumentException: Getting article problem.")));
    }
}

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

No comments:

Post a Comment

Fork me on GitHub