1 /**
2  * A simple HTTP client.
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  *   Vincent Povirk <madewokherd@gmail.com>
14  *   Simon Arlott
15  */
16 
17 module ae.net.http.client;
18 
19 import std.algorithm.comparison : among;
20 import std.algorithm.mutation : move, swap;
21 import std.exception : enforce;
22 import std.string;
23 import std.conv;
24 import std.datetime;
25 import std.uri;
26 import std.utf;
27 
28 import ae.net.asockets;
29 import ae.net.ietf.headers;
30 import ae.net.ietf.headerparse;
31 import ae.net.ietf.url;
32 import ae.net.ssl;
33 import ae.sys.dataset : DataVec, bytes, joinToGC;
34 import ae.utils.array : as, asBytes, asSlice, shift;
35 import ae.utils.exception : CaughtException;
36 import ae.sys.data;
37 
38 debug(HTTP_CLIENT) debug = HTTP;
39 debug(HTTP) import std.stdio : stderr;
40 
41 public import ae.net.http.common;
42 
43 /// Implements a HTTP client connection to a single server.
44 class HttpClient
45 {
46 protected:
47 	Connector connector;  // Bottom-level transport factory.
48 	TimeoutAdapter timer; // Timeout adapter.
49 	IConnection conn;     // Top-level abstract connection. Reused for new connections.
50 
51 	HttpRequest[] requestQueue; // Requests that have been enqueued to send after the connection is established.
52 
53 	HttpResponse currentResponse; // Response to the currently-processed request.
54 	ulong sentRequests, receivedResponses; // Used to know when we're still waiting for something.
55 										   // sentRequests is incremented when requestQueue is shifted.
56 
57 	DataVec headerBuffer; // Received but un-parsed headers
58 	size_t expect;    // How much data do we expect to receive in the current request (size_t.max if until disconnect)
59 
60 	/// Connect to a request's destination.
61 	void connect(HttpRequest request)
62 	{
63 		assert(conn.state == ConnectionState.disconnected);
64 
65 		// We must install a data read handler to indicate that we want to receive readable events.
66 		// Though, this isn't going to be actually called.
67 		// TODO: this should probably be fixed in OpenSSLAdapter instead.
68 		conn.handleReadData = (Data _/*data*/) { assert(false); };
69 
70 		if (request.proxy !is null)
71 			connector.connect(request.proxyHost, request.proxyPort);
72 		else
73 			connector.connect(request.host, request.port);
74 		assert(conn.state.among(ConnectionState.connecting, ConnectionState.disconnected));
75 	}
76 
77 	/// Pop off a request from the queue and return it, while incrementing `sentRequests`.
78 	final HttpRequest getNextRequest()
79 	{
80 		assert(requestQueue.length);
81 		sentRequests++;
82 		return requestQueue.shift();
83 	}
84 
85 	/// Called when the underlying connection (TCP, TLS...) is established.
86 	void onConnect()
87 	{
88 		onIdle();
89 	}
90 
91 	/// Called when we're ready to send a request.
92 	void onIdle()
93 	{
94 		assert(isIdle);
95 
96 		if (pipelining)
97 		{
98 			assert(keepAlive, "keepAlive is required for pipelining");
99 			// Pipeline all queued requests
100 			while (requestQueue.length)
101 				sendRequest(getNextRequest());
102 		}
103 		else
104 		{
105 			// One request at a time
106 			if (requestQueue.length)
107 				sendRequest(getNextRequest());
108 		}
109 
110 		expectResponse();
111 	}
112 
113 	/// Returns true when we are connected but not waiting for anything.
114 	/// Requests can always be sent immediately when this is true.
115 	bool isIdle()
116 	{
117 		if (conn.state == ConnectionState.connected && sentRequests == receivedResponses)
118 		{
119 			assert(!currentResponse);
120 			return true;
121 		}
122 		return false;
123 	}
124 
125 	/// Encode and send a request (headers and body) to the connection.
126 	/// Has no other side effects.
127 	void sendRequest(HttpRequest request)
128 	{
129 		string reqMessage = request.method ~ " ";
130 		if (request.proxy !is null) {
131 			reqMessage ~= "http://" ~ request.host;
132 			if (request.port != 80)
133 				reqMessage ~= format(":%d", request.port);
134 		}
135 		reqMessage ~= request.resource ~ " HTTP/1.0\r\n";
136 
137 		foreach (string header, string value; request.headers)
138 			if (value !is null)
139 				reqMessage ~= header ~ ": " ~ value ~ "\r\n";
140 
141 		reqMessage ~= "\r\n";
142 		debug(HTTP)
143 		{
144 			stderr.writefln("Sending request:");
145 			foreach (line; reqMessage.split("\r\n"))
146 				stderr.writeln("> ", line);
147 			if (request.data)
148 				stderr.writefln("} (%d bytes data follow)", request.data.bytes.length);
149 		}
150 
151 		conn.send(Data(reqMessage.asBytes));
152 		conn.send(request.data[]);
153 	}
154 
155 	/// Called to set up the client to be ready to receive a response.
156 	void expectResponse()
157 	{
158 		//assert(conn.handleReadData is null);
159 		if (receivedResponses < sentRequests)
160 		{
161 			conn.handleReadData = &onNewResponse;
162 			expect = 0;
163 		}
164 	}
165 
166 	/// Received data handler used while we are receiving headers.
167 	void onNewResponse(Data data)
168 	{
169 		if (timer)
170 			timer.markNonIdle();
171 
172 		onHeaderData(data.asSlice);
173 	}
174 
175 	/// Called when we've received some data from the response headers.
176 	void onHeaderData(scope Data[] data)
177 	{
178 		try
179 		{
180 			headerBuffer ~= data;
181 
182 			string statusLine;
183 			Headers headers;
184 
185 			debug(HTTP) auto oldData = headerBuffer.dup;
186 
187 			if (!parseHeaders(headerBuffer, statusLine, headers))
188 				return;
189 
190 			debug(HTTP)
191 			{
192 				stderr.writefln("Got response:");
193 				auto reqMessage = oldData.bytes[0..oldData.bytes.length-headerBuffer.bytes.length].joinToGC().as!string;
194 				foreach (line; reqMessage.split("\r\n"))
195 					stderr.writeln("< ", line);
196 			}
197 
198 			currentResponse = new HttpResponse;
199 			currentResponse.parseStatusLine(statusLine);
200 			currentResponse.headers = headers;
201 
202 			onHeadersReceived();
203 		}
204 		catch (CaughtException e)
205 		{
206 			if (conn.state == ConnectionState.connected)
207 				conn.disconnect(e.msg.length ? e.msg : e.classinfo.name, DisconnectType.error);
208 			else
209 				throw new Exception("Unhandled exception after connection was closed: " ~ e.msg, e);
210 		}
211 	}
212 
213 	/// Called when we've read all headers (currentResponse.headers is populated).
214 	void onHeadersReceived()
215 	{
216 		expect = size_t.max;
217 		// TODO: HEAD responses have Content-Length but no data!
218 		// We need to save a copy of the request (or at least the method) for that...
219 		if ("Content-Length" in currentResponse.headers)
220 			expect = currentResponse.headers["Content-Length"].strip().to!size_t();
221 
222 		conn.handleReadData = &onContinuation;
223 
224 		// Any remaining data in headerBuffer is now part of the response body
225 		// (and maybe even the headers of the next pipelined response).
226 		auto rest = move(headerBuffer);
227 		onData(rest[]);
228 	}
229 
230 	/// Received data handler used while we are receiving the response body.
231 	void onContinuation(Data data)
232 	{
233 		if (timer)
234 			timer.markNonIdle();
235 		onData(data.asSlice);
236 	}
237 
238 	/// Called when we've received some data from the response body.
239 	void onData(scope Data[] data)
240 	{
241 		assert(!headerBuffer.length);
242 
243 		currentResponse.data ~= data;
244 
245 		auto received = currentResponse.data.bytes.length;
246 		if (expect != size_t.max && received >= expect)
247 		{
248 			// Any data past expect is part of the next response
249 			auto rest = currentResponse.data.bytes[expect .. received];
250 			currentResponse.data = currentResponse.data.bytes[0 .. expect];
251 			onDone(rest[], null, false);
252 		}
253 	}
254 
255 	/// Called when we've read the entirety of the response.
256 	/// Any left-over data is in `rest`.
257 	/// `disconnectReason` is `null` if there was no disconnect.
258 	void onDone(scope Data[] rest, string disconnectReason, bool error)
259 	{
260 		auto response = finalizeResponse();
261 		if (error)
262 			response = null; // Discard partial response
263 
264 		if (disconnectReason)
265 		{
266 			assert(rest is null);
267 		}
268 		else
269 		{
270 			if (keepAlive)
271 			{
272 				if (isIdle())
273 					onIdle();
274 				else
275 					expectResponse();
276 			}
277 			else
278 			{
279 				enforce(rest.bytes.length == 0, "Left-over data after non-keepalive response");
280 				conn.disconnect("All data read");
281 			}
282 		}
283 
284 		// This is done as the (almost) last step, so that we don't
285 		// have to worry about the user response handler changing our
286 		// state while we are in the middle of a function.
287 		submitResponse(response, disconnectReason);
288 
289 		// We still have to handle any left-over data as the last
290 		// step, because otherwise recursion will cause us to call the
291 		// handleResponse functions in the wrong order.
292 		if (rest.bytes.length)
293 			onHeaderData(rest);
294 	}
295 
296 	/// Wrap up and return the current response,
297 	/// and clean up the client for another request.
298 	HttpResponse finalizeResponse()
299 	{
300 		auto response = currentResponse;
301 		currentResponse = null;
302 		expect = -1;
303 
304 		if (!response || response.status != HttpStatusCode.Continue)
305 			receivedResponses++;
306 
307 		conn.handleReadData = null;
308 
309 		return response;
310 	}
311 
312 	/// Submit a received response.
313 	void submitResponse(HttpResponse response, string reason)
314 	{
315 		if (!reason)
316 			reason = "All data read";
317 		if (handleResponse)
318 			handleResponse(response, reason);
319 	}
320 
321 	/// Disconnect handler
322 	void onDisconnect(string reason, DisconnectType type)
323 	{
324 		// If an error occurred, drain the entire queue, otherwise we
325 		// will retry indefinitely.  Retrying is not our responsibility.
326 		if (type == DisconnectType.error)
327 			while (requestQueue.length)
328 				cast(void) getNextRequest();
329 
330 		// If we were expecting any more responses, we're not getting them.
331 		while (receivedResponses < sentRequests)
332 			onDone(null, reason, type == DisconnectType.error);
333 
334 		// If there are more requests queued (keepAlive == false),
335 		// reconnect and keep going.
336 		if (requestQueue.length)
337 			connect(requestQueue[0]);
338 
339 		// Call the user disconnect handler, if one bas been set.
340 		if (handleDisconnect)
341 			handleDisconnect(reason, type);
342 	}
343 
344 	IConnection adaptConnection(IConnection conn)
345 	{
346 		return conn;
347 	}
348 
349 public:
350 	/// User-Agent header to advertise.
351 	string agent = "ae.net.http.client (+https://github.com/CyberShadow/ae)";
352 	/// Keep connection alive after one request.
353 	bool keepAlive = false;
354 	/// Send requests without waiting for a response. Requires keepAlive.
355 	bool pipelining = false;
356 
357 	/// Constructor.
358 	this(Duration timeout = 30.seconds, Connector connector = new TcpConnector)
359 	{
360 		assert(timeout >= Duration.zero);
361 
362 		this.connector = connector;
363 		IConnection c = connector.getConnection();
364 
365 		c = adaptConnection(c);
366 
367 		if (timeout > Duration.zero)
368 		{
369 			timer = new TimeoutAdapter(c);
370 			timer.setIdleTimeout(timeout);
371 			c = timer;
372 		}
373 
374 		conn = c;
375 		conn.handleConnect = &onConnect;
376 		conn.handleDisconnect = &onDisconnect;
377 	}
378 
379 	/// Fix up a response to set up required headers, etc.
380 	/// Done automatically by `request`, unless called with `normalize == false`.
381 	void normalizeRequest(HttpRequest request)
382 	{
383 		if ("User-Agent" !in request.headers && agent)
384 			request.headers["User-Agent"] = agent;
385 		if ("Accept-Encoding" !in request.headers)
386 		{
387 			static if (haveZlib)
388 				request.headers["Accept-Encoding"] = "gzip, deflate, identity;q=0.5, *;q=0";
389 			else
390 				request.headers["Accept-Encoding"] = "identity;q=0.5, *;q=0";
391 		}
392 		if (request.data)
393 			request.headers["Content-Length"] = to!string(request.data.bytes.length);
394 		if ("Connection" !in request.headers)
395 			request.headers["Connection"] = keepAlive ? "keep-alive" : "close";
396 	}
397 
398 	/// Send a HTTP request.
399 	void request(HttpRequest request, bool normalize = true)
400 	{
401 		if (normalize)
402 			normalizeRequest(request);
403 
404 		requestQueue ~= request;
405 
406 		assert(conn.state <= ConnectionState.connected, "Attempting a HTTP request on a %s connection".format(conn.state));
407 		if (conn.state == ConnectionState.disconnected)
408 		{
409 			connect(request);
410 			return; // onConnect will do the rest
411 		}
412 
413 		// |---------+------------+------------+---------------------------------------------------------------|
414 		// | enqueue | keep-alive | pipelining | outcome                                                       |
415 		// |---------+------------+------------+---------------------------------------------------------------|
416 		// | no      | no         | no         | one request and one connection at a time                      |
417 		// | no      | no         | yes        | error, need keep-alive for pipelining                         |
418 		// | no      | yes        | no         | keep connection alive so that we can send more requests later |
419 		// | no      | yes        | yes        | keep-alive + pipelining                                       |
420 		// | yes     | no         | no         | disconnect and connect again, once per queued request         |
421 		// | yes     | no         | yes        | error, need keep-alive for pipelining                         |
422 		// | yes     | yes        | no         | when one response is processed, send the next queued request  |
423 		// | yes     | yes        | yes        | send all requests at once after connecting                    |
424 		// |---------+------------+------------+---------------------------------------------------------------|
425 
426 		// |------------+------------+-----------------------------------------------------------------|
427 		// | keep-alive | pipelining | wat do in request()                                             |
428 		// |------------+------------+-----------------------------------------------------------------|
429 		// | no         | no         | assert(!connected), connect, enqueue                            |
430 		// | no         | yes        | assert                                                          |
431 		// | yes        | no         | enqueue or send now if connected; enqueue and connect otherwise |
432 		// | yes        | yes        | send now if connected; enqueue and connect otherwise            |
433 		// |------------+------------+-----------------------------------------------------------------|
434 
435 		if (!keepAlive)
436 		{
437 			if (!pipelining)
438 			{}
439 			else
440 				assert(false, "keepAlive is required for pipelining");
441 		}
442 		else
443 		{
444 			if (!pipelining)
445 			{
446 				// Can we send it now?
447 				if (isIdle())
448 					onIdle();
449 			}
450 			else
451 			{
452 				// Can we send it now?
453 				if (conn.state == ConnectionState.connected)
454 				{
455 					bool wasIdle = isIdle();
456 					assert(requestQueue.length == 1);
457 					while (requestQueue.length)
458 						sendRequest(getNextRequest());
459 					if (wasIdle)
460 						expectResponse();
461 				}
462 			}
463 		}
464 	}
465 
466 	/// Returns true if a connection is active
467 	/// (whether due to an in-flight request or due to keep-alive).
468 	bool connected()
469 	{
470 		if (receivedResponses < sentRequests)
471 			return true;
472 		if (keepAlive && conn.state == ConnectionState.connected)
473 			return true;
474 		return false;
475 	}
476 
477 	/// Close the connection to the HTTP server.
478 	void disconnect(string reason = IConnection.defaultDisconnectReason)
479 	{
480 		conn.disconnect(reason);
481 	}
482 
483 	/// User-supplied callback for handling the response.
484 	void delegate(HttpResponse response, string disconnectReason) handleResponse;
485 
486 	/// Optional disconnect callback.
487 	/// Generally using this only makes sense with a persistent
488 	/// connection with keepAlive=true.
489 	void delegate(string disconnectReason, DisconnectType type) handleDisconnect;
490 }
491 
492 /// HTTPS client.
493 class HttpsClient : HttpClient
494 {
495 	/// SSL context and adapter to use for TLS.
496 	SSLContext ctx;
497 	SSLAdapter adapter; /// ditto
498 
499 	/// Constructor.
500 	this(Duration timeout = 30.seconds)
501 	{
502 		ctx = ssl.createContext(SSLContext.Kind.client);
503 		super(timeout);
504 	}
505 
506 	protected override IConnection adaptConnection(IConnection conn)
507 	{
508 		adapter = ssl.createAdapter(ctx, conn);
509 		return adapter;
510 	}
511 
512 	protected override void connect(HttpRequest request)
513 	{
514 		super.connect(request);
515 		if (conn.state != ConnectionState.connecting)
516 		{
517 			assert(conn.state == ConnectionState.disconnected);
518 			return; // synchronous connection error
519 		}
520 		adapter.setHostName(request.host);
521 	}
522 }
523 
524 // Experimental for now
525 class Connector
526 {
527 	abstract IConnection getConnection();
528 	abstract void connect(string host, ushort port);
529 }
530 
531 // ditto
532 class SocketConnector(SocketType) : Connector
533 {
534 	protected SocketType conn;
535 
536 	this()
537 	{
538 		conn = new SocketType();
539 	}
540 
541 	override IConnection getConnection()
542 	{
543 		return conn;
544 	}
545 }
546 
547 // ditto
548 class TcpConnector : SocketConnector!TcpConnection
549 {
550 	override void connect(string host, ushort port)
551 	{
552 		conn.connect(host, port);
553 	}
554 }
555 
556 // ditto
557 version(Posix)
558 class UnixConnector : SocketConnector!SocketConnection
559 {
560 	string path;
561 
562 	this(string path)
563 	{
564 		this.path = path;
565 	}
566 
567 	override void connect(string host, ushort port)
568 	{
569 		import std.socket;
570 		auto addr = new UnixAddress(path);
571 		conn.connect([AddressInfo(AddressFamily.UNIX, SocketType.STREAM, cast(ProtocolType)0, addr, path)]);
572 	}
573 }
574 
575 private void delegate(HttpResponse response, string disconnectReason) toResponseHandler(
576 	void delegate(Data) resultHandler,
577 	void delegate(string) errorHandler,
578 	void delegate(HttpResponse) redirectHandler = null,
579 )
580 {
581 	return (HttpResponse response, string disconnectReason)
582 	{
583 		if (!response)
584 			if (errorHandler)
585 				errorHandler(disconnectReason);
586 			else
587 				throw new Exception(disconnectReason);
588 		else
589 		if (redirectHandler && response.status >= 300 && response.status < 400 && "Location" in response.headers)
590 			redirectHandler(response);
591 		else
592 			if (errorHandler)
593 				try
594 					resultHandler(response.getContent());
595 				catch (Exception e)
596 					errorHandler(e.msg);
597 			else
598 				resultHandler(response.getContent());
599 	};
600 }
601 
602 private void delegate(Data result) toDataResultHandler(void delegate(string result) resultHandler)
603 {
604 	return (Data data)
605 	{
606 		auto result = data.toGC().as!string;
607 		std.utf.validate(result);
608 		resultHandler(result);
609 	};
610 }
611 
612 private void delegate(HttpResponse response, string disconnectReason) toResponseHandler(
613 	void delegate(string) resultHandler,
614 	void delegate(string) errorHandler,
615 	void delegate(HttpResponse) redirectHandler = null,
616 )
617 {
618 	return toResponseHandler(
619 		resultHandler.toDataResultHandler,
620 		errorHandler,
621 		redirectHandler,
622 	);
623 }
624 
625 /// Asynchronous HTTP request
626 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler)
627 {
628 	HttpClient client;
629 	if (request.protocol == "https")
630 		client = new HttpsClient;
631 	else
632 		client = new HttpClient;
633 
634 	client.handleResponse = responseHandler;
635 	client.request(request);
636 }
637 
638 /// ditto
639 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0)
640 {
641 	httpRequest(request, toResponseHandler(
642 		resultHandler,
643 		errorHandler,
644 		(HttpResponse response)
645 		{
646 			if (redirectCount == 15)
647 				throw new Exception("HTTP redirect loop: " ~ request.url);
648 			request.resource = applyRelativeURL(request.url, response.headers["Location"]);
649 			if (response.status == HttpStatusCode.SeeOther)
650 			{
651 				request.method = "GET";
652 				request.data = null;
653 			}
654 			httpRequest(request, resultHandler, errorHandler, redirectCount+1);
655 		}
656 	));
657 }
658 
659 /// ditto
660 void httpRequest(HttpRequest request, void delegate(string) resultHandler, void delegate(string) errorHandler)
661 {
662 	httpRequest(request,
663 		resultHandler.toDataResultHandler,
664 		errorHandler,
665 	);
666 }
667 
668 /// ditto
669 void httpGet(string url, void delegate(HttpResponse response, string disconnectReason) responseHandler)
670 {
671 	httpRequest(new HttpRequest(url), responseHandler);
672 }
673 
674 /// ditto
675 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler)
676 {
677 	httpRequest(new HttpRequest(url), resultHandler, errorHandler);
678 }
679 
680 /// ditto
681 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler)
682 {
683 	httpRequest(new HttpRequest(url), resultHandler, errorHandler);
684 }
685 
686 /// ditto
687 void httpPost(string url, DataVec postData, string contentType, void delegate(HttpResponse response, string disconnectReason) responseHandler)
688 {
689 	auto request = new HttpRequest;
690 	request.resource = url;
691 	request.method = "POST";
692 	if (contentType)
693 		request.headers["Content-Type"] = contentType;
694 	request.data = move(postData);
695 	httpRequest(request, responseHandler);
696 }
697 
698 /// ditto
699 void httpPost(string url, DataVec postData, string contentType, void delegate(Data) resultHandler, void delegate(string) errorHandler)
700 {
701 	httpPost(url, move(postData), contentType, toResponseHandler(resultHandler, errorHandler));
702 }
703 
704 /// ditto
705 void httpPost(string url, DataVec postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler)
706 {
707 	httpPost(url, move(postData), contentType, toResponseHandler(resultHandler, errorHandler));
708 }
709 
710 /// ditto
711 void httpPost(string url, UrlParameters vars, void delegate(HttpResponse response, string disconnectReason) responseHandler)
712 {
713 	return httpPost(url, DataVec(Data(encodeUrlParameters(vars).asBytes)), "application/x-www-form-urlencoded", responseHandler);
714 }
715 
716 /// ditto
717 void httpPost(string url, UrlParameters vars, void delegate(Data) resultHandler, void delegate(string) errorHandler)
718 {
719 	return httpPost(url, DataVec(Data(encodeUrlParameters(vars).asBytes)), "application/x-www-form-urlencoded", resultHandler, errorHandler);
720 }
721 
722 /// ditto
723 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler)
724 {
725 	return httpPost(url, DataVec(Data(encodeUrlParameters(vars).asBytes)), "application/x-www-form-urlencoded", resultHandler, errorHandler);
726 }
727 
728 // https://issues.dlang.org/show_bug.cgi?id=7016
729 version (unittest)
730 {
731 	static import ae.net.http.server;
732 	static import ae.net.http.responseex;
733 }
734 
735 unittest
736 {
737 	import ae.net.http.common : HttpRequest, HttpResponse;
738 	import ae.net.http.server : HttpServer, HttpServerConnection;
739 	import ae.net.http.responseex : HttpResponseEx;
740 
741 	foreach (enqueue; [false, true])
742 	foreach (keepAlive; [false, true])
743 	foreach (pipelining; [false, true])
744 	{
745 		if (pipelining && !keepAlive)
746 			continue;
747 		debug (HTTP) stderr.writefln("===== Testing enqueue=%s keepAlive=%s pipelining=%s", enqueue, keepAlive, pipelining);
748 
749 		auto s = new HttpServer;
750 		s.handleRequest = (HttpRequest _/*request*/, HttpServerConnection conn) {
751 			auto response = new HttpResponseEx;
752 			conn.sendResponse(response.serveText("Hello!"));
753 		};
754 		auto port = s.listen(0, "127.0.0.1");
755 
756 		auto c = new HttpClient;
757 		c.keepAlive = keepAlive;
758 		c.pipelining = pipelining;
759 		auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port));
760 		int count;
761 		c.handleResponse =
762 			(HttpResponse response, string _/*disconnectReason*/)
763 			{
764 				assert(response, "HTTP server error");
765 				assert(response.getContent().toGC() == "Hello!");
766 				if (++count == 5)
767 				{
768 					s.close();
769 					if (keepAlive)
770 						c.disconnect();
771 				}
772 				else
773 					if (!enqueue)
774 						c.request(r);
775 			};
776 		foreach (n; 0 .. enqueue ? 5 : 1)
777 			c.request(r);
778 
779 		socketManager.loop();
780 
781 		assert(count == 5);
782 	}
783 }