Repository: square/retrofit
Issue: Evaluate Better AppEngine support

Reflection is not allowed in our use of `HttpUrlConnection`. Easy solution is to disable on that platform. Harder but arguably better solution is to use their [URL fetch API](https://developers.google.com/appengine/docs/java/javadoc/com/google/appengine/api/urlfetch/package-summary).


Issue comments:
@mattstep what's the state of the art for testing http binders against app engine?

Yo, hmm, good question.  I've not touched that in a few months.  I can respin context when I've got some time, if there isn't a Bart strike this week, wanna meet up for coffee?  Might be able to use some of the hooks we added for CapeDwarf...

@mattstep pretty busy this week and out some of next.  eom is pretty open so will follow-up offline

Sweet, give me a call or hit me on irc.


Pull request review comments:

Patch that resolved the issue:
diff --git a/pom.xml b/pom.xml
index e5c02f88d7..2ac4e7298c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,6 +52,7 @@
     <gson.version>2.2.4</gson.version>
     <okhttp.version>1.3.0</okhttp.version>
     <rxjava.version>0.16.1</rxjava.version>
+    <appengine.version>1.8.9</appengine.version>
 
     <!-- Converter Dependencies -->
     <protobuf.version>2.5.0</protobuf.version>
@@ -111,6 +112,11 @@
         <artifactId>rxjava-core</artifactId>
         <version>${rxjava.version}</version>
       </dependency>
+      <dependency>
+        <groupId>com.google.appengine</groupId>
+        <artifactId>appengine-api-1.0-sdk</artifactId>
+        <version>${appengine.version}</version>
+      </dependency>
 
       <dependency>
         <groupId>com.google.protobuf</groupId>
diff --git a/retrofit/pom.xml b/retrofit/pom.xml
index 7cbf095770..c964f9b48f 100644
--- a/retrofit/pom.xml
+++ b/retrofit/pom.xml
@@ -18,6 +18,7 @@
       <groupId>com.google.code.gson</groupId>
       <artifactId>gson</artifactId>
     </dependency>
+
     <dependency>
       <groupId>com.google.android</groupId>
       <artifactId>android</artifactId>
@@ -33,6 +34,11 @@
       <artifactId>rxjava-core</artifactId>
       <optional>true</optional>
     </dependency>
+    <dependency>
+      <groupId>com.google.appengine</groupId>
+      <artifactId>appengine-api-1.0-sdk</artifactId>
+      <optional>true</optional>
+    </dependency>
 
     <dependency>
       <groupId>junit</groupId>
@@ -55,4 +61,17 @@
       <scope>test</scope>
     </dependency>
   </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <!-- The AppEngine dependency has an annotation processor we don't want to run. -->
+          <compilerArgument>-proc:none</compilerArgument>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
 </project>
diff --git a/retrofit/src/main/java/retrofit/Platform.java b/retrofit/src/main/java/retrofit/Platform.java
index 2276419408..3e76c3dbe4 100644
--- a/retrofit/src/main/java/retrofit/Platform.java
+++ b/retrofit/src/main/java/retrofit/Platform.java
@@ -24,6 +24,7 @@
 import retrofit.android.AndroidApacheClient;
 import retrofit.android.AndroidLog;
 import retrofit.android.MainThreadExecutor;
+import retrofit.appengine.UrlFetchClient;
 import retrofit.client.Client;
 import retrofit.client.OkClient;
 import retrofit.client.UrlConnectionClient;
@@ -50,6 +51,11 @@ private static Platform findPlatform() {
       }
     } catch (ClassNotFoundException ignored) {
     }
+
+    if (System.getProperty("com.google.appengine.runtime.version") != null) {
+      return new AppEngine();
+    }
+
     return new Base();
   }
 
@@ -149,6 +155,17 @@ private static class Android extends Platform {
     }
   }
 
