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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -75,6 +85,16 @@ public class CacheKeyGenerator implements Resolver<URI, String> {
*/
public static final CacheKeyGenerator INSTANCE = new CacheKeyGenerator();

private final Function<HttpRequest, byte[]> bodyBytesExtractor;

public CacheKeyGenerator() {
this(null);
}

public CacheKeyGenerator(final Function<HttpRequest, byte[]> bodyBytesExtractor) {
this.bodyBytesExtractor = bodyBytesExtractor;
}

@Override
public String resolve(final URI uri) {
return generateKey(uri);
Expand Down Expand Up @@ -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();
}
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<AsyncExecChainHandler> execChainDefinition) {
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
Expand Down Expand Up @@ -142,7 +148,7 @@ protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler>
resourceFactoryCopy,
HttpCacheEntryFactory.INSTANCE,
storageCopy,
CacheKeyGenerator.INSTANCE);
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);

DefaultAsyncCacheRevalidator cacheRevalidator = null;
if (config.getAsynchronousWorkers() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<AsyncExecChainHandler> execChainDefinition) {
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
Expand Down Expand Up @@ -146,7 +152,7 @@ protected void customizeExecChain(final NamedElementChain<AsyncExecChainHandler>
resourceFactoryCopy,
HttpCacheEntryFactory.INSTANCE,
storageCopy,
CacheKeyGenerator.INSTANCE);
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);

DefaultAsyncCacheRevalidator cacheRevalidator = null;
if (config.getAsynchronousWorkers() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<ExecChainHandler> execChainDefinition) {
final CacheConfig config = this.cacheConfig != null ? this.cacheConfig : CacheConfig.DEFAULT;
Expand Down Expand Up @@ -140,7 +146,7 @@ protected void customizeExecChain(final NamedElementChain<ExecChainHandler> exec
resourceFactoryCopy,
HttpCacheEntryFactory.INSTANCE,
storageCopy,
CacheKeyGenerator.INSTANCE);
this.cacheKeyGenerator != null ? this.cacheKeyGenerator : CacheKeyGenerator.INSTANCE);

DefaultCacheRevalidator cacheRevalidator = null;
if (config.getAsynchronousWorkers() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"));
}

}
Loading
Loading