Browse Source

D/Hunt: Upgrade to Hunt 1.0 and add more tests for database (#4394)

* Upgrade to Hunt 1.0

* Add tests for database

* Impelement fortune test

* Install tools properly

* DB queries failed

* Support to query more data

* Failed to test fortunes

* Fixes:  test for fortunes not work for ldc 2.07
Heromyth 6 years ago
parent
commit
3fb593fd6b

+ 14 - 2
frameworks/D/hunt/README.md

@@ -7,11 +7,23 @@ This is the Hunt portion of a [benchmarking test suite](../) comparing a variety
 * Dlang > 2.077
 
 ## Test URLs
+    
+### PlanText Test
+
+    http://localhost:8080/plaintext
 
 ### JSON Encoding Test
 
     http://localhost:8080/json
     
-### PlanText Test
+### Single database query
 
-    http://localhost:8080/plaintext
+    http://localhost:8080/db
+    
+### Multiple database queries
+
+    http://localhost:8080//queries?queries=10
+    
+### Database updates
+
+    http://localhost:8080/updates?queries=10

+ 48 - 2
frameworks/D/hunt/benchmark_config.json

@@ -5,7 +5,7 @@
       "json_url": "/json",
       "plaintext_url": "/plaintext",
       "port": 8080,
-      "approach": "Stripped",
+      "approach": "Realistic",
       "classification": "Platform",
       "database": "None",
       "framework": "Hunt",
@@ -24,7 +24,7 @@
       "json_url": "/json",
       "plaintext_url": "/plaintext",
       "port": 8080,
-      "approach": "Stripped",
+      "approach": "Realistic",
       "classification": "Platform",
       "database": "None",
       "framework": "Hunt",
@@ -38,6 +38,52 @@
       "display_name": "Hunt",
       "notes": "",
       "versus": "Hunt"
+    },
+    "dmd-postgresql": {
+      "json_url": "/json",
+      "db_url": "/db",
+      "query_url": "/queries?queries=",
+      "plaintext_url": "/plaintext",
+      "fortune_url": "/fortunes",
+      "update_url": "/updates?queries=",
+      "port": 8080,
+      "approach": "Realistic",
+      "classification": "Platform",
+      "database": "Postgres",
+      "framework": "Hunt",
+      "language": "D",
+      "flavor": "DLang2",
+      "orm": "Raw",
+      "platform": "None",
+      "webserver": "None",
+      "os": "Linux",
+      "database_os": "Linux",
+      "display_name": "hunt-postgresql",
+      "notes": "",
+      "versus": "Hunt"
+    },
+    "ldc-postgresql": {
+      "json_url": "/json",
+      "db_url": "/db",
+      "query_url": "/queries?queries=",
+      "plaintext_url": "/plaintext",
+      "fortune_url": "/fortunes",
+      "update_url": "/updates?queries=",
+      "port": 8080,
+      "approach": "Realistic",
+      "classification": "Platform",
+      "database": "Postgres",
+      "framework": "Hunt",
+      "language": "D",
+      "flavor": "DLang2",
+      "orm": "Raw",
+      "platform": "None",
+      "webserver": "None",
+      "os": "Linux",
+      "database_os": "Linux",
+      "display_name": "hunt-ldc-postgresql",
+      "notes": "",
+      "versus": "Hunt"
     }
   }]
 }

+ 6 - 0
frameworks/D/hunt/build.sh

@@ -0,0 +1,6 @@
+#!/bin/bash
+
+rm -rf http-parser
+git clone https://github.com/nodejs/http-parser.git
+cd http-parser
+make package

+ 18 - 4
frameworks/D/hunt/dub.json

@@ -4,13 +4,27 @@
 	"authors": [
 		"Putao"
 	],
-	"description": "A simple http server powered by Hunt.",
-	"copyright": "Copyright © 2017-2018, HuntLabs.cn",
+	"description": "A mini http server powered by Hunt.",
+    "copyright": "Copyright (C) 2017-2019, HuntLabs",
+    "homepage": "https://www.huntlabs.net/",
 	"license": "Apache-2.0",
 	"libs-posix": [ "http_parser" ],
 	"lflags-posix": ["-Lhttp-parser/"],
 	"dependencies": {
-		"hunt": "~>1.0.0-rc.5",
+		"hunt": "~>1.1.0",
+		"hunt-database": "~>1.1.1",
 		"std_data_json": "~>0.18.2"
-	}
+	},
+	"configurations": [
+		{
+			"name": "lite"
+		},
+		{
+			"name": "postgresql",
+			"versions": ["POSTGRESQL"],
+			"subConfigurations": {
+				"hunt-database": "postgresql"
+			}
+		}
+	]
 }

+ 15 - 0
frameworks/D/hunt/hunt-dmd-postgresql.dockerfile

@@ -0,0 +1,15 @@
+FROM dlanguage/ldc:1.7.0
+
+ADD ./ /hunt
+WORKDIR /hunt
+
+RUN apt update -yqq && apt install -yqq git make libpq-dev
+
+RUN git clone https://github.com/nodejs/http-parser.git && \
+    cd http-parser && \
+    make package
+    
+RUN dub upgrade --verbose
+RUN dub build --build=release --arch=x86_64 --config=postgresql -f
+
+CMD ["./hunt-minihttp"]

+ 15 - 0
frameworks/D/hunt/hunt-ldc-postgresql.dockerfile

@@ -0,0 +1,15 @@
+FROM dlanguage/ldc:1.7.0
+
+ADD ./ /hunt
+WORKDIR /hunt
+
+RUN apt update -yqq && apt install -yqq git make libpq-dev
+
+RUN git clone https://github.com/nodejs/http-parser.git && \
+    cd http-parser && \
+    make package
+
+RUN dub upgrade --verbose
+RUN dub build --build=release --arch=x86_64 --compiler=ldc2 --config=postgresql -f
+
+CMD ["./hunt-minihttp"]

+ 3 - 3
frameworks/D/hunt/hunt-ldc.dockerfile

@@ -1,6 +1,6 @@
 FROM dlanguage/ldc:1.7.0
 
-RUN apt update -yqq && apt install -yqq git make
+RUN apt update -yqq && apt install -yqq git make libpq-dev
 
 ADD ./ /hunt
 WORKDIR /hunt
@@ -8,8 +8,8 @@ WORKDIR /hunt
 RUN git clone https://github.com/nodejs/http-parser.git && \
     cd http-parser && \
     make package
-    
+
 RUN dub upgrade --verbose
-RUN dub build -f --arch=x86_64 --build=release --compiler=ldc2
+RUN dub build -f --arch=x86_64 --build=release --compiler=ldc2 -c=lite
 
 CMD ["./hunt-minihttp"]

+ 2 - 2
frameworks/D/hunt/hunt.dockerfile

@@ -1,6 +1,6 @@
 FROM dlanguage/ldc:1.7.0
 
-RUN apt update -yqq && apt install -yqq git make
+RUN apt update -yqq && apt install -yqq git make libpq-dev
 
 ADD ./ /hunt
 WORKDIR /hunt
@@ -10,6 +10,6 @@ RUN git clone https://github.com/nodejs/http-parser.git && \
     make package
     
 RUN dub upgrade --verbose
