Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store raw responseBody and decompress when needed #6389

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions src/core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ dependencies {
isTransitive = false
}
implementation("org.apache.xmlgraphics:xmlgraphics-commons")
implementation("org.brotli:dec")
implementation("org.freemarker:freemarker")
implementation("org.jodd:jodd-core")
implementation("org.jodd:jodd-props")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

package org.apache.jmeter.samplers;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
Expand All @@ -25,9 +29,13 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPInputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

import org.apache.jmeter.assertions.AssertionResult;
import org.apache.jmeter.gui.Searchable;
Expand Down Expand Up @@ -160,6 +168,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 @@ -217,7 +227,7 @@ public class SampleResult implements Serializable, Cloneable, Searchable {

// TODO do contentType and/or dataEncoding belong in HTTPSampleResult instead?
private String dataEncoding;// (is this really the character set?) e.g.
// ISO-8895-1, UTF-8
// ISO-8895-1, UTF-8

private String contentType = ""; // e.g. text/html; charset=utf-8

Expand Down Expand Up @@ -791,6 +801,27 @@ 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 {
switch (contentEncoding.toLowerCase(Locale.ROOT)) {
case "gzip":
return decompressGzip(responseData);
case "x-gzip":
return decompressGzip(responseData);
case "deflate":
return decompressDeflate(responseData);
case "br":
return decompressBrotli(responseData);
default:
return responseData;
}
} catch (IOException e) {
log.warn("Failed to decompress response data", e);
}
}
Comment on lines +804 to +824
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered something like interface ResponseDecoder { String decode(byte[] contents); } or interface Response { String toString(); byte[] toByteArray(); }?

Then there could be something like SampleResult#setResponse(Response) or SampleResult#setResponseDecoder(ResponseDecoder)?

It just does not sound quite right to tie SampleResult with specific implementations of the decoding

return responseData;
}

Expand All @@ -802,12 +833,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 @@ -1665,4 +1696,63 @@ public TestLogicalAction getTestLogicalAction() {
public void setTestLogicalAction(TestLogicalAction testLogicalAction) {
this.testLogicalAction = testLogicalAction;
}

/**
* Sets the response data and its compression encoding.
* @param data The response data
* @param encoding The content encoding (e.g. gzip, deflate)
*/
public void setResponseData(byte[] data, String encoding) {
responseData = data == null ? EMPTY_BA : data;
contentEncoding = encoding;
responseDataAsString = null;
}

private static byte[] decompressGzip(byte[] in) throws IOException {
try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(in));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = gis.read(buf)) > 0) {
out.write(buf, 0, len);
}
return out.toByteArray();
}
}

private static byte[] decompressDeflate(byte[] in) throws IOException {
// Try with ZLIB wrapper first
try {
return decompressWithInflater(in, false);
} catch (IOException e) {
// If that fails, try with NO_WRAP for raw DEFLATE
return decompressWithInflater(in, true);
}
}

private static byte[] decompressWithInflater(byte[] in, boolean nowrap) throws IOException {
try (InflaterInputStream iis = new InflaterInputStream(
new ByteArrayInputStream(in),
new Inflater(nowrap));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = iis.read(buf)) > 0) {
out.write(buf, 0, len);
}
return out.toByteArray();
}
}

