GlobalExceptionHandler.java
package com.taxonomy.shared.config;
import com.taxonomy.analysis.session.AnalysisDraftConflictException;
import com.taxonomy.analysis.session.AnalysisDraftValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/**
* Global exception handler for all REST controllers.
* Prevents stack traces from leaking to clients and returns
* consistent JSON error responses.
*
* <p>Extends {@link ResponseEntityExceptionHandler} so that Spring MVC binding
* exceptions (e.g. missing required parameters, type mismatches) are correctly
* returned as 4xx responses rather than being caught by the generic 500 handler.
*/
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String DEFAULT_INTERNAL_MESSAGE =
"An internal error occurred. Please try again or check the server logs.";
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
/**
* Handles IllegalArgumentException (bad input from client).
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(
IllegalArgumentException exception,
WebRequest request) {
log.warn("Bad request: {}", exception.getMessage());
return buildErrorResponse(
HttpStatus.BAD_REQUEST,
clientErrorMessage(exception, HttpStatus.BAD_REQUEST),
request);
}
/** Return malformed or oversized working drafts as a stable client error. */
@ExceptionHandler(AnalysisDraftValidationException.class)
public ResponseEntity<Map<String, Object>> handleAnalysisDraftValidation(
AnalysisDraftValidationException exception,
WebRequest request) {
log.warn("Invalid analysis draft on {}: {}",
request.getDescription(false), exception.getMessage());
return buildErrorResponse(
HttpStatus.BAD_REQUEST,
clientErrorMessage(exception, HttpStatus.BAD_REQUEST),
request);
}
/**
* Preserve the optimistic-concurrency contract expected by browser tabs.
* The generic catch-all must never turn a stale draft revision into HTTP 500.
*/
@ExceptionHandler(AnalysisDraftConflictException.class)
public ResponseEntity<Map<String, Object>> handleAnalysisDraftConflict(
AnalysisDraftConflictException exception,
WebRequest request) {
log.warn("Analysis draft conflict on {}: {}",
request.getDescription(false), exception.getMessage());
return buildErrorResponse(
HttpStatus.CONFLICT,
clientErrorMessage(exception, HttpStatus.CONFLICT),
request);
}
/**
* Handles authorization failures raised after the security filter chain,
* for example while validating an explicit browser-tab workspace pin.
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(
AccessDeniedException exception,
WebRequest request) {
log.warn("Access denied on {}: {}",
request.getDescription(false), exception.getMessage());
Locale locale = LocaleContextHolder.getLocale();
String message = messageSource.getMessage(
"error.forbidden", null, "Access denied.", locale);
return buildErrorResponse(HttpStatus.FORBIDDEN, message, request);
}
/**
* Catch-all handler for any unhandled exception.
* Logs the full stack trace server-side but only returns a safe message to the client.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGenericException(
Exception exception,
WebRequest request) {
log.error("Unhandled exception on {}: {}",
request.getDescription(false), exception.getMessage(), exception);
return buildErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
internalErrorMessage(),
request);
}
/**
* Override the Spring MVC base handler to return our consistent JSON format
* for framework-level exceptions (missing params, type mismatches, etc.).
*/
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception exception,
Object body,
HttpHeaders headers,
HttpStatusCode statusCode,
WebRequest request) {
HttpStatus status = HttpStatus.resolve(statusCode.value());
if (status == null) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
String message;
if (status.is5xxServerError()) {
log.error("Spring MVC exception on {}",
request.getDescription(false), exception);
message = internalErrorMessage();
} else {
log.warn("Spring MVC exception on {}: {}",
request.getDescription(false), exception.getMessage());
message = clientErrorMessage(exception, status);
}
Map<String, Object> errorBody = new LinkedHashMap<>();
errorBody.put("timestamp", Instant.now().toString());
errorBody.put("status", status.value());
errorBody.put("error", status.getReasonPhrase());
errorBody.put("message", message);
errorBody.put("path", request.getDescription(false).replace("uri=", ""));
return ResponseEntity.status(status).headers(headers).body(errorBody);
}
private String internalErrorMessage() {
Locale locale = LocaleContextHolder.getLocale();
String message = messageSource.getMessage(
"error.internal", null, DEFAULT_INTERNAL_MESSAGE, locale);
return message == null || message.isBlank()
? DEFAULT_INTERNAL_MESSAGE
: message;
}
private static String clientErrorMessage(Exception exception, HttpStatus status) {
String message = exception.getMessage();
return message == null || message.isBlank()
? status.getReasonPhrase()
: message;
}
private ResponseEntity<Map<String, Object>> buildErrorResponse(
HttpStatus status,
String message,
WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now().toString());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", message);
body.put("path", request.getDescription(false).replace("uri=", ""));
return ResponseEntity.status(status).body(body);
}
}