From 3a0874cb220a26170e7097432676307feef4504f Mon Sep 17 00:00:00 2001 From: jwilson Date: Tue, 22 Jan 2013 10:45:24 -0500 Subject: [PATCH] Introduce MockSpdyServer. This is like MockWebServer, but it speaks SPDY behind the scenes instead of HTTP. The goal with this change is to make it very easy to test interactions between SPDY and HTTP features like cookies, fault tolerance and caching. --- README.md | 14 +- pom.xml | 28 +- .../okhttp/internal/spdy/SpdyConnection.java | 1 - .../mockspdyserver/MockSpdyServer.java | 267 ++++++++++++++++++ .../internal/spdy/HttpOverSpdyTest.java | 110 ++++++++ 5 files changed, 387 insertions(+), 33 deletions(-) create mode 100644 src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java create mode 100644 src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java diff --git a/README.md b/README.md index a05b3d5cd..b74570ebc 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,6 @@ You can also depend on the .jar through Maven: Known Issues ------------ -The SPDY implementation is incomplete: - -* Settings frames are not honored. Flow control is not implemented. -* It assumes a well-behaved peer. If the peer sends an invalid frame, OkHttp's SPDY client will not respond with the required `RST` frame. - OkHttp uses the platform's [ProxySelector][2]. Prior to Android 4.0, `ProxySelector` didn't honor the `proxyHost` and `proxyPort` system properties for HTTPS connections. Work around this by specifying the `https.proxyHost` and `https.proxyPort` system properties when using a proxy with HTTPS. OkHttp's test suite creates an in-process HTTPS server. Prior to Android 2.3, SSL server sockets were broken, and so HTTPS tests will time out when run on such devices. @@ -37,17 +32,10 @@ Building -------- ### On the Desktop -Run OkHttp tests on the desktop with Maven. +Run OkHttp tests on the desktop with Maven. Running SPDY tests on the desktop uses [Jetty-NPN](http://wiki.eclipse.org/Jetty/Feature/NPN) which requires OpenJDK 7+. ``` mvn clean test ``` -SPDY support uses a Deflater API that wasn't available in Java 6. For this reason SPDY tests will fail with this error: `Cannot SPDY; no SYNC_FLUSH available`. All other tests should run fine. - -### On the Desktop with NPN -Using NPN on the desktop uses [Jetty-NPN](http://wiki.eclipse.org/Jetty/Feature/NPN) which requires OpenJDK 7+. -``` -mvn clean test -Pspdy-tls -``` ### On a Device Test on a USB-attached Android using [Vogar](https://code.google.com/p/vogar/). Unfortunately `dx` requires that you build with Java 6, otherwise the test class will be silently omitted from the `.dex` file. diff --git a/pom.xml b/pom.xml index 7b7ecba85..0afcaa61e 100644 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,7 @@ 1.6 8.1.2.v20120308 - 20120905 + 20130122 1.47 @@ -120,25 +120,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.9 + + -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar + + - - - - spdy-tls - - - - org.apache.maven.plugins - maven-surefire-plugin - 2.9 - - -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar - - - - - - diff --git a/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java b/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java index 0f10cf3bc..347be634f 100644 --- a/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java +++ b/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java @@ -21,7 +21,6 @@ import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.net.ProtocolException; import java.net.Socket; import java.util.HashMap; import java.util.Iterator; diff --git a/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java b/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java new file mode 100644 index 000000000..64ab54926 --- /dev/null +++ b/src/test/java/com/squareup/okhttp/internal/mockspdyserver/MockSpdyServer.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2013 Square, Inc. + * Copyright (C) 2011 The Android Open Source Project + * + * 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 com.squareup.okhttp.internal.mockspdyserver; + +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.QueueDispatcher; +import com.google.mockwebserver.RecordedRequest; +import com.squareup.okhttp.internal.spdy.IncomingStreamHandler; +import com.squareup.okhttp.internal.spdy.SpdyConnection; +import com.squareup.okhttp.internal.spdy.SpdyStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import org.eclipse.jetty.npn.NextProtoNego; + +/** + * A scriptable spdy/3 + HTTP server. + */ +public final class MockSpdyServer { + private static final Logger logger = Logger.getLogger(MockSpdyServer.class.getName()); + private SSLSocketFactory sslSocketFactory; + private QueueDispatcher dispatcher = new QueueDispatcher(); + private ServerSocket serverSocket; + private final Set openClientSockets + = Collections.newSetFromMap(new ConcurrentHashMap()); + private int port = -1; + private final BlockingQueue requestQueue + = new LinkedBlockingQueue(); + + public MockSpdyServer(SSLSocketFactory sslSocketFactory) { + this.sslSocketFactory = sslSocketFactory; + } + + public String getHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + throw new AssertionError(); + } + } + + public int getPort() { + if (port == -1) { + throw new IllegalStateException("Cannot retrieve port before calling play()"); + } + return port; + } + + public URL getUrl(String path) { + try { + return new URL("https://" + getHostName() + ":" + getPort() + path); + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + } + + /** + * Awaits the next HTTP request, removes it, and returns it. Callers should + * use this to verify the request sent was as intended. + */ + public RecordedRequest takeRequest() throws InterruptedException { + return requestQueue.take(); + } + + public void play() throws IOException { + serverSocket = new ServerSocket(8888); + serverSocket.setReuseAddress(true); + port = serverSocket.getLocalPort(); + + Thread acceptThread = new Thread("MockSpdyServer-accept-" + port) { + @Override public void run() { + int sequenceNumber = 0; + try { + acceptConnections(sequenceNumber); + } catch (Throwable e) { + logger.log(Level.WARNING, "MockWebServer connection failed", e); + } + + /* + * This gnarly block of code will release all sockets and + * all thread, even if any close fails. + */ + try { + serverSocket.close(); + } catch (Throwable e) { + logger.log(Level.WARNING, "MockWebServer server socket close failed", e); + } + for (Iterator s = openClientSockets.iterator(); s.hasNext(); ) { + try { + s.next().close(); + s.remove(); + } catch (Throwable e) { + logger.log(Level.WARNING, "MockWebServer socket close failed", e); + } + } + } + }; + acceptThread.start(); + } + + public void enqueue(MockResponse response) { + dispatcher.enqueueResponse(response); + } + + private void acceptConnections(int sequenceNumber) throws Exception { + while (true) { + Socket socket; + try { + socket = serverSocket.accept(); + } catch (SocketException e) { + return; + } + openClientSockets.add(socket); + new SocketHandler(sequenceNumber++, socket).serve(); + } + } + + public void shutdown() throws IOException { + if (serverSocket != null) { + serverSocket.close(); // should cause acceptConnections() to break out + } + } + + private class SocketHandler implements IncomingStreamHandler { + private final int sequenceNumber; + private Socket socket; + + private SocketHandler(int sequenceNumber, Socket socket) throws IOException { + this.socket = socket; + this.sequenceNumber = sequenceNumber; + } + + public void serve() throws IOException { + if (sslSocketFactory != null) { + socket = doSsl(socket); + } + new SpdyConnection.Builder(false, socket).handler(this).build(); + } + + private Socket doSsl(Socket socket) throws IOException { + SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socket, + socket.getInetAddress().getHostAddress(), socket.getPort(), true); + sslSocket.setUseClientMode(false); + NextProtoNego.put(sslSocket, new NextProtoNego.ServerProvider() { + @Override public void unsupported() { + System.out.println("UNSUPPORTED"); + } + @Override public List protocols() { + return Arrays.asList("spdy/3"); + } + @Override public void protocolSelected(String protocol) { + System.out.println("PROTOCOL SELECTED: " + protocol); + } + }); + return sslSocket; + } + + @Override public void receive(final SpdyStream stream) throws IOException { + RecordedRequest request = readRequest(stream); + requestQueue.add(request); + MockResponse response; + try { + response = dispatcher.dispatch(request); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + writeResponse(stream, response); + logger.info("Received request: " + request + " and responded: " + response); + } + + private RecordedRequest readRequest(SpdyStream stream) throws IOException { + List spdyHeaders = stream.getRequestHeaders(); + List httpHeaders = new ArrayList(); + String method = "<:method omitted>"; + String path = "<:path omitted>"; + String version = "<:version omitted>"; + for (Iterator i = spdyHeaders.iterator(); i.hasNext(); ) { + String name = i.next(); + String value = i.next(); + if (":method".equals(name)) { + method = value; + } else if (":path".equals(name)) { + path = value; + } else if (":version".equals(name)) { + version = value; + } else { + httpHeaders.add(name + ": " + value); + } + } + + InputStream bodyIn = stream.getInputStream(); + ByteArrayOutputStream bodyOut = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = bodyIn.read(buffer)) != -1) { + bodyOut.write(buffer, 0, count); + } + bodyIn.close(); + String requestLine = method + ' ' + path + ' ' + version; + List chunkSizes = Collections.emptyList(); // No chunked encoding for SPDY. + return new RecordedRequest(requestLine, httpHeaders, chunkSizes, bodyOut.size(), + bodyOut.toByteArray(), sequenceNumber, socket); + } + + private void writeResponse(SpdyStream stream, MockResponse response) throws IOException { + List spdyHeaders = new ArrayList(); + String[] statusParts = response.getStatus().split(" ", 2); + if (statusParts.length != 2) { + throw new AssertionError("Unexpected status: " + response.getStatus()); + } + spdyHeaders.add(":status"); + spdyHeaders.add(statusParts[1]); + spdyHeaders.add(":version"); + spdyHeaders.add(statusParts[0]); + for (String header : response.getHeaders()) { + String[] headerParts = header.split(":", 2); + if (headerParts.length != 2) { + throw new AssertionError("Unexpected header: " + header); + } + spdyHeaders.add(headerParts[0].toLowerCase(Locale.US)); + spdyHeaders.add(headerParts[1]); + } + byte[] body = response.getBody(); + stream.reply(spdyHeaders, body.length > 0); + if (body.length > 0) { + stream.getOutputStream().write(body); + stream.getOutputStream().close(); + } + } + } +} diff --git a/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java b/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java new file mode 100644 index 000000000..160f9fdc9 --- /dev/null +++ b/src/test/java/com/squareup/okhttp/internal/spdy/HttpOverSpdyTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2013 Square, 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 com.squareup.okhttp.internal.spdy; + +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.RecordedRequest; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.internal.SslContextBuilder; +import com.squareup.okhttp.internal.mockspdyserver.MockSpdyServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URLConnection; +import java.net.UnknownHostException; +import java.security.GeneralSecurityException; +import java.util.Collection; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import org.junit.After; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Before; +import org.junit.Test; + +/** + * Test how SPDY interacts with HTTP features. + */ +public final class HttpOverSpdyTest { + private static final HostnameVerifier NULL_HOSTNAME_VERIFIER = new HostnameVerifier() { + public boolean verify(String hostname, SSLSession session) { + return true; + } + }; + + private static final SSLContext sslContext; + static { + try { + sslContext = new SslContextBuilder(InetAddress.getLocalHost().getHostName()).build(); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } + private final MockSpdyServer server = new MockSpdyServer(sslContext.getSocketFactory()); + private final String hostName = server.getHostName(); + private final OkHttpClient client = new OkHttpClient(); + + @Before public void setUp() throws Exception { + client.setSSLSocketFactory(sslContext.getSocketFactory()); + client.setHostnameVerifier(NULL_HOSTNAME_VERIFIER); + } + + @After public void tearDown() throws Exception { + server.shutdown(); + } + + @Test public void get() throws Exception { + MockResponse response = new MockResponse().setBody("ABCDE"); + server.enqueue(response); + server.play(); + + HttpURLConnection connection = client.open(server.getUrl("/foo")); + assertContent("ABCDE", connection, Integer.MAX_VALUE); + + RecordedRequest request = server.takeRequest(); + assertEquals("GET /foo HTTP/1.1", request.getRequestLine()); + assertContains(request.getHeaders(), ":scheme: https"); + assertContains(request.getHeaders(), ":host: " + hostName + ":" + server.getPort()); + } + + private void assertContains(Collection collection, T value) { + assertTrue(collection.toString(), collection.contains(value)); + } + + private void assertContent(String expected, URLConnection connection, int limit) + throws IOException { + connection.connect(); + assertEquals(expected, readAscii(connection.getInputStream(), limit)); + ((HttpURLConnection) connection).disconnect(); + } + + private String readAscii(InputStream in, int count) throws IOException { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < count; i++) { + int value = in.read(); + if (value == -1) { + in.close(); + break; + } + result.append((char) value); + } + return result.toString(); + } +}