diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java index 5915fa4670..2c4d840dce 100644 --- a/core/src/main/java/feign/Feign.java +++ b/core/src/main/java/feign/Feign.java @@ -13,6 +13,7 @@ */ package feign; +import static feign.ExceptionPropagationPolicy.NONE; import java.io.IOException; import java.lang.reflect.Method; import java.lang.reflect.Type; @@ -26,7 +27,6 @@ import feign.codec.Encoder; import feign.codec.ErrorDecoder; import feign.querymap.FieldQueryMapEncoder; -import static feign.ExceptionPropagationPolicy.NONE; /** * Feign's purpose is to ease development against http apis that feign restfulness.
@@ -66,7 +66,7 @@ public static Builder builder() { * @see MethodMetadata#configKey() */ public static String configKey(Class targetType, Method method) { - StringBuilder builder = new StringBuilder(); + final StringBuilder builder = new StringBuilder(); builder.append(targetType.getSimpleName()); builder.append('#').append(method.getName()).append('('); for (Type param : method.getGenericParameterTypes()) { @@ -208,7 +208,7 @@ public Builder requestInterceptor(RequestInterceptor requestInterceptor) { */ public Builder requestInterceptors(Iterable requestInterceptors) { this.requestInterceptors.clear(); - for (RequestInterceptor requestInterceptor : requestInterceptors) { + for (final RequestInterceptor requestInterceptor : requestInterceptors) { this.requestInterceptors.add(requestInterceptor); } return this; @@ -262,22 +262,22 @@ public T target(Target target) { } public Feign build() { - SynchronousMethodHandler.Factory synchronousMethodHandlerFactory = + final SynchronousMethodHandler.Factory synchronousMethodHandlerFactory = new SynchronousMethodHandler.Factory(client, retryer, requestInterceptors, logger, logLevel, decode404, closeAfterDecode, propagationPolicy, forceDecoding); - ParseHandlersByName handlersByName = + final ParseHandlersByName handlersByName = new ParseHandlersByName(contract, options, encoder, decoder, queryMapEncoder, errorDecoder, synchronousMethodHandlerFactory); return new ReflectiveFeign(handlersByName, invocationHandlerFactory, queryMapEncoder); } } - static class ResponseMappingDecoder implements Decoder { + public static class ResponseMappingDecoder implements Decoder { private final ResponseMapper mapper; private final Decoder delegate; - ResponseMappingDecoder(ResponseMapper mapper, Decoder decoder) { + public ResponseMappingDecoder(ResponseMapper mapper, Decoder decoder) { this.mapper = mapper; this.delegate = decoder; } diff --git a/hc5/pom.xml b/hc5/pom.xml index f6e0d6ee8b..b7a267b688 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -40,7 +40,7 @@ org.apache.httpcomponents.client5 httpclient5 - 5.0-beta5 + 5.0-beta7 @@ -56,6 +56,12 @@ test + + com.google.code.gson + gson + test + + com.squareup.okhttp3 mockwebserver diff --git a/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java b/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java new file mode 100644 index 0000000000..f2e82bd672 --- /dev/null +++ b/hc5/src/main/java/feign/hc5/AsyncApacheHttp5Client.java @@ -0,0 +1,183 @@ +/** + * Copyright 2012-2020 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.hc5; + +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.config.Configurable; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import feign.*; +import feign.Request.Options; + +/** + * This module directs Feign's http requests to Apache's + * HttpClient 5. Ex. + * + *
+ * GitHub github = Feign.builder().client(new ApacheHttp5Client()).target(GitHub.class,
+ * "https://api.github.com");
+ */
+/*
+ */
+public final class AsyncApacheHttp5Client implements AsyncClient {
+
+  private static final String ACCEPT_HEADER_NAME = "Accept";
+
+  private final CloseableHttpAsyncClient client;
+
+  public AsyncApacheHttp5Client() {
+    this(HttpAsyncClients.custom().build());
+  }
+
+  public AsyncApacheHttp5Client(CloseableHttpAsyncClient client) {
+    this.client = client;
+    client.start();
+  }
+
+  @Override
+  public CompletableFuture execute(Request request,
+                                             Options options,
+                                             Optional requestContext) {
+    final SimpleHttpRequest httpUriRequest = toClassicHttpRequest(request, options);
+
+    final CompletableFuture result = new CompletableFuture<>();
+    final FutureCallback callback = new FutureCallback() {
+
+      @Override
+      public void completed(SimpleHttpResponse httpResponse) {
+        result.complete(toFeignResponse(httpResponse, request));
+      }
+
+      @Override
+      public void failed(Exception ex) {
+        result.completeExceptionally(ex);
+      }
+
+      @Override
+      public void cancelled() {
+        result.cancel(false);
+      }
+    };
+
+    client.execute(httpUriRequest, callback);
+
+    return result;
+  }
+
+  protected HttpClientContext configureTimeouts(Request.Options options) {
+    final HttpClientContext context = new HttpClientContext();
+    // per request timeouts
+    final RequestConfig requestConfig =
+        (client instanceof Configurable
+            ? RequestConfig.copy(((Configurable) client).getConfig())
+            : RequestConfig.custom())
+                .setConnectTimeout(options.connectTimeout(), options.connectTimeoutUnit())
+                .setResponseTimeout(options.readTimeout(), options.readTimeoutUnit())
+                .build();
+    context.setRequestConfig(requestConfig);
+    return context;
+  }
+
+  SimpleHttpRequest toClassicHttpRequest(Request request,
+                                         Request.Options options) {
+    final SimpleHttpRequest httpRequest =
+        new SimpleHttpRequest(request.httpMethod().name(), request.url());
+
+    // request headers
+    boolean hasAcceptHeader = false;
+    for (final Map.Entry> headerEntry : request.headers().entrySet()) {
+      final String headerName = headerEntry.getKey();
+      if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) {
+        hasAcceptHeader = true;
+      }
+
+      if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH)) {
+        // The 'Content-Length' header is always set by the Apache client and it
+        // doesn't like us to set it as well.
+        continue;
+      }
+
+      for (final String headerValue : headerEntry.getValue()) {
+        httpRequest.addHeader(headerName, headerValue);
+      }
+    }
+    // some servers choke on the default accept string, so we'll set it to anything
+    if (!hasAcceptHeader) {
+      httpRequest.addHeader(ACCEPT_HEADER_NAME, "*/*");
+    }
+
+    // request body
+    // final Body requestBody = request.requestBody();
+    final byte[] data = request.body();
+    if (data != null) {
+      httpRequest.setBodyBytes(data, getContentType(request));
+    }
+
+    return httpRequest;
+  }
+
+  private ContentType getContentType(Request request) {
+    ContentType contentType = null;
+    for (final Map.Entry> entry : request.headers().entrySet()) {
+      if (entry.getKey().equalsIgnoreCase("Content-Type")) {
+        final Collection values = entry.getValue();
+        if (values != null && !values.isEmpty()) {
+          contentType = ContentType.parse(values.iterator().next());
+          if (contentType.getCharset() == null) {
+            contentType = contentType.withCharset(request.charset());
+          }
+          break;
+        }
+      }
+    }
+    return contentType;
+  }
+
+  Response toFeignResponse(SimpleHttpResponse httpResponse, Request request) {
+    final int statusCode = httpResponse.getCode();
+
+    final String reason = httpResponse.getReasonPhrase();
+
+    final Map> headers = new HashMap>();
+    for (final Header header : httpResponse.getHeaders()) {
+      final String name = header.getName();
+      final String value = header.getValue();
+
+      Collection headerValues = headers.get(name);
+      if (headerValues == null) {
+        headerValues = new ArrayList();
+        headers.put(name, headerValues);
+      }
+      headerValues.add(value);
+    }
+
+    return Response.builder()
+        .status(statusCode)
+        .reason(reason)
+        .headers(headers)
+        .request(request)
+        .body(httpResponse
+            .getBodyBytes())
+        .build();
+  }
+
+}
diff --git a/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java
new file mode 100644
index 0000000000..7f12c96993
--- /dev/null
+++ b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java
@@ -0,0 +1,1017 @@
+/**
+ * Copyright 2012-2020 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.hc5;
+
+import static feign.assertj.MockWebServerAssertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.data.MapEntry.entry;
+import static org.hamcrest.CoreMatchers.isA;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicReference;
+import feign.*;
+import feign.Feign.ResponseMappingDecoder;
+import feign.Request.HttpMethod;
+import feign.Target.HardCodedTarget;
+import feign.codec.*;
+import feign.querymap.BeanQueryMapEncoder;
+import feign.querymap.FieldQueryMapEncoder;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okio.Buffer;
+
+public class AsyncApacheHttp5ClientTest {
+
+  @Rule
+  public final ExpectedException thrown = ExpectedException.none();
+  @Rule
+  public final MockWebServer server = new MockWebServer();
+
+  @Test
+  public void iterableQueryParams() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    api.queryParams("user", Arrays.asList("apple", "pear"));
+
+    assertThat(server.takeRequest()).hasPath("/?1=user&2=apple&2=pear");
+  }
+
+  @Test
+  public void postTemplateParamsResolve() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    api.login("netflix", "denominator", "password");
+
+    assertThat(server.takeRequest()).hasBody(
+        "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}");
+  }
+
+  @Test
+  public void responseCoercesToStringBody() throws Throwable {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final Response response = unwrap(api.response());
+    assertTrue(response.body().isRepeatable());
+    assertEquals("foo", response.body().toString());
+  }
+
+  @Test
+  public void postFormParams() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.form("netflix", "denominator", "password");
+
+    assertThat(server.takeRequest())
+        .hasBody(
+            "{\"customer_name\":\"netflix\",\"user_name\":\"denominator\",\"password\":\"password\"}");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void postBodyParam() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.body(Arrays.asList("netflix", "denominator", "password"));
+
+    assertThat(server.takeRequest())
+        .hasHeaders(entry("Content-Length", Collections.singletonList("32")))
+        .hasBody("[netflix, denominator, password]");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  /**
+   * The type of a parameter value may not be the desired type to encode as. Prefer the interface
+   * type.
+   */
+  @Test
+  public void bodyTypeCorrespondsWithParameterType() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final AtomicReference encodedType = new AtomicReference();
+    final TestInterfaceAsync api = new TestInterfaceAsyncBuilder().encoder(new Encoder.Default() {
+      @Override
+      public void encode(Object object, Type bodyType, RequestTemplate template) {
+        encodedType.set(bodyType);
+      }
+    }).target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.body(Arrays.asList("netflix", "denominator", "password"));
+
+    server.takeRequest();
+
+    assertThat(encodedType.get()).isEqualTo(new TypeToken>() {}.getType());
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void singleInterceptor() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().requestInterceptor(new ForwardedForInterceptor())
+            .target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.post();
+
+    assertThat(server.takeRequest())
+        .hasHeaders(entry("X-Forwarded-For", Collections.singletonList("origin.host.com")));
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void multipleInterceptor() throws Exception {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().requestInterceptor(new ForwardedForInterceptor())
+            .requestInterceptor(new UserAgentInterceptor())
+            .target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.post();
+
+    assertThat(server.takeRequest()).hasHeaders(
+        entry("X-Forwarded-For", Collections.singletonList("origin.host.com")),
+        entry("User-Agent", Collections.singletonList("Feign")));
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void customExpander() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.expand(new Date(1234l));
+
+    assertThat(server.takeRequest()).hasPath("/?date=1234");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void customExpanderListParam() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf =
+        api.expandList(Arrays.asList(new Date(1234l), new Date(12345l)));
+
+    assertThat(server.takeRequest()).hasPath("/?date=1234&date=12345");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void customExpanderNullParam() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.expandList(Arrays.asList(new Date(1234l), null));
+
+    assertThat(server.takeRequest()).hasPath("/?date=1234");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void headerMap() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final Map headerMap = new LinkedHashMap();
+    headerMap.put("Content-Type", "myContent");
+    headerMap.put("Custom-Header", "fooValue");
+    final CompletableFuture cf = api.headerMap(headerMap);
+
+    assertThat(server.takeRequest()).hasHeaders(entry("Content-Type", Arrays.asList("myContent")),
+        entry("Custom-Header", Arrays.asList("fooValue")));
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void headerMapWithHeaderAnnotations() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final Map headerMap = new LinkedHashMap();
+    headerMap.put("Custom-Header", "fooValue");
+    api.headerMapWithHeaderAnnotations(headerMap);
+
+    // header map should be additive for headers provided by annotations
+    assertThat(server.takeRequest()).hasHeaders(entry("Content-Encoding", Arrays.asList("deflate")),
+        entry("Custom-Header", Arrays.asList("fooValue")));
+
+    server.enqueue(new MockResponse());
+    headerMap.put("Content-Encoding", "overrideFromMap");
+
+    final CompletableFuture cf = api.headerMapWithHeaderAnnotations(headerMap);
+
+    /*
+     * @HeaderMap map values no longer override @Header parameters. This caused confusion as it is
+     * valid to have more than one value for a header.
+     */
+    assertThat(server.takeRequest()).hasHeaders(
+        entry("Content-Encoding", Arrays.asList("deflate", "overrideFromMap")),
+        entry("Custom-Header", Arrays.asList("fooValue")));
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMap() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final Map queryMap = new LinkedHashMap();
+    queryMap.put("name", "alice");
+    queryMap.put("fooKey", "fooValue");
+    final CompletableFuture cf = api.queryMap(queryMap);
+
+    assertThat(server.takeRequest()).hasPath("/?name=alice&fooKey=fooValue");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMapIterableValuesExpanded() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final Map queryMap = new LinkedHashMap();
+    queryMap.put("name", Arrays.asList("Alice", "Bob"));
+    queryMap.put("fooKey", "fooValue");
+    queryMap.put("emptyListKey", new ArrayList());
+    queryMap.put("emptyStringKey", ""); // empty values are ignored.
+    final CompletableFuture cf = api.queryMap(queryMap);
+
+    assertThat(server.takeRequest())
+        .hasPath("/?name=Alice&name=Bob&fooKey=fooValue&emptyStringKey");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMapWithQueryParams() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    server.enqueue(new MockResponse());
+    Map queryMap = new LinkedHashMap();
+    queryMap.put("fooKey", "fooValue");
+    api.queryMapWithQueryParams("alice", queryMap);
+    // query map should be expanded after built-in parameters
+    assertThat(server.takeRequest()).hasPath("/?name=alice&fooKey=fooValue");
+
+    server.enqueue(new MockResponse());
+    queryMap = new LinkedHashMap();
+    queryMap.put("name", "bob");
+    api.queryMapWithQueryParams("alice", queryMap);
+    // queries are additive
+    assertThat(server.takeRequest()).hasPath("/?name=alice&name=bob");
+
+    server.enqueue(new MockResponse());
+    queryMap = new LinkedHashMap();
+    queryMap.put("name", null);
+    api.queryMapWithQueryParams("alice", queryMap);
+    // null value for a query map key removes query parameter
+    assertThat(server.takeRequest()).hasPath("/?name=alice");
+  }
+
+  @Test
+  public void queryMapValueStartingWithBrace() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    server.enqueue(new MockResponse());
+    Map queryMap = new LinkedHashMap();
+    queryMap.put("name", "{alice");
+    api.queryMap(queryMap);
+    assertThat(server.takeRequest()).hasPath("/?name=%7Balice");
+
+    server.enqueue(new MockResponse());
+    queryMap = new LinkedHashMap();
+    queryMap.put("{name", "alice");
+    api.queryMap(queryMap);
+    assertThat(server.takeRequest()).hasPath("/?%7Bname=alice");
+
+    server.enqueue(new MockResponse());
+    queryMap = new LinkedHashMap();
+    queryMap.put("name", "%7Balice");
+    api.queryMapEncoded(queryMap);
+    assertThat(server.takeRequest()).hasPath("/?name=%7Balice");
+
+    server.enqueue(new MockResponse());
+    queryMap = new LinkedHashMap();
+    queryMap.put("%7Bname", "%7Balice");
+    api.queryMapEncoded(queryMap);
+    assertThat(server.takeRequest()).hasPath("/?%7Bname=%7Balice");
+  }
+
+  @Test
+  public void queryMapPojoWithFullParams() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CustomPojo customPojo = new CustomPojo("Name", 3);
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPojo(customPojo);
+    assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("name=Name", "number=3"));
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMapPojoWithPartialParams() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CustomPojo customPojo = new CustomPojo("Name", null);
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPojo(customPojo);
+    assertThat(server.takeRequest()).hasPath("/?name=Name");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMapPojoWithEmptyParams() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CustomPojo customPojo = new CustomPojo(null, null);
+
+    server.enqueue(new MockResponse());
+    api.queryMapPojo(customPojo);
+    assertThat(server.takeRequest()).hasPath("/");
+  }
+
+  @Test
+  public void configKeyFormatsAsExpected() throws Exception {
+    assertEquals("TestInterfaceAsync#post()",
+        Feign.configKey(TestInterfaceAsync.class,
+            TestInterfaceAsync.class.getDeclaredMethod("post")));
+    assertEquals("TestInterfaceAsync#uriParam(String,URI,String)",
+        Feign.configKey(TestInterfaceAsync.class,
+            TestInterfaceAsync.class.getDeclaredMethod("uriParam", String.class, URI.class,
+                String.class)));
+  }
+
+  @Test
+  public void configKeyUsesChildType() throws Exception {
+    assertEquals("List#iterator()",
+        Feign.configKey(List.class, Iterable.class.getDeclaredMethod("iterator")));
+  }
+
+  private  T unwrap(CompletableFuture cf) throws Throwable {
+    try {
+      return cf.get(1, TimeUnit.SECONDS);
+    } catch (final ExecutionException e) {
+      throw e.getCause();
+    }
+  }
+
+  @Test
+  public void canOverrideErrorDecoder() throws Throwable {
+    server.enqueue(new MockResponse().setResponseCode(400).setBody("foo"));
+    thrown.expect(IllegalArgumentException.class);
+    thrown.expectMessage("bad zone name");
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().errorDecoder(new IllegalArgumentExceptionOn400())
+            .target("http://localhost:" + server.getPort());
+
+    unwrap(api.post());
+  }
+
+  @Test
+  public void overrideTypeSpecificDecoder() throws Throwable {
+    server.enqueue(new MockResponse().setBody("success!"));
+
+    final TestInterfaceAsync api = new TestInterfaceAsyncBuilder()
+        .decoder((response, type) -> "fail").target("http://localhost:" + server.getPort());
+
+    assertEquals("fail", unwrap(api.post()));
+  }
+
+  @Test
+  public void doesntRetryAfterResponseIsSent() throws Throwable {
+    server.enqueue(new MockResponse().setBody("success!"));
+    thrown.expect(FeignException.class);
+    thrown.expectMessage("timeout reading POST http://");
+
+    final TestInterfaceAsync api = new TestInterfaceAsyncBuilder().decoder((response, type) -> {
+      throw new IOException("timeout");
+    }).target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.post();
+    server.takeRequest();
+    unwrap(cf);
+  }
+
+  @Test
+  public void throwsFeignExceptionIncludingBody() throws Throwable {
+    server.enqueue(new MockResponse().setBody("success!"));
+
+    final TestInterfaceAsync api = AsyncFeign.asyncBuilder().decoder((response, type) -> {
+      throw new IOException("timeout");
+    }).target(TestInterfaceAsync.class, "http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.body("Request body");
+    server.takeRequest();
+    try {
+      unwrap(cf);
+    } catch (final FeignException e) {
+      assertThat(e.getMessage())
+          .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/");
+      assertThat(e.contentUTF8()).isEqualTo("Request body");
+      return;
+    }
+    fail();
+  }
+
+  @Test
+  public void throwsFeignExceptionWithoutBody() {
+    server.enqueue(new MockResponse().setBody("success!"));
+
+    final TestInterfaceAsync api = AsyncFeign.asyncBuilder().decoder((response, type) -> {
+      throw new IOException("timeout");
+    }).target(TestInterfaceAsync.class, "http://localhost:" + server.getPort());
+
+    try {
+      api.noContent();
+    } catch (final FeignException e) {
+      assertThat(e.getMessage())
+          .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/");
+      assertThat(e.contentUTF8()).isEqualTo("");
+    }
+  }
+
+  @SuppressWarnings("deprecation")
+  @Test
+  public void whenReturnTypeIsResponseNoErrorHandling() throws Throwable {
+    final Map> headers = new LinkedHashMap>();
+    headers.put("Location", Arrays.asList("http://bar.com"));
+    final Response response = Response.builder().status(302).reason("Found").headers(headers)
+        .request(Request.create(HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8))
+        .body(new byte[0]).build();
+
+    final ExecutorService execs = Executors.newSingleThreadExecutor();
+
+    // fake client as Client.Default follows redirects.
+    final TestInterfaceAsync api = AsyncFeign.asyncBuilder()
+        .client(new AsyncClient.Default<>((request, options) -> response, execs))
+        .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort());
+
+    assertEquals(Collections.singletonList("http://bar.com"),
+        unwrap(api.response()).headers().get("Location"));
+
+    execs.shutdown();
+  }
+
+  @Test
+  public void okIfDecodeRootCauseHasNoMessage() throws Throwable {
+    server.enqueue(new MockResponse().setBody("success!"));
+    thrown.expect(DecodeException.class);
+
+    final TestInterfaceAsync api = new TestInterfaceAsyncBuilder().decoder((response, type) -> {
+      throw new RuntimeException();
+    }).target("http://localhost:" + server.getPort());
+
+    unwrap(api.post());
+  }
+
+  @Test
+  public void decodingExceptionGetWrappedInDecode404Mode() throws Throwable {
+    server.enqueue(new MockResponse().setResponseCode(404));
+    thrown.expect(DecodeException.class);
+    thrown.expectCause(isA(NoSuchElementException.class));
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().decode404().decoder((response, type) -> {
+          assertEquals(404, response.status());
+          throw new NoSuchElementException();
+        }).target("http://localhost:" + server.getPort());
+
+    unwrap(api.post());
+  }
+
+  @Test
+  public void decodingDoesNotSwallow404ErrorsInDecode404Mode() throws Throwable {
+    server.enqueue(new MockResponse().setResponseCode(404));
+    thrown.expect(IllegalArgumentException.class);
+
+    final TestInterfaceAsync api = new TestInterfaceAsyncBuilder().decode404()
+        .errorDecoder(new IllegalArgumentExceptionOn404())
+        .target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.queryMap(Collections.emptyMap());
+    server.takeRequest();
+    unwrap(cf);
+  }
+
+  @Test
+  public void okIfEncodeRootCauseHasNoMessage() throws Throwable {
+    server.enqueue(new MockResponse().setBody("success!"));
+    thrown.expect(EncodeException.class);
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().encoder((object, bodyType, template) -> {
+          throw new RuntimeException();
+        }).target("http://localhost:" + server.getPort());
+
+    unwrap(api.body(Arrays.asList("foo")));
+  }
+
+  @Test
+  public void equalsHashCodeAndToStringWork() {
+    final Target t1 =
+        new HardCodedTarget(TestInterfaceAsync.class,
+            "http://localhost:8080");
+    final Target t2 =
+        new HardCodedTarget(TestInterfaceAsync.class,
+            "http://localhost:8888");
+    final Target t3 =
+        new HardCodedTarget(OtherTestInterfaceAsync.class,
+            "http://localhost:8080");
+    final TestInterfaceAsync i1 = AsyncFeign.asyncBuilder().target(t1);
+    final TestInterfaceAsync i2 = AsyncFeign.asyncBuilder().target(t1);
+    final TestInterfaceAsync i3 = AsyncFeign.asyncBuilder().target(t2);
+    final OtherTestInterfaceAsync i4 = AsyncFeign.asyncBuilder().target(t3);
+
+    assertThat(i1).isEqualTo(i2).isNotEqualTo(i3).isNotEqualTo(i4);
+
+    assertThat(i1.hashCode()).isEqualTo(i2.hashCode()).isNotEqualTo(i3.hashCode())
+        .isNotEqualTo(i4.hashCode());
+
+    assertThat(i1.toString()).isEqualTo(i2.toString()).isNotEqualTo(i3.toString())
+        .isNotEqualTo(i4.toString());
+
+    assertThat(t1).isNotEqualTo(i1);
+
+    assertThat(t1.hashCode()).isEqualTo(i1.hashCode());
+
+    assertThat(t1.toString()).isEqualTo(i1.toString());
+  }
+
+  @SuppressWarnings("resource")
+  @Test
+  public void decodeLogicSupportsByteArray() throws Throwable {
+    final byte[] expectedResponse = {12, 34, 56};
+    server.enqueue(new MockResponse().setBody(new Buffer().write(expectedResponse)));
+
+    final OtherTestInterfaceAsync api =
+        AsyncFeign.asyncBuilder().target(OtherTestInterfaceAsync.class,
+            "http://localhost:" + server.getPort());
+
+    assertThat(unwrap(api.binaryResponseBody())).containsExactly(expectedResponse);
+  }
+
+  @Test
+  public void encodeLogicSupportsByteArray() throws Exception {
+    final byte[] expectedRequest = {12, 34, 56};
+    server.enqueue(new MockResponse());
+
+    final OtherTestInterfaceAsync api =
+        AsyncFeign.asyncBuilder().target(OtherTestInterfaceAsync.class,
+            "http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.binaryRequestBody(expectedRequest);
+
+    assertThat(server.takeRequest()).hasBody(expectedRequest);
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void encodedQueryParam() throws Exception {
+    server.enqueue(new MockResponse());
+
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().target("http://localhost:" + server.getPort());
+
+    final CompletableFuture cf = api.encodedQueryParam("5.2FSi+");
+
+    assertThat(server.takeRequest()).hasPath("/?trim=5.2FSi%2B");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  private void checkCFCompletedSoon(CompletableFuture cf) {
+    try {
+      unwrap(cf);
+    } catch (final RuntimeException e) {
+      throw e;
+    } catch (final Throwable t) {
+      throw new RuntimeException(t);
+    }
+  }
+
+  @Test
+  public void responseMapperIsAppliedBeforeDelegate() throws IOException {
+    final ResponseMappingDecoder decoder =
+        new ResponseMappingDecoder(upperCaseResponseMapper(), new StringDecoder());
+    final String output = (String) decoder.decode(responseWithText("response"), String.class);
+
+    assertThat(output).isEqualTo("RESPONSE");
+  }
+
+  private ResponseMapper upperCaseResponseMapper() {
+    return new ResponseMapper() {
+      @SuppressWarnings("deprecation")
+      @Override
+      public Response map(Response response, Type type) {
+        try {
+          return response.toBuilder()
+              .body(Util.toString(response.body().asReader()).toUpperCase().getBytes())
+              .build();
+        } catch (final IOException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    };
+  }
+
+  @SuppressWarnings("deprecation")
+  private Response responseWithText(String text) {
+    return Response.builder().body(text, Util.UTF_8).status(200)
+        .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8))
+        .headers(new HashMap<>()).build();
+  }
+
+  @Test
+  public void mapAndDecodeExecutesMapFunction() throws Throwable {
+    server.enqueue(new MockResponse().setBody("response!"));
+
+    final TestInterfaceAsync api =
+        AsyncFeign.asyncBuilder().mapAndDecode(upperCaseResponseMapper(), new StringDecoder())
+            .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort());
+
+    assertEquals("RESPONSE!", unwrap(api.post()));
+  }
+
+  @Test
+  public void beanQueryMapEncoderWithPrivateGetterIgnored() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().queryMapEndcoder(new BeanQueryMapEncoder())
+            .target("http://localhost:" + server.getPort());
+
+    final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass();
+    propertyPojo.setPrivateGetterProperty("privateGetterProperty");
+    propertyPojo.setName("Name");
+    propertyPojo.setNumber(1);
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo);
+    assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("name=Name", "number=1"));
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void queryMap_with_child_pojo() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().queryMapEndcoder(new FieldQueryMapEncoder())
+            .target("http://localhost:" + server.getPort());
+
+    final ChildPojo childPojo = new ChildPojo();
+    childPojo.setChildPrivateProperty("first");
+    childPojo.setParentProtectedProperty("second");
+    childPojo.setParentPublicProperty("third");
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPropertyInheritence(childPojo);
+    assertThat(server.takeRequest()).hasQueryParams("parentPublicProperty=third",
+        "parentProtectedProperty=second",
+        "childPrivateProperty=first");
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void beanQueryMapEncoderWithNullValueIgnored() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().queryMapEndcoder(new BeanQueryMapEncoder())
+            .target("http://localhost:" + server.getPort());
+
+    final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass();
+    propertyPojo.setName(null);
+    propertyPojo.setNumber(1);
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo);
+
+    assertThat(server.takeRequest()).hasQueryParams("number=1");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  @Test
+  public void beanQueryMapEncoderWithEmptyParams() throws Exception {
+    final TestInterfaceAsync api =
+        new TestInterfaceAsyncBuilder().queryMapEndcoder(new BeanQueryMapEncoder())
+            .target("http://localhost:" + server.getPort());
+
+    final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass();
+
+    server.enqueue(new MockResponse());
+    final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo);
+    assertThat(server.takeRequest()).hasQueryParams("/");
+
+    checkCFCompletedSoon(cf);
+  }
+
+  public interface TestInterfaceAsync {
+
+    @RequestLine("POST /")
+    CompletableFuture response();
+
+    @RequestLine("POST /")
+    CompletableFuture post() throws TestInterfaceException;
+
+    @RequestLine("POST /")
+    @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D")
+    CompletableFuture login(@Param("customer_name") String customer,
+                                  @Param("user_name") String user,
+                                  @Param("password") String password);
+
+    @RequestLine("POST /")
+    CompletableFuture body(List contents);
+
+    @RequestLine("POST /")
+    CompletableFuture body(String content);
+
+    @RequestLine("POST /")
+    CompletableFuture noContent();
+
+    @RequestLine("POST /")
+    @Headers("Content-Encoding: gzip")
+    CompletableFuture gzipBody(List contents);
+
+    @RequestLine("POST /")
+    @Headers("Content-Encoding: deflate")
+    CompletableFuture deflateBody(List contents);
+
+    @RequestLine("POST /")
+    CompletableFuture form(@Param("customer_name") String customer,
+                                 @Param("user_name") String user,
+                                 @Param("password") String password);
+
+    @RequestLine("GET /{1}/{2}")
+    CompletableFuture uriParam(@Param("1") String one,
+                                         URI endpoint,
+                                         @Param("2") String two);
+
+    @RequestLine("GET /?1={1}&2={2}")
+    CompletableFuture queryParams(@Param("1") String one,
+                                            @Param("2") Iterable twos);
+
+    @RequestLine("POST /?date={date}")
+    CompletableFuture expand(@Param(value = "date", expander = DateToMillis.class) Date date);
+
+    @RequestLine("GET /?date={date}")
+    CompletableFuture expandList(@Param(value = "date",
+        expander = DateToMillis.class) List dates);
+
+    @RequestLine("GET /?date={date}")
+    CompletableFuture expandArray(@Param(value = "date",
+        expander = DateToMillis.class) Date[] dates);
+
+    @RequestLine("GET /")
+    CompletableFuture headerMap(@HeaderMap Map headerMap);
+
+    @RequestLine("GET /")
+    @Headers("Content-Encoding: deflate")
+    CompletableFuture headerMapWithHeaderAnnotations(@HeaderMap Map headerMap);
+
+    @RequestLine("GET /")
+    CompletableFuture queryMap(@QueryMap Map queryMap);
+
+    @RequestLine("GET /")
+    CompletableFuture queryMapEncoded(@QueryMap(encoded = true) Map queryMap);
+
+    @RequestLine("GET /?name={name}")
+    CompletableFuture queryMapWithQueryParams(@Param("name") String name,
+                                                    @QueryMap Map queryMap);
+
+    @RequestLine("GET /?trim={trim}")
+    CompletableFuture encodedQueryParam(@Param(value = "trim", encoded = true) String trim);
+
+    @RequestLine("GET /")
+    CompletableFuture queryMapPojo(@QueryMap CustomPojo object);
+
+    @RequestLine("GET /")
+    CompletableFuture queryMapPropertyPojo(@QueryMap PropertyPojo object);
+
+    @RequestLine("GET /")
+    CompletableFuture queryMapPropertyInheritence(@QueryMap ChildPojo object);
+
+    class DateToMillis implements Param.Expander {
+
+      @Override
+      public String expand(Object value) {
+        return String.valueOf(((Date) value).getTime());
+      }
+    }
+  }
+
+  class TestInterfaceException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    TestInterfaceException(String message) {
+      super(message);
+    }
+  }
+
+  public interface OtherTestInterfaceAsync {
+
+    @RequestLine("POST /")
+    CompletableFuture post();
+
+    @RequestLine("POST /")
+    CompletableFuture binaryResponseBody();
+
+    @RequestLine("POST /")
+    CompletableFuture binaryRequestBody(byte[] contents);
+  }
+
+  static class ForwardedForInterceptor implements RequestInterceptor {
+
+    @Override
+    public void apply(RequestTemplate template) {
+      template.header("X-Forwarded-For", "origin.host.com");
+    }
+  }
+
+  static class UserAgentInterceptor implements RequestInterceptor {
+
+    @Override
+    public void apply(RequestTemplate template) {
+      template.header("User-Agent", "Feign");
+    }
+  }
+
+  static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default {
+
+    @Override
+    public Exception decode(String methodKey, Response response) {
+      if (response.status() == 400) {
+        return new IllegalArgumentException("bad zone name");
+      }
+      return super.decode(methodKey, response);
+    }
+  }
+
+  static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default {
+
+    @Override
+    public Exception decode(String methodKey, Response response) {
+      if (response.status() == 404) {
+        return new IllegalArgumentException("bad zone name");
+      }
+      return super.decode(methodKey, response);
+    }
+  }
+
+  static final class TestInterfaceAsyncBuilder {
+
+    private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.asyncBuilder()
+        .client(new AsyncApacheHttp5Client())
+        .decoder(new Decoder.Default()).encoder(new Encoder() {
+
+          @SuppressWarnings("deprecation")
+          @Override
+          public void encode(Object object, Type bodyType, RequestTemplate template) {
+            if (object instanceof Map) {
+              template.body(new Gson().toJson(object));
+            } else {
+              template.body(object.toString());
+            }
+          }
+        });
+
+    TestInterfaceAsyncBuilder requestInterceptor(RequestInterceptor requestInterceptor) {
+      delegate.requestInterceptor(requestInterceptor);
+      return this;
+    }
+
+    TestInterfaceAsyncBuilder encoder(Encoder encoder) {
+      delegate.encoder(encoder);
+      return this;
+    }
+
+    TestInterfaceAsyncBuilder decoder(Decoder decoder) {
+      delegate.decoder(decoder);
+      return this;
+    }
+
+    TestInterfaceAsyncBuilder errorDecoder(ErrorDecoder errorDecoder) {
+      delegate.errorDecoder(errorDecoder);
+      return this;
+    }
+
+    TestInterfaceAsyncBuilder decode404() {
+      delegate.decode404();
+      return this;
+    }
+
+    TestInterfaceAsyncBuilder queryMapEndcoder(QueryMapEncoder queryMapEncoder) {
+      delegate.queryMapEncoder(queryMapEncoder);
+      return this;
+    }
+
+    TestInterfaceAsync target(String url) {
+      return delegate.target(TestInterfaceAsync.class, url);
+    }
+  }
+
+  static final class ExtendedCF extends CompletableFuture {
+
+  }
+
+  static abstract class NonInterface {
+    @RequestLine("GET /")
+    abstract CompletableFuture x();
+  }
+
+  interface NonCFApi {
+    @RequestLine("GET /")
+    void x();
+  }
+
+  interface ExtendedCFApi {
+    @RequestLine("GET /")
+    ExtendedCF x();
+  }
+
+  interface LowerWildApi {
+    @RequestLine("GET /")
+    CompletableFuture x();
+  }
+
+  interface UpperWildApi {
+    @RequestLine("GET /")
+    CompletableFuture x();
+  }
+
+  interface WildApi {
+    @RequestLine("GET /")
+    CompletableFuture x();
+  }
+
+
+}
diff --git a/hc5/src/test/java/feign/hc5/CustomPojo.java b/hc5/src/test/java/feign/hc5/CustomPojo.java
new file mode 100644
index 0000000000..7295707833
--- /dev/null
+++ b/hc5/src/test/java/feign/hc5/CustomPojo.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright 2012-2020 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package feign.hc5;
+
+public class CustomPojo {
+
+  private final String name;
+  private final Integer number;
+
+  CustomPojo(String name, Integer number) {
+    this.name = name;
+    this.number = number;
+  }
+}