ApiExceptionResolver.java
/*
* Copyright 2019-2022 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.exception.servlet;
import static java.util.Objects.requireNonNullElse;
import static org.springframework.core.annotation.AnnotationUtils.findAnnotation;
import static org.springframework.util.ObjectUtils.isEmpty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.bremersee.exception.RestApiExceptionConstants;
import org.bremersee.exception.RestApiExceptionMapper;
import org.bremersee.exception.RestApiResponseType;
import org.bremersee.exception.model.RestApiException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.lang.NonNull;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
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
*/
@Slf4j
public class ApiExceptionResolver implements HandlerExceptionResolver {
/**
* The constant MODEL_KEY.
*/
protected static final String MODEL_KEY = "error";
@Getter(AccessLevel.PROTECTED)
private final List<String> apiPaths;
@Getter(AccessLevel.PROTECTED)
@Setter
private PathMatcher pathMatcher = new AntPathMatcher();
@Getter(AccessLevel.PROTECTED)
@Setter
private Function<HttpServletRequest, String> restApiExceptionIdProvider;
@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 apiPaths the api paths
* @param exceptionMapper the exception mapper
*/
public ApiExceptionResolver(
List<String> apiPaths,
RestApiExceptionMapper exceptionMapper) {
this(apiPaths, exceptionMapper, new Jackson2ObjectMapperBuilder());
}
/**
* Instantiates a new api exception resolver.
*
* @param apiPaths the api paths
* @param exceptionMapper the exception mapper
* @param objectMapperBuilder the object mapper builder
*/
public ApiExceptionResolver(
List<String> apiPaths,
RestApiExceptionMapper exceptionMapper,
Jackson2ObjectMapperBuilder objectMapperBuilder) {
this(
apiPaths,
exceptionMapper,
objectMapperBuilder.build(),
objectMapperBuilder.createXmlMapper(true).build());
}
/**
* Instantiates a new api exception resolver.
*
* @param apiPaths the api paths
* @param exceptionMapper the exception mapper
* @param objectMapper the object mapper
* @param xmlMapper the xml mapper
*/
public ApiExceptionResolver(
List<String> apiPaths,
RestApiExceptionMapper exceptionMapper,
ObjectMapper objectMapper,
XmlMapper xmlMapper) {
this.apiPaths = apiPaths;
this.exceptionMapper = exceptionMapper;
this.objectMapper = objectMapper;
this.xmlMapper = xmlMapper;
}
@Override
public ModelAndView resolveException(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
Object handler,
@NonNull Exception ex) {
if (!isExceptionHandlerResponsible(request, handler)) {
return null;
}
RestApiException payload = exceptionMapper.build(ex, request.getRequestURI(), handler);
if (!isEmpty(restApiExceptionIdProvider)) {
payload.setId(restApiExceptionIdProvider.apply(request));
}
ServletServerHttpRequest httpRequest = new ServletServerHttpRequest(request);
List<MediaType> accepted = httpRequest.getHeaders().getAccept();
RestApiResponseType responseType = RestApiResponseType.detectByAccepted(accepted);
ModelAndView modelAndView;
switch (responseType) {
case JSON:
MappingJackson2JsonView mjv = new MappingJackson2JsonView(objectMapper);
mjv.setContentType(responseType.getContentTypeValue());
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(responseType.getContentTypeValue());
mxv.setPrettyPrint(true);
mxv.setModelKey(MODEL_KEY);
modelAndView = new ModelAndView(mxv, MODEL_KEY, payload);
break;
default:
modelAndView = new ModelAndView(new EmptyView(payload, responseType.getContentTypeValue()));
}
response.setContentType(responseType.getContentTypeValue());
int statusCode = requireNonNullElse(
payload.getStatus(),
HttpStatus.INTERNAL_SERVER_ERROR.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}
*/
protected boolean isExceptionHandlerResponsible(
HttpServletRequest request,
Object handler) {
if (!isEmpty(apiPaths)) {
return apiPaths.stream().anyMatch(
s -> pathMatcher.match(s, request.getServletPath()));
}
if (isEmpty(handler)) {
return false;
}
Class<?> cls = handler instanceof HandlerMethod
? ((HandlerMethod) handler).getBean().getClass()
: handler.getClass();
boolean result = !isEmpty(findAnnotation(cls, RestController.class));
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
*/
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 empty view.
*/
protected static class EmptyView extends AbstractView {
/**
* The rest api exception.
*/
protected final RestApiException restApiException;
/**
* Instantiates a new empty view.
*
* @param payload the payload
* @param contentType the content type
*/
protected EmptyView(RestApiException payload, String contentType) {
this.restApiException = payload;
setContentType(contentType);
}
@Override
protected void renderMergedOutputModel(
@NonNull Map<String, Object> map,
@NonNull HttpServletRequest httpServletRequest,
@NonNull HttpServletResponse httpServletResponse) {
if (!isEmpty(restApiException.getId())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.ID_HEADER_NAME,
restApiException.getId());
}
String timestamp;
if (!isEmpty(restApiException.getTimestamp())) {
timestamp = restApiException.getTimestamp()
.format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
} else {
timestamp = OffsetDateTime.now(ZoneOffset.UTC)
.format(RestApiExceptionConstants.TIMESTAMP_FORMATTER);
}
httpServletResponse.addHeader(RestApiExceptionConstants.TIMESTAMP_HEADER_NAME, timestamp);
if (!isEmpty(restApiException.getErrorCode())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.CODE_HEADER_NAME,
restApiException.getErrorCode());
httpServletResponse.addHeader(
RestApiExceptionConstants.CODE_INHERITED_HEADER_NAME,
String.valueOf(restApiException.getErrorCodeInherited()));
}
if (!isEmpty(restApiException.getMessage())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.MESSAGE_HEADER_NAME,
restApiException.getMessage());
}
if (!isEmpty(restApiException.getException())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.EXCEPTION_HEADER_NAME,
restApiException.getException());
}
if (!isEmpty(restApiException.getApplication())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.APPLICATION_HEADER_NAME,
restApiException.getApplication());
}
if (!isEmpty(restApiException.getPath())) {
httpServletResponse.addHeader(
RestApiExceptionConstants.PATH_HEADER_NAME,
restApiException.getPath());
}
}
}
}