ApiExceptionResolver.java
/*
* Copyright 2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.bremersee.web.servlet;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.bremersee.exception.RestApiExceptionMapper;
import org.bremersee.exception.RestApiExceptionUtils;
import org.bremersee.exception.model.RestApiException;
import org.bremersee.http.MediaTypeHelper;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.AbstractView;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
import org.springframework.web.servlet.view.xml.MappingJackson2XmlView;
import org.springframework.web.util.WebUtils;
/**
* The api exception resolver.
*
* @author Christian Bremer
*/
@Validated
@Slf4j
public class ApiExceptionResolver implements HandlerExceptionResolver {
/**
* The constant MODEL_KEY.
*/
protected static final String MODEL_KEY = "error";
@Getter(AccessLevel.PROTECTED)
@Setter
private PathMatcher pathMatcher = new AntPathMatcher();
@Getter(AccessLevel.PROTECTED)
private final RestApiExceptionMapper exceptionMapper;
@Getter(AccessLevel.PROTECTED)
private final ObjectMapper objectMapper;
@Getter(AccessLevel.PROTECTED)
private final XmlMapper xmlMapper;
/**
* Instantiates a new api exception resolver.
*
* @param exceptionMapper the exception mapper
*/
public ApiExceptionResolver(
RestApiExceptionMapper exceptionMapper) {
this.exceptionMapper = exceptionMapper;
this.objectMapper = Jackson2ObjectMapperBuilder.json().build();
this.xmlMapper = Jackson2ObjectMapperBuilder.xml().createXmlMapper(true).build();
}
/**
* Instantiates a new api exception resolver.
*
* @param exceptionMapper the exception mapper
* @param objectMapperBuilder the object mapper builder
*/
public ApiExceptionResolver(
RestApiExceptionMapper exceptionMapper,
Jackson2ObjectMapperBuilder objectMapperBuilder) {
this.exceptionMapper = exceptionMapper;
this.objectMapper = objectMapperBuilder.build();
this.xmlMapper = objectMapperBuilder.createXmlMapper(true).build();
}
/**
* Instantiates a new api exception resolver.
*
* @param exceptionMapper the exception mapper
* @param objectMapper the object mapper
* @param xmlMapper the xml mapper
*/
public ApiExceptionResolver(
RestApiExceptionMapper exceptionMapper,
ObjectMapper objectMapper,
XmlMapper xmlMapper) {
this.exceptionMapper = exceptionMapper;
this.objectMapper = objectMapper;
this.xmlMapper = xmlMapper;
}
@Override
public ModelAndView resolveException(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@Nullable Object handler,
@NonNull Exception ex) {
if (!isExceptionHandlerResponsible(request, handler)) {
return null;
}
RestApiException payload = exceptionMapper.build(ex, request.getRequestURI(), handler);
ModelAndView modelAndView;
ResponseFormatAndContentType chooser = new ResponseFormatAndContentType(request);
switch (chooser.getResponseFormat()) {
case JSON:
MappingJackson2JsonView mjv = new MappingJackson2JsonView(objectMapper);
mjv.setContentType(chooser.getContentType());
mjv.setPrettyPrint(true);
mjv.setModelKey(MODEL_KEY);
mjv.setExtractValueFromSingleKeyModel(true); // removes the MODEL_KEY from the output
modelAndView = new ModelAndView(mjv, MODEL_KEY, payload);
break;
case XML:
MappingJackson2XmlView mxv = new MappingJackson2XmlView(xmlMapper);
mxv.setContentType(chooser.getContentType());
mxv.setPrettyPrint(true);
mxv.setModelKey(MODEL_KEY);
modelAndView = new ModelAndView(mxv, MODEL_KEY, payload);
break;
default:
modelAndView = new ModelAndView(new EmptyView(payload, chooser.getContentType()));
}
response.setContentType(chooser.getContentType());
int statusCode = exceptionMapper.detectHttpStatus(ex, handler).value();
modelAndView.setStatus(HttpStatus.resolve(statusCode));
applyStatusCodeIfPossible(request, response, statusCode);
return modelAndView;
}
/**
* Is this exception handler responsible.
*
* @param request the request
* @param handler the handler
* @return {@code true} if it is responsible, otherwise {@code false}
*/
@SuppressWarnings("WeakerAccess")
protected boolean isExceptionHandlerResponsible(
HttpServletRequest request,
@Nullable Object handler) {
if (!exceptionMapper.getApiPaths().isEmpty()) {
return exceptionMapper.getApiPaths().stream().anyMatch(
s -> pathMatcher.match(s, request.getServletPath()));
}
if (handler == null) {
return false;
}
Class<?> cls = handler instanceof HandlerMethod
? ((HandlerMethod) handler).getBean().getClass()
: handler.getClass();
boolean result = AnnotationUtils.findAnnotation(cls, RestController.class) != null;
if (log.isDebugEnabled()) {
log.debug("Is handler [" + handler + "] a rest controller? " + result);
}
return result;
}
/**
* Apply status code if possible.
*
* @param request the request
* @param response the response
* @param statusCode the status code
*/
@SuppressWarnings("WeakerAccess")
protected final void applyStatusCodeIfPossible(
HttpServletRequest request,
HttpServletResponse response,
int statusCode) {
if (!WebUtils.isIncludeRequest(request)) {
if (log.isDebugEnabled()) {
log.debug("Applying HTTP status code " + statusCode);
}
response.setStatus(statusCode);
request.setAttribute(WebUtils.ERROR_STATUS_CODE_ATTRIBUTE, statusCode);
}
}
/**
* The response format.
*/
enum ResponseFormat {
/**
* Json response format.
*/
JSON,
/**
* Xml response format.
*/
XML,
/**
* Empty response format.
*/
EMPTY
}
/**
* The response format and content type.
*/
static class ResponseFormatAndContentType {
@Getter(AccessLevel.PROTECTED)
private final ResponseFormat responseFormat;
@Getter(AccessLevel.PROTECTED)
private final String contentType;
/**
* Instantiates a new response format and content type.
*
* @param request the request
*/
ResponseFormatAndContentType(@NotNull HttpServletRequest request) {
String acceptHeader = request.getHeader(HttpHeaders.ACCEPT);
if (MediaTypeHelper.canContentTypeBeJson(acceptHeader)) {
responseFormat = ResponseFormat.JSON;
contentType = MediaType.APPLICATION_JSON_VALUE;
} else if (MediaTypeHelper.canContentTypeBeXml(acceptHeader)) {
responseFormat = ResponseFormat.XML;
contentType = MediaType.APPLICATION_XML_VALUE;
} else {
responseFormat = ResponseFormat.EMPTY;
if (StringUtils.hasText(acceptHeader)) {
List<MediaType> accepts = MediaType.parseMediaTypes(acceptHeader);
contentType = String
.valueOf(MediaTypeHelper.findContentType(accepts, MediaType.TEXT_PLAIN));
} else {
contentType = MediaType.TEXT_PLAIN_VALUE;
}
}
}
}
/**
* The empty view.
*/
static class EmptyView extends AbstractView {
/**
* The rest api exception.
*/
final RestApiException restApiException;
/**
* Instantiates a new empty view.
*
* @param payload the payload
* @param contentType the content type
*/
EmptyView(@NotNull RestApiException payload, String contentType) {
this.restApiException = payload;
setContentType(contentType);
}
@Override
protected void renderMergedOutputModel(
@Nullable Map<String, Object> map,
@NonNull HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
httpServletResponse.addHeader(RestApiExceptionUtils.ID_HEADER_NAME,
StringUtils.hasText(restApiException.getId())
? restApiException.getId()
: RestApiExceptionUtils.NO_ID_VALUE);
httpServletResponse.addHeader(RestApiExceptionUtils.TIMESTAMP_HEADER_NAME,
restApiException.getTimestamp() != null
? restApiException.getTimestamp().format(RestApiExceptionUtils.TIMESTAMP_FORMATTER)
: OffsetDateTime.now(ZoneId.of("UTC")).format(
RestApiExceptionUtils.TIMESTAMP_FORMATTER));
httpServletResponse.addHeader(RestApiExceptionUtils.MESSAGE_HEADER_NAME,
StringUtils.hasText(restApiException.getMessage())
? restApiException.getMessage()
: RestApiExceptionUtils.NO_MESSAGE_VALUE);
httpServletResponse.addHeader(RestApiExceptionUtils.CODE_HEADER_NAME,
StringUtils.hasText(restApiException.getErrorCode())
? restApiException.getErrorCode()
: RestApiExceptionUtils.NO_ERROR_CODE_VALUE);
httpServletResponse.addHeader(RestApiExceptionUtils.CLASS_HEADER_NAME,
StringUtils.hasText(restApiException.getClassName())
? restApiException.getClassName()
: RestApiExceptionUtils.NO_CLASS_VALUE);
}
}
}