From 49b700e6f7850885facea99d426d01f3911f4c29 Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Mon, 26 Jan 2015 01:15:41 -0800 Subject: [PATCH] Adds OkHttp integration closes #134 --- CHANGELOG.md | 1 + README.md | 11 ++ core/src/main/java/feign/Response.java | 4 + .../java/feign/codec/DefaultDecoderTest.java | 2 +- .../feign/codec/DefaultErrorDecoderTest.java | 4 +- .../test/java/feign/gson/GsonModuleTest.java | 2 +- .../java/feign/jackson/JacksonModuleTest.java | 2 +- okhttp/README.md | 12 ++ okhttp/build.gradle | 12 ++ .../main/java/feign/okhttp/OkHttpClient.java | 131 ++++++++++++++++++ .../java/feign/okhttp/OkHttpClientTest.java | 103 ++++++++++++++ .../test/java/feign/sax/SAXDecoderTest.java | 2 +- settings.gradle | 2 +- 13 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 okhttp/README.md create mode 100644 okhttp/build.gradle create mode 100644 okhttp/src/main/java/feign/okhttp/OkHttpClient.java create mode 100644 okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f898bd8d..904b4b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### Version 7.1 * Introduces feign.@Param to annotate template parameters. Users must migrate from `javax.inject.@Named` to `feign.@Param` before updating to Feign 8.0. +* Adds OkHttp integration * Allows multiple headers with the same name. ### Version 7.0 diff --git a/README.md b/README.md index 2aec1505..297495d8 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,17 @@ GitHub github = Feign.builder() .contract(new JAXRSModule.JAXRSContract()) .target(GitHub.class, "https://api.github.com"); ``` +### OkHttp +[OkHttpClient](https://github.com/Netflix/feign/tree/master/okhttp) directs Feign's http requests to [OkHttp](http://square.github.io/okhttp/), which enables SPDY and better network control. + +To use OkHttp with Feign, add the OkHttp module to your classpath. Then, configure Feign to use the OkHttpClient: + +```java +GitHub github = Feign.builder() + .client(new OkHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` + ### Ribbon [RibbonModule](https://github.com/Netflix/feign/tree/master/ribbon) overrides URL resolution of Feign's client, adding smart routing and resiliency capabilities provided by [Ribbon](https://github.com/Netflix/ribbon). diff --git a/core/src/main/java/feign/Response.java b/core/src/main/java/feign/Response.java index 2324254d..4ea1941d 100644 --- a/core/src/main/java/feign/Response.java +++ b/core/src/main/java/feign/Response.java @@ -58,6 +58,10 @@ public final class Response { return new Response(status, reason, headers, ByteArrayBody.orNull(text, charset)); } + public static Response create(int status, String reason, Map> headers, Body body) { + return new Response(status, reason, headers, body); + } + private Response(int status, String reason, Map> headers, Body body) { checkState(status >= 200, "Invalid status code: %s", status); this.status = status; diff --git a/core/src/test/java/feign/codec/DefaultDecoderTest.java b/core/src/test/java/feign/codec/DefaultDecoderTest.java index c1505770..02c86c16 100644 --- a/core/src/test/java/feign/codec/DefaultDecoderTest.java +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -70,6 +70,6 @@ public class DefaultDecoderTest { } private Response nullBodyResponse() { - return Response.create(200, "OK", Collections.>emptyMap(), null); + return Response.create(200, "OK", Collections.>emptyMap(), (byte[]) null); } } diff --git a/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java index d78b022e..1fd443fe 100644 --- a/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -39,7 +39,7 @@ public class DefaultErrorDecoderTest { thrown.expect(FeignException.class); thrown.expectMessage("status 500 reading Service#foo()"); - Response response = Response.create(500, "Internal server error", headers, null); + Response response = Response.create(500, "Internal server error", headers, (byte[]) null); throw errorDecoder.decode("Service#foo()", response); } @@ -58,7 +58,7 @@ public class DefaultErrorDecoderTest { thrown.expectMessage("status 503 reading Service#foo()"); headers.put(RETRY_AFTER, Arrays.asList("Sat, 1 Jan 2000 00:00:00 GMT")); - Response response = Response.create(503, "Service Unavailable", headers, null); + Response response = Response.create(503, "Service Unavailable", headers, (byte[]) null); throw errorDecoder.decode("Service#foo()", response); } diff --git a/gson/src/test/java/feign/gson/GsonModuleTest.java b/gson/src/test/java/feign/gson/GsonModuleTest.java index fdf95cf0..bf6e1ada 100644 --- a/gson/src/test/java/feign/gson/GsonModuleTest.java +++ b/gson/src/test/java/feign/gson/GsonModuleTest.java @@ -141,7 +141,7 @@ public class GsonModuleTest { DecoderBindings bindings = new DecoderBindings(); ObjectGraph.create(bindings).inject(bindings); - Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + Response response = Response.create(204, "OK", Collections.>emptyMap(), (byte[]) null); assertNull(bindings.decoder.decode(response, String.class)); } diff --git a/jackson/src/test/java/feign/jackson/JacksonModuleTest.java b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java index 2aa8f9f0..698feb32 100644 --- a/jackson/src/test/java/feign/jackson/JacksonModuleTest.java +++ b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java @@ -128,7 +128,7 @@ public class JacksonModuleTest { DecoderBindings bindings = new DecoderBindings(); ObjectGraph.create(bindings).inject(bindings); - Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + Response response = Response.create(204, "OK", Collections.>emptyMap(), (byte[]) null); assertNull(bindings.decoder.decode(response, String.class)); } diff --git a/okhttp/README.md b/okhttp/README.md new file mode 100644 index 00000000..81f68373 --- /dev/null +++ b/okhttp/README.md @@ -0,0 +1,12 @@ +OkHttp +=================== + +This module directs Feign's http requests to [OkHttp](http://square.github.io/okhttp/), which enables SPDY and better network control. + +To use OkHttp with Feign, add the OkHttp module to your classpath. Then, configure Feign to use the OkHttpClient: + +```java +GitHub github = Feign.builder() + .client(new OkHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/okhttp/build.gradle b/okhttp/build.gradle new file mode 100644 index 00000000..a01cbef3 --- /dev/null +++ b/okhttp/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'com.squareup.okhttp:okhttp:2.2.0' + 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 +} diff --git a/okhttp/src/main/java/feign/okhttp/OkHttpClient.java b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java new file mode 100644 index 00000000..35e74af6 --- /dev/null +++ b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java @@ -0,0 +1,131 @@ +/* + * 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.okhttp; + +import com.squareup.okhttp.Headers; +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.RequestBody; +import com.squareup.okhttp.Response; +import com.squareup.okhttp.ResponseBody; +import feign.Client; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * This module directs Feign's http requests to OkHttp, which enables + * SPDY and better network control. + * Ex. + *
+ * GitHub github = Feign.builder().client(new OkHttpClient()).target(GitHub.class, "https://api.github.com");
+ */
+public final class OkHttpClient implements Client {
+  private final com.squareup.okhttp.OkHttpClient delegate;
+
+  public OkHttpClient() {
+    this(new com.squareup.okhttp.OkHttpClient());
+  }
+
+  public OkHttpClient(com.squareup.okhttp.OkHttpClient delegate) {
+    this.delegate = delegate;
+  }
+
+  @Override public feign.Response execute(feign.Request input, feign.Request.Options options) throws IOException {
+    com.squareup.okhttp.OkHttpClient requestScoped;
+    if (delegate.getConnectTimeout() != options.connectTimeoutMillis()
+        || delegate.getReadTimeout() != options.readTimeoutMillis()) {
+      requestScoped = delegate.clone();
+      requestScoped.setConnectTimeout(options.connectTimeoutMillis(), TimeUnit.MILLISECONDS);
+      requestScoped.setReadTimeout(options.readTimeoutMillis(), TimeUnit.MILLISECONDS);
+    } else {
+      requestScoped = delegate;
+    }
+    Request request = toOkHttpRequest(input);
+    Response response = requestScoped.newCall(request).execute();
+    return toFeignResponse(response);
+  }
+
+  static Request toOkHttpRequest(feign.Request input) {
+    Request.Builder requestBuilder = new Request.Builder();
+    requestBuilder.url(input.url());
+
+    MediaType mediaType = null;
+    for (String field : input.headers().keySet()) {
+      for (String value : input.headers().get(field)) {
+        if (field.equalsIgnoreCase("Content-Type")) {
+          mediaType = MediaType.parse(value);
+          if (input.charset() != null) mediaType.charset(input.charset());
+        } else {
+          requestBuilder.addHeader(field, value);
+        }
+      }
+    }
+    RequestBody body = input.body() != null ? RequestBody.create(mediaType, input.body()) : null;
+    requestBuilder.method(input.method(), body);
+    return requestBuilder.build();
+  }
+
+  private static feign.Response toFeignResponse(Response input) {
+    return feign.Response.create(input.code(), input.message(), toMap(input.headers()), toBody(input.body()));
+  }
+
+  private static Map> toMap(Headers headers) {
+    Map> result = new LinkedHashMap>(headers.size());
+    for (String name : headers.names()) {
+      // TODO: this is very inefficient as headers.values iterate case insensitively.
+      result.put(name, headers.values(name));
+    }
+    return result;
+  }
+
+  private static feign.Response.Body toBody(final ResponseBody input) {
+    if (input == null || input.contentLength() == 0) {
+      return null;
+    }
+    if (input.contentLength() > Integer.MAX_VALUE) {
+      throw new UnsupportedOperationException("Length too long "+ input.contentLength());
+    }
+    final Integer length = input.contentLength() != -1 ? (int) input.contentLength() : null;
+
+    return new feign.Response.Body() {
+
+      @Override public void close() throws IOException {
+        input.close();
+      }
+
+      @Override public Integer length() {
+        return length;
+      }
+
+      @Override public boolean isRepeatable() {
+        return false;
+      }
+
+      @Override public InputStream asInputStream() throws IOException {
+        return input.byteStream();
+      }
+
+      @Override public Reader asReader() throws IOException {
+        return input.charStream();
+      }
+    };
+  }
+}
diff --git a/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
new file mode 100644
index 00000000..c17e300d
--- /dev/null
+++ b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.okhttp;
+
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import com.squareup.okhttp.mockwebserver.SocketPolicy;
+import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
+import dagger.Lazy;
+import feign.Client;
+import feign.Feign;
+import feign.FeignException;
+import feign.Headers;
+import feign.RequestLine;
+import feign.Response;
+import feign.Util;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.MockWebServerAssertions.assertThat;
+import static java.util.Arrays.asList;
+import static org.assertj.core.data.MapEntry.entry;
+import static org.hamcrest.core.Is.isA;
+import static org.junit.Assert.assertEquals;
+
+public class OkHttpClientTest {
+  @Rule public final ExpectedException thrown = ExpectedException.none();
+  @Rule public final MockWebServerRule server = new MockWebServerRule();
+
+  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 /") String patch();
+  }
+
+  @Test public void parsesRequestAndResponse() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar"));
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .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: ", "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 OkHttpClient())
+        .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 OkHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    assertEquals("foo", api.patch());
+
+    assertThat(server.takeRequest())
+        .hasHeaders("Content-Length: 0") // Note: OkHttp adds content length.
+        .hasNoHeaderNamed("Content-Type")
+        .hasMethod("PATCH");
+  }
+}
diff --git a/sax/src/test/java/feign/sax/SAXDecoderTest.java b/sax/src/test/java/feign/sax/SAXDecoderTest.java
index cd3de0ec..f6274645 100644
--- a/sax/src/test/java/feign/sax/SAXDecoderTest.java
+++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java
@@ -142,7 +142,7 @@ public class SAXDecoderTest {
   }
 
   @Test public void nullBodyDecodesToNull() throws Exception {
-    Response response = Response.create(204, "OK", Collections.>emptyMap(), null);
+    Response response = Response.create(204, "OK", Collections.>emptyMap(), (byte[]) null);
     assertNull(decoder.decode(response, String.class));
   }
 }
diff --git a/settings.gradle b/settings.gradle
index 6f6dc626..3b5fdad8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,5 +1,5 @@
 rootProject.name='feign'
-include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'ribbon', 'slf4j', 'example-github', 'example-wikipedia'
+include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j', 'example-github', 'example-wikipedia'
 
 rootProject.children.each { childProject ->
     childProject.name = 'feign-' + childProject.name