diff --git a/README.md b/README.md index a05b3d5cdf12..b74570ebc238 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 7b7ecba85f65..0afcaa61e7d3 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 0f10cf3bc32c..347be634fed8 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.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 000000000000..64ab5492650d --- /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 000000000000..160f9fdc9cdd --- /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(); + } +}