/*
* Copyright 2015 herd contributors
*
* 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.finra.herd.service.helper;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.DataTruncation;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import javax.persistence.OptimisticLockException;
import javax.persistence.PersistenceException;
import javax.servlet.http.HttpServletResponse;
import org.activiti.engine.ActivitiClassLoadingException;
import org.activiti.engine.ActivitiException;
import org.activiti.engine.impl.javax.el.ELException;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.hibernate.exception.ConstraintViolationException;
import org.quartz.ObjectAlreadyExistsException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.TypeMismatchException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
import org.finra.herd.model.AlreadyExistsException;
import org.finra.herd.model.MethodNotAllowedException;
import org.finra.herd.model.ObjectNotFoundException;
import org.finra.herd.model.api.xml.ErrorInformation;
/**
* A class that handles various types of exceptions and returns summary error information containing the HTTP status, HTTP status description, and the error
* message that provides details about the error. Note that all "ExceptionHandler" annotated method that take additional parameters besides the exception itself
* should ensure that the method can handle cases when the other parameters are null. This is due to the isReportableError method which will invoke the
* exception handler methods to get the error information which is needed to determine if the exception is reportable.
*/
@Component
public class HerdErrorInformationExceptionHandler
{
/**
* The Oracle database specific error code for data too large.
*/
public static final int ORACLE_DATA_TOO_LARGE_ERROR_CODE = 12899;
/**
* The Oracle database specific error code for "can bind a LONG value only for insert into a LONG column". This could happen when the user enters a value
* that is > 4000 bytes for a VARCHAR.
*/
public static final int ORACLE_LONG_DATA_IN_LONG_COLUMN_ERROR_CODE = 1461;
/**
* Oracle specific SQL state code for generic SQL statement execution errors. https://docs.oracle.com/cd/E15817_01/appdev.111/b31228/appd.htm
*/
public static final String ORACLE_SQL_STATE_CODE_ERROR = "72000";
public static final String POSTGRES_SQL_STATE_CODE_FOREIGN_KEY_VIOLATION = "23503";
/**
* PostgreSQL specific SQL state code for string data truncation errors. http://www.postgresql.org/docs/9.3/static/errcodes-appendix.html
*/
public static final String POSTGRES_SQL_STATE_CODE_TRUNCATION_ERROR = "22001";
private static final Logger LOGGER = LoggerFactory.getLogger(HerdErrorInformationExceptionHandler.class);
// A flag that determines whether this class will log errors or not.
// When using the isReportableError method of this class, we typically don't want to enable logging which is why we're defaulting it to false.
// When using the class as a normal exception handler (e.g. via a ControllerAdvice bean that extends this class), we typically want to enable logging.
private boolean loggingEnabled = false;
// An exception handler method resolver that will resolve exception handling methods based on exceptions.
@Autowired
private ExceptionHandlerMethodResolver resolver;
/**
* Handle access denied exceptions.
*
* @param exception the exception.
*
* @return the error information.
*/
@ExceptionHandler(value = AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
@ResponseBody
public ErrorInformation handleAccessDeniedException(Exception exception)
{
return getErrorInformation(HttpStatus.FORBIDDEN, exception);
}
/**
* Handle Activiti exceptions. Note that this method properly handles a null response being passed in.
*
* @param exception the exception.
* @param response the response.
*
* @return the error information.
*/
@ExceptionHandler(value = ActivitiException.class)
@ResponseBody
public ErrorInformation handleActivitiException(Exception exception, HttpServletResponse response)
{
if ((ExceptionUtils.indexOfThrowable(exception, ActivitiClassLoadingException.class) != -1) ||
(ExceptionUtils.indexOfType(exception, ELException.class) != -1))
{
// These exceptions are caused by invalid workflow configurations (i.e. user error) so they are considered a bad request.
return getErrorInformationAndSetStatus(HttpStatus.BAD_REQUEST, exception, response);
}
else
{
// For all other exceptions, something is wrong that we weren't expecting so we'll return this as an internal server error and log the error.
logError("An Activiti error occurred.", exception);
return getErrorInformationAndSetStatus(HttpStatus.INTERNAL_SERVER_ERROR, exception, response);
}
}
/**
* Handle exceptions that result in a "bad request" status.
*/
@ExceptionHandler(value = {IllegalArgumentException.class, HttpMessageNotReadableException.class, MissingServletRequestParameterException.class,
TypeMismatchException.class, UnsupportedEncodingException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ErrorInformation handleBadRequestException(Exception exception)
{
return getErrorInformation(HttpStatus.BAD_REQUEST, exception);
}
/**
* Handle exceptions that result in a "conflict" status.
*/
@ExceptionHandler(value = {AlreadyExistsException.class, ObjectAlreadyExistsException.class, OptimisticLockException.class})
@ResponseStatus(HttpStatus.CONFLICT)
@ResponseBody
public ErrorInformation handleConflictException(Exception exception)
{
return getErrorInformation(HttpStatus.CONFLICT, exception);
}
/**
* Handle exceptions that result in an "internal server error" status.
*/
@ExceptionHandler(value = Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseBody
public ErrorInformation handleInternalServerErrorException(Exception exception)
{
logError("A general error occurred.", exception);
return getErrorInformation(HttpStatus.INTERNAL_SERVER_ERROR, exception);
}
/**
* Handle exceptions that result in a "not found" status.
*/
@ExceptionHandler(value = {org.hibernate.ObjectNotFoundException.class, ObjectNotFoundException.class})
@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseBody
public ErrorInformation handleNotFoundException(RuntimeException exception)
{
return getErrorInformation(HttpStatus.NOT_FOUND, exception);
}
/**
* Handle exceptions that result in a "operation not allowed" status.
*/
@ExceptionHandler(value = MethodNotAllowedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
@ResponseBody
public ErrorInformation handleOperationNotAllowedException(RuntimeException exception)
{
return getErrorInformation(HttpStatus.METHOD_NOT_ALLOWED, exception);
}
/**
* Handle persistence exceptions thrown by handlers. Note that this method properly handles a null response being passed in.
*
* @param exception the exception
* @param response the HTTP servlet response.
*
* @return the error information.
*/
@ExceptionHandler(value = {JpaSystemException.class, PersistenceException.class})
@ResponseBody
public ErrorInformation handlePersistenceException(Exception exception, HttpServletResponse response)
{
// Persistence exceptions typically wrap the cause which is what we're interested in to know what specific problem happened so get the root
// exception.
Throwable throwable = getRootCause(exception);
if (isDataTruncationException(throwable))
{
// This is because the data being inserted was too large for a specific column in the database. When this happens, it will be due to a bad request.
// Data truncation exceptions are thrown when we insert data that is too big for the column definition in MySQL.
// On the other hand, Oracle throws only a generic JDBC exception, but has an error code we can check.
// An alternative to using this database specific approach would be to define column lengths on the entities (e.g. @Column(length = 50))
// which should throw a consistent exception by JPA that could be caught here. The draw back to using this approach is that need to custom
// configure all column widths for all fields and keep that in sync with our DDL.
return getErrorInformationAndSetStatus(HttpStatus.BAD_REQUEST, throwable, response);
}
else if (isCausedByConstraintViolationException(exception))
{
// A constraint violation exception will not typically be the root exception, but some exception in the chain. It is thrown when we try
// to perform a database operation that violated a constraint (e.g. trying to delete a record that still has references to foreign keys
// that exist, trying to insert duplicate keys, etc.). We are using ExceptionUtils to see if it exists somewhere in the chain.
return getErrorInformationAndSetStatus(HttpStatus.BAD_REQUEST, new Exception("A constraint has been violated. Reason: " + throwable.getMessage()),
response);
}
else
{
// For all other persistence exceptions, something is wrong that we weren't expecting so we'll return this as an internal server error.
logError("A persistence error occurred.", exception);
return getErrorInformationAndSetStatus(HttpStatus.INTERNAL_SERVER_ERROR, throwable == null ? new Exception("General Error") : throwable, response);
}
}
public boolean isLoggingEnabled()
{
return loggingEnabled;
}
public void setLoggingEnabled(boolean loggingEnabled)
{
this.loggingEnabled = loggingEnabled;
}
/**
* Returns whether the specified exception is one that should be reported (i.e. a support team should be notified in some way).
*
* @param exception the exception to analyze.
*
* @return true if the exception is reportable or false if not.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public boolean isReportableError(Throwable exception)
{
// By default, the exception is reportable (i.e. the safe route).
boolean isReportable = true;
// Only proceed if we have an exception (as opposed to another Throwable) since the exception resolver only works off "exceptions".
if (exception instanceof Exception)
{
// Try to resolve the exception which should yield a method on our exception handler (i.e. this class).
Method method = resolver.resolveMethod((Exception) exception);
// Only proceed if we found a valid method and it returns error information. Error information is needed to make the determination
// whether or not the exception is reportable.
if ((method != null) && (ErrorInformation.class.isAssignableFrom(method.getReturnType())))
{
// Create a list of parameters we will need to pass to the method being invoking.
List<Object> parameterValues = new ArrayList<>();
// Get the method parameters as an array of "classes".
Class[] parameterTypes = method.getParameterTypes();
// Loop through the method class parameter types and add a parameter value for each one.
// The parameter will be the exception itself or null for all other cases. Note that we need to ensure that if the handler method takes
// additional parameters besides exceptions (i.e. the ones we will pass null), that the handler method will "handle" the null case
// and not throw a null pointer exception.
for (Class clazz : parameterTypes)
{
if (clazz.isAssignableFrom(exception.getClass()))
{
// The parameter class is assignable from the actual exception so pass the exception itself.
parameterValues.add(exception);
}
else
{
// The parameter class is something else so pass null.
parameterValues.add(null);
}
}
try
{
// Invoke the handler method specific to our exception and get the error information back.
ErrorInformation errorInformation = (ErrorInformation) method.invoke(this, parameterValues.toArray());
// The only error information status that is reportable is "internal server error" so set the flag to false for all other cases.
if (errorInformation.getStatusCode() != HttpStatus.INTERNAL_SERVER_ERROR.value())
{
isReportable = false;
}
}
catch (IllegalAccessException | InvocationTargetException ex)
{
logError("Unable to invoke method \"" + method.getDeclaringClass().getName() + "." + method.getName() +
"\" so couldn't determine if exception is reportable. Defaulting to true.", ex);
}
}
}
// Return whether the error should be reported.
return isReportable;
}
/**
* Gets a new error information based on the specified message.
*
* @param httpStatus the status of the error.
* @param exception the exception whose message will be used.
*
* @return the error information.
*/
private ErrorInformation getErrorInformation(HttpStatus httpStatus, Throwable exception)
{
ErrorInformation errorInformation = new ErrorInformation();
errorInformation.setStatusCode(httpStatus.value());
errorInformation.setStatusDescription(httpStatus.getReasonPhrase());
String errorMessage = exception.getMessage();
if (StringUtils.isEmpty(errorMessage))
{
errorMessage = exception.getClass().getName();
}
errorInformation.setMessage(errorMessage);
List<String> messageDetails = new ArrayList<>();
Throwable causeException = exception.getCause();
while (causeException != null)
{
messageDetails.add(causeException.getMessage());
causeException = causeException.getCause();
}
errorInformation.setMessageDetails(messageDetails);
return errorInformation;
}
/**
* Gets a new error information based on the specified message and sets the HTTP status on the HTTP response.
*
* @param httpStatus the status of the error.
* @param exception the exception whose message will be used.
* @param response the optional HTTP response that will have its status set from the specified httpStatus.
*
* @return the error information.
*/
private ErrorInformation getErrorInformationAndSetStatus(HttpStatus httpStatus, Throwable exception, HttpServletResponse response)
{
// Set the status one response if one was passed in.
if (response != null)
{
response.setStatus(httpStatus.value());
}
// Get the error information based on the status and error message.
return getErrorInformation(httpStatus, exception);
}
/**
* Gets the root cause of the given exception. If the given exception does not have any causes (that is, is already root), returns the given exception.
*
* @param throwable - the exception to get the root cause
*
* @return the root cause exception
*/
private Throwable getRootCause(Exception throwable)
{
Throwable rootThrowable = ExceptionUtils.getRootCause(throwable);
if (rootThrowable == null)
{
// Use the original exception if there are no causes.
rootThrowable = throwable;
}
return rootThrowable;
}
/**
* Returns {@code true} if the given throwable is or is not caused by a database constraint violation.
*
* @param exception - throwable to check.
*
* @return {@code true} if is constraint violation, {@code false} otherwise.
*/
private boolean isCausedByConstraintViolationException(Exception exception)
{
// some databases will throw ConstraintViolationException
boolean isConstraintViolation = ExceptionUtils.indexOfThrowable(exception, ConstraintViolationException.class) != -1;
// other databases will not throw a nice exception
if (!isConstraintViolation)
{
// We must manually check the error codes
Throwable rootThrowable = getRootCause(exception);
if (rootThrowable instanceof SQLException)
{
SQLException sqlException = (SQLException) rootThrowable;
isConstraintViolation = POSTGRES_SQL_STATE_CODE_FOREIGN_KEY_VIOLATION.equals(sqlException.getSQLState());
}
}
return isConstraintViolation;
}
/**
* Returns {@code true} if the given throwable is a data truncation exception. This method does not check the causes of the given throwable.
* <p/>
* This method will check the status codes and error codes of the underlying {@link SQLException}.
*
* @param throwable - throwable to check
*
* @return {@code true} if error is data truncation error, {@code false} otherwise.
*/
private boolean isDataTruncationException(Throwable throwable)
{
boolean isDataTruncationException = false;
// Exception must be a SQLException
if (throwable instanceof SQLException)
{
SQLException sqlException = (SQLException) throwable;
if (sqlException instanceof DataTruncation)
{
// Some drivers throw nice data truncation errors (e.g. MySQL).
isDataTruncationException = true;
}
else
{
// If drivers don't throw nice errors, we need to examine error codes.
// Check SQL state first to see what kind of error it is.
switch (sqlException.getSQLState())
{
// Oracle depends on error codes.
case ORACLE_SQL_STATE_CODE_ERROR:
switch (sqlException.getErrorCode())
{
// Oracle throws different error codes depending on whether the length was <= 4000 or not
case ORACLE_DATA_TOO_LARGE_ERROR_CODE:
case ORACLE_LONG_DATA_IN_LONG_COLUMN_ERROR_CODE:
isDataTruncationException = true;
break;
// In all other cases, assume it is not a data truncation exception.
default:
isDataTruncationException = false;
break;
}
break;
// Postgres does not use error codes.
case POSTGRES_SQL_STATE_CODE_TRUNCATION_ERROR:
isDataTruncationException = true;
break;
// In all other cases, assume it is not a data truncation exception.
default:
isDataTruncationException = false;
break;
}
}
}
return isDataTruncationException;
}
/**
* Logs an error if logging is enabled. Otherwise, no logging is performed.
*
* @param message the message to log.
* @param exception the exception to log along with the message.
*/
protected void logError(String message, Exception exception)
{
if (isLoggingEnabled())
{
LOGGER.error(message, exception);
}
}
}