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";
};
}
}