Skip to content

Commit ffd8667

Browse files
authored
Merge pull request #3454 from kumarprabhashanand/implement-http-query-verb
Add HTTP QUERY method support (RFC 10008)
2 parents e5a6a54 + b7d373e commit ffd8667

12 files changed

Lines changed: 153 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### Version 13.14
2+
3+
* Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a
4+
request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and
5+
incorporates a body hash into the cache key to reduce cross-body collisions.
6+
17
### Version 13.12
28

39
* `UrlencodedFormContentProcessor` now honors `CollectionFormat` from `@RequestLine`/`RequestTemplate` for array and

core/src/main/java/feign/Request.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ public enum HttpMethod {
4444
CONNECT,
4545
OPTIONS,
4646
TRACE,
47-
PATCH(true);
47+
PATCH(true),
48+
QUERY(true);
4849

4950
private final boolean withBody;
5051

core/src/main/java/feign/RequestLine.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@
3535
*
3636
* <p>The string must begin with a valid {@linkplain feign.Request.HttpMethod HTTP method name}
3737
* (e.g. {@linkplain feign.Request.HttpMethod#GET GET}, {@linkplain feign.Request.HttpMethod#POST
38-
* POST}, {@linkplain feign.Request.HttpMethod#PUT PUT}), followed by a space and a URI template.
39-
* If only the HTTP method is specified (e.g. {@code "DELETE"}), the request will use the base URL
40-
* defined for the client.
38+
* POST}, {@linkplain feign.Request.HttpMethod#PUT PUT}, {@linkplain
39+
* feign.Request.HttpMethod#QUERY QUERY}), followed by a space and a URI template. If only the
40+
* HTTP method is specified (e.g. {@code "DELETE"}), the request will use the base URL defined for
41+
* the client.
4142
*
4243
* <p>Example:
4344
*

core/src/test/java/feign/DefaultContractTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ void httpMethods() throws Exception {
5959
assertThat(parseAndValidateMetadata(Methods.class, "get").template()).hasMethod("GET");
6060

6161
assertThat(parseAndValidateMetadata(Methods.class, "delete").template()).hasMethod("DELETE");
62+
63+
assertThat(parseAndValidateMetadata(Methods.class, "query").template()).hasMethod("QUERY");
6264
}
6365

6466
@Test
@@ -422,6 +424,9 @@ interface Methods {
422424

423425
@RequestLine("DELETE /")
424426
void delete();
427+
428+
@RequestLine("QUERY /")
429+
void query();
425430
}
426431

427432
interface BodyParams {

core/src/test/java/feign/client/AbstractClientTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,28 @@ public void noResponseBodyForPatch() {
243243
api.noPatchBody();
244244
}
245245

246+
/**
247+
* Some client implementation tests should override this test if the QUERY operation is
248+
* unsupported.
249+
*/
250+
@Test
251+
public void query() throws Exception {
252+
server.enqueue(new MockResponse().setBody("foo"));
253+
server.enqueue(new MockResponse());
254+
255+
TestInterface api =
256+
newBuilder().target(TestInterface.class, "http://localhost:" + server.getPort());
257+
258+
assertThat(api.query("body")).isEqualTo("foo");
259+
260+
MockWebServerAssertions.assertThat(server.takeRequest())
261+
.hasHeaders(
262+
entry("Accept", Collections.singletonList("text/plain")),
263+
entry("Content-Type", Collections.singletonList("application/json")),
264+
entry("Content-Length", Collections.singletonList("4")))
265+
.hasMethod("QUERY");
266+
}
267+
246268
@Test
247269
public void parsesResponseMissingLength() throws IOException {
248270
server.enqueue(new MockResponse().setChunkedBody("foo", 1));
@@ -583,6 +605,10 @@ public interface TestInterface {
583605
@Headers("Accept: text/plain")
584606
String patch(String body);
585607

608+
@RequestLine("QUERY /")
609+
@Headers({"Accept: text/plain", "Content-Type: application/json"})
610+
String query(String body);
611+
586612
@RequestLine("POST")
587613
String noPostBody();
588614

core/src/test/java/feign/client/DefaultClientTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,18 @@ public void noResponseBodyForPatch() {
146146
assertThat(exception).hasCauseInstanceOf(ProtocolException.class);
147147
}
148148

149+
/**
150+
* {@link java.net.HttpURLConnection} does not support the QUERY method. For now, prefer okhttp.
151+
*
152+
* @see java.net.HttpURLConnection#setRequestMethod
153+
*/
154+
@Test
155+
@Override
156+
public void query() throws Exception {
157+
RetryableException exception = assertThrows(RetryableException.class, super::query);
158+
assertThat(exception).hasCauseInstanceOf(ProtocolException.class);
159+
}
160+
149161
@Test
150162
void canOverrideHostnameVerifier() throws IOException, InterruptedException {
151163
server.useHttps(TrustingSSLSocketFactory.get("bad.example.com"), false);

googlehttpclient/src/test/java/feign/googlehttpclient/GoogleHttpClientTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public void noResponseBodyForPatch() {}
4444
@Override
4545
public void patch() {}
4646

47+
// NetHttpTransport is backed by HttpURLConnection, which does not support QUERY.
48+
@Override
49+
public void query() throws Exception {}
50+
4751
@Override
4852
public void parsesUnauthorizedResponseBody() {}
4953

http-cache/src/main/java/feign/cache/HttpCacheInterceptor.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import feign.interceptor.Invocation;
2424
import feign.interceptor.MethodInterceptor;
2525
import java.time.Instant;
26+
import java.util.Arrays;
2627
import java.util.Collection;
2728
import java.util.Map;
2829
import java.util.function.Function;
@@ -36,7 +37,8 @@
3637
* <p>Successful responses (2xx) carrying an {@code ETag} or {@code Last-Modified} header are
3738
* stored. Responses with {@code Cache-Control: no-store} are skipped.
3839
*
39-
* <p>Default scope is HTTP {@code GET} and {@code HEAD}; override via {@link #cacheable(Function)}.
40+
* <p>Default scope is HTTP {@code GET}, {@code HEAD}, and {@code QUERY}; override via {@link
41+
* #cacheable(Function)}.
4042
*
4143
* <p><b>Important:</b> 304 detection relies on the configured {@link feign.codec.ErrorDecoder}
4244
* 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) {
129131

130132
private static String defaultKey(Invocation invocation) {
131133
RequestTemplate template = invocation.requestTemplate();
132-
return invocation.methodMetadata().configKey() + "|" + template.method() + " " + template.url();
134+
String base =
135+
invocation.methodMetadata().configKey() + "|" + template.method() + " " + template.url();
136+
byte[] body = template.body();
137+
return body != null ? base + "|" + Arrays.hashCode(body) : base;
133138
}
134139

135140
private static Boolean defaultCacheable(RequestTemplate template) {
136141
String method = template.method();
137-
return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method);
142+
return "GET".equalsIgnoreCase(method)
143+
|| "HEAD".equalsIgnoreCase(method)
144+
|| "QUERY".equalsIgnoreCase(method);
138145
}
139146

140147
private static boolean containsNoStore(Map<String, Collection<String>> headers) {

http-cache/src/test/java/feign/cache/HttpCacheInterceptorTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@
1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2020

21+
import feign.Client;
2122
import feign.Feign;
2223
import feign.FeignException;
2324
import feign.Param;
2425
import feign.RequestLine;
26+
import feign.Response;
27+
import feign.Util;
28+
import java.util.Collection;
29+
import java.util.Collections;
30+
import java.util.HashMap;
31+
import java.util.Map;
32+
import java.util.concurrent.atomic.AtomicInteger;
2533
import okhttp3.mockwebserver.MockResponse;
2634
import okhttp3.mockwebserver.MockWebServer;
2735
import okhttp3.mockwebserver.RecordedRequest;
@@ -44,6 +52,9 @@ interface Api {
4452

4553
@RequestLine("POST /things")
4654
String create(String body);
55+
56+
@RequestLine("QUERY /things")
57+
String query(String body);
4758
}
4859

4960
private Api api() {
@@ -120,6 +131,47 @@ void postRequestsBypassCache() throws Exception {
120131
assertThat(recorded.getHeader("If-None-Match")).isNull();
121132
}
122133

134+
@Test
135+
void queryRequestsParticipateInCache() {
136+
AtomicInteger calls = new AtomicInteger();
137+
Client client =
138+
(request, options) -> {
139+
calls.incrementAndGet();
140+
if (request.headers().containsKey("If-None-Match")) {
141+
return Response.builder()
142+
.status(304)
143+
.headers(Collections.emptyMap())
144+
.request(request)
145+
.build();
146+
}
147+
String body = request.body() != null ? new String(request.body(), Util.UTF_8) : "";
148+
Map<String, Collection<String>> headers = new HashMap<>();
149+
headers.put("ETag", Collections.singletonList("\"" + body + "\""));
150+
return Response.builder()
151+
.status(200)
152+
.headers(headers)
153+
.body("result-" + body, Util.UTF_8)
154+
.request(request)
155+
.build();
156+
};
157+
158+
Api api =
159+
Feign.builder()
160+
.client(client)
161+
.methodInterceptor(new HttpCacheInterceptor(store))
162+
.target(Api.class, "http://localhost:0");
163+
164+
String first = api.query("q1");
165+
String second = api.query("q2");
166+
String third = api.query("q1");
167+
168+
assertThat(first).isEqualTo("result-q1");
169+
assertThat(second).isEqualTo("result-q2");
170+
assertThat(third).isEqualTo("result-q1");
171+
assertThat(calls.get()).isEqualTo(3);
172+
assertThat(store.size()).isEqualTo(2);
173+
}
174+
123175
@Test
124176
void noStoreCacheControlPreventsStorage() throws Exception {
125177
server.enqueue(

jaxrs2/src/test/java/feign/jaxrs2/AbstractJAXRSClientTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ public void patch() throws Exception {
4444
}
4545
}
4646

47+
@Override
48+
public void query() throws Exception {
49+
try {
50+
super.query();
51+
} catch (final RuntimeException _) {
52+
Assumptions.assumeFalse(false, "JaxRS client do not support QUERY requests");
53+
}
54+
}
55+
4756
@Override
4857
public void noResponseBodyForPut() throws Exception {
4958
try {

0 commit comments

Comments
 (0)