Skip to content

Commit 5407250

Browse files
committed
style: add ResponseDecoderRegistry so SampleResult does not depend on every possible decoder
1 parent cacf8cb commit 5407250

File tree

12 files changed

+789
-63
lines changed

12 files changed

+789
-63
lines changed

src/core/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ dependencies {
111111
isTransitive = false
112112
}
113113
implementation("org.apache.xmlgraphics:xmlgraphics-commons")
114-
implementation("org.brotli:dec")
115114
implementation("org.freemarker:freemarker")
116115
implementation("org.jodd:jodd-core")
117116
implementation("org.jodd:jodd-props")

src/core/src/main/java/org/apache/jmeter/samplers/SampleResult.java

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@
1717

1818
package org.apache.jmeter.samplers;
1919

20-
import java.io.ByteArrayInputStream;
21-
import java.io.ByteArrayOutputStream;
2220
import java.io.IOException;
23-
import java.io.InputStream;
2421
import java.io.Serializable;
2522
import java.io.UnsupportedEncodingException;
2623
import java.net.HttpURLConnection;
@@ -29,13 +26,9 @@
2926
import java.nio.charset.StandardCharsets;
3027
import java.util.ArrayList;
3128
import java.util.List;
32-
import java.util.Locale;
3329
import java.util.Set;
3430
import java.util.concurrent.ConcurrentHashMap;
3531
import java.util.concurrent.TimeUnit;
36-
import java.util.zip.GZIPInputStream;
37-
import java.util.zip.Inflater;
38-
import java.util.zip.InflaterInputStream;
3932

