ApiExceptionHandler.java
package io.slayyyyer.orders.web;
import io.slayyyyer.orders.service.ConflictException;
import io.slayyyyer.orders.service.NotFoundException;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
/**
* RFC 9457 problem details for every failure path. The shape is stable and documented in
* the README: type, title, status, detail, plus a field map for validation failures.
*
* <p>Spring raises two different exceptions for failed bean validation, and which one you
* get depends on the controller signature: {@link MethodArgumentNotValidException} for a
* {@code @Valid} argument and {@link HandlerMethodValidationException} when validation runs
* on the method itself. The framework's own documentation says to handle both. Handling only
* one leaves the other falling through to another handler, which answers with a different
* document — no type, no field map — that a client reading problem details cannot parse.
*
* <p>The precedence matters: with {@code spring.mvc.problemdetails.enabled} on, Boot registers
* its own advice that also claims these exceptions, and it produced a bare
* "Invalid request content." instead of the documented shape.
*/
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
private static final String BASE = "https://slayyyyer.github.io/orders-service/problems/";
@ExceptionHandler(NotFoundException.class)
public ProblemDetail notFound(NotFoundException ex) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
detail.setType(URI.create(BASE + "not-found"));
detail.setTitle("Resource not found");
detail.setProperty("resource", ex.resource());
detail.setProperty("id", ex.id());
return detail;
}
@ExceptionHandler(ConflictException.class)
public ProblemDetail conflict(ConflictException ex) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
detail.setType(URI.create(BASE + "conflict"));
detail.setTitle("Request conflicts with the current state");
return detail;
}
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail badRequest(IllegalArgumentException ex) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
detail.setType(URI.create(BASE + "invalid-request"));
detail.setTitle("Invalid request");
return detail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail validation(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fields.putIfAbsent(error.getField(), error.getDefaultMessage());
}
return validationFailed(fields);
}
@ExceptionHandler(HandlerMethodValidationException.class)
public ProblemDetail methodValidation(HandlerMethodValidationException ex) {
Map<String, String> fields = new LinkedHashMap<>();
for (MessageSourceResolvable error : ex.getAllErrors()) {
String field = error instanceof FieldError fieldError ? fieldError.getField() : "request";
fields.putIfAbsent(field, error.getDefaultMessage());
}
return validationFailed(fields);
}
private static ProblemDetail validationFailed(Map<String, String> fields) {
ProblemDetail detail =
ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "one or more fields are invalid");
detail.setType(URI.create(BASE + "validation-failed"));
detail.setTitle("Validation failed");
detail.setProperty("errors", fields);
return detail;
}
}