GlobalExceptionHandler.java
package com.stocks.stockease.exception;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import com.stocks.stockease.dto.ApiResponse;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ConstraintViolationException;
/**
* Centralized exception handler for REST API error responses.
*
* Design pattern: @RestControllerAdvice (AOP-based exception interception)
* - Intercepts exceptions thrown in @RestController methods
* - Converts exceptions to standardized HTTP responses (JSON)
* - Decouples exception handling from business logic
*
* Response format (all handlers):
* {
* "success": false,
* "message": "Human-readable error description",
* "data": null or validation errors map
* }
*
* HTTP status mapping:
* - 400 Bad Request: Invalid input, validation failures, malformed JSON
* - 401 Unauthorized: Invalid/expired JWT, bad credentials
* - 403 Forbidden: User lacks required role/permission
* - 404 Not Found: Resource doesn't exist (product ID not found)
* - 500 Internal Server Error: Unexpected runtime exceptions
*
* Security considerations:
* - Never expose stack traces to clients (prevents reconnaissance)
* - Generic messages for auth failures (prevents username enumeration)
* - Detailed validation errors for client-side form rendering
*
* @author Team StockEase
* @version 1.0
* @since 2025-01-01
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Handles NoSuchElementException (Collection operations like Stream.get()).
*
* Scenario: Business logic calls stream.findFirst().get() without Optional wrapping.
* Response: 404 with user-friendly "Resource not found" message.
*
* @param ex the caught exception
* @return 404 response with error details
*/
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ApiResponse<String>> handleNoSuchElementException(NoSuchElementException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Resource not found: " + ex.getMessage(), null));
}
/**
* Handles JPA EntityNotFoundException (database queries on non-existent records).
*
* Scenario: Service calls productRepository.getReferenceById() then accesses lazy fields.
* Response: 404 with entity-specific message.
*
* @param ex the caught exception
* @return 404 response with error details
*/
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<String>> handleEntityNotFoundException(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Entity not found: " + ex.getMessage(), null));
}
/**
* Handles IllegalArgumentException (business logic validation failures).
*
* Scenario: Service validates input (e.g., quantity > 0) and throws with custom message.
* Response: 400 with validation message (preserves business semantics).
*
* @param ex the caught exception
* @return 400 response with error message
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<String>> handleIllegalArgumentException(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse<>(false, ex.getMessage(), null));
}
/**
* Handles AccessDeniedException (Spring Security authorization failures).
*
* Scenario: User with USER role attempts DELETE /api/products/123 (ADMIN only).
* Response: 403 with permission denial message (complements SecurityConfig exception handler).
*
* @param ex the caught exception
* @return 403 response with permission error
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<String>> handleAccessDeniedException(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ApiResponse<>(false, "You do not have permission to access this resource.", null));
}
/**
* Handles JwtException (invalid/expired JWT tokens).
*
* Scenario: JwtFilter detects malformed or expired token signature.
* Response: 401 with security-appropriate message (doesn't expose token structure).
*
* @param ex the caught exception
* @return 401 response with authentication error
*/
@ExceptionHandler(io.jsonwebtoken.JwtException.class)
public ResponseEntity<ApiResponse<String>> handleJwtException(io.jsonwebtoken.JwtException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(false, "Invalid or expired token.", null));
}
/**
* Handles BadCredentialsException (login with wrong password).
*
* Scenario: AuthController authenticate(username, password) fails during login.
* Response: 401 with generic message (prevents username enumeration).
*
* @param ex the caught exception
* @return 401 response with generic auth error
*/
@ExceptionHandler(org.springframework.security.authentication.BadCredentialsException.class)
public ResponseEntity<ApiResponse<String>> handleBadCredentialsException(
org.springframework.security.authentication.BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(false, "Invalid username or password", null));
}
/**
* Handles MethodArgumentNotValidException (@Valid bean validation failures).
*
* Scenario: POST /api/products with missing @NotNull fields or @Size violations.
* Response: 400 with field-level validation errors (enables frontend form highlighting).
*
* @param ex the caught exception
* @return 400 response with field errors map
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Validation failed for request parameters.", errors));
}
/**
* Handles HttpMessageNotReadableException (malformed request body).
*
* Scenario: POST /api/products with invalid JSON or type mismatch (e.g., string for price).
* Response: 400 with user-friendly parsing error message.
*
* @param ex the caught exception
* @return 400 response with parsing error
*/
@ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<String>> handleHttpMessageNotReadableException(
org.springframework.http.converter.HttpMessageNotReadableException ex) {
String message = "Invalid or missing request body. Please check your input.";
if (ex.getMessage() != null && ex.getMessage().contains("Cannot deserialize")) {
message = "Invalid request format or data type.";
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse<>(false, message, null));
}
/**
* Handles HandlerMethodValidationException (path variable/request param validation).
*
* Scenario: GET /api/products/{id} with id="invalid" (expects Long) or @Min violation.
* Response: 400 with validation error details extracted from cause chain.
*
* Note: Uses if-else pattern matching (Java 16+). Switch pattern matching (Java 21+) not yet available.
*
* @param ex the caught exception
* @return 400 response with constraint violation details
*/
@SuppressWarnings("preview") // Switch pattern matching requires Java 21+
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleHandlerMethodValidationException(HandlerMethodValidationException ex) {
Map<String, String> errors = new HashMap<>();
// Pattern matching with if-else (Java 16+): cleaner than instanceof + cast
Throwable cause = ex.getCause();
if (cause instanceof ConstraintViolationException constraintViolationException) {
// Extract constraint violations (e.g., @Min, @NotNull on path variables)
constraintViolationException.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
} else if (cause instanceof BindException bindException) {
// Extract field binding errors (type mismatches)
bindException.getBindingResult().getFieldErrors().forEach(fieldError ->
errors.put(fieldError.getField(), fieldError.getDefaultMessage())
);
} else {
// Fallback for null or unknown validation errors
errors.put("Unknown", "Unable to extract detailed validation error.");
}
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Validation failed for request parameters.", errors));
}
/**
* Handles all other uncaught exceptions (safety net).
*
* Scenario: Unexpected RuntimeException or database deadlock.
* Response: 500 with generic message (never expose stack traces to clients).
*
* Recommendation: Log full exception and stack trace server-side for debugging.
*
* @param ex the caught exception
* @return 500 response with generic error message
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<String>> handleGeneralException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse<>(false, "An unexpected error occurred. Please try again later.", null));
}
}