diff --git a/core/src/main/java/feign/Request.java b/core/src/main/java/feign/Request.java index f5e2657032..164b95ebee 100644 --- a/core/src/main/java/feign/Request.java +++ b/core/src/main/java/feign/Request.java @@ -18,6 +18,7 @@ import java.net.HttpURLConnection; import java.nio.charset.Charset; import java.util.*; +import java.util.concurrent.TimeUnit; import feign.template.BodyTemplate; /** @@ -39,15 +40,17 @@ private Body(byte[] data, Charset encoding, BodyTemplate bodyTemplate) { } public Request.Body expand(Map variables) { - if (bodyTemplate == null) + if (bodyTemplate == null) { return this; + } return encoded(bodyTemplate.expand(variables).getBytes(encoding), encoding); } public List getVariables() { - if (bodyTemplate == null) + if (bodyTemplate == null) { return Collections.emptyList(); + } return bodyTemplate.getVariables(); } @@ -73,7 +76,7 @@ public String bodyTemplate() { } public String asString() { - return encoding != null && data != null + return !isBinary() ? new String(data, encoding) : "Binary data"; } @@ -82,6 +85,10 @@ public static Body empty() { return new Request.Body(null, null, null); } + public boolean isBinary() { + return encoding == null || data == null; + } + } public enum HttpMethod { @@ -94,13 +101,14 @@ public enum HttpMethod { * * @deprecated {@link #create(HttpMethod, String, Map, byte[], Charset)} */ + @Deprecated public static Request create(String method, String url, Map> headers, byte[] body, Charset charset) { checkNotNull(method, "httpMethod of %s", method); - HttpMethod httpMethod = HttpMethod.valueOf(method.toUpperCase()); + final HttpMethod httpMethod = HttpMethod.valueOf(method.toUpperCase()); return create(httpMethod, url, headers, body, charset); } @@ -156,6 +164,7 @@ public static Request create(HttpMethod httpMethod, * @return the HttpMethod string * @deprecated @see {@link #httpMethod()} */ + @Deprecated public String method() { return httpMethod.name(); } @@ -186,6 +195,7 @@ public Map> headers() { * * @deprecated use {@link #requestBody()} instead */ + @Deprecated public Charset charset() { return body.encoding; } @@ -197,6 +207,7 @@ public Charset charset() { * @see #charset() * @deprecated use {@link #requestBody()} instead */ + @Deprecated public byte[] body() { return body.data; } @@ -207,10 +218,10 @@ public Body requestBody() { @Override public String toString() { - StringBuilder builder = new StringBuilder(); + final StringBuilder builder = new StringBuilder(); builder.append(httpMethod).append(' ').append(url).append(" HTTP/1.1\n"); - for (String field : headers.keySet()) { - for (String value : valuesOrEmpty(headers, field)) { + for (final String field : headers.keySet()) { + for (final String value : valuesOrEmpty(headers, field)) { builder.append(field).append(": ").append(value).append('\n'); } } @@ -226,22 +237,38 @@ public String toString() { */ public static class Options { - private final int connectTimeoutMillis; - private final int readTimeoutMillis; + private final long connectTimeout; + private final TimeUnit connectTimeoutUnit; + private final long readTimeout; + private final TimeUnit readTimeoutUnit; private final boolean followRedirects; - public Options(int connectTimeoutMillis, int readTimeoutMillis, boolean followRedirects) { - this.connectTimeoutMillis = connectTimeoutMillis; - this.readTimeoutMillis = readTimeoutMillis; + + public Options(long connectTimeout, TimeUnit connectTimeoutUnit, + long readTimeout, TimeUnit readTimeoutUnit, + boolean followRedirects) { + super(); + this.connectTimeout = connectTimeout; + this.connectTimeoutUnit = connectTimeoutUnit; + this.readTimeout = readTimeout; + this.readTimeoutUnit = readTimeoutUnit; this.followRedirects = followRedirects; } + @Deprecated + public Options(int connectTimeoutMillis, int readTimeoutMillis, boolean followRedirects) { + this(connectTimeoutMillis, TimeUnit.MILLISECONDS, + readTimeoutMillis, TimeUnit.MILLISECONDS, + followRedirects); + } + + @Deprecated public Options(int connectTimeoutMillis, int readTimeoutMillis) { this(connectTimeoutMillis, readTimeoutMillis, true); } public Options() { - this(10 * 1000, 60 * 1000); + this(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true); } /** @@ -249,8 +276,9 @@ public Options() { * * @see java.net.HttpURLConnection#getConnectTimeout() */ + @Deprecated public int connectTimeoutMillis() { - return connectTimeoutMillis; + return (int) connectTimeoutUnit.toMillis(connectTimeout); } /** @@ -258,8 +286,9 @@ public int connectTimeoutMillis() { * * @see java.net.HttpURLConnection#getReadTimeout() */ + @Deprecated public int readTimeoutMillis() { - return readTimeoutMillis; + return (int) readTimeoutUnit.toMillis(readTimeout); } @@ -271,5 +300,22 @@ public int readTimeoutMillis() { public boolean isFollowRedirects() { return followRedirects; } + + public long connectTimeout() { + return connectTimeout; + } + + public TimeUnit connectTimeoutUnit() { + return connectTimeoutUnit; + } + + public long readTimeout() { + return readTimeout; + } + + public TimeUnit readTimeoutUnit() { + return readTimeoutUnit; + } + } } diff --git a/hc5/README.md b/hc5/README.md new file mode 100644 index 0000000000..bf65df9484 --- /dev/null +++ b/hc5/README.md @@ -0,0 +1,12 @@ +Apache Http Compoments 5 +======================== + +This module directs Feign's http requests to Apache's [HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html). + +To use HttpClient with Feign, add the `feign-hc5` module to your classpath. Then, configure Feign to use the `ApacheHttp5Client`: + +```java +GitHub github = Feign.builder() + .client(new ApacheHttp5Client()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/hc5/pom.xml b/hc5/pom.xml new file mode 100644 index 0000000000..d3d3b83979 --- /dev/null +++ b/hc5/pom.xml @@ -0,0 +1,65 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.4.1-SNAPSHOT + + + feign-hc5 + Feign Apache Http Client 5 + Feign Apache HttpComponents Client 5 + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.0-beta5 + + + + ${project.groupId} + feign-core + test-jar + test + + + + ${project.groupId} + feign-jaxrs2 + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + diff --git a/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java b/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java new file mode 100644 index 0000000000..38e47aa8ab --- /dev/null +++ b/hc5/src/main/java/feign/hc5/ApacheHttp5Client.java @@ -0,0 +1,235 @@ +/** + * Copyright 2012-2019 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.Util.UTF_8; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.Configurable; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.*; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.net.URIBuilder; +import org.apache.hc.core5.net.URLEncodedUtils; +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.*; +import feign.*; +import feign.Request.Body; + +/** + * 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 ApacheHttp5Client implements Client {
+  private static final String ACCEPT_HEADER_NAME = "Accept";
+
+  private final HttpClient client;
+
+  public ApacheHttp5Client() {
+    this(HttpClientBuilder.create().build());
+  }
+
+  public ApacheHttp5Client(HttpClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Response execute(Request request, Request.Options options) throws IOException {
+    ClassicHttpRequest httpUriRequest;
+    try {
+      httpUriRequest = toClassicHttpRequest(request, options);
+    } catch (final URISyntaxException e) {
+      throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e);
+    }
+    final HttpHost target = HttpHost.create(URI.create(request.url()));
+    final HttpClientContext context = configureTimeouts(options);
+
+    final ClassicHttpResponse httpResponse =
+        (ClassicHttpResponse) client.execute(target, httpUriRequest, context);
+    return toFeignResponse(httpResponse, request);
+  }
+
+  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;
+  }
+
+  ClassicHttpRequest toClassicHttpRequest(Request request, Request.Options options)
+      throws URISyntaxException {
+    final ClassicRequestBuilder requestBuilder =
+        ClassicRequestBuilder.create(request.httpMethod().name());
+
+    final URI uri = new URIBuilder(request.url()).build();
+
+    requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getRawPath());
+
+    // request query params
+    final List queryParams =
+        URLEncodedUtils.parse(uri, requestBuilder.getCharset());
+    for (final NameValuePair queryParam : queryParams) {
+      requestBuilder.addParameter(queryParam);
+    }
+
+    // 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()) {
+        requestBuilder.addHeader(headerName, headerValue);
+      }
+    }
+    // some servers choke on the default accept string, so we'll set it to anything
+    if (!hasAcceptHeader) {
+      requestBuilder.addHeader(ACCEPT_HEADER_NAME, "*/*");
+    }
+
+    // request body
+    final Body requestBody = request.requestBody();
+    if (requestBody.asBytes() != null) {
+      HttpEntity entity;
+      if (requestBody.isBinary()) {
+        entity = new ByteArrayEntity(requestBody.asBytes(), null);
+      } else {
+        final ContentType contentType = getContentType(request);
+        entity = new StringEntity(requestBody.asString(), contentType);
+      }
+
+      requestBuilder.setEntity(entity);
+    } else {
+      requestBuilder.setEntity(new ByteArrayEntity(new byte[0], null));
+    }
+
+    final ClassicHttpRequest classicRequest = requestBuilder.build();
+
+    return classicRequest;
+  }
+
+  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(ClassicHttpResponse httpResponse, Request request) throws IOException {
+    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(toFeignBody(httpResponse))
+        .build();
+  }
+
+  Response.Body toFeignBody(ClassicHttpResponse httpResponse) {
+    final HttpEntity entity = httpResponse.getEntity();
+    if (entity == null) {
+      return null;
+    }
+    return new Response.Body() {
+
+      @Override
+      public Integer length() {
+        return entity.getContentLength() >= 0 && entity.getContentLength() <= Integer.MAX_VALUE
+            ? (int) entity.getContentLength()
+            : null;
+      }
+
+      @Override
+      public boolean isRepeatable() {
+        return entity.isRepeatable();
+      }
+
+      @Override
+      public InputStream asInputStream() throws IOException {
+        return entity.getContent();
+      }
+
+      @Override
+      public Reader asReader() throws IOException {
+        return new InputStreamReader(asInputStream(), UTF_8);
+      }
+
+      @Override
+      public Reader asReader(Charset charset) throws IOException {
+        Util.checkNotNull(charset, "charset should not be null");
+        return new InputStreamReader(asInputStream(), charset);
+      }
+
+      @Override
+      public void close() throws IOException {
+        EntityUtils.consume(entity);
+      }
+    };
+  }
+}
diff --git a/hc5/src/test/java/feign/hc5/ApacheHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/ApacheHttp5ClientTest.java
new file mode 100644
index 0000000000..4cf8e2fd47
--- /dev/null
+++ b/hc5/src/test/java/feign/hc5/ApacheHttp5ClientTest.java
@@ -0,0 +1,79 @@
+/**
+ * Copyright 2012-2019 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 org.junit.Assert.assertEquals;
+import static org.junit.Assume.assumeTrue;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.junit.Test;
+import java.nio.charset.StandardCharsets;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.QueryParam;
+import feign.Feign;
+import feign.Feign.Builder;
+import feign.client.AbstractClientTest;
+import feign.jaxrs.JAXRSContract;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.RecordedRequest;
+
+/**
+ * Tests client-specific behavior, such as ensuring Content-Length is sent when specified.
+ */
+public class ApacheHttp5ClientTest extends AbstractClientTest {
+
+  @Override
+  public Builder newBuilder() {
+    return Feign.builder().client(new ApacheHttp5Client());
+  }
+
+  @Test
+  public void queryParamsAreRespectedWhenBodyIsEmpty() throws InterruptedException {
+    final HttpClient httpClient = HttpClientBuilder.create().build();
+    final JaxRsTestInterface testInterface = Feign.builder()
+        .contract(new JAXRSContract())
+        .client(new ApacheHttp5Client(httpClient))
+        .target(JaxRsTestInterface.class, "http://localhost:" + server.getPort());
+
+    server.enqueue(new MockResponse().setBody("foo"));
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    assertEquals("foo", testInterface.withBody("foo", "bar"));
+    final RecordedRequest request1 = server.takeRequest();
+    assertEquals("/withBody?foo=foo", request1.getPath());
+    assertEquals("bar", request1.getBody().readString(StandardCharsets.UTF_8));
+
+    assertEquals("foo", testInterface.withoutBody("foo"));
+    final RecordedRequest request2 = server.takeRequest();
+    assertEquals("/withoutBody?foo=foo", request2.getPath());
+    assertEquals("", request2.getBody().readString(StandardCharsets.UTF_8));
+  }
+
+  @Override
+  public void testVeryLongResponseNullLength() {
+    assumeTrue("HC5 client seems to hang with response size equalto Long.MAX", false);
+  }
+
+  @Path("/")
+  public interface JaxRsTestInterface {
+    @PUT
+    @Path("/withBody")
+    String withBody(@QueryParam("foo") String foo, String bar);
+
+    @PUT
+    @Path("/withoutBody")
+    String withoutBody(@QueryParam("foo") String foo);
+  }
+}
diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml
index cdc31e0727..2507359d48 100644
--- a/jaxrs2/pom.xml
+++ b/jaxrs2/pom.xml
@@ -57,7 +57,6 @@
       javax.ws.rs
       javax.ws.rs-api
       2.1
-      provided
     
 
     
diff --git a/pom.xml b/pom.xml
index 60c2424478..3b8d9a0547 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,6 +29,7 @@
     core
     gson
     httpclient
+    hc5
     hystrix
     jackson
     jackson-jaxb