-RUN dub build -f --arch=x86_64 --build=release
+RUN dub build -f --arch=x86_64 --build=release -c=lite
 
 CMD ["./hunt-minihttp"]

+ 232 - 14
frameworks/D/hunt/source/DemoProcessor.d

@@ -1,8 +1,28 @@
 module DemoProcessor;
 
+// import stdx.data.json;
+import std.json;
+
+import hunt.database;
 import hunt.io;
 import http.Processor;
-import stdx.data.json;
+import http.HttpURI;
+import http.UrlEncoded;
+import hunt.logging.ConsoleLogger : trace, warning, tracef;
+
+import std.algorithm;
+import std.array;
+import std.exception;
+import std.random;
+import std.string;
+
+version (POSTGRESQL) {
+    __gshared Database dbConnection;
+}
+
+enum HttpHeader textHeader = HttpHeader("Content-Type", "text/plain; charset=UTF-8");
+enum HttpHeader htmlHeader = HttpHeader("Content-Type", "text/html; charset=UTF-8");
+enum HttpHeader jsonHeader = HttpHeader("Content-Type", "application/json; charset=UTF-8");
 
 class DemoProcessor : HttpProcessor {
     this(TcpStream client) {
@@ -10,19 +30,217 @@ class DemoProcessor : HttpProcessor {
     }
 
     override void onComplete(HttpRequest req) {
-        switch (req.uri) {
-        case "/plaintext":
-            respondWith("Hello, World!", 200, HttpHeader("Content-Type", "text/plain"));
-            break;
-
-        case "/json":
-            JSONValue js = JSONValue(["message" : JSONValue("Hello, World!")]);
-            respondWith(js.toJSON(), 200, HttpHeader("Content-Type", "application/json"));
-            break;
-
-        default:
-            respondWith("The accessable path are: /plaintext and /json", 404);
-            break;
+        debug trace(req.uri);
+        HttpURI uri = new HttpURI(req.uri);
+
+        version (POSTGRESQL) {
+            switch (uri.getPath) {
+            case "/plaintext":
+                respondWith("Hello, World!", 200, textHeader);
+                break;
+
+            case "/json":
+                JSONValue js = JSONValue(["message" : JSONValue("Hello, World!")]);
+                respondWith(js.toJSON(), 200, jsonHeader);
+                break;
+
+            case "/db":
+                respondSingleQuery();
+                break;
+
+            case "/queries":
+                UrlEncoded queriesMap = new UrlEncoded();
+                uri.decodeQueryTo(queriesMap);
+                int number = 1;
+                debug {
+                    trace(queriesMap.toString());
+                    if (!queriesMap.containsKey("queries")) {
+                        respondWith404();
+                        return;
+                    }
+
+                    string v = queriesMap.getValue("queries", 0);
+                    if (!v.empty) {
+                        try {
+                            number = to!int(v);
+                        } catch (Exception ex) {
+                            warning(ex.msg);
+                        }
+                    }
+                } else {
+                    string v = queriesMap.getValue("queries", 0);
+                    if (!v.empty) {
+                        try {
+                            number = to!int(v);
+                        } catch (Exception ex) {
+                        }
+                    }
+                }
+
+                respondMultipleQuery(number);
+                break;
+
+            case "/fortunes":
+                respondFortunes();
+                break;
+
+            case "/updates":
+                UrlEncoded queriesMap = new UrlEncoded();
+                uri.decodeQueryTo(queriesMap);
+                int number = 1;
+                debug {
+                    if (!queriesMap.containsKey("queries")) {
+                        respondWith404();
+                        return;
+                    }
+
+                    string v = queriesMap.getValue("queries", 0);
+                    if (!v.empty) {
+                        try {
+                            number = to!int(v);
+                        } catch (Exception ex) {
+                            warning(ex.msg);
+                        }
+                    }
+                } else {
+                    string v = queriesMap.getValue("queries", 0);
+                    if (!v.empty) {
+                        try {
+                            number = to!int(v);
+                        } catch (Exception ex) {
+                        }
+                    }
+                }
+                respondUpdates(number);
+                break;
+
+            default:
+                respondWith404();
+                break;
+            }
+
+        } else {
+            switch (uri.getPath) {
+            case "/plaintext":
+                respondWith("Hello, World!", 200, textHeader);
+                break;
+
+            case "/json":
+                JSONValue js = JSONValue(["message" : JSONValue("Hello, World!")]);
+                respondWith(js.toJSON(), 200, jsonHeader);
+                break;
+
+            default:
+                respondWith404();
+                break;
+            }
         }
     }
+
+    private void respondWith404() {
+        respondWith("The available paths are: /plaintext, /json, /db, /queries?queries=number", 404);
+    }
+
+    version (POSTGRESQL) {
+        private void respondSingleQuery() {
+            int id = uniform(1, 10000);
+            string query = "SELECT randomNumber FROM world WHERE id = " ~ id.to!string;
+            Statement statement = dbConnection.prepare(query);
+            ResultSet rs = statement.query();
+
+            JSONValue js = JSONValue(["id" : JSONValue(id), "randomNumber"
+                    : JSONValue(to!int(rs.front()[0]))]);
+
+            respondWith(js.toJSON(), 200, jsonHeader);
+        }
+
+        private void respondMultipleQuery(int queries) {
+            if (queries < 1)
+                queries = 1;
+            else if (queries > 500)
+                queries = 500;
+
+            JSONValue[] arr = new JSONValue[queries];
+            for (int i = 0; i < queries; i++) {
+                immutable id = uniform(1, 10000);
+                immutable query = "SELECT randomNumber FROM world WHERE id = " ~ id.to!string;
+                ResultSet rs = dbConnection.query(query);
+
+                arr[i] = JSONValue(["id" : JSONValue(id), "randomNumber"
+                        : JSONValue(to!int(rs.front()[0]))]);
+            }
+            JSONValue js = JSONValue(arr);
+            respondWith(js.toJSON(), 200, jsonHeader);
+        }
+
+        private void respondFortunes() {
+            immutable query = "SELECT id, message::text FROM Fortune";
+            ResultSet rs = dbConnection.query(query);
+            FortuneModel[] data = rs.map!(f => FortuneModel(f["id"].to!int, f["message"])).array;
+            data ~= FortuneModel(0, "Additional fortune added at request time.");
+            data.sort!((a, b) => a.message < b.message);
+            // trace(data);
+
+            respondWith(randerFortunes(data), 200, htmlHeader);
+        }
+
+        static string randerFortunes(FortuneModel[] data) {
+            Appender!string sb;
+            sb.put(`<!DOCTYPE html>
+<html>
+	<head>
+		<title>Fortunes</title>
+	</head>
+	<body>
+		<table>
+			<tr>
+				<th>id</th><th>message</th>
+			</tr>
+`);
+
+            foreach (FortuneModel f; data) {
+                string message = replace(f.message, ">", "&gt;");
+                message = replace(message, "<", "&lt;");
+                message = replace(message, "\"", "&quot;");
+                sb.put(format("			<tr>\n				<td>%d</td><td>%s</td>\n			</tr>\n", f.id, message));
+            }
+
+            sb.put("		</table>\n	</body>\n</html>");
+
+            return sb.data;
+        }
+
+        private void respondUpdates(int queries) {
+            if (queries < 1)
+                queries = 1;
+            else if (queries > 500)
+                queries = 500;
+
+            JSONValue[] arr = new JSONValue[queries];
+            for (int i = 0; i < queries; i++) {
+                immutable id = uniform(1, 10000);
+                immutable idString = id.to!string;
+                immutable query = "SELECT randomNumber FROM world WHERE id = " ~ idString;
+                ResultSet rs = dbConnection.query(query);
+                int randomNumber = to!int(rs.front()[0]);
+                debug tracef("id=%d, randomNumber=%d", id, randomNumber);
+
+                randomNumber = uniform(1, 10000);
+                string updateSql = "UPDATE world SET randomNumber = "
+                    ~ randomNumber.to!string ~ "  WHERE id = " ~ idString;
+                int r = dbConnection.execute(updateSql);
+                // debug tracef("r=%d", r);
+
+                arr[i] = JSONValue(["id" : JSONValue(id), "randomNumber" : JSONValue(randomNumber)]);
+            }
+
+            JSONValue js = JSONValue(arr);
+            respondWith(js.toJSON(), 200, jsonHeader);
+        }
+    }
+}
+
+struct FortuneModel {
+    int id;
+    string message;
 }

+ 12 - 2
frameworks/D/hunt/source/app.d

@@ -11,14 +11,14 @@
 import std.getopt;
 import std.stdio;
 
+import hunt.database;
 import hunt.io;
-import hunt.util.memory : totalCPUs;
+import hunt.system.Memory : totalCPUs;
 import http.Processor;
 import http.Server;
 import DemoProcessor;
 
 void main(string[] args) {
-
 	ushort port = 8080;
 	GetoptResult o = getopt(args, "port|p", "Port (default 8080)", &port);
 	if (o.helpWanted) {
@@ -26,6 +26,16 @@ void main(string[] args) {
 		return;
 	}
 
+version(POSTGRESQL) {
+	debug {
+		dbConnection = new Database("postgresql://benchmarkdbuser:[email protected]:5432/hello_world?charset=utf-8");
+		dbConnection.getOption().setMinimumConnection(64);
+	} else {
+		dbConnection = new Database("postgresql://benchmarkdbuser:benchmarkdbpass@tfb-database:5432/hello_world?charset=utf-8");
+		dbConnection.getOption().setMinimumConnection(64);
+	}
+		dbConnection.getOption().setMaximumConnection(64);
+}
 	HttpServer httpServer = new HttpServer("0.0.0.0", port, totalCPUs-1);
 	httpServer.onProcessorCreate(delegate HttpProcessor (TcpStream client) {
 		return new DemoProcessor(client);

+ 1166 - 0
frameworks/D/hunt/source/http/HttpURI.d

@@ -0,0 +1,1166 @@
+module http.HttpURI;
+
+import hunt.collection.MultiMap;
+
+import hunt.Exceptions;
+import hunt.text.Charset;
+import hunt.text.Common;
+import hunt.text.StringBuilder;
+import hunt.util.TypeUtils;
+import http.UrlEncoded;
+
+import std.array;
+import std.conv;
+import std.string;
+
+import hunt.logging;
+
+
+/**
+ * Http URI. Parse a HTTP URI from a string or byte array. Given a URI
+ * <code>http://user@host:port/path/info;param?query#fragment</code> this class
+ * will split it into the following undecoded optional elements:
+ * <ul>
+ * <li>{@link #getScheme()} - http:</li>
+ * <li>{@link #getAuthority()} - //name@host:port</li>
+ * <li>{@link #getHost()} - host</li>
+ * <li>{@link #getPort()} - port</li>
+ * <li>{@link #getPath()} - /path/info</li>
+ * <li>{@link #getParam()} - param</li>
+ * <li>{@link #getQuery()} - query</li>
+ * <li>{@link #getFragment()} - fragment</li>
+ * </ul>
+ * 
+	https://bob:[email protected]:8080/file;p=1?q=2#third
+	\___/   \_/ \___/ \______________/ \__/\_______/ \_/ \___/
+	|      |    |          |          |      | \_/  |    |
+	Scheme User Password    Host       Port  Path |   | Fragment
+			\_____________________________/       | Query
+						|               Path parameter
+					Authority 
+ * <p>
+ * Any parameters will be returned from {@link #getPath()}, but are excluded
+ * from the return value of {@link #getDecodedPath()}. If there are multiple
+ * parameters, the {@link #getParam()} method returns only the last one.
+ * 
+ * See_Also:
+ *	 https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20
+ *   https://web.archive.org/web/20151218094722/http://blog.lunatech.com/2009/02/03/what-every-web-developer-must-know-about-url-encoding
+ */
+class HttpURI {
+	private enum State {
+		START, HOST_OR_PATH, SCHEME_OR_PATH, HOST, IPV6, PORT, PATH, PARAM, QUERY, FRAGMENT, ASTERISK
+	}
+
+	private string _scheme;
+	private string _user;
+	private string _host;
+	private int _port;
+	private string _path;
+	private string _param;
+	private string _query;
+	private string _fragment;
+
+	string _uri;
+	string _decodedPath;
+
+	/**
+	 * Construct a normalized URI. Port is not set if it is the default port.
+	 * 
+	 * @param scheme
+	 *            the URI scheme
+	 * @param host
+	 *            the URI hose
+	 * @param port
+	 *            the URI port
+	 * @param path
+	 *            the URI path
+	 * @param param
+	 *            the URI param
+	 * @param query
+	 *            the URI query
+	 * @param fragment
+	 *            the URI fragment
+	 * @return the normalized URI
+	 */
+	static HttpURI createHttpURI(string scheme, string host, int port, string path, string param, string query,
+			string fragment) {
+		if (port == 80 && (scheme == "http"))
+			port = 0;
+		if (port == 443 && (scheme == "https"))
+			port = 0;
+		return new HttpURI(scheme, host, port, path, param, query, fragment);
+	}
+
+	this() {
+	}
+
+	this(string scheme, string host, int port, string path, string param, string query, string fragment) {
+		_scheme = scheme;
+		_host = host;
+		_port = port;
+		_path = path;
+		_param = param;
+		_query = query;
+		_fragment = fragment;
+	}
+
+	this(HttpURI uri) {
+		this(uri._scheme, uri._host, uri._port, uri._path, uri._param, uri._query, uri._fragment);
+		_uri = uri._uri;
+	}
+
+	this(string uri) {
+		_port = -1;
+		parse(State.START, uri);
+	}
+
+	// this(URI uri) {
+	// 	_uri = null;
+
+	// 	_scheme = uri.getScheme();
+	// 	_host = uri.getHost();
+	// 	if (_host == null && uri.getRawSchemeSpecificPart().startsWith("//"))
+	// 		_host = "";
+	// 	_port = uri.getPort();
+	// 	_user = uri.getUserInfo();
+	// 	_path = uri.getRawPath();
+
+	// 	_decodedPath = uri.getPath();
+	// 	if (_decodedPath != null) {
+	// 		int p = _decodedPath.lastIndexOf(';');
+	// 		if (p >= 0)
+	// 			_param = _decodedPath.substring(p + 1);
+	// 	}
+	// 	_query = uri.getRawQuery();
+	// 	_fragment = uri.getFragment();
+
+	// 	_decodedPath = null;
+	// }
+
+	this(string scheme, string host, int port, string pathQuery) {
+		_uri = null;
+
+		_scheme = scheme;
+		_host = host;
+		_port = port;
+
+		parse(State.PATH, pathQuery);
+
+	}
+
+	void parse(string uri) {
+		clear();
+		_uri = uri;
+		parse(State.START, uri);
+	}
+
+	/**
+	 * Parse according to https://tools.ietf.org/html/rfc7230#section-5.3
+	 * 
+	 * @param method
+	 *            the request method
+	 * @param uri
+	 *            the request uri
+	 */
+	void parseRequestTarget(string method, string uri) {
+		clear();
+		_uri = uri;
+
+		if (method == "CONNECT")
+			_path = uri;
+		else
+			parse(uri.startsWith("/") ? State.PATH : State.START, uri);
+	}
+
+	// deprecated("")
+	// void parseConnect(string uri) {
+	// 	clear();
+	// 	_uri = uri;
+	// 	_path = uri;
+	// }
+
+	void parse(string uri, int offset, int length) {
+		clear();
+		int end = offset + length;
+		_uri = uri.substring(offset, end);
+		parse(State.START, uri);
+	}
+
+	private void parse(State state, string uri) {
+		bool encoded = false;
+		int end = cast(int)uri.length;
+		int mark = 0;
+		int path_mark = 0;
+		char last = '/';
+		for (int i = 0; i < end; i++) {
+			char c = uri[i];
+
+			final switch (state) {
+			case State.START: {
+				switch (c) {
+				case '/':
+					mark = i;
+					state = State.HOST_OR_PATH;
+					break;
+				case ';':
+					mark = i + 1;
+					state = State.PARAM;
+					break;
+				case '?':
+					// assume empty path (if seen at start)
+					_path = "";
+					mark = i + 1;
+					state = State.QUERY;
+					break;
+				case '#':
+					mark = i + 1;
+					state = State.FRAGMENT;
+					break;
+				case '*':
+					_path = "*";
+					state = State.ASTERISK;
+					break;
+
+				case '.':
+					path_mark = i;
+					state = State.PATH;
+					encoded = true;
+					break;
+
+				default:
+					mark = i;
+					if (_scheme == null)
+						state = State.SCHEME_OR_PATH;
+					else {
+						path_mark = i;
+						state = State.PATH;
+					}
+					break;
+				}
+
+				continue;
+			}
+
+			case State.SCHEME_OR_PATH: {
+				switch (c) {
+				case ':':
+					// must have been a scheme
+					_scheme = uri.substring(mark, i);
+					// Start again with scheme set
+					state = State.START;
+					break;
+
+				case '/':
+					// must have been in a path and still are
+					state = State.PATH;
+					break;
+
+				case ';':
+					// must have been in a path
+					mark = i + 1;
+					state = State.PARAM;
+					break;
+
+				case '?':
+					// must have been in a path
+					_path = uri.substring(mark, i);
+					mark = i + 1;
+					state = State.QUERY;
+					break;
+
+				case '%':
+					// must have be in an encoded path
+					encoded = true;
+					state = State.PATH;
+					break;
+
+				case '#':
+					// must have been in a path
+					_path = uri.substring(mark, i);
+					state = State.FRAGMENT;
+					break;
+
+				default:
+					break;
+				}
+				continue;
+			}
+
+			case State.HOST_OR_PATH: {
+				switch (c) {
+				case '/':
+					_host = "";
+					mark = i + 1;
+					state = State.HOST;
+					break;
+
+				case '@':
+				case ';':
+				case '?':
+				case '#':
+					// was a path, look again
+					i--;
+					path_mark = mark;
+					state = State.PATH;
+					break;
+
+				case '.':
+					// it is a path
+					encoded = true;
+					path_mark = mark;
+					state = State.PATH;
+					break;
+
+				default:
+					// it is a path
+					path_mark = mark;
+					state = State.PATH;
+				}
+				continue;
+			}
+
+			case State.HOST: {
+				switch (c) {
+				case '/':
+					_host = uri.substring(mark, i);
+					path_mark = mark = i;
+					state = State.PATH;
+					break;
+				case ':':
+					if (i > mark)
+						_host = uri.substring(mark, i);
+					mark = i + 1;
+					state = State.PORT;
+					break;
+				case '@':
+					if (_user != null)
+						throw new IllegalArgumentException("Bad authority");
+					_user = uri.substring(mark, i);
+					mark = i + 1;
+					break;
+
+				case '[':
+					state = State.IPV6;
+					break;
+					
+				default:
+					break;
+				}
+				break;
+			}
+
+			case State.IPV6: {
+				switch (c) {
+				case '/':
+					throw new IllegalArgumentException("No closing ']' for ipv6 in " ~ uri);
+				case ']':
+					c = uri.charAt(++i);
+					_host = uri.substring(mark, i);
+					if (c == ':') {
+						mark = i + 1;
+						state = State.PORT;
+					} else {
+						path_mark = mark = i;
+						state = State.PATH;
+					}
+					break;
+					
+				default:
+					break;
+				}
+
+				break;
+			}
+
+			case State.PORT: {
+				if (c == '@') {
+					if (_user != null)
+						throw new IllegalArgumentException("Bad authority");
+					// It wasn't a port, but a password!
+					_user = _host ~ ":" ~ uri.substring(mark, i);
+					mark = i + 1;
+					state = State.HOST;
+				} else if (c == '/') {
+					// _port = TypeUtils.parseInt(uri, mark, i - mark, 10);
+					_port = to!int(uri[mark .. i], 10);
+					path_mark = mark = i;
+					state = State.PATH;
+				}
+				break;
+			}
+
+			case State.PATH: {
+				switch (c) {
+				case ';':
+					mark = i + 1;
+					state = State.PARAM;
+					break;
+				case '?':
+					_path = uri.substring(path_mark, i);
+					mark = i + 1;
+					state = State.QUERY;
+					break;
+				case '#':
+					_path = uri.substring(path_mark, i);
+					mark = i + 1;
+					state = State.FRAGMENT;
+					break;
+				case '%':
+					encoded = true;
+					break;
+				case '.':
+					if ('/' == last)
+						encoded = true;
+					break;
+					
+				default:
+					break;
+				}
+				break;
+			}
+
+			case State.PARAM: {
+				switch (c) {
+				case '?':
+					_path = uri.substring(path_mark, i);
+					_param = uri.substring(mark, i);
+					mark = i + 1;
+					state = State.QUERY;
+					break;
+				case '#':
+					_path = uri.substring(path_mark, i);
+					_param = uri.substring(mark, i);
+					mark = i + 1;
+					state = State.FRAGMENT;
+					break;
+				case '/':
+					encoded = true;
+					// ignore internal params
+					state = State.PATH;
+					break;
+				case ';':
+					// multiple parameters
+					mark = i + 1;
+					break;
+					
+				default:
+					break;
+				}
+				break;
+			}
+
+			case State.QUERY: {
+				if (c == '#') {
+					_query = uri.substring(mark, i);
+					mark = i + 1;
+					state = State.FRAGMENT;
+				}
+				break;
+			}
+
+			case State.ASTERISK: {
+				throw new IllegalArgumentException("Bad character '*'");
+			}
+
+			case State.FRAGMENT: {
+				_fragment = uri.substring(mark, end);
+				i = end;
+				break;
+			}
+			}
+			last = c;
+		}
+
+		final switch (state) {
+		case State.START:
+			break;
+		case State.SCHEME_OR_PATH:
+			_path = uri.substring(mark, end);
+			break;
+
+		case State.HOST_OR_PATH:
+			_path = uri.substring(mark, end);
+			break;
+
+		case State.HOST:
+			if (end > mark)
+				_host = uri.substring(mark, end);
+			break;
+
+		case State.IPV6:
+			throw new IllegalArgumentException("No closing ']' for ipv6 in " ~ uri);
+
+		case State.PORT:
+			// _port = TypeUtils.parseInt(uri, mark, end - mark, 10);
+			_port = to!int(uri[mark .. end], 10);
+			break;
+
+		case State.ASTERISK:
+			break;
+
+		case State.FRAGMENT:
+			_fragment = uri.substring(mark, end);
+			break;
+
+		case State.PARAM:
+			_path = uri.substring(path_mark, end);
+			_param = uri.substring(mark, end);
+			break;
+
+		case State.PATH:
+			_path = uri.substring(path_mark, end);
+			break;
+
+		case State.QUERY:
+			_query = uri.substring(mark, end);
+			break;
+		}
+
+		if (!encoded) {
+			if (_param == null)
+				_decodedPath = _path;
+			else
+				_decodedPath = _path[0 .. _path.length - _param.length - 1];
+		}
+	}
+
+	string getScheme() {
+		return _scheme;
+	}
+
+	string getHost() {
+		// Return null for empty host to retain compatibility with java.net.URI
+		if (_host != null && _host.length == 0)
+			return null;
+		return _host;
+	}
+
+	int getPort() {
+		return _port;
+	}
+
+	/**
+	 * The parsed Path.
+	 * 
+	 * @return the path as parsed on valid URI. null for invalid URI.
+	 */
+	string getPath() {
+		return _path;
+	}
+
+	string getDecodedPath() {
+		if (_decodedPath.empty && !_path.empty)
+			_decodedPath = URIUtils.canonicalPath(URIUtils.decodePath(_path));
+		return _decodedPath;
+	}
+
+	string getParam() {
+		return _param;
+	}
+
+	string getQuery() {
+		return _query;
+	}
+
+	bool hasQuery() {
+		return _query != null && _query.length > 0;
+	}
+
+	string getFragment() {
+		return _fragment;
+	}
+
+	void decodeQueryTo(MultiMap!string parameters, string encoding = StandardCharsets.UTF_8) {
+		if (_query == _fragment)
+			return;
+
+		UrlEncoded.decodeTo(_query, parameters, encoding);
+	}
+
+	void clear() {
+		_uri = null;
+
+		_scheme = null;
+		_host = null;
+		_port = -1;
+		_path = null;
+		_param = null;
+		_query = null;
+		_fragment = null;
+
+		_decodedPath = null;
+	}
+
+	bool isAbsolute() {
+		return _scheme != null && _scheme.length > 0;
+	}
+
+	override
+	string toString() {
+		if (_uri is null) {
+			StringBuilder ot = new StringBuilder();
+
+			if (_scheme != null)
+				ot.append(_scheme).append(':');
+
+			if (_host != null) {
+				ot.append("//");
+				if (_user != null)
+					ot.append(_user).append('@');
+				ot.append(_host);
+			}
+
+			if (_port > 0)
+				ot.append(':').append(_port);
+
+			if (_path != null)
+				ot.append(_path);
+
+			if (_query != null)
+				ot.append('?').append(_query);
+
+			if (_fragment != null)
+				ot.append('#').append(_fragment);
+
+			if (ot.length > 0)
+				_uri = ot.toString();
+			else
+				_uri = "";
+		}
+		return _uri;
+	}
+
+	bool equals(Object o) {
+		if (o is this)
+			return true;
+		if (!(typeid(o) == typeid(HttpURI)))
+			return false;
+		return toString().equals(o.toString());
+	}
+
+	void setScheme(string scheme) {
+		_scheme = scheme;
+		_uri = null;
+	}
+
+	/**
+	 * @param host
+	 *            the host
+	 * @param port
+	 *            the port
+	 */
+	void setAuthority(string host, int port) {
+		_host = host;
+		_port = port;
+		_uri = null;
+	}
+
+	/**
+	 * @param path
+	 *            the path
+	 */
+	void setPath(string path) {
+		_uri = null;
+		_path = path;
+		_decodedPath = null;
+	}
+
+	/**
+	 * @param path
+	 *            the decoded path
+	 */
+	// void setDecodedPath(string path) {
+	// 	_uri = null;
+	// 	_path = URIUtils.encodePath(path);
+	// 	_decodedPath = path;
+	// }
+
+	void setPathQuery(string path) {
+		_uri = null;
+		_path = null;
+		_decodedPath = null;
+		_param = null;
+		_fragment = null;
+		if (path != null)
+			parse(State.PATH, path);
+	}
+
+	void setQuery(string query) {
+		_query = query;
+		_uri = null;
+	}
+
+	// URI toURI() {
+	// 	return new URI(_scheme, null, _host, _port, _path, _query == null ? null : UrlEncoded.decodestring(_query),
+	// 			_fragment);
+	// }
+
+	string getPathQuery() {
+		if (_query == null)
+			return _path;
+		return _path ~ "?" ~ _query;
+	}
+
+	bool hasAuthority() {
+		return _host != null;
+	}
+
+	string getAuthority() {
+		if (_port > 0)
+			return _host ~ ":" ~ to!string(_port);
+		return _host;
+	}
+
+	string getUser() {
+		return _user;
+	}
+
+}
+
+
+/**
+ * Parse an authority string into Host and Port
+ * <p>Parse a string in the form "host:port", handling IPv4 an IPv6 hosts</p>
+ *
+ */
+class URIUtils
+{
+	/* ------------------------------------------------------------ */
+    /* Decode a URI path and strip parameters
+     */
+    static string decodePath(string path) {
+        return decodePath(path, 0, cast(int)path.length);
+    }
+
+    /* ------------------------------------------------------------ */
+    /* Decode a URI path and strip parameters of UTF-8 path
+     */
+    static string decodePath(string path, int offset, int length) {
+        try {
+            StringBuilder builder = null;
+
+            int end = offset + length;
+            for (int i = offset; i < end; i++) {
+                char c = path[i];
+                switch (c) {
+                    case '%':
+                        if (builder is null) {
+                            builder = new StringBuilder(path.length);
+                            builder.append(path, offset, i - offset);
+                        }
+                        if ((i + 2) < end) {
+                            char u = path.charAt(i + 1);
+                            if (u == 'u') {
+                                // TODO this is wrong. This is a codepoint not a char
+                                builder.append(cast(char) (0xffff & TypeUtils.parseInt(path, i + 2, 4, 16)));
+                                i += 5;
+                            } else {
+                                builder.append(cast(byte) (0xff & (TypeUtils.convertHexDigit(u) * 16 + TypeUtils.convertHexDigit(path.charAt(i + 2)))));
+                                i += 2;
+                            }
+                        } else {
+                            throw new IllegalArgumentException("Bad URI % encoding");
+                        }
+
+                        break;
+
+                    case ';':
+                        if (builder is null) {
+                            builder = new StringBuilder(path.length);
+                            builder.append(path, offset, i - offset);
+                        }
+
+                        while (++i < end) {
+                            if (path[i] == '/') {
+                                builder.append('/');
+                                break;
+                            }
+                        }
+
+                        break;
+
+                    default:
+                        if (builder !is null)
+                            builder.append(c);
+                        break;
+                }
+            }
+
+            if (builder !is null)
+                return builder.toString();
+            if (offset == 0 && length == path.length)
+                return path;
+            return path.substring(offset, end);
+        } catch (Exception e) {
+            // System.err.println(path.substring(offset, offset + length) + " " + e);
+			error(e.toString);
+            return decodeISO88591Path(path, offset, length);
+        }
+    }
+
+
+    /* ------------------------------------------------------------ */
+    /* Decode a URI path and strip parameters of ISO-8859-1 path
+     */
+    private static string decodeISO88591Path(string path, int offset, int length) {
+        StringBuilder builder = null;
+        int end = offset + length;
+        for (int i = offset; i < end; i++) {
+            char c = path[i];
+            switch (c) {
+                case '%':
+                    if (builder is null) {
+                        builder = new StringBuilder(path.length);
+                        builder.append(path, offset, i - offset);
+                    }
+                    if ((i + 2) < end) {
+                        char u = path.charAt(i + 1);
+                        if (u == 'u') {
+                            // TODO this is wrong. This is a codepoint not a char
+                            builder.append(cast(char) (0xffff & TypeUtils.parseInt(path, i + 2, 4, 16)));
+                            i += 5;
+                        } else {
+                            builder.append(cast(byte) (0xff & (TypeUtils.convertHexDigit(u) * 16 + TypeUtils.convertHexDigit(path.charAt(i + 2)))));
+                            i += 2;
+                        }
+                    } else {
+                        throw new IllegalArgumentException("");
+                    }
+
+                    break;
+
+                case ';':
+                    if (builder is null) {
+                        builder = new StringBuilder(path.length);
+                        builder.append(path, offset, i - offset);
+                    }
+                    while (++i < end) {
+                        if (path[i] == '/') {
+                            builder.append('/');
+                            break;
+                        }
+                    }
+                    break;
+
+
+                default:
+                    if (builder !is null)
+                        builder.append(c);
+                    break;
+            }
+        }
+
+        if (builder !is null)
+            return builder.toString();
+        if (offset == 0 && length == path.length)
+            return path;
+        return path.substring(offset, end);
+    }
+
+	/* ------------------------------------------------------------ */
+
+    /**
+     * Convert a decoded path to a canonical form.
+     * <p>
+     * All instances of "." and ".." are factored out.
+     * </p>
+     * <p>
+     * Null is returned if the path tries to .. above its root.
+     * </p>
+     *
+     * @param path the path to convert, decoded, with path separators '/' and no queries.
+     * @return the canonical path, or null if path traversal above root.
+     */
+    static string canonicalPath(string path) {
+        if (path.empty)
+            return path;
+
+        bool slash = true;
+        int end = cast(int)path.length;
+        int i = 0;
+
+        loop:
+        while (i < end) {
+            char c = path[i];
+            switch (c) {
+                case '/':
+                    slash = true;
+                    break;
+
+                case '.':
+                    if (slash)
+                        break loop;
+                    slash = false;
+                    break;
+
+                default:
+                    slash = false;
+            }
+
+            i++;
+        }
+
+        if (i == end)
+            return path;
+
+        StringBuilder canonical = new StringBuilder(path.length);
+        canonical.append(path, 0, i);
+
+        int dots = 1;
+        i++;
+        while (i <= end) {
+            char c = i < end ? path[i] : '\0';
+            switch (c) {
+                case '\0':
+                case '/':
+                    switch (dots) {
+                        case 0:
+                            if (c != '\0')
+                                canonical.append(c);
+                            break;
+
+                        case 1:
+                            break;
+
+                        case 2:
+                            if (canonical.length < 2)
+                                return null;
+                            canonical.setLength(canonical.length - 1);
+                            canonical.setLength(canonical.lastIndexOf("/") + 1);
+                            break;
+
+                        default:
+                            while (dots-- > 0)
+                                canonical.append('.');
+                            if (c != '\0')
+                                canonical.append(c);
+                    }
+
+                    slash = true;
+                    dots = 0;
+                    break;
+
+                case '.':
+                    if (dots > 0)
+                        dots++;
+                    else if (slash)
+                        dots = 1;
+                    else
+                        canonical.append('.');
+                    slash = false;
+                    break;
+
+                default:
+                    while (dots-- > 0)
+                        canonical.append('.');
+                    canonical.append(c);
+                    dots = 0;
+                    slash = false;
+            }
+
+            i++;
+        }
+        return canonical.toString();
+    }
+
+
+    /* ------------------------------------------------------------ */
+
+    /**
+     * Convert a path to a cananonical form.
+     * <p>
+     * All instances of "." and ".." are factored out.
+     * </p>
+     * <p>
+     * Null is returned if the path tries to .. above its root.
+     * </p>
+     *
+     * @param path the path to convert (expects URI/URL form, encoded, and with path separators '/')
+     * @return the canonical path, or null if path traversal above root.
+     */
+    static string canonicalEncodedPath(string path) {
+        if (path.empty)
+            return path;
+
+        bool slash = true;
+        int end = cast(int)path.length;
+        int i = 0;
+
+        loop:
+        while (i < end) {
+            char c = path[i];
+            switch (c) {
+                case '/':
+                    slash = true;
+                    break;
+
+                case '.':
+                    if (slash)
+                        break loop;
+                    slash = false;
+                    break;
+
+                case '?':
+                    return path;
+
+                default:
+                    slash = false;
+            }
+
+            i++;
+        }
+
+        if (i == end)
+            return path;
+
+        StringBuilder canonical = new StringBuilder(path.length);
+        canonical.append(path, 0, i);
+
+        int dots = 1;
+        i++;
+        while (i <= end) {
+            char c = i < end ? path[i] : '\0';
+            switch (c) {
+                case '\0':
+                case '/':
+                case '?':
+                    switch (dots) {
+                        case 0:
+                            if (c != '\0')
+                                canonical.append(c);
+                            break;
+
+                        case 1:
+                            if (c == '?')
+                                canonical.append(c);
+                            break;
+
+                        case 2:
+                            if (canonical.length < 2)
+                                return null;
+                            canonical.setLength(canonical.length - 1);
+                            canonical.setLength(canonical.lastIndexOf("/") + 1);
+                            if (c == '?')
+                                canonical.append(c);
+                            break;
+                        default:
+                            while (dots-- > 0)
+                                canonical.append('.');
+                            if (c != '\0')
+                                canonical.append(c);
+                    }
+
+                    slash = true;
+                    dots = 0;
+                    break;
+
+                case '.':
+                    if (dots > 0)
+                        dots++;
+                    else if (slash)
+                        dots = 1;
+                    else
+                        canonical.append('.');
+                    slash = false;
+                    break;
+
+                default:
+                    while (dots-- > 0)
+                        canonical.append('.');
+                    canonical.append(c);
+                    dots = 0;
+                    slash = false;
+            }
+
+            i++;
+        }
+        return canonical.toString();
+    }
+
+
+
+    /* ------------------------------------------------------------ */
+
+    /**
+     * Convert a path to a compact form.
+     * All instances of "//" and "///" etc. are factored out to single "/"
+     *
+     * @param path the path to compact
+     * @return the compacted path
+     */
+    static string compactPath(string path) {
+        if (path == null || path.length == 0)
+            return path;
+
+        int state = 0;
+        int end = cast(int)path.length;
+        int i = 0;
+
+        loop:
+        while (i < end) {
+            char c = path[i];
+            switch (c) {
+                case '?':
+                    return path;
+                case '/':
+                    state++;
+                    if (state == 2)
+                        break loop;
+                    break;
+                default:
+                    state = 0;
+            }
+            i++;
+        }
+
+        if (state < 2)
+            return path;
+
+        StringBuilder buf = new StringBuilder(path.length);
+        buf.append(path, 0, i);
+
+        loop2:
+        while (i < end) {
+            char c = path[i];
+            switch (c) {
+                case '?':
+                    buf.append(path, i, end);
+                    break loop2;
+                case '/':
+                    if (state++ == 0)
+                        buf.append(c);
+                    break;
+                default:
+                    state = 0;
+                    buf.append(c);
+            }
+            i++;
+        }
+
+        return buf.toString();
+    }
+
+    /* ------------------------------------------------------------ */
+
+    /**
+     * @param uri URI
+     * @return True if the uri has a scheme
+     */
+    static bool hasScheme(string uri) {
+        for (int i = 0; i < uri.length; i++) {
+            char c = uri[i];
+            if (c == ':')
+                return true;
+            if (!(c >= 'a' && c <= 'z' ||
+                    c >= 'A' && c <= 'Z' ||
+                    (i > 0 && (c >= '0' && c <= '9' ||
+                            c == '.' ||
+                            c == '+' ||
+                            c == '-'))
+            ))
+                break;
+        }
+        return false;
+    }
+}

+ 9 - 3
frameworks/D/hunt/source/http/Processor.d

@@ -7,7 +7,7 @@ import core.stdc.stdlib;
 import core.thread, core.atomic;
 import http.Parser;
 
-import hunt.datetime;
+import hunt.util.DateTime;
 import hunt.logging;
 import hunt.io;
 
@@ -41,6 +41,7 @@ private:
 	HttpRequest request;
 	State state;
 	bool serving;
+	
 public:
 	TcpStream client;
 
@@ -157,8 +158,13 @@ public:
 	final int onMessageComplete(Parser* parser) {
 		import std.stdio;
 
-		if (state == State.done)
-			onComplete(request);
+		if (state == State.done) {
+			try {
+				onComplete(request);
+			} catch(Exception ex) {
+				respondWith(ex.msg, 500);
+			}
+		}
 		if (!parser.shouldKeepAlive)
 			serving = false;
 		return 0;

+ 3 - 3
frameworks/D/hunt/source/http/Server.d

@@ -1,10 +1,10 @@
 module http.Server;
 
-import hunt.datetime;
 import hunt.event;
 import hunt.io;
-import hunt.logging;
-import hunt.util.memory : totalCPUs;
+import hunt.logging.ConsoleLogger;
+import hunt.system.Memory : totalCPUs;
+import hunt.util.DateTime;
 
 import std.array;
 import std.conv;

+ 361 - 0
frameworks/D/hunt/source/http/UrlEncoded.d

@@ -0,0 +1,361 @@
+module http.UrlEncoded;
+
+import hunt.collection.List;
+import hunt.collection.MultiMap;
+import hunt.collection.StringBuffer;
+import hunt.Exceptions;
+import hunt.logging;
+import hunt.text.Charset;
+import hunt.text.Common;
+import hunt.text.StringBuilder;
+import hunt.util.TypeUtils;
+
+import std.conv;
+import std.array;
+
+
+/**
+ * Handles coding of MIME "x-www-form-urlencoded".
+ * <p>
+ * This class handles the encoding and decoding for either the query string of a
+ * URL or the _content of a POST HTTP request.
+ * </p>
+ * <b>Notes</b>
+ * <p>
+ * The UTF-8 charset is assumed, unless otherwise defined by either passing a
+ * parameter or setting the "org.hunt.utils.UrlEncoding.charset" System
+ * property.
+ * </p>
+ * <p>
+ * The hashtable either contains string single values, vectors of string or
+ * arrays of Strings.
+ * </p>
+ * <p>
+ * This class is only partially synchronised. In particular, simple get
+ * operations are not protected from concurrent updates.
+ * </p>
+ *
+ * @see java.net.URLEncoder
+ */
+class UrlEncoded  : MultiMap!string { 
+    
+    enum string ENCODING = StandardCharsets.UTF_8;
+
+
+    this() {
+    }
+
+    this(string query) {
+        decodeTo(query, this, ENCODING);
+    }
+
+    void decode(string query) {
+        decodeTo(query, this, ENCODING);
+    }
+
+    void decode(string query, string charset) {
+        decodeTo(query, this, charset);
+    }
+
+    /**
+     * Encode MultiMap with % encoding for UTF8 sequences.
+     *
+     * @return the MultiMap as a string with % encoding
+     */
+    string encode() {
+        return encode(ENCODING, false);
+    }
+
+    /**
+     * Encode MultiMap with % encoding for arbitrary string sequences.
+     *
+     * @param charset the charset to use for encoding
+     * @return the MultiMap as a string encoded with % encodings
+     */
+    string encode(string charset) {
+        return encode(charset, false);
+    }
+
+    /**
+     * Encode MultiMap with % encoding.
+     *
+     * @param charset            the charset to encode with
+     * @param equalsForNullValue if True, then an '=' is always used, even
+     *                           for parameters without a value. e.g. <code>"blah?a=&amp;b=&amp;c="</code>.
+     * @return the MultiMap as a string encoded with % encodings
+     */
+    string encode(string charset, bool equalsForNullValue) {
+        return encode(this, charset, equalsForNullValue);
+    }
+
+    /**
+     * Encode MultiMap with % encoding.
+     *
+     * @param map                the map to encode
+     * @param charset            the charset to use for encoding (uses default encoding if null)
+     * @param equalsForNullValue if True, then an '=' is always used, even
+     *                           for parameters without a value. e.g. <code>"blah?a=&amp;b=&amp;c="</code>.
+     * @return the MultiMap as a string encoded with % encodings.
+     */
+    static string encode(MultiMap!string map, string charset, bool equalsForNullValue) {
+        if (charset is null)
+            charset = ENCODING;
+
+        StringBuilder result = new StringBuilder(128);
+
+        bool delim = false;
+        foreach(string key, List!string list; map)
+        {
+            int s = list.size();
+
+            if (delim) {
+                result.append('&');
+            }
+
+            if (s == 0) {
+                result.append(encodeString(key, charset));
+                if (equalsForNullValue)
+                    result.append('=');
+            } else {
+                for (int i = 0; i < s; i++) {
+                    if (i > 0)
+                        result.append('&');
+                    string val = list.get(i);
+                    result.append(encodeString(key, charset));
+
+                    if (val != null) {
+                        if (val.length > 0) {
+                            result.append('=');
+                            result.append(encodeString(val, charset));
+                        } else if (equalsForNullValue)
+                            result.append('=');
+                    } else if (equalsForNullValue)
+                        result.append('=');
+                }
+            }
+            delim = true;
+        }
+        return result.toString();
+    }
+
+    /**
+     * Decoded parameters to Map.
+     *
+     * @param content the string containing the encoded parameters
+     * @param map     the MultiMap to put parsed query parameters into
+     * @param charset the charset to use for decoding
+     */
+    static void decodeTo(string content, MultiMap!string map, string charset = ENCODING) {
+        if (charset.empty)
+            charset = ENCODING;
+
+        synchronized (map) {
+            string key = null;
+            string value = null;
+            int mark = -1;
+            bool encoded = false;
+            for (int i = 0; i < content.length; i++) {
+                char c = content[i];
+                switch (c) {
+                    case '&':
+                        int l = i - mark - 1;
+                        value = l == 0 ? "" :
+                                (encoded ? decodeString(content, mark + 1, l) : content.substring(mark + 1, i));
+                        mark = i;
+                        encoded = false;
+                        if (key != null) {
+                            map.add(key, value);
+                        } else if (value != null && value.length > 0) {
+                            map.add(value, "");
+                        }
+                        key = null;
+                        value = null;
+                        break;
+                    case '=':
+                        if (key != null)
+                            break;
+                        key = encoded ? decodeString(content, mark + 1, i - mark - 1) : content.substring(mark + 1, i);
+                        mark = i;
+                        encoded = false;
+                        break;
+                    case '+':
+                        encoded = true;
+                        break;
+                    case '%':
+                        encoded = true;
+                        break;
+                    default: break;
+                }
+            }
+
+            int contentLen = cast(int)content.length;
+
+            if (key != null) {
+                int l =  contentLen - mark - 1;
+                value = l == 0 ? "" : (encoded ? decodeString(content, mark + 1, l) : content.substring(mark + 1));
+                version(HUNT_DEBUG) tracef("key=%s, value=%s", key, value);
+                map.add(key, value);
+            } else if (mark < contentLen) {
+                version(HUNT_DEBUG) tracef("empty value: content=%s, key=%s", content, key);
+                key = encoded
+                        ? decodeString(content, mark + 1, contentLen - mark - 1, charset)
+                        : content.substring(mark + 1);
+                if (!key.empty) {
+                    map.add(key, "");
+                }
+            } else {
+                warningf("No key found.");
+            }
+        }
+    }
+
+    /**
+     * Decode string with % encoding.
+     * This method makes the assumption that the majority of calls
+     * will need no decoding.
+     *
+     * @param encoded the encoded string to decode
+     * @return the decoded string
+     */
+    static string decodeString(string encoded) {
+        return decodeString(encoded, 0, cast(int)encoded.length);
+    }
+
+    /**
+     * Decode string with % encoding.
+     * This method makes the assumption that the majority of calls
+     * will need no decoding.
+     *
+     * @param encoded the encoded string to decode
+     * @param offset  the offset in the encoded string to decode from
+     * @param length  the length of characters in the encoded string to decode
+     * @param charset the charset to use for decoding
+     * @return the decoded string
+     */
+    static string decodeString(string encoded, int offset, int length, string charset = ENCODING) {
+        StringBuffer buffer = null;
+
+        for (int i = 0; i < length; i++) {
+            char c = encoded.charAt(offset + i);
+            if (c < 0 || c > 0xff) {
+                if (buffer is null) {
+                    buffer = new StringBuffer(length);
+                    buffer.append(encoded, offset, offset + i + 1);
+                } else
+                    buffer.append(c);
+            } else if (c == '+') {
+                if (buffer is null) {
+                    buffer = new StringBuffer(length);
+                    buffer.append(encoded, offset, offset + i);
+                }
+
+                buffer.append(' ');
+            } else if (c == '%') {
+                if (buffer is null) {
+                    buffer = new StringBuffer(length);
+                    buffer.append(encoded, offset, offset + i);
+                }
+
+                byte[] ba = new byte[length];
+                int n = 0;
+                while (c >= 0 && c <= 0xff) {
+                    if (c == '%') {
+                        if (i + 2 < length) {
+                            int o = offset + i + 1;
+                            i += 3;
+                            ba[n] = cast(byte) TypeUtils.parseInt(encoded, o, 2, 16);
+                            n++;
+                        } else {
+                            ba[n++] = cast(byte) '?';
+                            i = length;
+                        }
+                    } else if (c == '+') {
+                        ba[n++] = cast(byte) ' ';
+                        i++;
+                    } else {
+                        ba[n++] = cast(byte) c;
+                        i++;
+                    }
+
+                    if (i >= length)
+                        break;
+                    c = encoded.charAt(offset + i);
+                }
+
+                i--;
+                buffer.append(cast(string)(ba[0 .. n]));
+
+            } else if (buffer !is null)
+                buffer.append(c);
+        }
+
+        if (buffer is null) {
+            if (offset == 0 && encoded.length == length)
+                return encoded;
+            return encoded.substring(offset, offset + length);
+        }
+
+        return buffer.toString();
+    }
+
+
+    /**
+     * Perform URL encoding.
+     *
+     * @param string the string to encode
+     * @return encoded string.
+     */
+    static string encodeString(string string) {
+        return encodeString(string, ENCODING);
+    }
+
+    /**
+     * Perform URL encoding.
+     *
+     * @param string  the string to encode
+     * @param charset the charset to use for encoding
+     * @return encoded string.
+     */
+    static string encodeString(string str, string charset) {
+        if (charset is null)
+            charset = ENCODING;
+        byte[] bytes = cast(byte[])str;
+        // bytes = string.getBytes(charset);
+
+        int len = cast(int)bytes.length;
+        byte[] encoded = new byte[bytes.length * 3];
+        int n = 0;
+        bool noEncode = true;
+
+        for (int i = 0; i < len; i++) {
+            byte b = bytes[i];
+
+            if (b == ' ') {
+                noEncode = false;
+                encoded[n++] = cast(byte) '+';
+            } else if (b >= 'a' && b <= 'z' ||
+                    b >= 'A' && b <= 'Z' ||
+                    b >= '0' && b <= '9') {
+                encoded[n++] = b;
+            } else {
+                noEncode = false;
+                encoded[n++] = cast(byte) '%';
+                byte nibble = cast(byte) ((b & 0xf0) >> 4);
+                if (nibble >= 10)
+                    encoded[n++] = cast(byte) ('A' + nibble - 10);
+                else
+                    encoded[n++] = cast(byte) ('0' + nibble);
+                nibble = cast(byte) (b & 0xf);
+                if (nibble >= 10)
+                    encoded[n++] = cast(byte) ('A' + nibble - 10);
+                else
+                    encoded[n++] = cast(byte) ('0' + nibble);
+            }
+        }
+
+        if (noEncode)
+            return str;
+
+        return cast(string)(encoded[0 .. n]);
+    }
+}