+  private static class AppEngine extends Base {
+    @Override Client.Provider defaultClient() {
+      final UrlFetchClient client = new UrlFetchClient();
+      return new Client.Provider() {
+        @Override public Client get() {
+          return client;
+        }
+      };
+    }
+  }
+
   /** Determine whether or not OkHttp is present on the runtime classpath. */
   private static boolean hasOkHttpOnClasspath() {
     try {
diff --git a/retrofit/src/main/java/retrofit/appengine/UrlFetchClient.java b/retrofit/src/main/java/retrofit/appengine/UrlFetchClient.java
new file mode 100644
index 0000000000..fd25039c34
--- /dev/null
+++ b/retrofit/src/main/java/retrofit/appengine/UrlFetchClient.java
@@ -0,0 +1,112 @@
+package retrofit.appengine;
+
+import com.google.appengine.api.urlfetch.HTTPHeader;
+import com.google.appengine.api.urlfetch.HTTPMethod;
+import com.google.appengine.api.urlfetch.HTTPRequest;
+import com.google.appengine.api.urlfetch.HTTPResponse;
+import com.google.appengine.api.urlfetch.URLFetchService;
+import com.google.appengine.api.urlfetch.URLFetchServiceFactory;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import retrofit.client.Client;
+import retrofit.client.Header;
+import retrofit.client.Request;
+import retrofit.client.Response;
+import retrofit.mime.TypedByteArray;
+import retrofit.mime.TypedOutput;
+
+/** A {@link Client} for Google AppEngine's which uses its {@link URLFetchService}. */
+public class UrlFetchClient implements Client {
+  private static HTTPMethod getHttpMethod(String method) {
+    if ("GET".equals(method)) {
+      return HTTPMethod.GET;
+    } else if ("POST".equals(method)) {
+      return HTTPMethod.POST;
+    } else if ("PATCH".equals(method)) {
+      return HTTPMethod.PATCH;
+    } else if ("PUT".equals(method)) {
+      return HTTPMethod.PUT;
+    } else if ("DELETE".equals(method)) {
+      return HTTPMethod.DELETE;
+    } else if ("HEAD".equals(method)) {
+      return HTTPMethod.HEAD;
+    } else {
+      throw new IllegalStateException("Illegal HTTP method: " + method);
+    }
+  }
+
+  private final URLFetchService urlFetchService;
+
+  public UrlFetchClient() {
+    this(URLFetchServiceFactory.getURLFetchService());
+  }
+
+  public UrlFetchClient(URLFetchService urlFetchService) {
+    this.urlFetchService = urlFetchService;
+  }
+
+  @Override public Response execute(Request request) throws IOException {
+    HTTPRequest fetchRequest = createRequest(request);
+    HTTPResponse fetchResponse = execute(urlFetchService, fetchRequest);
+    return parseResponse(fetchResponse);
+  }
+
+  /** Execute the specified {@code request} using the provided {@code urlFetchService}. */
+  protected HTTPResponse execute(URLFetchService urlFetchService, HTTPRequest request)
+      throws IOException {
+    return urlFetchService.fetch(request);
+  }
+
+  static HTTPRequest createRequest(Request request) throws IOException {
+    HTTPMethod httpMethod = getHttpMethod(request.getMethod());
+    URL url = new URL(request.getUrl());
+    HTTPRequest fetchRequest = new HTTPRequest(url, httpMethod);
+
+    for (Header header : request.getHeaders()) {
+      fetchRequest.addHeader(new HTTPHeader(header.getName(), header.getValue()));
+    }
+
+    TypedOutput body = request.getBody();
+    if (body != null) {
+      fetchRequest.setHeader(new HTTPHeader("Content-Type", body.mimeType()));
+      long length = body.length();
+      if (length != -1) {
+        fetchRequest.setHeader(new HTTPHeader("Content-Length", String.valueOf(length)));
+      }
+
+      ByteArrayOutputStream baos = new ByteArrayOutputStream();
+      body.writeTo(baos);
+      fetchRequest.setPayload(baos.toByteArray());
+    }
+
+    return fetchRequest;
+  }
+
+  static Response parseResponse(HTTPResponse response) {
+    String url = response.getFinalUrl().toString();
+    int status = response.getResponseCode();
+
+    List<HTTPHeader> fetchHeaders = response.getHeaders();
+    List<Header> headers = new ArrayList<Header>(fetchHeaders.size());
+    String contentType = "application/octet-stream";
+    for (HTTPHeader fetchHeader : fetchHeaders) {
+      String name = fetchHeader.getName();
+      String value = fetchHeader.getValue();
+      if ("Content-Type".equalsIgnoreCase(name)) {
+        contentType = value;
+      }
+      headers.add(new Header(name, value));
+    }
+
+    TypedByteArray body = null;
+    byte[] fetchBody = response.getContent();
+    if (fetchBody != null) {
+      body = new TypedByteArray(contentType, fetchBody);
+    }
+
+    return new Response(url, status, "", headers, body);
+  }
+}


Test patch:
diff --git a/retrofit/src/test/java/retrofit/TestingUtils.java b/retrofit/src/test/java/retrofit/TestingUtils.java
index 709b1bcb5c..a4a031fe7a 100644
--- a/retrofit/src/test/java/retrofit/TestingUtils.java
+++ b/retrofit/src/test/java/retrofit/TestingUtils.java
@@ -1,6 +1,7 @@
 // Copyright 2013 Square, Inc.
 package retrofit;
 
+import java.io.IOException;
 import java.lang.reflect.Method;
 import java.util.Map;
 import retrofit.mime.MultipartTypedOutput;
@@ -26,11 +27,7 @@ public static TypedOutput createMultipart(Map<String, TypedOutput> parts) {
     return typedOutput;
   }
 
-  public static void assertMultipart(TypedOutput typedOutput) {
-    assertThat(typedOutput).isInstanceOf(MultipartTypedOutput.class);
-  }
-
-  public static void assertBytes(byte[] bytes, String expected) throws Exception {
+  public static void assertBytes(byte[] bytes, String expected) throws IOException {
     assertThat(new String(bytes, "UTF-8")).isEqualTo(expected);
   }
 }
diff --git a/retrofit/src/test/java/retrofit/appengine/UrlFetchClientTest.java b/retrofit/src/test/java/retrofit/appengine/UrlFetchClientTest.java
new file mode 100644
index 0000000000..76b942a248
--- /dev/null
+++ b/retrofit/src/test/java/retrofit/appengine/UrlFetchClientTest.java
@@ -0,0 +1,131 @@
+// Copyright 2014 Square, Inc.
+package retrofit.appengine;
+
+import com.google.appengine.api.urlfetch.HTTPHeader;
+import com.google.appengine.api.urlfetch.HTTPRequest;
+import com.google.appengine.api.urlfetch.HTTPResponse;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+import retrofit.TestingUtils;
+import retrofit.client.Header;
+import retrofit.client.Request;
+import retrofit.client.Response;
+import retrofit.mime.TypedOutput;
+import retrofit.mime.TypedString;
+
+import static com.google.appengine.api.urlfetch.HTTPMethod.GET;
+import static com.google.appengine.api.urlfetch.HTTPMethod.POST;
+import static java.util.Arrays.asList;
+import static org.fest.assertions.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static retrofit.TestingUtils.assertBytes;
+
+public class UrlFetchClientTest {
+  private static final String HOST = "http://example.com";
+
+  @Test public void get() throws IOException {
+    Request request = new Request("GET", HOST + "/foo/bar/?kit=kat", null, null);
+    HTTPRequest fetchRequest = UrlFetchClient.createRequest(request);
+
+    assertThat(fetchRequest.getMethod()).isEqualTo(GET);
+    assertThat(fetchRequest.getURL().toString()).isEqualTo(HOST + "/foo/bar/?kit=kat");
+    assertThat(fetchRequest.getHeaders()).isEmpty();
+    assertThat(fetchRequest.getPayload()).isNull();
+  }
+
+  @Test public void post() throws IOException {
+    TypedString body = new TypedString("hi");
+    Request request = new Request("POST", HOST + "/foo/bar/", null, body);
+    HTTPRequest fetchRequest = UrlFetchClient.createRequest(request);
+
+    assertThat(fetchRequest.getMethod()).isEqualTo(POST);
+    assertThat(fetchRequest.getURL().toString()).isEqualTo(HOST + "/foo/bar/");
+    List<HTTPHeader> fetchHeaders = fetchRequest.getHeaders();
+    assertThat(fetchHeaders).hasSize(2);
+    assertHeader(fetchHeaders.get(0), "Content-Type", "text/plain; charset=UTF-8");
+    assertHeader(fetchHeaders.get(1), "Content-Length", "2");
+    assertBytes(fetchRequest.getPayload(), "hi");
+  }
+
+  @Test public void multipart() throws IOException {
+    Map<String, TypedOutput> bodyParams = new LinkedHashMap<String, TypedOutput>();
+    bodyParams.put("foo", new TypedString("bar"));
+    bodyParams.put("ping", new TypedString("pong"));
+    TypedOutput body = TestingUtils.createMultipart(bodyParams);
+    Request request = new Request("POST", HOST + "/that/", null, body);
+    HTTPRequest fetchRequest = UrlFetchClient.createRequest(request);
+
+    assertThat(fetchRequest.getMethod()).isEqualTo(POST);
+    assertThat(fetchRequest.getURL().toString()).isEqualTo(HOST + "/that/");
+    List<HTTPHeader> fetchHeaders = fetchRequest.getHeaders();
+    assertThat(fetchHeaders).hasSize(2);
+    HTTPHeader headerZero = fetchHeaders.get(0);
+    assertThat(headerZero.getName()).isEqualTo("Content-Type");
+    assertThat(headerZero.getValue()).startsWith("multipart/form-data; boundary=");
+    assertHeader(fetchHeaders.get(1), "Content-Length", String.valueOf(body.length()));
+    assertThat(fetchRequest.getPayload()).isNotEmpty();
+  }
+
+  @Test public void headers() throws IOException {
+    List<Header> headers = new ArrayList<Header>();
+    headers.add(new Header("kit", "kat"));
+    headers.add(new Header("foo", "bar"));
+    Request request = new Request("GET", HOST + "/this/", headers, null);
+    HTTPRequest fetchRequest = UrlFetchClient.createRequest(request);
+
+    List<HTTPHeader> fetchHeaders = fetchRequest.getHeaders();
+    assertThat(fetchHeaders).hasSize(2);
+    assertHeader(fetchHeaders.get(0), "kit", "kat");
+    assertHeader(fetchHeaders.get(1), "foo", "bar");
+  }
+
+  @Test public void response() throws Exception {
+    HTTPResponse fetchResponse = mock(HTTPResponse.class);
+    when(fetchResponse.getHeaders()).thenReturn(
+        asList(new HTTPHeader("foo", "bar"), new HTTPHeader("kit", "kat"),
+            new HTTPHeader("Content-Type", "text/plain")));
+    when(fetchResponse.getContent()).thenReturn("hello".getBytes("UTF-8"));
+    when(fetchResponse.getFinalUrl()).thenReturn(new URL(HOST + "/foo/bar/"));
+    when(fetchResponse.getResponseCode()).thenReturn(200);
+
+    Response response = UrlFetchClient.parseResponse(fetchResponse);
+
+    assertThat(response.getUrl()).isEqualTo(HOST + "/foo/bar/");
+    assertThat(response.getStatus()).isEqualTo(200);
+    assertThat(response.getReason()).isEqualTo("");
+    assertThat(response.getHeaders()).hasSize(3) //
+        .containsOnly(new Header("foo", "bar"), new Header("kit", "kat"),
+            new Header("Content-Type", "text/plain"));
+    assertBytes(ByteStreams.toByteArray(response.getBody().in()), "hello");
+  }
+
+  @Test public void emptyResponse() throws Exception {
+    HTTPResponse fetchResponse = mock(HTTPResponse.class);
+    when(fetchResponse.getHeaders()).thenReturn(
+        asList(new HTTPHeader("foo", "bar"), new HTTPHeader("kit", "kat")));
+    when(fetchResponse.getContent()).thenReturn(null);
+    when(fetchResponse.getFinalUrl()).thenReturn(new URL(HOST + "/foo/bar/"));
+    when(fetchResponse.getResponseCode()).thenReturn(200);
+
+    Response response = UrlFetchClient.parseResponse(fetchResponse);
+
+    assertThat(response.getUrl()).isEqualTo(HOST + "/foo/bar/");
+    assertThat(response.getStatus()).isEqualTo(200);
+    assertThat(response.getReason()).isEqualTo("");
+    assertThat(response.getHeaders()).hasSize(2) //
+        .containsExactly(new Header("foo", "bar"), new Header("kit", "kat"));
+    assertThat(response.getBody()).isNull();
+  }
+
+  private static void assertHeader(HTTPHeader header, String name, String value) {
+    assertThat(header.getName()).isEqualTo(name);
+    assertThat(header.getValue()).isEqualTo(value);
+  }
+}
diff --git a/retrofit/src/test/java/retrofit/client/ApacheClientTest.java b/retrofit/src/test/java/retrofit/client/ApacheClientTest.java
index 74cc4c3bb8..59dd74a07c 100644
--- a/retrofit/src/test/java/retrofit/client/ApacheClientTest.java
+++ b/retrofit/src/test/java/retrofit/client/ApacheClientTest.java
@@ -2,6 +2,7 @@
 package retrofit.client;
 
 import com.google.common.io.ByteStreams;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -16,12 +17,12 @@
 import org.apache.http.message.BasicStatusLine;
 import org.junit.Test;
 import retrofit.TestingUtils;
+import retrofit.mime.MultipartTypedOutput;
 import retrofit.mime.TypedOutput;
 import retrofit.mime.TypedString;
 
 import static org.fest.assertions.api.Assertions.assertThat;
 import static retrofit.TestingUtils.assertBytes;
-import static retrofit.TestingUtils.assertMultipart;
 import static retrofit.client.ApacheClient.TypedOutputEntity;
 
 public class ApacheClientTest {
@@ -41,14 +42,14 @@ public class ApacheClientTest {
     }
   }
 
-  @Test public void post() throws Exception {
+  @Test public void post() throws IOException {
     TypedString body = new TypedString("hi");
     Request request = new Request("POST", HOST + "/foo/bar/", null, body);
     HttpUriRequest apacheRequest = ApacheClient.createRequest(request);
 
     assertThat(apacheRequest.getMethod()).isEqualTo("POST");
     assertThat(apacheRequest.getURI().toString()).isEqualTo(HOST + "/foo/bar/");
-    assertThat(apacheRequest.getAllHeaders()).hasSize(0);
+    assertThat(apacheRequest.getAllHeaders()).isEmpty();
 
     assertThat(apacheRequest).isInstanceOf(HttpEntityEnclosingRequest.class);
     HttpEntityEnclosingRequest entityRequest = (HttpEntityEnclosingRequest) apacheRequest;
@@ -68,12 +69,12 @@ public class ApacheClientTest {
 
     assertThat(apacheRequest.getMethod()).isEqualTo("POST");
     assertThat(apacheRequest.getURI().toString()).isEqualTo(HOST + "/that/");
-    assertThat(apacheRequest.getAllHeaders()).hasSize(0);
+    assertThat(apacheRequest.getAllHeaders()).isEmpty();
 
     assertThat(apacheRequest).isInstanceOf(HttpEntityEnclosingRequest.class);
     HttpEntityEnclosingRequest entityRequest = (HttpEntityEnclosingRequest) apacheRequest;
     TypedOutputEntity entity = (TypedOutputEntity) entityRequest.getEntity();
-    assertMultipart(entity.typedOutput);
+    assertThat(entity.typedOutput).isInstanceOf(MultipartTypedOutput.class);
     // TODO test more?
   }
 
@@ -93,7 +94,7 @@ public class ApacheClientTest {
     assertThat(foo.getValue()).isEqualTo("bar");
   }
 
-  @Test public void response() throws Exception {
+  @Test public void response() throws IOException {
     StatusLine statusLine = new BasicStatusLine(HttpVersion.HTTP_1_1, 200, "OK");
     HttpResponse apacheResponse = new BasicHttpResponse(statusLine);
     apacheResponse.setEntity(new TypedOutputEntity(new TypedString("hello")));
@@ -111,7 +112,7 @@ public class ApacheClientTest {
     assertBytes(ByteStreams.toByteArray(response.getBody().in()), "hello");
   }
 
-  @Test public void emptyResponse() throws Exception {
+  @Test public void emptyResponse() throws IOException {
     StatusLine statusLine = new BasicStatusLine(HttpVersion.HTTP_1_1, 200, "OK");
     HttpResponse apacheResponse = new BasicHttpResponse(statusLine);
     apacheResponse.addHeader("foo", "bar");