diff --git a/settings.gradle b/settings.gradle index 575282a2..f5fac105 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ include ':stetho' include ':stetho-urlconnection' +include ':stetho-okhttp' include ':stetho-sample' diff --git a/stetho-okhttp/.gitignore b/stetho-okhttp/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/stetho-okhttp/.gitignore @@ -0,0 +1 @@ +/build diff --git a/stetho-okhttp/build.gradle b/stetho-okhttp/build.gradle new file mode 100644 index 00000000..d3d0781f --- /dev/null +++ b/stetho-okhttp/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.android.library' +apply plugin: 'robolectric' + +android { + compileSdkVersion 21 + buildToolsVersion "21.1.2" + + defaultConfig { + minSdkVersion 9 + targetSdkVersion 21 + versionCode 1 + versionName "1.0" + } + + sourceSets { + androidTest { + setRoot('src/test') + } + } + + lintOptions { + // This seems to be firing due to okio referencing java.nio.File + // which is harmless for us. Not sure how to disable this in + // more targeted fashion... + warning 'InvalidPackage' + } +} + +robolectric { + include '**/*Test.class' + exclude '**/espresso/**/*.class' +} + +dependencies { + compile project(':stetho') + compile 'com.google.code.findbugs:jsr305:2.0.1' + //noinspection GradleDynamicVersion + compile 'com.squareup.okhttp:okhttp:2.2.0+' + + androidTestCompile 'junit:junit:4.12' + androidTestCompile('org.robolectric:robolectric:2.4') { + exclude module: 'commons-logging' + exclude module: 'httpclient' + } + androidTestCompile 'org.powermock:powermock-api-mockito:1.6.1' + androidTestCompile 'org.powermock:powermock-module-junit4:1.6.1' + + // Needed for Robolectric and PowerMock to be combined in a single test. + androidTestCompile 'org.powermock:powermock-module-junit4-rule:1.6.1' + androidTestCompile 'org.powermock:powermock-classloading-xstream:1.6.1' +} diff --git a/stetho-okhttp/src/main/AndroidManifest.xml b/stetho-okhttp/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7c833acc --- /dev/null +++ b/stetho-okhttp/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/stetho-okhttp/src/main/java/com/facebook/stetho/okhttp/StethoInterceptor.java b/stetho-okhttp/src/main/java/com/facebook/stetho/okhttp/StethoInterceptor.java new file mode 100644 index 00000000..9c55c70a --- /dev/null +++ b/stetho-okhttp/src/main/java/com/facebook/stetho/okhttp/StethoInterceptor.java @@ -0,0 +1,286 @@ +package com.facebook.stetho.okhttp; + +import javax.annotation.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicInteger; + +import com.facebook.stetho.inspector.network.DefaultResponseHandler; +import com.facebook.stetho.inspector.network.NetworkEventReporter; +import com.facebook.stetho.inspector.network.NetworkEventReporterImpl; + +import com.squareup.okhttp.Connection; +import com.squareup.okhttp.Interceptor; +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 okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; + +/** + * Provides easy integration with OkHttp 2.2.0+ + * by way of the new Interceptor + * system. To use: + *
+ *   OkHttpClient client = new OkHttpClient();
+ *   client.networkInterceptors().add(new StethoInterceptor());
+ * 
+ */ +public class StethoInterceptor implements Interceptor { + private final NetworkEventReporter mEventReporter = NetworkEventReporterImpl.get(); + + private final AtomicInteger mNextRequestId = new AtomicInteger(0); + + @Override + public Response intercept(Chain chain) throws IOException { + String requestId = String.valueOf(mNextRequestId.getAndIncrement()); + + Request request = chain.request(); + + int requestSize = 0; + if (mEventReporter.isEnabled()) { + OkHttpInspectorRequest inspectorRequest = new OkHttpInspectorRequest(requestId, request); + mEventReporter.requestWillBeSent(inspectorRequest); + byte[] requestBody = inspectorRequest.body(); + if (requestBody != null) { + requestSize += requestBody.length; + } + } + + Response response; + try { + response = chain.proceed(request); + } catch (IOException e) { + if (mEventReporter.isEnabled()) { + mEventReporter.httpExchangeFailed(requestId, e.toString()); + } + throw e; + } + + if (mEventReporter.isEnabled()) { + if (requestSize > 0) { + mEventReporter.dataSent(requestId, requestSize, requestSize); + } + + Connection connection = chain.connection(); + mEventReporter.responseHeadersReceived( + new OkHttpInspectorResponse( + requestId, + request, + response, + connection)); + + ResponseBody body = response.body(); + MediaType contentType = null; + InputStream responseStream = null; + if (body != null) { + contentType = body.contentType(); + responseStream = body.byteStream(); + } + + responseStream = mEventReporter.interpretResponseStream( + requestId, + contentType != null ? contentType.toString() : null, + responseStream, + new DefaultResponseHandler(mEventReporter, requestId)); + if (responseStream != null) { + response = response.newBuilder() + .body(new ForwardingResponseBody(body, responseStream)) + .build(); + } + } + + return response; + } + + private static class OkHttpInspectorRequest implements NetworkEventReporter.InspectorRequest { + private final String mRequestId; + private final Request mRequest; + private byte[] mBody; + private boolean mBodyGenerated; + + public OkHttpInspectorRequest(String requestId, Request request) { + mRequestId = requestId; + mRequest = request; + } + + @Override + public String id() { + return mRequestId; + } + + @Override + public String friendlyName() { + // Hmm, can we do better? tag() perhaps? + return null; + } + + @Nullable + @Override + public Integer friendlyNameExtra() { + return null; + } + + @Override + public String url() { + return mRequest.urlString(); + } + + @Override + public String method() { + return mRequest.method(); + } + + @Nullable + @Override + public byte[] body() throws IOException { + if (!mBodyGenerated) { + mBodyGenerated = true; + mBody = generateBody(); + } + return mBody; + } + + private byte[] generateBody() throws IOException { + RequestBody body = mRequest.body(); + if (body != null) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + BufferedSink sink = Okio.buffer(Okio.sink(out)); + body.writeTo(sink); + sink.flush(); + return out.toByteArray(); + } else { + return null; + } + } + + @Override + public int headerCount() { + return mRequest.headers().size(); + } + + @Override + public String headerName(int index) { + return mRequest.headers().name(index); + } + + @Override + public String headerValue(int index) { + return mRequest.headers().value(index); + } + + @Nullable + @Override + public String firstHeaderValue(String name) { + return mRequest.header(name); + } + } + + private static class OkHttpInspectorResponse implements NetworkEventReporter.InspectorResponse { + private final String mRequestId; + private final Request mRequest; + private final Response mResponse; + private final Connection mConnection; + + public OkHttpInspectorResponse( + String requestId, + Request request, + Response response, + Connection connection) { + mRequestId = requestId; + mRequest = request; + mResponse = response; + mConnection = connection; + } + + @Override + public String requestId() { + return mRequestId; + } + + @Override + public String url() { + return mRequest.urlString(); + } + + @Override + public int statusCode() { + return mResponse.code(); + } + + @Override + public String reasonPhrase() { + return mResponse.message(); + } + + @Override + public boolean connectionReused() { + // Not sure... + return false; + } + + @Override + public int connectionId() { + return mConnection.hashCode(); + } + + @Override + public boolean fromDiskCache() { + return mResponse.cacheResponse() != null; + } + + @Override + public int headerCount() { + return mResponse.headers().size(); + } + + @Override + public String headerName(int index) { + return mResponse.headers().name(index); + } + + @Override + public String headerValue(int index) { + return mResponse.headers().value(index); + } + + @Nullable + @Override + public String firstHeaderValue(String name) { + return mResponse.header(name); + } + } + + private static class ForwardingResponseBody extends ResponseBody { + private final ResponseBody mBody; + private final BufferedSource mInterceptedSource; + + public ForwardingResponseBody(ResponseBody body, InputStream interceptedStream) { + mBody = body; + mInterceptedSource = Okio.buffer(Okio.source(interceptedStream)); + } + + @Override + public MediaType contentType() { + return mBody.contentType(); + } + + @Override + public long contentLength() { + return mBody.contentLength(); + } + + @Override + public BufferedSource source() { + // close on the delegating body will actually close this intercepted source, but it + // was derived from mBody.byteStream() therefore the close will be forwarded all the + // way to the original. + return mInterceptedSource; + } + } +} diff --git a/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java b/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java new file mode 100644 index 00000000..df36ec63 --- /dev/null +++ b/stetho-okhttp/src/test/java/com/facebook/stetho/okhttp/StethoInterceptorTest.java @@ -0,0 +1,150 @@ +package com.facebook.stetho.okhttp; + +import android.net.Uri; +import android.os.Build; +import com.facebook.stetho.inspector.network.*; +import com.squareup.okhttp.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.*; + +@Config(emulateSdk = Build.VERSION_CODES.JELLY_BEAN) +@RunWith(RobolectricTestRunner.class) +@PowerMockIgnore({ "org.mockito.*", "org.robolectric.*", "android.*" }) +@PrepareForTest(NetworkEventReporterImpl.class) +public class StethoInterceptorTest { + @Rule + public PowerMockRule rule = new PowerMockRule(); + + @Test + public void testManualChainHappyPath() throws IOException { + PowerMockito.mockStatic(NetworkEventReporterImpl.class); + + final NetworkEventReporter mockEventReporter = Mockito.mock(NetworkEventReporter.class); + InOrder inOrder = Mockito.inOrder(mockEventReporter); + Mockito.when(mockEventReporter.isEnabled()).thenReturn(true); + ByteArrayOutputStream capturedOutput = fakeInterpretResponseStream(mockEventReporter); + PowerMockito.when(NetworkEventReporterImpl.get()).thenReturn(mockEventReporter); + + StethoInterceptor interceptor = new StethoInterceptor(); + + Uri requestUri = Uri.parse("http://www.facebook.com/nowhere"); + Request request = new Request.Builder() + .url(requestUri.toString()) + .method( + "POST", + RequestBody.create(MediaType.parse("text/plain"), "Test input")) + .build(); + String originalBodyData = "Success!"; + Response reply = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .body(ResponseBody.create(MediaType.parse("text/plain"), originalBodyData)) + .build(); + Response filteredResponse = + interceptor.intercept( + new SimpleTestChain(request, reply, null)); + + inOrder.verify(mockEventReporter).isEnabled(); + inOrder.verify(mockEventReporter) + .requestWillBeSent(any(NetworkEventReporter.InspectorRequest.class)); + inOrder.verify(mockEventReporter).dataSent(anyString(), anyInt(), anyInt()); + inOrder.verify(mockEventReporter) + .responseHeadersReceived(any(NetworkEventReporter.InspectorResponse.class)); + + String filteredResponseString = filteredResponse.body().string(); + String interceptedOutput = capturedOutput.toString(); + + inOrder.verify(mockEventReporter).dataReceived(anyString(), anyInt(), anyInt()); + inOrder.verify(mockEventReporter).responseReadFinished(anyString()); + + assertEquals(originalBodyData, filteredResponseString); + assertEquals(originalBodyData, interceptedOutput); + + inOrder.verifyNoMoreInteractions(); + } + + /** + * Provide a suitably "real" implementation of + * {@link NetworkEventReporter#interpretResponseStream} for our mock to test that + * events are properly delegated. + */ + private static ByteArrayOutputStream fakeInterpretResponseStream( + final NetworkEventReporter mockEventReporter) { + final ByteArrayOutputStream capturedOutput = new ByteArrayOutputStream(); + Mockito.when( + mockEventReporter.interpretResponseStream( + anyString(), + anyString(), + any(InputStream.class), + any(ResponseHandler.class))) + .thenAnswer( + new Answer() { + @Override + public InputStream answer(InvocationOnMock invocationOnMock) throws Throwable { + Object[] args = invocationOnMock.getArguments(); + String requestId = (String)args[0]; + InputStream responseStream = (InputStream)args[2]; + return new ResponseHandlingInputStream( + responseStream, + requestId, + capturedOutput, + null /* networkPeerManager */, + new DefaultResponseHandler(mockEventReporter, requestId)); + } + }); + return capturedOutput; + } + + private static class SimpleTestChain implements Interceptor.Chain { + private final Request mRequest; + private final Response mResponse; + @Nullable private final Connection mConnection; + + public SimpleTestChain(Request request, Response response, @Nullable Connection connection) { + mRequest = request; + mResponse = response; + mConnection = connection; + } + + @Override + public Request request() { + return mRequest; + } + + @Override + public Response proceed(Request request) throws IOException { + if (mRequest != request) { + throw new IllegalArgumentException( + "Expected " + System.identityHashCode(mRequest) + + "; got " + System.identityHashCode(request)); + } + return mResponse; + } + + @Override + public Connection connection() { + return mConnection; + } + } +} diff --git a/stetho/src/main/java/com/facebook/stetho/inspector/network/ResponseHandlingInputStream.java b/stetho/src/main/java/com/facebook/stetho/inspector/network/ResponseHandlingInputStream.java index bf9c9013..6597fcf5 100644 --- a/stetho/src/main/java/com/facebook/stetho/inspector/network/ResponseHandlingInputStream.java +++ b/stetho/src/main/java/com/facebook/stetho/inspector/network/ResponseHandlingInputStream.java @@ -20,7 +20,8 @@ * {@link InputStream} passing all data to the {@link OutputStream}. * This is done to allow us to guarantee all responses are represented in the webkit inspector. */ -final class ResponseHandlingInputStream extends FilterInputStream { +// @VisibleForTest +public final class ResponseHandlingInputStream extends FilterInputStream { public static final String TAG = "ResponseHandlingInputStream";