Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Spring Error Controller response with Not Acceptable

I have build an error controller that should be the "last line" for catching exceptions within my Spring REST service. However it seems that I can't return POJOs as response type. Why does Jackson not working for this case?

My Class looks like:

@RestController
public class CustomErrorController implements ErrorController
{
  private static final String PATH = "/error";

  @Override
  public String getErrorPath()
  {
     return PATH;
  }


  @RequestMapping (value = PATH)
  public ResponseEntity<WebErrorResponse> handleError(HttpStatus status, HttpServletRequest request)
  {
     WebErrorResponse response = new WebErrorResponse();

    // original requested URI
    String uri = String.valueOf(request.getAttribute(RequestDispatcher.FORWARD_REQUEST_URI));
    // status code
    String code = String.valueOf(request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE));
    // status message
    String msg = String.valueOf(request.getAttribute(RequestDispatcher.ERROR_MESSAGE));

    response.title = "Internal Server Error";
    response.type = request.getMethod() + ": " + uri;
    response.code = Integer.valueOf(code);
    response.message = msg;

    // build headers
    HttpHeaders headers = new HttpHeaders();

    headers.setContentType(MediaType.APPLICATION_JSON_UTF8);

    // build the response
    return new ResponseEntity<>(response, headers, status);
}

public class WebErrorResponse
{
/**
 * The error message.
 */
public String message;

/**
 * The status code.
 */
public int code;

/**
 * The error title.
 */
public String title;

/**
 * The error type.
 */
public String type;
}

This should work but the only response is a Jetty Error Message with 406 - Not Acceptable.

Changing the the response entity body type to String works perfect. Whats wrong? Maybe this is a bug?

P.S: Working with Spring 4.2.8, Spring Boot 1.3.8.

like image 288
Zipunrar Avatar asked Jan 12 '17 15:01

Zipunrar


2 Answers

FINAL SOLUTION

After many try-and-error loops and round trips in Google I finally found a solution that does what I want. The main problems with error handling in Spring are caused by the default behavior and the small documentation.

Using only Spring without Spring Boot is no problem. But using both to build a web (REST) service is like hell.

So I want to share my solution to help everybody coming accross the same b**lsh*t...

What you will need is:

  • a Spring Java configuration class
  • an exception handler for spring (using @ControllerAdvice and extending ResponseEntityExceptionHandler)
  • an error controller (using @Controller and extending AbstractErrorController)
  • a simple POJO to generate error responses via Jackson (optional)

Configuration (cutout important parts)

@Configuration
public class SpringConfig extends WebMvcConfigurerAdapter
{
   // ... init stuff if needed

@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer)
{
    // setup content negotiation (automatic detection of content types)
    configurer
            // use format parameter and extension to detect mimetype
            .favorPathExtension(true).favorParameter(true)
            // set default mimetype
            .defaultContentType(MediaType.APPLICATION_XML)
            .mediaType(...)
            // and so on ....
 }

 /**
 * Configuration of the {@link DispatcherServlet} bean.
 *
 * <p>This is needed because Spring and Spring Boot auto-configuration override each other.</p>
 *
 * @see <a href="http://stackoverflow.com/questions/28902374/spring-boot-rest-service-exception-handling">
 *      Stackoverflow - Spring Boot REST service exception handling</a>
 *
 * @param dispatcher dispatcher servlet instance
 */
@Autowired
@SuppressWarnings ("SpringJavaAutowiringInspection")
public void setupDispatcherServlet(DispatcherServlet dispatcher)
{
    // FIX: for global REST error handling
    // enable exceptions if endpoint not found (instead of static error page)
    dispatcher.setThrowExceptionIfNoHandlerFound(true);
}

/**
 * Creates the error properties used to setup the global REST error controller.
 *
 * <p>Using {@link ErrorProperties} is compliant to base implementation if Spring Boot's
 * {@link org.springframework.boot.autoconfigure.web.BasicErrorController}.</p>
 *
 *
 * @return error properties
 */
