GlobalExceptionHandler.java

package com.licensis.notaire.config;

import com.licensis.notaire.exception.BusinessValidationException;
import com.licensis.notaire.exception.ErrorResponse;
import com.licensis.notaire.exception.NotaireException;
import com.licensis.notaire.exception.ResourceNotFoundException;
import com.licensis.notaire.observability.StructuredLogger;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.Map;
import java.util.stream.Collectors;

/**
 * Global exception handler for all REST controllers.
 * Provides consistent error responses with structured logging.
 */
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private static final StructuredLogger STRUCTURED_LOG = StructuredLogger.getInstance();

    /**
     * Handle NotaireException and its subclasses.
     *
     * @param ex      the exception
     * @param request the HTTP request
     * @return error response entity
     */
    @ExceptionHandler({NotaireException.class})
    public ResponseEntity<ErrorResponse> handleNotaireException(
            NotaireException ex,
            HttpServletRequest request) {
        int statusCode = ex.getStatusCode();
        ErrorResponse errorResponse = new ErrorResponse(
            statusCode,
            HttpStatus.valueOf(statusCode).getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        STRUCTURED_LOG.logError(
            "Notaire exception occurred",
            ex,
            Map.of(
                "statusCode", statusCode,
                "path", request.getRequestURI(),
                "method", request.getMethod()
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.valueOf(statusCode));
    }

    /**
     * Handle ResourceNotFoundException.
     *
     * @param ex      the exception
     * @param request the HTTP request
     * @return 404 error response entity
     */
    @ExceptionHandler({ResourceNotFoundException.class})
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex,
            HttpServletRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
            404,
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        STRUCTURED_LOG.logWarn(
            "Resource not found",
            Map.of(
                "path", request.getRequestURI(),
                "message", ex.getMessage()
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    /**
     * Handle BusinessValidationException.
     *
     * @param ex      the exception
     * @param request the HTTP request
     * @return 400 error response entity
     */
    @ExceptionHandler({BusinessValidationException.class})
    public ResponseEntity<ErrorResponse> handleBusinessValidationException(
            BusinessValidationException ex,
            HttpServletRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
            400,
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        STRUCTURED_LOG.logWarn(
            "Business validation failed",
            Map.of(
                "path", request.getRequestURI(),
                "message", ex.getMessage()
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
    }

    /**
     * Handle bean validation failures on {@code @RequestBody} arguments (issue #561).
     * Overrides the superclass's default {@code ProblemDetail} handling to keep the
     * project's own {@link ErrorResponse} shape consistent across all error responses.
     *
     * @param ex      the exception
     * @param headers the HTTP headers
     * @param status  the HTTP status
     * @param request the web request
     * @return 400 error response entity
     */
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.joining("; "));
        String path = request.getDescription(false).replace("uri=", "");
        ErrorResponse errorResponse = new ErrorResponse(
            400,
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            message,
            path
        );
        STRUCTURED_LOG.logWarn(
            "Request body validation failed",
            Map.of(
                "path", path,
                "message", message
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
    }

    /**
     * Handle bean validation failures on {@code @RequestParam}/{@code @PathVariable}
     * arguments, raised when the controller class is annotated {@code @Validated} (issue #561).
     *
     * @param ex      the exception
     * @param request the HTTP request
     * @return 400 error response entity
     */
    @ExceptionHandler({ConstraintViolationException.class})
    public ResponseEntity<ErrorResponse> handleConstraintViolationException(
            ConstraintViolationException ex,
            HttpServletRequest request) {
        String message = ex.getConstraintViolations().stream()
                .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
                .collect(Collectors.joining("; "));
        ErrorResponse errorResponse = new ErrorResponse(
            400,
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            message,
            request.getRequestURI()
        );
        STRUCTURED_LOG.logWarn(
            "Request parameter validation failed",
            Map.of(
                "path", request.getRequestURI(),
                "message", message
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
    }

    /**
     * Handle all other exceptions.
     *
     * @param ex      the exception
     * @param request the HTTP request
     * @return 500 error response entity
     */
    @ExceptionHandler({Exception.class})
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex,
            HttpServletRequest request) {
        ErrorResponse errorResponse = new ErrorResponse(
            500,
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "An unexpected error occurred",
            request.getRequestURI()
        );
        STRUCTURED_LOG.logError(
            "Unexpected exception occurred",
            ex,
            Map.of(
                "path", request.getRequestURI(),
                "method", request.getMethod(),
                "exceptionType", ex.getClass().getSimpleName()
            )
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}