Skip to content

Commit d0f39b4

Browse files
committed
Add QUERY method caching support
1 parent 06aac6d commit d0f39b4

10 files changed

Lines changed: 266 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Protocol conformance
6464
- [RFC 2817](https://datatracker.ietf.org/doc/html/rfc2817) - Upgrading to TLS Within HTTP/1.1
6565
- [RFC 9218](https://datatracker.ietf.org/doc/html/rfc9218) - Extensible Prioritization Scheme for HTTP
6666
- [RFC 7804](https://datatracker.ietf.org/doc/html/rfc7804) - Salted Challenge Response HTTP Authentication Mechanism
67+
- [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008) - The HTTP QUERY Method
6768

6869
Licensing
6970
---------

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import java.net.URI;
3030
import java.net.URISyntaxException;
3131
import java.nio.charset.StandardCharsets;
32+
import java.security.MessageDigest;
33+
import java.security.NoSuchAlgorithmException;
3234
import java.util.ArrayList;
3335
import java.util.Collection;
3436
import java.util.Iterator;
@@ -38,21 +40,29 @@
3840
import java.util.Spliterators;
3941
import java.util.concurrent.atomic.AtomicBoolean;
4042
import java.util.function.Consumer;
43+
import java.util.function.Function;
4144
import java.util.stream.StreamSupport;
4245

4346
import org.apache.hc.client5.http.cache.HttpCacheEntry;
44-
import org.apache.hc.core5.annotation.Contract;
45-
import org.apache.hc.core5.annotation.Internal;
46-
import org.apache.hc.core5.annotation.ThreadingBehavior;
47-
import org.apache.hc.core5.function.Resolver;
47+
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
48+
import org.apache.hc.core5.http.ClassicHttpRequest;
49+
import org.apache.hc.core5.http.ContentType;
50+
import org.apache.hc.core5.http.HttpEntity;
4851
import org.apache.hc.core5.http.Header;
4952
import org.apache.hc.core5.http.HeaderElement;
5053
import org.apache.hc.core5.http.HttpHeaders;
5154
import org.apache.hc.core5.http.HttpHost;
5255
import org.apache.hc.core5.http.HttpRequest;
56+
import org.apache.hc.core5.http.Method;
5357
import org.apache.hc.core5.http.MessageHeaders;
5458
import org.apache.hc.core5.http.NameValuePair;
5559
import org.apache.hc.core5.http.URIScheme;
60+
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
61+
import org.apache.hc.core5.http.io.entity.EntityUtils;
62+
import org.apache.hc.core5.annotation.Contract;
63+
import org.apache.hc.core5.annotation.Internal;
64+
import org.apache.hc.core5.annotation.ThreadingBehavior;
65+
import org.apache.hc.core5.function.Resolver;
5666
import org.apache.hc.core5.http.message.BasicHeaderElementIterator;
5767
import org.apache.hc.core5.http.message.BasicHeaderValueFormatter;
5868
import org.apache.hc.core5.http.message.BasicNameValuePair;
@@ -75,6 +85,16 @@ public class CacheKeyGenerator implements Resolver<URI, String> {
7585
*/
7686
public static final CacheKeyGenerator INSTANCE = new CacheKeyGenerator();
7787

88+
private final Function<HttpRequest, byte[]> bodyBytesExtractor;
89+
90+
public CacheKeyGenerator() {
91+
this(null);
92+
}
93+
94+
public CacheKeyGenerator(final Function<HttpRequest, byte[]> bodyBytesExtractor) {
95+
this.bodyBytesExtractor = bodyBytesExtractor;
96+
}
97+
7898
@Override
7999
public String resolve(final URI uri) {
80100
return generateKey(uri);
@@ -174,9 +194,24 @@ public static URI normalize(final String requestUri) {
174194
* @return cache key
175195
*/
176196
public String generateKey(final URI requestUri) {
197+
return generateKey(requestUri, null);
198+
}
199+
200+
/**
201+
* Computes a key for the given request {@link URI} and body hash that can
202+
* be used as a unique identifier for cached resources. The URI is
203+
* expected to be in an absolute form.
204+
*
205+
* @param requestUri request URI
206+
* @param bodyHash hash of the request body
207+
* @return cache key
208+
*/
209+
public String generateKey(final URI requestUri, final String bodyHash) {
177210
try {
178211
final URI normalizeRequestUri = normalize(requestUri);
179-
return normalizeRequestUri.toASCIIString();
212+
final String uriString = normalizeRequestUri.toASCIIString();
213+
// Using a Record Separator to make it easy to spot
214+
return bodyHash == null ? uriString : uriString + '\u001e' + bodyHash;
180215
} catch (final URISyntaxException ex) {
181216
return requestUri.toASCIIString();
182217
}
@@ -192,13 +227,62 @@ public String generateKey(final URI requestUri) {
192227
*/
193228
public String generateKey(final HttpHost host, final HttpRequest request) {
194229
final String s = getRequestUri(host, request);
230+
final String hash = Method.QUERY.isSame(request.getMethod()) ? getBodyHash(request) : null;
195231
try {
196-
return generateKey(new URI(s));
232+
return generateKey(new URI(s), hash);
197233
} catch (final URISyntaxException ex) {
198234
return s;
199235
}
200236
}
201237

238+
protected byte[] getRequestBodyBytes(final HttpRequest request) {
239+
if (bodyBytesExtractor != null) {
240+
final byte[] bytes = bodyBytesExtractor.apply(request);
241+
if (bytes != null) {
242+
return bytes;
243+
}
244+
}
245+
if (request instanceof SimpleHttpRequest) {
246+
return ((SimpleHttpRequest) request).getBodyBytes();
247+
}
248+
if (request instanceof ClassicHttpRequest) {
249+
final HttpEntity entity = ((ClassicHttpRequest) request).getEntity();
250+
if (entity != null) {
251+
try {
252+
if (entity.isRepeatable()) {
253+
final byte[] bytes = EntityUtils.toByteArray(entity);
254+
((ClassicHttpRequest) request).setEntity(new ByteArrayEntity(bytes, ContentType.parse(entity.getContentType())));
255+
return bytes;
256+
}
257+
} catch (final Exception ignore) {
258+
}
259+
}
260+
}
261+
return null;
262+
}
263+
264+
protected String getBodyHash(final HttpRequest request) {
265+
final byte[] bodyBytes = getRequestBodyBytes(request);
266+
if (bodyBytes == null || bodyBytes.length == 0) {
267+
return "";
268+
}
269+
try {
270+
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
271+
final byte[] hashBytes = digest.digest(bodyBytes);
272+
final StringBuilder hexString = new StringBuilder();
273+
for (final byte b : hashBytes) {
274+
final String hex = Integer.toHexString(0xff & b);
275+
if (hex.length() == 1) {
276+
hexString.append('0');
277+
}
278+
hexString.append(hex);
279+
}
280+
return hexString.toString();
281+
} catch (final NoSuchAlgorithmException e) {
282+
return "";
283+
}
284+
}
285+
202286
/**
203287
* Returns all variant names contained in {@literal VARY} headers of the given message.
204288
*

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public boolean canBeServedFromCache(final RequestCacheControl cacheControl, fina
5757
return false;
5858
}
5959

60-
if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method)) {
60+
if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method) && !Method.QUERY.isSame(method)) {
6161
if (LOG.isDebugEnabled()) {
6262
LOG.debug("{} request cannot be served from cache", method);
6363
}

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachedHttpResponseGenerator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ private void generateContentLength(final HttpResponse response, final byte[] bod
156156
}
157157

158158
private boolean responseShouldContainEntity(final HttpRequest request, final HttpCacheEntry cacheEntry) {
159-
return Method.GET.isSame(request.getMethod()) && cacheEntry.getResource() != null;
159+
return (Method.GET.isSame(request.getMethod()) || Method.QUERY.isSame(request.getMethod())) && cacheEntry.getResource() != null;
160160
}
161161

162162
}

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingH2AsyncClientBuilder.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ public class CachingH2AsyncClientBuilder extends H2AsyncClientBuilder {
5656
private SchedulingStrategy schedulingStrategy;
5757
private CacheConfig cacheConfig;
5858
private boolean deleteCache;
59+
private CacheKeyGenerator cacheKeyGenerator;
5960

6061
public static CachingH2AsyncClientBuilder create() {
6162
return new CachingH2AsyncClientBuilder();
@@ -112,6 +113,11 @@ public CachingH2AsyncClientBuilder setDeleteCache(final boolean deleteCache) {
112113
return this;
113114
}
114115

116+
public final CachingH2AsyncClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) {
117+
this.cacheKeyGenerator = cacheKeyGenerator;
118+
return this;
119+
}
120+
115121
@Override
116122
protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler> execChainDefinition) {
117123
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
@@ -142,7 +148,7 @@ protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler>
142148
resourceFactoryCopy,
143149
HttpCacheEntryFactory.INSTANCE,
144150
storageCopy,
145-
CacheKeyGenerator.INSTANCE);
151+
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);
146152

147153
DefaultAsyncCacheRevalidator cacheRevalidator = null;
148154
if (config.getAsynchronousWorkers() > 0) {

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpAsyncClientBuilder.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public class CachingHttpAsyncClientBuilder extends HttpAsyncClientBuilder {
6060
private SchedulingStrategy schedulingStrategy;
6161
private CacheConfig cacheConfig;
6262
private boolean deleteCache;
63+
private CacheKeyGenerator cacheKeyGenerator;
6364

6465
public static CachingHttpAsyncClientBuilder create() {
6566
return new CachingHttpAsyncClientBuilder();
@@ -116,6 +117,11 @@ public CachingHttpAsyncClientBuilder setDeleteCache(final boolean deleteCache) {
116117
return this;
117118
}
118119

120+
public final CachingHttpAsyncClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) {
121+
this.cacheKeyGenerator = cacheKeyGenerator;
122+
return this;
123+
}
124+
119125
@Override
120126
protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler> execChainDefinition) {
121127
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
@@ -146,7 +152,7 @@ protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler>
146152
resourceFactoryCopy,
147153
HttpCacheEntryFactory.INSTANCE,
148154
storageCopy,
149-
CacheKeyGenerator.INSTANCE);
155+
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);
150156

151157
DefaultAsyncCacheRevalidator cacheRevalidator = null;
152158
if (config.getAsynchronousWorkers() > 0) {

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingHttpClientBuilder.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public class CachingHttpClientBuilder extends HttpClientBuilder {
5858
private SchedulingStrategy schedulingStrategy;
5959
private CacheConfig cacheConfig;
6060
private boolean deleteCache;
61+
private CacheKeyGenerator cacheKeyGenerator;
6162

6263
public static CachingHttpClientBuilder create() {
6364
return new CachingHttpClientBuilder();
@@ -110,6 +111,11 @@ public final CachingHttpClientBuilder setDeleteCache(final boolean deleteCache)
110111
return this;
111112
}
112113

114+
public final CachingHttpClientBuilder setCacheKeyGenerator(final CacheKeyGenerator cacheKeyGenerator) {
115+
this.cacheKeyGenerator = cacheKeyGenerator;
116+
return this;
117+
}
118+
113119
@Override
114120
protected void customizeExecChain(final NamedElementChain<ExecChainHandler> execChainDefinition) {
115121
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
@@ -140,7 +146,7 @@ protected void customizeExecChain(final NamedElementChain<ExecChainHandler> exec
140146
resourceFactoryCopy,
141147
HttpCacheEntryFactory.INSTANCE,
142148
storageCopy,
143-
CacheKeyGenerator.INSTANCE);
149+
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);
144150

145151
DefaultCacheRevalidator cacheRevalidator = null;
146152
if (config.getAsynchronousWorkers() > 0) {

httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ public boolean isResponseCacheable(final ResponseCacheControl cacheControl, fina
9898
return false;
9999
}
100100

101-
// Presently only GET and HEAD methods are supported
101+
// Presently only GET, HEAD and QUERY methods are supported
102102
final String httpMethod = request.getMethod();
103-
if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod)) {
103+
if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod) && !Method.QUERY.isSame(httpMethod)) {
104104
if (LOG.isDebugEnabled()) {
105105
LOG.debug("{} method response is not cacheable", httpMethod);
106106
}

httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import java.net.URI;
3030
import java.net.URISyntaxException;
31+
import java.nio.charset.StandardCharsets;
3132
import java.util.ArrayList;
3233
import java.util.Arrays;
3334
import java.util.Collections;
@@ -36,13 +37,17 @@
3637

3738
import org.apache.hc.client5.http.cache.HttpCacheEntry;
3839
import org.apache.hc.client5.http.classic.methods.HttpGet;
40+
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
3941
import org.apache.hc.core5.http.Header;
4042
import org.apache.hc.core5.http.HttpHeaders;
4143
import org.apache.hc.core5.http.HttpHost;
4244
import org.apache.hc.core5.http.HttpRequest;
45+
import org.apache.hc.core5.http.ContentType;
46+
import org.apache.hc.core5.http.io.entity.StringEntity;
4347
import org.apache.hc.core5.http.message.BasicHeader;
4448
import org.apache.hc.core5.http.message.BasicHeaderIterator;
4549
import org.apache.hc.core5.http.message.BasicHttpRequest;
50+
import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
4651
import org.apache.hc.core5.http.support.BasicRequestBuilder;
4752
import org.junit.jupiter.api.Assertions;
4853
import org.junit.jupiter.api.BeforeEach;
@@ -404,4 +409,56 @@ void testGetVariantKeyFromCachedResponse() {
404409
Assertions.assertEquals("{accept-encoding=text%2Fplain&user-agent=agent1}", extractor.generateVariantKey(request, entry2));
405410
}
406411

412+
@Test
413+
void testQueryKeyGenerationWithBody() throws Exception {
414+
final HttpHost host = new HttpHost("http", "www.example.com", -1);
415+
416+
// SimpleHttpRequest with body
417+
final SimpleHttpRequest req1 = SimpleHttpRequest.create("QUERY", "/stuff");
418+
req1.setBody("my-query-1", ContentType.TEXT_PLAIN);
419+
final String key1 = extractor.generateKey(host, req1);
420+
421+
final SimpleHttpRequest req2 = SimpleHttpRequest.create("QUERY", "/stuff");
422+
req2.setBody("my-query-2", ContentType.TEXT_PLAIN);
423+
final String key2 = extractor.generateKey(host, req2);
424+
425+
Assertions.assertNotEquals(key1, key2);
426+
Assertions.assertTrue(key1.contains("\u001e"));
427+
428+
// ClassicHttpRequest with body
429+
final BasicClassicHttpRequest req3 = new BasicClassicHttpRequest("QUERY", "/stuff");
430+
req3.setEntity(new StringEntity("my-query-1", ContentType.TEXT_PLAIN));
431+
final String key3 = extractor.generateKey(host, req3);
432+
Assertions.assertEquals(key1, key3);
433+
}
434+
435+
@Test
436+
void testQueryKeyGenerationWithCustomSubclass() throws Exception {
437+
final HttpHost host = new HttpHost("http", "www.example.com", -1);
438+
final CacheKeyGenerator customGenerator = new CacheKeyGenerator() {
439+
@Override
440+
protected byte[] getRequestBodyBytes(final HttpRequest request) {
441+
return "custom-subclass-body".getBytes(StandardCharsets.UTF_8);
442+
}
443+
};
444+
445+
final SimpleHttpRequest req = SimpleHttpRequest.create("QUERY", "/stuff");
446+
final String key = customGenerator.generateKey(host, req);
447+
Assertions.assertTrue(key.contains("\u001e"));
448+
Assertions.assertTrue(key.endsWith("01db3f2aa79e0ed79fa787efe21aae8708f2f395082bb420537cb4a1fbc04d6d"));
449+
}
450+
451+
@Test
452+
void testQueryKeyGenerationWithCustomExtractor() throws Exception {
453+
final HttpHost host = new HttpHost("http", "www.example.com", -1);
454+
final CacheKeyGenerator customGenerator = new CacheKeyGenerator(
455+
request -> "custom-extractor-body".getBytes(StandardCharsets.UTF_8)
456+
);
457+
458+
final SimpleHttpRequest req = SimpleHttpRequest.create("QUERY", "/stuff");
459+
final String key = customGenerator.generateKey(host, req);
460+
Assertions.assertTrue(key.contains("\u001e"));
461+
Assertions.assertTrue(key.endsWith("1277c424fd056514377d8acd78e602891cee40812bef763577c06b0428c4a817"));
462+
}
463+
407464
}

0 commit comments

Comments
 (0)