diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 2648ffc..21fd3c1 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -1134,7 +1134,7 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu } ensureHostHeader(http11Request, url); - configureContentDecoders(httpClientHttp1Only, http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); // Copy body if present setBody(http11Request, sampler, result); @@ -1200,7 +1200,7 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest, } ensureHostHeader(http11Request, uri); - configureContentDecoders(httpClientHttp1Only, http11Request); + configureContentDecodersAndCapture(httpClientHttp1Only, http11Request); // Copy body if present if (originalRequest.getBody() != null) { @@ -1254,7 +1254,7 @@ private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest) } } ensureHostHeader(h2cRequest, uri); - configureContentDecoders(httpClientH2cPrior, h2cRequest); + configureContentDecodersAndCapture(httpClientH2cPrior, h2cRequest); if (originalRequest.getBody() != null) { h2cRequest.body(originalRequest.getBody()); } @@ -1299,7 +1299,7 @@ private void samplePrepareRequest(Request request, addPreemptiveAuthorizationHeader(request, url, sampler.getAuthManager()); lowLevelDebug("Headers set, request URI: {}", request.getURI()); - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); String ae = request.getHeaders() != null ? request.getHeaders().get(HttpHeader.ACCEPT_ENCODING) @@ -2105,6 +2105,11 @@ private void configureContentDecoders(HttpClient client, Request request) { } } + private void configureContentDecodersAndCapture(HttpClient client, Request request) { + configureContentDecoders(client, request); + JmeterCompressionHeadersSupport.installCapture(request); + } + private HttpClient selectHttpClient(URI uri) { if (uri != null && "http".equalsIgnoreCase(uri.getScheme())) { if (!enableHttp2 && enableHttp1) { @@ -2511,7 +2516,7 @@ private Request cloneRequest(Request originalRequest, HttpClient client) if (originalRequest.getBody() != null) { request.body(originalRequest.getBody()); } - configureContentDecoders(client, request); + configureContentDecodersAndCapture(client, request); return request; } @@ -3637,9 +3642,11 @@ private void setResultContentResponse(HTTPSampleResult result, result.setURL(contentResponse.getRequest().getURI().toURL()); } + HttpFields sampleResultHeaders = + JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); long headerBytes = (long) result.getResponseHeaders().length() // condensed length (without \r) - + (long) contentResponse.getHeaders().asString().length() // Add \r for each header + + (long) sampleResultHeaders.asString().length() // Add \r for each header + 1L // Add \r for initial header + 2L; // final \r\n before data result.setHeadersSize((int) headerBytes); @@ -3647,8 +3654,9 @@ private void setResultContentResponse(HTTPSampleResult result, private String extractResponseHeaders(ContentResponse contentResponse, String message) { + HttpFields headers = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); return contentResponse.getVersion() + " " + contentResponse.getStatus() + " " + message + "\n" - + buildHeadersString(contentResponse.getHeaders()); + + buildHeadersString(headers); } private String extractRedirectLocation(ContentResponse contentResponse) { @@ -3738,6 +3746,8 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) { if (contentEncoding == null || contentEncoding.trim().isEmpty()) { return content; } + JmeterCompressionHeadersSupport.captureIfCompressed( + contentResponse.getRequest(), contentResponse.getHeaders()); String encodingToken = normalizeEncodingToken(contentEncoding); if (encodingToken.isEmpty()) { return content; diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java new file mode 100644 index 0000000..b936947 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java @@ -0,0 +1,144 @@ +package com.blazemeter.jmeter.http2.core; + +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.Response; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; + +/** + * Preserves wire {@code Content-Encoding}, {@code Content-Length} and {@code Content-MD5} + * for compressed responses, matching Apache JMeter {@code HTTPHC4Impl} behaviour after + * HttpClient {@code ResponseContentEncoding} decodes the body (Bug 59401). + */ +final class JmeterCompressionHeadersSupport { + + static final String REQUEST_ATTR_WIRE_COMPRESSION_HEADERS = + "bzm.jmeter.wireCompressionHeaders"; + + private static final String[] HEADERS_TO_SAVE = { + HTTPConstants.HEADER_CONTENT_LENGTH, + HTTPConstants.HEADER_CONTENT_ENCODING, + "Content-MD5" + }; + + private JmeterCompressionHeadersSupport() { + } + + static void installCapture(Request request) { + if (request == null) { + return; + } + request.onResponseHeader(JmeterCompressionHeadersSupport::onResponseHeader); + request.onResponseHeaders(JmeterCompressionHeadersSupport::onResponseHeaders); + } + + private static boolean onResponseHeader(Response response, HttpField field) { + if (response != null && field != null && HttpHeader.CONTENT_ENCODING.is(field.getName())) { + Request request = response.getRequest(); + if (request != null) { + mergeSavedHeader(request, field.getName(), field.getValue()); + } + } + return true; + } + + private static void onResponseHeaders(Response response) { + if (response != null && response.getRequest() != null && response.getHeaders() != null) { + captureIfCompressed(response.getRequest(), response.getHeaders()); + } + } + + private static void mergeSavedHeader(Request request, String name, String value) { + if (request == null || name == null || value == null) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + saved.put(name, value); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + + private static HttpFields.Mutable getOrCreateSaved(Request request) { + Object existing = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (existing instanceof HttpFields.Mutable) { + return (HttpFields.Mutable) existing; + } + if (existing instanceof HttpFields) { + HttpFields.Mutable copy = HttpFields.build((HttpFields) existing); + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, copy); + return copy; + } + return HttpFields.build(); + } + + static void captureIfCompressed(Request request, HttpFields headers) { + if (request == null || headers == null) { + return; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + if (contentEncoding == null || contentEncoding.trim().isEmpty()) { + return; + } + HttpFields.Mutable saved = getOrCreateSaved(request); + for (String name : HEADERS_TO_SAVE) { + String value = headers.get(name); + if (value != null) { + saved.put(name, value); + } + } + if (saved.size() > 0) { + request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved); + } + } + + static HttpFields headersForSampleResult(ContentResponse contentResponse) { + if (contentResponse == null || contentResponse.getHeaders() == null) { + return contentResponse != null ? contentResponse.getHeaders() : HttpFields.EMPTY; + } + HttpFields current = contentResponse.getHeaders(); + Request request = contentResponse.getRequest(); + if (request == null) { + return current; + } + Object savedAttr = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS); + if (!(savedAttr instanceof HttpFields)) { + return current; + } + HttpFields saved = (HttpFields) savedAttr; + if (saved.size() == 0) { + return current; + } + HttpFields.Mutable merged = HttpFields.build(current); + // Jetty removes Content-Encoding but often leaves an updated Content-Length (decoded size). + // Match HTTPHC4Impl: restore all wire compression headers when encoding was stripped. + if (!hasContentEncoding(current) && hasContentEncoding(saved)) { + for (String name : HEADERS_TO_SAVE) { + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } else { + for (String name : HEADERS_TO_SAVE) { + if (merged.contains(name)) { + continue; + } + String value = saved.get(name); + if (value != null) { + merged.put(name, value); + } + } + } + return merged; + } + + private static boolean hasContentEncoding(HttpFields headers) { + if (headers == null) { + return false; + } + String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING); + return contentEncoding != null && !contentEncoding.trim().isEmpty(); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 2233d21..8da8819 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -280,9 +280,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeGzi sampler.setHeaderManager(hm); try { HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_GZIP); - boolean hasGzipHeader = result.getResponseHeaders().contains("Content-Encoding: gzip"); - boolean hasBody = result.getResponseData() != null && result.getResponseData().length > 0; - assertThat(hasGzipHeader || hasBody).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: gzip"); + assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } finally { if (originalEnableHttp1 == null) { JMeterUtils.getJMeterProperties().remove("httpJettyClient.enableHttp1"); @@ -300,8 +299,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeBro hm.add(new Header(HttpHeader.ACCEPT_ENCODING.asString(), "br")); sampler.setHeaderManager(hm); HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_BROTLI); - // Verify that the request was successful and content was decompressed assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: br"); assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } @@ -313,8 +312,8 @@ public void shouldReturnSuccessSampleResultWhenSuccessResponseWithContentTypeZst hm.add(new Header(HttpHeader.ACCEPT_ENCODING.asString(), "zstd")); sampler.setHeaderManager(hm); HTTPSampleResult result = sampleWithGet(SERVER_PATH_200_ZSTD); - // Verify that the request was successful and content was decompressed assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getResponseHeaders()).containsIgnoringCase("content-encoding: zstd"); assertThat(result.getResponseData()).containsExactly(BINARY_RESPONSE_BODY); } diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java new file mode 100644 index 0000000..6818707 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupportTest.java @@ -0,0 +1,95 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +import java.util.HashMap; +import java.util.Map; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.junit.Test; +import org.mockito.Mockito; + +public class JmeterCompressionHeadersSupportTest { + + private static Request mockRequestWithAttributes() { + Map attributes = new HashMap<>(); + Request request = Mockito.mock(Request.class); + Mockito.when(request.getAttributes()).thenReturn(attributes); + Mockito.doAnswer(invocation -> { + attributes.put(invocation.getArgument(0), invocation.getArgument(1)); + return invocation.getMock(); + }).when(request).attribute(anyString(), any()); + return request; + } + + @Test + public void shouldRestoreWireCompressionHeadersRemovedByDecoder() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_TYPE, "application/json") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("gzip"); + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("643"); + assertThat(merged.get(HttpHeader.CONTENT_TYPE)).isEqualTo("application/json"); + } + + @Test + public void shouldNotOverrideExistingHeaders() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "643"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "gzip") + .add(HttpHeader.CONTENT_LENGTH, "238"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_LENGTH)).isEqualTo("238"); + } + + @Test + public void shouldCaptureContentMd5WhenPresent() { + Request request = mockRequestWithAttributes(); + + HttpFields wire = HttpFields.build() + .add(HttpHeader.CONTENT_ENCODING, "br") + .add(HTTPConstants.HEADER_CONTENT_LENGTH, "100") + .add("Content-MD5", "abc123"); + JmeterCompressionHeadersSupport.captureIfCompressed(request, wire); + + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + HttpFields decoded = HttpFields.build().add(HttpHeader.CONTENT_TYPE, "text/plain"); + Mockito.when(contentResponse.getRequest()).thenReturn(request); + Mockito.when(contentResponse.getHeaders()).thenReturn(decoded); + + HttpFields merged = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse); + + assertThat(merged.get(HttpHeader.CONTENT_ENCODING)).isEqualTo("br"); + assertThat(merged.get(HTTPConstants.HEADER_CONTENT_LENGTH)).isEqualTo("100"); + assertThat(merged.get("Content-MD5")).isEqualTo("abc123"); + } +}