Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.apache.jmeter.samplers;

import java.io.IOException;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
Expand Down Expand Up @@ -161,6 +162,8 @@ public class SampleResult implements Serializable, Cloneable, Searchable {

private byte[] responseData = EMPTY_BA;

private String contentEncoding; // Stores gzip/deflate encoding if response is compressed

private String responseCode = "";// Never return null

private String label = "";// Never return null
Expand Down Expand Up @@ -792,6 +795,16 @@ public void setResponseData(final String response, final String encoding) {
* @return the responseData value (cannot be null)
*/
public byte[] getResponseData() {
if (responseData == null) {
return EMPTY_BA;
}
if (contentEncoding != null && responseData.length > 0) {
try {
return ResponseDecoderRegistry.decode(contentEncoding, responseData);
} catch (IOException e) {
log.warn("Failed to decompress response data", e);
}
}
return responseData;
}

Expand All @@ -803,12 +816,12 @@ public byte[] getResponseData() {
public String getResponseDataAsString() {
try {
if(responseDataAsString == null) {
responseDataAsString= new String(responseData,getDataEncodingWithDefault());
responseDataAsString= new String(getResponseData(),getDataEncodingWithDefault());
}
return responseDataAsString;
} catch (UnsupportedEncodingException e) {
log.warn("Using platform default as {} caused {}", getDataEncodingWithDefault(), e.getLocalizedMessage());
return new String(responseData,Charset.defaultCharset()); // N.B. default charset is used deliberately here
return new String(getResponseData(),Charset.defaultCharset()); // N.B. default charset is used deliberately here
}
}

Expand Down Expand Up @@ -1666,4 +1679,15 @@ public TestLogicalAction getTestLogicalAction() {
public void setTestLogicalAction(TestLogicalAction testLogicalAction) {
this.testLogicalAction = testLogicalAction;
}

/**
* Sets the response data and its contentEncoding.
* @param data The response data
* @param contentEncoding The content contentEncoding (e.g. gzip, deflate)
*/
public void setResponseData(byte[] data, String contentEncoding) {
responseData = data == null ? EMPTY_BA : data;
this.contentEncoding = contentEncoding;
responseDataAsString = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,12 @@ private static HashTree readTree(InputStream inputStream, File file)
log.error("Problem loading XML: see above.");
return null;
}
return wrapper.testPlan;
HashTree testPlan = wrapper.testPlan;
// Upgrade legacy property values (e.g. HTTPSampler.md5 -> responseProcessingMode) right after
// load, so the in-memory tree and any re-save use the current representation. NameUpdater only
// renames keys/classes, so value transforms live in TestElementUpgrader services instead.
TestElementUpgraders.upgrade(testPlan);
return testPlan;
} catch (CannotResolveClassException | ConversionException | NoClassDefFoundError e) {
if(file != null) {
throw new IllegalArgumentException("Problem loading XML from:'"+file.getAbsolutePath()+"'. \nCause:\n"+
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.jmeter.samplers

import org.apache.jorphan.io.DirectAccessByteArrayOutputStream
import org.apache.jorphan.reflect.JMeterService
import org.apiguardian.api.API
import java.io.ByteArrayInputStream
import java.io.InputStream

/**
* Interface for response data decoders that handle different content encodings.
* Implementations can be automatically discovered via [java.util.ServiceLoader].
*
* To add a custom decoder:
* 1. Implement this interface
* 2. Create `META-INF/services/org.apache.jmeter.samplers.ResponseDecoder` file
* 4. Add your implementation's fully qualified class name to the file
*
* Example decoders: gzip, deflate, brotli
*
* @since 6.0.0
*/
@JMeterService
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
public interface ResponseDecoder {

/**
* Returns the content encodings handled by this decoder.
* These should match Content-Encoding header values (case-insensitive).
*
* A decoder can handle multiple encoding names (e.g., "gzip" and "x-gzip").
*
* Examples: ["gzip", "x-gzip"], ["deflate"], ["br"]
*
* @return list of encoding names this decoder handles (must not be null or empty)
*/
public val encodings: List<String>

/**
* Decodes (decompresses) the given compressed data.
*
* @param compressed the compressed data to decode
* @return the decompressed data
* @throws java.io.IOException if decompression fails
*/
public fun decode(compressed: ByteArray): ByteArray {
val out = DirectAccessByteArrayOutputStream()
decodeStream(ByteArrayInputStream(compressed)).use {
it.transferTo(out)
}
return out.toByteArray()
}

/**
* Creates a decompressing InputStream that wraps the given compressed input stream.
* This allows streaming decompression without buffering the entire response in memory.
*
* Used for scenarios like MD5 computation on decompressed data, where we want to
* compute the hash on-the-fly without storing the entire decompressed response.
*
* @param input the compressed input stream to wrap
* @return an InputStream that decompresses data as it's read
* @throws java.io.IOException if the decompressing stream cannot be created
*/
public fun decodeStream(input: InputStream): InputStream

/**
* Returns the priority of this decoder.
* When multiple decoders are registered for the same encoding,
* the one with the highest priority is used.
*
* Default priority is 0. Built-in decoders use priority 0.
* Plugins can override built-in decoders by returning a higher priority.
*
* @return priority value (higher = preferred), default is 0
*/
public val priority: Int
get() = 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* 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.jmeter.samplers

import org.apache.jmeter.samplers.decoders.DeflateDecoder
import org.apache.jmeter.samplers.decoders.GzipDecoder
import org.apache.jmeter.util.JMeterUtils
import org.apache.jorphan.reflect.LogAndIgnoreServiceLoadExceptionHandler
import org.apiguardian.api.API
import org.slf4j.LoggerFactory
import java.io.IOException
import java.io.InputStream
import java.util.Locale
import java.util.ServiceLoader
import java.util.concurrent.ConcurrentHashMap

/**
* Registry for [ResponseDecoder] implementations.
* Provides centralized management of response decoders for different content encodings.
*
* Decoders are discovered via:
* - Built-in decoders (gzip, deflate)
* - ServiceLoader mechanism (META-INF/services)
*
* Thread-safe singleton registry.
*
* @since 6.0.0
*/
@API(status = API.Status.EXPERIMENTAL, since = "6.0.0")
public object ResponseDecoderRegistry {

private val log = LoggerFactory.getLogger(ResponseDecoderRegistry::class.java)

/**
* Map of encoding name (lowercase) to decoder implementation.
* Uses ConcurrentHashMap for thread-safe access.
*/
private val decoders = ConcurrentHashMap<String, ResponseDecoder>()

init {
// Register built-in decoders, this ensures the decoders are there even if service registration fails
registerDecoder(GzipDecoder())
registerDecoder(DeflateDecoder())

// Load decoders via ServiceLoader
loadServiceLoaderDecoders()
}

/**
* Loads decoders using ServiceLoader mechanism.
*/
private fun loadServiceLoaderDecoders() {
try {
JMeterUtils.loadServicesAndScanJars(
ResponseDecoder::class.java,
ServiceLoader.load(ResponseDecoder::class.java),
Thread.currentThread().contextClassLoader,
LogAndIgnoreServiceLoadExceptionHandler(log)
).forEach { registerDecoder(it) }
} catch (e: Exception) {
log.error("Error loading ResponseDecoder services", e)
}
}

/**
* Registers a decoder for all its encoding types.
* If a decoder already exists for an encoding, the one with higher priority is kept.
*
* @param decoder the decoder to register
*/
@JvmStatic
public fun registerDecoder(decoder: ResponseDecoder) {
val encodings = decoder.encodings
if (encodings.isEmpty()) {
log.warn("Decoder {} has null or empty encodings list, skipping registration", decoder.javaClass.name)
return
}

for (encoding in encodings) {
val key = encoding.lowercase(Locale.ROOT)

decoders.merge(key, decoder) { existing, newDecoder ->
// Keep the decoder with higher priority
if (newDecoder.priority > existing.priority) {
log.info(
"Replacing decoder for '{}': {} (priority {}) -> {} (priority {})",
encoding,
existing.javaClass.simpleName, existing.priority,
newDecoder.javaClass.simpleName, newDecoder.priority
)
newDecoder
} else {
log.debug(
"Keeping existing decoder for '{}': {} (priority {}) over {} (priority {})",
encoding,
existing.javaClass.simpleName, existing.priority,
newDecoder.javaClass.simpleName, newDecoder.priority
)
existing
}
}
}
}

/**
* Decodes the given data using the decoder registered for the specified encoding.
* If no decoder is found for the encoding, returns the data unchanged.
*
* @param encoding the content encoding (e.g., "gzip", "deflate", "br")
* @param data the data to decode
* @return decoded data, or original data if no decoder found or encoding is null
* @throws IOException if decoding fails
*/
@JvmStatic
@Throws(IOException::class)
public fun decode(encoding: String?, data: ByteArray?): ByteArray {
if (encoding.isNullOrEmpty() || data == null || data.isEmpty()) {
return data ?: ByteArray(0)
}

val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)]

if (decoder == null) {
log.debug("No decoder found for encoding '{}', returning data unchanged", encoding)
return data
}

return decoder.decode(data)
}

/**
* Creates a decompressing InputStream that wraps the given input stream using the decoder
* registered for the specified encoding.
*
* This enables streaming decompression without buffering the entire response in memory,
* which is useful for computing checksums on decompressed data or processing large responses.
*
* If no decoder is found for the encoding, returns the original input stream unchanged.
*
* @param encoding the content encoding (e.g., "gzip", "deflate", "br")
* @param input the input stream to wrap with decompression
* @return a decompressing InputStream, or the original stream if no decoder found or encoding is null
* @throws IOException if the decompressing stream cannot be created
* @since 6.0.0
*/
@JvmStatic
@Throws(IOException::class)
public fun decodeStream(encoding: String?, input: InputStream): InputStream {
if (encoding.isNullOrEmpty()) {
return input
}

val decoder = decoders[encoding] ?: decoders[encoding.lowercase(Locale.ROOT)]

if (decoder == null) {
log.debug("No decoder found for encoding '{}', returning input stream unchanged", encoding)
return input
}

return decoder.decodeStream(input)
}

/**
* Checks if a decoder is registered for the given encoding.
* Primarily for testing purposes.
*
* @param encoding the encoding to check
* @return true if a decoder is registered for this encoding
*/
@JvmStatic
public fun hasDecoder(encoding: String): Boolean =
decoders.containsKey(encoding.lowercase(Locale.ROOT))
}
Loading
Loading