RestResponseEntityExceptionHandler.java
package net.hostsharing.hsadminng.errors;
import com.fasterxml.jackson.databind.JsonMappingException;
import lombok.RequiredArgsConstructor;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.config.RetroactiveTranslator;
import org.iban4j.Iban4jException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.lang.Nullable;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ValidationException;
import java.util.*;
import java.util.function.Function;
import static net.hostsharing.hsadminng.errors.CustomErrorResponse.*;
@ControllerAdvice
@RequiredArgsConstructor
// HOWTO error handler mapping exceptions to specific http error codes and sensible error messages
public class RestResponseEntityExceptionHandler
extends ResponseEntityExceptionHandler {
@Autowired
private final MessageTranslator messageTranslator;
@Autowired(required = false)
private final List<RetroactiveTranslator> retroactiveTranslators;
@ExceptionHandler(AccessDeniedException.class)
protected ResponseEntity<CustomErrorResponse> handleAccessDeniedException(
final AccessDeniedException exc, final WebRequest request) {
final var fullMaybeLocalizedMessage = localizedMessage(NestedExceptionUtils.getMostSpecificCause(exc));
final var sprippedMaybeLocalizedMessage = stripTechnicalDetails(fullMaybeLocalizedMessage);
return errorResponse(request, HttpStatus.UNAUTHORIZED, sprippedMaybeLocalizedMessage);
}
@ExceptionHandler(DataIntegrityViolationException.class)
protected ResponseEntity<CustomErrorResponse> handleConflict(
final RuntimeException exc, final WebRequest request) {
final var causingException = NestedExceptionUtils.getMostSpecificCause(exc);
final var fullMaybeLocalizedMessage = localizedMessage(causingException);
final var maybeTranslatedMessage = tryTranslation(fullMaybeLocalizedMessage);
final var strippedMaybeLocalizedMessage = stripTechnicalDetails(maybeTranslatedMessage);
return errorResponse(
request,
// Some DB constraints raise client errors such as ERROR: [400],
// those do not count as conflicts at the HTTP level.
httpStatus(causingException, strippedMaybeLocalizedMessage).orElse(HttpStatus.CONFLICT),
strippedMaybeLocalizedMessage);
}
@ExceptionHandler(JpaSystemException.class)
protected ResponseEntity<CustomErrorResponse> handleJpaExceptions(
final RuntimeException exc, final WebRequest request) {
final var fullMaybeLocalizedMessage = localizedMessage(NestedExceptionUtils.getMostSpecificCause(exc));
final var sprippedMaybeLocalizedMessage = stripTechnicalDetails(fullMaybeLocalizedMessage);
return errorResponse(request, httpStatus(exc, sprippedMaybeLocalizedMessage).orElse(HttpStatus.INTERNAL_SERVER_ERROR), sprippedMaybeLocalizedMessage);
}
@ExceptionHandler(NoSuchElementException.class)
protected ResponseEntity<CustomErrorResponse> handleNoSuchElementException(
final RuntimeException exc, final WebRequest request) {
final var fullMaybeLocalizedMessage = localizedMessage(NestedExceptionUtils.getMostSpecificCause(exc));
final var sprippedMaybeLocalizedMessage = stripTechnicalDetails(fullMaybeLocalizedMessage);
return errorResponse(request, HttpStatus.NOT_FOUND, sprippedMaybeLocalizedMessage);
}
@ExceptionHandler(ReferenceNotFoundException.class)
protected ResponseEntity<CustomErrorResponse> handleReferenceNotFoundException(
final ReferenceNotFoundException exc, final WebRequest request) {
return errorResponse(request, HttpStatus.BAD_REQUEST, localizedMessage(exc));
}
@ExceptionHandler(ForbiddenException.class)
protected ResponseEntity<CustomErrorResponse> handleForbiddenException(
final ForbiddenException exc, final WebRequest request) {
return errorResponse(request, HttpStatus.FORBIDDEN, localizedMessage(exc));
}
@ExceptionHandler(ConflictException.class)
protected ResponseEntity<CustomErrorResponse> handleConflictException(
final ConflictException exc, final WebRequest request) {
return errorResponse(request, HttpStatus.CONFLICT, localizedMessage(exc));
}
@ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class })
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
final RuntimeException exc, final WebRequest request) {
final var localizedMessage = localizedMessage(NestedExceptionUtils.getMostSpecificCause(exc));
final var sprippedMaybeLocalizedMessage = stripTechnicalDetails(localizedMessage);
return errorResponse(request, HttpStatus.BAD_REQUEST, sprippedMaybeLocalizedMessage);
}
@ExceptionHandler({ Iban4jException.class, ValidationException.class })
protected ResponseEntity<CustomErrorResponse> handleValidationExceptions(
final Throwable exc, final WebRequest request) {
final var localizedMessage = localizedMessage(NestedExceptionUtils.getMostSpecificCause(exc));
final var sprippedMaybeLocalizedMessage = exc instanceof MultiValidationException ? localizedMessage : stripTechnicalDetails(localizedMessage);
return errorResponse(request, HttpStatus.BAD_REQUEST, sprippedMaybeLocalizedMessage);
}
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> handleOtherExceptions(
final Throwable exc, final WebRequest request) {
final var causingException = NestedExceptionUtils.getMostSpecificCause(exc);
final var message = firstMessageLine(causingException);
return errorResponse(request, httpStatus(causingException, message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message);
}
@Override
@SuppressWarnings("unchecked,rawtypes")
protected ResponseEntity handleExceptionInternal(
Exception exc, @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
final var response = super.handleExceptionInternal(exc, body, headers, statusCode, request);
return errorResponse(request, HttpStatus.valueOf(statusCode.value()),
Optional.ofNullable(response).map(HttpEntity::getBody).map(Object::toString).orElse(firstMessageLine(exc)));
}
@Override
@SuppressWarnings("unchecked,rawtypes")
protected ResponseEntity handleHttpMessageNotReadable(
HttpMessageNotReadableException exc, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
final var localizedMessage = localizedMessage(exc);
final var strippedMaybeLocalizedMessage = stripTechnicalDetails(localizedMessage);
final var translatedMessage = tryTranslation(strippedMaybeLocalizedMessage);
final var messageWithProperty = prependRejectedProperty(
NestedExceptionUtils.getMostSpecificCause(exc), translatedMessage);
return customErrorResponse(request, HttpStatus.BAD_REQUEST, messageWithProperty);
}
private String prependRejectedProperty(final Throwable causingException, final String message) {
final var propertyPath = jsonPropertyPath(causingException);
if (propertyPath == null) {
return message;
}
return messageTranslator.translate("general.property-{0}", propertyPath) + ": " + message;
}
private static String jsonPropertyPath(final Throwable causingException) {
if (!(causingException instanceof JsonMappingException jsonMappingException)
|| jsonMappingException.getPath().isEmpty()) {
return null;
}
final var propertyPath = new StringBuilder();
for (final var reference : jsonMappingException.getPath()) {
if (reference.getFieldName() != null) {
if (propertyPath.length() > 0) {
propertyPath.append('.');
}
propertyPath.append(reference.getFieldName());
} else {
propertyPath.append('[').append(reference.getIndex()).append(']');
}
}
return propertyPath.toString();
}
@Override
@SuppressWarnings("unchecked,rawtypes")
protected ResponseEntity handleMethodArgumentNotValid(
MethodArgumentNotValidException exc,
HttpHeaders headers,
HttpStatusCode statusCode,
WebRequest request) {
final var errorList = exc
.getBindingResult()
.getFieldErrors()
.stream()
.map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() + " but is \""
+ fieldError.getRejectedValue() + "\"")
.toList();
return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString());
}
@SuppressWarnings("unchecked,rawtypes")
@Override
protected ResponseEntity handleHandlerMethodValidationException(
final HandlerMethodValidationException exc,
final HttpHeaders headers,
final HttpStatusCode status,
final WebRequest request) {
final var errorList = exc
.getParameterValidationResults()
.stream()
.map(ParameterValidationResult::getResolvableErrors)
.flatMap(Collection::stream)
.filter(FieldError.class::isInstance)
.map(FieldError.class::cast)
.map(toEnrichedFieldErrorMessage())
.toList();
return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString());
}
private Function<FieldError, String> toEnrichedFieldErrorMessage() {
final var translatedButIsLiteral = messageTranslator.translate("general.but-is");
// TODO.i18n: the following does not work in all languages, e.g. not in right-to-left languages
return fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() +
" " + translatedButIsLiteral + " " + optionallyQuoted(fieldError.getRejectedValue());
}
private String optionallyQuoted(final Object value) {
if (value == null) {
return "null";
}
if (value instanceof Number) {
return value.toString();
}
return "\"" + value + "\"";
}
private Optional<HttpStatus> httpStatus(final Throwable causingException, final String message) {
if ( EntityNotFoundException.class.isInstance(causingException) ) {
return Optional.of(HttpStatus.BAD_REQUEST);
}
if (message.startsWith("ERROR: [")) {
for (HttpStatus status : HttpStatus.values()) {
if (message.startsWith("ERROR: [" + status.value() + "]")) {
return Optional.of(status);
}
}
return Optional.of(HttpStatus.INTERNAL_SERVER_ERROR);
}
return Optional.empty();
}
private String localizedMessage(final Throwable throwable) {
// most libraries seem to provide the localized message in both properties, but just for the case:
return throwable.getLocalizedMessage() != null ? throwable.getLocalizedMessage() : throwable.getMessage();
}
private String tryTranslation(final String message) {
for ( RetroactiveTranslator rtx: retroactiveTranslators ) {
if (rtx.canTranslate(message)) {
return rtx.translate(message);
}
}
return message;
}
private ResponseEntity<CustomErrorResponse> errorResponse(
final WebRequest request,
final HttpStatus httpStatus,
final String maybeTranslatedMessage) {
return customErrorResponse(request, httpStatus, tryTranslation(maybeTranslatedMessage));
}
}