blob: 299ae6fed8474703ab9d479e8578e88cf727ecfb [file] [log] [blame]
// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.net.test.util;
import android.util.Base64;
import android.util.Log;
import android.util.Pair;
import org.chromium.base.ApiCompatibilityUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Simple http test server for testing.
*
* Extends WebServer with the ability to map requests to prepared responses.
*/
public class TestWebServer extends WebServer {
private static final String TAG = "TestWebServer";
private static class Response {
final byte[] mResponseData;
final List<Pair<String, String>> mResponseHeaders;
final boolean mIsRedirect;
final Runnable mResponseAction;
final boolean mIsNotFound;
final boolean mIsNoContent;
final boolean mForWebSocket;
final boolean mIsEmptyResponse;
Response(
byte[] responseData,
List<Pair<String, String>> responseHeaders,
boolean isRedirect,
boolean isNotFound,
boolean isNoContent,
boolean forWebSocket,
boolean isEmptyResponse,
Runnable responseAction) {
mIsRedirect = isRedirect;
mIsNotFound = isNotFound;
mIsNoContent = isNoContent;
mForWebSocket = forWebSocket;
mIsEmptyResponse = isEmptyResponse;
mResponseData = responseData;
mResponseHeaders =
responseHeaders == null
? new ArrayList<Pair<String, String>>()
: responseHeaders;
mResponseAction = responseAction;
}
}
// The Maps below are modified on both the client thread and the internal server thread, so
// need to use a lock when accessing them.
private final Object mLock = new Object();
private final Map<String, Response> mResponseMap = new HashMap<String, Response>();
private final Map<String, Integer> mResponseCountMap = new HashMap<String, Integer>();
private final Map<String, HTTPRequest> mLastRequestMap = new HashMap<String, HTTPRequest>();
/**
* Create and start a local HTTP server instance.
* @param port Port number the server must use, or 0 to automatically choose a free port.
* @param ssl True if the server should be using secure sockets.
* @param additional True if creating an additional server instance.
* @throws Exception
*/
private TestWebServer(int port, boolean ssl, boolean additional) throws Exception {
super(port, ssl, additional);
setRequestHandler(new Handler());
}
private class Handler implements WebServer.RequestHandler {
@Override
public void handleRequest(WebServer.HTTPRequest request, OutputStream stream) {
WebServerPrintStream printStream = new WebServerPrintStream(stream);
try {
outputResponse(request, printStream);
} catch (NoSuchAlgorithmException ignore) {
} catch (IOException e) {
Log.w(TAG, e);
} finally {
printStream.close();
}
}
}
/**
* Create and start a local HTTP server instance. This function must only
* be called if no other instances were created. You are responsible for
* calling shutdown() on each instance you create.
*
* @param port Port number the server must use, or 0 to automatically choose a free port.
*/
public static TestWebServer start(int port) throws Exception {
return new TestWebServer(port, false, false);
}
/** Same as start(int) but chooses a free port. */
public static TestWebServer start() throws Exception {
return start(0);
}
/**
* Create and start a local HTTP server instance. This function must only
* be called if you need more than one server instance and the first one
* was already created using start() or start(int). You are responsible for
* calling shutdown() on each instance you create.
*
* @param port Port number the server must use, or 0 to automatically choose a free port.
*/
public static TestWebServer startAdditional(int port) throws Exception {
return new TestWebServer(port, false, true);
}
/** Same as startAdditional(int) but chooses a free port. */
public static TestWebServer startAdditional() throws Exception {
return startAdditional(0);
}
/**
* Create and start a local secure HTTP server instance. This function must
* only be called if no other secure instances were created. You are
* responsible for calling shutdown() on each instance you create.
*
* @param port Port number the server must use, or 0 to automatically choose a free port.
*/
public static TestWebServer startSsl(int port) throws Exception {
return new TestWebServer(port, true, false);
}
/** Same as startSsl(int) but chooses a free port. */
public static TestWebServer startSsl() throws Exception {
return startSsl(0);
}
/**
* Create and start a local secure HTTP server instance. This function must
* only be called if you need more than one secure server instance and the
* first one was already created using startSsl() or startSsl(int). You are
* responsible for calling shutdown() on each instance you create.
*
* @param port Port number the server must use, or 0 to automatically choose a free port.
*/
public static TestWebServer startAdditionalSsl(int port) throws Exception {
return new TestWebServer(port, true, true);
}
/** Same as startAdditionalSsl(int) but chooses a free port. */
public static TestWebServer startAdditionalSsl() throws Exception {
return startAdditionalSsl(0);
}
private static final int RESPONSE_STATUS_NORMAL = 0;
private static final int RESPONSE_STATUS_MOVED_TEMPORARILY = 1;
private static final int RESPONSE_STATUS_NOT_FOUND = 2;
private static final int RESPONSE_STATUS_NO_CONTENT = 3;
private static final int RESPONSE_STATUS_FOR_WEBSOCKET = 4;
private static final int RESPONSE_STATUS_EMPTY_RESPONSE = 5;
private String setResponseInternal(
String requestPath,
byte[] responseData,
List<Pair<String, String>> responseHeaders,
Runnable responseAction,
int status) {
final boolean isRedirect = (status == RESPONSE_STATUS_MOVED_TEMPORARILY);
final boolean isNotFound = (status == RESPONSE_STATUS_NOT_FOUND);
final boolean isNoContent = (status == RESPONSE_STATUS_NO_CONTENT);
final boolean forWebSocket = (status == RESPONSE_STATUS_FOR_WEBSOCKET);
final boolean isEmptyResponse = (status == RESPONSE_STATUS_EMPTY_RESPONSE);
synchronized (mLock) {
mResponseMap.put(
requestPath,
new Response(
responseData,
responseHeaders,
isRedirect,
isNotFound,
isNoContent,
forWebSocket,
isEmptyResponse,
responseAction));
mResponseCountMap.put(requestPath, Integer.valueOf(0));
mLastRequestMap.put(requestPath, null);
}
return getResponseUrl(requestPath);
}
/**
* Sets a 404 (not found) response to be returned when a particular request path is passed in.
*
* @param requestPath The path to respond to.
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseWithNotFoundStatus(String requestPath) {
return setResponseWithNotFoundStatus(requestPath, null);
}
/**
* Sets a 404 (not found) response to be returned when a particular request path is passed in.
*
* @param requestPath The path to respond to.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseWithNotFoundStatus(
String requestPath, List<Pair<String, String>> responseHeaders) {
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(""),
responseHeaders,
null,
RESPONSE_STATUS_NOT_FOUND);
}
/**
* Sets a 204 (no content) response to be returned when a particular request path is passed in.
*
* @param requestPath The path to respond to.
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseWithNoContentStatus(String requestPath) {
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(""),
null,
null,
RESPONSE_STATUS_NO_CONTENT);
}
/**
* Sets an empty response to be returned when a particular request path is passed in.
*
* @param requestPath The path to respond to.
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setEmptyResponse(String requestPath) {
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(""),
null,
null,
RESPONSE_STATUS_EMPTY_RESPONSE);
}
/**
* Sets a response to be returned when a particular request path is passed
* in (with the option to specify additional headers).
*
* @param requestPath The path to respond to.
* @param responseString The response body that will be returned.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponse(
String requestPath, String responseString, List<Pair<String, String>> responseHeaders) {
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(responseString),
responseHeaders,
null,
RESPONSE_STATUS_NORMAL);
}
/**
* Sets a response to be returned when a particular request path is passed
* in with the option to specify additional headers as well as an arbitrary action to be
* executed on each request.
*
* @param requestPath The path to respond to.
* @param responseString The response body that will be returned.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @param responseAction The action to be performed when fetching the response. This action
* will be executed for each request and will be handled on a background
* thread.
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseWithRunnableAction(
String requestPath,
String responseString,
List<Pair<String, String>> responseHeaders,
Runnable responseAction) {
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(responseString),
responseHeaders,
responseAction,
RESPONSE_STATUS_NORMAL);
}
/**
* Sets a redirect.
*
* @param requestPath The path to respond to.
* @param targetLocation The path (or absolute URL) to redirect to.
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setRedirect(String requestPath, String targetLocation) {
return setRedirect(requestPath, targetLocation, new ArrayList<>());
}
/**
* Sets a redirect with optional headers.
*
* @param requestPath The path to respond to.
* @param targetLocation The path (or absolute URL) to redirect to.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setRedirect(
String requestPath, String targetLocation, List<Pair<String, String>> responseHeaders) {
responseHeaders.add(Pair.create("Location", targetLocation));
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(targetLocation),
responseHeaders,
null,
RESPONSE_STATUS_MOVED_TEMPORARILY);
}
/**
* Sets a base64 encoded response to be returned when a particular request path is passed
* in (with the option to specify additional headers).
*
* @param requestPath The path to respond to.
* @param base64EncodedResponse The response body that is base64 encoded. The actual server
* response will the decoded binary form.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseBase64(
String requestPath,
String base64EncodedResponse,
List<Pair<String, String>> responseHeaders) {
return setResponseInternal(
requestPath,
Base64.decode(base64EncodedResponse, Base64.DEFAULT),
responseHeaders,
null,
RESPONSE_STATUS_NORMAL);
}
/**
* Sets a response to a WebSocket handshake request.
*
* @param requestPath The path to respond to.
* @param responseHeaders Any additional headers that should be returned along with the
* response (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public String setResponseForWebSocket(
String requestPath, List<Pair<String, String>> responseHeaders) {
if (responseHeaders == null) {
responseHeaders = new ArrayList<Pair<String, String>>();
} else {
responseHeaders = new ArrayList<Pair<String, String>>(responseHeaders);
}
responseHeaders.add(Pair.create("Connection", "Upgrade"));
responseHeaders.add(Pair.create("Upgrade", "websocket"));
return setResponseInternal(
requestPath,
ApiCompatibilityUtils.getBytesUtf8(""),
responseHeaders,
null,
RESPONSE_STATUS_FOR_WEBSOCKET);
}
/** Get the number of requests was made at this path since it was last set. */
public int getRequestCount(String requestPath) {
Integer count = null;
synchronized (mLock) {
count = mResponseCountMap.get(requestPath);
}
if (count == null) throw new IllegalArgumentException("Path not set: " + requestPath);
return count.intValue();
}
/** Returns the last HttpRequest at this path. Can return null if it is never requested. */
public HTTPRequest getLastRequest(String requestPath) {
synchronized (mLock) {
if (!mLastRequestMap.containsKey(requestPath)) {
throw new IllegalArgumentException("Path not set: " + requestPath);
}
return mLastRequestMap.get(requestPath);
}
}
private static class WebServerPrintStream extends PrintStream {
WebServerPrintStream(OutputStream out) {
super(out);
}
@Override
public void println(String s) {
Log.w(TAG, s);
super.println(s);
}
}
/**
* Generate a response to the given request.
*
* <p>Always executed on the background server thread.
*
* <p>If there is an action associated with the response, it will be executed inside of
* this function.
*
* @throws NoSuchAlgorithmException, IOException
*/
private void outputResponse(HTTPRequest request, WebServerPrintStream stream)
throws NoSuchAlgorithmException, IOException {
// Don't dump headers to decrease log.
Log.w(TAG, request.requestLine());
final String bodyTemplate =
"<html><head><title>%s</title></head>" + "<body>%s</body></html>";
boolean copyHeadersToResponse = true;
boolean copyBinaryBodyToResponse = false;
boolean contentLengthAlreadyIncluded = false;
boolean contentTypeAlreadyIncluded = false;
StringBuilder textBody = new StringBuilder();
String requestURI = request.getURI();
Response response;
synchronized (mLock) {
response = mResponseMap.get(requestURI);
}
if (response == null || response.mIsNotFound) {
stream.println("HTTP/1.0 404 Not Found");
textBody.append(String.format(bodyTemplate, "Not Found", "Not Found"));
} else if (response.mForWebSocket) {
String keyHeader = request.headerValue("Sec-WebSocket-Key");
if (!keyHeader.isEmpty()) {
stream.println("HTTP/1.0 101 Switching Protocols");
stream.println("Sec-WebSocket-Accept: " + computeWebSocketAccept(keyHeader));
} else {
stream.println("HTTP/1.0 404 Not Found");
textBody.append(String.format(bodyTemplate, "Not Found", "Not Found"));
copyHeadersToResponse = false;
}
} else if (response.mIsNoContent) {
stream.println("HTTP/1.0 204 No Content");
copyHeadersToResponse = false;
} else if (response.mIsRedirect) {
stream.println("HTTP/1.0 302 Found");
textBody.append(String.format(bodyTemplate, "Found", "Found"));
} else if (response.mIsEmptyResponse) {
stream.println("HTTP/1.0 200 OK");
copyHeadersToResponse = false;
} else {
if (response.mResponseAction != null) response.mResponseAction.run();
stream.println("HTTP/1.0 200 OK");
copyBinaryBodyToResponse = true;
}
if (response != null) {
if (copyHeadersToResponse) {
for (Pair<String, String> header : response.mResponseHeaders) {
stream.println(header.first + ": " + header.second);
if (header.first.toLowerCase(Locale.ENGLISH).equals("content-length")) {
contentLengthAlreadyIncluded = true;
} else if (header.first.toLowerCase(Locale.ENGLISH).equals("content-type")) {
contentTypeAlreadyIncluded = true;
}
}
}
synchronized (mLock) {
mResponseCountMap.put(
requestURI,
Integer.valueOf(mResponseCountMap.get(requestURI).intValue() + 1));
mLastRequestMap.put(requestURI, request);
}
}
// RFC 1123
final SimpleDateFormat dateFormat =
new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
// Using print and println() because we don't want to dump it into log.
stream.print("Date: " + dateFormat.format(new Date()));
stream.println();
if (textBody.length() != 0) {
if (!contentTypeAlreadyIncluded
&& (requestURI.endsWith(".html") || requestURI.endsWith(".htm"))) {
stream.println("Content-Type: text/html");
}
stream.println("Content-Length: " + textBody.length());
stream.println();
stream.print(textBody.toString());
} else if (copyBinaryBodyToResponse) {
if (!contentTypeAlreadyIncluded && requestURI.endsWith(".js")) {
stream.println("Content-Type: application/javascript");
} else if (!contentTypeAlreadyIncluded
&& (requestURI.endsWith(".html") || requestURI.endsWith(".htm"))) {
stream.println("Content-Type: text/html");
}
if (!contentLengthAlreadyIncluded) {
stream.println("Content-Length: " + response.mResponseData.length);
}
stream.println();
stream.write(response.mResponseData);
} else {
stream.println();
}
}
/** Return a response for WebSocket handshake challenge. */
private static String computeWebSocketAccept(String keyString) throws NoSuchAlgorithmException {
byte[] key = keyString.getBytes(Charset.forName("US-ASCII"));
byte[] guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(Charset.forName("US-ASCII"));
MessageDigest md = MessageDigest.getInstance("SHA");
md.update(key);
md.update(guid);
byte[] output = md.digest();
return Base64.encodeToString(output, Base64.NO_WRAP);
}
}