From 5934e09299aa86397619693e35aa5354461d3323 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Mon, 27 Feb 2023 10:08:05 +0100 Subject: [PATCH] HTTP response compression - fix Undertow and RESTEasy Classsic support - honor the quarkus.http.compress-media-types in Undertow Servlet and RESTEasy Classsic extensions - fixes #31415 and #26112 --- docs/src/main/asciidoc/resteasy.adoc | 2 +- .../web/runtime/HttpCompressionHandler.java | 15 ++--- .../ResteasyStandaloneBuildStep.java | 5 +- .../ResteasyStandaloneRecorder.java | 14 ++++- .../deployment/UndertowBuildStep.java | 5 +- .../compress/CompressionDisabledTestCase.java | 29 +++++++++ .../compress/CompressionEnabledTestCase.java | 25 ++++++++ .../undertow/test/compress/SimpleServlet.java | 24 ++++++++ .../runtime/UndertowDeploymentRecorder.java | 24 +++++++- .../http/runtime/HttpBuildTimeConfig.java | 12 ++-- .../http/runtime/HttpCompressionHandler.java | 59 +++++++++++++++++++ 11 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionDisabledTestCase.java create mode 100644 extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionEnabledTestCase.java create mode 100644 extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/SimpleServlet.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCompressionHandler.java diff --git a/docs/src/main/asciidoc/resteasy.adoc b/docs/src/main/asciidoc/resteasy.adoc index 0b2501e866474..92ebfba3a98fa 100644 --- a/docs/src/main/asciidoc/resteasy.adoc +++ b/docs/src/main/asciidoc/resteasy.adoc @@ -720,7 +720,7 @@ This configuration option would recognize strings in this format (shown as a reg Once GZip support has been enabled you can use it on an endpoint by adding the `@org.jboss.resteasy.annotations.GZIP` annotation to your endpoint method. -NOTE: The configuration property `quarkus.http.enable-compression` has no effect on compression support of RESTEasy Classic endpoints. +NOTE: There is also the `quarkus.http.enable-compression` configuration property which enables HTTP response compression globally. If enabled then a response body is compressed if the `Content-Type` HTTP header is set and the value is a compressed media type as configured via the `quarkus.http.compress-media-types` configuration property. == Multipart Support diff --git a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/runtime/HttpCompressionHandler.java b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/runtime/HttpCompressionHandler.java index 8fe850f548dbf..ee6a9d14210be 100644 --- a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/runtime/HttpCompressionHandler.java +++ b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/runtime/HttpCompressionHandler.java @@ -37,13 +37,14 @@ public void handle(AsyncResult result) { break; case UNDEFINED: String contentType = headers.get(HttpHeaders.CONTENT_TYPE); - int paramIndex = contentType.indexOf(';'); - if (paramIndex > -1) { - contentType = contentType.substring(0, paramIndex); - } - if (contentType != null - && compressedMediaTypes.contains(contentType)) { - headers.remove(HttpHeaders.CONTENT_ENCODING); + if (contentType != null) { + int paramIndex = contentType.indexOf(';'); + if (paramIndex > -1) { + contentType = contentType.substring(0, paramIndex); + } + if (compressedMediaTypes.contains(contentType)) { + headers.remove(HttpHeaders.CONTENT_ENCODING); + } } break; default: diff --git a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java index 4dd5a3fd1cd48..73b99d8406866 100644 --- a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java +++ b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java @@ -95,7 +95,8 @@ public void boot(ShutdownContextBuildItem shutdown, ResteasyStandaloneBuildItem standalone, Optional requireVirtual, ExecutorBuildItem executorBuildItem, - ResteasyVertxConfig resteasyVertxConfig) throws Exception { + ResteasyVertxConfig resteasyVertxConfig, + HttpBuildTimeConfig httpBuildTimeConfig) throws Exception { if (standalone == null) { return; @@ -105,7 +106,7 @@ public void boot(ShutdownContextBuildItem shutdown, // Handler used for both the default and non-default deployment path (specified as application path or resteasyConfig.path) // Routes use the order VertxHttpRecorder.DEFAULT_ROUTE_ORDER + 1 to ensure the default route is called before the resteasy one Handler handler = recorder.vertxRequestHandler(vertx.getVertx(), - executorBuildItem.getExecutorProxy(), resteasyVertxConfig); + executorBuildItem.getExecutorProxy(), resteasyVertxConfig, httpBuildTimeConfig); final boolean noCustomAuthCompletionExMapper; final boolean noCustomAuthFailureExMapper; diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java index 729a962356e6f..074519e4a4298 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java @@ -2,6 +2,7 @@ import static io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler.extractRootCause; +import java.util.Set; import java.util.concurrent.Executor; import java.util.function.BiConsumer; import java.util.function.Supplier; @@ -21,6 +22,8 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpCompressionHandler; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; @@ -70,11 +73,18 @@ public void run() { } public Handler vertxRequestHandler(Supplier vertx, Executor executor, - ResteasyVertxConfig config) { + ResteasyVertxConfig config, HttpBuildTimeConfig httpBuildTimeConfig) { if (deployment != null) { - return new VertxRequestHandler(vertx.get(), deployment, contextPath, + Handler handler = new VertxRequestHandler(vertx.get(), deployment, contextPath, new ResteasyVertxAllocator(config.responseBufferSize), executor, readTimeout.getValue().readTimeout.toMillis()); + + Set compressMediaTypes = httpBuildTimeConfig.compressMediaTypes.map(Set::copyOf).orElse(Set.of()); + if (httpBuildTimeConfig.enableCompression && !compressMediaTypes.isEmpty()) { + // If compression is enabled and the set of compressed media types is not empty then wrap the standalone handler + handler = new HttpCompressionHandler(handler, compressMediaTypes); + } + return handler; } return null; } diff --git a/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java b/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java index bbc6400670510..152ab7c59dccc 100644 --- a/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java +++ b/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java @@ -186,7 +186,8 @@ public ServiceStartBuildItem boot(UndertowDeploymentRecorder recorder, ExecutorBuildItem executorBuildItem, ServletRuntimeConfig servletRuntimeConfig, ServletContextPathBuildItem servletContextPathBuildItem, - Capabilities capabilities) throws Exception { + Capabilities capabilities, + HttpBuildTimeConfig httpBuildTimeConfig) throws Exception { if (capabilities.isPresent(Capability.SECURITY)) { recorder.setupSecurity(servletDeploymentManagerBuildItem.getDeploymentManager()); @@ -194,7 +195,7 @@ public ServiceStartBuildItem boot(UndertowDeploymentRecorder recorder, Handler ut = recorder.startUndertow(shutdown, executorBuildItem.getExecutorProxy(), servletDeploymentManagerBuildItem.getDeploymentManager(), wrappers.stream().map(HttpHandlerWrapperBuildItem::getValue).collect(Collectors.toList()), - servletRuntimeConfig); + servletRuntimeConfig, httpBuildTimeConfig); if (servletContextPathBuildItem.getServletContextPath().equals("/")) { undertowProducer.accept(new DefaultRouteBuildItem(ut)); diff --git a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionDisabledTestCase.java b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionDisabledTestCase.java new file mode 100644 index 0000000000000..5c8a70213333b --- /dev/null +++ b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionDisabledTestCase.java @@ -0,0 +1,29 @@ +package io.quarkus.undertow.test.compress; + +import static io.restassured.RestAssured.get; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +public class CompressionDisabledTestCase { + + @RegisterExtension + static QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root + .addClasses(SimpleServlet.class)) + .overrideConfigKey("quarkus.http.enable-compression", "false"); + + @Test + public void testCompressed() throws Exception { + ExtractableResponse response = get(SimpleServlet.SERVLET_ENDPOINT) + .then().statusCode(200).extract(); + assertTrue(response.header("Content-Encoding") == null, response.headers().toString()); + assertEquals("ok", response.asString()); + } +} diff --git a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionEnabledTestCase.java b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionEnabledTestCase.java new file mode 100644 index 0000000000000..1aa904449bff0 --- /dev/null +++ b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/CompressionEnabledTestCase.java @@ -0,0 +1,25 @@ +package io.quarkus.undertow.test.compress; + +import static io.restassured.RestAssured.get; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class CompressionEnabledTestCase { + + @RegisterExtension + static QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root + .addClasses(SimpleServlet.class)) + .overrideConfigKey("quarkus.http.enable-compression", "true"); + + @Test + public void testCompressed() throws Exception { + String bodyStr = get(SimpleServlet.SERVLET_ENDPOINT).then().statusCode(200).header("Content-Encoding", "gzip").extract() + .asString(); + assertEquals("ok", bodyStr); + } +} diff --git a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/SimpleServlet.java b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/SimpleServlet.java new file mode 100644 index 0000000000000..0ab905d75e09f --- /dev/null +++ b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/compress/SimpleServlet.java @@ -0,0 +1,24 @@ +package io.quarkus.undertow.test.compress; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet(urlPatterns = SimpleServlet.SERVLET_ENDPOINT) +public class SimpleServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + public static final String SERVLET_ENDPOINT = "/simple"; + + @Override + protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException { + // this one must be listed in the quarkus.http.compress-media-types + resp.setHeader("Content-type", "text/plain"); + resp.getWriter().write("ok"); + } +} diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java index 90def7a486baa..b2c0692265d0d 100644 --- a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java @@ -7,6 +7,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.EventListener; import java.util.List; import java.util.Optional; @@ -49,6 +50,8 @@ import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpCompressionHandler; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.VertxHttpRecorder; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; @@ -102,6 +105,7 @@ import io.undertow.util.AttachmentKey; import io.undertow.util.ImmediateAuthenticationMechanismFactory; import io.undertow.vertx.VertxHttpExchange; +import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -348,7 +352,7 @@ public void setupSecurity(DeploymentManager manager) { public Handler startUndertow(ShutdownContext shutdown, ExecutorService executorService, DeploymentManager manager, List wrappers, - ServletRuntimeConfig servletRuntimeConfig) throws Exception { + ServletRuntimeConfig servletRuntimeConfig, HttpBuildTimeConfig httpBuildTimeConfig) throws Exception { shutdown.addShutdownTask(new Runnable() { @Override @@ -382,6 +386,10 @@ public void run() { undertowOptions.set(UndertowOptions.MAX_PARAMETERS, servletRuntimeConfig.maxParameters); UndertowOptionMap undertowOptionMap = undertowOptions.getMap(); + Set compressMediaTypes = httpBuildTimeConfig.enableCompression + ? Set.copyOf(httpBuildTimeConfig.compressMediaTypes.get()) + : Collections.emptySet(); + return new Handler() { @Override public void handle(RoutingContext event) { @@ -396,6 +404,20 @@ public void handle(RoutingContext event) { event.getBody()); exchange.setPushHandler(VertxHttpRecorder.getRootHandler()); + // Note that we can't add an end handler in a separate HttpCompressionHandler because VertxHttpExchange does set + // its own end handler and so the end handlers added previously are just ignored... + if (!compressMediaTypes.isEmpty()) { + event.addEndHandler(new Handler>() { + + @Override + public void handle(AsyncResult result) { + if (result.succeeded()) { + HttpCompressionHandler.compressIfNeeded(event, compressMediaTypes); + } + } + }); + } + Optional maxBodySize = httpConfiguration.getValue().limits.maxBodySize; if (maxBodySize.isPresent()) { exchange.setMaxEntitySize(maxBodySize.get().asLongValue()); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java index 220204a6fe115..0443874178509 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpBuildTimeConfig.java @@ -64,14 +64,12 @@ public class HttpBuildTimeConfig { public Duration testTimeout; /** - * If responses should be compressed. + * If enabled then the response body is compressed if the {@code Content-Type} header is set and the value is a compressed + * media type as configured via {@link #compressMediaTypes}. * - * Note that this will attempt to compress all responses, to avoid compressing - * already compressed content (such as images) you need to set the following header: - * - * Content-Encoding: identity - * - * Which will tell vert.x not to compress the response. + * Note that the RESTEasy Reactive and Reactive Routes extensions also make it possible to enable/disable compression + * declaratively using the annotations {@link io.quarkus.vertx.http.Compressed} and + * {@link io.quarkus.vertx.http.Uncompressed}. */ @ConfigItem public boolean enableCompression; diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCompressionHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCompressionHandler.java new file mode 100644 index 0000000000000..80d066a78d7ab --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpCompressionHandler.java @@ -0,0 +1,59 @@ +package io.quarkus.vertx.http.runtime; + +import java.util.Set; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +/** + * A simple wrapping handler that removes the {@code Content-Encoding: identity} HTTP header if the {@code Content-Type} + * header is set and the value is a compressed media type as configured via + * {@link io.quarkus.vertx.http.runtime.HttpBuildTimeConfig#compressMediaTypes}. + */ +public class HttpCompressionHandler implements Handler { + + private final Handler routeHandler; + private final Set compressedMediaTypes; + + public HttpCompressionHandler(Handler routeHandler, Set compressedMediaTypes) { + this.routeHandler = routeHandler; + this.compressedMediaTypes = compressedMediaTypes; + } + + @Override + public void handle(RoutingContext context) { + context.addEndHandler(new Handler>() { + @Override + public void handle(AsyncResult result) { + if (result.succeeded()) { + compressIfNeeded(context, compressedMediaTypes); + } + } + }); + routeHandler.handle(context); + } + + public static void compressIfNeeded(RoutingContext context, Set compressedMediaTypes) { + MultiMap headers = context.response().headers(); + // "Content-Encoding: identity" header means that compression is enabled in the config + // and this header is set to disable the compression by default + String contentEncoding = headers.get(HttpHeaders.CONTENT_ENCODING); + if (contentEncoding != null && HttpHeaders.IDENTITY.toString().equals(contentEncoding)) { + // Just remove the header if the compression should be enabled for the current HTTP response + String contentType = headers.get(HttpHeaders.CONTENT_TYPE); + if (contentType != null) { + int paramIndex = contentType.indexOf(';'); + if (paramIndex > -1) { + contentType = contentType.substring(0, paramIndex); + } + if (compressedMediaTypes.contains(contentType)) { + headers.remove(HttpHeaders.CONTENT_ENCODING); + } + } + } + } + +}