diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/cs/CompletionStageRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/cs/CompletionStageRouteTest.java index 0d8fc91cecd39..c00ff27b26454 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/cs/CompletionStageRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/cs/CompletionStageRouteTest.java @@ -44,7 +44,7 @@ public void testCsRoute() { when().get("/failure").then().statusCode(500).body(containsString("boom")); when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); when().get("/cs-null").then().statusCode(500); when().get("/void").then().statusCode(204).body(hasLength(0)); } diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java index ff9b2aec24cac..46f22dece90d2 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java @@ -59,8 +59,8 @@ public void testMultiRoute() { .header("content-type", "application/json;charset=utf-8"); when().get("/failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); - when().get("/sync-failure").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); } diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/MultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/MultiRouteTest.java index 551f395597c5c..d870a6e45aeb3 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/MultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/MultiRouteTest.java @@ -52,8 +52,8 @@ public void testMultiRoute() { .header("content-type", is(nullValue())); when().get("/failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); - when().get("/sync-failure").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); } diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java index 8c2d652896705..febc1f021494f 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java @@ -80,8 +80,8 @@ public void testNdjsonMultiRoute() { .header(HttpHeaders.CONTENT_TYPE.toString(), CONTENT_TYPE_STREAM_JSON); when().get("/failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); - when().get("/sync-failure").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); } static class SimpleBean { diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java index 3dd1656631f1e..9cfdbb2ea6195 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java @@ -106,8 +106,8 @@ public void testSSEMultiRoute() { .header("content-type", is("text/event-stream")); when().get("/failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); - when().get("/sync-failure").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); } diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/UniRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/UniRouteTest.java index 87e52fb81b31b..5b4819f433384 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/UniRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/UniRouteTest.java @@ -43,7 +43,7 @@ public void testUniRoute() { when().get("/failure").then().statusCode(500).body(containsString("boom")); when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); - when().get("/null").then().statusCode(500).body(containsString("null")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); when().get("/uni-null").then().statusCode(500); when().get("/void").then().statusCode(204).body(hasLength(0)); } diff --git a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorServletTestCase.java b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorServletTestCase.java index a3c60ded2fb70..4474d8712a1a5 100644 --- a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorServletTestCase.java +++ b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorServletTestCase.java @@ -31,7 +31,7 @@ public void testJsonError() { RestAssured.given().accept(ContentType.JSON) .when().get("/error").then() .statusCode(500) - .body("details", startsWith("Error handling")) + .body("details", startsWith("Error id")) .body("stack", startsWith("java.lang.RuntimeException: Error !!!")); } } diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/QuarkusErrorServlet.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/QuarkusErrorServlet.java index 5e2046ef91e13..9515d858312f2 100644 --- a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/QuarkusErrorServlet.java +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/QuarkusErrorServlet.java @@ -65,7 +65,7 @@ private static String generateStackTrace(final Throwable exception) { } private static String generateHeaderMessage(final Throwable exception, String uuid) { - return String.format("Error handling %s, %s: %s", uuid, exception.getClass().getName(), + return String.format("Error id %s, %s: %s", uuid, exception.getClass().getName(), extractFirstLine(exception.getMessage())); } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/UnhandledExceptionTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/UnhandledExceptionTest.java new file mode 100644 index 0000000000000..623efb2fed0f5 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/UnhandledExceptionTest.java @@ -0,0 +1,264 @@ +package io.quarkus.vertx.http; + +import static io.restassured.RestAssured.given; +import static io.restassured.config.HeaderConfig.headerConfig; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.event.Observes; + +import org.hamcrest.Matcher; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.config.RestAssuredConfig; +import io.vertx.core.Handler; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; + +public class UnhandledExceptionTest { + + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_JSON = "text/json"; + private static final String TEXT_HTML = "text/html"; + private static final String APPLICATION_XML = "application/xml"; + private static final String TEXT_XML = "text/xml"; + private static final String APPLICATION_XHTML = "application/xhtml+xml"; + private static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(BeanRegisteringRouteThatThrowsException.class)); + + @Test + public void testNoAccept() { + given().get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) // Default to JSON + .body(jsonBodyMatcher()); + } + + @Test + public void testAcceptUnsupported() { + given() + .accept(APPLICATION_OCTET_STREAM) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) // Default to JSON + .body(jsonBodyMatcher()); + } + + @Test + public void testAcceptApplicationJson() { + given() + .accept(APPLICATION_JSON) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + } + + @Test + public void testAcceptTextJson() { + given() + .accept(TEXT_JSON) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_JSON) + .body(jsonBodyMatcher()); + } + + @Test + public void testAcceptTextHtml() { + given() + .accept(TEXT_HTML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) + .body(htmlBodyMatcher()); + } + + @Test + public void testAcceptTextXml() { + given() + .accept(TEXT_XML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) // Not quite what they want, but better than nothing + .body(htmlBodyMatcher()); + } + + @Test + public void testAcceptApplicationXml() { + given() + .accept(APPLICATION_XML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) // Not quite what they want, but better than nothing + .body(htmlBodyMatcher()); + } + + @Test + public void testAcceptXHtml() { + given() + .accept(APPLICATION_XHTML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) // Not quite what they want, but better than nothing + .body(htmlBodyMatcher()); + } + + @Test + public void testAcceptWildcard() { + given() + .accept("text/*") + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_JSON) + .body(jsonBodyMatcher()); + } + + @Test + public void testAcceptParameter() { + // We don't support accept parameters: they will be ignored. + given() + .accept("text/html;q=0.8") + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) + .body(htmlBodyMatcher()); + } + + @Test + public void testMultipleAcceptHeaders() { + RestAssuredConfig multipleAcceptHeadersConfig = RestAssured.config() + .headerConfig(headerConfig().mergeHeadersWithName("Accept")); + given() + .config(multipleAcceptHeadersConfig) + .accept(APPLICATION_JSON) + .accept(TEXT_HTML) + .accept(APPLICATION_OCTET_STREAM) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + + given() + .config(multipleAcceptHeadersConfig) + .accept(TEXT_HTML) + .accept(APPLICATION_JSON) + .accept(APPLICATION_OCTET_STREAM) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) + .body(htmlBodyMatcher()); + + given() + .config(multipleAcceptHeadersConfig) + .accept(APPLICATION_OCTET_STREAM) + .accept(TEXT_HTML) + .accept(APPLICATION_JSON) + .get("/unhandled-exception") + .then() + .statusCode(500) + // Ideally we'd like TEXT_HTML here, but due to some strange behavior of + // io.vertx.ext.web.ParsedHeaderValues.findBestUserAcceptedIn, + // we get this. + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + + given() + .config(multipleAcceptHeadersConfig) + .accept(APPLICATION_OCTET_STREAM) + .accept(APPLICATION_JSON) + .accept(TEXT_HTML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + } + + @Test + public void testCompositeAcceptHeaders() { + given() + .accept(APPLICATION_JSON + ", " + TEXT_HTML + ", " + APPLICATION_OCTET_STREAM) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + + given() + .accept(TEXT_HTML + ", " + APPLICATION_JSON + ", " + APPLICATION_OCTET_STREAM) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) + .body(htmlBodyMatcher()); + + given() + .accept(APPLICATION_OCTET_STREAM + ", " + TEXT_HTML + ", " + APPLICATION_JSON) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(TEXT_HTML) + .body(htmlBodyMatcher()); + + given() + .accept(APPLICATION_OCTET_STREAM + ", " + APPLICATION_JSON + ", " + TEXT_HTML) + .get("/unhandled-exception") + .then() + .statusCode(500) + .contentType(APPLICATION_JSON) + .body(jsonBodyMatcher()); + } + + private Matcher jsonBodyMatcher() { + return allOf( + containsString("\"details\":\"Error id"), + containsString("\"stack\":\"java.lang.RuntimeException: Simulated failure"), + containsString("at " + BeanRegisteringRouteThatThrowsException.class.getName() + "$1.handle")); + } + + private Matcher htmlBodyMatcher() { + return allOf( + containsString(""), + containsString("Internal Server Error"), + containsString("java.lang.RuntimeException: Simulated failure"), + containsString("at " + BeanRegisteringRouteThatThrowsException.class.getName() + "$1.handle")); + } + + @ApplicationScoped + static class BeanRegisteringRouteThatThrowsException { + + public void register(@Observes Router router) { + router.route("/unhandled-exception").handler(new Handler<RoutingContext>() { + @Override + public void handle(RoutingContext event) { + throw new RuntimeException("Simulated failure"); + } + }); + } + + } + +} diff --git a/extensions/vertx-http/runtime/pom.xml b/extensions/vertx-http/runtime/pom.xml index a7ab8fd5933b6..3b6ab58554b66 100644 --- a/extensions/vertx-http/runtime/pom.xml +++ b/extensions/vertx-http/runtime/pom.xml @@ -78,6 +78,16 @@ <artifactId>jackson-databind</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-junit-jupiter</artifactId> + <scope>test</scope> + </dependency> </dependencies> <build> diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java index cd2c3b962eb9d..e957143cd6ee8 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java @@ -231,6 +231,20 @@ public class HttpConfiguration { @ConfigItem public boolean enableDecompression; + /** + * Provides a hint (optional) for the default content type of responses generated for + * the errors not handled by the application. + * <p> + * If the client requested a supported content-type in request headers + * (e.g. "Accept: application/json", "Accept: text/html"), + * Quarkus will use that content type. + * <p> + * Otherwise, it will default to the content type configured here. + * </p> + */ + @ConfigItem + public Optional<PayloadHint> unhandledErrorContentTypeDefault; + public ProxyConfig proxy; public int determinePort(LaunchMode launchMode) { @@ -246,4 +260,9 @@ public enum InsecureRequests { REDIRECT, DISABLED; } + + public enum PayloadHint { + JSON, + HTML, + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java index 6484de2a3f34d..b469d594b17ed 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java @@ -5,6 +5,10 @@ import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; @@ -19,7 +23,9 @@ import io.quarkus.security.UnauthorizedException; import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; import io.vertx.core.Handler; +import io.vertx.ext.web.MIMEHeader; import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.impl.ParsableMIMEValue; public class QuarkusErrorHandler implements Handler<RoutingContext> { @@ -34,9 +40,11 @@ public class QuarkusErrorHandler implements Handler<RoutingContext> { private static final AtomicLong ERROR_COUNT = new AtomicLong(); private final boolean showStack; + private final Optional<HttpConfiguration.PayloadHint> contentTypeDefault; - public QuarkusErrorHandler(boolean showStack) { + public QuarkusErrorHandler(boolean showStack, Optional<HttpConfiguration.PayloadHint> contentTypeDefault) { this.showStack = showStack; + this.contentTypeDefault = contentTypeDefault; } @Override @@ -92,15 +100,24 @@ public void accept(Throwable throwable) { } String uuid = BASE_ID + ERROR_COUNT.incrementAndGet(); - String details = ""; + String details; String stack = ""; Throwable exception = event.failure(); + String responseContentType = null; + try { + responseContentType = ContentTypes.pickFirstSupportedAndAcceptedContentType(event); + } catch (RuntimeException e) { + // Let's shield ourselves from bugs in this parsing code: + // we're already handling an exception, + // so the priority is to return *something* to the user. + // If we can't pick the appropriate content-type, well, so be it. + exception.addSuppressed(e); + } if (showStack && exception != null) { details = generateHeaderMessage(exception, uuid); stack = generateStackTrace(exception); - } else { - details += "Error id " + uuid; + details = generateHeaderMessage(uuid); } if (event.failure() instanceof IOException) { log.debugf(exception, @@ -118,26 +135,55 @@ public void accept(Throwable throwable) { event.response().end(); return; } - String accept = event.request().getHeader("Accept"); - if (accept != null && accept.contains("application/json")) { - event.response().headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8"); - String escapedDetails = escapeJsonString(details); - String escapedStack = escapeJsonString(stack); - StringBuilder jsonPayload = new StringBuilder("{\"details\":\"") - .append(escapedDetails) - .append("\",\"stack\":\"") - .append(escapedStack) - .append("\"}"); - writeResponse(event, jsonPayload.toString()); - } else { - //We default to HTML representation - event.response().headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=utf-8"); - final TemplateHtmlBuilder htmlBuilder = new TemplateHtmlBuilder("Internal Server Error", details, details); - if (showStack && exception != null) { - htmlBuilder.stack(exception); - } - writeResponse(event, htmlBuilder.toString()); + + if (responseContentType == null) { + responseContentType = ""; } + switch (responseContentType) { + case ContentTypes.TEXT_HTML: + case ContentTypes.APPLICATION_XHTML: + case ContentTypes.APPLICATION_XML: + case ContentTypes.TEXT_XML: + htmlResponse(event, details, exception); + break; + case ContentTypes.APPLICATION_JSON: + case ContentTypes.TEXT_JSON: + jsonResponse(event, responseContentType, details, stack); + break; + default: + // We default to JSON representation + switch (contentTypeDefault.orElse(HttpConfiguration.PayloadHint.JSON)) { + case HTML: + htmlResponse(event, details, exception); + break; + case JSON: + default: + jsonResponse(event, ContentTypes.APPLICATION_JSON, details, stack); + break; + } + break; + } + } + + private void jsonResponse(RoutingContext event, String contentType, String details, String stack) { + event.response().headers().set(HttpHeaderNames.CONTENT_TYPE, contentType + "; charset=utf-8"); + String escapedDetails = escapeJsonString(details); + String escapedStack = escapeJsonString(stack); + StringBuilder jsonPayload = new StringBuilder("{\"details\":\"") + .append(escapedDetails) + .append("\",\"stack\":\"") + .append(escapedStack) + .append("\"}"); + writeResponse(event, jsonPayload.toString()); + } + + private void htmlResponse(RoutingContext event, String details, Throwable exception) { + event.response().headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=utf-8"); + final TemplateHtmlBuilder htmlBuilder = new TemplateHtmlBuilder("Internal Server Error", details, details); + if (showStack && exception != null) { + htmlBuilder.stack(exception); + } + writeResponse(event, htmlBuilder.toString()); } private void writeResponse(RoutingContext event, String output) { @@ -154,10 +200,14 @@ private static String generateStackTrace(final Throwable exception) { } private static String generateHeaderMessage(final Throwable exception, String uuid) { - return String.format("Error handling %s, %s: %s", uuid, exception.getClass().getName(), + return String.format("Error id %s, %s: %s", uuid, exception.getClass().getName(), extractFirstLine(exception.getMessage())); } + private static String generateHeaderMessage(String uuid) { + return String.format("Error id %s", uuid); + } + private static String extractFirstLine(final String message) { if (null == message) { return ""; @@ -200,4 +250,31 @@ static String escapeJsonString(final String text) { return sb.toString(); } + private static final class ContentTypes { + + private ContentTypes() { + } + + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_JSON = "text/json"; + private static final String TEXT_HTML = "text/html"; + private static final String APPLICATION_XHTML = "application/xhtml+xml"; + private static final String APPLICATION_XML = "application/xml"; + private static final String TEXT_XML = "text/xml"; + + // WARNING: The order matters for wildcards: if text/json is before text/html, then text/* will match text/json. + private static final Collection<MIMEHeader> SUPPORTED = Arrays.asList( + new ParsableMIMEValue(APPLICATION_JSON).forceParse(), + new ParsableMIMEValue(TEXT_JSON).forceParse(), + new ParsableMIMEValue(TEXT_HTML).forceParse(), + new ParsableMIMEValue(APPLICATION_XHTML).forceParse(), + new ParsableMIMEValue(APPLICATION_XML).forceParse(), + new ParsableMIMEValue(TEXT_XML).forceParse()); + + static String pickFirstSupportedAndAcceptedContentType(RoutingContext context) { + List<MIMEHeader> acceptableTypes = context.parsedHeaders().accept(); + MIMEHeader result = context.parsedHeaders().findBestUserAcceptedIn(acceptableTypes, SUPPORTED); + return result == null ? null : result.value(); + } + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java index a7cf733fbb442..4f351e10fe299 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java @@ -308,7 +308,8 @@ public void finalizeRouter(BeanContainer container, Consumer<Route> defaultRoute defaultRouteHandler.accept(httpRouteRouter.route().order(DEFAULT_ROUTE_ORDER)); } - httpRouteRouter.route().last().failureHandler(new QuarkusErrorHandler(launchMode.isDevOrTest())); + httpRouteRouter.route().last().failureHandler( + new QuarkusErrorHandler(launchMode.isDevOrTest(), httpConfiguration.unhandledErrorContentTypeDefault)); if (requireBodyHandler) { //if this is set then everything needs the body handler installed diff --git a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java index 721cde5b029f1..f1634fee29f5f 100644 --- a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java +++ b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java @@ -1,13 +1,30 @@ package io.quarkus.vertx.http.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; + +import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import io.netty.handler.codec.http.HttpHeaderNames; import io.vertx.core.json.Json; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.impl.ParsableMIMEValue; +@ExtendWith(MockitoExtension.class) class QuarkusErrorHandlerTest { + private static final Throwable testError = new IllegalStateException("test123"); + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + RoutingContext routingContext; + @Test public void string_with_tab_should_be_correctly_escaped() { String initial = "String with a tab\tcharacter"; @@ -32,4 +49,68 @@ public void string_with_quotes_should_be_correctly_escaped() { assertEquals(initial, parsed); } + @Test + public void json_content_type_hint_should_be_respected_if_not_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.JSON)); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("application/foo+json").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, + "application/json; charset=utf-8"); + } + + @Test + public void json_content_type_hint_should_be_ignored_if_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.JSON)); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("text/html").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, + "text/html; charset=utf-8"); + } + + @Test + public void content_type_should_default_to_json_if_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.empty()); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("application/json").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, + "application/json; charset=utf-8"); + } + + @Test + public void html_content_type_hint_should_be_respected_if_not_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.HTML)); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("application/foo+json").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=utf-8"); + } + + @Test + public void html_content_type_hint_should_be_ignored_if_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.HTML)); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("application/json").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, + "application/json; charset=utf-8"); + } + + @Test + public void content_type_should_default_to_html_if_accepted() { + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.empty()); + Mockito.when(routingContext.failure()).thenReturn(testError); + Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) + .thenReturn(new ParsableMIMEValue("text/html").forceParse()); + errorHandler.handle(routingContext); + Mockito.verify(routingContext.response().headers()).set(HttpHeaderNames.CONTENT_TYPE, + "text/html; charset=utf-8"); + } } diff --git a/integration-tests/hibernate-validator-resteasy-reactive/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java b/integration-tests/hibernate-validator-resteasy-reactive/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java index b7480e36695cb..8ef836dcb6ded 100644 --- a/integration-tests/hibernate-validator-resteasy-reactive/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java +++ b/integration-tests/hibernate-validator-resteasy-reactive/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java @@ -126,7 +126,7 @@ public void testRestEndPointReturnValueValidation() { .then() .body(containsString(ResteasyReactiveViolationException.class.getName())) // Exception type .body(containsString("numeric value out of bounds")) // Exception message - .body(containsString("testRestEndPointReturnValueValidation.<return value>")) + .body(containsString("testRestEndPointReturnValueValidation.<return value>")) .body(containsString(HibernateValidatorTestResource.class.getName())) // Stack trace .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); diff --git a/integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java b/integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java index d6def9b5d81be..a5cde57c4284b 100644 --- a/integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java +++ b/integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/HibernateValidatorFunctionalityTest.java @@ -119,7 +119,7 @@ public void testCDIBeanMethodValidationUncaught() { ValidatableResponse response = RestAssured.when() .get("/hibernate-validator/test/cdi-bean-method-validation-uncaught") .then() - .body(containsString("Internal Server Error")) + .body(containsString("Error id")) .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); if (isInternalErrorExceptionLeakedInQuarkusErrorHandlerResponse()) { response @@ -182,13 +182,13 @@ public void testRestEndPointReturnValueValidation() { ValidatableResponse response = RestAssured.when() .get("/hibernate-validator/test/rest-end-point-return-value-validation/plop/") .then() - .body(containsString("Internal Server Error")) + .body(containsString("Error id")) .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()); if (isInternalErrorExceptionLeakedInQuarkusErrorHandlerResponse()) { response .body(containsString(ResteasyViolationExceptionImpl.class.getName())) // Exception type .body(containsString("numeric value out of bounds")) // Exception message - .body(containsString("testRestEndPointReturnValueValidation.<return value>")) + .body(containsString("testRestEndPointReturnValueValidation.<return value>")) .body(containsString(HibernateValidatorTestResource.class.getName())); // Stack trace }