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