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