1 /**
2  * Concepts shared between HTTP clients and servers.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Stéphan Kochen <stephan@kochen.nl>
12  *   Vladimir Panteleev <ae@cy.md>
13  *   Simon Arlott
14  */
15 
16 module ae.net.http.common;
17 
18 import core.time;
19 
20 import std.algorithm;
21 import std.array;
22 import std..string;
23 import std.conv;
24 import std.ascii;
25 import std.exception;
26 import std.datetime;
27 import std.typecons : tuple;
28 
29 import ae.net.ietf.headers;
30 import ae.sys.data;
31 import ae.utils.array : amap, afilter, auniq, asort;
32 import ae.utils.text;
33 import ae.utils.time;
34 import zlib = ae.utils.zlib;
35 import gzip = ae.utils.gzip;
36 
37 /// Base HTTP message class
38 private abstract class HttpMessage
39 {
40 public:
41 	string protocol = "http";
42 	string protocolVersion = "1.0";
43 	Headers headers;
44 	Data[] data;
45 	SysTime creationTime;
46 
47 	this()
48 	{
49 		creationTime = Clock.currTime();
50 	}
51 
52 	@property Duration age()
53 	{
54 		return Clock.currTime() - creationTime;
55 	}
56 }
57 
58 /// HTTP request class
59 class HttpRequest : HttpMessage
60 {
61 public:
62 	/// HTTP method, e.g., "GET".
63 	string method = "GET";
64 
65 	/// If this request is going through a HTTP proxy server, this
66 	/// should be set to its address.
67 	string proxy;
68 
69 	this()
70 	{
71 	} ///
72 
73 	this(string url)
74 	{
75 		this.resource = url;
76 	} ///
77 
78 	/// Resource part of URL (everything after the hostname)
79 	@property string resource()
80 	{
81 		return _resource;
82 	}
83 
84 	/// Set the resource part of the URL, or the entire URL.
85 	/// Setting the resource to a full URL will fill in the Host header, as well.
86 	@property void resource(string value)
87 	{
88 		_resource = value;
89 
90 		// applies to both Client/Server as some clients put a full URL in the GET line instead of using a "Host" header
91 		string protocol;
92 		if (_resource.asciiStartsWith("http://"))
93 			protocol = "http";
94 		else
95 		if (_resource.asciiStartsWith("https://"))
96 			protocol = "https";
97 
98 		if (protocol)
99 		{
100 			this.protocol = protocol;
101 
102 			value = value[protocol.length+3..$];
103 			auto pathstart = value.indexOf('/');
104 			if (pathstart == -1)
105 			{
106 				host = value;
107 				_resource = "/";
108 			}
109 			else
110 			{
111 				host = value[0..pathstart];
112 				_resource = value[pathstart..$];
113 			}
114 			auto portstart = host().indexOf(':');
115 			if (portstart != -1)
116 			{
117 				port = to!ushort(host[portstart+1..$]);
118 				host = host[0..portstart];
119 			}
120 		}
121 	}
122 
123 	/// The hostname, without the port number
124 	@property string host()
125 	{
126 		string _host = headers.get("Host", null);
127 		auto colon = _host.lastIndexOf(":");
128 		return colon<0 ? _host : _host[0..colon];
129 	}
130 
131 	/// Sets the hostname (and the `"Host"` header).
132 	/// Must not include a port number.
133 	/// Does not change the previously-set port number.
134 	@property void host(string _host)
135 	{
136 		auto _port = this.port;
137 		headers["Host"] = _port==protocolDefaultPort ? _host : _host ~ ":" ~ text(_port);
138 	}
139 
140 	/// Retrieves the default port number for the currently set `protocol`.
141 	@property ushort protocolDefaultPort()
142 	{
143 		switch (protocol)
144 		{
145 			case "http":
146 				return 80;
147 			case "https":
148 				return 443;
149 			default:
150 				throw new Exception("Unknown protocol: " ~ protocol);
151 		}
152 	}
153 
154 	/// Port number, from `"Host"` header.
155 	/// Defaults to `protocolDefaultPort`.
156 	@property ushort port()
157 	{
158 		if ("Host" in headers)
159 		{
160 			string _host = headers["Host"];
161 			auto colon = _host.lastIndexOf(":");
162 			return colon<0 ? protocolDefaultPort : to!ushort(_host[colon+1..$]);
163 		}
164 		else
165 			return _port ? _port : protocolDefaultPort;
166 	}
167 
168 	/// Sets the port number.
169 	/// If it is equal to `protocolDefaultPort`, then it is not
170 	/// included in the `"Host"` header.
171 	@property void port(ushort _port)
172 	{
173 		if ("Host" in headers)
174 		{
175 			if (_port == protocolDefaultPort)
176 				headers["Host"] = this.host;
177 			else
178 				headers["Host"] = this.host ~ ":" ~ text(_port);
179 		}
180 		else
181 			this._port = _port;
182 	}
183 
184 	/// Path part of request (until the `'?'`).
185 	@property string path()
186 	{
187 		auto p = resource.indexOf('?');
188 		if (p >= 0)
189 			return resource[0..p];
190 		else
191 			return resource;
192 	}
193 
194 	/// Query string part of request (atfer the `'?'`).
195 	@property string queryString()
196 	{
197 		auto p = resource.indexOf('?');
198 		if (p >= 0)
199 			return resource[p+1..$];
200 		else
201 			return null;
202 	}
203 
204 	/// ditto
205 	@property void queryString(string value)
206 	{
207 		auto p = resource.indexOf('?');
208 		if (p >= 0)
209 			resource = resource[0..p];
210 		if (value)
211 			resource = resource ~ '?' ~ value;
212 	}
213 
214 	/// The query string parameters.
215 	@property UrlParameters urlParameters()
216 	{
217 		return decodeUrlParameters(queryString);
218 	}
219 
220 	/// ditto
221 	@property void urlParameters(UrlParameters parameters)
222 	{
223 		queryString = encodeUrlParameters(parameters);
224 	}
225 
226 	/// URL without resource (protocol, host and port).
227 	@property string root()
228 	{
229 		return protocol ~ "://" ~ host ~ (port==protocolDefaultPort ? null : ":" ~ to!string(port));
230 	}
231 
232 	/// Full URL.
233 	@property string url()
234 	{
235 		return root ~ resource;
236 	}
237 
238 	/// Full URL without query parameters or fragment.
239 	@property string baseURL()
240 	{
241 		return root ~ resource.findSplit("?")[0];
242 	}
243 
244 	/// The hostname part of the proxy address, if any.
245 	@property string proxyHost()
246 	{
247 		auto portstart = proxy.indexOf(':');
248 		if (portstart != -1)
249 			return proxy[0..portstart];
250 		return proxy;
251 	}
252 
253 	/// The port number of the proxy address if it specified, otherwise `80`.
254 	@property ushort proxyPort()
255 	{
256 		auto portstart = proxy.indexOf(':');
257 		if (portstart != -1)
258 			return to!ushort(proxy[portstart+1..$]);
259 		return 80;
260 	}
261 
262 	/// Parse the first line in a HTTP request ("METHOD /resource HTTP/1.x").
263 	void parseRequestLine(string reqLine)
264 	{
265 		enforce(reqLine.length > 10, "Request line too short");
266 		auto methodEnd = reqLine.indexOf(' ');
267 		enforce(methodEnd > 0, "Malformed request line");
268 		method = reqLine[0 .. methodEnd];
269 		reqLine = reqLine[methodEnd + 1 .. reqLine.length];
270 
271 		auto resourceEnd = reqLine.lastIndexOf(' ');
272 		enforce(resourceEnd > 0, "Malformed request line");
273 		resource = reqLine[0 .. resourceEnd];
274 
275 		string protocol = reqLine[resourceEnd+1..$];
276 		enforce(protocol.startsWith("HTTP/"));
277 		protocolVersion = protocol[5..$];
278 	}
279 
280 	/// Decodes submitted form data, and returns an AA of values.
281 	UrlParameters decodePostData()
282 	{
283 		auto contentType = headers.get("Content-Type", "").decodeTokenHeader;
284 
285 		switch (contentType.value)
286 		{
287 			case "application/x-www-form-urlencoded":
288 				return decodeUrlParameters(cast(string)data.joinToHeap());
289 			case "multipart/form-data":
290 				return decodeMultipart(data.joinData, contentType.properties.get("boundary", null))
291 					.map!(part => tuple(part.headers.get("Content-Disposition", null).decodeTokenHeader.properties.get("name", null), cast(string)part.data.toHeap()))
292 					.UrlParameters;
293 			case "":
294 				throw new Exception("No Content-Type");
295 			default:
296 				throw new Exception("Unknown Content-Type: " ~ contentType.value);
297 		}
298 	}
299 
300 	/// Get list of hosts as specified in headers (e.g. X-Forwarded-For).
301 	/// First item in returned array is the node furthest away.
302 	/// Duplicates are removed.
303 	/// Specify socket remote address in remoteHost to add it to the list.
304 	deprecated("Insecure, use HttpServer.remoteIPHeader")
305 	string[] remoteHosts(string remoteHost = null)
306 	{
307 		return
308 			(headers.get("X-Forwarded-For", null).split(",").amap!(std..string.strip)() ~
309 			 headers.get("X-Forwarded-Host", null) ~
310 			 remoteHost)
311 			.afilter!`a && a != "unknown"`()
312 			.auniq();
313 	}
314 
315 	deprecated unittest
316 	{
317 		auto req = new HttpRequest();
318 		assert(req.remoteHosts() == []);
319 		assert(req.remoteHosts("3.3.3.3") == ["3.3.3.3"]);
320 
321 		req.headers["X-Forwarded-For"] = "1.1.1.1, 2.2.2.2";
322 		req.headers["X-Forwarded-Host"] = "2.2.2.2";
323 		assert(req.remoteHosts("3.3.3.3") == ["1.1.1.1", "2.2.2.2", "3.3.3.3"]);
324 	}
325 
326 	/// Basic cookie parsing
327 	string[string] getCookies()
328 	{
329 		string[string] cookies;
330 		foreach (segment; headers.get("Cookie", null).split(";"))
331 		{
332 			segment = segment.strip();
333 			auto p = segment.indexOf('=');
334 			if (p > 0)
335 				cookies[segment[0..p]] = segment[p+1..$];
336 		}
337 		return cookies;
338 	}
339 
340 private:
341 	string _resource;
342 	ushort _port = 0; // used only when no "Host" in headers; otherwise, taken from there
343 }
344 
345 /// HTTP response status codes
346 enum HttpStatusCode : ushort
347 {
348 	None                         =   0,  ///
349 
350 	Continue                     = 100,  ///
351 	SwitchingProtocols           = 101,  ///
352 
353 	OK                           = 200,  ///
354 	Created                      = 201,  ///
355 	Accepted                     = 202,  ///
356 	NonAuthoritativeInformation  = 203,  ///
357 	NoContent                    = 204,  ///
358 	ResetContent                 = 205,  ///
359 	PartialContent               = 206,  ///
360 
361 	MultipleChoices              = 300,  ///
362 	MovedPermanently             = 301,  ///
363 	Found                        = 302,  ///
364 	SeeOther                     = 303,  ///
365 	NotModified                  = 304,  ///
366 	UseProxy                     = 305,  ///
367 	//(Unused)                   = 306,  ///
368 	TemporaryRedirect            = 307,  ///
369 
370 	BadRequest                   = 400,  ///
371 	Unauthorized                 = 401,  ///
372 	PaymentRequired              = 402,  ///
373 	Forbidden                    = 403,  ///
374 	NotFound                     = 404,  ///
375 	MethodNotAllowed             = 405,  ///
376 	NotAcceptable                = 406,  ///
377 	ProxyAuthenticationRequired  = 407,  ///
378 	RequestTimeout               = 408,  ///
379 	Conflict                     = 409,  ///
380 	Gone                         = 410,  ///
381 	LengthRequired               = 411,  ///
382 	PreconditionFailed           = 412,  ///
383 	RequestEntityTooLarge        = 413,  ///
384 	RequestUriTooLong            = 414,  ///
385 	UnsupportedMediaType         = 415,  ///
386 	RequestedRangeNotSatisfiable = 416,  ///
387 	ExpectationFailed            = 417,  ///
388 
389 	InternalServerError          = 500,  ///
390 	NotImplemented               = 501,  ///
391 	BadGateway                   = 502,  ///
392 	ServiceUnavailable           = 503,  ///
393 	GatewayTimeout               = 504,  ///
394 	HttpVersionNotSupported      = 505,  ///
395 }
396 
397 /// HTTP reply class
398 class HttpResponse : HttpMessage
399 {
400 public:
401 	HttpStatusCode status; /// HTTP status code
402 	string statusMessage; /// HTTP status message, if one was supplied
403 
404 	/// What Zlib compression level to use when compressing the reply.
405 	int compressionLevel = 1;
406 
407 	/// Returns the message corresponding to the given `HttpStatusCode`,
408 	/// or `null` if the code is unknown.
409 	static string getStatusMessage(HttpStatusCode code)
410 	{
411 		switch(code)
412 		{
413 			case 100: return "Continue";
414 			case 101: return "Switching Protocols";
415 
416 			case 200: return "OK";
417 			case 201: return "Created";
418 			case 202: return "Accepted";
419 			case 203: return "Non-Authoritative Information";
420 			case 204: return "No Content";
421 			case 205: return "Reset Content";
422 			case 206: return "Partial Content";
423 			case 300: return "Multiple Choices";
424 			case 301: return "Moved Permanently";
425 			case 302: return "Found";
426 			case 303: return "See Other";
427 			case 304: return "Not Modified";
428 			case 305: return "Use Proxy";
429 			case 306: return "(Unused)";
430 			case 307: return "Temporary Redirect";
431 
432 			case 400: return "Bad Request";
433 			case 401: return "Unauthorized";
434 			case 402: return "Payment Required";
435 			case 403: return "Forbidden";
436 			case 404: return "Not Found";
437 			case 405: return "Method Not Allowed";
438 			case 406: return "Not Acceptable";
439 			case 407: return "Proxy Authentication Required";
440 			case 408: return "Request Timeout";
441 			case 409: return "Conflict";
442 			case 410: return "Gone";
443 			case 411: return "Length Required";
444 			case 412: return "Precondition Failed";
445 			case 413: return "Request Entity Too Large";
446 			case 414: return "Request-URI Too Long";
447 			case 415: return "Unsupported Media Type";
448 			case 416: return "Requested Range Not Satisfiable";
449 			case 417: return "Expectation Failed";
450 
451 			case 500: return "Internal Server Error";
452 			case 501: return "Not Implemented";
453 			case 502: return "Bad Gateway";
454 			case 503: return "Service Unavailable";
455 			case 504: return "Gateway Timeout";
456 			case 505: return "HTTP Version Not Supported";
457 			default: return null;
458 		}
459 	}
460 
461 	/// Set the response status code and message
462 	void setStatus(HttpStatusCode code)
463 	{
464 		status = code;
465 		statusMessage = getStatusMessage(code);
466 	}
467 
468 	/// Initializes this `HttpResponse` with the given `statusLine`.
469 	final void parseStatusLine(string statusLine)
470 	{
471 		auto versionEnd = statusLine.indexOf(' ');
472 		if (versionEnd == -1)
473 			throw new Exception("Malformed status line");
474 		protocolVersion = statusLine[0..versionEnd];
475 		statusLine = statusLine[versionEnd+1..statusLine.length];
476 
477 		auto statusEnd = statusLine.indexOf(' ');
478 		string statusCode;
479 		if (statusEnd >= 0)
480 		{
481 			statusCode = statusLine[0 .. statusEnd];
482 			statusMessage = statusLine[statusEnd+1..statusLine.length];
483 		}
484 		else
485 		{
486 			statusCode = statusLine;
487 			statusMessage = null;
488 		}
489 		status = cast(HttpStatusCode)to!ushort(statusCode);
490 	}
491 
492 	/// If the data is compressed, return the decompressed data
493 	// this is not a property on purpose - to avoid using it multiple times as it will unpack the data on every access
494 	// TODO: there is no reason for above limitation
495 	Data getContent()
496 	{
497 		if ("Content-Encoding" in headers && headers["Content-Encoding"]=="deflate")
498 			return zlib.uncompress(data).joinData();
499 		else
500 		if ("Content-Encoding" in headers && headers["Content-Encoding"]=="gzip")
501 			return gzip.uncompress(data).joinData();
502 		else
503 			return data.joinData();
504 		assert(0);
505 	}
506 
507 	protected void compressWithDeflate()
508 	{
509 		data = zlib.compress(data, zlib.ZlibOptions(compressionLevel));
510 	}
511 
512 	protected void compressWithGzip()
513 	{
514 		data = gzip.compress(data, zlib.ZlibOptions(compressionLevel));
515 	}
516 
517 	/// Called by the server to compress content, if possible/appropriate
518 	final package void optimizeData(ref const Headers requestHeaders)
519 	{
520 		auto acceptEncoding = requestHeaders.get("Accept-Encoding", null);
521 		if (acceptEncoding && "Content-Encoding" !in headers)
522 		{
523 			auto contentType = headers.get("Content-Type", null);
524 			if (contentType.startsWith("text/")
525 			 || contentType == "application/json"
526 			 || contentType == "image/vnd.microsoft.icon"
527 			 || contentType == "image/svg+xml")
528 			{
529 				auto supported = parseItemList(acceptEncoding) ~ ["*"];
530 				foreach (method; supported)
531 					switch (method)
532 					{
533 						case "deflate":
534 							headers["Content-Encoding"] = method;
535 							headers.add("Vary", "Accept-Encoding");
536 							compressWithDeflate();
537 							return;
538 						case "gzip":
539 							headers["Content-Encoding"] = method;
540 							headers.add("Vary", "Accept-Encoding");
541 							compressWithGzip();
542 							return;
543 						case "*":
544 							if("Content-Encoding" in headers)
545 								headers.remove("Content-Encoding");
546 							return;
547 						default:
548 							break;
549 					}
550 				assert(0);
551 			}
552 		}
553 	}
554 
555 	/// Called by the server to apply range request.
556 	final package void sliceData(ref const Headers requestHeaders)
557 	{
558 		if (status == HttpStatusCode.OK)
559 		{
560 			if ("If-Modified-Since" in requestHeaders &&
561 				"Last-Modified" in headers &&
562 				headers["Last-Modified"].parseTime!(TimeFormats.RFC2822) <= requestHeaders["If-Modified-Since"].parseTime!(TimeFormats.RFC2822))
563 			{
564 				setStatus(HttpStatusCode.NotModified);
565 				data = null;
566 				return;
567 			}
568 
569 			headers["Accept-Ranges"] = "bytes";
570 			auto prange = "Range" in requestHeaders;
571 			if (prange && (*prange).startsWith("bytes="))
572 			{
573 				auto ranges = (*prange)[6..$].split(",")[0].split("-").map!(s => s.length ? s.to!size_t : size_t.max)().array();
574 				enforce(ranges.length == 2, "Bad range request");
575 				ranges[1]++;
576 				auto datum = DataSetBytes(this.data);
577 				if (ranges[1] == size_t.min) // was not specified (size_t.max overflowed into 0)
578 					ranges[1] = datum.length;
579 				if (ranges[0] >= datum.length || ranges[0] >= ranges[1] || ranges[1] > datum.length)
580 				{
581 					//writeError(HttpStatusCode.RequestedRangeNotSatisfiable);
582 					setStatus(HttpStatusCode.RequestedRangeNotSatisfiable);
583 					data = [Data(statusMessage)];
584 					return;
585 				}
586 				else
587 				{
588 					setStatus(HttpStatusCode.PartialContent);
589 					this.data = datum[ranges[0]..ranges[1]];
590 					headers["Content-Range"] = "bytes %d-%d/%d".format(ranges[0], ranges[0] + this.data.bytes.length - 1, datum.length);
591 				}
592 			}
593 		}
594 	}
595 }
596 
597 /// Sets headers to request clients to not cache a response.
598 void disableCache(ref Headers headers)
599 {
600 	headers["Expires"] = "Mon, 26 Jul 1997 05:00:00 GMT";  // disable IE caching
601 	//headers["Last-Modified"] = "" . gmdate( "D, d M Y H:i:s" ) . " GMT";
602 	headers["Cache-Control"] = "no-cache, must-revalidate";
603 	headers["Pragma"] = "no-cache";
604 }
605 
606 /// Sets headers to request clients to cache a response indefinitely.
607 void cacheForever(ref Headers headers)
608 {
609 	headers["Expires"] = httpTime(Clock.currTime().add!"years"(1));
610 	headers["Cache-Control"] = "public, max-age=31536000";
611 }
612 
613 /// Formats a timestamp in the format used by HTTP (RFC 2822).
614 string httpTime(SysTime time)
615 {
616 	// Apache is bad at timezones
617 	time.timezone = UTC();
618 	return time.formatTime!(TimeFormats.RFC2822)();
619 }
620 
621 import std.algorithm : sort;
622 
623 /// Parses a list in the format of "a, b, c;q=0.5, d" and returns
624 /// an array of items sorted by "q" (["a", "b", "d", "c"])
625 string[] parseItemList(string s)
626 {
627 	static struct Item
628 	{
629 		float q = 1.0;
630 		string str;
631 
632 		this(string s)
633 		{
634 			auto params = s.split(";");
635 			if (!params.length) return;
636 			str = params[0];
637 			foreach (param; params[1..$])
638 				if (param.startsWith("q="))
639 					q = to!float(param[2..$]);
640 		}
641 	}
642 
643 	return s
644 		.split(",")
645 		.amap!(a => Item(strip(a)))()
646 		.asort!`a.q > b.q`()
647 		.amap!`a.str`();
648 }
649 
650 unittest
651 {
652 	assert(parseItemList("a, b, c;q=0.5, d") == ["a", "b", "d", "c"]);
653 }
654 
655 // TODO: optimize / move to HtmlWriter
656 string httpEscape(string str)
657 {
658 	string result;
659 	foreach(c;str)
660 		switch(c)
661 		{
662 			case '<':
663 				result ~= "&lt;";
664 				break;
665 			case '>':
666 				result ~= "&gt;";
667 				break;
668 			case '&':
669 				result ~= "&amp;";
670 				break;
671 			case '\xDF':  // the beta-like symbol
672 				result ~= "&szlig;";
673 				break;
674 			default:
675 				result ~= [c];
676 		}
677 	return result;
678 }
679 
680 public import ae.net.ietf.url : UrlParameters, encodeUrlParameter, encodeUrlParameters, decodeUrlParameter, decodeUrlParameters;
681 
682 /// Represents a part from a multipart/* message.
683 struct MultipartPart
684 {
685 	/// The part's individual headers.
686 	Headers headers;
687 
688 	/// The part's contents.
689 	Data data;
690 }
691 
692 /// Encode a multipart body with the given parts and boundary.
693 Data encodeMultipart(MultipartPart[] parts, string boundary)
694 {
695 	Data data;
696 	foreach (ref part; parts)
697 	{
698 		data ~= "--" ~ boundary ~ "\r\n";
699 		foreach (name, value; part.headers)
700 			data ~= name ~ ": " ~ value ~ "\r\n";
701 		data ~= "\r\n";
702 		assert((cast(string)part.data.contents).indexOf(boundary) < 0);
703 		data ~= part.data;
704 		data ~= "\r\n";
705 	}
706 	data ~= "--" ~ boundary ~ "--\r\n";
707 	return data;
708 }
709 
710 /// Decode a multipart body using the given boundary.
711 MultipartPart[] decodeMultipart(Data data, string boundary)
712 {
713 	auto s = cast(char[])data.contents;
714 	auto term = "\r\n--" ~ boundary ~ "--\r\n";
715 	enforce(s.endsWith(term), "Bad multipart terminator");
716 	s = s[0..$-term.length];
717 	auto delim = "--" ~ boundary ~ "\r\n";
718 	enforce(s.skipOver(delim), "Bad multipart start");
719 	delim = "\r\n" ~ delim;
720 	auto parts = s.split(delim);
721 	MultipartPart[] result;
722 	foreach (part; parts)
723 	{
724 		auto segs = part.findSplit("\r\n\r\n");
725 		enforce(segs[1], "Can't find headers in multipart part");
726 		MultipartPart p;
727 		foreach (line; segs[0].split("\r\n"))
728 		{
729 			auto hparts = line.findSplit(":");
730 			p.headers[hparts[0].strip.idup] = hparts[2].strip.idup;
731 		}
732 		p.data = Data(segs[2]);
733 		result ~= p;
734 	}
735 	return result;
736 }
737 
738 unittest
739 {
740 	auto parts = [
741 		MultipartPart(Headers(["Foo" : "bar"]), Data.init),
742 		MultipartPart(Headers(["Baz" : "quux", "Frob" : "xyzzy"]), Data("Content goes here\xFF")),
743 	];
744 	auto boundary = "abcde";
745 	auto parts2 = parts.encodeMultipart(boundary).decodeMultipart(boundary);
746 	assert(parts2.length == parts.length);
747 	foreach (p; 0..parts.length)
748 	{
749 		assert(parts[p].headers == parts2[p].headers);
750 		assert(parts[p].data.contents == parts2[p].data.contents);
751 	}
752 }
753 
754 private bool asciiStartsWith(string s, string prefix)
755 {
756 	if (s.length < prefix.length)
757 		return false;
758 	import std.ascii;
759 	foreach (i, c; prefix)
760 		if (toLower(c) != toLower(s[i]))
761 			return false;
762 	return true;
763 }