diff --git a/java/org/apache/coyote/CompressionConfig.java b/java/org/apache/coyote/CompressionConfig.java index c48dee1b1330..8a6beec70820 100644 --- a/java/org/apache/coyote/CompressionConfig.java +++ b/java/org/apache/coyote/CompressionConfig.java @@ -18,14 +18,10 @@ import java.io.IOException; import java.io.StringReader; -import java.util.ArrayList; -import java.util.Enumeration; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.StringTokenizer; +import java.util.*; import java.util.regex.Pattern; +import org.apache.coyote.http11.filters.GzipOutputFilter; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; import org.apache.tomcat.util.buf.MessageBytes; @@ -47,7 +43,11 @@ public class CompressionConfig { "text/javascript,application/javascript,application/json,application/xml"; private String[] compressibleMimeTypes = null; private int compressionMinSize = 2048; - + private Set noCompressionEncodings = new HashSet(Arrays.asList( + "br", "compress", "dcb", "dcz", "deflate", "gzip", "pack200-gzip", "zstd" + )); + private int gzipLevel = -1; + private int gzipBufferSize = GzipOutputFilter.DEFAULT_BUFFER_SIZE; /** * Set compression level. @@ -162,6 +162,63 @@ public int getCompressionMinSize() { return compressionMinSize; } + /** + * Set the compression level for gzip. + * @param gzipLevel The compression level. Valid values are -1 (default), or 1-9. + * -1 uses the default compression level. + * 1 gives best speed, 9 gives best compression. + */ + public void setGzipLevel(int gzipLevel) { + if (gzipLevel < -1 || gzipLevel > 9) { + throw new IllegalArgumentException(sm.getString("compressionConfig.invalidGzipLevel", Integer.valueOf(gzipLevel))); + } + this.gzipLevel = gzipLevel; + } + + public int getGzipLevel() { + return gzipLevel; + } + + /** + * Set the buffer size for gzip compression stream. + * + * @param gzipBufferSize The buffer size in bytes. Must be positive. + */ + public void setGzipBufferSize(int gzipBufferSize) { + if (gzipBufferSize <= 0) { + throw new IllegalArgumentException(sm.getString("compressionConfig.invalidGzipBufferSize", Integer.valueOf(gzipBufferSize))); + } + this.gzipBufferSize = gzipBufferSize; + } + + public int getGzipBufferSize() { + return gzipBufferSize; + } + + public String getNoCompressionEncodings() { + return String.join(",", noCompressionEncodings); + } + + /** + * Set the list of content encodings that indicate already-compressed content. + * When content is already encoded with one of these encodings, compression will not be applied + * to prevent double compression. + * + * @param encodings Comma-separated list of encoding names (e.g., "gzip,br.dflate") + */ + public void setNoCompressionEncodings(String encodings) { + Set newEncodings = new HashSet(); + if (encodings != null && !encodings.isEmpty()) { + StringTokenizer tokens = new StringTokenizer(encodings, ","); + while (tokens.hasMoreTokens()) { + String token = tokens.nextToken().trim(); + if(!token.isEmpty()) { + newEncodings.add(token); + } + } + } + this.noCompressionEncodings = newEncodings; + } /** * Set Minimum size to trigger compression. @@ -172,6 +229,18 @@ public void setCompressionMinSize(int compressionMinSize) { this.compressionMinSize = compressionMinSize; } + /** + * Determines if gzip compression should be enabled for the given response and if it is, sets any necessary headers to + * mark it as such. + * + * @param request The request that triggered the response + * @param response The response to consider compressing + * + * @return {@code true} if compression was enabled for the given response, otherwise {@code false} + */ + public boolean useCompression(Request request, Response response) { + return this.useCompression(request, response, "gzip"); + } /** * Determines if compression should be enabled for the given response and if it is, sets any necessary headers to @@ -179,10 +248,11 @@ public void setCompressionMinSize(int compressionMinSize) { * * @param request The request that triggered the response * @param response The response to consider compressing + * @param encoding The compression encoding to use (e.g., "gzip", "br", "deflate", "zstd") * * @return {@code true} if compression was enabled for the given response, otherwise {@code false} */ - public boolean useCompression(Request request, Response response) { + public boolean useCompression(Request request, Response response, String encoding) { // Check if compression is enabled if (compressionLevel == 0) { return false; @@ -210,9 +280,7 @@ public boolean useCompression(Request request, Response response) { if (tokens.contains("identity")) { // If identity, do not do content modifications useContentEncoding = false; - } else if (tokens.contains("br") || tokens.contains("compress") || tokens.contains("dcb") || - tokens.contains("dcz") || tokens.contains("deflate") || tokens.contains("gzip") || - tokens.contains("pack200-gzip") || tokens.contains("zstd")) { + } else if (noCompressionEncodings.stream().anyMatch(tokens::contains)) { // Content should not be compressed twice return false; } @@ -235,9 +303,9 @@ public boolean useCompression(Request request, Response response) { } Enumeration headerValues = request.getMimeHeaders().values("TE"); - boolean foundGzip = false; + boolean foundEncoding = false; // TE and accept-encoding seem to have equivalent syntax - while (!foundGzip && headerValues.hasMoreElements()) { + while (!foundEncoding && headerValues.hasMoreElements()) { List tes; try { tes = TE.parse(new StringReader(headerValues.nextElement())); @@ -247,9 +315,9 @@ public boolean useCompression(Request request, Response response) { } for (TE te : tes) { - if ("gzip".equalsIgnoreCase(te.getEncoding())) { + if (encoding.equalsIgnoreCase(te.getEncoding())) { useTransferEncoding = true; - foundGzip = true; + foundEncoding = true; break; } } @@ -268,11 +336,11 @@ public boolean useCompression(Request request, Response response) { // Therefore, set the Vary header to keep proxies happy ResponseUtil.addVaryFieldName(responseHeaders, "accept-encoding"); - // Check if user-agent supports gzip encoding - // Only interested in whether gzip encoding is supported. Other + // Check if user-agent supports the specified encoding + // Only interested in whether the encoding is supported. Other // encodings and weights can be ignored. headerValues = request.getMimeHeaders().values("accept-encoding"); - while (!foundGzip && headerValues.hasMoreElements()) { + while (!foundEncoding && headerValues.hasMoreElements()) { List acceptEncodings; try { acceptEncodings = AcceptEncoding.parse(new StringReader(headerValues.nextElement())); @@ -282,15 +350,15 @@ public boolean useCompression(Request request, Response response) { } for (AcceptEncoding acceptEncoding : acceptEncodings) { - if ("gzip".equalsIgnoreCase(acceptEncoding.getEncoding())) { - foundGzip = true; + if (encoding.equalsIgnoreCase(acceptEncoding.getEncoding())) { + foundEncoding = true; break; } } } } - if (!foundGzip) { + if (!foundEncoding) { return false; } @@ -315,10 +383,10 @@ public boolean useCompression(Request request, Response response) { response.setContentLength(-1); if (useTransferEncoding) { // Configure the transfer encoding for compressed content - responseHeaders.addValue("Transfer-Encoding").setString("gzip"); + responseHeaders.addValue("Transfer-Encoding").setString(encoding); } else { // Configure the content encoding for compressed content - responseHeaders.addValue("Content-Encoding").setString("gzip"); + responseHeaders.addValue("Content-Encoding").setString(encoding); } return true; diff --git a/java/org/apache/coyote/LocalStrings.properties b/java/org/apache/coyote/LocalStrings.properties index bd4e432e64ba..dffc70a4afe5 100644 --- a/java/org/apache/coyote/LocalStrings.properties +++ b/java/org/apache/coyote/LocalStrings.properties @@ -57,6 +57,8 @@ asyncStateMachine.invalidAsyncState=Calling [{0}] is not valid for a request wit asyncStateMachine.stateChange=Changing async state from [{0}] to [{1}] compressionConfig.ContentEncodingParseFail=Failed to parse Content-Encoding header when checking to see if compression was already in use +compressionConfig.invalidGzipLevel=The gzip compression level [{0}] is not valid. Valid values are -1 (default) or 1-9. +compressionConfig.invalidGzipBufferSize=The gzip buffer size [{0}] is not valid. Must be a positive integer. continueResponseTiming.invalid=The value [{0}] is not a valid configuration option for continueResponseTiming diff --git a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java index 0b876e6543f6..d6d2fc8e1364 100644 --- a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java +++ b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java @@ -40,6 +40,8 @@ import org.apache.coyote.Response; import org.apache.coyote.UpgradeProtocol; import org.apache.coyote.UpgradeToken; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.apache.coyote.http11.upgrade.InternalHttpUpgradeHandler; import org.apache.coyote.http11.upgrade.UpgradeGroupInfo; import org.apache.coyote.http11.upgrade.UpgradeProcessorExternal; @@ -59,6 +61,7 @@ public abstract class AbstractHttp11Protocol extends AbstractProtocol { protected static final StringManager sm = StringManager.getManager(AbstractHttp11Protocol.class); private final CompressionConfig compressionConfig = new CompressionConfig(); + private OutputFilterFactory outputFilterFactory = new GzipOutputFilterFactory(); private HttpParser httpParser = null; @@ -339,9 +342,49 @@ public void setCompressionMinSize(int compressionMinSize) { compressionConfig.setCompressionMinSize(compressionMinSize); } + public int getGzipLevel() { + return compressionConfig.getGzipLevel(); + } + + public void setGzipLevel(int gzipLevel) { + compressionConfig.setGzipLevel(gzipLevel); + } + + public int getGzipBufferSize() { + return compressionConfig.getGzipBufferSize(); + } + + public void setGzipBufferSize(int gzipBufferSize) { + compressionConfig.setGzipBufferSize(gzipBufferSize); + } + + public String getNoCompressionEncodings() { + return compressionConfig.getNoCompressionEncodings(); + } + + public void setNoCompressionEncodings(String encodings) { + compressionConfig.setNoCompressionEncodings(encodings); + } + + public OutputFilterFactory getOutputFilterFactory() { + return outputFilterFactory; + } + + public void setOutputFilterFactory(OutputFilterFactory outputFilterFactory) { + this.outputFilterFactory = outputFilterFactory; + } + + public void setOutputFilterFactory(String className) { + try { + Class clazz = Class.forName(className); + this.outputFilterFactory = (OutputFilterFactory) clazz.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException(sm.getString("abstractHttp11Protocol.invalidOutputFilterFactory", className), e); + } + } public boolean useCompression(Request request, Response response) { - return compressionConfig.useCompression(request, response); + return compressionConfig.useCompression(request, response, outputFilterFactory.getEncodingName()); } diff --git a/java/org/apache/coyote/http11/Http11Processor.java b/java/org/apache/coyote/http11/Http11Processor.java index b6c9c5239ceb..a122574106f1 100644 --- a/java/org/apache/coyote/http11/Http11Processor.java +++ b/java/org/apache/coyote/http11/Http11Processor.java @@ -190,7 +190,10 @@ public Http11Processor(AbstractHttp11Protocol protocol, Adapter adapter) { // Create and add the gzip filters. // inputBuffer.addFilter(new GzipInputFilter()); - outputBuffer.addFilter(new GzipOutputFilter()); + GzipOutputFilter gzipOutputFilter = new GzipOutputFilter(); + gzipOutputFilter.setLevel(protocol.getGzipLevel()); + gzipOutputFilter.setBufferSize(protocol.getGzipBufferSize()); + outputBuffer.addFilter(gzipOutputFilter); pluggableFilterIndex = inputBuffer.getFilters().length; } diff --git a/java/org/apache/coyote/http11/LocalStrings.properties b/java/org/apache/coyote/http11/LocalStrings.properties index 39c7b89c7df5..f35db3492a56 100644 --- a/java/org/apache/coyote/http11/LocalStrings.properties +++ b/java/org/apache/coyote/http11/LocalStrings.properties @@ -18,6 +18,7 @@ abstractHttp11Protocol.alpnWithNoAlpn=The upgrade handler [{0}] for [{1}] only s abstractHttp11Protocol.httpUpgradeConfigured=The [{0}] connector has been configured to support HTTP upgrade to [{1}] abstractHttp11Protocol.upgradeJmxNameFail=Failed to create ObjectName with which to register upgrade protocol in JMX abstractHttp11Protocol.upgradeJmxRegistrationFail=Failed to register upgrade protocol in JMX +abstractHttp11Protocol.invalidOutputFilterFactory=The output filter factory class [{0}] could not be loaded or instantiated. http11processor.fallToDebug=\n\ \ Note: further occurrences of HTTP request parsing errors will be logged at DEBUG level. diff --git a/java/org/apache/coyote/http11/filters/GzipOutputFilter.java b/java/org/apache/coyote/http11/filters/GzipOutputFilter.java index 9ed432aba682..0342936c569b 100644 --- a/java/org/apache/coyote/http11/filters/GzipOutputFilter.java +++ b/java/org/apache/coyote/http11/filters/GzipOutputFilter.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; +import java.util.zip.Deflater; import java.util.zip.GZIPOutputStream; import org.apache.coyote.Response; @@ -32,7 +33,7 @@ * Gzip output filter. */ public class GzipOutputFilter implements OutputFilter { - + public static final int DEFAULT_BUFFER_SIZE = 512; protected static final Log log = LogFactory.getLog(GzipOutputFilter.class); private static final StringManager sm = StringManager.getManager(GzipOutputFilter.class); @@ -56,13 +57,24 @@ public class GzipOutputFilter implements OutputFilter { */ protected final OutputStream fakeOutputStream = new FakeOutputStream(); + /** + * Compression level for gzip. Valid values are -1 (default), or 1-9 + */ + private int level = Deflater.DEFAULT_COMPRESSION; + + /** + * Buffer size for gzip compression stream. Default is Deflater default size 512. + */ + private int bufferSize = DEFAULT_BUFFER_SIZE; // --------------------------------------------------- OutputBuffer Methods @Override public int doWrite(ByteBuffer chunk) throws IOException { if (compressionStream == null) { - compressionStream = new GZIPOutputStream(fakeOutputStream, true); + compressionStream = new GZIPOutputStream(fakeOutputStream, bufferSize, true) {{ + this.def.setLevel(level); + }}; } int len = chunk.remaining(); if (chunk.hasArray()) { @@ -121,7 +133,9 @@ public void setBuffer(HttpOutputBuffer buffer) { @Override public void end() throws IOException { if (compressionStream == null) { - compressionStream = new GZIPOutputStream(fakeOutputStream, true); + compressionStream = new GZIPOutputStream(fakeOutputStream, bufferSize, true) {{ + this.def.setLevel(level); + }}; } compressionStream.finish(); compressionStream.close(); @@ -135,7 +149,30 @@ public void recycle() { compressionStream = null; } + /** + * Set the compression level for gzip. + * @param level The compression level. Valid values are -1 (default), or 1-9. + * -1 uses the default compression level. + * 1 gives best speed, 9 gives best compression. + */ + public void setLevel(int level) { + if (level < -1 || level > 9) { + throw new IllegalArgumentException(sm.getString("gzipOutputFilter.invalidLevel", Integer.valueOf(level))); + } + this.level = level; + } + /** + * Set the buffer size for gzip compression stream. + * + * @param bufferSize The buffer size in bytes. Must be positive. + */ + public void setBufferSize(int bufferSize) { + if (bufferSize <= 0) { + throw new IllegalArgumentException(sm.getString("gzipOutputFilter.invalidBufferSize", Integer.valueOf(bufferSize))); + } + this.bufferSize = bufferSize; + } // ------------------------------------------- FakeOutputStream Inner Class @@ -157,11 +194,13 @@ public void write(byte[] b, int off, int len) throws IOException { @Override public void flush() throws IOException { - /* NOOP */} + /* NOOP */ + } @Override public void close() throws IOException { - /* NOOP */} + /* NOOP */ + } } diff --git a/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java b/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java new file mode 100644 index 000000000000..6a7713c6e1ac --- /dev/null +++ b/java/org/apache/coyote/http11/filters/GzipOutputFilterFactory.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.coyote.http11.filters; + +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.OutputFilter; + +/** + * Factory for creating GzipOutputFilter instances with protocol configuration. + * This is the default output filter factory used by Tomcat. + */ +public class GzipOutputFilterFactory implements OutputFilterFactory{ + + @Override + public OutputFilter createFilter(AbstractHttp11Protocol protocol) { + GzipOutputFilter filter = new GzipOutputFilter(); + + // Apply configuration from protocol + filter.setLevel(protocol.getGzipLevel()); + filter.setBufferSize(protocol.getGzipBufferSize()); + + return filter; + } + + @Override + public String getEncodingName() { + return "gzip"; + } +} diff --git a/java/org/apache/coyote/http11/filters/LocalStrings.properties b/java/org/apache/coyote/http11/filters/LocalStrings.properties index 3650316b88f9..a93adae67c51 100644 --- a/java/org/apache/coyote/http11/filters/LocalStrings.properties +++ b/java/org/apache/coyote/http11/filters/LocalStrings.properties @@ -30,5 +30,7 @@ chunkedInputFilter.maxExtension=maxExtensionSize exceeded chunkedInputFilter.maxTrailer=maxTrailerSize exceeded gzipOutputFilter.flushFail=Ignored exception while flushing gzip filter +gzipOutputFilter.invalidLevel=The gzip compression level [{0}] is not valid. Valid values are -1 (default) or 1-9. +gzipOutputFilter.invalidBufferSize=The gzip buffer size [{0}] is not valid. Must be a positive integer. inputFilter.maxSwallow=maxSwallowSize exceeded diff --git a/java/org/apache/coyote/http11/filters/OutputFilterFactory.java b/java/org/apache/coyote/http11/filters/OutputFilterFactory.java new file mode 100644 index 000000000000..c36e0efba23e --- /dev/null +++ b/java/org/apache/coyote/http11/filters/OutputFilterFactory.java @@ -0,0 +1,28 @@ +package org.apache.coyote.http11.filters; + +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http11.OutputFilter; + +/** + * Factory interface for creating output filters. + * Allows pluggable compression and transformation filters. + */ +public interface OutputFilterFactory { + + /** + * Create a new output filter instance configured for the given protocol. + * + * @param protocol The HTTP protocol instance providing configuration + * @return A configured output filter ready for use + */ + OutputFilter createFilter(AbstractHttp11Protocol protocol); + + /** + * Get the encoding name for this filter. + * Used for Content-Encoding or Transfer-Encoding headers and + * for matching against client Accept-Encoding preferences. + * + * @return The encoding name (e.g., "gzip", "br", "deflate", "zstd") + */ + String getEncodingName(); +} diff --git a/java/org/apache/coyote/http2/StreamProcessor.java b/java/org/apache/coyote/http2/StreamProcessor.java index 942ed2014cc6..399defbf3dc2 100644 --- a/java/org/apache/coyote/http2/StreamProcessor.java +++ b/java/org/apache/coyote/http2/StreamProcessor.java @@ -39,6 +39,7 @@ import org.apache.coyote.Request; import org.apache.coyote.RequestGroupInfo; import org.apache.coyote.Response; +import org.apache.coyote.http11.AbstractHttp11Protocol; import org.apache.coyote.http11.filters.GzipOutputFilter; import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; @@ -211,7 +212,15 @@ static void prepareHeaders(Request coyoteRequest, Response coyoteResponse, boole if (noSendfile && protocol != null && protocol.useCompression(coyoteRequest, coyoteResponse)) { // Enable compression. Headers will have been set. Need to configure // output filter at this point. - stream.addOutputFilter(new GzipOutputFilter()); + GzipOutputFilter gzipOutputFilter = new GzipOutputFilter(); + AbstractHttp11Protocol http11Protocol = protocol.getHttp11Protocol(); + + if (http11Protocol != null) { + gzipOutputFilter.setLevel(http11Protocol.getGzipLevel()); + gzipOutputFilter.setBufferSize(http11Protocol.getGzipBufferSize()); + } + + stream.addOutputFilter(gzipOutputFilter); } // Check to see if a response body is present diff --git a/test/org/apache/coyote/TestCompressionConfig.java b/test/org/apache/coyote/TestCompressionConfig.java index ae16c02f8f43..959af855fd90 100644 --- a/test/org/apache/coyote/TestCompressionConfig.java +++ b/test/org/apache/coyote/TestCompressionConfig.java @@ -17,8 +17,10 @@ package org.apache.coyote; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.zip.Deflater; import org.junit.Assert; import org.junit.Test; @@ -91,7 +93,7 @@ public void testUseCompression() throws Exception { response.getMimeHeaders().addValue("ETag").setString(eTag); } - boolean useCompression = compressionConfig.useCompression(request, response); + boolean useCompression = compressionConfig.useCompression(request, response, "gzip"); Assert.assertEquals(compress, Boolean.valueOf(useCompression)); if (useTE.booleanValue()) { @@ -110,4 +112,55 @@ public void testUseCompression() throws Exception { } } } + + @Test + public void testGzipLevelConfiguration() { + CompressionConfig config = new CompressionConfig(); + + Assert.assertEquals(-1, config.getGzipLevel()); + + config.setGzipLevel(Deflater.BEST_SPEED); + Assert.assertEquals(Deflater.BEST_SPEED, config.getGzipLevel()); + + config.setGzipLevel(Deflater.BEST_COMPRESSION); + Assert.assertEquals(Deflater.BEST_COMPRESSION, config.getGzipLevel()); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipLevelLow() { + new CompressionConfig().setGzipLevel(-2); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipLevelHigh() { + new CompressionConfig().setGzipLevel(10); + } + + @Test + public void testGzipBufferSizeConfiguration() { + CompressionConfig config = new CompressionConfig(); + + Assert.assertEquals(512, config.getGzipBufferSize()); + + config.setGzipBufferSize(1024); + Assert.assertEquals(1024, config.getGzipBufferSize()); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipBufferSize() { + new CompressionConfig().setGzipBufferSize(0); + } + + @Test + public void testNoCompressionEncodings() { + CompressionConfig config = new CompressionConfig(); + String encodings = config.getNoCompressionEncodings(); + Assert.assertTrue(Arrays.asList("br", "compress", "dcb", "dcz", "deflate", "gzip", "pack200-gzip", "zstd") + .stream() + .anyMatch(encodings::contains)); + + config.setNoCompressionEncodings("br"); + Assert.assertTrue(encodings.contains("br")); + Assert.assertFalse(encodings.contains("gzip")); + } } diff --git a/test/org/apache/coyote/http11/TestHttp11Processor.java b/test/org/apache/coyote/http11/TestHttp11Processor.java index cc00ac4d848e..2d4b4154731a 100644 --- a/test/org/apache/coyote/http11/TestHttp11Processor.java +++ b/test/org/apache/coyote/http11/TestHttp11Processor.java @@ -31,11 +31,9 @@ import java.net.SocketAddress; import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.CountDownLatch; +import java.util.zip.Deflater; import jakarta.servlet.AsyncContext; import jakarta.servlet.DispatcherType; @@ -47,6 +45,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.coyote.http11.filters.GzipOutputFilterFactory; +import org.apache.coyote.http11.filters.OutputFilterFactory; import org.junit.Assert; import org.junit.Test; @@ -2148,7 +2148,80 @@ public void testEarlyHintsSendErrorWithMessage() throws Exception { Assert.assertEquals(HttpServletResponse.SC_OK, client.getStatusCode()); } + @Test + public void testGzipLevel() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + Assert.assertEquals(-1, protocol.getGzipLevel()); + + protocol.setGzipLevel(Deflater.BEST_SPEED); + Assert.assertEquals(Deflater.BEST_SPEED, protocol.getGzipLevel()); + + protocol.setGzipLevel(Deflater.BEST_COMPRESSION); + Assert.assertEquals(Deflater.BEST_COMPRESSION, protocol.getGzipLevel()); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipLevelLow() { + new Http11NioProtocol().setGzipLevel(-2); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipLevelHigh() { + new Http11NioProtocol().setGzipLevel(10); + } + + @Test + public void testGzipBufferSize() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + Assert.assertEquals(512, protocol.getGzipBufferSize()); + + protocol.setGzipBufferSize(1024); + Assert.assertEquals(1024, protocol.getGzipBufferSize()); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipBufferSize() { + new Http11NioProtocol().setGzipBufferSize(0); + } + + @Test + public void testOutputFilterFactory() { + Http11NioProtocol protocol = new Http11NioProtocol(); + // Test default factory is GzipOutputFilterFactory + Assert.assertNotNull(protocol.getOutputFilterFactory()); + Assert.assertTrue(protocol.getOutputFilterFactory() instanceof GzipOutputFilterFactory); + + protocol.setOutputFilterFactory(new MockOutputFilterFactory()); + Assert.assertNotNull(protocol.getOutputFilterFactory()); + Assert.assertTrue(protocol.getOutputFilterFactory() instanceof MockOutputFilterFactory); + } + + @Test + public void testOutputFilterFactoryClassName() { + Http11NioProtocol protocol = new Http11NioProtocol(); + + protocol.setOutputFilterFactory(MockOutputFilterFactory.class.getName()); + Assert.assertNotNull(protocol.getOutputFilterFactory()); + Assert.assertTrue(protocol.getOutputFilterFactory() instanceof MockOutputFilterFactory); + } + + @Test + public void testNoCompressionEncodings() { + Http11NioProtocol protocol = new Http11NioProtocol(); + String encodings = protocol.getNoCompressionEncodings(); + Assert.assertTrue(Arrays.asList("br", "compress", "dcb", "dcz", "deflate", "gzip", "pack200-gzip", "zstd") + .stream() + .anyMatch(encodings::contains)); + + protocol.setNoCompressionEncodings("br"); + + String newEncodings = protocol.getNoCompressionEncodings(); + Assert.assertTrue(newEncodings.contains("br")); + Assert.assertFalse(newEncodings.contains("gzip")); + } private static class EarlyHintsServlet extends HttpServlet { @@ -2185,4 +2258,15 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se resp.getWriter().write("OK"); } } + + public static class MockOutputFilterFactory implements OutputFilterFactory { + + public OutputFilter createFilter(AbstractHttp11Protocol protocol) { + return null; + } + + public String getEncodingName() { + return ""; + } + } } diff --git a/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java b/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java index 7873b0e54773..e24951db242a 100644 --- a/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java +++ b/test/org/apache/coyote/http11/filters/TestGzipOutputFilter.java @@ -81,4 +81,19 @@ public void testFlushingWithGzip() throws Exception { // most of the data should have been flushed out Assert.assertTrue(dataFound.length >= (dataExpected.length - 20)); } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidLevelLow() { + new GzipOutputFilter().setLevel(-2); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidLevelHigh() { + new GzipOutputFilter().setLevel(10); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidGzipBufferSize() { + new GzipOutputFilter().setBufferSize(0); + } } diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml index 674794a5ef24..2039a1832f9d 100644 --- a/webapps/docs/config/http.xml +++ b/webapps/docs/config/http.xml @@ -450,6 +450,38 @@ Units are in bytes.

+ +

If compression is set to "on" or "force" then this + attribute may be used to configure the compression level for gzip + compression. Valid values are -1 (default compression) + or 1 through 9 where 1 gives + beset speed and 9 gives best compression ratio. If not + specified, this attribute defaults to -1 (JDK default compression).

+
+ + +

If compression is set to "on" or "force" then this + attribute may be used to configure the internal buffer size (in bytes) + for the gzip compression stream. Larger buffers can improve compression + ratios and throughput for bulk responses, while smaller buffers reduce + latency for streaming scenarios. If not specified, this attribute + defaults to 512 bytes.

+
+ + +

A comma-separated list of content encodings that indicate + already-compressed content. When the response already has a + Content-Encoding header with one of these values, compression + will not be applied to prevent double compression. This attribute is only + used if compression is set to on or + force.

+

If not specified, the default values is + br,compress,dcb,dcz,deflate,gzip,pack2000-gzip,zstd, which + includes all commonly used compression algorithms. This can be customized + to support custom compression algorithms when using a custom + outputFilterFactory.

+
+

The number of seconds during which the sockets used by this Connector will linger when they are closed. The default