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
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 @@ -41,18 +43,25 @@
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 Down Expand Up @@ -174,9 +183,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 +216,56 @@ 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;
}
}

private static byte[] getRequestBodyBytes(final HttpRequest request) {
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;
}

private static 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 @@ -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 @@ -36,13 +36,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 +408,27 @@ 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

}


Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ public static Request options(final String uri) {
return new Request(new BasicClassicHttpRequest(Method.OPTIONS, uri));
}

/**
* @since 5.7
*/
public static Request query(final URI uri) {
return new Request(new BasicClassicHttpRequest(Method.QUERY, uri));
}

/**
* @since 5.7
*/
public static Request query(final String uri) {
return new Request(new BasicClassicHttpRequest(Method.QUERY, uri));
}

Request(final ClassicHttpRequest request) {
super();
this.request = request;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ static Stream<Arguments> data() {
Arguments.of("patch", "PATCH"),
Arguments.of("post", "POST"),
Arguments.of("put", "PUT"),
Arguments.of("query", "QUERY"),
Arguments.of("trace", "TRACE")
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ public static SimpleRequestBuilder delete(final String uri) {
return new SimpleRequestBuilder(Method.DELETE, uri);
}

public static SimpleRequestBuilder query() {
return new SimpleRequestBuilder(Method.QUERY);
}

public static SimpleRequestBuilder query(final URI uri) {
return new SimpleRequestBuilder(Method.QUERY, uri);
}

public static SimpleRequestBuilder query(final String uri) {
return new SimpleRequestBuilder(Method.QUERY, uri);
}

public static SimpleRequestBuilder trace() {
return new SimpleRequestBuilder(Method.TRACE);
}
Expand Down Expand Up @@ -374,7 +386,7 @@ public SimpleHttpRequest build() {
final List<NameValuePair> parameters = getParameters();
if (parameters != null && !parameters.isEmpty()) {
final Charset charsetCopy = getCharset();
if (bodyCopy == null && (Method.POST.isSame(method) || Method.PUT.isSame(method))) {
if (bodyCopy == null && (Method.POST.isSame(method) || Method.PUT.isSame(method) || Method.QUERY.isSame(method))) {
final String content = WWWFormCodec.format(
parameters,
charsetCopy != null ? charsetCopy : ContentType.APPLICATION_FORM_URLENCODED.getCharset());
Expand Down
Loading