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.string;
20 import std.conv;
21 import std.datetime;
22 import std.uri;
23 import std.utf;
24 
25 import ae.net.asockets;
26 import ae.net.ietf.headers;
27 import ae.net.ietf.headerparse;
28 import ae.net.ietf.url;
29 import ae.net.ssl;
30 import ae.utils.array : toArray;
31 import ae.utils.exception : CaughtException;
32 import ae.sys.data;
33 debug(HTTP) import std.stdio : stderr;
34 
35 public import ae.net.http.common;
36 
37 /// Implements a HTTP client.
38 /// Generally used to send one HTTP request.
39 class HttpClient
40 {
41 protected:
42 	Connector connector;  // Bottom-level transport factory.
43 	TimeoutAdapter timer; // Timeout adapter.
44 	IConnection conn;     // Top-level abstract connection. Reused for new connections.
45 
46 	Data[] inBuffer;
47 
48 	HttpRequest currentRequest;
49 
50 	HttpResponse currentResponse;
51 	size_t expect;
52 
53 	void onConnect()
54 	{
55 		sendRequest(currentRequest);
56 	}
57 
58 	void sendRequest(HttpRequest request)
59 	{
60 		if ("User-Agent" !in request.headers && agent)
61 			request.headers["User-Agent"] = agent;
62 		if ("Accept-Encoding" !in request.headers)
63 			request.headers["Accept-Encoding"] = "gzip, deflate, identity;q=0.5, *;q=0";
64 		if (request.data)
65 			request.headers["Content-Length"] = to!string(request.data.bytes.length);
66 		if ("Connection" !in request.headers)
67 			request.headers["Connection"] = keepAlive ? "keep-alive" : "close";
68 
69 		sendRawRequest(request);
70 	}
71 
72 	void sendRawRequest(HttpRequest request)
73 	{
74 		string reqMessage = request.method ~ " ";
75 		if (request.proxy !is null) {
76 			reqMessage ~= "http://" ~ request.host;
77 			if (request.port != 80)
78 				reqMessage ~= format(":%d", request.port);
79 		}
80 		reqMessage ~= request.resource ~ " HTTP/1.0\r\n";
81 
82 		foreach (string header, string value; request.headers)
83 			if (value !is null)
84 				reqMessage ~= header ~ ": " ~ value ~ "\r\n";
85 
86 		reqMessage ~= "\r\n";
87 		debug(HTTP)
88 		{
89 			stderr.writefln("Sending request:");
90 			foreach (line; reqMessage.split("\r\n"))
91 				stderr.writeln("> ", line);
92 			if (request.data)
93 				stderr.writefln("} (%d bytes data follow)", request.data.bytes.length);
94 		}
95 
96 		conn.send(Data(reqMessage));
97 		conn.send(request.data);
98 	}
99 
100 	void onNewResponse(Data data)
101 	{
102 		try
103 		{
104 			inBuffer ~= data;
105 			if (timer)
106 				timer.markNonIdle();
107 
108 			string statusLine;
109 			Headers headers;
110 
111 			debug(HTTP) auto oldData = inBuffer.dup;
112 
113 			if (!parseHeaders(inBuffer, statusLine, headers))
114 				return;
115 
116 			debug(HTTP)
117 			{
118 				stderr.writefln("Got response:");
119 				auto reqMessage = cast(string)oldData.bytes[0..oldData.bytes.length-inBuffer.bytes.length].joinToHeap();
120 				foreach (line; reqMessage.split("\r\n"))
121 					stderr.writeln("< ", line);
122 			}
123 
124 			currentResponse = new HttpResponse;
125 			currentResponse.parseStatusLine(statusLine);
126 			currentResponse.headers = headers;
127 
128 			onHeadersReceived();
129 		}
130 		catch (CaughtException e)
131 		{
132 			if (conn.state == ConnectionState.connected)
133 				conn.disconnect(e.msg.length ? e.msg : e.classinfo.name, DisconnectType.error);
134 			else
135 				throw new Exception("Unhandled exception after connection was closed: " ~ e.msg, e);
136 		}
137 	}
138 
139 	void onHeadersReceived()
140 	{
141 		expect = size_t.max;
142 		if ("Content-Length" in currentResponse.headers)
143 			expect = to!size_t(strip(currentResponse.headers["Content-Length"]));
144 
145 		if (inBuffer.bytes.length < expect)
146 		{
147 			onData(inBuffer);
148 			conn.handleReadData = &onContinuation;
149 		}
150 		else
151 		{
152 			onData(inBuffer.bytes[0 .. expect]); // TODO: pipelining
153 			onDone();
154 		}
155 
156 		inBuffer.destroy();
157 	}
158 
159 	void onData(Data[] data)
160 	{
161 		currentResponse.data ~= data;
162 	}
163 
164 	void onContinuation(Data data)
165 	{
166 		onData(data.toArray);
167 		if (timer)
168 			timer.markNonIdle();
169 
170 		// HACK (if onData override called disconnect).
171 		// TODO: rewrite this entire pile of garbage
172 		if (!currentResponse)
173 			return;
174 
175 		auto received = currentResponse.data.bytes.length;
176 		if (expect!=size_t.max && received >= expect)
177 		{
178 			inBuffer = currentResponse.data.bytes[expect..received];
179 			currentResponse.data = currentResponse.data.bytes[0..expect];
180 			onDone();
181 		}
182 	}
183 
184 	void onDone()
185 	{
186 		if (keepAlive)
187 			processResponse();
188 		else
189 			conn.disconnect("All data read");
190 	}
191 
192 	void processResponse(string reason = "All data read")
193 	{
194 		auto response = currentResponse;
195 
196 		currentRequest = null;
197 		currentResponse = null;
198 		expect = -1;
199 		conn.handleReadData = null;
200 
201 		if (handleResponse)
202 			handleResponse(response, reason);
203 	}
204 
205 	void onDisconnect(string reason, DisconnectType type)
206 	{
207 		if (type == DisconnectType.error)
208 			currentResponse = null;
209 
210 		if (currentRequest)
211 			processResponse(reason);
212 	}
213 
214 	IConnection adaptConnection(IConnection conn)
215 	{
216 		return conn;
217 	}
218 
219 public:
220 	/// User-Agent header to advertise.
221 	string agent = "ae.net.http.client (+https://github.com/CyberShadow/ae)";
222 	/// Keep connection alive after one request.
223 	bool keepAlive = false;
224 
225 	/// Constructor.
226 	this(Duration timeout = 30.seconds, Connector connector = new TcpConnector)
227 	{
228 		assert(timeout >= Duration.zero);
229 
230 		this.connector = connector;
231 		IConnection c = connector.getConnection();
232 
233 		c = adaptConnection(c);
234 
235 		if (timeout > Duration.zero)
236 		{
237 			timer = new TimeoutAdapter(c);
238 			timer.setIdleTimeout(timeout);
239 			c = timer;
240 		}
241 
242 		conn = c;
243 		conn.handleConnect = &onConnect;
244 		conn.handleDisconnect = &onDisconnect;
245 	}
246 
247 	/// Send a HTTP request.
248 	void request(HttpRequest request)
249 	{
250 		//debug writefln("New HTTP request: %s", request.url);
251 		currentRequest = request;
252 		currentResponse = null;
253 		conn.handleReadData = &onNewResponse;
254 		expect = 0;
255 
256 		if (conn.state != ConnectionState.disconnected)
257 		{
258 			assert(conn.state == ConnectionState.connected, "Attempting a HTTP request on a %s connection".format(conn.state));
259 			assert(keepAlive, "Attempting a second HTTP request on a connected non-keepalive connection");
260 			sendRequest(request);
261 		}
262 		else
263 		{
264 			if (request.proxy !is null)
265 				connector.connect(request.proxyHost, request.proxyPort);
266 			else
267 				connector.connect(request.host, request.port);
268 		}
269 	}
270 
271 	/// Returns true if a connection is active
272 	/// (whether due to an in-flight request or due to keep-alive).
273 	bool connected()
274 	{
275 		if (currentRequest !is null)
276 			return true;
277 		if (keepAlive && conn.state == ConnectionState.connected)
278 			return true;
279 		return false;
280 	}
281 
282 	/// Close the connection to the HTTP server.
283 	void disconnect(string reason = IConnection.defaultDisconnectReason)
284 	{
285 		conn.disconnect(reason);
286 	}
287 
288 	/// User-supplied callback for handling the response.
289 	void delegate(HttpResponse response, string disconnectReason) handleResponse;
290 }
291 
292 /// HTTPS client.
293 class HttpsClient : HttpClient
294 {
295 	/// SSL context and adapter to use for TLS.
296 	SSLContext ctx;
297 	SSLAdapter adapter; /// ditto
298 
299 	/// Constructor.
300 	this(Duration timeout = 30.seconds)
301 	{
302 		ctx = ssl.createContext(SSLContext.Kind.client);
303 		super(timeout);
304 	}
305 
306 	protected override IConnection adaptConnection(IConnection conn)
307 	{
308 		adapter = ssl.createAdapter(ctx, conn);
309 		return adapter;
310 	}
311 
312 	protected override void request(HttpRequest request)
313 	{
314 		super.request(request);
315 		if (conn.state == ConnectionState.connecting)
316 			adapter.setHostName(request.host);
317 	}
318 }
319 
320 // Experimental for now
321 class Connector
322 {
323 	abstract IConnection getConnection();
324 	abstract void connect(string host, ushort port);
325 }
326 
327 // ditto
328 class TcpConnector : Connector
329 {
330 	protected TcpConnection conn;
331 
332 	this()
333 	{
334 		conn = new TcpConnection();
335 	}
336 
337 	override IConnection getConnection()
338 	{
339 		return conn;
340 	}
341 
342 	override void connect(string host, ushort port)
343 	{
344 		conn.connect(host, port);
345 	}
346 }
347 
348 // ditto
349 version(Posix)
350 class UnixConnector : TcpConnector
351 {
352 	string path;
353 
354 	this(string path)
355 	{
356 		this.path = path;
357 	}
358 
359 	override void connect(string host, ushort port)
360 	{
361 		import std.socket;
362 		auto addr = new UnixAddress(path);
363 		conn.connect([AddressInfo(AddressFamily.UNIX, SocketType.STREAM, cast(ProtocolType)0, addr, path)]);
364 	}
365 }
366 
367 /// Asynchronous HTTP request
368 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler)
369 {
370 	HttpClient client;
371 	if (request.protocol == "https")
372 		client = new HttpsClient;
373 	else
374 		client = new HttpClient;
375 
376 	client.handleResponse = responseHandler;
377 	client.request(request);
378 }
379 
380 /// ditto
381 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0)
382 {
383 	void responseHandler(HttpResponse response, string disconnectReason)
384 	{
385 		if (!response)
386 			if (errorHandler)
387 				errorHandler(disconnectReason);
388 			else
389 				throw new Exception(disconnectReason);
390 		else
391 		if (response.status >= 300 && response.status < 400 && "Location" in response.headers)
392 		{
393 			if (redirectCount == 15)
394 				throw new Exception("HTTP redirect loop: " ~ request.url);
395 			request.resource = applyRelativeURL(request.url, response.headers["Location"]);
396 			if (response.status == HttpStatusCode.SeeOther)
397 			{
398 				request.method = "GET";
399 				request.data = null;
400 			}
401 			httpRequest(request, resultHandler, errorHandler, redirectCount+1);
402 		}
403 		else
404 			if (errorHandler)
405 				try
406 					resultHandler(response.getContent());
407 				catch (Exception e)
408 					errorHandler(e.msg);
409 			else
410 				resultHandler(response.getContent());
411 	}
412 
413 	httpRequest(request, &responseHandler);
414 }
415 
416 /// ditto
417 void httpGet(string url, void delegate(HttpResponse response, string disconnectReason) responseHandler)
418 {
419 	httpRequest(new HttpRequest(url), responseHandler);
420 }
421 
422 /// ditto
423 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler)
424 {
425 	httpRequest(new HttpRequest(url), resultHandler, errorHandler);
426 }
427 
428 /// ditto
429 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler)
430 {
431 	httpGet(url,
432 		(Data data)
433 		{
434 			auto result = (cast(char[])data.contents).idup;
435 			std.utf.validate(result);
436 			resultHandler(result);
437 		},
438 		errorHandler);
439 }
440 
441 /// ditto
442 void httpPost(string url, Data[] postData, string contentType, void delegate(Data) resultHandler, void delegate(string) errorHandler)
443 {
444 	auto request = new HttpRequest;
445 	request.resource = url;
446 	request.method = "POST";
447 	if (contentType)
448 		request.headers["Content-Type"] = contentType;
449 	request.data = postData;
450 	httpRequest(request, resultHandler, errorHandler);
451 }
452 
453 /// ditto
454 void httpPost(string url, Data[] postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler)
455 {
456 	httpPost(url, postData, contentType,
457 		(Data data)
458 		{
459 			auto result = (cast(char[])data.contents).idup;
460 			std.utf.validate(result);
461 			resultHandler(result);
462 		},
463 		errorHandler);
464 }
465 
466 /// ditto
467 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler)
468 {
469 	return httpPost(url, [Data(encodeUrlParameters(vars))], "application/x-www-form-urlencoded", resultHandler, errorHandler);
470 }
471 
472 // https://issues.dlang.org/show_bug.cgi?id=7016
473 version (unittest)
474 {
475 	static import ae.net.http.server;
476 	static import ae.net.http.responseex;
477 }
478 
479 unittest
480 {
481 	import ae.net.http.common : HttpRequest, HttpResponse;
482 	import ae.net.http.server : HttpServer, HttpServerConnection;
483 	import ae.net.http.responseex : HttpResponseEx;
484 
485 	void test(bool keepAlive)
486 	{
487 		auto s = new HttpServer;
488 		s.handleRequest = (HttpRequest request, HttpServerConnection conn) {
489 			auto response = new HttpResponseEx;
490 			conn.sendResponse(response.serveText("Hello!"));
491 		};
492 		auto port = s.listen(0, "127.0.0.1");
493 
494 		auto c = new HttpClient;
495 		c.keepAlive = keepAlive;
496 		auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port));
497 		int count;
498 		c.handleResponse =
499 			(HttpResponse response, string disconnectReason)
500 			{
501 				assert(response, "HTTP server error");
502 				assert(cast(string)response.getContent.toHeap == "Hello!");
503 				if (++count == 5)
504 				{
505 					s.close();
506 					if (c.connected)
507 						c.disconnect();
508 				}
509 				else
510 					c.request(r);
511 			};
512 		c.request(r);
513 
514 		socketManager.loop();
515 
516 		assert(count == 5);
517 	}
518 
519 	test(false);
520 	test(true);
521 }