kayahr
kayahr

Reputation: 22020

How to handle exceptions in Spring MVC differently for HTML and JSON requests

I'm using the following exception handler in Spring 4.0.3 to intercept exceptions and display a custom error page to the user:

@ControllerAdvice
public class ExceptionHandlerController
{
    @ExceptionHandler(value = Exception.class)
    public ModelAndView handleError(HttpServletRequest request, Exception e)
    {
        ModelAndView mav = new ModelAndView("/errors/500"));
        mav.addObject("exception", e);
        return mav;
    }
}

But now I want a different handling for JSON requests so I get JSON error responses for this kind of requests when an exception occurred. Currently the above code is also triggered by JSON requests (Using an Accept: application/json header) and the JavaScript client doesn't like the HTML response.

How can I handle exceptions differently for HTML and JSON requests?

Upvotes: 32

Views: 18874

Answers (7)

Brent Bradburn
Brent Bradburn

Reputation: 54899

The trick is to have a REST controller with two mappings, one of which specifies "text/html" and returns a valid HTML source. The example below, which was tested in Spring Boot 2.0, assumes the existence of a separate template named "error.html".

@RestController
public class CustomErrorController implements ErrorController {

    @Autowired
    private ErrorAttributes errorAttributes;

    private Map<String,Object> getErrorAttributes( HttpServletRequest request ) {
        WebRequest webRequest = new ServletWebRequest(request);
        boolean includeStacktrace = false;
        return errorAttributes.getErrorAttributes(webRequest,includeStacktrace);
    }

    @GetMapping(value="/error", produces="text/html")
    ModelAndView errorHtml(HttpServletRequest request) {
        return new ModelAndView("error.html",getErrorAttributes(request));
    }

    @GetMapping(value="/error")
    Map<String,Object> error(HttpServletRequest request) {
        return getErrorAttributes(request);
    }

    @Override public String getErrorPath() { return "/error"; }

}

References

Upvotes: 2

Katharsas
Katharsas

Reputation: 560

Since i didn't find any solution for this, i wrote some code that manually checks the accept header of the request to determine the format. I then check if the user is logged in and either send the complete stacktrace if he is or a short error message.

I use ResponseEntity to be able to return both JSON or HTML like here.
Code:

@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleExceptions(Exception ex, HttpServletRequest request) throws Exception {

    final HttpHeaders headers = new HttpHeaders();
    Object answer; // String if HTML, any object if JSON
    if(jsonHasPriority(request.getHeader("accept"))) {
        logger.info("Returning exception to client as json object");
        headers.setContentType(MediaType.APPLICATION_JSON);
        answer = errorJson(ex, isUserLoggedIn());
    } else {
        logger.info("Returning exception to client as html page");
        headers.setContentType(MediaType.TEXT_HTML);
        answer = errorHtml(ex, isUserLoggedIn());
    }
    final HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    return new ResponseEntity<>(answer, headers, status);
}

private String errorHtml(Exception e, boolean isUserLoggedIn) {
    String error = // html code with exception information here
    return error;
}

private Object errorJson(Exception e, boolean isUserLoggedIn) {
    // return error wrapper object which will be converted to json
    return null;
}

/**
 * @param acceptString - HTTP accept header field, format according to HTTP spec:
 *      "mime1;quality1,mime2;quality2,mime3,mime4,..." (quality is optional)
 * @return true only if json is the MIME type with highest quality of all specified MIME types.
 */
private boolean jsonHasPriority(String acceptString) {
    if (acceptString != null) {
        final String[] mimes = acceptString.split(",");
        Arrays.sort(mimes, new MimeQualityComparator());
        final String firstMime = mimes[0].split(";")[0];
        return firstMime.equals("application/json");
    }
    return false;
}

private static class MimeQualityComparator implements Comparator<String> {
    @Override
    public int compare(String mime1, String mime2) {
        final double m1Quality = getQualityofMime(mime1);
        final double m2Quality = getQualityofMime(mime2);
        return Double.compare(m1Quality, m2Quality) * -1;
    }
}

/**
 * @param mimeAndQuality - "mime;quality" pair from the accept header of a HTTP request,
 *      according to HTTP spec (missing mimeQuality means quality = 1).
 * @return quality of this pair according to HTTP spec.
 */
private static Double getQualityofMime(String mimeAndQuality) {
    //split off quality factor
    final String[] mime = mimeAndQuality.split(";");
    if (mime.length <= 1) {
        return 1.0;
    } else {
        final String quality = mime[1].split("=")[1];
        return Double.parseDouble(quality);
    }
}

Upvotes: 2

dnang
dnang

Reputation: 939

The ControllerAdvice annotation has an element/attribute called basePackage which can be set to determine which packages it should scan for Controllers and apply the advices. So, what you can do is to separate those Controllers handling normal requests and those handling AJAX requests into different packages then write 2 Exception Handling Controllers with appropriate ControllerAdvice annotations. For example:

@ControllerAdvice("com.acme.webapp.ajaxcontrollers")
public class AjaxExceptionHandlingController {
...
@ControllerAdvice("com.acme.webapp.controllers")
public class ExceptionHandlingController {

Upvotes: 14

Daniel
Daniel

Reputation: 209

As you have the HttpServletRequest, you should be able to get the request "Accept" header. Then you could process the exception based on it.

Something like:

String header = request.getHeader("Accept");
if(header != null && header.equals("application/json")) {
    // Process JSON exception
} else {
    ModelAndView mav = new ModelAndView("/errors/500"));
    mav.addObject("exception", e);
    return mav;
}

Upvotes: 2

Michiel
Michiel

Reputation: 1051

Use @ControllerAdvice Let the exception handler send a DTO containing the field errors.

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ValidationErrorDTO processValidationError(MethodArgumentNotValidException ex) {
    BindingResult result = ex.getBindingResult();
    List<FieldError> fieldErrors = result.getFieldErrors();

    return processFieldErrors(fieldErrors);
}

This code is of this website:http://www.petrikainulainen.net/programming/spring-framework/spring-from-the-trenches-adding-validation-to-a-rest-api/ Look there for more info.

Upvotes: 0

Dave Syer
Dave Syer

Reputation: 58094

The best way to do this (especially in servlet 3) is to register an error page with the container, and use that to call a Spring @Controller. That way you get to handle different response types in a standard Spring MVC way (e.g. using @RequestMapping with produces=... for your machine clients).

I see from your other question that you are using Spring Boot. If you upgrade to a snapshot (1.1 or better in other words) you get this behaviour out of the box (see BasicErrorController). If you want to override it you just need to map the /error path to your own @Controller.

Upvotes: 8

Martin Frey
Martin Frey

Reputation: 10075

The controlleradvice annotation has several properties that can be set, since spring 4. You can define multiple controller advices applying different rules.

One property is "annotations. Probably you can use a specific annotation on the json request mapping or you might find another property more usefull?

Upvotes: 0

Related Questions