package io.oasp.module.rest.service.impl;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import javax.inject.Inject;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Path.Node;
import javax.validation.ValidationException;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.ServerErrorException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import net.sf.mmm.util.exception.api.NlsRuntimeException;
import net.sf.mmm.util.exception.api.NlsThrowable;
import net.sf.mmm.util.exception.api.TechnicalErrorUserException;
import net.sf.mmm.util.exception.api.ValidationErrorUserException;
import net.sf.mmm.util.lang.api.StringUtil;
import net.sf.mmm.util.security.api.SecurityErrorUserException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* This is an implementation of {@link ExceptionMapper} that acts as generic exception facade for REST services.
*
* the exception handling class for all upcoming exceptions thrown at REST requests. Each type of possible thrown
* exception will be fetched within the method "toResponse".
*
*/
@Provider
public class RestServiceExceptionFacade implements ExceptionMapper<Throwable> {
/** JSON key for {@link Throwable#getMessage() error message}. */
public static final String KEY_MESSAGE = "message";
/** JSON key for {@link NlsRuntimeException#getUuid() error ID}. */
public static final String KEY_UUID = "uuid";
/** JSON key for {@link NlsRuntimeException#getCode() error code}. */
public static final String KEY_CODE = "code";
/** JSON key for {@link NlsRuntimeException#getCode() errors}. */
public static final String KEY_ERRORS = "errors";
/** Logger instance. */
private static final Logger LOG = LoggerFactory.getLogger(RestServiceExceptionFacade.class);
private final List<Class<? extends Throwable>> securityExceptions;
private final Class<? extends Throwable> transactionSystemException;
private final Class<? extends Throwable> rollbackException;
private ObjectMapper mapper;
private boolean exposeInternalErrorDetails;
/**
* The constructor.
*/
public RestServiceExceptionFacade() {
super();
this.securityExceptions = new ArrayList<>();
registerToplevelSecurityExceptions();
this.transactionSystemException = loadException("org.springframework.transaction.TransactionSystemException");
this.rollbackException = loadException("javax.persistence.RollbackException");
}
/**
* Registers a {@link Class} as a top-level security {@link Throwable exception}. Instances of this class and all its
* subclasses will be handled as security errors. Therefore an according HTTP error code is used and no further
* details about the exception is send to the client to prevent
* <a href="https://www.owasp.org/index.php/Top_10_2013-A6-Sensitive_Data_Exposure">sensitive data exposure</a>.
*
* @param securityException is the {@link Class} reflecting the security error.
*/
protected void registerToplevelSecurityException(Class<? extends Throwable> securityException) {
this.securityExceptions.add(securityException);
}
/**
* This method registers the {@link #registerToplevelSecurityException(Class) top-level security exceptions}. You may
* override it to add additional or other classes.
*/
protected void registerToplevelSecurityExceptions() {
this.securityExceptions.add(SecurityException.class);
this.securityExceptions.add(SecurityErrorUserException.class);
registerToplevelSecurityExceptions("org.springframework.security.access.AccessDeniedException");
registerToplevelSecurityExceptions("org.springframework.security.authentication.AuthenticationServiceException");
registerToplevelSecurityExceptions(
"org.springframework.security.authentication.AuthenticationCredentialsNotFoundException");
registerToplevelSecurityExceptions("org.springframework.security.authentication.BadCredentialsException");
registerToplevelSecurityExceptions("org.springframework.security.authentication.AccountExpiredException");
}
/**
* @param className the className to be registered
*/
protected void registerToplevelSecurityExceptions(String className) {
Class<? extends Throwable> securityException = loadException(className);
if (securityException != null) {
registerToplevelSecurityException(securityException);
}
}
private Class<? extends Throwable> loadException(String className) {
try {
@SuppressWarnings("unchecked")
Class<? extends Throwable> exception = (Class<? extends Throwable>) Class.forName(className);
return exception;
} catch (ClassNotFoundException e) {
LOG.info("Exception {} was not found on classpath and can not be handled by this {}.", className,
getClass().getSimpleName());
} catch (Exception e) {
LOG.error("Exception {} is invalid and can not be handled by this {}.", className, getClass().getSimpleName(), e);
}
return null;
}
@Override
public Response toResponse(Throwable exception) {
if (exception instanceof WebApplicationException) {
return createResponse((WebApplicationException) exception);
} else if (exception instanceof NlsRuntimeException) {
return toResponse(exception, exception);
} else {
Throwable error = exception;
Throwable catched = exception;
error = getRollbackCause(exception);
if (error == null) {
error = unwrapNlsUserError(exception);
}
if (error == null) {
error = exception;
}
return toResponse(error, catched);
}
}
/**
* Unwraps potential NLS user error from a wrapper exception such as {@code JsonMappingException} or
* {@code PersistenceException}.
*
* @param exception the exception to unwrap.
* @return the unwrapped {@link NlsRuntimeException} exception or {@code null} if no
* {@link NlsRuntimeException#isForUser() use error}.
*/
private NlsRuntimeException unwrapNlsUserError(Throwable exception) {
Throwable cause = exception.getCause();
if (cause instanceof NlsRuntimeException) {
NlsRuntimeException nlsError = (NlsRuntimeException) cause;
if (nlsError.isForUser()) {
return nlsError;
}
}
return null;
}
private Throwable getRollbackCause(Throwable exception) {
Class<?> exceptionClass = exception.getClass();
if (exceptionClass == this.transactionSystemException) {
Throwable cause = exception.getCause();
if (cause != null) {
exceptionClass = cause.getClass();
if (exceptionClass == this.rollbackException) {
return cause.getCause();
}
}
}
return null;
}
/**
* @see #toResponse(Throwable)
*
* @param exception the exception to handle
* @param catched the original exception that was cached. Either same as {@code error} or a (child-)
* {@link Throwable#getCause() cause} of it.
* @return the response build from the exception.
*/
protected Response toResponse(Throwable exception, Throwable catched) {
if (exception instanceof ValidationException) {
return handleValidationException(exception, catched);
} else if (exception instanceof ValidationErrorUserException) {
return createResponse(exception, (ValidationErrorUserException) exception, null);
} else {
Class<?> exceptionClass = exception.getClass();
for (Class<?> securityError : this.securityExceptions) {
if (securityError.isAssignableFrom(exceptionClass)) {
return handleSecurityError(exception, catched);
}
}
return handleGenericError(exception, catched);
}
}
/**
* Creates the {@link Response} for the given validation exception.
*
* @param exception is the original validation exception.
* @param error is the wrapped exception or the same as <code>exception</code>.
* @param errorsMap is a map with all validation errors
* @return the requested {@link Response}.
*/
protected Response createResponse(Throwable exception, ValidationErrorUserException error,
Map<String, List<String>> errorsMap) {
LOG.warn("Service failed due to validation failure.", error);
if (exception == error) {
return createResponse(Status.BAD_REQUEST, error, errorsMap);
} else {
return createResponse(Status.BAD_REQUEST, error, exception.getMessage(), errorsMap);
}
}
/**
* Exception handling for generic exception (fallback).
*
* @param exception the exception to handle
* @param catched the original exception that was cached. Either same as {@code error} or a (child-)
* {@link Throwable#getCause() cause} of it.
* @return the response build from the exception
*/
protected Response handleGenericError(Throwable exception, Throwable catched) {
NlsRuntimeException userError;
boolean logged = false;
if (exception instanceof NlsThrowable) {
NlsThrowable nlsError = (NlsThrowable) exception;
if (!nlsError.isTechnical()) {
LOG.warn("Service failed due to business error: {}", nlsError.getMessage());
logged = true;
}
userError = TechnicalErrorUserException.getOrCreateUserException(exception);
} else {
userError = TechnicalErrorUserException.getOrCreateUserException(catched);
}
if (!logged) {
LOG.error("Service failed on server", userError);
}
return createResponse(userError);
}
/**
* Exception handling for security exception.
*
* @param exception the exception to handle
* @param catched the original exception that was cached. Either same as {@code error} or a (child-)
* {@link Throwable#getCause() cause} of it.
* @return the response build from exception
*/
protected Response handleSecurityError(Throwable exception, Throwable catched) {
NlsRuntimeException error;
if ((exception == catched) && (exception instanceof NlsRuntimeException)) {
error = (NlsRuntimeException) exception;
} else {
error = new SecurityErrorUserException(catched);
}
LOG.error("Service failed due to security error", error);
// NOTE: for security reasons we do not send any details about the error to the client!
String message;
String code = null;
if (this.exposeInternalErrorDetails) {
message = getExposedErrorDetails(error);
} else {
message = "forbidden";
}
return createResponse(Status.FORBIDDEN, message, code, error.getUuid(), null);
}
/**
* Exception handling for validation exception.
*
* @param exception the exception to handle
* @param catched the original exception that was cached. Either same as {@code error} or a (child-)
* {@link Throwable#getCause() cause} of it.
* @return the response build from the exception.
*/
protected Response handleValidationException(Throwable exception, Throwable catched) {
Throwable t = catched;
Map<String, List<String>> errorsMap = null;
if (exception instanceof ConstraintViolationException) {
ConstraintViolationException constraintViolationException = (ConstraintViolationException) exception;
Set<ConstraintViolation<?>> violations = constraintViolationException.getConstraintViolations();
errorsMap = new HashMap<>();
for (ConstraintViolation<?> violation : violations) {
Iterator<Node> it = violation.getPropertyPath().iterator();
String fieldName = null;
// Getting fieldname from the exception
while (it.hasNext()) {
fieldName = it.next().toString();
}
List<String> errorsList = errorsMap.get(fieldName);
if (errorsList == null) {
errorsList = new ArrayList<>();
errorsMap.put(fieldName, errorsList);
}
errorsList.add(violation.getMessage());
}
t = new ValidationException(errorsMap.toString(), catched);
}
ValidationErrorUserException error = new ValidationErrorUserException(t);
return createResponse(t, error, errorsMap);
}
/**
* @param error is the {@link Throwable} to extract message details from.
* @return the exposed message(s).
*/
protected String getExposedErrorDetails(Throwable error) {
StringBuilder buffer = new StringBuilder();
Throwable e = error;
while (e != null) {
if (buffer.length() > 0) {
buffer.append(StringUtil.LINE_SEPARATOR);
}
buffer.append(e.getClass().getSimpleName());
buffer.append(": ");
buffer.append(e.getLocalizedMessage());
e = e.getCause();
}
return buffer.toString();
}
/**
* Create the {@link Response} for the given {@link NlsRuntimeException}.
*
* @param error the generic {@link NlsRuntimeException}.
* @return the corresponding {@link Response}.
*/
protected Response createResponse(NlsRuntimeException error) {
Status status;
if (error.isTechnical()) {
status = Status.INTERNAL_SERVER_ERROR;
} else {
status = Status.BAD_REQUEST;
}
return createResponse(status, error, null);
}
/**
* Create a response message as a JSON-String from the given parts.
*
* @param status is the HTTP {@link Status}.
* @param error is the catched or wrapped {@link NlsRuntimeException}.
* @param errorsMap is a map with all validation errors
* @return the corresponding {@link Response}.
*/
protected Response createResponse(Status status, NlsRuntimeException error, Map<String, List<String>> errorsMap) {
String message;
if (this.exposeInternalErrorDetails) {
message = getExposedErrorDetails(error);
} else {
message = error.getLocalizedMessage();
}
return createResponse(status, error, message, errorsMap);
}
/**
* Create a response message as a JSON-String from the given parts.
*
* @param status is the HTTP {@link Status}.
* @param error is the catched or wrapped {@link NlsRuntimeException}.
* @param message is the JSON message attribute.
* @param errorsMap is a map with all validation errors
* @return the corresponding {@link Response}.
*/
protected Response createResponse(Status status, NlsRuntimeException error, String message,
Map<String, List<String>> errorsMap) {
return createResponse(status, error, message, error.getCode(), errorsMap);
}
/**
* Create a response message as a JSON-String from the given parts.
*
* @param status is the HTTP {@link Status}.
* @param error is the catched or wrapped {@link NlsRuntimeException}.
* @param message is the JSON message attribute.
* @param code is the {@link NlsRuntimeException#getCode() error code}.
* @param errorsMap is a map with all validation errors
* @return the corresponding {@link Response}.
*/
protected Response createResponse(Status status, NlsRuntimeException error, String message, String code,
Map<String, List<String>> errorsMap) {
return createResponse(status, message, code, error.getUuid(), errorsMap);
}
/**
* Create a response message as a JSON-String from the given parts.
*
* @param status is the HTTP {@link Status}.
* @param message is the JSON message attribute.
* @param code is the {@link NlsRuntimeException#getCode() error code}.
* @param uuid the {@link UUID} of the response message.
* @param errorsMap is a map with all validation errors
* @return the corresponding {@link Response}.
*/
protected Response createResponse(Status status, String message, String code, UUID uuid,
Map<String, List<String>> errorsMap) {
String json = createJsonErrorResponseMessage(message, code, uuid, errorsMap);
return Response.status(status).entity(json).build();
}
/**
* Create a response message as a JSON-String from the given parts.
*
* @param message the message of the response message
* @param code the code of the response message
* @param uuid the uuid of the response message
* @param errorsMap is a map with all validation errors
* @return the response message as a JSON-String
*/
protected String createJsonErrorResponseMessage(String message, String code, UUID uuid,
Map<String, List<String>> errorsMap) {
Map<String, Object> jsonMap = new HashMap<>();
if (message != null) {
jsonMap.put(KEY_MESSAGE, message);
}
if (code != null) {
jsonMap.put(KEY_CODE, code);
}
if (uuid != null) {
jsonMap.put(KEY_UUID, uuid.toString());
}
if (errorsMap != null) {
jsonMap.put(KEY_ERRORS, errorsMap);
}
String responseMessage = "";
try {
responseMessage = this.mapper.writeValueAsString(jsonMap);
} catch (JsonProcessingException e) {
LOG.error("Exception facade failed to create JSON.", e);
responseMessage = "{}";
}
return responseMessage;
}
/**
* Add a response message to an existing response.
*
* @param exception the {@link WebApplicationException}.
* @return the response with the response message added
*/
protected Response createResponse(WebApplicationException exception) {
Response response = exception.getResponse();
int statusCode = response.getStatus();
Status status = Status.fromStatusCode(statusCode);
NlsRuntimeException error;
if (exception instanceof ServerErrorException) {
error = new TechnicalErrorUserException(exception);
LOG.error("Service failed on server", error);
return createResponse(status, error, null);
} else {
UUID uuid = UUID.randomUUID();
if (exception instanceof ClientErrorException) {
LOG.warn("Service failed due to unexpected request. UUDI: {}, reason: {} ", uuid, exception.getMessage());
} else {
LOG.warn("Service caused redirect or other error. UUID: {}, reason: {}", uuid, exception.getMessage());
}
return createResponse(status, exception.getMessage(), String.valueOf(statusCode), uuid, null);
}
}
/**
* @return the {@link ObjectMapper} for JSON mapping.
*/
public ObjectMapper getMapper() {
return this.mapper;
}
/**
* @param mapper the mapper to set
*/
@Inject
public void setMapper(ObjectMapper mapper) {
this.mapper = mapper;
}
/**
* @param exposeInternalErrorDetails - {@code true} if internal exception details shall be exposed to clients (useful
* for debugging and testing), {@code false} if such details are hidden to prevent
* <a href="https://www.owasp.org/index.php/Top_10_2013-A6-Sensitive_Data_Exposure">Sensitive Data Exposure</a>
* (default, has to be used in production environment).
*/
public void setExposeInternalErrorDetails(boolean exposeInternalErrorDetails) {
this.exposeInternalErrorDetails = exposeInternalErrorDetails;
if (exposeInternalErrorDetails) {
String message =
"****** Exposing of internal error details is enabled! This violates OWASP A6 (Sensitive Data Exposure) and shall only be used for testing/debugging and never in production. ******";
LOG.warn(message);
// CHECKSTYLE:OFF (for development only)
System.err.println(message);
// CHECKSTYLE:ON
}
}
/**
* @return exposeInternalErrorDetails the value set by {@link #setExposeInternalErrorDetails(boolean)}.
*/
public boolean isExposeInternalErrorDetails() {
return this.exposeInternalErrorDetails;
}
}