diff --git a/README.md b/README.md index 25af00b..d11236a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ files. | [`spring-aop/`](spring-aop) | [Spring AOP Explained](https://ankurm.com/) | every pointcut designator with real matches, JDK vs CGLIB proxies, six aspects that do not fire | | [`docker-images/`](docker-images) | [Dockerizing Spring Boot 4: Layered Jars, Buildpacks, Distroless and Image Size Benchmarks](https://ankurm.com/dockerizing-spring-boot-4-layered-jars-buildpacks-distroless/) | one service packaged nine ways and measured: size on disk and pushed, rebuild delta, startup, PID 1 and signals, jlink, the JDK 25 AOT cache | | [`kubernetes-deployment/`](kubernetes-deployment) | [Deploying Spring Boot 4 on Kubernetes](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/) | probe groups under a dependency outage, graceful shutdown under load four ways, JVM ergonomics per pod shape, CPU limits throttling GC, HPA on a Micrometer metric | +| [`problem-details/`](problem-details) | [Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/) | thirteen failures under five handling setups, validation errors, i18n, content negotiation, errors outside MVC, silent 500s, decoding on the client | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/problem-details/README.md b/problem-details/README.md new file mode 100644 index 0000000..518101c --- /dev/null +++ b/problem-details/README.md @@ -0,0 +1,97 @@ +# Global exception handling with ProblemDetail (RFC 9457) + +Companion project for [**Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4**](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/) +on ankurm.com. + +One small order API with thirteen ways to fail, run under five exception-handling setups. Every +table and transcript in the article came out of [`docs/output/`](docs/output), and +`./scripts/run-all.sh` regenerates all of it. + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 | +| Spring Framework | 7.0.9 | +| Spring Security | 7.1.1 | +| Jackson | 3 (`tools.jackson`) | +| JDK | Eclipse Temurin 25.0.4.1 (LTS) | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn -DskipTests package +./scripts/run.sh advice,errors # the recommended setup +curl -s localhost:8080/orders/999 +./scripts/run-all.sh # regenerate every transcript in docs/output/ +mvn test # 16 contract tests +``` + +## Profiles + +| Profile | What it activates | +|---|---| +| *(none)* | Spring Boot defaults: no problem details anywhere | +| `boot-flag` | `spring.mvc.problemdetails.enabled=true` - Boot's `ProblemDetailsExceptionHandler` | +| `advice` | [`GlobalExceptionHandler`](src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) + [problem-detail security handlers](src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java) | +| `errors` | [`ProblemDetailErrorController`](src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java) replacing `BasicErrorController` | +| `advice,errors` | **the recommended combination** - all thirteen failures become `application/problem+json` | +| `catchall-first` | a trap: a highest-precedence catch-all advice that turns every 4xx into 500 | +| `ambiguous` | a trap: a handler that stops the application starting | + +## Endpoints + +| Endpoint | Fails with | +|---|---| +| `GET /orders/{id}` | `OrderNotFoundException` (domain); `TypeMismatchException` for `/orders/abc` | +| `POST /orders` | `MethodArgumentNotValidException`; `OutOfStockException` (an `ErrorResponseException`) | +| `GET /orders?limit=` | `HandlerMethodValidationException` above 100 | +| `GET /orders/boom` | `IllegalStateException` whose message must not leak | +| `GET /orders/legacy/{id}` | `ResponseStatusException` | +| any path with `X-Tenant: BAD!` | exception thrown in a servlet filter | +| `GET /admin/orders` | 401 / 403 from Spring Security | +| `GET /diag/advice` | *diagnostic* - every `@ControllerAdvice` in consultation order | +| `GET /diag/decode?path=` | *diagnostic* - what `RestClient` decodes from an error body | +| `GET /diag/mixin` | *diagnostic* - one `ProblemDetail` serialised by three mappers | + +The `/diag` endpoints are for the article. Delete them before shipping anything. + +## Documentation + +1. [The mental model: RFC 9457 and Spring's four types](docs/01-mental-model.md) +2. [Choosing a mechanism - defaults, the Boot flag, an advice, the error controller](docs/02-choosing-a-mechanism.md) +3. [Validation errors that say something](docs/03-validation-errors.md) +4. [Problem types, message codes and the `about:blank` change in 7.0](docs/04-i18n-and-types.md) +5. [Content negotiation: why `Accept: application/xml` gets JSON](docs/05-content-negotiation.md) +6. [Errors that never reach an advice: filters, Security, and hand-built mappers](docs/06-outside-mvc.md) +7. [Silent 500s: the catch-all that makes errors invisible](docs/07-silent-500s.md) +8. [The client side: decoding problems with `RestClient`](docs/08-clients.md) + +## Captured output + +| File | Produced by | +|---|---| +| [`matrix-summary.txt`](docs/output/matrix-summary.txt) and `matrix-.txt` | `scripts/demo-matrix.sh` | +| [`content-negotiation.txt`](docs/output/content-negotiation.txt) | `scripts/demo-negotiation.sh` | +| [`i18n.txt`](docs/output/i18n.txt) | `scripts/demo-i18n.sh` | +| [`silent-500.txt`](docs/output/silent-500.txt) | `scripts/demo-silent-500.sh` | +| [`ambiguous-handler.txt`](docs/output/ambiguous-handler.txt) | `scripts/demo-ambiguous.sh` | +| [`client-decoding.txt`](docs/output/client-decoding.txt) | `scripts/demo-client.sh` | +| [`advice-order.txt`](docs/output/advice-order.txt) | `scripts/demo-advice-order.sh` | +| [`type-default.txt`](docs/output/type-default.txt) | `scripts/demo-type-default.sh` | +| [`errors-only.txt`](docs/output/errors-only.txt) | `scripts/demo-errors-only.sh` | + +## Findings worth the trip + +- **`spring.mvc.problemdetails.enabled=true` covers Spring MVC's own exceptions only.** Your + domain exceptions, unexpected 500s, filter exceptions and Security's 401/403 keep Boot's + `/error` JSON - two error shapes in one API. +- **Spring Framework 7 stopped defaulting `type` to `about:blank`.** 6.2.19 returns the URI; + 7.0.9 returns `null` and the member disappears from the JSON. +- **`Accept: application/xml` gets a JSON error** even with Jackson XML present, because + `application/xml` is not compatible with `application/problem+xml`. +- **A `new JsonMapper()` renders `ProblemDetail` wrongly** - `"properties":{...}` nested and + `"type":null` - so hand-written entry points must use the application's mapper. +- **A catch-all advice that forgets to log makes every 500 invisible**, and one given the highest + precedence also turns every 404, 405 and 400 into a 500. diff --git a/problem-details/docs/01-mental-model.md b/problem-details/docs/01-mental-model.md new file mode 100644 index 0000000..d55b05b --- /dev/null +++ b/problem-details/docs/01-mental-model.md @@ -0,0 +1,48 @@ +# 1. The mental model: RFC 9457 and Spring's four types + +[Index](../README.md) · Next: [2. Choosing a mechanism →](02-choosing-a-mechanism.md) + +## What RFC 9457 actually specifies + +A problem document is a JSON (or XML) object served as `application/problem+json` +(`application/problem+xml`) with five optional members: + +| Member | Meaning | +|---|---| +| `type` | a URI identifying the *kind* of problem. When absent it is assumed to be `about:blank` | +| `title` | a short, human-readable summary of that kind - the same for every occurrence | +| `status` | the HTTP status, repeated for convenience | +| `detail` | a human-readable explanation of *this* occurrence | +| `instance` | a URI identifying this occurrence | + +Anything else is an *extension member* - `orderId`, `errors`, `errorId` in this project. + +RFC 9457 obsoletes RFC 7807. The wire format did not change. What it added: a registry of common +problem type URIs, guidance on multiple problems ("the most relevant or urgent problem" should be +represented), and guidance for `type` URIs that cannot be dereferenced. The XML namespace is still +`urn:ietf:rfc:7807` - you will see it in [`content-negotiation.txt`](output/content-negotiation.txt). + +## Spring's four types + +| Type | What it is | Used here | +|---|---|---| +| `ProblemDetail` | the body: the five members plus a `properties` map for extensions | every handler | +| `ErrorResponse` | an interface: "I know my status, headers and `ProblemDetail`" | all Spring MVC exceptions implement it | +| `ErrorResponseException` | a convenient base class implementing `ErrorResponse` | [`OutOfStockException`](../src/main/java/com/ankurm/problems/domain/OutOfStockException.java) | +| `ResponseEntityExceptionHandler` | a `@ControllerAdvice` base class that renders every Spring MVC exception as a problem | [`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) | + +## Who renders what + +There are three places an error body can come from, and most confusion comes from not knowing +which one produced the response you are looking at: + +1. **An `@ExceptionHandler`** - inside the `DispatcherServlet`, for exceptions thrown by handler + methods (and Spring MVC's own exceptions). +2. **The container's error page** - Tomcat forwards to `/error`, Spring Boot's + `BasicErrorController` renders `{"timestamp","status","error","path"}`. Everything the first + place did not handle ends up here, including exceptions from servlet filters. +3. **Code that writes the response directly** - Spring Security's entry point and access-denied + handler, or your own filters. + +The five setups in [chapter 2](02-choosing-a-mechanism.md) differ only in which of these three +places produce `application/problem+json`. diff --git a/problem-details/docs/02-choosing-a-mechanism.md b/problem-details/docs/02-choosing-a-mechanism.md new file mode 100644 index 0000000..c4bcff8 --- /dev/null +++ b/problem-details/docs/02-choosing-a-mechanism.md @@ -0,0 +1,71 @@ +# 2. Choosing a mechanism + +[← 1. Mental model](01-mental-model.md) · [Index](../README.md) · Next: [3. Validation errors →](03-validation-errors.md) + +The same thirteen failures under five setups - [`matrix-summary.txt`](output/matrix-summary.txt), +produced by [`scripts/demo-matrix.sh`](../scripts/demo-matrix.sh): + +``` +failure | defaults | boot-flag | advice | advice,errors | catchall-first +--------------------------------------------------------------------------------------------------------------------------------------------------------------------- +domain exception (OrderNotFoundException) | 500 Boot /error JSON | 500 Boot /error JSON | 404 problem+json | 404 problem+json | 500 problem+json +type mismatch (/orders/abc) | 400 Boot /error JSON | 400 problem+json | 400 problem+json | 400 problem+json | 500 problem+json +exception in a servlet filter | 500 Boot /error JSON | 500 Boot /error JSON | 500 Boot /error JSON | 500 problem+json | 500 Boot /error JSON +401 no credentials | 401 Boot /error JSON | 401 Boot /error JSON | 401 problem+json | 401 problem+json | 401 Boot /error JSON +``` + +## `spring.mvc.problemdetails.enabled` + +Defaults to `false` in Spring Boot 4.1.1 (`spring-configuration-metadata.json` in +`spring-boot-webmvc`). When `true`, `WebMvcAutoConfiguration$ProblemDetailsErrorHandlingConfiguration` +registers `ProblemDetailsExceptionHandler` - an empty subclass of `ResponseEntityExceptionHandler` - +with: + +- `@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)` - it backs off as soon as you + declare your own subclass ([`advice-order.txt`](output/advice-order.txt), profiles + `boot-flag,advice`) +- `@Order(0)` - it is consulted before any advice with the default (lowest) precedence + +It handles exactly what `ResponseEntityExceptionHandler` handles: Spring MVC's exceptions and any +`ErrorResponse`. **It does not handle your exceptions**, which is why the first and tenth rows stay +Boot's JSON. Turning the flag on and stopping there gives an API with two error shapes. + +## Advice ordering, and the catch-all trap + +`ExceptionHandlerExceptionResolver` walks the advices in order and uses the **first advice that has +any matching handler**, not the most specific handler across all advices. The +[`catchall-first`](../src/main/java/com/ankurm/problems/advice/CatchAllFirstHandler.java) profile +declares `@ExceptionHandler(Exception.class)` at `Ordered.HIGHEST_PRECEDENCE`; it matches +`NoResourceFoundException`, `HttpRequestMethodNotSupportedException` and every other framework +exception before Boot's handler at order 0 gets a look. Every 4xx in the matrix becomes a 500. + +Keep the catch-all in the *same* class as your specific handlers (the most specific handler wins +within one class), or give it the lowest precedence. + +## The handler that stops the application starting + +Extending `ResponseEntityExceptionHandler` and adding +`@ExceptionHandler(MethodArgumentNotValidException.class)` - the obvious way to customise +validation errors - fails at startup ([`ambiguous-handler.txt`](output/ambiguous-handler.txt)): + +``` +Caused by: java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]: + {org.springframework.http.ProblemDetail com.ankurm.problems.advice.AmbiguousExceptionHandler.invalid(org.springframework.web.bind.MethodArgumentNotValidException), + public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception} +``` + +The base class's `handleException` is `final` and already mapped. Override the protected +`handleMethodArgumentNotValid(...)` instead - [chapter 3](03-validation-errors.md). + +## What the recommended setup is + +`advice` + `errors`: + +- [`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) + extends `ResponseEntityExceptionHandler`, adds domain handlers and a logging catch-all +- [`ProblemDetailErrorController`](../src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java) + covers the container error page - [chapter 6](06-outside-mvc.md) +- [`ProblemDetailSecurityHandlers`](../src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java) + covers 401/403 with a `WWW-Authenticate` header intact + +[`AdviceContractTest`](../src/test/java/com/ankurm/problems/AdviceContractTest.java) pins it. diff --git a/problem-details/docs/03-validation-errors.md b/problem-details/docs/03-validation-errors.md new file mode 100644 index 0000000..83b7786 --- /dev/null +++ b/problem-details/docs/03-validation-errors.md @@ -0,0 +1,62 @@ +# 3. Validation errors that say something + +[← 2. Choosing a mechanism](02-choosing-a-mechanism.md) · [Index](../README.md) · Next: [4. Types and message codes →](04-i18n-and-types.md) + +Two exceptions, depending on where the constraint is: + +| Constraint on | Exception | Stock `detail` | +|---|---|---| +| a `@Valid @RequestBody` object | `MethodArgumentNotValidException` | `Invalid request content.` | +| a parameter directly (`@RequestParam @Max(100) int limit`) | `HandlerMethodValidationException` | `Validation failure` | + +The second is Spring Framework 6.1's built-in method validation: a constraint annotation on a +controller parameter is enough, no `@Validated` on the class. + +## The stock body tells the client nothing + +From [`matrix-boot-flag.txt`](output/matrix-boot-flag.txt) - three violations in the request, none in +the response: + +``` +HTTP 400 Content-Type: application/problem+json +{"detail":"Invalid request content.","instance":"/orders","status":400,"title":"Bad Request"} +``` + +## Adding the violations + +Override the protected hook, keep the body the base class built, add an extension member, and hand +it back to `handleExceptionInternal` so headers and status stay right +([`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java)): + +```java +@Override +protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + 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); +} +``` + +The `{"pointer","detail"}` shape is not invented: it is the example RFC 9457 itself uses in +section 3. From [`matrix-advice.txt`](output/matrix-advice.txt): + +``` +{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"pointer":"#/quantity","detail":"must be greater than or equal to 1"},{"pointer":"#/customerEmail","detail":"must be a well-formed email address"},{"pointer":"#/sku","detail":"must not be blank"}]} +``` + +## Do not assert on the order + +The order in that line is not declaration order, and it differed between runs of the same +request. The contract +test asserts membership, not position. + +## Nested fields + +`FieldError.getField()` uses dots for nesting (`address.city`) and brackets for indexes +(`lines[0].sku`). The `replace('.', '/')` here handles the first; if your API has lists of objects, +convert `[n]` to `/n` as well so the pointer is a valid JSON Pointer. diff --git a/problem-details/docs/04-i18n-and-types.md b/problem-details/docs/04-i18n-and-types.md new file mode 100644 index 0000000..b8e234e --- /dev/null +++ b/problem-details/docs/04-i18n-and-types.md @@ -0,0 +1,51 @@ +# 4. Problem types, message codes and the `about:blank` change in 7.0 + +[← 3. Validation errors](03-validation-errors.md) · [Index](../README.md) · Next: [5. Content negotiation →](05-content-negotiation.md) + +## `type` is for machines + +`title` and `detail` are for people and may be translated. `type` is the one member a client should +branch on, so give each of your problem kinds a stable URI. It does not have to resolve - RFC 9457 +section 3.1.1 covers non-dereferenceable URIs - but if it does, put documentation there. + +## Spring Framework 7 no longer defaults it + +[`type-default.txt`](output/type-default.txt), evaluated in jshell against both jars: + +``` +spring-web-6.2.19.jar -> getType() = about:blank +spring-web-7.0.9.jar -> getType() = null +``` + +Both versions' `ProblemDetailJacksonMixin` carry `@JsonInclude(NON_EMPTY)` (read with `javap -v`), +so 6.2 rendered `"type":"about:blank"` on every framework error and 7.0 omits the member. RFC 9457 +says an absent `type` *means* `about:blank`, so neither is wrong - but: + +- a contract test or client that matched the literal `"type":"about:blank"` breaks on the upgrade +- on the client side `ProblemDetail.getType()` is now `null` for these responses, so + `pd.getType().equals(...)` throws ([chapter 8](08-clients.md)) + +## Message codes + +`ResponseEntityExceptionHandler` resolves `title` and `detail` for any `ErrorResponse` through the +`MessageSource`, with these codes: + +| Member | Code | +|---|---| +| `type` | `problemDetail.type.` | +| `title` | `problemDetail.title.` | +| `detail` | `problemDetail.` (+ optional suffix) | + +Arguments for `detail` come from `getDetailMessageArguments()`. +[`OutOfStockException`](../src/main/java/com/ankurm/problems/domain/OutOfStockException.java) +returns `{sku, available}`, and [`messages.properties`](../src/main/resources/messages.properties) +uses them. Its constructor sets `title="Insufficient stock"`; the message source wins +([`i18n.txt`](output/i18n.txt)): + +``` +{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} +{"detail":"Von SKU-2 sind nur 0 Stück verfügbar.","instance":"/orders","status":409,"title":"Nicht vorrätig","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} +``` + +This only happens on the `ResponseEntityExceptionHandler` path. A `ProblemDetail` you build in your +own `@ExceptionHandler` method is rendered as built. diff --git a/problem-details/docs/05-content-negotiation.md b/problem-details/docs/05-content-negotiation.md new file mode 100644 index 0000000..f893426 --- /dev/null +++ b/problem-details/docs/05-content-negotiation.md @@ -0,0 +1,27 @@ +# 5. Content negotiation: why `Accept: application/xml` gets JSON + +[← 4. Types and message codes](04-i18n-and-types.md) · [Index](../README.md) · Next: [6. Outside Spring MVC →](06-outside-mvc.md) + +With `jackson-dataformat-xml` on the classpath a successful response honours `Accept: +application/xml`. An error response to the same request does not +([`content-negotiation.txt`](output/content-negotiation.txt)): + +| `Accept` | `GET /orders/1` (200) | `GET /orders/999` (404) | +|---|---|---| +| `application/json` | `application/json` | `application/problem+json` | +| `application/xml` | `application/xml` | **`application/problem+json`** | +| `application/problem+xml` | - | `application/problem+xml` | +| `text/html` | - | `application/problem+json` | +| `image/png` | - | `application/problem+json` (still 404, not 406) | + +For a `ProblemDetail` body the producible types are `application/problem+json` and +`application/problem+xml`. `application/xml` is compatible with neither - `problem+xml` is a +different subtype, not a specialisation - so negotiation finds no match and Spring falls back to +JSON rather than failing the error response with a 406. The fallback is the right call; the +surprise is that an XML client must ask for `application/problem+xml` by name to get XML errors. + +The XML body uses the RFC's namespace, which RFC 9457 kept from 7807: + +``` +Order 999 does not exist/orders/999404Order not foundhttps://ankurm.com/problems/order-not-found999 +``` diff --git a/problem-details/docs/06-outside-mvc.md b/problem-details/docs/06-outside-mvc.md new file mode 100644 index 0000000..9fc448d --- /dev/null +++ b/problem-details/docs/06-outside-mvc.md @@ -0,0 +1,51 @@ +# 6. Errors that never reach an advice + +[← 5. Content negotiation](05-content-negotiation.md) · [Index](../README.md) · Next: [7. Silent 500s →](07-silent-500s.md) + +`@ControllerAdvice` runs inside the `DispatcherServlet`. Three kinds of error happen before or +around it. + +## Exceptions thrown by servlet filters + +[`TenantHeaderFilter`](../src/main/java/com/ankurm/problems/web/TenantHeaderFilter.java) throws for +a malformed header. No advice sees it; Tomcat forwards to `/error`, and even the `advice` profile +answers with Boot's JSON. The fix is to own `/error`: +[`ProblemDetailErrorController`](../src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java) +implements Boot's `ErrorController` (`org.springframework.boot.webmvc.error` in Boot 4), which makes +`ErrorMvcAutoConfiguration` skip `BasicErrorController`. It builds the problem from +`ErrorAttributes` and keeps the 5xx detail generic, because the message there is whatever a filter +happened to throw. + +## Spring Security's 401 and 403 + +Decided in the filter chain. By default the entry point calls `sendError(401)`, which also ends at +`/error` - so the error controller alone turns them into problems ([`errors-only.txt`](output/errors-only.txt)): + +``` +HTTP/1.1 401 +WWW-Authenticate: Basic realm="Realm", charset="UTF-8" +Content-Type: application/problem+json +{"detail":"Unauthorized","instance":"/admin/orders","status":401,"title":"Unauthorized"} +``` + This project goes one step +further with [`ProblemDetailSecurityHandlers`](../src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java) +so the 401 keeps a precise `type` and its `WWW-Authenticate` header. + +Note it is registered for `httpBasic` as well as `exceptionHandling`: HTTP Basic has its own entry +point and would otherwise bypass the one set on `exceptionHandling`. + +## Writing a `ProblemDetail` yourself: use the application's mapper + +[`/diag/mixin`](../src/main/java/com/ankurm/problems/diag/DiagController.java) serialises one +`ProblemDetail` with one extension member three ways ([`client-decoding.txt`](output/client-decoding.txt)): + +``` + "Spring Boot's JsonMapper bean": "{\"detail\":\"demo\",\"status\":409,\"title\":\"Conflict\",\"sku\":\"SKU-2\"}", + "JsonMapper.builder().build()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}", + "new JsonMapper()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}" +``` + +`ProblemDetail` is a plain bean; the flattening of `properties` and the omission of empty members +come from `ProblemDetailJacksonMixin`, which the Boot-configured mapper has and a mapper you +construct does not. An entry point that does `new ObjectMapper().writeValue(...)` - the version in +most tutorials - produces a body that is not a valid problem document. Inject the `JsonMapper`. diff --git a/problem-details/docs/07-silent-500s.md b/problem-details/docs/07-silent-500s.md new file mode 100644 index 0000000..bf91815 --- /dev/null +++ b/problem-details/docs/07-silent-500s.md @@ -0,0 +1,25 @@ +# 7. Silent 500s: the catch-all that makes errors invisible + +[← 6. Outside Spring MVC](06-outside-mvc.md) · [Index](../README.md) · Next: [8. Clients →](08-clients.md) + +When an exception reaches the container, Tomcat logs it with its stack trace. When an +`@ExceptionHandler` handles it, **nothing logs it unless that handler does** - handling an +exception means it is no longer an error as far as the framework is concerned. + +[`silent-500.txt`](output/silent-500.txt), one request to `/orders/boom` per setup: + +``` +defaults (Boot /error) ERROR lines: 1 stack frames: 87 +boot-flag ERROR lines: 1 stack frames: 87 +advice, catch-all logs with errorId ERROR lines: 1 stack frames: 87 +advice, catch-all WITHOUT the log line ERROR lines: 0 stack frames: 0 +catchall-first (returns 500, never logs) ERROR lines: 0 stack frames: 0 +``` + +The last two return a tidy `500` problem and leave no trace anywhere. Adding problem details to an +API that used to log its failures can remove the only record of them. + +[`GlobalExceptionHandler.unexpected`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) +logs at ERROR with a generated `errorId`, puts the same id in the response, and keeps +`ex.getMessage()` out of it - the message in this project contains a JDBC URL and a database user, +which is exactly what RFC 9457's security considerations warn against exposing. diff --git a/problem-details/docs/08-clients.md b/problem-details/docs/08-clients.md new file mode 100644 index 0000000..a35d8de --- /dev/null +++ b/problem-details/docs/08-clients.md @@ -0,0 +1,42 @@ +# 8. The client side: decoding problems with `RestClient` + +[← 7. Silent 500s](07-silent-500s.md) · [Index](../README.md) + +`RestClient`'s default status handler throws `HttpClientErrorException` / +`HttpServerErrorException`; `getResponseBodyAs(ProblemDetail.class)` decodes the body with the +client's converters. [`/diag/decode`](../src/main/java/com/ankurm/problems/diag/DiagController.java) +reports what came back ([`client-decoding.txt`](output/client-decoding.txt)). + +## What works + +Extension members land in `getProperties()` - `orderId`, the `errors` list, `errorId`. The mixin +works in both directions. + +## Two things to code defensively against + +**`getType()` is `null` when the server omitted it**, which Spring Framework 7 servers do for every +framework error ([chapter 4](04-i18n-and-types.md)). Treat `null` as `about:blank`. + +**Decoding succeeds on a body that is not a problem at all.** Against the `defaults` profile the +server sends Boot's `application/json` error, and it still decodes: + +``` + "exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError", + "status": 500, + "contentType": "application/json", + "problemDetail": { + "type": "null", + "title": "Internal Server Error", + "status": 500, + "detail": null, + "instance": "null", + "properties": { + "timestamp": "2026-09-11T17:05:01.408Z", + "error": "Internal Server Error", + "path": "/orders/999" + } + } +``` + +Unknown members go into `properties`, so any JSON object "is" a `ProblemDetail`. Check the +`Content-Type` is `application/problem+json` before believing you have one. diff --git a/problem-details/docs/output/advice-order.txt b/problem-details/docs/output/advice-order.txt new file mode 100644 index 0000000..345f528 --- /dev/null +++ b/problem-details/docs/output/advice-order.txt @@ -0,0 +1,15 @@ +## profiles: + +## profiles: boot-flag + order 0 org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler + +## profiles: advice + order 2147483647 com.ankurm.problems.advice.GlobalExceptionHandler + +## profiles: boot-flag,advice + order 2147483647 com.ankurm.problems.advice.GlobalExceptionHandler + +## profiles: catchall-first + order -2147483648 com.ankurm.problems.advice.CatchAllFirstHandler + order 0 org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler + diff --git a/problem-details/docs/output/ambiguous-handler.txt b/problem-details/docs/output/ambiguous-handler.txt new file mode 100644 index 0000000..24c0422 --- /dev/null +++ b/problem-details/docs/output/ambiguous-handler.txt @@ -0,0 +1,5 @@ +# Profile: ambiguous (exit code 1) + +Caused by: java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]: + {org.springframework.http.ProblemDetail com.ankurm.problems.advice.AmbiguousExceptionHandler.invalid(org.springframework.web.bind.MethodArgumentNotValidException), + public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception} diff --git a/problem-details/docs/output/client-decoding.txt b/problem-details/docs/output/client-decoding.txt new file mode 100644 index 0000000..3630ec0 --- /dev/null +++ b/problem-details/docs/output/client-decoding.txt @@ -0,0 +1,123 @@ +# Profile: advice + +## RestClient GET /orders/999 -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpClientErrorException$NotFound", + "status": 404, + "contentType": "application/problem+json", + "problemDetail": { + "type": "https://ankurm.com/problems/order-not-found", + "title": "Order not found", + "status": 404, + "detail": "Order 999 does not exist", + "instance": "/orders/999", + "properties": { + "orderId": 999 + } + } +} + +## RestClient GET /orders?limit=500 -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpClientErrorException$BadRequest", + "status": 400, + "contentType": "application/problem+json", + "problemDetail": { + "type": "null", + "title": "Bad Request", + "status": 400, + "detail": "Validation failure", + "instance": "/orders", + "properties": { + "errors": [ + { + "parameter": "limit", + "detail": "must be less than or equal to 100" + } + ] + } + } +} + +## RestClient GET /orders/boom -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError", + "status": 500, + "contentType": "application/problem+json", + "problemDetail": { + "type": "null", + "title": "Internal Server Error", + "status": 500, + "detail": "An unexpected error occurred. Quote errorId when reporting it.", + "instance": "/orders/boom", + "properties": { + "errorId": "d09000a5-d6bf-438d-a1a0-527d7cd9d6b7" + } + } +} + +# Profile: defaults + +## RestClient GET /orders/999 -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError", + "status": 500, + "contentType": "application/json", + "problemDetail": { + "type": "null", + "title": "Internal Server Error", + "status": 500, + "detail": null, + "instance": "null", + "properties": { + "timestamp": "2026-09-11T17:05:01.408Z", + "error": "Internal Server Error", + "path": "/orders/999" + } + } +} + +## RestClient GET /orders?limit=500 -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpClientErrorException$BadRequest", + "status": 400, + "contentType": "application/json", + "problemDetail": { + "type": "null", + "title": "Bad Request", + "status": 400, + "detail": null, + "instance": "null", + "properties": { + "timestamp": "2026-09-11T17:05:01.620Z", + "error": "Bad Request", + "path": "/orders" + } + } +} + +## RestClient GET /orders/boom -> ex.getResponseBodyAs(ProblemDetail.class) +{ + "exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError", + "status": 500, + "contentType": "application/json", + "problemDetail": { + "type": "null", + "title": "Internal Server Error", + "status": 500, + "detail": null, + "instance": "null", + "properties": { + "timestamp": "2026-09-11T17:05:01.660Z", + "error": "Internal Server Error", + "path": "/orders/boom" + } + } +} + +# ProblemDetail with one extension member, serialised three ways +{ + "Spring Boot's JsonMapper bean": "{\"detail\":\"demo\",\"status\":409,\"title\":\"Conflict\",\"sku\":\"SKU-2\"}", + "JsonMapper.builder().build()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}", + "new JsonMapper()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}" +} diff --git a/problem-details/docs/output/content-negotiation.txt b/problem-details/docs/output/content-negotiation.txt new file mode 100644 index 0000000..03a6b68 --- /dev/null +++ b/problem-details/docs/output/content-negotiation.txt @@ -0,0 +1,44 @@ +# Accept header vs error body (profile: advice, jackson-dataformat-xml on the classpath) + +## A successful response, for comparison +$ curl -H "Accept: application/xml" /orders/1 +HTTP/1.1 200 +Content-Type: application/xml;charset=UTF-8 +1SKU-12 + +## Accept: application/json +$ curl -H "Accept: application/json" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## Accept: application/xml +$ curl -H "Accept: application/xml" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## Accept: application/problem+xml +$ curl -H "Accept: application/problem+xml" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+xml +Order 999 does not exist/orders/999404Order not foundhttps://ankurm.com/problems/order-not-found999 + +## Accept: text/html +$ curl -H "Accept: text/html" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## Accept: image/png +$ curl -H "Accept: image/png" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## Accept: */* +$ curl -H "Accept: */*" /orders/999 +HTTP/1.1 404 +Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + diff --git a/problem-details/docs/output/errors-only.txt b/problem-details/docs/output/errors-only.txt new file mode 100644 index 0000000..163ae37 --- /dev/null +++ b/problem-details/docs/output/errors-only.txt @@ -0,0 +1,18 @@ +# Profile: errors (ProblemDetailErrorController only - no @ControllerAdvice, no Security handlers) + +$ curl /admin/orders +HTTP/1.1 401 +WWW-Authenticate: Basic realm="Realm", charset="UTF-8" +Content-Type: application/problem+json +{"detail":"Unauthorized","instance":"/admin/orders","status":401,"title":"Unauthorized"} + +$ curl -u user:user /admin/orders +HTTP/1.1 403 +Content-Type: application/problem+json +{"detail":"Forbidden","instance":"/admin/orders","status":403,"title":"Forbidden"} + +$ curl /orders/999 +HTTP/1.1 500 +Content-Type: application/problem+json +{"detail":"An unexpected error occurred.","instance":"/orders/999","status":500,"title":"Internal Server Error"} + diff --git a/problem-details/docs/output/i18n.txt b/problem-details/docs/output/i18n.txt new file mode 100644 index 0000000..ace5a07 --- /dev/null +++ b/problem-details/docs/output/i18n.txt @@ -0,0 +1,13 @@ +# OutOfStockException - its own ProblemDetail vs messages.properties vs messages_de.properties + +## Exception's own text (what the constructor set): + title="Insufficient stock" detail="Requested 3 of SKU-2 but only 0 available" + +## Accept-Language: +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} + +## Accept-Language: de +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' -H 'Accept-Language: de' +{"detail":"Von SKU-2 sind nur 0 Stück verfügbar.","instance":"/orders","status":409,"title":"Nicht vorrätig","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} + diff --git a/problem-details/docs/output/matrix-advice,errors.txt b/problem-details/docs/output/matrix-advice,errors.txt new file mode 100644 index 0000000..1f2810c --- /dev/null +++ b/problem-details/docs/output/matrix-advice,errors.txt @@ -0,0 +1,67 @@ +# Profile: advice,errors (spring-boot 4.1.1, spring-framework 7.0.9) + +## domain exception (OrderNotFoundException) +$ curl -X GET /orders/999 +HTTP 404 Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## type mismatch (/orders/abc) +$ curl -X GET /orders/abc +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"} + +## invalid body (@Valid) +$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}' +HTTP 400 Content-Type: application/problem+json +{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"detail":"must not be blank","pointer":"#/sku"},{"detail":"must be a well-formed email address","pointer":"#/customerEmail"},{"detail":"must be greater than or equal to 1","pointer":"#/quantity"}]} + +## invalid @RequestParam (@Max) +$ curl -X GET /orders?limit=500 +HTTP 400 Content-Type: application/problem+json +{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request","errors":[{"detail":"must be less than or equal to 100","parameter":"limit"}]} + +## ErrorResponseException (OutOfStock) +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +HTTP 409 Content-Type: application/problem+json +{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} + +## ResponseStatusException +$ curl -X GET /orders/legacy/7 +HTTP 410 Content-Type: application/problem+json +{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"} + +## unknown path +$ curl -X GET /no-such-thing +HTTP 404 Content-Type: application/problem+json +{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"} + +## wrong HTTP method +$ curl -X DELETE /orders/1 +HTTP 405 Content-Type: application/problem+json +{"detail":"Method 'DELETE' is not supported.","instance":"/orders/1","status":405,"title":"Method Not Allowed"} + +## malformed JSON +$ curl -X POST /orders -d '{"sku":' +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"} + +## unexpected exception +$ curl -X GET /orders/boom +HTTP 500 Content-Type: application/problem+json +{"detail":"An unexpected error occurred. Quote errorId when reporting it.","instance":"/orders/boom","status":500,"title":"Internal Server Error","errorId":"a3838ab0-afd2-4bbd-85b5-15e9a309c1f8"} + +## exception in a servlet filter +$ curl -X GET /orders/1 [X-Tenant: BAD!] +HTTP 500 Content-Type: application/problem+json +{"detail":"An unexpected error occurred.","instance":"/orders/1","status":500,"title":"Internal Server Error"} + +## 401 no credentials +$ curl -X GET /admin/orders +HTTP 401 Content-Type: application/problem+json +{"detail":"Authentication is required to access this resource","instance":"/admin/orders","status":401,"title":"Unauthorized","type":"https://ankurm.com/problems/authentication-required","scheme":"Basic"} + +## 403 wrong role +$ curl -X GET /admin/orders [AUTH:user:user] +HTTP 403 Content-Type: application/problem+json +{"detail":"Your credentials do not grant access to this resource","instance":"/admin/orders","status":403,"title":"Forbidden","type":"https://ankurm.com/problems/access-denied"} + diff --git a/problem-details/docs/output/matrix-advice.txt b/problem-details/docs/output/matrix-advice.txt new file mode 100644 index 0000000..b5685e4 --- /dev/null +++ b/problem-details/docs/output/matrix-advice.txt @@ -0,0 +1,67 @@ +# Profile: advice (spring-boot 4.1.1, spring-framework 7.0.9) + +## domain exception (OrderNotFoundException) +$ curl -X GET /orders/999 +HTTP 404 Content-Type: application/problem+json +{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999} + +## type mismatch (/orders/abc) +$ curl -X GET /orders/abc +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"} + +## invalid body (@Valid) +$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}' +HTTP 400 Content-Type: application/problem+json +{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"pointer":"#/quantity","detail":"must be greater than or equal to 1"},{"pointer":"#/customerEmail","detail":"must be a well-formed email address"},{"pointer":"#/sku","detail":"must not be blank"}]} + +## invalid @RequestParam (@Max) +$ curl -X GET /orders?limit=500 +HTTP 400 Content-Type: application/problem+json +{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request","errors":[{"parameter":"limit","detail":"must be less than or equal to 100"}]} + +## ErrorResponseException (OutOfStock) +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +HTTP 409 Content-Type: application/problem+json +{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} + +## ResponseStatusException +$ curl -X GET /orders/legacy/7 +HTTP 410 Content-Type: application/problem+json +{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"} + +## unknown path +$ curl -X GET /no-such-thing +HTTP 404 Content-Type: application/problem+json +{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"} + +## wrong HTTP method +$ curl -X DELETE /orders/1 +HTTP 405 Content-Type: application/problem+json +{"detail":"Method 'DELETE' is not supported.","instance":"/orders/1","status":405,"title":"Method Not Allowed"} + +## malformed JSON +$ curl -X POST /orders -d '{"sku":' +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"} + +## unexpected exception +$ curl -X GET /orders/boom +HTTP 500 Content-Type: application/problem+json +{"detail":"An unexpected error occurred. Quote errorId when reporting it.","instance":"/orders/boom","status":500,"title":"Internal Server Error","errorId":"ac0a11a2-1ef5-4ef2-828f-21107e0b9008"} + +## exception in a servlet filter +$ curl -X GET /orders/1 [X-Tenant: BAD!] +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:21.460Z","status":500,"error":"Internal Server Error","path":"/orders/1"} + +## 401 no credentials +$ curl -X GET /admin/orders +HTTP 401 Content-Type: application/problem+json +{"detail":"Authentication is required to access this resource","instance":"/admin/orders","status":401,"title":"Unauthorized","type":"https://ankurm.com/problems/authentication-required","scheme":"Basic"} + +## 403 wrong role +$ curl -X GET /admin/orders [AUTH:user:user] +HTTP 403 Content-Type: application/problem+json +{"detail":"Your credentials do not grant access to this resource","instance":"/admin/orders","status":403,"title":"Forbidden","type":"https://ankurm.com/problems/access-denied"} + diff --git a/problem-details/docs/output/matrix-boot-flag.txt b/problem-details/docs/output/matrix-boot-flag.txt new file mode 100644 index 0000000..ff5a473 --- /dev/null +++ b/problem-details/docs/output/matrix-boot-flag.txt @@ -0,0 +1,67 @@ +# Profile: boot-flag (spring-boot 4.1.1, spring-framework 7.0.9) + +## domain exception (OrderNotFoundException) +$ curl -X GET /orders/999 +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:12.637Z","status":500,"error":"Internal Server Error","path":"/orders/999"} + +## type mismatch (/orders/abc) +$ curl -X GET /orders/abc +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"} + +## invalid body (@Valid) +$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}' +HTTP 400 Content-Type: application/problem+json +{"detail":"Invalid request content.","instance":"/orders","status":400,"title":"Bad Request"} + +## invalid @RequestParam (@Max) +$ curl -X GET /orders?limit=500 +HTTP 400 Content-Type: application/problem+json +{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request"} + +## ErrorResponseException (OutOfStock) +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +HTTP 409 Content-Type: application/problem+json +{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0} + +## ResponseStatusException +$ curl -X GET /orders/legacy/7 +HTTP 410 Content-Type: application/problem+json +{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"} + +## unknown path +$ curl -X GET /no-such-thing +HTTP 404 Content-Type: application/problem+json +{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"} + +## wrong HTTP method +$ curl -X DELETE /orders/1 +HTTP 405 Content-Type: application/problem+json +{"detail":"Method 'DELETE' is not supported.","instance":"/orders/1","status":405,"title":"Method Not Allowed"} + +## malformed JSON +$ curl -X POST /orders -d '{"sku":' +HTTP 400 Content-Type: application/problem+json +{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"} + +## unexpected exception +$ curl -X GET /orders/boom +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:13.097Z","status":500,"error":"Internal Server Error","path":"/orders/boom"} + +## exception in a servlet filter +$ curl -X GET /orders/1 [X-Tenant: BAD!] +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:13.125Z","status":500,"error":"Internal Server Error","path":"/orders/1"} + +## 401 no credentials +$ curl -X GET /admin/orders +HTTP 401 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:13.161Z","status":401,"error":"Unauthorized","path":"/admin/orders"} + +## 403 wrong role +$ curl -X GET /admin/orders [AUTH:user:user] +HTTP 403 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:13.399Z","status":403,"error":"Forbidden","path":"/admin/orders"} + diff --git a/problem-details/docs/output/matrix-catchall-first.txt b/problem-details/docs/output/matrix-catchall-first.txt new file mode 100644 index 0000000..f14e2ad --- /dev/null +++ b/problem-details/docs/output/matrix-catchall-first.txt @@ -0,0 +1,67 @@ +# Profile: catchall-first (spring-boot 4.1.1, spring-framework 7.0.9) + +## domain exception (OrderNotFoundException) +$ curl -X GET /orders/999 +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders/999","status":500,"title":"Internal Server Error"} + +## type mismatch (/orders/abc) +$ curl -X GET /orders/abc +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders/abc","status":500,"title":"Internal Server Error"} + +## invalid body (@Valid) +$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}' +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"} + +## invalid @RequestParam (@Max) +$ curl -X GET /orders?limit=500 +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"} + +## ErrorResponseException (OutOfStock) +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"} + +## ResponseStatusException +$ curl -X GET /orders/legacy/7 +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders/legacy/7","status":500,"title":"Internal Server Error"} + +## unknown path +$ curl -X GET /no-such-thing +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/no-such-thing","status":500,"title":"Internal Server Error"} + +## wrong HTTP method +$ curl -X DELETE /orders/1 +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders/1","status":500,"title":"Internal Server Error"} + +## malformed JSON +$ curl -X POST /orders -d '{"sku":' +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"} + +## unexpected exception +$ curl -X GET /orders/boom +HTTP 500 Content-Type: application/problem+json +{"detail":"Something went wrong","instance":"/orders/boom","status":500,"title":"Internal Server Error"} + +## exception in a servlet filter +$ curl -X GET /orders/1 [X-Tenant: BAD!] +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:37.596Z","status":500,"error":"Internal Server Error","path":"/orders/1"} + +## 401 no credentials +$ curl -X GET /admin/orders +HTTP 401 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:37.638Z","status":401,"error":"Unauthorized","path":"/admin/orders"} + +## 403 wrong role +$ curl -X GET /admin/orders [AUTH:user:user] +HTTP 403 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:37.845Z","status":403,"error":"Forbidden","path":"/admin/orders"} + diff --git a/problem-details/docs/output/matrix-defaults.txt b/problem-details/docs/output/matrix-defaults.txt new file mode 100644 index 0000000..6022887 --- /dev/null +++ b/problem-details/docs/output/matrix-defaults.txt @@ -0,0 +1,67 @@ +# Profile: defaults (spring-boot 4.1.1, spring-framework 7.0.9) + +## domain exception (OrderNotFoundException) +$ curl -X GET /orders/999 +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.029Z","status":500,"error":"Internal Server Error","path":"/orders/999"} + +## type mismatch (/orders/abc) +$ curl -X GET /orders/abc +HTTP 400 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.063Z","status":400,"error":"Bad Request","path":"/orders/abc"} + +## invalid body (@Valid) +$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}' +HTTP 400 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.250Z","status":400,"error":"Bad Request","path":"/orders"} + +## invalid @RequestParam (@Max) +$ curl -X GET /orders?limit=500 +HTTP 400 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.301Z","status":400,"error":"Bad Request","path":"/orders"} + +## ErrorResponseException (OutOfStock) +$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +HTTP 409 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.354Z","status":409,"error":"Conflict","path":"/orders"} + +## ResponseStatusException +$ curl -X GET /orders/legacy/7 +HTTP 410 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.383Z","status":410,"error":"Gone","path":"/orders/legacy/7"} + +## unknown path +$ curl -X GET /no-such-thing +HTTP 404 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.412Z","status":404,"error":"Not Found","path":"/no-such-thing"} + +## wrong HTTP method +$ curl -X DELETE /orders/1 +HTTP 405 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.437Z","status":405,"error":"Method Not Allowed","path":"/orders/1"} + +## malformed JSON +$ curl -X POST /orders -d '{"sku":' +HTTP 400 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.462Z","status":400,"error":"Bad Request","path":"/orders"} + +## unexpected exception +$ curl -X GET /orders/boom +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.487Z","status":500,"error":"Internal Server Error","path":"/orders/boom"} + +## exception in a servlet filter +$ curl -X GET /orders/1 [X-Tenant: BAD!] +HTTP 500 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.509Z","status":500,"error":"Internal Server Error","path":"/orders/1"} + +## 401 no credentials +$ curl -X GET /admin/orders +HTTP 401 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.540Z","status":401,"error":"Unauthorized","path":"/admin/orders"} + +## 403 wrong role +$ curl -X GET /admin/orders [AUTH:user:user] +HTTP 403 Content-Type: application/json +{"timestamp":"2026-09-11T17:03:04.735Z","status":403,"error":"Forbidden","path":"/admin/orders"} + diff --git a/problem-details/docs/output/matrix-summary.txt b/problem-details/docs/output/matrix-summary.txt new file mode 100644 index 0000000..833d43b --- /dev/null +++ b/problem-details/docs/output/matrix-summary.txt @@ -0,0 +1,18 @@ +# Which error shape does each failure produce? (status + body shape) +# Spring Boot 4.1.1 / Spring Framework 7.0.9. Full bodies in matrix-.txt + +failure | defaults | boot-flag | advice | advice,errors | catchall-first +--------------------------------------------------------------------------------------------------------------------------------------------------------------------- +domain exception (OrderNotFoundException) | 500 Boot /error JSON | 500 Boot /error JSON | 404 problem+json | 404 problem+json | 500 problem+json +type mismatch (/orders/abc) | 400 Boot /error JSON | 400 problem+json | 400 problem+json | 400 problem+json | 500 problem+json +invalid body (@Valid) | 400 Boot /error JSON | 400 problem+json | 400 problem+json | 400 problem+json | 500 problem+json +invalid @RequestParam (@Max) | 400 Boot /error JSON | 400 problem+json | 400 problem+json | 400 problem+json | 500 problem+json +ErrorResponseException (OutOfStock) | 409 Boot /error JSON | 409 problem+json | 409 problem+json | 409 problem+json | 500 problem+json +ResponseStatusException | 410 Boot /error JSON | 410 problem+json | 410 problem+json | 410 problem+json | 500 problem+json +unknown path | 404 Boot /error JSON | 404 problem+json | 404 problem+json | 404 problem+json | 500 problem+json +wrong HTTP method | 405 Boot /error JSON | 405 problem+json | 405 problem+json | 405 problem+json | 500 problem+json +malformed JSON | 400 Boot /error JSON | 400 problem+json | 400 problem+json | 400 problem+json | 500 problem+json +unexpected exception | 500 Boot /error JSON | 500 Boot /error JSON | 500 problem+json | 500 problem+json | 500 problem+json +exception in a servlet filter | 500 Boot /error JSON | 500 Boot /error JSON | 500 Boot /error JSON | 500 problem+json | 500 Boot /error JSON +401 no credentials | 401 Boot /error JSON | 401 Boot /error JSON | 401 problem+json | 401 problem+json | 401 Boot /error JSON +403 wrong role | 403 Boot /error JSON | 403 Boot /error JSON | 403 problem+json | 403 problem+json | 403 Boot /error JSON diff --git a/problem-details/docs/output/silent-500.txt b/problem-details/docs/output/silent-500.txt new file mode 100644 index 0000000..149fbbe --- /dev/null +++ b/problem-details/docs/output/silent-500.txt @@ -0,0 +1,15 @@ +# One request to an endpoint that throws IllegalStateException. What reaches the log? + +defaults (Boot /error) ERROR lines: 1 stack frames: 87 + ERROR 30257 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request pr +boot-flag ERROR lines: 1 stack frames: 87 + ERROR 30389 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request pr +advice, catch-all logs with errorId ERROR lines: 1 stack frames: 87 + ERROR 30521 --- [problem-details] [omcat-handler-1] c.a.p.advice.GlobalExceptionHandler : Unhandled exception, errorId=0becc142-9239-44ba-93ae-c0d479052095 +advice, catch-all WITHOUT the log line ERROR lines: 0 stack frames: 0 +catchall-first (returns 500, never logs) ERROR lines: 0 stack frames: 0 + +# And for an exception thrown by a servlet filter (never reaches any advice): + +advice,errors - filter exception ERROR lines: 1 stack frames: 68 + ERROR 30882 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception diff --git a/problem-details/docs/output/type-default.txt b/problem-details/docs/output/type-default.txt new file mode 100644 index 0000000..51359dd --- /dev/null +++ b/problem-details/docs/output/type-default.txt @@ -0,0 +1,9 @@ +# ProblemDetail.forStatus(404).getType(), evaluated in jshell against each spring-web jar + +spring-web-6.2.19.jar -> getType() = about:blank +spring-web-7.0.9.jar -> getType() = null + +# Both versions' ProblemDetailJacksonMixin carry @JsonInclude(NON_EMPTY), so 6.2 rendered +# "type":"about:blank" and 7.0 omits the member. As rendered by this application (7.0.9): +$ curl /no-such-thing +{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"} diff --git a/problem-details/pom.xml b/problem-details/pom.xml new file mode 100644 index 0000000..8a17762 --- /dev/null +++ b/problem-details/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + problem-details + 1.0.0 + problem-details + Global exception handling with ProblemDetail (RFC 9457) on Spring Boot 4 + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-restclient + + + + tools.jackson.dataformat + jackson-dataformat-xml + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/problem-details/scripts/demo-advice-order.sh b/problem-details/scripts/demo-advice-order.sh new file mode 100644 index 0000000..3d7ee90 --- /dev/null +++ b/problem-details/scripts/demo-advice-order.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Which @ControllerAdvice beans exist, in consultation order. -> docs/output/advice-order.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +{ + for profile in "" boot-flag advice "boot-flag,advice" catchall-first; do + "$MODULE_DIR/scripts/run.sh" "$profile" || exit 1 + echo "## profiles: ${profile:-}" + curl -s "$BASE/diag/advice" | python3 -c 'import json,sys +for r in json.load(sys.stdin): print(" order %-12s %s" % (r["order"], r["bean"])) +' ; echo + done +} > "$OUT/advice-order.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/advice-order.txt" diff --git a/problem-details/scripts/demo-ambiguous.sh b/problem-details/scripts/demo-ambiguous.sh new file mode 100644 index 0000000..bcff1d1 --- /dev/null +++ b/problem-details/scripts/demo-ambiguous.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Declaring @ExceptionHandler(MethodArgumentNotValidException.class) in a subclass of +# ResponseEntityExceptionHandler. -> docs/output/ambiguous-handler.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +"$MODULE_DIR/scripts/stop.sh" +timeout 60 java -jar "$JAR" --spring.profiles.active=ambiguous --server.port="$PORT" > "$LOG" 2>&1 +code=$? +{ + echo "# Profile: ambiguous (exit code $code)" + echo + grep -v 'Picked up JAVA_TOOL_OPTIONS' "$LOG" | grep -E '^Caused by: java.lang.IllegalStateException: Ambiguous' | head -1 \ + | sed -e 's/: {/:\n {/' -e 's/, public/,\n public/' +} > "$OUT/ambiguous-handler.txt" +cat "$OUT/ambiguous-handler.txt" diff --git a/problem-details/scripts/demo-client.sh b/problem-details/scripts/demo-client.sh new file mode 100644 index 0000000..2853f6c --- /dev/null +++ b/problem-details/scripts/demo-client.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# What a RestClient caller can decode from each error body. -> docs/output/client-decoding.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +{ + for profile in advice ""; do + "$MODULE_DIR/scripts/run.sh" "$profile" || exit 1 + echo "# Profile: ${profile:-defaults}" + echo + for path in /orders/999 '/orders?limit=500' /orders/boom; do + echo "## RestClient GET $path -> ex.getResponseBodyAs(ProblemDetail.class)" + curl -s -G "$BASE/diag/decode" --data-urlencode "path=$path" | python3 -m json.tool + echo + done + done + echo "# ProblemDetail with one extension member, serialised three ways" + "$MODULE_DIR/scripts/run.sh" advice || exit 1 + curl -s "$BASE/diag/mixin" | python3 -m json.tool +} > "$OUT/client-decoding.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/client-decoding.txt" diff --git a/problem-details/scripts/demo-errors-only.sh b/problem-details/scripts/demo-errors-only.sh new file mode 100644 index 0000000..2fc4d9a --- /dev/null +++ b/problem-details/scripts/demo-errors-only.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# The error controller on its own (profile errors, no advice): Security's sendError() responses +# land on /error too, so they become problems without custom Security handlers. +# -> docs/output/errors-only.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +"$MODULE_DIR/scripts/run.sh" errors || exit 1 +{ + echo "# Profile: errors (ProblemDetailErrorController only - no @ControllerAdvice, no Security handlers)" + echo + for args in "/admin/orders" "-u user:user /admin/orders" "/orders/999"; do + echo "\$ curl $args" + curl -s -i $(echo "$args" | sed "s#/#$BASE/#") | grep -iE '^HTTP|^content-type|^www-authenticate|^\{' | tr -d '\r' + echo + done +} > "$OUT/errors-only.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/errors-only.txt" diff --git a/problem-details/scripts/demo-i18n.sh b/problem-details/scripts/demo-i18n.sh new file mode 100644 index 0000000..2777187 --- /dev/null +++ b/problem-details/scripts/demo-i18n.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# title/detail resolved from messages*.properties for an ErrorResponseException. -> docs/output/i18n.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +"$MODULE_DIR/scripts/run.sh" advice || exit 1 +BODY='{"sku":"SKU-2","quantity":3,"customerEmail":"a@b.co"}' +{ + echo "# OutOfStockException - its own ProblemDetail vs messages.properties vs messages_de.properties" + echo + echo "## Exception's own text (what the constructor set):" + echo " title=\"Insufficient stock\" detail=\"Requested 3 of SKU-2 but only 0 available\"" + echo + for lang in '' 'de'; do + echo "## Accept-Language: ${lang:-}" + echo "\$ curl -X POST /orders -d '$BODY'${lang:+ -H 'Accept-Language: $lang'}" + curl -s -X POST "$BASE/orders" -H 'Content-Type: application/json' ${lang:+-H "Accept-Language: $lang"} --data "$BODY"; echo; echo + done +} > "$OUT/i18n.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/i18n.txt" diff --git a/problem-details/scripts/demo-matrix.sh b/problem-details/scripts/demo-matrix.sh new file mode 100644 index 0000000..c0b13e4 --- /dev/null +++ b/problem-details/scripts/demo-matrix.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# The article's first table: the same thirteen failures under each exception-handling setup. +# Writes docs/output/matrix-.txt (full responses) and docs/output/matrix-summary.txt. +set -uo pipefail +source "$(dirname "$0")/env.sh" + +SCENARIOS=( + "domain exception (OrderNotFoundException)|GET|/orders/999||" + "type mismatch (/orders/abc)|GET|/orders/abc||" + "invalid body (@Valid)|POST|/orders|{\"sku\":\"\",\"quantity\":0,\"customerEmail\":\"nope\"}|" + "invalid @RequestParam (@Max)|GET|/orders?limit=500||" + "ErrorResponseException (OutOfStock)|POST|/orders|{\"sku\":\"SKU-2\",\"quantity\":3,\"customerEmail\":\"a@b.co\"}|" + "ResponseStatusException|GET|/orders/legacy/7||" + "unknown path|GET|/no-such-thing||" + "wrong HTTP method|DELETE|/orders/1||" + "malformed JSON|POST|/orders|{\"sku\":|" + "unexpected exception|GET|/orders/boom||" + "exception in a servlet filter|GET|/orders/1||X-Tenant: BAD!" + "401 no credentials|GET|/admin/orders||" + "403 wrong role|GET|/admin/orders||AUTH:user:user" +) + +classify() { # content-type, body -> shape label + local ct="$1" body="$2" + if [[ "$ct" == *problem+json* ]]; then echo "problem+json" + elif [[ -z "$body" ]]; then echo "(empty body)" + elif [[ "$body" == *'"timestamp"'* && "$body" == *'"error"'* ]]; then echo "Boot /error JSON" + else echo "other: ${ct:-none}"; fi +} + +SUMMARY="$OUT/matrix-summary.txt" +PROFILES=("" "boot-flag" "advice" "advice,errors" "catchall-first") +declare -A CELL +for profile in "${PROFILES[@]}"; do + name="${profile:-defaults}" + "$MODULE_DIR/scripts/run.sh" "$profile" || exit 1 + file="$OUT/matrix-$name.txt" + { + echo "# Profile: $name (spring-boot 4.1.1, spring-framework 7.0.9)" + echo + } > "$file" + i=0 + for s in "${SCENARIOS[@]}"; do + IFS='|' read -r label method path body extra <<< "$s" + args=(-s -o /tmp/pd-body -D /tmp/pd-headers -X "$method" "$BASE$path") + [ -n "$body" ] && args+=(-H 'Content-Type: application/json' --data "$body") + if [[ "$extra" == AUTH:* ]]; then args+=(-u "${extra#AUTH:}"); + elif [ -n "$extra" ]; then args+=(-H "$extra"); fi + curl "${args[@]}" + status=$(head -1 /tmp/pd-headers | awk '{print $2}') + ct=$(grep -i '^content-type:' /tmp/pd-headers | head -1 | cut -d' ' -f2- | tr -d '\r') + resp=$(cat /tmp/pd-body) + { + echo "## $label" + echo "\$ curl -X $method $path${body:+ -d '$body'}${extra:+ [$extra]}" + echo "HTTP $status Content-Type: ${ct:-}" + [ -n "$resp" ] && echo "$resp" + echo + } >> "$file" + CELL["$i|$name"]="$status $(classify "$ct" "$resp")" + i=$((i+1)) + done +done +"$MODULE_DIR/scripts/stop.sh" + +{ + echo "# Which error shape does each failure produce? (status + body shape)" + echo "# Spring Boot 4.1.1 / Spring Framework 7.0.9. Full bodies in matrix-.txt" + echo + printf '%-42s | %-22s | %-22s | %-22s | %-22s | %-22s\n' "failure" "defaults" "boot-flag" "advice" "advice,errors" "catchall-first" + printf '%s\n' "$(printf -- '-%.0s' {1..165})" + i=0 + for s in "${SCENARIOS[@]}"; do + label="${s%%|*}" + printf '%-42s | %-22s | %-22s | %-22s | %-22s | %-22s\n' "$label" "${CELL["$i|defaults"]}" "${CELL["$i|boot-flag"]}" "${CELL["$i|advice"]}" "${CELL["$i|advice,errors"]}" "${CELL["$i|catchall-first"]}" + i=$((i+1)) + done +} > "$SUMMARY" +cat "$SUMMARY" diff --git a/problem-details/scripts/demo-negotiation.sh b/problem-details/scripts/demo-negotiation.sh new file mode 100644 index 0000000..f46dfd2 --- /dev/null +++ b/problem-details/scripts/demo-negotiation.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# What Accept header gets which error body. Profile: advice. -> docs/output/content-negotiation.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +"$MODULE_DIR/scripts/run.sh" advice || exit 1 +{ + echo "# Accept header vs error body (profile: advice, jackson-dataformat-xml on the classpath)" + echo + echo "## A successful response, for comparison" + echo '$ curl -H "Accept: application/xml" /orders/1' + curl -s -i -H 'Accept: application/xml' "$BASE/orders/1" | grep -iE '^HTTP|^content-type' | tr -d '\r' + curl -s -H 'Accept: application/xml' "$BASE/orders/1"; echo; echo + for a in 'application/json' 'application/xml' 'application/problem+xml' 'text/html' 'image/png' '*/*'; do + echo "## Accept: $a" + echo "\$ curl -H \"Accept: $a\" /orders/999" + curl -s -i -H "Accept: $a" "$BASE/orders/999" | grep -iE '^HTTP|^content-type' | tr -d '\r' + curl -s -H "Accept: $a" "$BASE/orders/999"; echo; echo + done +} > "$OUT/content-negotiation.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/content-negotiation.txt" diff --git a/problem-details/scripts/demo-silent-500.sh b/problem-details/scripts/demo-silent-500.sh new file mode 100644 index 0000000..3a7a7b2 --- /dev/null +++ b/problem-details/scripts/demo-silent-500.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Is the unexpected exception LOGGED? Counts log lines mentioning it after one request. +# -> docs/output/silent-500.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +probe() { # label, profiles, extra args, path, header + local label="$1" profiles="$2" extra="$3" path="$4" header="${5:-}" + EXTRA_ARGS="$extra" "$MODULE_DIR/scripts/run.sh" "$profiles" || exit 1 + local before; before=$(wc -l < "$LOG") + curl -s -o /dev/null ${header:+-H "$header"} "$BASE$path" + sleep 1 + local lines; lines=$(tail -n +"$((before+1))" "$LOG" | grep -v 'Picked up JAVA_TOOL_OPTIONS') + local errors; errors=$(echo "$lines" | grep -c ' ERROR ' || true) + local traces; traces=$(echo "$lines" | grep -c '^ at ' || true) + printf '%-58s ERROR lines: %-3s stack frames: %s\n' "$label" "$errors" "$traces" + echo "$lines" | grep ' ERROR ' | sed 's/^.* ERROR / ERROR /' | cut -c1-200 | head -2 +} +{ + echo "# One request to an endpoint that throws IllegalStateException. What reaches the log?" + echo + probe "defaults (Boot /error)" "" "" /orders/boom + probe "boot-flag" "boot-flag" "" /orders/boom + probe "advice, catch-all logs with errorId" "advice" "" /orders/boom + probe "advice, catch-all WITHOUT the log line" "advice" "--demo.problems.log-unhandled=false" /orders/boom + probe "catchall-first (returns 500, never logs)" "catchall-first" "" /orders/boom + echo + echo "# And for an exception thrown by a servlet filter (never reaches any advice):" + echo + probe "advice,errors - filter exception" "advice,errors" "" /orders/1 "X-Tenant: BAD!" +} > "$OUT/silent-500.txt" +"$MODULE_DIR/scripts/stop.sh" +cat "$OUT/silent-500.txt" diff --git a/problem-details/scripts/demo-type-default.sh b/problem-details/scripts/demo-type-default.sh new file mode 100644 index 0000000..34b318a --- /dev/null +++ b/problem-details/scripts/demo-type-default.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# ProblemDetail's default "type" in Spring Framework 6.2 vs 7.0, read straight from the jars. +# Needs spring-web 6.2.x and 7.0.x jars in the local Maven repository (mvn dependency:get +# -Dartifact=org.springframework:spring-web:6.2.19 fetches the old one). +# -> docs/output/type-default.txt +set -uo pipefail +source "$(dirname "$0")/env.sh" +M2="${M2:-$HOME/.m2/repository}" +CORE="$M2/org/springframework/spring-core/7.0.9/spring-core-7.0.9.jar:$(ls "$M2"/org/jspecify/jspecify/*/jspecify-*.jar | head -1)" +{ + echo "# ProblemDetail.forStatus(404).getType(), evaluated in jshell against each spring-web jar" + echo + for W in "$M2/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar" \ + "$M2/org/springframework/spring-web/7.0.9/spring-web-7.0.9.jar"; do + JSH="$(mktemp --suffix=.jsh)" + printf 'System.out.println("%s -> getType() = " + org.springframework.http.ProblemDetail.forStatus(404).getType());\n/exit\n' "$(basename "$W")" > "$JSH" + jshell --class-path "$W:$CORE" -q "$JSH" 2>&1 | grep -- '->' + rm -f "$JSH" + done + echo + echo "# Both versions' ProblemDetailJacksonMixin carry @JsonInclude(NON_EMPTY), so 6.2 rendered" + echo "# \"type\":\"about:blank\" and 7.0 omits the member. As rendered by this application (7.0.9):" + "$MODULE_DIR/scripts/run.sh" boot-flag >/dev/null || exit 1 + echo '$ curl /no-such-thing' + curl -s "$BASE/no-such-thing"; echo + "$MODULE_DIR/scripts/stop.sh" +} > "$OUT/type-default.txt" +cat "$OUT/type-default.txt" diff --git a/problem-details/scripts/env.sh b/problem-details/scripts/env.sh new file mode 100644 index 0000000..921af22 --- /dev/null +++ b/problem-details/scripts/env.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Shared settings for every script in this module. +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$MODULE_DIR/docs/output" +JAR="$MODULE_DIR/target/problem-details-1.0.0.jar" +PORT="${PORT:-8080}" +BASE="http://localhost:$PORT" +PIDFILE="$MODULE_DIR/target/app.pid" +LOG="$MODULE_DIR/target/app.log" +mkdir -p "$OUT" diff --git a/problem-details/scripts/run-all.sh b/problem-details/scripts/run-all.sh new file mode 100644 index 0000000..cf5359e --- /dev/null +++ b/problem-details/scripts/run-all.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Regenerate every transcript under docs/output/. Takes about two minutes. +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -DskipTests package +./scripts/demo-matrix.sh +./scripts/demo-negotiation.sh +./scripts/demo-i18n.sh +./scripts/demo-silent-500.sh +./scripts/demo-ambiguous.sh +./scripts/demo-client.sh +./scripts/demo-advice-order.sh +./scripts/demo-type-default.sh +./scripts/demo-errors-only.sh +./scripts/stop.sh +echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/" diff --git a/problem-details/scripts/run.sh b/problem-details/scripts/run.sh new file mode 100644 index 0000000..b69773b --- /dev/null +++ b/problem-details/scripts/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Start the application with the given profiles (comma-separated, may be empty) and wait until it +# answers. Any instance started by a previous run is stopped first, by PID file - never by +# pattern-matching the process list, which can match (and kill) the calling shell. +# ./scripts/run.sh advice +# ./scripts/run.sh "" # Spring Boot defaults +set -euo pipefail +source "$(dirname "$0")/env.sh" +"$MODULE_DIR/scripts/stop.sh" +PROFILES="${1:-}" +[ -f "$JAR" ] || (cd "$MODULE_DIR" && mvn -q -DskipTests package) +nohup java -jar "$JAR" --server.port="$PORT" ${PROFILES:+--spring.profiles.active=$PROFILES} ${EXTRA_ARGS:-} \ + > "$LOG" 2>&1 < /dev/null & +echo $! > "$PIDFILE" +for _ in $(seq 1 60); do + if curl -s -o /dev/null "$BASE/orders/1"; then exit 0; fi + if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "application exited - see $LOG" >&2; exit 1; fi + sleep 0.5 +done +echo "application did not start within 30s - see $LOG" >&2; exit 1 diff --git a/problem-details/scripts/stop.sh b/problem-details/scripts/stop.sh new file mode 100644 index 0000000..378cd88 --- /dev/null +++ b/problem-details/scripts/stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/env.sh" +if [ -f "$PIDFILE" ]; then + PID="$(cat "$PIDFILE")" + kill "$PID" 2>/dev/null || true + for _ in $(seq 1 40); do kill -0 "$PID" 2>/dev/null || break; sleep 0.25; done + kill -9 "$PID" 2>/dev/null || true + rm -f "$PIDFILE" +fi diff --git a/problem-details/src/main/java/com/ankurm/problems/ProblemDetailsApplication.java b/problem-details/src/main/java/com/ankurm/problems/ProblemDetailsApplication.java new file mode 100644 index 0000000..78ce99f --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/ProblemDetailsApplication.java @@ -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); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/advice/AmbiguousExceptionHandler.java b/problem-details/src/main/java/com/ankurm/problems/advice/AmbiguousExceptionHandler.java new file mode 100644 index 0000000..c497617 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/advice/AmbiguousExceptionHandler.java @@ -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"); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/advice/CatchAllFirstHandler.java b/problem-details/src/main/java/com/ankurm/problems/advice/CatchAllFirstHandler.java new file mode 100644 index 0000000..8c90187 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/advice/CatchAllFirstHandler.java @@ -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"); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java b/problem-details/src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java new file mode 100644 index 0000000..0900a15 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java @@ -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. + * + *

Three things this class does that the defaults do not, each measured in docs/output: + *

    + *
  1. 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).
  2. + *
  3. 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).
  4. + *
  5. Domain exceptions get a stable {@code type} URI that clients can switch on.
  6. + *