@Bean
public ErrorProperties errorProperties()
{
    ErrorProperties properties = new ErrorProperties();

    properties.setIncludeStacktrace(ErrorProperties.IncludeStacktrace.NEVER);
    properties.setPath("/error");

    return properties;
}
// ...
}

The Spring exception handler:

@ControllerAdvice(annotations = RestController.class)
public class WebExceptionHandler extends ResponseEntityExceptionHandler
{
/**
 * This function handles the exceptions.
 *
 * @param e the thrown exception
 *
 * @return error message as XML-document
 */
@ExceptionHandler (Exception.class)
public ResponseEntity<Object> handleErrorResponse(Exception e)
{
    logger.trace("Catching Exception in REST API.", e);

    return handleExceptionInternal(e, null, null, null, null);
}

@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
                                                         Object body,
                                                         HttpHeaders headers,
                                                         HttpStatus status,
                                                         WebRequest request)
{
    logger.trace("Catching Spring Exception in REST API.");
    logger.debug("Using " + getClass().getSimpleName() + " for exception handling.");

    // fatal, should not happen
    if(ex == null) throw new NullPointerException("empty exception");

    // set defaults
    String title = "API Error";
    String msg   = ex.getMessage();

    if(status == null) status = HttpStatus.BAD_REQUEST;

    // build response body
    WebErrorResponse response = new WebErrorResponse();

    response.type = ex.getClass().getSimpleName();
    response.title = title;
    response.message = msg;
    response.code = status.value();

    // build response headers
    if(headers == null) headers = new HttpHeaders();

    try {
        headers.setContentType(getContentType(request));
    }
    catch(NullPointerException e)
    {
        // ignore (empty headers will result in default)
    }
    catch(IllegalArgumentException e)
    {
        // return only status code
        return new ResponseEntity<>(status);
    }

    return new ResponseEntity<>(response, headers, status);
}

/**
 * Checks the given request and returns the matching response content type
 * or throws an exceptions if the requested content type could not be delivered.
 *
 * @param request current request
 *
 * @return response content type matching the request
 *
 * @throws NullPointerException     if the request does not an accept header field
 * @throws IllegalArgumentException if the requested content type is not supported
 */
private static MediaType getContentType(WebRequest request) throws NullPointerException, IllegalArgumentException
{
    String accepts = request.getHeader(HttpHeaders.ACCEPT);

    if(accepts==null) throw new NullPointerException();

    // XML
    if(accepts.contains(MediaType.APPLICATION_XML_VALUE) ||
       accepts.contains(MediaType.TEXT_XML_VALUE) ||
       accepts.contains(MediaType.APPLICATION_XHTML_XML_VALUE))
        return MediaType.APPLICATION_XML;
    // JSON
    else if(accepts.contains(MediaType.APPLICATION_JSON_VALUE))
        return MediaType.APPLICATION_JSON_UTF8;
    // other
    else throw new IllegalArgumentException();
}
}

And the error controller for Spring Boot:

@Controller
@RequestMapping("/error")
public class CustomErrorController extends AbstractErrorController
{
    protected final Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * The global settings for this error controller.
     */
    private final ErrorProperties properties;

    /**
     * Bean constructor.
     *
     * @param properties global properties
     * @param attributes default error attributes
     */
    @Autowired
    public CustomErrorController(ErrorProperties properties, ErrorAttributes attributes)
    {
        super(attributes);

        this.properties = new ErrorProperties();
    }

    @Override
    public String getErrorPath()
    {
        return this.properties.getPath();
    }

    /**
     * Returns the configuration properties of this controller.
     *
     * @return error properties
     */
    public ErrorProperties getErrorProperties()
    {
        return this.properties;
    }

