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,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