/** Copyright (C) 2004 Juho Vh-Herttua This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 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 General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. */ import java.io.*; import java.util.*; import java.text.*; import java.net.URLDecoder; import java.nio.charset.*; /** Class to parse an incoming HTTP request at a server */ public class HttpParser { /** array of all HTTP reply codes and titles */ private static final String[][] HttpReplies = {{"100", "Continue"}, {"101", "Switching Protocols"}, {"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"203", "Non-Authoritative Information"}, {"204", "No Content"}, {"205", "Reset Content"}, {"206", "Partial Content"}, {"300", "Multiple Choices"}, {"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"}, {"304", "Not Modified"}, {"305", "Use Proxy"}, {"306", "(Unused)"}, {"307", "Temporary Redirect"}, {"400", "Bad Request"}, {"401", "Unauthorized"}, {"402", "Payment Required"}, {"403", "Forbidden"}, {"404", "Not Found"}, {"405", "Method Not Allowed"}, {"406", "Not Acceptable"}, {"407", "Proxy Authentication Required"}, {"408", "Request Timeout"}, {"409", "Conflict"}, {"410", "Gone"}, {"411", "Length Required"}, {"412", "Precondition Failed"}, {"413", "Request Entity Too Large"}, {"414", "Request-URI Too Long"}, {"415", "Unsupported Media Type"}, {"416", "Requested Range Not Satisfiable"}, {"417", "Expectation Failed"}, {"500", "Internal Server Error"}, {"501", "Not Implemented"}, {"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"}, {"505", "HTTP Version Not Supported"}}; /** Default headers for HTTP replies, after first line */ public static final String [][] defaultReplyHeaders = { {"X-Powered-By", "St Olaf MCA"}, {"Content-Type", "text/plain; charset=utf-8"}, {"Content-Length", "0"}, {"Date", ""}, {"Connection", "close"}, }; /** content type for JSON HTTP replies */ public static final String jsonContentType = "application/json; charset=utf-8"; /** Reader used for parsing an incoming request */ private BufferedReader reader; /** HTTP method, such as GET, POST, etc., from line 1 of HTTP request */ private String method = ""; /** URL indicated in line 1 of HTTP request, with GET parameters removed */ private String url = ""; /** base component of URL indicated in line 1 of HTTP request */ private String urlBase = ""; /** final id component of URL indicated in line 1 of HTTP request */ private String urlId = ""; /** Filled with header label/value pairs during parsing. Current implementation assumes only single-line headers */ private Hashtable<String,String> headers = new Hashtable<String,String>(); /** Filled with parameter key/value pairs during parsing */ private Hashtable<String,String> params = new Hashtable<String,String>(); /** Major and minor HTTP version numbers, from line 1 of HTTP request */ private int[] ver = new int[2]; /** Byte-array constructor @param buff Contains an entire HTTP request @param offset First byte of HTTP request @param len Length of HTTP request @sc Initializes state variables including <code>reader</code>, after removing any extraneous whitespace from lines of the raw HTTP request */ public HttpParser(byte [] buff, int offset, int len) { // remove any whitespace garbage from ends of lines String str = new String(buff, offset, len, StandardCharsets.UTF_8); //System.out.println("str: " + str); String [] strs = str.split("\n"); str = ""; for (int i = 0; i < strs.length; i++) str += strs[i].trim() + "\n"; //System.out.println("str: " + str); reader = new BufferedReader( new InputStreamReader( new ByteArrayInputStream(str.getBytes()))); } /** Stream constructor. Intended for use with InputStream from a Socket, but doesn't behave well sometimes if whitespace appears before newlines. @param is For reading input directly from a Socket. */ public HttpParser(InputStream is) { reader = new BufferedReader(new InputStreamReader(is)); } /** Perform parsing on a constructed object. @return An HTTP reply code. Value 200 indicates that parsing succeeded, other values indicate the nature of a parse failure. */ public int parseRequest() throws IOException { String initial, cmd[], temp[]; int ret, idx, i; ret = 200; // default is OK now initial = reader.readLine(); if (initial == null || initial.length() == 0) return 0; if (Character.isWhitespace(initial.charAt(0))) { // starting whitespace, return bad request return 400; } cmd = initial.split("\\s"); if (cmd.length != 3) { return 400; } if (cmd[2].indexOf("HTTP/") == 0 && cmd[2].indexOf('.') > 5) { temp = cmd[2].substring(5).split("\\."); try { ver[0] = Integer.parseInt(temp[0]); ver[1] = Integer.parseInt(temp[1]); } catch (NumberFormatException nfe) { ret = 400; } } else ret = 400; //System.out.println("DEBUG: cmd[0]=" + cmd[0]); if (cmd[0].equals("GET") || cmd[0].equals("HEAD")) { method = cmd[0]; idx = cmd[1].indexOf('?'); if (idx < 0) setUrl(cmd[1]); else { setUrl(URLDecoder.decode(cmd[1].substring(0, idx), "ISO-8859-1")); parseGETParams(cmd[1].substring(idx+1)); } parseHeaders(); if (headers == null) ret = 400; if (!parsePOSTParams()) ret = 400; } else if (cmd[0].equals("POST")) { method = cmd[0]; setUrl(cmd[1]); parseHeaders(); if (headers == null) ret = 400; if (!parsePOSTParams()) ret = 400; } else if (ver[0] == 1 && ver[1] >= 1) { if (cmd[0].equals("PATCH")) { method = cmd[0]; setUrl(cmd[1]); parseHeaders(); if (headers == null) ret = 400; if (!parsePOSTParams()) ret = 400; } else if (cmd[0].equals("DELETE")) { method = cmd[0]; setUrl(cmd[1]); parseHeaders(); if (headers == null) ret = 400; if (!parsePOSTParams()) ret = 400; } else if (cmd[0].equals("OPTIONS") || cmd[0].equals("PUT") || cmd[0].equals("TRACE") || cmd[0].equals("CONNECT")) { ret = 501; // not implemented } } else { // meh not understand, bad request ret = 400; } if (ver[0] == 1 && ver[1] >= 1 && getHeader("Host") == null) { ret = 400; } return ret; } /** Helper method for parseRequest(), to perform parsing of headers in an HTTP request. */ private void parseHeaders() throws IOException { String line; int idx; // rfc822 allows multiple lines, but that's not implemented here line = reader.readLine(); while (!line.trim().equals("")) { idx = line.indexOf(':'); if (idx < 0) { headers = null; break; } else { headers.put(line.substring(0, idx).toLowerCase(), line.substring(idx+1).trim()); } line = reader.readLine(); } } /** Helper method for parseRequest(), to perform parsing of GET-style parameters in URL portion of an HTTP request. @return True on success, false if string contains a parameter element that contains no <code>=</code>. */ private boolean parseGETParams(String paramString) throws UnsupportedEncodingException { String [] prms = paramString.split("&"); int i; //params = new Hashtable<String,String>(); // RAB note - why another? for (i=0; i<prms.length; i++) { String [] temp = prms[i].split("="); if (temp.length == 2) { // we use ISO-8859-1 as temporary charset and then // String.getBytes("ISO-8859-1") to get the data params.put(URLDecoder.decode(temp[0], "ISO-8859-1"), URLDecoder.decode(temp[1], "ISO-8859-1")); } else if(temp.length == 1 && prms[i].indexOf('=') == prms[i].length()-1) { // handle empty string separately params.put(URLDecoder.decode(temp[0], "ISO-8859-1"), ""); } } return true; } /** Helper method for parseRequest(), to perform parsing of POST-style parameters in content portion of an HTTP request. @return True on success, false if <code>reader</code> encounters a line that contains no <code>=</code>.*/ private boolean parsePOSTParams() throws IOException { String line; int idx; line = reader.readLine(); while (line != null) { System.out.println("DEBUG: " + line); idx = line.indexOf('='); if (idx < 0) { return false; } else { params.put(line.substring(0, idx).toLowerCase(), line.substring(idx+1).trim()); } line = reader.readLine(); } return true; } /** retrieve HTTP method from a parsed HTTP request */ public String getMethod() { return method; } /** retrieve value of one header from a parsed HTTP request @param label Label identifying a header line in that HTTP request @return Value for header <code>label</code>, or null if no such header. */ public String getHeader(String label) { if (headers != null) return (String) headers.get(label.toLowerCase()); else return null; } /** retrieve all headers from a parsed HTTP request @return Holds all label/value header pairs from that parsed HTTP request */ public Hashtable<String,String> getHeaders() { return headers; } /** retrieve target URL from a parsed HTTP request @return URL indicated in first line of that HTTP request */ public String getRequestURL() { return url; } /** retrieve base URL for a REST API request. @return Initial portion of the original request <code>url</code> that indicates an API action to be performed. This portion of <code>url</code> <ul> <li>begins with <code>/</code>,</li> <li>excludes a final path component if that component parses to an integer (see <code>getURLId()</code> to retrieve that component if present), and</li> <li>does not end in <code>/</code> <em>except when</em> <code>urlId</code> = <code>/</code>.</li> </ul> */ public String getURLBase() { return urlBase; } /** retrieve final integer component of URL for a REST API request, if present. @return If the final component of the request <code>url</code> parses to an integer, return that component (with no <code>/</code> characters). Otherwise, return empty string. */ public String getURLId() { return urlId; } /** retrieve value of one parameter from a parsed HTTP request @param key Key identifying a (GET or POST-style) parameter in that HTTP request @return Value for that parameter <code>key</code>, or null if no such parameter. */ public String getParam(String key) { return params.get(key); } /** retrieve all parameters from a parsed HTTP request @return Holds all key/value parameter pairs from that parsed HTTP request */ public Hashtable<String,String> getParams() { return params; } /** Retrieve HTTP version for a parsed HTTP request @return HTTP version as it appears in first line of that HTTP request */ public String getVersion() { return ver[0] + "." + ver[1]; } /** Compare HTTP version in a parsed HTTP request to alternative major/minor version numbers @param major An HTTP major version number @param minor An HTTP minor version number @return Negative if that request's HTTP version precedes <code>major</code>.<code>minor</code>; positive if that request's version comes after <code>major</code>.<code>minor</code>; and zero if the the version levels are the same. */ public int compareVersion(int major, int minor) { if (major < ver[0]) return -1; else if (major > ver[0]) return 1; else if (minor < ver[1]) return -1; else if (minor > ver[1]) return 1; else return 0; } /** helper for parseRequest that assigns to URL-related state variables @param origURL URL obtained from first line of HTTP request after breaking off any GET-style parameters. @sc Assigns <code>origURL</code> to state variable <code>url</code>, assigns a normalized version (see getUrlBase() documentation) of <code>origURL</code> after removing final integer path component (if any) to <code>urlBase</code>, and assigns final integer path component (or "" if none) to <code>urlId</code>. */ private void setUrl(String origURL) { url = origURL; urlBase = url; // normalize urlBase to start with a slash, and not finish with one // except in the root case urlBase = "/" if (urlBase.charAt(0) != '/') urlBase = "/" + urlBase; int lastSlash = urlBase.lastIndexOf('/'); while (lastSlash != 0 && lastSlash == urlBase.length() - 1) { urlBase = urlBase.substring(0,lastSlash); lastSlash = urlBase.lastIndexOf('/'); } // urlBase normalized, and lastSlash is index of / before final component try { String last = urlBase.substring(lastSlash+1); // final component Integer.parseInt(last); urlId = last; urlBase = urlBase.substring(0, lastSlash); } catch (NumberFormatException e) {}; } /** generate an HTTP reply message @param code HTTP reply code @param hdrs Headers to supplement/override <code>defaultReplyHeaders</code> @param body Content of HTTP reply @return Complete HTTP reply message */ public static String makeReply(int code, String [][] moreHdrs, String body) { String ret = new String("HTTP/1.1 " + getHttpReply(code) + "\n"); Hashtable<String,String> hdrVals = new Hashtable<String,String>(); /* It's not necessary for (single-line) headers to appear in a consistent order, but it's useful for writing code tests, so we implement it */ String hdrKeys0 = " "; // records header keys in order of appearance int i; for (i = 0; i < defaultReplyHeaders.length; i++) { hdrVals.put(defaultReplyHeaders[i][0], defaultReplyHeaders[i][1]); hdrKeys0 += defaultReplyHeaders[i][0] + " "; } hdrVals.put("Date", getDateHeaderValue()); hdrVals.put("Content-Length", Integer.toString(body.length())); for (i = 0; i < moreHdrs.length; i++) { hdrVals.put(moreHdrs[i][0], moreHdrs[i][1]); if (hdrKeys0.indexOf(" " + moreHdrs[i][0] + " ") == -1) hdrKeys0 += moreHdrs[i][0] + " "; } String [] hdrKeys = hdrKeys0.split(" "); for (i = 1; i < hdrKeys.length; i++) { ret += hdrKeys[i] + ": " + hdrVals.get(hdrKeys[i]) + "\n"; } ret += "\n"; ret += body; return ret; } /** generate an HTTP reply message @param code HTTP reply code @param body Content of HTTP reply @return Complete HTTP reply message */ public static String makeReply(int code, String body) { return makeReply(code, new String[][] {}, body); } /** generate an HTTP reply message with an empty content @param code HTTP reply code @return Complete HTTP reply message */ public static String makeReply(int code) { return makeReply(code, ""); } /** generate an HTTP reply message with JSON content @param code HTTP reply code @param body Content of HTTP reply @return Complete HTTP reply message */ public static String makeJsonReply(int code, String body) { return makeReply(code, new String[][] { {"Content-Type", HttpParser.jsonContentType} }, body); } /** Retrieve title for a given HTTP reply code @param HTTP reply code @return Title for that HTTP reply code <code>codevalue</code> */ public static String getHttpReply(int codevalue) { String key, ret; int i; ret = null; key = "" + codevalue; for (i=0; i<HttpReplies.length; i++) { if (HttpReplies[i][0].equals(key)) { ret = codevalue + " " + HttpReplies[i][1]; break; } } return ret; } /** generate value for a <code>Date:</code> header for an HTTP reply message @return Properly formatted <code>Date:</code> header value for the current time. */ public static String getDateHeaderValue() { SimpleDateFormat format; String ret; format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.US); format.setTimeZone(TimeZone.getTimeZone("GMT")); ret = format.format(new Date()) + " GMT"; return ret; } }