CoreExceptionHandlerAdvice.java
package com.github.jenkaby.bikerental.shared.web.advice;
import com.github.jenkaby.bikerental.shared.exception.EquipmentNotAvailableException;
import com.github.jenkaby.bikerental.shared.exception.ReferenceNotFoundException;
import com.github.jenkaby.bikerental.shared.exception.ResourceConflictException;
import com.github.jenkaby.bikerental.shared.exception.ResourceNotFoundException;
import com.github.jenkaby.bikerental.shared.infrastructure.port.uuid.UuidGenerator;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.MissingApiVersionException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.ArrayList;
import java.util.stream.Collectors;
import static com.github.jenkaby.bikerental.shared.web.advice.ErrorCodes.*;
import static com.github.jenkaby.bikerental.shared.web.advice.ProblemDetailField.*;
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class CoreExceptionHandlerAdvice {
private final UuidGenerator uuidGenerator;
private final ValidationErrorMapper validationErrorMapper;
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ProblemDetail> handleError(MethodArgumentNotValidException ex) {
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(validationErrorMapper::mapFieldError)
.toList();
var globalErrors = ex.getBindingResult().getGlobalErrors().stream()
.map(validationErrorMapper::mapObjectError)
.toList();
var errors = new ArrayList<>();
errors.addAll(fieldErrors);
errors.addAll(globalErrors);
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation error");
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, METHOD_ARGUMENTS_VALIDATION_FAILED);
body.setProperty(ERRORS, errors);
log.warn("[correlationId={}] Bad request for MethodArgumentNotValidException", correlationId, ex);
return ResponseEntity.of(body).build();
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<ProblemDetail> handleError(MethodArgumentTypeMismatchException ex) {
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, METHOD_ARGUMENT_TYPE_MISMATCH);
log.warn("[correlationId={}] Bad request for MethodArgumentTypeMismatchException: {}", correlationId, ex.getMessage());
return ResponseEntity.of(body).build();
}
@ExceptionHandler(MissingServletRequestParameterException.class)
ResponseEntity<ProblemDetail> handleError(MissingServletRequestParameterException ex) {
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, REQUEST_PARAMS_MISSING);
log.warn("[correlationId={}] Bad request for MissingServletRequestParameterException: {}", correlationId, ex.getMessage());
return ResponseEntity.of(body).build();
}
// Occurs when validation failed in annotated @RequestParam, @PathVariable, @RequestHeader, @CookieValue, @ModelAttribute
@ExceptionHandler(HandlerMethodValidationException.class)
ResponseEntity<ProblemDetail> handleError(HandlerMethodValidationException ex) {
var results = ex.getValueResults();
var errors = results.stream()
.flatMap(r -> r.getResolvableErrors().stream())
.map(this.validationErrorMapper::mapResolvableError)
.toList();
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation error");
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, HANDLER_METHOD_ERROR);
body.setProperty(ERRORS, errors);
log.warn("[correlationId={}] Bad request for HandlerMethodValidationException", correlationId, ex);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ConstraintViolationException.class)
ResponseEntity<ProblemDetail> handleError(ConstraintViolationException ex) {
var logDetail = ex.getConstraintViolations().stream()
.map(violation -> "%s: %s".formatted(violation.getPropertyPath(), violation.getMessage()))
.collect(Collectors.joining(","));
var errors = ex.getConstraintViolations().stream()
.map(this.validationErrorMapper::toValidationError)
.toList();
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase());
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, CONSTRAINT_VIOLATION);
body.setProperty(ERRORS, errors);
log.warn("[correlationId={}] Bad request for ConstraintViolationException: {}", correlationId, logDetail);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ProblemDetail> handleError(HttpMessageNotReadableException ex) {
var safeMessage = "Malformed or missing request body";
var message = ex.getMessage() != null ? ex.getMessage() : safeMessage;
var body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, safeMessage);
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, "shared.request.not_readable");
log.warn("[correlationId={}] Bad request for HttpMessageNotReadableException: {}", correlationId, message);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler({InvalidApiVersionException.class, MissingApiVersionException.class})
ResponseEntity<ProblemDetail> handleError(ResponseStatusException ex) {
var correlationId = resolveCorrelationId();
var errorCode = ex instanceof MissingApiVersionException ? API_VERSION_MISSING : API_VERSION_INVALID;
log.warn("[correlationId={}] Invalid api version requested", correlationId, ex);
var body = ex.getBody();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, errorCode);
return new ResponseEntity<>(body, ex.getStatusCode());
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
ResponseEntity<ProblemDetail> handleError(HttpRequestMethodNotSupportedException ex) {
var message = ex.getMessage() != null ? ex.getMessage() : "HTTP method not supported";
var body = ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, message);
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, "shared.request.method_not_allowed");
log.warn("[correlationId={}] Method not allowed for HttpRequestMethodNotSupportedException: {}", correlationId, message);
return new ResponseEntity<>(body, HttpStatus.METHOD_NOT_ALLOWED);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
ResponseEntity<ProblemDetail> handleError(HttpMediaTypeNotSupportedException ex) {
var message = ex.getMessage() != null ? ex.getMessage() : "Media type not supported";
var body = ProblemDetail.forStatusAndDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message);
var correlationId = resolveCorrelationId();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, "shared.request.media_type_not_supported");
log.warn("[correlationId={}] Unsupported media type for HttpMediaTypeNotSupportedException: {}", correlationId, message);
return new ResponseEntity<>(body, HttpStatus.UNSUPPORTED_MEDIA_TYPE);
}
@ExceptionHandler(Exception.class)
ResponseEntity<ProblemDetail> handleError(Exception ex) {
var correlationId = resolveCorrelationId();
log.error("[correlationId={}] Unexpected {} was thrown: {}", correlationId, ex.getClass(), ex.getMessage());
log.debug("[correlationId={}] Unexpected error", correlationId, ex);
var body = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, INTERNAL_SERVER_ERROR);
return ResponseEntity.of(body).build();
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ProblemDetail> handleNoResourceFound(NoResourceFoundException ex) {
var correlationId = resolveCorrelationId();
var body = ex.getBody();
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, RESOURCE_NOT_FOUND);
log.warn("[correlationId={}] The resource '{}' not found by reason: {}", correlationId, ex.getResourcePath(), ex.getMessage());
return ResponseEntity.of(body).build();
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleResourceNotFoundException(ResourceNotFoundException ex) {
var correlationId = resolveCorrelationId();
log.warn("[correlationId={}] The resource '{}[{}]' not found in DB", correlationId, ex.getResourceName(), ex.getIdentifier());
var body = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, ex.getErrorCode());
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ReferenceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleReferenceNotFoundException(ReferenceNotFoundException ex) {
var correlationId = resolveCorrelationId();
var details = ex.getDetails();
log.warn("[correlationId={}] The referenced resource '{}[{}]' not found in DB", correlationId, details.resourceName(), details.identifier());
var body = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage());
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, ex.getErrorCode());
body.setProperty(PARAMS, details);
return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_CONTENT);
}
@ExceptionHandler(ResourceConflictException.class)
public ResponseEntity<ProblemDetail> handleResourceConflictException(ResourceConflictException ex) {
var correlationId = resolveCorrelationId();
var details = ex.getDetails();
log.warn("[correlationId={}] The resource '{}[{}]' conflicts with other entity in DB", correlationId, details.resourceName(), details.identifier());
var body = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, ex.getErrorCode());
body.setProperty(PARAMS, details);
return new ResponseEntity<>(body, HttpStatus.CONFLICT);
}
@ExceptionHandler(EquipmentNotAvailableException.class)
public ResponseEntity<ProblemDetail> handleEquipmentNotAvailableException(EquipmentNotAvailableException ex) {
var correlationId = resolveCorrelationId();
var details = ex.getDetails();
log.warn("[correlationId={}] Equipment {} is not available for operation. Current status: {}", correlationId, details.identifier(), details.status());
var body = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage());
body.setProperty(CORRELATION_ID, correlationId);
body.setProperty(ERROR_CODE, ex.getErrorCode());
body.setProperty(PARAMS, details);
return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_CONTENT);
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ProblemDetail> handleOptimisticLockException(ObjectOptimisticLockingFailureException ex) {
var correlationId = resolveCorrelationId();
log.warn("[correlationId={}] Optimistic lock occurred: {}", correlationId, ex.getMessage());
var problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("Optimistic lock");
problem.setDetail("Concurrent update — please retry");
problem.setProperty(ProblemDetailField.CORRELATION_ID, correlationId);
problem.setProperty(ProblemDetailField.ERROR_CODE, ErrorCodes.RESOURCE_OPTIMISTIC_LOCK);
return ResponseEntity.of(problem).build();
}
private String resolveCorrelationId() {
String correlationId = MDC.get(CORRELATION_ID);
return correlationId != null ? correlationId : uuidGenerator.generate().toString();
}
}