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