/* MonkeyTalk - a cross-platform functional testing tool
Copyright (C) 2012 Gorilla Logic, Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package com.gorillalogic.monkeytalk.server;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import org.json.JSONException;
import org.json.JSONObject;
import com.gorillalogic.monkeytalk.BuildStamp;
import com.gorillalogic.monkeytalk.utils.Base64;
/**
* <p>
* Nano-sized JSON-based HTTP server. All inbound POSTs have JSON-encoded bodies, all outbound
* responses have JSON-encoded bodies. Some server code is borrowed from <a
* href="http://elonen.iki.fi/code/nanohttpd/">NanoHTTPD</a>, which is BSD licensed.
* </p>
*
* <p>
* By default the server comes up in echo mode (aka inbound messages are just echoed back). The
* easiest way to customize the response is to extend {@link JsonServer} and override the
* {@link JsonServer#serve(String, String, Map, JSONObject)} method.
* </p>
*
* <p>
* Alternately, you can override the {@link JsonServer#serve(String, String, Map, String)} method if
* you wish to handle the JSON parsing yourself.
* </p>
*
* @see <a href="http://elonen.iki.fi/code/nanohttpd/">NanoHTTPD</a>
*/
public class JsonServer {
private static final String MIME_JSON = "application/json";
private static final String MIME_MULTIPART = "multipart/form-data";
private static final String MIME_HTML = "text/html";
private ServerSocket serverSocket;
private Thread serverThread;
private static final SimpleDateFormat sdf;
static {
sdf = new java.text.SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
}
/**
* Instantiate a new JSON server with the given port on a background daemon thread. The server
* immediately comes up listening for messages on the given port.
*
* @param port
* the server port
* @throws IOException
* if an I/O error occurs while opening the socket
*/
public JsonServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
serverThread = new Thread(new Runnable() {
@Override
public void run() {
try {
while (true) {
new HttpSession(serverSocket.accept());
}
} catch (IOException ex) {
// do nothing
}
}
});
serverThread.setDaemon(true);
serverThread.start();
}
/**
* Stop the server and close the socket. This blocks while the server thread is stopped. If an
* error occurs, just die silently.
*/
public void stop() {
try {
serverSocket.close();
serverThread.join();
} catch (InterruptedException ex) {
throw new RuntimeException("ERROR: interrupted while stopping - ex=" + ex.getMessage());
} catch (IOException ex) {
throw new RuntimeException("ERROR: error while stopping - ex=" + ex.getMessage());
}
}
/**
* <p>
* By default, just echo the incoming request as the response. Returns a {@link Response} object
* with a status code of 200 and a simple JSON message. For example, if the request is sent to
* <code>/foo</code> with a body of <code>{hello:123}</code>, then response would be
* <code>{result:OK, message:{method:"POST", uri:"/foo", body:{hello:123}}}</code> .
* </p>
*
* <p>
* Extend {@link JsonServer} and override this method if you wish to get the body as a string
* and handle the JSON parsing yourself. A better choice, would be to override
* {@link JsonServer#serve(String, String, Map, JSONObject)} to customize the response.
* </p>
*
* @see JsonServer#serve(String, String, Map, JSONObject)
*
* @param uri
* the URI
* @param method
* the HTTP method (GET, POST, etc.)
* @param headers
* the HTTP headers
* @param body
* the POST body
* @return the response
*/
public Response serve(String uri, String method, Map<String, String> headers, String body) {
if (body == null) {
return serve(uri, method, headers, (JSONObject) null);
}
JSONObject json;
try {
json = new JSONObject(body);
} catch (JSONException ex) {
json = new JSONObject();
}
return serve(uri, method, headers, json);
}
/**
* <p>
* By default, just echo the incoming request as the response. Returns a {@link Response} object
* with a status code of 200 and a simple JSON message. For example, if the request is sent to
* <code>/foo</code> with a body of <code>{hello:123}</code>, then response would be
* <code>{result:OK, message:{method:"POST", uri:"/foo", body:{hello:123}}}</code> .
* </p>
*
* <p>
* Extend {@link JsonServer} and override this method if you wish to get the body as a string
* and handle the JSON parsing yourself. A better choice, would be to override
* {@link JsonServer#serve(String, String, Map, JSONObject)} to customize the response.
* </p>
*
* @see JsonServer#serve(String, String, Map, JSONObject)
*
* @param uri
* the URI
* @param method
* the HTTP method (GET, POST, etc.)
* @param headers
* the HTTP headers
* @param body
* the POST body
* @param imageHeaders
* the HTTP multipart headers from the image
* @param image
* the raw image bytes
* @return the response
*/
public Response serve(String uri, String method, Map<String, String> headers, String body,
Map<String, String> imageHeaders, byte[] image) {
if (body == null) {
return serve(uri, method, headers, (JSONObject) null);
}
JSONObject json;
try {
json = new JSONObject(body);
} catch (JSONException ex) {
json = new JSONObject();
}
return serve(uri, method, headers, json);
}
/**
* <p>
* By default, just echo the incoming request as the response. Returns a {@link Response} object
* with a status code of 200 and a simple JSON message. For example, if the request is sent to
*
* <code>/foo</code> with a body of <code>{hello:123}</code>, then response would be
* <code>{result:OK, message:{method:"POST", uri:"/foo", body:{hello:123}}}</code>.
* </p>
*
* <p>
* Extend {@link JsonServer} and override this method to customize the response. Alternately,
* you may wish to override {@link JsonServer#serve(String, String, Map, String)} if you wish to
* get the body as a string and handle the JSON parsing yourself.
* </p>
*
* @see JsonServer#serve(String, String, Map, String)
*
* @param uri
* the URI
* @param method
* the HTTP method (GET, POST, etc.)
* @param headers
* the HTTP headers
* @param json
* the POST body as a JSON object
* @return the response
*/
public Response serve(String uri, String method, Map<String, String> headers, JSONObject json) {
if ("GET".equals(method)) {
return new Response(HttpStatus.OK, "<!DOCTYPE html>\n<html>\n"
+ "<head>\n<title>MonkeyTalk</title>\n</head>\n"
+ "<body>\n<h1>OK</h1>\n<p>server running on port " + this.getPort()
+ "</p>\n<p>" + BuildStamp.STAMP + "</p>\n</body>\n</html>");
} else {
JSONObject message = new JSONObject();
try {
message.put("method", method);
message.put("uri", uri);
message.put("body", json);
} catch (JSONException ex) {
message = new JSONObject();
}
JSONObject resp = new JSONObject();
try {
resp.put("result", "OK");
resp.put("message", message);
} catch (JSONException ex) {
resp = new JSONObject();
}
return new Response(HttpStatus.OK, resp);
}
}
/**
* Return true if the JSON server is up and running.
*
* @return true if the server is running, otherwise false
*/
public boolean isRunning() {
return serverThread.isAlive();
}
/**
* Return the port of the JSON server or {@code -1} if the server is not bound and listening
* yet.
*
* @return the port
*/
public int getPort() {
return serverSocket.getLocalPort();
}
@Override
public String toString() {
return "JsonServer " + (serverThread.isAlive() ? "alive and running" : "dead")
+ " on port " + getPort();
}
/**
* Handles one HTTP session -- parse the request and return the response.
*/
private class HttpSession implements Runnable {
private Socket socket;
public HttpSession(Socket socket) {
this.socket = socket;
Thread t = new Thread(this);
t.setDaemon(true);
t.start();
}
public void run() {
try {
InputStream is = socket.getInputStream();
// no stream, so we are done
if (is == null) {
return;
}
// read the first 8192 bytes, which should get the whole header
byte[] buf = new byte[8192];
int len = is.read(buf, 0, buf.length);
// nothing to read, so we are done
if (len == -1) {
is.close();
return;
}
int headerStart = search(buf, new byte[] { 13, 10 });
int headerEnd = search(buf, new byte[] { 13, 10, 13, 10 });
if (headerStart == -1 || headerEnd == -1) {
sendError("failed to read headers");
is.close();
return;
}
// first line contains HTTP method and target URI
String urlLine = getStringFromBytes(buf, headerStart);
String[] parts = urlLine.split("\\s+");
String method = parts[0];
String uri = parts[1];
if (uri.indexOf("?") != -1) {
uri = uri.substring(0, uri.indexOf("?"));
}
// then comes all the headers
Map<String, String> headers = getHeaders(buf, headerStart + 2, headerEnd);
boolean isMultipart = false;
String boundary = null;
if ("GET".equals(method)) {
Response r = serve(uri, method, headers, (String) null);
send(r.getStatus(), MIME_HTML, r.getBody(), r.getHeaders());
is.close();
return;
} else if ("POST".equals(method)) {
if (!headers.containsKey("content-type")) {
sendError("Content-Type header is missing");
is.close();
return;
} else if (headers.get("content-type").toLowerCase().startsWith(MIME_JSON)) {
// we are vanilla JSON
isMultipart = false;
} else if (headers.get("content-type").toLowerCase().startsWith(MIME_MULTIPART)) {
// we are Multipart
isMultipart = true;
int boundaryIdx = headers.get("content-type").indexOf("boundary=");
if (boundaryIdx > 0) {
boundary = headers.get("content-type").substring(boundaryIdx + 9);
}
} else {
sendError("post data must be " + MIME_JSON + " or " + MIME_MULTIPART);
is.close();
return;
}
}
if (!headers.containsKey("content-length")) {
sendError("Content-Length header is missing");
is.close();
return;
}
int L = Integer.parseInt(headers.get("content-length"));
byte[] body = new byte[L];
int bodylen = len - headerEnd - 4;
// read in what we've got so far
for (int i = 0; i < bodylen; ++i) {
body[i] = buf[i + headerEnd + 4];
}
// we didn't get the entire body with first read, so read some more
while (bodylen < L) {
int sz = L - bodylen;
int l = is.read(buf, 0, (sz > 1024 ? 1024 : sz));
if (l != -1) {
// append to body
for (int i = 0; i < l; i++) {
body[bodylen + i] = buf[i];
}
bodylen += l;
}
}
Response r;
// now we got the whole body, but if multipart we need to serve() the image
if (isMultipart) {
byte[] sep = ("--" + boundary).getBytes("UTF-8");
int start1 = search(body, sep);
int end1 = search(body, new byte[] { 13, 10, 13, 10 }, start1);
int start2 = search(body, sep, end1);
int end2 = search(body, new byte[] { 13, 10, 13, 10 }, start2);
int last = search(body, sep, end2);
Map<String, String> headers1 = getHeaders(body, start1 + sep.length + 2, end1);
Map<String, String> headers2 = getHeaders(body, start2 + sep.length + 2, end2);
if (headers1 == null) {
sendError("failed to read " + MIME_MULTIPART + " headers1");
is.close();
return;
}
if (headers2 == null) {
sendError("failed to read " + MIME_MULTIPART + " headers2");
is.close();
return;
}
String bodyStr = null;
byte[] image = null;
if (headers1.containsKey("content-disposition")) {
if (headers1.get("content-disposition").toLowerCase()
.contains("name=\"message\"")) {
bodyStr = getStringFromBytes(body, start2 - end1 - 4, end1 + 4);
image = new byte[last - end2 - 6];
for (int i = 0; i < image.length; i++) {
image[i] = body[end2 + 4 + i];
}
} else {
bodyStr = getStringFromBytes(body, last - end2 - 4, end2 + 4);
image = new byte[start2 - end1 - 6];
for (int i = 0; i < image.length; i++) {
image[i] = body[end1 + 4 + i];
}
}
}
if (bodyStr != null) {
bodyStr = bodyStr.trim();
}
// now serve the image
r = serve(uri, method, headers, bodyStr, headers1, image);
} else {
// now serve the JSON
r = serve(uri, method, headers, new String(body, "UTF-8").trim());
}
if (r == null) {
sendError("serve() returned null");
is.close();
return;
} else {
send(r.getStatus(), MIME_JSON, r.getBody(), r.getHeaders());
}
is.close();
} catch (IOException ex) {
sendError("exception - " + ex.getMessage());
} finally {
try {
socket.close();
} catch (IOException ex) {
// do nothing
}
}
}
private Map<String, String> getHeaders(byte[] body, int start, int end) {
if (start < 0 || end < 0) {
return null;
}
String s = getStringFromBytes(body, end - start, start);
Map<String, String> headers = new HashMap<String, String>();
for (String line : s.split("\r\n")) {
int i = line.indexOf(':');
if (i > 0) {
String key = line.substring(0, i).trim().toLowerCase();
String val = line.substring(i + 1).trim();
headers.put(key, val);
}
}
return headers;
}
/**
* Helper to send an error JSON response.
*
* @param err
*/
private void sendError(String err) {
send(HttpStatus.INTERNAL_ERROR, MIME_JSON,
"{result:\"ERROR\",message:\"" + err + "\"}", null);
}
/**
* Helper to send a full response: HTTP status, MIME type, body, and HTTP response headers.
* Always return response headers {@code Content-Type} and {@code Date} even if the
* {@code headers} is {@code null}.
*
* @param status
* the HTTP status
* @param mine
* the MIME type
* @param body
* the body
* @param headers
* the HTTP headers
*/
private void send(HttpStatus status, String mime, String body, Map<String, String> headers) {
try {
PrintWriter pw = new PrintWriter(socket.getOutputStream());
pw.print("HTTP/1.0 " + status + " \r\n");
pw.print("Content-Type: " + mime + "\r\n");
//pw.print("Access-Control-Allow-Origin: *\r\n");
if (headers == null || headers.get("Date") == null) {
pw.print("Date: " + sdf.format(new Date()) + "\r\n");
}
if (headers != null) {
for (Map.Entry<String, String> header : headers.entrySet()) {
pw.print(header.getKey() + ": " + header.getValue() + "\r\n");
}
}
pw.print("\r\n");
if (body != null) {
pw.print(body);
}
pw.close();
} catch (IOException ex) {
try {
socket.close();
} catch (IOException ex2) {
}
}
}
/**
* Helper to convert a byte array into a UTF-8 string.
*
* @param bytes
* the byte array
* @param len
* the number of bytes to convert
* @return the UTF-8 string
*/
private String getStringFromBytes(byte[] bytes, int len) {
return getStringFromBytes(bytes, len, 0);
}
/**
* Helper to convert a byte array into a UTF-8 string.
*
* @param bytes
* the byte array
* @param len
* the number of bytes to convert
* @param offset
* the number of bytes to skip
* @return the UTF-8 string
*/
private String getStringFromBytes(byte[] bytes, int len, int offset) {
if (bytes.length > len + offset) {
if (len == 0) {
return "";
}
byte[] b = new byte[len];
for (int i = 0; i < len; i++) {
b[i] = bytes[i + offset];
}
try {
return new String(b, "UTF-8");
} catch (UnsupportedEncodingException ex) {
return null;
}
}
return null;
}
/**
* Helper to find a pattern of bytes (the needle) in the given byte array (the haystack).
*
* @param haystack
* the byte array to search
* @param needle
* the byte pattern to find
* @return the starting index of the found pattern, or -1 if not found
*/
private int search(byte[] haystack, byte[] needle) {
return search(haystack, needle, 0);
}
/**
* Helper to find a pattern of bytes (the needle) in the given byte array (the haystack)
* starting from the given offset.
*
* @param haystack
* the byte array to search
* @param needle
* the byte pattern to find
* @param offset
* the starting offset in bytes
* @return the starting index of the found pattern, or -1 if not found
*/
private int search(byte[] haystack, byte[] needle, int offset) {
int i = offset;
while (i <= haystack.length - needle.length) {
if (haystack[i] == needle[0]) {
boolean match = true;
for (int j = 1; j < needle.length; j++) {
if (haystack[i + j] != needle[j]) {
match = false;
break;
}
}
if (match) {
return i;
}
}
i++;
}
return -1;
}
}
/**
* The HTTP response as returned by the {@code serve()} method, contains the status code (and
* status message), JSON return body, HTTP response headers, and optionally an image.
*/
public class Response {
private HttpStatus status;
private String body;
private Map<String, String> headers;
private byte[] image;
/**
* Instantiate a new {@code 200 OK} response, with no JSON body and no HTTP headers.
*/
public Response() {
this(HttpStatus.OK, (String) null, null);
}
/**
* Instantiate a new response with the given HTTP status and JSON body, and no HTTP headers.
*
* @param status
* the HTTP status
* @param body
* the JSON body
*/
public Response(HttpStatus status, String body) {
this(status, body, null);
}
/**
* Instantiate a new response with the given HTTP status and JSON body, and no HTTP headers.
*
* @param status
* the HTTP status
* @param json
* the JSON body
*/
public Response(HttpStatus status, JSONObject json) {
this(status, json, null);
}
/**
* Instantiate a new response with the given HTTP status, JSON body, and HTTP headers.
*
* @param status
* the HTTP status
* @param json
* the JSON body
* @param headers
* the HTTP headers
*/
public Response(HttpStatus status, JSONObject json, Map<String, String> headers) {
this(status, (json != null ? json.toString() : null), headers);
}
/**
* Instantiate a new response with the given HTTP status, JSON body, and HTTP headers.
*
* @param status
* the HTTP status
* @param body
* the JSON body
* @param headers
* the HTTP headers
*/
public Response(HttpStatus status, String body, Map<String, String> headers) {
this(status, body, headers, null);
}
/**
* Instantiate a new response with the given HTTP status, JSON body, and HTTP headers.
*
* @param status
* the HTTP status
* @param body
* the JSON body
* @param headers
* the HTTP headers
* @param image
* the raw image bytes
*/
public Response(HttpStatus status, String body, Map<String, String> headers, byte[] image) {
this.status = status;
this.body = body;
this.headers = headers;
this.image = image;
}
/**
* Get the HTTP status.
*
* @return the HTTP status
*/
public HttpStatus getStatus() {
return status;
}
/**
* Get the JSON body as a string.
*
* @return the JSON body
*/
public String getBody() {
if (image == null) {
return body;
}
try {
JSONObject json = new JSONObject(body);
json.put("screenshot", Base64.encodeBytes(image));
return json.toString();
} catch (JSONException e) {
return body;
}
}
/**
* Get the HTTP headers.
*
* @return the HTTP headers
*/
public Map<String, String> getHeaders() {
return headers;
}
/**
* Get the raw image bytes.
*
* @return the image
*/
public byte[] getImage() {
return image;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("Response:\n");
sb.append(" status=").append(status).append("\n");
sb.append(" body=").append(body).append("\n");
if (headers == null) {
sb.append(" headers=null\n");
} else {
sb.append(" headers:\n");
for (Map.Entry<String, String> entry : headers.entrySet()) {
sb.append(" ").append(entry.getKey()).append("=").append(entry.getValue())
.append("\n");
}
}
sb.append(" image=").append(image == null ? "NULL" : "YES").append('\n');
return sb.substring(0, sb.length() - 1);
}
}
/**
* Enum wrapper around the HTTP Status codes.
*/
public enum HttpStatus {
/**
* 200 OK
*/
OK(200, "OK"),
/**
* 400 Bad Request
*/
BAD_REQUEST(400, "Bad Request"),
/**
* 404 Not Found
*/
NOT_FOUND(404, "Not Found"),
/**
* 500 Internal Server Error
*/
INTERNAL_ERROR(500, "Internal Server Error");
private int code;
private String message;
/**
* Instantiate a new HTTP Status with the given HTTP status code and HTTP status message.
*
* @param code
* the HTTP status code
* @param message
* the HTTP status message
*/
private HttpStatus(int code, String message) {
this.code = code;
this.message = message;
}
/**
* Get the HTTP status code (ex: {@code 200} for a {@code 200 OK} status, {@code 404} for
* {@code 404 Not Found} status, etc.)
*
* @return the HTTP status code
*/
public int getCode() {
return code;
}
/**
* Get the HTTP status message (ex: {@code OK} for a {@code 200 OK} status,
* {@code Not Found} for {@code 404 Not Found} status, etc.)
*
* @return the HTTP status message
*/
public String getMessage() {
return message;
}
@Override
/**
* Output the HTTP Status message exactly as it will appear in the response.
* @return the HTTP Status message (status code + space + status message)
*/
public String toString() {
return code + " " + message;
}
}
}