GlobalExceptionHandler.java
package com.smartsupplypro.inventory.exception;
import java.util.NoSuchElementException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import com.smartsupplypro.inventory.exception.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
/**
* Enterprise global exception handler for REST API standardization.
*
* <p>Handles framework-level exceptions (validation, security, HTTP). Domain business
* logic exceptions are handled by {@link BusinessExceptionHandler}.
*
* <p><strong>Status Mapping</strong>: 400 (validation), 401 (auth), 403 (authz),
* 404 (not found), 409 (conflicts), 500 (unexpected).
*
* @author Smart Supply Pro Development Team
* @version 2.0.0
* @see BusinessExceptionHandler
* @see ErrorResponse
*/
@Order(Ordered.HIGHEST_PRECEDENCE + 1) // Runs after BusinessExceptionHandler as a catch-all
@RestControllerAdvice
public class GlobalExceptionHandler {
/* =======================================================================
* 400 BAD REQUEST - Validation & Parameter Errors
* ======================================================================= */
/** Handles {@code @Valid} validation failures. Extracts first field error. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(fe -> fe.getField() + " " + fe.getDefaultMessage())
.orElse("Validation failed");
return ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.message(sanitize(message))
.build();
}
/** Handles JSR-380 constraint violations ({@code @NotNull}, {@code @Size}). */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraint(ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.findFirst()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.orElse("Constraint violation");
return ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.message(sanitize(message))
.build();
}
/** Handles malformed JSON payloads and deserialization errors. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleParsingError(HttpMessageNotReadableException ex) {
return ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.message("Request body is invalid or unreadable")
.build();
}
/** Handles missing parameters and type conversion failures. */
@ExceptionHandler({
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<ErrorResponse> handleParameterError(Exception ex) {
String message;
if (ex instanceof MissingServletRequestParameterException missingParam) {
message = "Missing required parameter: " + missingParam.getParameterName();
} else if (ex instanceof MethodArgumentTypeMismatchException typeMismatch) {
message = "Invalid parameter value: " + (typeMismatch.getName() != null ? typeMismatch.getName() : "unknown");
} else {
message = "Invalid parameter";
}
return ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.message(message)
.build();
}
/* =======================================================================
* 401 / 403 - Security & Authorization
* ======================================================================= */
/** Handles authentication failures. Returns generic message to prevent enumeration.
*
* @enterprise
* - Avoids leaking details about authentication mechanisms or user existence.
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthentication(AuthenticationException ex) {
return ErrorResponse.builder()
.status(HttpStatus.UNAUTHORIZED)
.message("Authentication required")
.build();
}
/**
* Handle Spring Security access control violations.
*
* @enterprise
* - Returns generic 403 message to avoid leaking details about permissions.
* - For demo-mode violations, returns a friendly UX message that matches frontend wording.
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
// By default: generic authorization failure
String message = "You are not allowed to perform this operation.";
// Heuristic: if the expression for @PreAuthorize with demo flag failed,
// the underlying exception message may carry that expression.
if (ex.getMessage() != null && ex.getMessage().contains("principal.isDemo")) {
message = "You are in demo mode and cannot perform this operation.";
}
return ErrorResponse.builder()
.status(HttpStatus.FORBIDDEN)
.message(sanitize(message))
.build();
}
/* =======================================================================
* 404 - Resource Not Found
* ======================================================================= */
/** Handles resource lookup failures from repositories and service layer. */
@ExceptionHandler({NoSuchElementException.class, IllegalArgumentException.class})
public ResponseEntity<ErrorResponse> handleNotFound(RuntimeException ex) {
String message = (ex.getMessage() != null && !ex.getMessage().isBlank())
? ex.getMessage()
: "Resource not found";
return ErrorResponse.builder()
.status(HttpStatus.NOT_FOUND)
.message(sanitize(message))
.build();
}
/** Handles missing static resources (CSS, JS, images) with no response body. */
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public void handleStaticResource() {
// No body - standard 404 for static resources
}
/* =======================================================================
* 409 - Data Conflicts
* ======================================================================= */
/** Handles database constraint violations. Sanitizes SQL to prevent disclosure. */
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException ex) {
return ErrorResponse.builder()
.status(HttpStatus.CONFLICT)
.message("Data conflict - constraint violation")
.build();
}
/** Handles JPA optimistic locking failures during concurrent updates. */
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
return ErrorResponse.builder()
.status(HttpStatus.CONFLICT)
.message("Concurrent update detected - please refresh and retry")
.build();
}
/* =======================================================================
* Pass-Through & Fallback
* ======================================================================= */
/** Handles explicit {@link ResponseStatusException}. Preserves original status. */
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex,
HttpServletRequest request) {
HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value());
String reason = ex.getReason(); // Store to avoid multiple null-check warnings
String message = (reason != null && !reason.isBlank())
? reason
: (status != null ? status.getReasonPhrase() : "Request failed");
return ErrorResponse.builder()
.status(status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR)
.message(sanitize(message))
.build();
}
/**
* Enterprise safety net for unhandled exceptions. Prevents stack trace exposure.
* <p><strong>Production</strong>: Add ERROR-level logging with correlation ID.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
return ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.message("Unexpected server error")
.build();
}
/* =======================================================================
* Security - Message Sanitization
* ======================================================================= */
/**
* Sanitizes error messages to prevent sensitive information disclosure.
* Removes file paths, class names, SQL fragments, and credentials.
*/
private String sanitize(String message) {
if (message == null) return "Unknown error";
return message
.replaceAll("\\b[A-Za-z]:\\\\[\\w\\\\.-]+", "[PATH]") // Windows paths
.replaceAll("/[\\w/.-]+\\.(java|class)", "[INTERNAL]") // Unix paths
.replaceAll("\\bcom\\.smartsupplypro\\.[\\w.]+", "[INTERNAL]") // Package names
.replaceAll("(?i)\\bSQL.*", "Database operation failed") // SQL fragments
.replaceAll("(?i)\\bPassword.*", "Authentication failed") // Credentials
.replaceAll("(?i)\\bToken.*", "Authentication failed") // Tokens
.trim();
}
}