private static byte[] decompressBrotli(byte[] in) throws IOException {
try (InputStream bis = new org.brotli.dec.BrotliInputStream(new ByteArrayInputStream(in));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = bis.read(buf)) > 0) {
out.write(buf, 0, len);
}
return out.toByteArray();
}
}
}
1 change: 0 additions & 1 deletion src/protocol/http/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ dependencies {
implementation("dnsjava:dnsjava")
implementation("org.apache.httpcomponents:httpmime")
implementation("org.apache.httpcomponents:httpcore")
implementation("org.brotli:dec")
implementation("com.miglayout:miglayout-swing")
implementation("com.fasterxml.jackson.core:jackson-core")
implementation("com.fasterxml.jackson.core:jackson-databind")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.HttpResponseInterceptor;
import org.apache.http.NameValuePair;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthSchemeProvider;
Expand All @@ -72,7 +71,6 @@
import org.apache.http.client.config.AuthSchemes;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.InputStreamFactory;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
Expand All @@ -86,7 +84,6 @@
import org.apache.http.client.methods.HttpTrace;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.protocol.ResponseContentEncoding;
import org.apache.http.config.Lookup;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
Expand Down Expand Up @@ -147,8 +144,6 @@
import org.apache.jmeter.protocol.http.control.DynamicKerberosSchemeFactory;
import org.apache.jmeter.protocol.http.control.DynamicSPNegoSchemeFactory;
import org.apache.jmeter.protocol.http.control.HeaderManager;
import org.apache.jmeter.protocol.http.sampler.hc.LaxDeflateInputStream;
import org.apache.jmeter.protocol.http.sampler.hc.LaxGZIPInputStream;
import org.apache.jmeter.protocol.http.sampler.hc.LazyLayeredConnectionSocketFactory;
import org.apache.jmeter.protocol.http.util.ConversionUtils;
import org.apache.jmeter.protocol.http.util.HTTPArgument;
Expand All @@ -166,7 +161,6 @@
import org.apache.jmeter.util.JsseSSLManager;
import org.apache.jmeter.util.SSLManager;
import org.apache.jorphan.util.JOrphanUtils;
import org.brotli.dec.BrotliInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -195,20 +189,8 @@ public class HTTPHC4Impl extends HTTPHCAbstractImpl {

private static final boolean DISABLE_DEFAULT_UA = JMeterUtils.getPropDefault("httpclient4.default_user_agent_disabled", false);

private static final boolean GZIP_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.gzip_relax_mode", false);

private static final boolean DEFLATE_RELAX_MODE = JMeterUtils.getPropDefault("httpclient4.deflate_relax_mode", false);

private static final Logger log = LoggerFactory.getLogger(HTTPHC4Impl.class);

private static final InputStreamFactory GZIP =
instream -> new LaxGZIPInputStream(instream, GZIP_RELAX_MODE);

private static final InputStreamFactory DEFLATE =
instream -> new LaxDeflateInputStream(instream, DEFLATE_RELAX_MODE);

private static final InputStreamFactory BROTLI = BrotliInputStream::new;

private static final class ManagedCredentialsProvider implements CredentialsProvider {
private final AuthManager authManager;
private final Credentials proxyCredentials;
Expand Down Expand Up @@ -472,55 +454,6 @@ protected HttpResponse doSendRequest(
}
};

private static final String[] HEADERS_TO_SAVE = new String[]{
"content-length",
"content-encoding",
"content-md5"
};

/**
* Custom implementation that backups headers related to Compressed responses
* that HC core {@link ResponseContentEncoding} removes after uncompressing
* See Bug 59401
*/
@SuppressWarnings("UnnecessaryAnonymousClass")
private static final HttpResponseInterceptor RESPONSE_CONTENT_ENCODING = new ResponseContentEncoding(createLookupRegistry()) {
@Override
public void process(HttpResponse response, HttpContext context)
throws HttpException, IOException {
ArrayList<Header[]> headersToSave = null;

final HttpEntity entity = response.getEntity();
final HttpClientContext clientContext = HttpClientContext.adapt(context);
final RequestConfig requestConfig = clientContext.getRequestConfig();
// store the headers if necessary
if (requestConfig.isContentCompressionEnabled() && entity != null && entity.getContentLength() != 0) {
final Header ceheader = entity.getContentEncoding();
if (ceheader != null) {
headersToSave = new ArrayList<>(3);
for(String name : HEADERS_TO_SAVE) {
Header[] hdr = response.getHeaders(name); // empty if none
headersToSave.add(hdr);
}
}
}

// Now invoke original parent code
super.process(response, clientContext);
// Should this be in a finally ?
if(headersToSave != null) {
for (Header[] headers : headersToSave) {
for (Header headerToRestore : headers) {
if (response.containsHeader(headerToRestore.getName())) {
break;
}
response.addHeader(headerToRestore);
}
}
}
}
};

/**
* 1 HttpClient instance per combination of (HttpClient,HttpClientKey)
*/
Expand Down Expand Up @@ -558,19 +491,6 @@ protected HTTPHC4Impl(HTTPSamplerBase testElement) {
super(testElement);
}

/**
* Customize to plug Brotli
* @return {@link Lookup}
*/
private static Lookup<InputStreamFactory> createLookupRegistry() {
return
RegistryBuilder.<InputStreamFactory>create()
.register("br", BROTLI)
.register("gzip", GZIP)
.register("x-gzip", GZIP)
.register("deflate", DEFLATE).build();
}

/**
* Implementation that allows GET method to have a body
*/
Expand Down Expand Up @@ -675,7 +595,12 @@ protected HTTPSampleResult sample(URL url, String method,
}
HttpEntity entity = httpResponse.getEntity();
if (entity != null) {
res.setResponseData(readResponse(res, entity.getContent(), entity.getContentLength()));
Header contentEncodingHeader = entity.getContentEncoding();
if (contentEncodingHeader != null) {
res.setResponseData(EntityUtils.toByteArray(entity), contentEncodingHeader.getValue());
} else {
res.setResponseData(EntityUtils.toByteArray(entity));
}
}

res.sampleEnd(); // Done with the sampling proper.
Expand Down Expand Up @@ -1157,7 +1082,7 @@ private MutableTriple<CloseableHttpClient, AuthState, PoolingHttpClientConnectio
}
builder.setDefaultCredentialsProvider(credsProvider);
}
builder.disableContentCompression().addInterceptorLast(RESPONSE_CONTENT_ENCODING);
builder.disableContentCompression(); // Disable automatic decompression
if(BASIC_AUTH_PREEMPTIVE) {
builder.addInterceptorFirst(PREEMPTIVE_AUTH_INTERCEPTOR);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.zip.GZIPInputStream;

import org.apache.commons.io.input.CountingInputStream;
import org.apache.jmeter.protocol.http.control.AuthManager;
Expand Down Expand Up @@ -240,15 +239,11 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I
}

// works OK even if ContentEncoding is null
boolean gzipped = HTTPConstants.ENCODING_GZIP.equals(conn.getContentEncoding());
String contentEncoding = conn.getContentEncoding();
CountingInputStream instream = null;
try {
instream = new CountingInputStream(conn.getInputStream());
if (gzipped) {
in = new GZIPInputStream(instream);
} else {
in = instream;
}
in = instream;
} catch (IOException e) {
if (! (e.getCause() instanceof FileNotFoundException))
{
Expand Down Expand Up @@ -276,28 +271,15 @@ protected byte[] readResponse(HttpURLConnection conn, SampleResult res) throws I
log.info("Error Response Code: {}", conn.getResponseCode());
}

if (gzipped) {
in = new GZIPInputStream(errorStream);
} else {
in = errorStream;
}
} catch (Exception e) {
log.error("readResponse: {}", e.toString());
Throwable cause = e.getCause();
if (cause != null){
log.error("Cause: {}", cause.toString());
if(cause instanceof Error) {
throw (Error)cause;
}
}
in = conn.getErrorStream();
in = errorStream;
}
// N.B. this closes 'in'
byte[] responseData = readResponse(res, in, contentLength);
if (instream != null) {
res.setBodySize(instream.getByteCount());
instream.close();
}
res.setResponseData(responseData, contentEncoding);
return responseData;
}

Expand Down
Loading