GlobalExceptionHandler.java

package com.rbctcsworld.ecommerce.common.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.apache.tomcat.util.http.InvalidParameterException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.ErrorResponse;
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.MethodArgumentTypeMismatchException;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Converts exceptions into correct HTTP status codes.
 * Before this class every failure became HTTP 500, which hid real defects from the tests.
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(NotFoundException.class)
    ResponseEntity<ApiError> notFound(NotFoundException ex, HttpServletRequest req) {
        return build(HttpStatus.NOT_FOUND, ex.getMessage(), req);
    }

    @ExceptionHandler(ConflictException.class)
    ResponseEntity<ApiError> conflict(ConflictException ex, HttpServletRequest req) {
        return build(HttpStatus.CONFLICT, ex.getMessage(), req);
    }

    @ExceptionHandler(TooManyRequestsException.class)
    ResponseEntity<ApiError> tooMany(TooManyRequestsException ex, HttpServletRequest req) {
        ResponseEntity<ApiError> r = build(HttpStatus.TOO_MANY_REQUESTS, ex.getMessage(), req);
        return ResponseEntity.status(r.getStatusCode())
                .header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()))
                .body(r.getBody());
    }

    @ExceptionHandler(ForbiddenException.class)
    ResponseEntity<ApiError> forbidden(ForbiddenException ex, HttpServletRequest req) {
        return build(HttpStatus.FORBIDDEN, ex.getMessage(), req);
    }

    @ExceptionHandler(BusinessRuleException.class)
    ResponseEntity<ApiError> businessRule(BusinessRuleException ex, HttpServletRequest req) {
        return build(HttpStatus.BAD_REQUEST, ex.getMessage(), req);
    }

    @ExceptionHandler(PaymentUnavailableException.class)
    ResponseEntity<ApiError> paymentUnavailable(PaymentUnavailableException ex, HttpServletRequest req) {
        ResponseEntity<ApiError> r = build(HttpStatus.SERVICE_UNAVAILABLE, ex.getMessage(), req);
        return ResponseEntity.status(r.getStatusCode()).header("Retry-After", "30").body(r.getBody());
    }

    @ExceptionHandler(PaymentDeclinedException.class)
    ResponseEntity<ApiError> paymentDeclined(PaymentDeclinedException ex, HttpServletRequest req) {
        return build(HttpStatus.PAYMENT_REQUIRED, ex.getMessage(), req);
    }

    @ExceptionHandler(InvalidCredentialsException.class)
    ResponseEntity<ApiError> invalidCredentials(InvalidCredentialsException ex, HttpServletRequest req) {
        return build(HttpStatus.UNAUTHORIZED, ex.getMessage(), req);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ApiError> validation(MethodArgumentNotValidException ex, HttpServletRequest req) {
        Map<String, String> fields = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(fe -> fields.putIfAbsent(fe.getField(), fe.getDefaultMessage()));
        HttpStatus s = HttpStatus.BAD_REQUEST;
        return ResponseEntity.status(s)
                .body(ApiError.of(s.value(), s.getReasonPhrase(), "Validation failed", req.getRequestURI(), fields));
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    ResponseEntity<ApiError> unreadable(HttpMessageNotReadableException ex, HttpServletRequest req) {
        return build(HttpStatus.BAD_REQUEST, "Malformed or missing JSON request body", req);
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    ResponseEntity<ApiError> typeMismatch(MethodArgumentTypeMismatchException ex, HttpServletRequest req) {
        return build(HttpStatus.BAD_REQUEST, "Invalid value for parameter '" + ex.getName() + "'", req);
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    ResponseEntity<ApiError> integrity(DataIntegrityViolationException ex, HttpServletRequest req) {
        return build(HttpStatus.CONFLICT, "Request conflicts with existing data", req);
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<ApiError> unexpected(Exception ex, HttpServletRequest req) {
        // DEF-017: Tomcat 11 (Spring Boot 4) rejects a malformed query string, e.g. "?=x" (a parameter without a
        // name), by throwing InvalidParameterException from request.getParameter(). It is the client's error.
        for (Throwable t = ex; t != null; t = t.getCause()) {
            if (t instanceof InvalidParameterException ipe) {
                HttpStatus s = HttpStatus.resolve(ipe.getErrorCode());
                return build(s != null && s.is4xxClientError() ? s : HttpStatus.BAD_REQUEST,
                        "Malformed query string or form parameters", req);
            }
        }
        // Spring MVC's own exceptions (405 method not allowed, 404 no endpoint, 415 media type...)
        // already know their status code - keep it instead of turning them into 500.
        if (ex instanceof ErrorResponse er) {
            HttpStatus s = HttpStatus.resolve(er.getStatusCode().value());
            if (s == null) s = HttpStatus.BAD_REQUEST;
            return build(s, er.getBody().getDetail() != null ? er.getBody().getDetail() : s.getReasonPhrase(), req);
        }
        log.error("Unhandled error on {} {}", req.getMethod(), req.getRequestURI(), ex);
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected server error", req);
    }

    private ResponseEntity<ApiError> build(HttpStatus s, String message, HttpServletRequest req) {
        return ResponseEntity.status(s).body(ApiError.of(s.value(), s.getReasonPhrase(), message, req.getRequestURI()));
    }
}