    /**
     * This function handles runtime and application errors.
     *
     * @param request the incorrect request instance
     *
     * @return error message as XML-document
     */
    @RequestMapping (produces = {MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_XML_VALUE})
    @ResponseBody
    public ResponseEntity<Object> handleError(HttpServletRequest request)
    {
        logger.trace("Catching Exception in REST API.");
        logger.debug("Using {} for exception handling." , getClass().getSimpleName());

        // original requested REST endpoint
        String endpoint = String.valueOf(request.getAttribute(RequestDispatcher.FORWARD_REQUEST_URI));
        // status code
        String code = String.valueOf(request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE));
        // thrown exception
        Exception ex = ((Exception) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION));

        if(ex == null) {
            ex = new RuntimeException(String.valueOf(request.getAttribute(RequestDispatcher.ERROR_MESSAGE)));
        }

        // release nested exceptions (we want source exception only)
        if(ex instanceof NestedServletException && ex.getCause() instanceof Exception) {
            ex = (Exception) ex.getCause();
        }

        // build response body
        WebErrorResponse response = new WebErrorResponse();

        response.title   = "Internal Server Error";
        response.type    = ex.getClass().getSimpleName();
        response.code    = Integer.valueOf(code);
        response.message = request.getMethod() + ": " + endpoint+"; "+ex.getMessage();

        // build response headers
        HttpHeaders headers = new HttpHeaders();

        headers.setContentType(getResponseType(request));

        // build the response
        return new ResponseEntity<>(response, headers, getStatus(request));
    }

    /*@RequestMapping (produces = {MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_XML_VALUE})
    public ResponseEntity<Map<String, Object>> handleError(HttpServletRequest request)
    {
        Boolean stacktrace = properties.getIncludeStacktrace().equals(ErrorProperties.IncludeStacktrace.ALWAYS);

        Map<String, Object> r = getErrorAttributes(request, stacktrace);

        return new ResponseEntity<Map<String, Object>>(r, getStatus(request));
    }*/

    /**
     * Extracts the response content type from the "Accept" HTTP header field.
     *
     * @param request request instance
     *
     * @return response content type
     */
    private MediaType getResponseType(HttpServletRequest request)
    {
        String accepts = request.getHeader(HttpHeaders.ACCEPT);

        // only XML or JSON allowed
        if(accepts.contains(MediaType.APPLICATION_JSON_VALUE))
            return MediaType.APPLICATION_JSON_UTF8;
        else return MediaType.APPLICATION_XML;
    }
}

So thats it, the POJO WebErrorResponse is a plain class using only public Strings and int fields.

The above classes works for a REST API that support XML and JSON. How it works:

  • exceptions from controllers (custom and application logic) will be handled by the Spring exception handler
  • exceptions from Spring will be handled by the Spring exception handler (e.g. missing parameter)
  • 404 (missing endpoint) will be handled by Spring Boot error controller
  • mimetype issues (e.g. requesting an image/png but throwing an exception) will be first moved to Spring excpetion handler and then redirected to the Spring Boot error controller (due to mimetype exception)

I hope that will clarify things for others that are confused as me.

Best regards,

Zipunrar

like image 119
Zipunrar Avatar answered Nov 01 '22 08:11

Zipunrar


This is a content negotiation issue. Basically, the request is asking for a response in a specific format, and the server is saying it cannot deliver the response in that format.

There are a couple things that could be the problem here.

  1. Your request is not specifying application/json for the Accept header.
  2. Your request does specify an Accept header with a value of application/json, but the Spring Web MVC configuration is not setup to handle JSON content types.

In the first case, it would be wise to specify the type that the requester can handle. I would do this whether or not it is your exact issue.

In the second case, Spring defaults to do content negotiation via XML. You can modify this default behavior by adding a WebMvcConfigurer to your ApplicationContext:

public class WebConfig extends WebMvcConfigurerAdapter {
  @Override
  public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
    configurer.defaultContentType(MediaType.APPLICATION_JSON);
  }
}

Further, it would be wise to be more explicit on your @RequestMapping annotations. Make sure to utilize the 'hint' parameters of consumes and produces (which assists with mapping requests via the Accepts and Content-type request headers).

like image 29
nicholas.hauschild Avatar answered Nov 01 '22 07:11

nicholas.hauschild