Commit 7194bb00 authored by Colin DAMON's avatar Colin DAMON
Browse files

Custom errors management

parent 51b24085
......@@ -245,10 +245,6 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>problem-spring-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
......@@ -305,6 +301,11 @@
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
......
package com.ippon.borestop.common.domain.error;
public final class Assert {
private Assert() {}
public static void notNull(String field, Object input) {
if (input == null) {
throw MissingMandatoryValueException.forNullValue(field);
}
}
public static void notBlank(String field, String input) {
notNull(field, input);
if (input.isBlank()) {
throw MissingMandatoryValueException.forBlankValue(field);
}
}
}
package com.ippon.borestop.common.domain.error;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* Parent exception used in Borestop application. Those exceptions will be resolved as human readable errors.
*
* <p>
* You can use this implementation directly:
* </p>
*
* <p>
* <code>
* <pre>
* BorestopException.builder(StandardMessages.USER_MANDATORY)
* .argument("key", "value")
* .argument("other", "test")
* .status(ErrorsHttpStatus.BAD_REQUEST)
* .message("Error message")
* .cause(new RuntimeException())
* .build();
* </pre>
* </code>
* </p>
*
* <p>
* Or make extension exceptions:
* </p>
*
* <p>
* <code>
* <pre>
* public class MissingMandatoryValueException extends BorestopException {
*
* public MissingMandatoryValueException(BorestopMessage borestopMessage, String fieldName) {
* this(builder(borestopMessage, fieldName, defaultMessage(fieldName)));
* }
*
* protected MissingMandatoryValueException(BorestopExceptionBuilder builder) {
* super(builder);
* }
*
* private static BorestopExceptionBuilder builder(BorestopMessage borestopMessage, String fieldName, String message) {
* return BorestopException.builder(borestopMessage)
* .status(ErrorsHttpStatus.INTERNAL_SERVER_ERROR)
* .argument("field", fieldName)
* .message(message);
* }
*
* private static String defaultMessage(String fieldName) {
* return "The field \"" + fieldName + "\" is mandatory and wasn't set";
* }
* }
* </pre>
* </code>
* </p>
*/
public class BorestopException extends RuntimeException {
private final Map<String, String> arguments;
private final ErrorStatus status;
private final BorestopMessage borestopMessage;
protected BorestopException(BorestopExceptionBuilder builder) {
super(getMessage(builder), getCause(builder));
arguments = getArguments(builder);
status = getStatus(builder);
borestopMessage = getBorestopMessage(builder);
}
private static String getMessage(BorestopExceptionBuilder builder) {
if (builder == null) {
return null;
}
return builder.message;
}
private static Throwable getCause(BorestopExceptionBuilder builder) {
if (builder == null) {
return null;
}
return builder.cause;
}
private static Map<String, String> getArguments(BorestopExceptionBuilder builder) {
if (builder == null) {
return null;
}
return Collections.unmodifiableMap(builder.arguments);
}
private static ErrorStatus getStatus(BorestopExceptionBuilder builder) {
if (builder == null) {
return null;
}
return builder.status;
}
private static BorestopMessage getBorestopMessage(BorestopExceptionBuilder builder) {
if (builder == null) {
return null;
}
return builder.borestopMessage;
}
public static BorestopExceptionBuilder builder(BorestopMessage message) {
return new BorestopExceptionBuilder(message);
}
public Map<String, String> getArguments() {
return arguments;
}
public ErrorStatus getStatus() {
return status;
}
public BorestopMessage getBorestopMessage() {
return borestopMessage;
}
public static class BorestopExceptionBuilder {
private final Map<String, String> arguments = new HashMap<>();
private String message;
private ErrorStatus status;
private BorestopMessage borestopMessage;
private Throwable cause;
public BorestopExceptionBuilder(BorestopMessage borestopMessage) {
this.borestopMessage = borestopMessage;
}
public BorestopExceptionBuilder argument(String key, Object value) {
arguments.put(key, getStringValue(value));
return this;
}
private String getStringValue(Object value) {
if (value == null) {
return "null";
}
return value.toString();
}
public BorestopExceptionBuilder status(ErrorStatus status) {
this.status = status;
return this;
}
public BorestopExceptionBuilder message(String message) {
this.message = message;
return this;
}
public BorestopExceptionBuilder cause(Throwable cause) {
this.cause = cause;
return this;
}
public BorestopException build() {
return new BorestopException(this);
}
}
}
package com.ippon.borestop.common.domain.error;
import java.io.Serializable;
@FunctionalInterface
public interface BorestopMessage extends Serializable {
String getMessageKey();
}
package com.ippon.borestop.common.domain.error;
public enum ErrorStatus {
BAD_REQUEST,
UNAUTHORIZED,
FORBIDDEN,
NOT_FOUND,
CONFLICT,
INTERNAL_SERVER_ERROR
}
package com.ippon.borestop.common.domain.error;
public class MissingMandatoryValueException extends BorestopException {
protected MissingMandatoryValueException(BorestopExceptionBuilder builder) {
super(builder);
}
private static BorestopExceptionBuilder builder(BorestopMessage borestopMessage, String fieldName, String message) {
return BorestopException
.builder(borestopMessage)
.status(ErrorStatus.INTERNAL_SERVER_ERROR)
.argument("field", fieldName)
.message(message);
}
private static String defaultMessage(String fieldName) {
return "The field \"" + fieldName + "\" is mandatory and wasn't set";
}
public static MissingMandatoryValueException forNullValue(String fieldName) {
return new MissingMandatoryValueException(
builder(StandardMessage.SERVER_MANDATORY_NULL, fieldName, defaultMessage(fieldName) + " (null)")
);
}
public static MissingMandatoryValueException forBlankValue(String fieldName) {
return new MissingMandatoryValueException(
builder(StandardMessage.SERVER_MANDATORY_BLANK, fieldName, defaultMessage(fieldName) + " (blank)")
);
}
}
package com.ippon.borestop.common.domain.error;
public enum StandardMessage implements BorestopMessage {
USER_MANDATORY("user.mandatory"),
BAD_REQUEST("user.bad-request"),
INTERNAL_SERVER_ERROR("server.internal-server-error"),
SERVER_MANDATORY_NULL("server.mandatory-null"),
SERVER_MANDATORY_BLANK("server.mandatory-blank");
private final String messageKey;
private StandardMessage(String code) {
this.messageKey = code;
}
@Override
public String getMessageKey() {
return messageKey;
}
}
package com.ippon.borestop.common.infrastructure.primary;
import java.util.Map;
final class ArgumentsReplacer {
private static final String OPEN_MUSTACHE = "\\{\\{\\s*";
private static final String CLOSE_MUSTACHE = "\\s*\\}\\}";
private ArgumentsReplacer() {}
public static String replaceArguments(String message, Map<String, String> arguments) {
if (message == null || arguments == null) {
return message;
}
String result = message;
for (Map.Entry<String, String> argument : arguments.entrySet()) {
String argumentRegex = OPEN_MUSTACHE + argument.getKey() + CLOSE_MUSTACHE;
result = result.replaceAll(argumentRegex, argument.getValue());
}
return result;
}
}
package com.ippon.borestop.common.infrastructure.primary;
import com.ippon.borestop.common.domain.error.BorestopMessage;
enum AuthenticationMessage implements BorestopMessage {
NOT_AUTHENTICATED("user.authentication-not-authenticated");
private final String messageKey;
AuthenticationMessage(String messageKey) {
this.messageKey = messageKey;
}
@Override
public String getMessageKey() {
return messageKey;
}
}
/**
*
*/
package com.ippon.borestop.common.infrastructure.primary;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.Collection;
import java.util.List;
@ApiModel(description = "Error result for a WebService call")
public class BorestopError {
@ApiModelProperty(value = "Technical type of this error", example = "user.mandatory", required = true)
private final String errorType;
@ApiModelProperty(
value = "Human readable error message",
example = "Une erreur technique est survenue lors du traitement de votre demande",
required = true
)
private final String message;
@ApiModelProperty(value = "Invalid fields", required = false)
private final List<BorestopFieldError> fieldsErrors;
public BorestopError(
@JsonProperty("errorType") String errorType,
@JsonProperty("message") String message,
@JsonProperty("fieldsErrors") List<BorestopFieldError> fieldsErrors
) {
this.errorType = errorType;
this.message = message;
this.fieldsErrors = fieldsErrors;
}
public String getErrorType() {
return errorType;
}
public String getMessage() {
return message;
}
public Collection<BorestopFieldError> getFieldsErrors() {
return fieldsErrors;
}
}
/**
*
*/
package com.ippon.borestop.common.infrastructure.primary;
import com.ippon.borestop.common.domain.error.BorestopException;
import com.ippon.borestop.common.domain.error.ErrorStatus;
import com.ippon.borestop.common.domain.error.StandardMessage;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
public class BorestopErrorHandler extends ResponseEntityExceptionHandler {
private static final String MESSAGE_PREFIX = "borestop.error.";
private static final String DEFAULT_KEY = StandardMessage.INTERNAL_SERVER_ERROR.getMessageKey();
private static final String BAD_REQUEST_KEY = StandardMessage.BAD_REQUEST.getMessageKey();
private static final String STATUS_EXCEPTION_KEY = "status-exception";
private static final Logger logger = LoggerFactory.getLogger(BorestopErrorHandler.class);
private final MessageSource messages;
public BorestopErrorHandler(MessageSource messages) {
Locale.setDefault(Locale.FRANCE);
this.messages = messages;
}
@ExceptionHandler
public ResponseEntity<BorestopError> handleBorestopException(BorestopException exception) {
HttpStatus status = getStatus(exception);
logError(exception, status);
String messageKey = getMessageKey(status, exception);
BorestopError error = new BorestopError(messageKey, getMessage(messageKey, exception.getArguments()), null);
return new ResponseEntity<>(error, status);
}
@ExceptionHandler
public ResponseEntity<BorestopError> handleResponseStatusException(ResponseStatusException exception) {
HttpStatus status = exception.getStatus();
logError(exception, status);
BorestopError error = new BorestopError(STATUS_EXCEPTION_KEY, buildErrorStatusMessage(exception), null);
return new ResponseEntity<>(error, status);
}
private String buildErrorStatusMessage(ResponseStatusException exception) {
String reason = exception.getReason();
if (StringUtils.isBlank(reason)) {
Map<String, String> statusArgument = Map.of("status", String.valueOf(exception.getStatus().value()));
return getMessage(STATUS_EXCEPTION_KEY, statusArgument);
}
return reason;
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<BorestopError> handleFileSizeException(MaxUploadSizeExceededException maxUploadSizeExceededException) {
logger.warn("File size limit exceeded: {}", maxUploadSizeExceededException.getMessage(), maxUploadSizeExceededException);
BorestopError error = new BorestopError("server.upload-too-big", getMessage("server.upload-too-big", null), null);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<BorestopError> handleAccessDeniedException(AccessDeniedException accessDeniedException) {
BorestopError error = new BorestopError("user.access-denied", getMessage("user.access-denied", null), null);
return new ResponseEntity<>(error, HttpStatus.FORBIDDEN);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<BorestopError> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) {
Throwable rootCause = ExceptionUtils.getRootCause(exception);
if (rootCause instanceof BorestopException) {
return handleBorestopException((BorestopException) rootCause);
}
BorestopError error = new BorestopError(BAD_REQUEST_KEY, getMessage(BAD_REQUEST_KEY, null), null);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
private HttpStatus getStatus(BorestopException exception) {
ErrorStatus status = exception.getStatus();
if (status == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
switch (status) {
case BAD_REQUEST:
return HttpStatus.BAD_REQUEST;
case UNAUTHORIZED:
return HttpStatus.UNAUTHORIZED;
case FORBIDDEN:
return HttpStatus.FORBIDDEN;
case NOT_FOUND:
return HttpStatus.NOT_FOUND;
case CONFLICT:
return HttpStatus.CONFLICT;
default:
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
private void logError(Exception exception, HttpStatus status) {
if (status.is5xxServerError()) {
logger.error("A server error was sent to a user: {}", exception.getMessage(), exception);
logErrorBody(exception);
} else {
logger.warn("An error was sent to a user: {}", exception.getMessage(), exception);
}
}
private void logErrorBody(Exception exception) {
Throwable cause = exception.getCause();
if (cause instanceof RestClientResponseException) {
RestClientResponseException restCause = (RestClientResponseException) cause;
logger.error("Cause body: {}", restCause.getResponseBodyAsString());
}
}
private String getMessageKey(HttpStatus status, BorestopException exception) {
if (exception.getBorestopMessage() == null) {
return getDefaultMessage(status);
}
return exception.getBorestopMessage().getMessageKey();
}
private String getDefaultMessage(HttpStatus status) {
if (status.is5xxServerError()) {
return DEFAULT_KEY;
}
return BAD_REQUEST_KEY;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException exception,
HttpHeaders headers,
HttpStatus status,
WebRequest request
) {
logger.debug("Bean validation error {}", exception.getMessage(), exception);
BorestopError error = new BorestopError(BAD_REQUEST_KEY, getMessage(BAD_REQUEST_KEY, null), getFieldsError(exception));
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);