Add problem-details: global exception handling with RFC 9457

Companion code for "Global Exception Handling with ProblemDetail (RFC 9457)
in Spring Boot 4". Thirteen failures under five handling setups (Boot
defaults, the Boot flag, a ResponseEntityExceptionHandler advice, advice
plus an ErrorController, a catch-all ordered first), validation errors,
i18n, content negotiation, Security's 401/403, silent 500s and decoding
on the client. 16 tests pin the behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
This commit is contained in:
2026-09-11 17:12:22 +00:00
co-authored by Claude Opus 5
parent a065696478
commit 926250e1a9
63 changed files with 2414 additions and 0 deletions
@@ -0,0 +1,12 @@
package com.ankurm.problems;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProblemDetailsApplication {
public static void main(String[] args) {
SpringApplication.run(ProblemDetailsApplication.class, args);
}
}
@@ -0,0 +1,25 @@
package com.ankurm.problems.advice;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/**
* The most common first attempt at "customise the validation response" - and it does not start.
* {@link ResponseEntityExceptionHandler} already declares an {@code @ExceptionHandler} for
* {@link MethodArgumentNotValidException}; declaring a second one in the same class makes the
* mapping ambiguous. Profile {@code ambiguous} only; scripts/demo-ambiguous.sh captures the error.
*/
@RestControllerAdvice
@Profile("ambiguous")
public class AmbiguousExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "custom");
}
}
@@ -0,0 +1,26 @@
package com.ankurm.problems.advice;
import org.springframework.context.annotation.Profile;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* A catch-all advice given the highest precedence "so it always runs". Advices are consulted in
* order and the first one with ANY matching handler wins, so this one also claims every Spring
* MVC exception: a bad path variable, an unknown URL and a wrong HTTP method all become 500.
* Profile {@code catchall-first}; measured in docs/output/matrix-catchall-first.txt.
*/
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
@Profile("catchall-first")
public class CatchAllFirstHandler {
@ExceptionHandler(Exception.class)
ProblemDetail everything(Exception ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
}
}
@@ -0,0 +1,105 @@
package com.ankurm.problems.advice;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.ankurm.problems.domain.OrderNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
/**
* The recommended shape: extend {@link ResponseEntityExceptionHandler} so every Spring MVC
* exception is already an RFC 9457 response, then add handlers for your own exceptions and a
* catch-all that does not leak.
*
* <p>Three things this class does that the defaults do not, each measured in docs/output:
* <ol>
* <li>Validation failures list every violation with a JSON Pointer, instead of the stock
* {@code "Invalid request content."} with nothing to act on (docs/03-validation-errors.md).</li>
* <li>Unhandled exceptions get a generic detail plus a correlation id, and the stack trace is
* LOGGED with that id - a catch-all that forgets to log makes every 500 invisible
* (docs/07-silent-500s.md).</li>
* <li>Domain exceptions get a stable {@code type} URI that clients can switch on.</li>
* </ol>
*
* <p>Do not also declare {@code @ExceptionHandler(MethodArgumentNotValidException.class)} here:
* the base class already maps it, and the context refuses to start. Override
* {@link #handleMethodArgumentNotValid} instead (docs/02-choosing-a-mechanism.md).
*/
@RestControllerAdvice
@Profile("advice")
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final boolean logUnhandled;
public GlobalExceptionHandler(@Value("${demo.problems.log-unhandled:true}") boolean logUnhandled) {
this.logUnhandled = logUnhandled;
}
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail orderNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://ankurm.com/problems/order-not-found"));
pd.setTitle("Order not found");
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
String errorId = UUID.randomUUID().toString();
if (logUnhandled) {
log.error("Unhandled exception, errorId={}", errorId, ex);
}
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. Quote errorId when reporting it.");
pd.setProperty("errorId", errorId);
return pd;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
// RFC 9457 section 3 uses exactly this shape in its own example: an "errors" array of
// objects with a "detail" and a JSON Pointer to the offending member.
ProblemDetail pd = ex.getBody();
pd.setDetail("The request body has " + ex.getErrorCount() + " invalid field(s)");
pd.setProperty("errors", ex.getFieldErrors().stream()
.map(e -> Map.of("pointer", "#/" + e.getField().replace('.', '/'),
"detail", String.valueOf(e.getDefaultMessage())))
.toList());
return handleExceptionInternal(ex, pd, headers, status, request);
}
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(HandlerMethodValidationException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
List<Map<String, Object>> errors = ex.getParameterValidationResults().stream()
.flatMap(result -> result.getResolvableErrors().stream()
.map(error -> Map.<String, Object>of(
"parameter", result.getMethodParameter().getParameterName(),
"detail", String.valueOf(error.getDefaultMessage()))))
.toList();
pd.setProperty("errors", errors);
return handleExceptionInternal(ex, pd, headers, status, request);
}
}
@@ -0,0 +1,51 @@
package com.ankurm.problems.advice;
import java.net.URI;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.webmvc.error.ErrorAttributes;
import org.springframework.boot.webmvc.error.ErrorController;
import org.springframework.context.annotation.Profile;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
/**
* Replaces Spring Boot's {@code BasicErrorController} so the container's error page - where
* anything that escaped the DispatcherServlet ends up, including exceptions thrown by servlet
* filters - also speaks RFC 9457.
*
* <p>Boot's {@code ErrorMvcAutoConfiguration} registers {@code BasicErrorController} only when no
* {@link ErrorController} bean exists, so declaring this one is enough to swap it out. The
* detail for 5xx is deliberately generic: the exception message here is whatever a filter
* happened to throw. Profile {@code errors}; see docs/06-outside-mvc.md.
*/
@RestController
@Profile("errors")
public class ProblemDetailErrorController implements ErrorController {
private final ErrorAttributes errorAttributes;
public ProblemDetailErrorController(ErrorAttributes errorAttributes) {
this.errorAttributes = errorAttributes;
}
@RequestMapping("${server.error.path:${error.path:/error}}")
ResponseEntity<ProblemDetail> error(HttpServletRequest request) {
Map<String, Object> attrs = errorAttributes.getErrorAttributes(
new ServletWebRequest(request), ErrorAttributeOptions.defaults());
int status = attrs.get("status") instanceof Integer s ? s : 500;
ProblemDetail pd = ProblemDetail.forStatus(status);
pd.setDetail(status >= 500 ? "An unexpected error occurred." : String.valueOf(attrs.get("error")));
if (attrs.get("path") instanceof String path) {
pd.setInstance(URI.create(path));
}
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON).body(pd);
}
}
@@ -0,0 +1,98 @@
package com.ankurm.problems.diag;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.method.ControllerAdviceBean;
import tools.jackson.databind.json.JsonMapper;
/**
* Diagnostics for the article. Delete before shipping anything real.
*
* <ul>
* <li>{@code /diag/advice} - every {@code @ControllerAdvice} in consultation order, which is the
* order that decides who handles an exception (docs/02-choosing-a-mechanism.md).</li>
* <li>{@code /diag/decode?path=...} - calls this application with {@link RestClient} and
* reports what the client side actually decoded from the problem body (docs/08-clients.md).</li>
* <li>{@code /diag/mixin} - serialises one ProblemDetail with an extension member through three
* different mappers, to show which ones flatten {@code properties} (docs/06-outside-mvc.md).</li>
* </ul>
*/
@RestController
public class DiagController {
private final ApplicationContext context;
private final RestClient client;
private final JsonMapper bootMapper;
public DiagController(ApplicationContext context, RestClient.Builder builder, Environment env,
JsonMapper bootMapper) {
this.context = context;
this.bootMapper = bootMapper;
this.client = builder.baseUrl("http://localhost:" + env.getProperty("local.server.port", "8080")).build();
}
@GetMapping("/diag/advice")
public List<Map<String, Object>> advice() {
return ControllerAdviceBean.findAnnotatedBeans(context).stream()
.map(bean -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("bean", bean.getBeanType() == null ? "?" : bean.getBeanType().getName());
row.put("order", bean.getOrder());
return row;
})
.toList();
}
@GetMapping("/diag/decode")
public Map<String, Object> decode(@RequestParam String path) {
Map<String, Object> out = new LinkedHashMap<>();
try {
String body = client.get().uri(path).retrieve().body(String.class);
out.put("outcome", "no exception");
out.put("body", body);
}
catch (RestClientResponseException ex) {
out.put("exception", ex.getClass().getName());
out.put("status", ex.getStatusCode().value());
out.put("contentType", String.valueOf(ex.getResponseHeaders() == null ? null
: ex.getResponseHeaders().getContentType()));
ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
if (pd == null) {
out.put("problemDetail", null);
}
else {
Map<String, Object> decoded = new LinkedHashMap<>();
decoded.put("type", String.valueOf(pd.getType()));
decoded.put("title", pd.getTitle());
decoded.put("status", pd.getStatus());
decoded.put("detail", pd.getDetail());
decoded.put("instance", String.valueOf(pd.getInstance()));
decoded.put("properties", pd.getProperties());
out.put("problemDetail", decoded);
}
}
return out;
}
@GetMapping("/diag/mixin")
public Map<String, String> mixin() {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(org.springframework.http.HttpStatus.CONFLICT, "demo");
pd.setProperty("sku", "SKU-2");
Map<String, String> out = new LinkedHashMap<>();
out.put("Spring Boot's JsonMapper bean", bootMapper.writeValueAsString(pd));
out.put("JsonMapper.builder().build()", JsonMapper.builder().build().writeValueAsString(pd));
out.put("new JsonMapper()", new JsonMapper().writeValueAsString(pd));
return out;
}
}
@@ -0,0 +1,4 @@
package com.ankurm.problems.domain;
public record Order(long id, String sku, int quantity) {
}
@@ -0,0 +1,20 @@
package com.ankurm.problems.domain;
/**
* A plain domain exception that knows nothing about HTTP. Whether it becomes a 404 with a
* problem body, a 500 with Spring Boot's error JSON, or something else is decided entirely by
* which exception handling is active - that difference is the first table in the article.
*/
public class OrderNotFoundException extends RuntimeException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("Order " + orderId + " does not exist");
this.orderId = orderId;
}
public long getOrderId() {
return orderId;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.problems.domain;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
/**
* Request body for {@code POST /orders}. Three constraints, so one bad request can produce three
* violations and the article can show where (and whether) each one ends up in the response.
*
* @see <a href="../../../../../../../docs/03-validation-errors.md">docs/03-validation-errors.md</a>
*/
public record OrderRequest(
@NotBlank String sku,
@Min(1) int quantity,
@Email String customerEmail) {
}
@@ -0,0 +1,33 @@
package com.ankurm.problems.domain;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final Map<Long, Order> orders = new ConcurrentHashMap<>(Map.of(1L, new Order(1, "SKU-1", 2)));
private final Map<String, Integer> stock = new ConcurrentHashMap<>(Map.of("SKU-1", 5, "SKU-2", 0));
private final AtomicLong ids = new AtomicLong(1);
public Order find(long id) {
Order order = orders.get(id);
if (order == null) {
throw new OrderNotFoundException(id);
}
return order;
}
public Order place(OrderRequest request) {
int available = stock.getOrDefault(request.sku(), 0);
if (request.quantity() > available) {
throw new OutOfStockException(request.sku(), request.quantity(), available);
}
Order order = new Order(ids.incrementAndGet(), request.sku(), request.quantity());
orders.put(order.id(), order);
return order;
}
}
@@ -0,0 +1,45 @@
package com.ankurm.problems.domain;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
/**
* A self-describing exception: it extends {@link ErrorResponseException}, so it carries its own
* status, {@code type}, and extension members. Any {@code ResponseEntityExceptionHandler} renders
* it without a dedicated handler method.
*
* <p>The {@code title} and {@code detail} are also resolvable from {@code messages.properties}
* under {@code problemDetail.title.<FQCN>} and {@code problemDetail.<FQCN>}, with
* {@link #getDetailMessageArguments()} supplying {0} and {1}. See docs/04-i18n-and-types.md.
*/
public class OutOfStockException extends ErrorResponseException {
public static final URI TYPE = URI.create("https://ankurm.com/problems/out-of-stock");
private final String sku;
private final int available;
public OutOfStockException(String sku, int requested, int available) {
super(HttpStatus.CONFLICT, problem(sku, requested, available), null);
this.sku = sku;
this.available = available;
}
private static ProblemDetail problem(String sku, int requested, int available) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Requested " + requested + " of " + sku + " but only " + available + " available");
pd.setType(TYPE);
pd.setTitle("Insufficient stock");
pd.setProperty("sku", sku);
pd.setProperty("available", available);
return pd;
}
@Override
public Object[] getDetailMessageArguments() {
return new Object[] {sku, available};
}
}
@@ -0,0 +1,70 @@
package com.ankurm.problems.security;
import java.io.IOException;
import java.net.URI;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
/**
* Writes RFC 9457 bodies for the two responses Spring Security produces itself. Registered only
* with the {@code advice} profile, so the default profiles show what you get without it.
*
* <p>Uses the application's {@link JsonMapper} (Jackson 3 in Spring Boot 4) so extension members
* are rendered the same way the MVC converters render them.
*/
@Component
@Profile("advice")
public class ProblemDetailSecurityHandlers implements AuthenticationEntryPoint, AccessDeniedHandler {
private final JsonMapper mapper;
public ProblemDetailSecurityHandlers(JsonMapper mapper) {
this.mapper = mapper;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex)
throws IOException {
response.setHeader("WWW-Authenticate", "Basic realm=\"orders\"");
ProblemDetail pd = problem(request, HttpStatus.UNAUTHORIZED, "authentication-required",
"Authentication is required to access this resource");
// An extension member. Whether it is rendered at the top level or nested under
// "properties" depends on the mapper having ProblemDetail's Jackson mixin - see
// docs/06-outside-mvc.md for what the Boot JsonMapper bean does.
pd.setProperty("scheme", "Basic");
write(response, pd);
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex)
throws IOException {
write(response, problem(request, HttpStatus.FORBIDDEN, "access-denied",
"Your credentials do not grant access to this resource"));
}
private static ProblemDetail problem(HttpServletRequest request, HttpStatus status, String type, String detail) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
pd.setType(URI.create("https://ankurm.com/problems/" + type));
pd.setInstance(URI.create(request.getRequestURI()));
return pd;
}
private void write(HttpServletResponse response, ProblemDetail pd) throws IOException {
response.setStatus(pd.getStatus());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
mapper.writeValue(response.getOutputStream(), pd);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.problems.security;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* HTTP Basic with two users. /admin/** needs ROLE_ADMIN, everything else is open.
*
* <p>401 and 403 are decided inside the security filter chain, before the DispatcherServlet.
* A {@code @ControllerAdvice} therefore cannot render them. When the {@code advice} profile is
* active a {@link ProblemDetailSecurityHandlers} bean exists and is plugged in here; otherwise
* Spring Security's defaults apply. See docs/06-outside-mvc.md.
*/
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http, ObjectProvider<ProblemDetailSecurityHandlers> handlers) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
ProblemDetailSecurityHandlers h = handlers.getIfAvailable();
if (h != null) {
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(h)
.accessDeniedHandler(h));
http.httpBasic(basic -> basic.authenticationEntryPoint(h));
}
return http.build();
}
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("user").password("{noop}user").roles("USER").build(),
User.withUsername("admin").password("{noop}admin").roles("ADMIN").build());
}
}
@@ -0,0 +1,16 @@
package com.ankurm.problems.web;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Requires ROLE_ADMIN. Used to show where 401 and 403 bodies come from. */
@RestController
public class AdminController {
@GetMapping("/admin/orders")
public Map<String, Object> adminOrders() {
return Map.of("orders", 1);
}
}
@@ -0,0 +1,73 @@
package com.ankurm.problems.web;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import com.ankurm.problems.domain.Order;
import com.ankurm.problems.domain.OrderRequest;
import com.ankurm.problems.domain.OrderService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
/**
* One endpoint per kind of failure. Every row of the article's "which shape do you get" matrix
* comes from calling one of these under each profile - see scripts/demo-matrix.sh.
*/
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
/** Domain exception (404 when handled), and a TypeMismatchException for /orders/abc. */
@GetMapping("/{id}")
public Order find(@PathVariable long id) {
return orders.find(id);
}
/** Bean validation on the body: MethodArgumentNotValidException. Stock check: OutOfStockException. */
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Order place(@Valid @RequestBody OrderRequest request) {
return orders.place(request);
}
/**
* Constraint directly on a parameter: since Spring Framework 6.1 this is validated by the
* built-in method validation and fails with HandlerMethodValidationException - no
* {@code @Validated} on the class required.
*/
@GetMapping
public List<Order> list(@RequestParam @Min(1) @Max(100) int limit) {
return List.of(orders.find(1)).subList(0, Math.min(limit, 1));
}
/** An unexpected exception whose message contains something that must not reach a client. */
@GetMapping("/boom")
public Order boom() {
throw new IllegalStateException(
"Connection pool exhausted for jdbc:postgresql://orders-db.internal:5432/orders (user=orders_rw)");
}
/** The framework's own ErrorResponse, thrown from application code. */
@GetMapping("/legacy/{id}")
public Order legacy(@PathVariable long id) {
throw new ResponseStatusException(HttpStatus.GONE, "Legacy order ids were retired in 2024");
}
}
@@ -0,0 +1,30 @@
package com.ankurm.problems.web;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* A servlet filter that rejects requests with a malformed {@code X-Tenant} header by throwing.
* It runs before the DispatcherServlet, so no {@code @ControllerAdvice} ever sees the
* exception - it goes to the container's error page instead. See docs/06-outside-mvc.md.
*/
@Component
public class TenantHeaderFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String tenant = request.getHeader("X-Tenant");
if (tenant != null && !tenant.matches("[a-z0-9-]{3,32}")) {
throw new IllegalArgumentException("Malformed X-Tenant header: " + tenant);
}
chain.doFilter(request, response);
}
}
@@ -0,0 +1,4 @@
spring:
mvc:
problemdetails:
enabled: true
@@ -0,0 +1,4 @@
spring:
mvc:
problemdetails:
enabled: true
@@ -0,0 +1,14 @@
spring:
application:
name: problem-details
threads:
virtual:
enabled: true
server:
port: 8080
# Profiles used by the article:
# (none) Spring Boot defaults - no problem details at all
# boot-flag spring.mvc.problemdetails.enabled=true
# advice GlobalExceptionHandler + ProblemDetail security handlers (recommended)
# catchall-first boot-flag plus a highest-precedence catch-all advice (a trap)
# ambiguous a handler that fails to start (a trap)
@@ -0,0 +1,5 @@
# Resolved by ResponseEntityExceptionHandler for any ErrorResponse, via the codes
# problemDetail.title.<fully qualified exception class>
# problemDetail.<fully qualified exception class> (detail; {0},{1} from getDetailMessageArguments)
problemDetail.title.com.ankurm.problems.domain.OutOfStockException=Out of stock
problemDetail.com.ankurm.problems.domain.OutOfStockException=Only {1} unit(s) of {0} are available.
@@ -0,0 +1,2 @@
problemDetail.title.com.ankurm.problems.domain.OutOfStockException=Nicht vorrätig
problemDetail.com.ankurm.problems.domain.OutOfStockException=Von {0} sind nur {1} Stück verfügbar.
@@ -0,0 +1,100 @@
package com.ankurm.problems;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The contract of the recommended setup (profiles advice + errors): every failure, including the
* ones produced outside Spring MVC, is application/problem+json with the right status.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"advice", "errors"})
class AdviceContractTest {
private static final String PROBLEM = "application/problem+json";
@LocalServerPort
int port;
@Test
void domainExceptionIs404WithStableType() throws Exception {
Http.Response r = Http.get(port, "/orders/999");
assertThat(r.status()).isEqualTo(404);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).contains("\"type\":\"https://ankurm.com/problems/order-not-found\"")
.contains("\"orderId\":999")
.contains("\"instance\":\"/orders/999\"");
}
@Test
void validationListsEveryViolationWithAPointer() throws Exception {
Http.Response r = Http.send(port, "POST", "/orders", "{\"sku\":\"\",\"quantity\":0,\"customerEmail\":\"nope\"}");
assertThat(r.status()).isEqualTo(400);
assertThat(r.contentType()).isEqualTo(PROBLEM);
// Order of the three is not stable between runs - assert membership, not position.
assertThat(r.body()).contains("\"pointer\":\"#/sku\"", "\"pointer\":\"#/quantity\"",
"\"pointer\":\"#/customerEmail\"");
}
@Test
void unexpectedExceptionDoesNotLeakItsMessage() throws Exception {
Http.Response r = Http.get(port, "/orders/boom");
assertThat(r.status()).isEqualTo(500);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).contains("\"errorId\":").doesNotContain("jdbc:").doesNotContain("orders_rw");
}
@Test
void filterExceptionReachesTheErrorControllerNotTheAdvice() throws Exception {
Http.Response r = Http.get(port, "/orders/1", "X-Tenant", "BAD!");
assertThat(r.status()).isEqualTo(500);
assertThat(r.contentType()).isEqualTo(PROBLEM);
assertThat(r.body()).doesNotContain("X-Tenant").doesNotContain("errorId");
}
@Test
void securityResponsesAreProblemsToo() throws Exception {
Http.Response anonymous = Http.get(port, "/admin/orders");
assertThat(anonymous.status()).isEqualTo(401);
assertThat(anonymous.contentType()).isEqualTo(PROBLEM);
assertThat(anonymous.headers().firstValue("WWW-Authenticate")).hasValue("Basic realm=\"orders\"");
Http.Response user = Http.get(port, "/admin/orders", "Authorization", Http.basic("user", "user"));
assertThat(user.status()).isEqualTo(403);
assertThat(user.contentType()).isEqualTo(PROBLEM);
}
@Test
void anAcceptOfApplicationXmlStillGetsJson() throws Exception {
// application/xml is not compatible with application/problem+xml, so the error falls back
// to problem+json - while a successful response to the same Accept header is XML.
assertThat(Http.get(port, "/orders/1", "Accept", "application/xml").contentType())
.startsWith("application/xml");
assertThat(Http.get(port, "/orders/999", "Accept", "application/xml").contentType())
.isEqualTo(PROBLEM);
assertThat(Http.get(port, "/orders/999", "Accept", "application/problem+xml").contentType())
.isEqualTo("application/problem+xml");
}
@Test
void anUnsatisfiableAcceptDoesNotTurnTheErrorInto406() throws Exception {
Http.Response r = Http.get(port, "/orders/999", "Accept", "image/png");
assertThat(r.status()).isEqualTo(404);
assertThat(r.contentType()).isEqualTo(PROBLEM);
}
@Test
void messageSourceOverridesTitleAndDetailPerLocale() throws Exception {
String body = "{\"sku\":\"SKU-2\",\"quantity\":3,\"customerEmail\":\"[email protected]\"}";
assertThat(Http.send(port, "POST", "/orders", body).body())
.contains("\"title\":\"Out of stock\"")
.contains("\"detail\":\"Only 0 unit(s) of SKU-2 are available.\"");
assertThat(Http.send(port, "POST", "/orders", body, "Accept-Language", "de").body())
.contains("\"title\":\"Nicht vorrätig\"");
}
}
@@ -0,0 +1,43 @@
package com.ankurm.problems;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import tools.jackson.databind.json.JsonMapper;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Facts about Spring Framework 7 the article relies on, each checked rather than remembered. */
class FrameworkFactsTest {
@Test
void typeNoLongerDefaultsToAboutBlank() {
// 6.2.x returned URI "about:blank" here; 7.0 returns null and omits the member.
assertThat(ProblemDetail.forStatus(404).getType()).isNull();
}
@Test
void aHandBuiltMapperDoesNotFlattenProperties() {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "demo");
pd.setProperty("sku", "SKU-2");
assertThat(new JsonMapper().writeValueAsString(pd))
.contains("\"properties\":{\"sku\":\"SKU-2\"}")
.contains("\"type\":null");
}
@Test
void redeclaringAnInheritedHandlerFailsStartup() {
assertThatThrownBy(() -> new SpringApplicationBuilder(ProblemDetailsApplication.class)
.profiles("ambiguous")
.properties("server.port=0")
.run())
.rootCause()
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Ambiguous @ExceptionHandler method mapped for")
.hasMessageContaining("MethodArgumentNotValidException");
}
}
@@ -0,0 +1,74 @@
package com.ankurm.problems;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pins the behaviour of the setups the article warns about, so a Spring Boot upgrade that changes
* any of it fails here first. Nested classes are non-static on purpose - Surefire silently skips
* static nested test classes.
*/
class GapsAndTrapsTest {
@Nested
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class Defaults {
@LocalServerPort
int port;
@Test
void noProblemDetailsAnywhere() throws Exception {
assertThat(Http.get(port, "/orders/abc").contentType()).isEqualTo("application/json");
assertThat(Http.get(port, "/orders/abc").body()).contains("\"timestamp\"");
}
}
@Nested
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("boot-flag")
class BootFlagOnly {
@LocalServerPort
int port;
@Test
void springMvcExceptionsBecomeProblems() throws Exception {
Http.Response r = Http.get(port, "/orders/abc");
assertThat(r.status()).isEqualTo(400);
assertThat(r.contentType()).isEqualTo("application/problem+json");
}
@Test
void yourOwnExceptionsDoNot() throws Exception {
Http.Response r = Http.get(port, "/orders/999");
assertThat(r.status()).isEqualTo(500);
assertThat(r.contentType()).isEqualTo("application/json");
}
@Test
void validationDetailSaysNothingUseful() throws Exception {
Http.Response r = Http.send(port, "POST", "/orders", "{\"sku\":\"\",\"quantity\":0}");
assertThat(r.body()).contains("\"detail\":\"Invalid request content.\"").doesNotContain("sku");
}
}
@Nested
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("catchall-first")
class CatchAllFirst {
@LocalServerPort
int port;
@Test
void turnsEveryFrameworkErrorInto500() throws Exception {
assertThat(Http.get(port, "/no-such-thing").status()).isEqualTo(500);
assertThat(Http.get(port, "/orders/abc").status()).isEqualTo(500);
assertThat(Http.send(port, "DELETE", "/orders/1", null).status()).isEqualTo(500);
}
}
}
@@ -0,0 +1,45 @@
package com.ankurm.problems;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
/**
* Tests talk to the real embedded Tomcat rather than MockMvc on purpose: MockMvc has no servlet
* container, so it never performs the error-page dispatch to /error - and half of what these
* tests pin down happens there.
*/
final class Http {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
record Response(int status, String contentType, String body, java.net.http.HttpHeaders headers) {
}
static Response send(int port, String method, String path, String body, String... headers) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (body != null) {
b.header("Content-Type", "application/json");
}
for (int i = 0; i < headers.length; i += 2) {
b.header(headers[i], headers[i + 1]);
}
HttpResponse<String> r = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
return new Response(r.statusCode(), r.headers().firstValue("Content-Type").orElse(""), r.body(), r.headers());
}
static Response get(int port, String path, String... headers) throws Exception {
return send(port, "GET", path, null, headers);
}
static String basic(String user, String password) {
return "Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes());
}
private Http() {
}
}