From d0f39b4d6ef64763ef54380bcab91a340121cf9f Mon Sep 17 00:00:00 2001 From: Mario Daniel Ruiz Saavedra Date: Wed, 8 Jul 2026 12:11:34 -0300 Subject: [PATCH] Add QUERY method caching support --- README.md | 1 + .../http/impl/cache/CacheKeyGenerator.java | 96 +++++++++++++++++-- .../impl/cache/CacheableRequestPolicy.java | 2 +- .../cache/CachedHttpResponseGenerator.java | 2 +- .../cache/CachingH2AsyncClientBuilder.java | 8 +- .../cache/CachingHttpAsyncClientBuilder.java | 8 +- .../impl/cache/CachingHttpClientBuilder.java | 8 +- .../impl/cache/ResponseCachingPolicy.java | 4 +- .../impl/cache/TestCacheKeyGenerator.java | 57 +++++++++++ .../http/impl/cache/TestCachingExecChain.java | 93 ++++++++++++++++++ 10 files changed, 266 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 15bc97bb78..a1d08ca4e1 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Protocol conformance - [RFC 2817](https://datatracker.ietf.org/doc/html/rfc2817) - Upgrading to TLS Within HTTP/1.1 - [RFC 9218](https://datatracker.ietf.org/doc/html/rfc9218) - Extensible Prioritization Scheme for HTTP - [RFC 7804](https://datatracker.ietf.org/doc/html/rfc7804) - Salted Challenge Response HTTP Authentication Mechanism +- [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008) - The HTTP QUERY Method Licensing --------- diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java index e15b06ca85..f1a805f4ee 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java @@ -29,6 +29,8 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; @@ -38,21 +40,29 @@ import java.util.Spliterators; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.StreamSupport; import org.apache.hc.client5.http.cache.HttpCacheEntry; -import org.apache.hc.core5.annotation.Contract; -import org.apache.hc.core5.annotation.Internal; -import org.apache.hc.core5.annotation.ThreadingBehavior; -import org.apache.hc.core5.function.Resolver; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HeaderElement; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.Method; import org.apache.hc.core5.http.MessageHeaders; import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.function.Resolver; import org.apache.hc.core5.http.message.BasicHeaderElementIterator; import org.apache.hc.core5.http.message.BasicHeaderValueFormatter; import org.apache.hc.core5.http.message.BasicNameValuePair; @@ -75,6 +85,16 @@ public class CacheKeyGenerator implements Resolver { */ public static final CacheKeyGenerator INSTANCE = new CacheKeyGenerator(); + private final Function bodyBytesExtractor; + + public CacheKeyGenerator() { + this(null); + } + + public CacheKeyGenerator(final Function bodyBytesExtractor) { + this.bodyBytesExtractor = bodyBytesExtractor; + } + @Override public String resolve(final URI uri) { return generateKey(uri); @@ -174,9 +194,24 @@ public static URI normalize(final String requestUri) { * @return cache key */ public String generateKey(final URI requestUri) { + return generateKey(requestUri, null); + } + + /** + * Computes a key for the given request {@link URI} and body hash that can + * be used as a unique identifier for cached resources. The URI is + * expected to be in an absolute form. + * + * @param requestUri request URI + * @param bodyHash hash of the request body + * @return cache key + */ + public String generateKey(final URI requestUri, final String bodyHash) { try { final URI normalizeRequestUri = normalize(requestUri); - return normalizeRequestUri.toASCIIString(); + final String uriString = normalizeRequestUri.toASCIIString(); + // Using a Record Separator to make it easy to spot + return bodyHash == null ? uriString : uriString + '\u001e' + bodyHash; } catch (final URISyntaxException ex) { return requestUri.toASCIIString(); } @@ -192,13 +227,62 @@ public String generateKey(final URI requestUri) { */ public String generateKey(final HttpHost host, final HttpRequest request) { final String s = getRequestUri(host, request); + final String hash = Method.QUERY.isSame(request.getMethod()) ? getBodyHash(request) : null; try { - return generateKey(new URI(s)); + return generateKey(new URI(s), hash); } catch (final URISyntaxException ex) { return s; } } + protected byte[] getRequestBodyBytes(final HttpRequest request) { + if (bodyBytesExtractor != null) { + final byte[] bytes = bodyBytesExtractor.apply(request); + if (bytes != null) { + return bytes; + } + } + if (request instanceof SimpleHttpRequest) { + return ((SimpleHttpRequest) request).getBodyBytes(); + } + if (request instanceof ClassicHttpRequest) { + final HttpEntity entity = ((ClassicHttpRequest) request).getEntity(); + if (entity != null) { + try { + if (entity.isRepeatable()) { + final byte[] bytes = EntityUtils.toByteArray(entity); + ((ClassicHttpRequest) request).setEntity(new ByteArrayEntity(bytes, ContentType.parse(entity.getContentType()))); + return bytes; + } + } catch (final Exception ignore) { + } + } + } + return null; + } + + protected String getBodyHash(final HttpRequest request) { + final byte[] bodyBytes = getRequestBodyBytes(request); + if (bodyBytes == null || bodyBytes.length == 0) { + return ""; + } + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hashBytes = digest.digest(bodyBytes); + final StringBuilder hexString = new StringBuilder(); + for (final byte b : hashBytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (final NoSuchAlgorithmException e) { + return ""; + } + } + /** * Returns all variant names contained in {@literal VARY} headers of the given message. * diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java index f1fbdb6a7f..3006224763 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java @@ -57,7 +57,7 @@ public boolean canBeServedFromCache(final RequestCacheControl cacheControl, fina return false; } - if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method)) { + if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method) && !Method.QUERY.isSame(method)) { if (LOG.isDebugEnabled()) { LOG.debug("{} request cannot be served from cache", method); } diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java index 91b36b63a6..75dd9cf826 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java @@ -156,7 +156,7 @@ private void generateContentLength(final HttpResponse response, final byte[] bod } private boolean responseShouldContainEntity(final HttpRequest request, final HttpCacheEntry cacheEntry) { - return Method.GET.isSame(request.getMethod()) && cacheEntry.getResource() != null; + return (Method.GET.isSame(request.getMethod()) || Method.QUERY.isSame(request.getMethod())) && cacheEntry.getResource() != null; } } diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingH2AsyncClientBuilder.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingH2AsyncClientBuilder.java index 5cd9f33767..783040af49 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingH2AsyncClientBuilder.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingH2AsyncClientBuilder.java @@ -56,6 +56,7 @@ public class CachingH2AsyncClientBuilder extends H2AsyncClientBuilder { private SchedulingStrategy schedulingStrategy; private CacheConfig cacheConfig; private boolean deleteCache; + private CacheKeyGenerator cacheKeyGenerator; public static CachingH2AsyncClientBuilder create() { return new CachingH2AsyncClientBuilder(); @@ -112,6 +113,11 @@ public CachingH2AsyncClientBuilder setDeleteCache(final boolean deleteCache) { return this; } + public final CachingH2AsyncClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) { + this.cacheKeyGenerator = cacheKeyGenerator; + return this; + } + @Override protected void customizeExecChain(final NamedElementChain execChainDefinition) { final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT; @@ -142,7 +148,7 @@ protected void customizeExecChain(final NamedElementChain resourceFactoryCopy, HttpCacheEntryFactory.INSTANCE, storageCopy, - CacheKeyGenerator.INSTANCE); + this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE); DefaultAsyncCacheRevalidator cacheRevalidator = null; if (config.getAsynchronousWorkers() > 0) { diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpAsyncClientBuilder.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpAsyncClientBuilder.java index 2dda48a9a2..82d9646bd4 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpAsyncClientBuilder.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpAsyncClientBuilder.java @@ -60,6 +60,7 @@ public class CachingHttpAsyncClientBuilder extends HttpAsyncClientBuilder { private SchedulingStrategy schedulingStrategy; private CacheConfig cacheConfig; private boolean deleteCache; + private CacheKeyGenerator cacheKeyGenerator; public static CachingHttpAsyncClientBuilder create() { return new CachingHttpAsyncClientBuilder(); @@ -116,6 +117,11 @@ public CachingHttpAsyncClientBuilder setDeleteCache(final boolean deleteCache) { return this; } + public final CachingHttpAsyncClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) { + this.cacheKeyGenerator = cacheKeyGenerator; + return this; + } + @Override protected void customizeExecChain(final NamedElementChain execChainDefinition) { final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT; @@ -146,7 +152,7 @@ protected void customizeExecChain(final NamedElementChain resourceFactoryCopy, HttpCacheEntryFactory.INSTANCE, storageCopy, - CacheKeyGenerator.INSTANCE); + this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE); DefaultAsyncCacheRevalidator cacheRevalidator = null; if (config.getAsynchronousWorkers() > 0) { diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpClientBuilder.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpClientBuilder.java index 303e1f05b6..2685c16592 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpClientBuilder.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpClientBuilder.java @@ -58,6 +58,7 @@ public class CachingHttpClientBuilder extends HttpClientBuilder { private SchedulingStrategy schedulingStrategy; private CacheConfig cacheConfig; private boolean deleteCache; + private CacheKeyGenerator cacheKeyGenerator; public static CachingHttpClientBuilder create() { return new CachingHttpClientBuilder(); @@ -110,6 +111,11 @@ public final CachingHttpClientBuilder setDeleteCache(final boolean deleteCache) return this; } + public final CachingHttpClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) { + this.cacheKeyGenerator = cacheKeyGenerator; + return this; + } + @Override protected void customizeExecChain(final NamedElementChain execChainDefinition) { final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT; @@ -140,7 +146,7 @@ protected void customizeExecChain(final NamedElementChain exec resourceFactoryCopy, HttpCacheEntryFactory.INSTANCE, storageCopy, - CacheKeyGenerator.INSTANCE); + this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE); DefaultCacheRevalidator cacheRevalidator = null; if (config.getAsynchronousWorkers() > 0) { diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java index f5d25c35c6..5c5c3af929 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java @@ -98,9 +98,9 @@ public boolean isResponseCacheable(final ResponseCacheControl cacheControl, fina return false; } - // Presently only GET and HEAD methods are supported + // Presently only GET, HEAD and QUERY methods are supported final String httpMethod = request.getMethod(); - if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod)) { + if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod) && !Method.QUERY.isSame(httpMethod)) { if (LOG.isDebugEnabled()) { LOG.debug("{} method response is not cacheable", httpMethod); } diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java index 8fcb19553e..70a0fbb01c 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java @@ -28,6 +28,7 @@ import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -36,13 +37,17 @@ import org.apache.hc.client5.http.cache.HttpCacheEntry; import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.BasicHeaderIterator; import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.support.BasicRequestBuilder; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -404,4 +409,56 @@ void testGetVariantKeyFromCachedResponse() { Assertions.assertEquals("{accept-encoding=text%2Fplain&user-agent=agent1}", extractor.generateVariantKey(request, entry2)); } + @Test + void testQueryKeyGenerationWithBody() throws Exception { + final HttpHost host = new HttpHost("http", "www.example.com", -1); + + // SimpleHttpRequest with body + final SimpleHttpRequest req1 = SimpleHttpRequest.create("QUERY", "/stuff"); + req1.setBody("my-query-1", ContentType.TEXT_PLAIN); + final String key1 = extractor.generateKey(host, req1); + + final SimpleHttpRequest req2 = SimpleHttpRequest.create("QUERY", "/stuff"); + req2.setBody("my-query-2", ContentType.TEXT_PLAIN); + final String key2 = extractor.generateKey(host, req2); + + Assertions.assertNotEquals(key1, key2); + Assertions.assertTrue(key1.contains("\u001e")); + + // ClassicHttpRequest with body + final BasicClassicHttpRequest req3 = new BasicClassicHttpRequest("QUERY", "/stuff"); + req3.setEntity(new StringEntity("my-query-1", ContentType.TEXT_PLAIN)); + final String key3 = extractor.generateKey(host, req3); + Assertions.assertEquals(key1, key3); + } + + @Test + void testQueryKeyGenerationWithCustomSubclass() throws Exception { + final HttpHost host = new HttpHost("http", "www.example.com", -1); + final CacheKeyGenerator customGenerator = new CacheKeyGenerator() { + @Override + protected byte[] getRequestBodyBytes(final HttpRequest request) { + return "custom-subclass-body".getBytes(StandardCharsets.UTF_8); + } + }; + + final SimpleHttpRequest req = SimpleHttpRequest.create("QUERY", "/stuff"); + final String key = customGenerator.generateKey(host, req); + Assertions.assertTrue(key.contains("\u001e")); + Assertions.assertTrue(key.endsWith("01db3f2aa79e0ed79fa787efe21aae8708f2f395082bb420537cb4a1fbc04d6d")); + } + + @Test + void testQueryKeyGenerationWithCustomExtractor() throws Exception { + final HttpHost host = new HttpHost("http", "www.example.com", -1); + final CacheKeyGenerator customGenerator = new CacheKeyGenerator( + request -> "custom-extractor-body".getBytes(StandardCharsets.UTF_8) + ); + + final SimpleHttpRequest req = SimpleHttpRequest.create("QUERY", "/stuff"); + final String key = customGenerator.generateKey(host, req); + Assertions.assertTrue(key.contains("\u001e")); + Assertions.assertTrue(key.endsWith("1277c424fd056514377d8acd78e602891cee40812bef763577c06b0428c4a817")); + } + } diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java index c76b1ece05..02acdb6db7 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java @@ -57,8 +57,10 @@ import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; @@ -1285,4 +1287,95 @@ void testNoCacheFieldsRevalidation() throws Exception { Mockito.verify(mockExecChain, Mockito.times(5)).proceed(Mockito.any(), Mockito.any()); } + @Test + void testQueryRequestsGoIntoCacheAndMatchBody() throws Exception { + final ClassicHttpRequest req1 = new BasicClassicHttpRequest("QUERY", "/stuff"); + req1.setEntity(new StringEntity("query-body-1", ContentType.TEXT_PLAIN)); + + final ClassicHttpResponse resp1 = new BasicClassicHttpResponse(200, "OK"); + resp1.setHeader("Date", DateUtils.formatStandardDate(Instant.now())); + resp1.setHeader("Cache-Control", "max-age=3600"); + resp1.setHeader("Content-Type", "text/plain"); + resp1.setEntity(new StringEntity("response-body-1", ContentType.TEXT_PLAIN)); + + Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1); + + // First execution (Cache Miss, goes to backend) + final ClassicHttpResponse result1 = execute(req1); + Assertions.assertEquals(200, result1.getCode()); + Assertions.assertEquals("response-body-1", EntityUtils.toString(result1.getEntity())); + + // Second execution with identical QUERY request (Cache Hit) + final ClassicHttpRequest req2 = new BasicClassicHttpRequest("QUERY", "/stuff"); + req2.setEntity(new StringEntity("query-body-1", ContentType.TEXT_PLAIN)); + final ClassicHttpResponse result2 = execute(req2); + Assertions.assertEquals(200, result2.getCode()); + Assertions.assertEquals("response-body-1", EntityUtils.toString(result2.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_HIT, context.getCacheResponseStatus()); + + // Third execution with different QUERY request body (Cache Miss, goes to backend) + final ClassicHttpRequest req3 = new BasicClassicHttpRequest("QUERY", "/stuff"); + req3.setEntity(new StringEntity("query-body-2", ContentType.TEXT_PLAIN)); + + final ClassicHttpResponse resp3 = new BasicClassicHttpResponse(200, "OK"); + resp3.setHeader("Date", DateUtils.formatStandardDate(Instant.now())); + resp3.setHeader("Cache-Control", "max-age=3600"); + resp3.setHeader("Content-Type", "text/plain"); + resp3.setEntity(new StringEntity("response-body-2", ContentType.TEXT_PLAIN)); + + Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp3); + + final ClassicHttpResponse result3 = execute(req3); + Assertions.assertEquals(200, result3.getCode()); + Assertions.assertEquals("response-body-2", EntityUtils.toString(result3.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_MISS, context.getCacheResponseStatus()); + } + + @Test + void testQueryAndGetRequestsCachedSeparately() throws Exception { + // 1. GET request + final ClassicHttpRequest getReq = new BasicClassicHttpRequest("GET", "/stuff"); + final ClassicHttpResponse getResp = new BasicClassicHttpResponse(200, "OK"); + getResp.setHeader("Date", DateUtils.formatStandardDate(Instant.now())); + getResp.setHeader("Cache-Control", "max-age=3600"); + getResp.setHeader("Content-Type", "text/plain"); + getResp.setEntity(new StringEntity("get-response-content", ContentType.TEXT_PLAIN)); + + Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(getResp); + + // Execute GET (Cache Miss) + final ClassicHttpResponse getResult1 = execute(getReq); + Assertions.assertEquals("get-response-content", EntityUtils.toString(getResult1.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_MISS, context.getCacheResponseStatus()); + + // 2. QUERY request + final ClassicHttpRequest queryReq = new BasicClassicHttpRequest("QUERY", "/stuff"); + queryReq.setEntity(new StringEntity("query-body", ContentType.TEXT_PLAIN)); + + final ClassicHttpResponse queryResp = new BasicClassicHttpResponse(200, "OK"); + queryResp.setHeader("Date", DateUtils.formatStandardDate(Instant.now())); + queryResp.setHeader("Cache-Control", "max-age=3600"); + queryResp.setHeader("Content-Type", "text/plain"); + queryResp.setEntity(new StringEntity("query-response-content", ContentType.TEXT_PLAIN)); + + Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(queryResp); + + // Execute QUERY (Cache Miss) + final ClassicHttpResponse queryResult1 = execute(queryReq); + Assertions.assertEquals("query-response-content", EntityUtils.toString(queryResult1.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_MISS, context.getCacheResponseStatus()); + + // 3. Re-execute GET (should Cache Hit with GET content) + final ClassicHttpResponse getResult2 = execute(getReq); + Assertions.assertEquals("get-response-content", EntityUtils.toString(getResult2.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_HIT, context.getCacheResponseStatus()); + + // 4. Re-execute QUERY (should Cache Hit with QUERY content) + final ClassicHttpResponse queryResult2 = execute(queryReq); + Assertions.assertEquals("query-response-content", EntityUtils.toString(queryResult2.getEntity())); + Assertions.assertEquals(CacheResponseStatus.CACHE_HIT, context.getCacheResponseStatus()); + } + } + +