diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d4bb75fe..51fad7326f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### Version 13.14 + +* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a + request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and + incorporates a body hash into the cache key to reduce cross-body collisions. + ### Version 13.12 * `UrlencodedFormContentProcessor` now honors `CollectionFormat` from `@RequestLine`/`RequestTemplate` for array and diff --git a/core/src/main/java/feign/Request.java b/core/src/main/java/feign/Request.java index a3627a164d..f9645774fb 100644 --- a/core/src/main/java/feign/Request.java +++ b/core/src/main/java/feign/Request.java @@ -44,7 +44,8 @@ public enum HttpMethod { CONNECT, OPTIONS, TRACE, - PATCH(true); + PATCH(true), + QUERY(true); private final boolean withBody; diff --git a/core/src/main/java/feign/RequestLine.java b/core/src/main/java/feign/RequestLine.java index 626c618757..4fc9178372 100644 --- a/core/src/main/java/feign/RequestLine.java +++ b/core/src/main/java/feign/RequestLine.java @@ -35,9 +35,10 @@ * *
The string must begin with a valid {@linkplain feign.Request.HttpMethod HTTP method name} * (e.g. {@linkplain feign.Request.HttpMethod#GET GET}, {@linkplain feign.Request.HttpMethod#POST - * POST}, {@linkplain feign.Request.HttpMethod#PUT PUT}), followed by a space and a URI template. - * If only the HTTP method is specified (e.g. {@code "DELETE"}), the request will use the base URL - * defined for the client. + * POST}, {@linkplain feign.Request.HttpMethod#PUT PUT}, {@linkplain + * feign.Request.HttpMethod#QUERY QUERY}), followed by a space and a URI template. If only the + * HTTP method is specified (e.g. {@code "DELETE"}), the request will use the base URL defined for + * the client. * *
Example: * diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java index 6b26d1ed0e..4cbe306920 100644 --- a/core/src/test/java/feign/DefaultContractTest.java +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -59,6 +59,8 @@ void httpMethods() throws Exception { assertThat(parseAndValidateMetadata(Methods.class, "get").template()).hasMethod("GET"); assertThat(parseAndValidateMetadata(Methods.class, "delete").template()).hasMethod("DELETE"); + + assertThat(parseAndValidateMetadata(Methods.class, "query").template()).hasMethod("QUERY"); } @Test @@ -422,6 +424,9 @@ interface Methods { @RequestLine("DELETE /") void delete(); + + @RequestLine("QUERY /") + void query(); } interface BodyParams { diff --git a/core/src/test/java/feign/client/AbstractClientTest.java b/core/src/test/java/feign/client/AbstractClientTest.java index bb01525c99..b37fd7d7ff 100644 --- a/core/src/test/java/feign/client/AbstractClientTest.java +++ b/core/src/test/java/feign/client/AbstractClientTest.java @@ -243,6 +243,28 @@ public void noResponseBodyForPatch() { api.noPatchBody(); } + /** + * Some client implementation tests should override this test if the QUERY operation is + * unsupported. + */ + @Test + public void query() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + server.enqueue(new MockResponse()); + + TestInterface api = + newBuilder().target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertThat(api.query("body")).isEqualTo("foo"); + + MockWebServerAssertions.assertThat(server.takeRequest()) + .hasHeaders( + entry("Accept", Collections.singletonList("text/plain")), + entry("Content-Type", Collections.singletonList("application/json")), + entry("Content-Length", Collections.singletonList("4"))) + .hasMethod("QUERY"); + } + @Test public void parsesResponseMissingLength() throws IOException { server.enqueue(new MockResponse().setChunkedBody("foo", 1)); @@ -583,6 +605,10 @@ public interface TestInterface { @Headers("Accept: text/plain") String patch(String body); + @RequestLine("QUERY /") + @Headers({"Accept: text/plain", "Content-Type: application/json"}) + String query(String body); + @RequestLine("POST") String noPostBody(); diff --git a/core/src/test/java/feign/client/DefaultClientTest.java b/core/src/test/java/feign/client/DefaultClientTest.java index c61b1b0a4d..c7933bdb09 100644 --- a/core/src/test/java/feign/client/DefaultClientTest.java +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -128,6 +128,18 @@ public void noResponseBodyForPatch() { assertThat(exception).hasCauseInstanceOf(ProtocolException.class); } + /** + * {@link java.net.HttpURLConnection} does not support the QUERY method. For now, prefer okhttp. + * + * @see java.net.HttpURLConnection#setRequestMethod + */ + @Test + @Override + public void query() throws Exception { + RetryableException exception = assertThrows(RetryableException.class, super::query); + assertThat(exception).hasCauseInstanceOf(ProtocolException.class); + } + @Test void canOverrideHostnameVerifier() throws IOException, InterruptedException { server.useHttps(TrustingSSLSocketFactory.get("bad.example.com"), false); diff --git a/googlehttpclient/src/test/java/feign/googlehttpclient/GoogleHttpClientTest.java b/googlehttpclient/src/test/java/feign/googlehttpclient/GoogleHttpClientTest.java index 4f0702f2ad..6ee3d4c25f 100644 --- a/googlehttpclient/src/test/java/feign/googlehttpclient/GoogleHttpClientTest.java +++ b/googlehttpclient/src/test/java/feign/googlehttpclient/GoogleHttpClientTest.java @@ -44,6 +44,10 @@ public void noResponseBodyForPatch() {} @Override public void patch() {} + // NetHttpTransport is backed by HttpURLConnection, which does not support QUERY. + @Override + public void query() throws Exception {} + @Override public void parsesUnauthorizedResponseBody() {} diff --git a/http-cache/src/main/java/feign/cache/HttpCacheInterceptor.java b/http-cache/src/main/java/feign/cache/HttpCacheInterceptor.java index f95d66d95b..be9e2cfb9b 100644 --- a/http-cache/src/main/java/feign/cache/HttpCacheInterceptor.java +++ b/http-cache/src/main/java/feign/cache/HttpCacheInterceptor.java @@ -23,6 +23,7 @@ import feign.interceptor.Invocation; import feign.interceptor.MethodInterceptor; import java.time.Instant; +import java.util.Arrays; import java.util.Collection; import java.util.Map; import java.util.function.Function; @@ -36,7 +37,8 @@ *
Successful responses (2xx) carrying an {@code ETag} or {@code Last-Modified} header are * stored. Responses with {@code Cache-Control: no-store} are skipped. * - *
Default scope is HTTP {@code GET} and {@code HEAD}; override via {@link #cacheable(Function)}. + *
Default scope is HTTP {@code GET}, {@code HEAD}, and {@code QUERY}; override via {@link + * #cacheable(Function)}. * *
Important: 304 detection relies on the configured {@link feign.codec.ErrorDecoder}
* raising a {@link FeignException} for non-2xx responses (the default behaviour). If a custom error
@@ -129,12 +131,17 @@ private void maybeStore(String key, Object result, Response response) {
private static String defaultKey(Invocation invocation) {
RequestTemplate template = invocation.requestTemplate();
- return invocation.methodMetadata().configKey() + "|" + template.method() + " " + template.url();
+ String base =
+ invocation.methodMetadata().configKey() + "|" + template.method() + " " + template.url();
+ byte[] body = template.body();
+ return body != null ? base + "|" + Arrays.hashCode(body) : base;
}
private static Boolean defaultCacheable(RequestTemplate template) {
String method = template.method();
- return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method);
+ return "GET".equalsIgnoreCase(method)
+ || "HEAD".equalsIgnoreCase(method)
+ || "QUERY".equalsIgnoreCase(method);
}
private static boolean containsNoStore(Map