Skip to content

Commit

Permalink
Introduce MockSpdyServer.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
squarejesse committed Jan 22, 2013
1 parent 514fbfd commit 3a0874c
Show file tree
Hide file tree
Showing 5 changed files with 387 additions and 33 deletions.
14 changes: 1 addition & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
28 changes: 9 additions & 19 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<!-- Compilation -->
<java.version>1.6</java.version>
<npn.version>8.1.2.v20120308</npn.version>
<mockwebserver.version>20120905</mockwebserver.version>
<mockwebserver.version>20130122</mockwebserver.version>
<bouncycastle.version>1.47</bouncycastle.version>

<!-- Test Dependencies -->
Expand Down Expand Up @@ -120,25 +120,15 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.9</version>
<configuration>
<argLine>-Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar</argLine>
</configuration>
</plugin>
</plugins>
</build>

<profiles>
<profile>
<id>spdy-tls</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.9</version>
<configuration>
<argLine>-Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/npn/npn-boot/${npn.version}/npn-boot-${npn.version}.jar</argLine>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Socket> openClientSockets
= Collections.newSetFromMap(new ConcurrentHashMap<Socket, Boolean>());
private int port = -1;
private final BlockingQueue<RecordedRequest> requestQueue
= new LinkedBlockingQueue<RecordedRequest>();

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<Socket> 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<String> 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<String> spdyHeaders = stream.getRequestHeaders();
List<String> httpHeaders = new ArrayList<String>();
String method = "<:method omitted>";
String path = "<:path omitted>";
String version = "<:version omitted>";
for (Iterator<String> 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<Integer> 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<String> spdyHeaders = new ArrayList<String>();
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();
}
}
}
}
Loading

0 comments on commit 3a0874c

Please sign in to comment.