+ * + *

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 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 handleHandlerMethodValidationException(HandlerMethodValidationException ex, + HttpHeaders headers, HttpStatusCode status, WebRequest request) { + ProblemDetail pd = ex.getBody(); + List> errors = ex.getParameterValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream() + .map(error -> Map.of( + "parameter", result.getMethodParameter().getParameterName(), + "detail", String.valueOf(error.getDefaultMessage())))) + .toList(); + pd.setProperty("errors", errors); + return handleExceptionInternal(ex, pd, headers, status, request); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java b/problem-details/src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java new file mode 100644 index 0000000..e2f35df --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java @@ -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. + * + *

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 error(HttpServletRequest request) { + Map 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); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/diag/DiagController.java b/problem-details/src/main/java/com/ankurm/problems/diag/DiagController.java new file mode 100644 index 0000000..7f357d5 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/diag/DiagController.java @@ -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. + * + *

    + *
  • {@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).
  • + *
  • {@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).
  • + *
  • {@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).
  • + *
+ */ +@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> advice() { + return ControllerAdviceBean.findAnnotatedBeans(context).stream() + .map(bean -> { + Map 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 decode(@RequestParam String path) { + Map 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 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 mixin() { + ProblemDetail pd = ProblemDetail.forStatusAndDetail(org.springframework.http.HttpStatus.CONFLICT, "demo"); + pd.setProperty("sku", "SKU-2"); + Map 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; + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/domain/Order.java b/problem-details/src/main/java/com/ankurm/problems/domain/Order.java new file mode 100644 index 0000000..5796899 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/domain/Order.java @@ -0,0 +1,4 @@ +package com.ankurm.problems.domain; + +public record Order(long id, String sku, int quantity) { +} diff --git a/problem-details/src/main/java/com/ankurm/problems/domain/OrderNotFoundException.java b/problem-details/src/main/java/com/ankurm/problems/domain/OrderNotFoundException.java new file mode 100644 index 0000000..2f77aa9 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/domain/OrderNotFoundException.java @@ -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; + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/domain/OrderRequest.java b/problem-details/src/main/java/com/ankurm/problems/domain/OrderRequest.java new file mode 100644 index 0000000..abf0ae2 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/domain/OrderRequest.java @@ -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 docs/03-validation-errors.md + */ +public record OrderRequest( + @NotBlank String sku, + @Min(1) int quantity, + @Email String customerEmail) { +} diff --git a/problem-details/src/main/java/com/ankurm/problems/domain/OrderService.java b/problem-details/src/main/java/com/ankurm/problems/domain/OrderService.java new file mode 100644 index 0000000..1a0a915 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/domain/OrderService.java @@ -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 orders = new ConcurrentHashMap<>(Map.of(1L, new Order(1, "SKU-1", 2))); + private final Map 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; + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/domain/OutOfStockException.java b/problem-details/src/main/java/com/ankurm/problems/domain/OutOfStockException.java new file mode 100644 index 0000000..1210345 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/domain/OutOfStockException.java @@ -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. + * + *

The {@code title} and {@code detail} are also resolvable from {@code messages.properties} + * under {@code problemDetail.title.} and {@code problemDetail.}, 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}; + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java b/problem-details/src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java new file mode 100644 index 0000000..74a61b0 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java @@ -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. + * + *

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); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/security/SecurityConfig.java b/problem-details/src/main/java/com/ankurm/problems/security/SecurityConfig.java new file mode 100644 index 0000000..61b96f8 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/security/SecurityConfig.java @@ -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. + * + *

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 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()); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/web/AdminController.java b/problem-details/src/main/java/com/ankurm/problems/web/AdminController.java new file mode 100644 index 0000000..02971b9 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/web/AdminController.java @@ -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 adminOrders() { + return Map.of("orders", 1); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/web/OrderController.java b/problem-details/src/main/java/com/ankurm/problems/web/OrderController.java new file mode 100644 index 0000000..8accbcb --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/web/OrderController.java @@ -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 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"); + } +} diff --git a/problem-details/src/main/java/com/ankurm/problems/web/TenantHeaderFilter.java b/problem-details/src/main/java/com/ankurm/problems/web/TenantHeaderFilter.java new file mode 100644 index 0000000..7966379 --- /dev/null +++ b/problem-details/src/main/java/com/ankurm/problems/web/TenantHeaderFilter.java @@ -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); + } +} diff --git a/problem-details/src/main/resources/application-boot-flag.yaml b/problem-details/src/main/resources/application-boot-flag.yaml new file mode 100644 index 0000000..3520394 --- /dev/null +++ b/problem-details/src/main/resources/application-boot-flag.yaml @@ -0,0 +1,4 @@ +spring: + mvc: + problemdetails: + enabled: true diff --git a/problem-details/src/main/resources/application-catchall-first.yaml b/problem-details/src/main/resources/application-catchall-first.yaml new file mode 100644 index 0000000..3520394 --- /dev/null +++ b/problem-details/src/main/resources/application-catchall-first.yaml @@ -0,0 +1,4 @@ +spring: + mvc: + problemdetails: + enabled: true diff --git a/problem-details/src/main/resources/application.yaml b/problem-details/src/main/resources/application.yaml new file mode 100644 index 0000000..d945af7 --- /dev/null +++ b/problem-details/src/main/resources/application.yaml @@ -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) diff --git a/problem-details/src/main/resources/messages.properties b/problem-details/src/main/resources/messages.properties new file mode 100644 index 0000000..dd380b4 --- /dev/null +++ b/problem-details/src/main/resources/messages.properties @@ -0,0 +1,5 @@ +# Resolved by ResponseEntityExceptionHandler for any ErrorResponse, via the codes +# problemDetail.title. +# problemDetail. (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. diff --git a/problem-details/src/main/resources/messages_de.properties b/problem-details/src/main/resources/messages_de.properties new file mode 100644 index 0000000..96409e4 --- /dev/null +++ b/problem-details/src/main/resources/messages_de.properties @@ -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. diff --git a/problem-details/src/test/java/com/ankurm/problems/AdviceContractTest.java b/problem-details/src/test/java/com/ankurm/problems/AdviceContractTest.java new file mode 100644 index 0000000..2aa1752 --- /dev/null +++ b/problem-details/src/test/java/com/ankurm/problems/AdviceContractTest.java @@ -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\":\"a@b.co\"}"; + 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\""); + } +} diff --git a/problem-details/src/test/java/com/ankurm/problems/FrameworkFactsTest.java b/problem-details/src/test/java/com/ankurm/problems/FrameworkFactsTest.java new file mode 100644 index 0000000..6f2d58d --- /dev/null +++ b/problem-details/src/test/java/com/ankurm/problems/FrameworkFactsTest.java @@ -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"); + } +} diff --git a/problem-details/src/test/java/com/ankurm/problems/GapsAndTrapsTest.java b/problem-details/src/test/java/com/ankurm/problems/GapsAndTrapsTest.java new file mode 100644 index 0000000..975dd85 --- /dev/null +++ b/problem-details/src/test/java/com/ankurm/problems/GapsAndTrapsTest.java @@ -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); + } + } +} diff --git a/problem-details/src/test/java/com/ankurm/problems/Http.java b/problem-details/src/test/java/com/ankurm/problems/Http.java new file mode 100644 index 0000000..031932c --- /dev/null +++ b/problem-details/src/test/java/com/ankurm/problems/Http.java @@ -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 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() { + } +}