| /* |
| * Copyright (C) 2010 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 tests.http; |
| |
| import java.io.BufferedInputStream; |
| import java.io.BufferedOutputStream; |
| import java.io.ByteArrayOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.net.HttpURLConnection; |
| import java.net.InetAddress; |
| import java.net.InetSocketAddress; |
| import java.net.MalformedURLException; |
| import java.net.Proxy; |
| 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.Collections; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.concurrent.BlockingQueue; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.LinkedBlockingDeque; |
| import java.util.concurrent.LinkedBlockingQueue; |
| import java.util.concurrent.atomic.AtomicInteger; |
| import java.util.logging.Level; |
| import java.util.logging.Logger; |
| import javax.net.ssl.SSLSocket; |
| import javax.net.ssl.SSLSocketFactory; |
| import static tests.http.SocketPolicy.DISCONNECT_AT_START; |
| |
| /** |
| * A scriptable web server. Callers supply canned responses and the server |
| * replays them upon request in sequence. |
| * |
| * @deprecated prefer com.google.mockwebserver.MockWebServer |
| */ |
| @Deprecated |
| public final class MockWebServer { |
| |
| static final String ASCII = "US-ASCII"; |
| |
| private static final Logger logger = Logger.getLogger(MockWebServer.class.getName()); |
| private final BlockingQueue<RecordedRequest> requestQueue |
| = new LinkedBlockingQueue<RecordedRequest>(); |
| private final BlockingQueue<MockResponse> responseQueue |
| = new LinkedBlockingDeque<MockResponse>(); |
| private final Set<Socket> openClientSockets |
| = Collections.newSetFromMap(new ConcurrentHashMap<Socket, Boolean>()); |
| private boolean singleResponse; |
| private final AtomicInteger requestCount = new AtomicInteger(); |
| private int bodyLimit = Integer.MAX_VALUE; |
| private ServerSocket serverSocket; |
| private SSLSocketFactory sslSocketFactory; |
| private ExecutorService executor; |
| private boolean tunnelProxy; |
| |
| private int port = -1; |
| |
| public int getPort() { |
| if (port == -1) { |
| throw new IllegalStateException("Cannot retrieve port before calling play()"); |
| } |
| return port; |
| } |
| |
| public String getHostName() { |
| return InetAddress.getLoopbackAddress().getHostName(); |
| } |
| |
| public Proxy toProxyAddress() { |
| return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(getHostName(), getPort())); |
| } |
| |
| /** |
| * Returns a URL for connecting to this server. |
| * |
| * @param path the request path, such as "/". |
| */ |
| public URL getUrl(String path) throws MalformedURLException, UnknownHostException { |
| return sslSocketFactory != null |
| ? new URL("https://" + getHostName() + ":" + getPort() + path) |
| : new URL("http://" + getHostName() + ":" + getPort() + path); |
| } |
| |
| /** |
| * Sets the number of bytes of the POST body to keep in memory to the given |
| * limit. |
| */ |
| public void setBodyLimit(int maxBodyLength) { |
| this.bodyLimit = maxBodyLength; |
| } |
| |
| /** |
| * Serve requests with HTTPS rather than otherwise. |
| * |
| * @param tunnelProxy whether to expect the HTTP CONNECT method before |
| * negotiating TLS. |
| */ |
| public void useHttps(SSLSocketFactory sslSocketFactory, boolean tunnelProxy) { |
| this.sslSocketFactory = sslSocketFactory; |
| this.tunnelProxy = tunnelProxy; |
| } |
| |
| /** |
| * 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(); |
| } |
| |
| /** |
| * Returns the number of HTTP requests received thus far by this server. |
| * This may exceed the number of HTTP connections when connection reuse is |
| * in practice. |
| */ |
| public int getRequestCount() { |
| return requestCount.get(); |
| } |
| |
| public void enqueue(MockResponse response) { |
| responseQueue.add(response.clone()); |
| } |
| |
| /** |
| * By default, this class processes requests coming in by adding them to a |
| * queue and serves responses by removing them from another queue. This mode |
| * is appropriate for correctness testing. |
| * |
| * <p>Serving a single response causes the server to be stateless: requests |
| * are not enqueued, and responses are not dequeued. This mode is appropriate |
| * for benchmarking. |
| */ |
| public void setSingleResponse(boolean singleResponse) { |
| this.singleResponse = singleResponse; |
| } |
| |
| /** |
| * Equivalent to {@code play(0)}. |
| */ |
| public void play() throws IOException { |
| play(0); |
| } |
| |
| /** |
| * Starts the server, serves all enqueued requests, and shuts the server |
| * down. |
| * |
| * @param port the port to listen to, or 0 for any available port. |
| * Automated tests should always use port 0 to avoid flakiness when a |
| * specific port is unavailable. |
| */ |
| public void play(int port) throws IOException { |
| executor = Executors.newCachedThreadPool(); |
| serverSocket = new ServerSocket(port); |
| serverSocket.setReuseAddress(true); |
| |
| this.port = serverSocket.getLocalPort(); |
| executor.execute(namedRunnable("MockWebServer-accept-" + port, new Runnable() { |
| public void run() { |
| try { |
| acceptConnections(); |
| } 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); |
| } |
| } |
| try { |
| executor.shutdown(); |
| } catch (Throwable e) { |
| logger.log(Level.WARNING, "MockWebServer executor shutdown failed", e); |
| } |
| } |
| |
| private void acceptConnections() throws Exception { |
| do { |
| Socket socket; |
| try { |
| socket = serverSocket.accept(); |
| } catch (SocketException ignored) { |
| continue; |
| } |
| MockResponse peek = responseQueue.peek(); |
| if (peek != null && peek.getSocketPolicy() == DISCONNECT_AT_START) { |
| responseQueue.take(); |
| socket.close(); |
| } else { |
| openClientSockets.add(socket); |
| serveConnection(socket); |
| } |
| } while (!responseQueue.isEmpty()); |
| } |
| })); |
| } |
| |
| public void shutdown() throws IOException { |
| if (serverSocket != null) { |
| serverSocket.close(); // should cause acceptConnections() to break out |
| } |
| } |
| |
| private void serveConnection(final Socket raw) { |
| String name = "MockWebServer-" + raw.getRemoteSocketAddress(); |
| executor.execute(namedRunnable(name, new Runnable() { |
| int sequenceNumber = 0; |
| |
| public void run() { |
| try { |
| processConnection(); |
| } catch (Exception e) { |
| logger.log(Level.WARNING, "MockWebServer connection failed", e); |
| } |
| } |
| |
| public void processConnection() throws Exception { |
| Socket socket; |
| if (sslSocketFactory != null) { |
| if (tunnelProxy) { |
| createTunnel(); |
| } |
| socket = sslSocketFactory.createSocket( |
| raw, raw.getInetAddress().getHostAddress(), raw.getPort(), true); |
| ((SSLSocket) socket).setUseClientMode(false); |
| openClientSockets.add(socket); |
| openClientSockets.remove(raw); |
| } else { |
| socket = raw; |
| } |
| |
| InputStream in = new BufferedInputStream(socket.getInputStream()); |
| OutputStream out = new BufferedOutputStream(socket.getOutputStream()); |
| |
| while (!responseQueue.isEmpty() && processOneRequest(in, out, socket)) {} |
| |
| if (sequenceNumber == 0) { |
| logger.warning("MockWebServer connection didn't make a request"); |
| } |
| |
| in.close(); |
| out.close(); |
| socket.close(); |
| if (responseQueue.isEmpty()) { |
| shutdown(); |
| } |
| openClientSockets.remove(socket); |
| } |
| |
| /** |
| * Respond to CONNECT requests until a SWITCH_TO_SSL_AT_END response |
| * is dispatched. |
| */ |
| private void createTunnel() throws IOException, InterruptedException { |
| while (true) { |
| MockResponse connect = responseQueue.peek(); |
| if (!processOneRequest(raw.getInputStream(), raw.getOutputStream(), raw)) { |
| throw new IllegalStateException("Tunnel without any CONNECT!"); |
| } |
| if (connect.getSocketPolicy() == SocketPolicy.UPGRADE_TO_SSL_AT_END) { |
| return; |
| } |
| } |
| } |
| |
| /** |
| * Reads a request and writes its response. Returns true if a request |
| * was processed. |
| */ |
| private boolean processOneRequest(InputStream in, OutputStream out, Socket socket) |
| throws IOException, InterruptedException { |
| RecordedRequest request = readRequest(in, sequenceNumber); |
| if (request == null) { |
| return false; |
| } |
| MockResponse response = dispatch(request); |
| writeResponse(out, response); |
| if (response.getSocketPolicy() == SocketPolicy.DISCONNECT_AT_END) { |
| in.close(); |
| out.close(); |
| } else if (response.getSocketPolicy() == SocketPolicy.SHUTDOWN_INPUT_AT_END) { |
| socket.shutdownInput(); |
| } else if (response.getSocketPolicy() == SocketPolicy.SHUTDOWN_OUTPUT_AT_END) { |
| socket.shutdownOutput(); |
| } |
| sequenceNumber++; |
| return true; |
| } |
| })); |
| } |
| |
| /** |
| * @param sequenceNumber the index of this request on this connection. |
| */ |
| private RecordedRequest readRequest(InputStream in, int sequenceNumber) throws IOException { |
| String request; |
| try { |
| request = readAsciiUntilCrlf(in); |
| } catch (IOException streamIsClosed) { |
| return null; // no request because we closed the stream |
| } |
| if (request.isEmpty()) { |
| return null; // no request because the stream is exhausted |
| } |
| |
| List<String> headers = new ArrayList<String>(); |
| int contentLength = -1; |
| boolean chunked = false; |
| String header; |
| while (!(header = readAsciiUntilCrlf(in)).isEmpty()) { |
| headers.add(header); |
| String lowercaseHeader = header.toLowerCase(); |
| if (contentLength == -1 && lowercaseHeader.startsWith("content-length:")) { |
| contentLength = Integer.parseInt(header.substring(15).trim()); |
| } |
| if (lowercaseHeader.startsWith("transfer-encoding:") && |
| lowercaseHeader.substring(18).trim().equals("chunked")) { |
| chunked = true; |
| } |
| } |
| |
| boolean hasBody = false; |
| TruncatingOutputStream requestBody = new TruncatingOutputStream(); |
| List<Integer> chunkSizes = new ArrayList<Integer>(); |
| if (contentLength != -1) { |
| hasBody = true; |
| transfer(contentLength, in, requestBody); |
| } else if (chunked) { |
| hasBody = true; |
| while (true) { |
| int chunkSize = Integer.parseInt(readAsciiUntilCrlf(in).trim(), 16); |
| if (chunkSize == 0) { |
| readEmptyLine(in); |
| break; |
| } |
| chunkSizes.add(chunkSize); |
| transfer(chunkSize, in, requestBody); |
| readEmptyLine(in); |
| } |
| } |
| |
| if (request.startsWith("OPTIONS ") || request.startsWith("GET ") |
| || request.startsWith("HEAD ") || request.startsWith("DELETE ") |
| || request .startsWith("TRACE ") || request.startsWith("CONNECT ")) { |
| if (hasBody) { |
| throw new IllegalArgumentException("Request must not have a body: " + request); |
| } |
| } else if (request.startsWith("POST ") || request.startsWith("PUT ")) { |
| if (!hasBody) { |
| throw new IllegalArgumentException("Request must have a body: " + request); |
| } |
| } else { |
| throw new UnsupportedOperationException("Unexpected method: " + request); |
| } |
| |
| return new RecordedRequest(request, headers, chunkSizes, |
| requestBody.numBytesReceived, requestBody.toByteArray(), sequenceNumber); |
| } |
| |
| /** |
| * Returns a response to satisfy {@code request}. |
| */ |
| private MockResponse dispatch(RecordedRequest request) throws InterruptedException { |
| if (responseQueue.isEmpty()) { |
| throw new IllegalStateException("Unexpected request: " + request); |
| } |
| |
| // to permit interactive/browser testing, ignore requests for favicons |
| if (request.getRequestLine().equals("GET /favicon.ico HTTP/1.1")) { |
| System.out.println("served " + request.getRequestLine()); |
| return new MockResponse() |
| .setResponseCode(HttpURLConnection.HTTP_NOT_FOUND); |
| } |
| |
| if (singleResponse) { |
| return responseQueue.peek(); |
| } else { |
| requestCount.incrementAndGet(); |
| requestQueue.add(request); |
| return responseQueue.take(); |
| } |
| } |
| |
| private void writeResponse(OutputStream out, MockResponse response) throws IOException { |
| out.write((response.getStatus() + "\r\n").getBytes(ASCII)); |
| for (String header : response.getHeaders()) { |
| out.write((header + "\r\n").getBytes(ASCII)); |
| } |
| out.write(("\r\n").getBytes(ASCII)); |
| out.write(response.getBody()); |
| out.flush(); |
| } |
| |
| /** |
| * Transfer bytes from {@code in} to {@code out} until either {@code length} |
| * bytes have been transferred or {@code in} is exhausted. |
| */ |
| private void transfer(int length, InputStream in, OutputStream out) throws IOException { |
| byte[] buffer = new byte[1024]; |
| while (length > 0) { |
| int count = in.read(buffer, 0, Math.min(buffer.length, length)); |
| if (count == -1) { |
| return; |
| } |
| out.write(buffer, 0, count); |
| length -= count; |
| } |
| } |
| |
| /** |
| * Returns the text from {@code in} until the next "\r\n", or null if |
| * {@code in} is exhausted. |
| */ |
| private String readAsciiUntilCrlf(InputStream in) throws IOException { |
| StringBuilder builder = new StringBuilder(); |
| while (true) { |
| int c = in.read(); |
| if (c == '\n' && builder.length() > 0 && builder.charAt(builder.length() - 1) == '\r') { |
| builder.deleteCharAt(builder.length() - 1); |
| return builder.toString(); |
| } else if (c == -1) { |
| return builder.toString(); |
| } else { |
| builder.append((char) c); |
| } |
| } |
| } |
| |
| private void readEmptyLine(InputStream in) throws IOException { |
| String line = readAsciiUntilCrlf(in); |
| if (!line.isEmpty()) { |
| throw new IllegalStateException("Expected empty but was: " + line); |
| } |
| } |
| |
| /** |
| * An output stream that drops data after bodyLimit bytes. |
| */ |
| private class TruncatingOutputStream extends ByteArrayOutputStream { |
| private int numBytesReceived = 0; |
| @Override public void write(byte[] buffer, int offset, int len) { |
| numBytesReceived += len; |
| super.write(buffer, offset, Math.min(len, bodyLimit - count)); |
| } |
| @Override public void write(int oneByte) { |
| numBytesReceived++; |
| if (count < bodyLimit) { |
| super.write(oneByte); |
| } |
| } |
| } |
| |
| private static Runnable namedRunnable(final String name, final Runnable runnable) { |
| return new Runnable() { |
| public void run() { |
| String originalName = Thread.currentThread().getName(); |
| Thread.currentThread().setName(name); |
| try { |
| runnable.run(); |
| } finally { |
| Thread.currentThread().setName(originalName); |
| } |
| } |
| }; |
| } |
| } |