GlobalExceptionMapper.java

package com.fulfilment.application.monolith.exception;

import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.List;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;

/**
 * Centralized exception handler for all REST resources.
 *
 * <p>Produces RFC 7807 Problem Details ({@code application/problem+json}) for every error.
 *
 * <p>Exception → HTTP status mapping:
 * <ul>
 *   <li>ConstraintViolationException → 400 with per-field violations</li>
 *   <li>BadRequestException          → 400</li>
 *   <li>NotAuthorizedException       → 401</li>
 *   <li>ForbiddenException           → 403</li>
 *   <li>NotFoundException            → 404</li>
 *   <li>NotAllowedException          → 405</li>
 *   <li>WebApplicationException      → status from exception</li>
 *   <li>Any other Exception          → 500 (safe message only)</li>
 * </ul>
 *
 * <p>Log level policy:
 * <ul>
 *   <li>4xx — WARN (caller error)</li>
 *   <li>5xx — ERROR with full stack trace (server fault; details never sent to caller)</li>
 * </ul>
 */
@Provider
public class GlobalExceptionMapper implements ExceptionMapper<Exception> {

  private static final Logger LOGGER = Logger.getLogger(GlobalExceptionMapper.class);

  private static final String PROBLEM_JSON = "application/problem+json";

  @Override
  public Response toResponse(Exception exception) {

    if (exception instanceof ConstraintViolationException cve) {
      return handleConstraintViolation(cve);
    }

    if (exception instanceof WebApplicationException wae) {
      return handleWebApplicationException(wae);
    }

    // Unexpected server error — log full details, return safe message only
    LOGGER.errorf(exception, "Unhandled server error");
    ProblemDetail body = ProblemDetail.of(
        "urn:problem:internal-server-error",
        "Internal Server Error",
        500,
        "An unexpected error occurred. Please try again later.");

    return Response.status(500)
        .type(PROBLEM_JSON)
        .entity(body)
        .build();
  }

  // ─────────────────────────────────────────────────────────────
  // Handlers
  // ─────────────────────────────────────────────────────────────

  /** Maps Bean Validation failures to 422 with per-field violations and a combined detail message. */
  private Response handleConstraintViolation(ConstraintViolationException cve) {
    List<ProblemDetail.FieldViolation> violations = cve.getConstraintViolations().stream()
        .map(cv -> new ProblemDetail.FieldViolation(
            fieldName(cv.getPropertyPath().toString()),
            cv.getMessage()))
        .toList();

    LOGGER.warnf("Validation failed: %d violation(s)", violations.size());

    String detail = violations.stream()
        .map(v -> v.message)
        .collect(Collectors.joining("; "));
    if (detail.isBlank()) {
      detail = "Request validation failed.";
    }

    ProblemDetail body = ProblemDetail.of(
        "urn:problem:unprocessable-entity",
        "Unprocessable Entity",
        HttpStatus.UNPROCESSABLE_ENTITY,
        detail);
    body.violations = violations;

    return Response.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .type(PROBLEM_JSON)
        .entity(body)
        .build();
  }

  /** Maps JAX-RS WebApplicationException subclasses to their proper RFC 7807 responses. */
  private Response handleWebApplicationException(WebApplicationException wae) {
    int status = wae.getResponse().getStatus();
    // 4xx messages are intentional business/validation messages — safe to return
    // 5xx messages may contain internal details — replace with safe fallback
    String detail = status >= 500
        ? "An unexpected error occurred. Please try again later."
        : wae.getMessage();

    if (status >= 500) {
      LOGGER.errorf(wae, "Server-side WebApplicationException (HTTP %d)", status);
    } else {
      LOGGER.warnf("Client error (HTTP %d): %s", status, wae.getMessage());
    }

    ProblemDetail body = ProblemDetail.of(problemType(status), problemTitle(status), status, detail);

    return Response.status(status)
        .type(PROBLEM_JSON)
        .entity(body)
        .build();
  }

  // ─────────────────────────────────────────────────────────────
  // Helpers
  // ─────────────────────────────────────────────────────────────

  /** Extracts the leaf property name from a constraint violation path (e.g. "create.arg0.name" → "name"). */
  private String fieldName(String propertyPath) {
    int dot = propertyPath.lastIndexOf('.');
    return dot >= 0 ? propertyPath.substring(dot + 1) : propertyPath;
  }

  private String problemType(int status) {
    return switch (status) {
      case 400 -> "urn:problem:bad-request";
      case 401 -> "urn:problem:unauthorized";
      case 403 -> "urn:problem:forbidden";
      case 404 -> "urn:problem:not-found";
      case 405 -> "urn:problem:method-not-allowed";
      case 409 -> "urn:problem:conflict";
      case 422 -> "urn:problem:unprocessable-entity";
      default  -> "urn:problem:error";
    };
  }

  private String problemTitle(int status) {
    return switch (status) {
      case 400 -> "Bad Request";
      case 401 -> "Unauthorized";
      case 403 -> "Forbidden";
      case 404 -> "Not Found";
      case 405 -> "Method Not Allowed";
      case 409 -> "Conflict";
      case 422 -> "Unprocessable Entity";
      default  -> status >= 500 ? "Internal Server Error" : "Error";
    };
  }
}