GlobalExceptionHandler.java

package com.dmasone.identity.infrastructure.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

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

/**
 * Converts application and validation exceptions into RFC 7807 Problem Details.
 *
 * <p>Centralizing exception translation keeps controllers focused on HTTP
 * orchestration and gives API consumers consistent error payloads across
 * versions.</p>
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Converts duplicate-email failures into HTTP 409 responses.
     *
     * @param ex duplicate email exception
     * @return problem detail payload for the API response
     */
    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ProblemDetail handleEmailExists(EmailAlreadyExistsException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        problem.setTitle("Email already exists");
        problem.setDetail(ex.getMessage());
        return problem;
    }

    /**
     * Converts missing-user failures into HTTP 404 responses.
     *
     * @param ex missing user exception
     * @return problem detail payload for the API response
     */
    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problem.setTitle("User not found");
        problem.setDetail(ex.getMessage());
        return problem;
    }

    /**
     * Converts bean validation errors into HTTP 400 responses with field details.
     *
     * @param ex validation exception raised by Spring MVC
     * @return problem detail payload with an {@code errors} extension
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Validation error");
        problem.setDetail("Invalid request payload");
        problem.setProperty("errors", fieldErrors(ex));
        return problem;
    }

    /**
     * Converts invalid path parameters, such as malformed UUIDs, into HTTP 400 responses.
     *
     * @param ex type mismatch raised by Spring MVC
     * @return problem detail payload for the API response
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Invalid path parameter");
        problem.setDetail("Parameter '%s' has an invalid value".formatted(ex.getName()));
        return problem;
    }

    /**
     * Extracts validation messages in a stable field-name keyed structure.
     *
     * @param ex validation exception raised by Spring MVC
     * @return field error messages ordered as reported by the binding result
     */
    private Map<String, String> fieldErrors(MethodArgumentNotValidException ex) {
        return ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .collect(Collectors.toMap(
                        error -> error.getField(),
                        error -> error.getDefaultMessage() == null ? "Invalid value" : error.getDefaultMessage(),
                        (first, ignored) -> first,
                        LinkedHashMap::new
                ));
    }
}