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