From 1705c150463fc46fcef853958dcc1d1f6604eaf2 Mon Sep 17 00:00:00 2001 From: Tristan Burch Date: Fri, 8 May 2015 13:32:51 -0600 Subject: [PATCH] Support for Apache HttpClient as Feign client --- CHANGELOG.md | 3 + httpclient/README.md | 12 + httpclient/build.gradle | 12 + .../feign/httpclient/ApacheHttpClient.java | 207 ++++++++++++++++++ .../httpclient/ApacheHttpClientTest.java | 109 +++++++++ settings.gradle | 2 +- 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 httpclient/README.md create mode 100644 httpclient/build.gradle create mode 100644 httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java create mode 100644 httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f4244c02..cd322fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### Version 8.3 +* Adds client implementation for Apache Http Client + ### Version 8.2 * Allows customized request construction by exposing `Request.create()` * Adds JMH benchmark module diff --git a/httpclient/README.md b/httpclient/README.md new file mode 100644 index 00000000..ed20b439 --- /dev/null +++ b/httpclient/README.md @@ -0,0 +1,12 @@ +Apache HttpClient +=================== + +This module directs Feign's http requests to Apache's [HttpClient](https://hc.apache.org/httpcomponents-client-ga/). + +To use HttpClient with Feign, add the HttpClient module to your classpath. Then, configure Feign to use the HttpClient: + +```java +GitHub github = Feign.builder() + .client(new ApacheHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/httpclient/build.gradle b/httpclient/build.gradle new file mode 100644 index 00000000..52b18074 --- /dev/null +++ b/httpclient/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'org.apache.httpcomponents:httpclient:4.4.1' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' + testCompile 'com.squareup.okhttp:mockwebserver:2.2.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} \ No newline at end of file diff --git a/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java new file mode 100644 index 00000000..27a8f2b5 --- /dev/null +++ b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java @@ -0,0 +1,207 @@ +/* + * Copyright 2015 Netflix, Inc. + * + * 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.httpclient; + +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This module directs Feign's http requests to Apache's + * HttpClient. Ex. + *
+ * GitHub github = Feign.builder().client(new ApacheHttpClient()).target(GitHub.class,
+ * "https://api.github.com");
+ */
+/*
+ * Based on Square, Inc's Retrofit ApacheClient implementation
+ */
+public final class ApacheHttpClient implements Client {
+  private static final String ACCEPT_HEADER_NAME = "Accept";
+
+  private final HttpClient client;
+
+  public ApacheHttpClient() {
+    this(HttpClientBuilder.create().build());
+  }
+
+  public ApacheHttpClient(HttpClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Response execute(Request request, Request.Options options) throws IOException {
+    HttpUriRequest httpUriRequest;
+    try {
+      httpUriRequest = toHttpUriRequest(request, options);
+    } catch (URISyntaxException e) {
+      throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e);
+    }
+    HttpResponse httpResponse = client.execute(httpUriRequest);
+    return toFeignResponse(httpResponse);
+  }
+
+  HttpUriRequest toHttpUriRequest(Request request, Request.Options options) throws
+          UnsupportedEncodingException, MalformedURLException, URISyntaxException {
+    RequestBuilder requestBuilder = RequestBuilder.create(request.method());
+
+    //per request timeouts
+    RequestConfig requestConfig = RequestConfig
+            .custom()
+            .setConnectTimeout(options.connectTimeoutMillis())
+            .setSocketTimeout(options.readTimeoutMillis())
+            .build();
+    requestBuilder.setConfig(requestConfig);
+
+    URI uri = new URIBuilder(request.url()).build();
+
+    //request url
+    requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getPath());
+
+    //request query params
+    List queryParams = URLEncodedUtils.parse(uri, requestBuilder.getCharset().name());
+    for (NameValuePair queryParam: queryParams) {
+      requestBuilder.addParameter(queryParam);
+    }
+
+    //request body
+    if (request.body() != null) {
+      HttpEntity entity = request.charset() != null ?
+              new StringEntity(new String(request.body(), request.charset())) :
+              new ByteArrayEntity(request.body());
+      requestBuilder.setEntity(entity);
+    }
+
+    //request headers
+    boolean hasAcceptHeader = false;
+    for (Map.Entry> headerEntry : request.headers().entrySet()) {
+      String headerName = headerEntry.getKey();
+      if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) {
+        hasAcceptHeader = true;
+      }
+      if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH) &&
+              requestBuilder.getHeaders(headerName) != null) {
+        //if the 'Content-Length' header is already present, it's been set from HttpEntity, so we
+        //won't add it again
+        continue;
+      }
+
+      for (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, "*/*");
+    }
+
+    return requestBuilder.build();
+  }
+
+  Response toFeignResponse(HttpResponse httpResponse) throws IOException {
+    StatusLine statusLine = httpResponse.getStatusLine();
+    int statusCode = statusLine.getStatusCode();
+
+    String reason = statusLine.getReasonPhrase();
+
+    Map> headers = new HashMap>();
+    for (Header header : httpResponse.getAllHeaders()) {
+      String name = header.getName();
+      String value = header.getValue();
+
+      Collection headerValues = headers.get(name);
+      if (headerValues == null) {
+        headerValues = new ArrayList();
+        headers.put(name, headerValues);
+      }
+      headerValues.add(value);
+    }
+
+    return Response.create(statusCode, reason, headers, toFeignBody(httpResponse));
+  }
+
+  Response.Body toFeignBody(HttpResponse httpResponse) throws IOException {
+    HttpEntity entity = httpResponse.getEntity();
+    final Integer length = entity != null && entity.getContentLength() != -1 ?
+            (int) entity.getContentLength() :
+            null;
+    final InputStream input = entity != null ?
+            new ByteArrayInputStream(EntityUtils.toByteArray(entity)) :
+            null;
+
+    return new Response.Body() {
+
+      @Override
+      public void close() throws IOException {
+        if (input != null) {
+          input.close();
+        }
+      }
+
+      @Override
+      public Integer length() {
+        return length;
+      }
+
+      @Override
+      public boolean isRepeatable() {
+        return false;
+      }
+
+      @Override
+      public InputStream asInputStream() throws IOException {
+        return input;
+      }
+
+      @Override
+      public Reader asReader() throws IOException {
+        return new InputStreamReader(input);
+      }
+    };
+  }
+
+}
diff --git a/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
new file mode 100644
index 00000000..96e734c6
--- /dev/null
+++ b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2015 Netflix, Inc.
+ *
+ * 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.httpclient;
+
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
+import feign.Feign;
+import feign.FeignException;
+import feign.Headers;
+import feign.RequestLine;
+import feign.Response;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.MockWebServerAssertions.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+
+public class ApacheHttpClientTest {
+
+  @Rule
+  public final ExpectedException thrown = ExpectedException.none();
+  @Rule
+  public final MockWebServerRule server = new MockWebServerRule();
+
+  @Test
+  public void parsesRequestAndResponse() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar"));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.post("foo");
+
+    assertThat(response.status()).isEqualTo(200);
+    assertThat(response.reason()).isEqualTo("OK");
+    assertThat(response.headers())
+        .containsEntry("Content-Length", asList("3"))
+        .containsEntry("Foo", asList("Bar"));
+    assertThat(response.body().asInputStream())
+        .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8)));
+
+    assertThat(server.takeRequest()).hasMethod("POST")
+        .hasPath("/?foo=bar&foo=baz&qux=")
+        .hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3")
+        .hasBody("foo");
+  }
+
+  @Test
+  public void parsesErrorResponse() throws IOException, InterruptedException {
+    thrown.expect(FeignException.class);
+    thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH");
+
+    server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH"));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  @Test
+  public void patch() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo"));
+    server.enqueue(new MockResponse());
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    assertEquals("foo", api.patch());
+
+    assertThat(server.takeRequest())
+        .hasHeaders("Accept: text/plain")
+        .hasNoHeaderNamed("Content-Type")
+        .hasMethod("PATCH");
+  }
+
+  interface TestInterface {
+
+    @RequestLine("POST /?foo=bar&foo=baz&qux=")
+    @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"})
+    Response post(String body);
+
+    @RequestLine("PATCH /")
+    @Headers("Accept: text/plain")
+    String patch();
+  }
+}
diff --git a/settings.gradle b/settings.gradle
index ccbde471..ecf20616 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,5 +1,5 @@
 rootProject.name='feign'
-include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j'
+include 'core', 'sax', 'gson', 'httpclient', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j'
 
 rootProject.children.each { childProject ->
     childProject.name = 'feign-' + childProject.name