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