4033
import org.apache.jmeter.assertions.AssertionResult;
4134
import org.apache.jmeter.gui.Searchable;
@@ -807,13 +800,7 @@ public byte[] getResponseData() {
807800
}
808801
if (contentEncoding != null && responseData.length > 0) {
809802
try {
810-
return switch (contentEncoding.toLowerCase(Locale.ROOT)) {
811-
case "gzip" -> decompressGzip(responseData);
812-
case "x-gzip" -> decompressGzip(responseData);
813-
case "deflate" -> decompressDeflate(responseData);
814-
case "br" -> decompressBrotli(responseData);
815-
default -> responseData;
816-
};
803+
return ResponseDecoderRegistry.decode(contentEncoding, responseData);
817804
} catch (IOException e) {
818805
log.warn("Failed to decompress response data", e);
819806
}
@@ -1703,52 +1690,4 @@ public void setResponseData(byte[] data, String encoding) {
17031690
contentEncoding = encoding;
17041691
responseDataAsString = null;
17051692
}
1706-
1707-
private static byte[] decompressGzip(byte[] in) throws IOException {
1708-
try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(in));
1709-
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
1710-
byte[] buf = new byte[8192];
1711-
int len;
1712-
while ((len = gis.read(buf)) > 0) {
1713-
out.write(buf, 0, len);
1714-
}
1715-
return out.toByteArray();
1716-
}
1717-
}
1718-
1719-
private static byte[] decompressDeflate(byte[] in) throws IOException {
1720-
// Try with ZLIB wrapper first
1721-
try {
1722-
return decompressWithInflater(in, false);
1723-
} catch (IOException e) {
1724-
// If that fails, try with NO_WRAP for raw DEFLATE
1725-
return decompressWithInflater(in, true);
1726-
}
1727-
}
1728-
1729-
private static byte[] decompressWithInflater(byte[] in, boolean nowrap) throws IOException {
1730-
try (InflaterInputStream iis = new InflaterInputStream(
1731-
new ByteArrayInputStream(in),
1732-
new Inflater(nowrap));
1733-
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
1734-
byte[] buf = new byte[8192];
1735-
int len;
1736-
while ((len = iis.read(buf)) > 0) {
1737-
out.write(buf, 0, len);
1738-
}
1739-
return out.toByteArray();
1740-
}
1741-
}
1742-
1743-
private static byte[] decompressBrotli(byte[] in) throws IOException {
1744-
try (InputStream bis = new org.brotli.dec.BrotliInputStream(new ByteArrayInputStream(in));
1745-
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
1746-
byte[] buf = new byte[8192];
1747-
int len;
1748-
while ((len = bis.read(buf)) > 0) {
1749-
out.write(buf, 0, len);
1750-
}
1751-
return out.toByteArray();
1752-
}
1753-
}
17541693
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.samplers
19+
20+
import org.apache.jorphan.reflect.JMeterService
21+
import org.apiguardian.api.API
22+
23+
/**
24+
* Interface for response data decoders that handle different content encodings.
25+
* Implementations can be automatically discovered via [java.util.ServiceLoader].
26+
*
27+
* To add a custom decoder:
28+
* 1. Implement this interface
29+
* 2. Create `META-INF/services/org.apache.jmeter.samplers.ResponseDecoder` file
30+
* 4. Add your implementation's fully qualified class name to the file
31+
*
32+
* Example decoders: gzip, deflate, brotli
33+
*
34+
* @since 6.0.0
35+
*/
36+
@JMeterService
37+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
38+
public interface ResponseDecoder {
39+
40+
/**
41+
* Returns the content encodings handled by this decoder.
42+
* These should match Content-Encoding header values (case-insensitive).
43+
*
44+
* A decoder can handle multiple encoding names (e.g., "gzip" and "x-gzip").
45+
*
46+
* Examples: ["gzip", "x-gzip"], ["deflate"], ["br"]
47+
*
48+
* @return list of encoding names this decoder handles (must not be null or empty)
49+
*/
50+
public val encodings: List<String>
51+
52+
/**
53+
* Decodes (decompresses) the given compressed data.
54+
*
55+
* @param compressed the compressed data to decode
56+
* @return the decompressed data
57+
* @throws java.io.IOException if decompression fails
58+
*/
59+
public fun decode(compressed: ByteArray): ByteArray
60+
61+
/**
62+
* Returns the priority of this decoder.
63+
* When multiple decoders are registered for the same encoding,
64+
* the one with the highest priority is used.
65+
*
66+
* Default priority is 0. Built-in decoders use priority 0.
67+
* Plugins can override built-in decoders by returning a higher priority.
68+
*
69+
* @return priority value (higher = preferred), default is 0
70+
*/
71+
public val priority: Int
72+
get() = 0
73+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.samplers
19+
20+
import org.apache.jmeter.samplers.decoders.DeflateDecoder
21+
import org.apache.jmeter.samplers.decoders.GzipDecoder
22+
import org.apache.jmeter.util.JMeterUtils
23+
import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler
24+
import org.apiguardian.api.API
25+
import org.slf4j.LoggerFactory
26+
import java.io.IOException
27+
import java.util.Locale
28+
import java.util.ServiceLoader
29+
import java.util.concurrent.ConcurrentHashMap
30+
31+
/**
32+
* Registry for [ResponseDecoder] implementations.
33+
* Provides centralized management of response decoders for different content encodings.
34+
*
35+
* Decoders are discovered via:
36+
* - Built-in decoders (gzip, deflate)
37+
* - ServiceLoader mechanism (META-INF/services)
38+
*
39+
* Thread-safe singleton registry.
40+
*
41+
* @since 6.0.0
42+
*/
43+
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
44+
public object ResponseDecoderRegistry {
45+
46+
private val log = LoggerFactory.getLogger(ResponseDecoderRegistry::class.java)
47+
48+
/**
49+
* Map of encoding name (lowercase) to decoder implementation.
50+
* Uses ConcurrentHashMap for thread-safe access.
51+
*/
52+
private val decoders = ConcurrentHashMap<String, ResponseDecoder>()
53+
54+
init {
55+
// Register built-in decoders, this ensures the decoders are there even if service registration fails
56+
registerDecoder(GzipDecoder())
57+
registerDecoder(DeflateDecoder())
58+
59+
// Load decoders via ServiceLoader
60+
loadServiceLoaderDecoders()
61+
}
62+
63+
/**
64+
* Loads decoders using ServiceLoader mechanism.
65+
*/
66+
private fun loadServiceLoaderDecoders() {
67+
try {
68+
JMeterUtils.loadServicesAndScanJars(
69+
ResponseDecoder::class.java,
70+
ServiceLoader.load(ResponseDecoder::class.java),
71+
Thread.currentThread().contextClassLoader,
72+
LogAndIgnoreServiceLoadExceptionHandler(log)
73+
).forEach { registerDecoder(it) }
74+
} catch (e: Exception) {
75+
log.error("Error loading ResponseDecoder services", e)
76+
}
77+
}
78+
79+
/**
80+
* Registers a decoder for all its encoding types.
81+
* If a decoder already exists for an encoding, the one with higher priority is kept.
82+
*
83+
* @param decoder the decoder to register
84+
*/
85+
@JvmStatic
86+
public fun registerDecoder(decoder: ResponseDecoder) {
87+
val encodings = decoder.encodings
88+
if (encodings.isEmpty()) {
89+
log.warn("Decoder {} has null or empty encodings list, skipping registration", decoder.javaClass.name)
90+
return
91+
}
92+
93+
for (encoding in encodings) {
94+
val key = encoding.lowercase(Locale.ROOT)
95+
96+
decoders.merge(key, decoder) { existing, newDecoder ->
97+
// Keep the decoder with higher priority
98+
if (newDecoder.priority > existing.priority) {
99+
log.info(
100+
"Replacing decoder for '{}': {} (priority {}) -> {} (priority {})",
101+
encoding,
102+
existing.javaClass.simpleName, existing.priority,
103+
newDecoder.javaClass.simpleName, newDecoder.priority
104+
)
105+
newDecoder
106+
} else {
107+
log.debug(
108+
"Keeping existing decoder for '{}': {} (priority {}) over {} (priority {})",
109+
encoding,
110+
existing.javaClass.simpleName, existing.priority,
111+
newDecoder.javaClass.simpleName, newDecoder.priority
112+
)
113+
existing
114+
}
115+
}
116+
}
117+
}
118+
119+
/**
120+
* Decodes the given data using the decoder registered for the specified encoding.
121+
* If no decoder is found for the encoding, returns the data unchanged.
122+
*
123+
* @param encoding the content encoding (e.g., "gzip", "deflate", "br")
124+
* @param data the data to decode
125+
* @return decoded data, or original data if no decoder found or encoding is null
126+
* @throws IOException if decoding fails
127+
*/
128+
@JvmStatic
129+
@Throws(IOException::class)
130+
public fun decode(encoding: String?, data: ByteArray?): ByteArray {
131+
if (encoding.isNullOrEmpty() || data == null || data.isEmpty()) {
132+
return data ?: ByteArray(0)
133+
}
134+
135+
val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)]
136+
137+
if (decoder == null) {
138+
log.debug("No decoder found for encoding '{}', returning data unchanged", encoding)
139+
return data
140+
}
141+
142+
return decoder.decode(data)
143+
}
144+
145+
/**
146+
* Checks if a decoder is registered for the given encoding.
147+
* Primarily for testing purposes.
148+
*
149+
* @param encoding the encoding to check
150+
* @return true if a decoder is registered for this encoding
151+
*/
152+
@JvmStatic
153+
public fun hasDecoder(encoding: String): Boolean =
154+
decoders.containsKey(encoding.lowercase(Locale.ROOT))
155+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.jmeter.samplers.decoders
19+
20+
import org.apache.jmeter.samplers.ResponseDecoder
21+
import org.apache.jorphan.io.DirectAccessByteArrayOutputStream
22+
import org.apiguardian.api.API
23+
import java.io.ByteArrayInputStream
24+
import java.io.IOException
25+
import java.util.zip.Inflater
26+
import java.util.zip.InflaterInputStream
27+
28+
/**
29+
* Decoder for deflate compressed response data.
30+
* Attempts decompression with ZLIB wrapper first, falls back to raw DEFLATE if that fails.
31+
*
32+
* @since 6.0.0
33+
*/
34+
@API(status = API.Status.INTERNAL, since = "6.0.0")
35+
public class DeflateDecoder : ResponseDecoder {
36+
override val encodings: List<String>
37+
get() = listOf("deflate")
38+
39+
override fun decode(compressed: ByteArray): ByteArray {
40+
// Try with ZLIB wrapper first
41+
return try {
42+
decompressWithInflater(compressed, nowrap = false)
43+
} catch (e: IOException) {
44+
// If that fails, try with NO_WRAP for raw DEFLATE
45+
decompressWithInflater(compressed, nowrap = true)
46+
}
47+
}
48+
49+
/**
50+
* Decompresses data using Inflater with specified nowrap setting.
51+
*
52+
* @param compressed the compressed data
53+
* @param nowrap if true, uses raw DEFLATE (no ZLIB wrapper)
54+
* @return decompressed data
55+
* @throws IOException if decompression fails
56+
*/
57+
private fun decompressWithInflater(compressed: ByteArray, nowrap: Boolean): ByteArray {
58+
val out = DirectAccessByteArrayOutputStream()
59+
InflaterInputStream(ByteArrayInputStream(compressed), Inflater(nowrap)).use {
60+
it.transferTo(out)
61+
}
62+
return out.toByteArray()
63+
}
64+
}

0 commit comments

Comments
 (0)