|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 package org.mozilla.gecko.sync.net; |
|
6 |
|
7 import java.io.BufferedReader; |
|
8 import java.io.IOException; |
|
9 import java.io.UnsupportedEncodingException; |
|
10 import java.lang.ref.WeakReference; |
|
11 import java.net.URI; |
|
12 import java.net.URISyntaxException; |
|
13 import java.security.GeneralSecurityException; |
|
14 import java.security.KeyManagementException; |
|
15 import java.security.NoSuchAlgorithmException; |
|
16 import java.security.SecureRandom; |
|
17 |
|
18 import javax.net.ssl.SSLContext; |
|
19 |
|
20 import org.json.simple.JSONArray; |
|
21 import org.json.simple.JSONObject; |
|
22 import org.mozilla.gecko.background.common.log.Logger; |
|
23 import org.mozilla.gecko.sync.ExtendedJSONObject; |
|
24 |
|
25 import ch.boye.httpclientandroidlib.Header; |
|
26 import ch.boye.httpclientandroidlib.HttpEntity; |
|
27 import ch.boye.httpclientandroidlib.HttpResponse; |
|
28 import ch.boye.httpclientandroidlib.HttpVersion; |
|
29 import ch.boye.httpclientandroidlib.client.AuthCache; |
|
30 import ch.boye.httpclientandroidlib.client.ClientProtocolException; |
|
31 import ch.boye.httpclientandroidlib.client.methods.HttpDelete; |
|
32 import ch.boye.httpclientandroidlib.client.methods.HttpGet; |
|
33 import ch.boye.httpclientandroidlib.client.methods.HttpPost; |
|
34 import ch.boye.httpclientandroidlib.client.methods.HttpPut; |
|
35 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; |
|
36 import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; |
|
37 import ch.boye.httpclientandroidlib.client.protocol.ClientContext; |
|
38 import ch.boye.httpclientandroidlib.conn.ClientConnectionManager; |
|
39 import ch.boye.httpclientandroidlib.conn.scheme.PlainSocketFactory; |
|
40 import ch.boye.httpclientandroidlib.conn.scheme.Scheme; |
|
41 import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry; |
|
42 import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory; |
|
43 import ch.boye.httpclientandroidlib.entity.StringEntity; |
|
44 import ch.boye.httpclientandroidlib.impl.client.BasicAuthCache; |
|
45 import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; |
|
46 import ch.boye.httpclientandroidlib.impl.conn.tsccm.ThreadSafeClientConnManager; |
|
47 import ch.boye.httpclientandroidlib.params.HttpConnectionParams; |
|
48 import ch.boye.httpclientandroidlib.params.HttpParams; |
|
49 import ch.boye.httpclientandroidlib.params.HttpProtocolParams; |
|
50 import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; |
|
51 import ch.boye.httpclientandroidlib.protocol.HttpContext; |
|
52 import ch.boye.httpclientandroidlib.util.EntityUtils; |
|
53 |
|
54 /** |
|
55 * Provide simple HTTP access to a Sync server or similar. |
|
56 * Implements Basic Auth by asking its delegate for credentials. |
|
57 * Communicates with a ResourceDelegate to asynchronously return responses and errors. |
|
58 * Exposes simple get/post/put/delete methods. |
|
59 */ |
|
60 public class BaseResource implements Resource { |
|
61 private static final String ANDROID_LOOPBACK_IP = "10.0.2.2"; |
|
62 |
|
63 private static final int MAX_TOTAL_CONNECTIONS = 20; |
|
64 private static final int MAX_CONNECTIONS_PER_ROUTE = 10; |
|
65 |
|
66 private boolean retryOnFailedRequest = true; |
|
67 |
|
68 public static boolean rewriteLocalhost = true; |
|
69 |
|
70 private static final String LOG_TAG = "BaseResource"; |
|
71 |
|
72 protected final URI uri; |
|
73 protected BasicHttpContext context; |
|
74 protected DefaultHttpClient client; |
|
75 public ResourceDelegate delegate; |
|
76 protected HttpRequestBase request; |
|
77 public String charset = "utf-8"; |
|
78 |
|
79 protected static WeakReference<HttpResponseObserver> httpResponseObserver = null; |
|
80 |
|
81 public BaseResource(String uri) throws URISyntaxException { |
|
82 this(uri, rewriteLocalhost); |
|
83 } |
|
84 |
|
85 public BaseResource(URI uri) { |
|
86 this(uri, rewriteLocalhost); |
|
87 } |
|
88 |
|
89 public BaseResource(String uri, boolean rewrite) throws URISyntaxException { |
|
90 this(new URI(uri), rewrite); |
|
91 } |
|
92 |
|
93 public BaseResource(URI uri, boolean rewrite) { |
|
94 if (uri == null) { |
|
95 throw new IllegalArgumentException("uri must not be null"); |
|
96 } |
|
97 if (rewrite && "localhost".equals(uri.getHost())) { |
|
98 // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface. |
|
99 Logger.debug(LOG_TAG, "Rewriting " + uri + " to point to " + ANDROID_LOOPBACK_IP + "."); |
|
100 try { |
|
101 this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment()); |
|
102 } catch (URISyntaxException e) { |
|
103 Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e); |
|
104 throw new IllegalArgumentException("Invalid URI", e); |
|
105 } |
|
106 } else { |
|
107 this.uri = uri; |
|
108 } |
|
109 } |
|
110 |
|
111 public static synchronized HttpResponseObserver getHttpResponseObserver() { |
|
112 if (httpResponseObserver == null) { |
|
113 return null; |
|
114 } |
|
115 return httpResponseObserver.get(); |
|
116 } |
|
117 |
|
118 public static synchronized void setHttpResponseObserver(HttpResponseObserver newHttpResponseObserver) { |
|
119 if (httpResponseObserver != null) { |
|
120 httpResponseObserver.clear(); |
|
121 } |
|
122 httpResponseObserver = new WeakReference<HttpResponseObserver>(newHttpResponseObserver); |
|
123 } |
|
124 |
|
125 @Override |
|
126 public URI getURI() { |
|
127 return this.uri; |
|
128 } |
|
129 |
|
130 @Override |
|
131 public String getURIString() { |
|
132 return this.uri.toString(); |
|
133 } |
|
134 |
|
135 @Override |
|
136 public String getHostname() { |
|
137 return this.getURI().getHost(); |
|
138 } |
|
139 |
|
140 /** |
|
141 * This shuts up HttpClient, which will otherwise debug log about there |
|
142 * being no auth cache in the context. |
|
143 */ |
|
144 private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) { |
|
145 AuthCache authCache = new BasicAuthCache(); // Not thread safe. |
|
146 context.setAttribute(ClientContext.AUTH_CACHE, authCache); |
|
147 } |
|
148 |
|
149 /** |
|
150 * Invoke this after delegate and request have been set. |
|
151 * @throws NoSuchAlgorithmException |
|
152 * @throws KeyManagementException |
|
153 */ |
|
154 protected void prepareClient() throws KeyManagementException, NoSuchAlgorithmException, GeneralSecurityException { |
|
155 context = new BasicHttpContext(); |
|
156 |
|
157 // We could reuse these client instances, except that we mess around |
|
158 // with their parameters… so we'd need a pool of some kind. |
|
159 client = new DefaultHttpClient(getConnectionManager()); |
|
160 |
|
161 // TODO: Eventually we should use Apache HttpAsyncClient. It's not out of alpha yet. |
|
162 // Until then, we synchronously make the request, then invoke our delegate's callback. |
|
163 AuthHeaderProvider authHeaderProvider = delegate.getAuthHeaderProvider(); |
|
164 if (authHeaderProvider != null) { |
|
165 Header authHeader = authHeaderProvider.getAuthHeader(request, context, client); |
|
166 if (authHeader != null) { |
|
167 request.addHeader(authHeader); |
|
168 Logger.debug(LOG_TAG, "Added auth header."); |
|
169 } |
|
170 } |
|
171 |
|
172 addAuthCacheToContext(request, context); |
|
173 |
|
174 HttpParams params = client.getParams(); |
|
175 HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout()); |
|
176 HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout()); |
|
177 HttpConnectionParams.setStaleCheckingEnabled(params, false); |
|
178 HttpProtocolParams.setContentCharset(params, charset); |
|
179 HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1); |
|
180 final String userAgent = delegate.getUserAgent(); |
|
181 if (userAgent != null) { |
|
182 HttpProtocolParams.setUserAgent(params, userAgent); |
|
183 } |
|
184 delegate.addHeaders(request, client); |
|
185 } |
|
186 |
|
187 private static Object connManagerMonitor = new Object(); |
|
188 private static ClientConnectionManager connManager; |
|
189 |
|
190 // Call within a synchronized block on connManagerMonitor. |
|
191 private static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException { |
|
192 SSLContext sslContext = SSLContext.getInstance("TLS"); |
|
193 sslContext.init(null, null, new SecureRandom()); |
|
194 SSLSocketFactory sf = new TLSSocketFactory(sslContext); |
|
195 SchemeRegistry schemeRegistry = new SchemeRegistry(); |
|
196 schemeRegistry.register(new Scheme("https", 443, sf)); |
|
197 schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory())); |
|
198 ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry); |
|
199 |
|
200 cm.setMaxTotal(MAX_TOTAL_CONNECTIONS); |
|
201 cm.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE); |
|
202 connManager = cm; |
|
203 return cm; |
|
204 } |
|
205 |
|
206 public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException |
|
207 { |
|
208 // TODO: shutdown. |
|
209 synchronized (connManagerMonitor) { |
|
210 if (connManager != null) { |
|
211 return connManager; |
|
212 } |
|
213 return enableTLSConnectionManager(); |
|
214 } |
|
215 } |
|
216 |
|
217 /** |
|
218 * Do some cleanup, so we don't need the stale connection check. |
|
219 */ |
|
220 public static void closeExpiredConnections() { |
|
221 ClientConnectionManager connectionManager; |
|
222 synchronized (connManagerMonitor) { |
|
223 connectionManager = connManager; |
|
224 } |
|
225 if (connectionManager == null) { |
|
226 return; |
|
227 } |
|
228 Logger.trace(LOG_TAG, "Closing expired connections."); |
|
229 connectionManager.closeExpiredConnections(); |
|
230 } |
|
231 |
|
232 public static void shutdownConnectionManager() { |
|
233 ClientConnectionManager connectionManager; |
|
234 synchronized (connManagerMonitor) { |
|
235 connectionManager = connManager; |
|
236 connManager = null; |
|
237 } |
|
238 if (connectionManager == null) { |
|
239 return; |
|
240 } |
|
241 Logger.debug(LOG_TAG, "Shutting down connection manager."); |
|
242 connectionManager.shutdown(); |
|
243 } |
|
244 |
|
245 private void execute() { |
|
246 HttpResponse response; |
|
247 try { |
|
248 response = client.execute(request, context); |
|
249 Logger.debug(LOG_TAG, "Response: " + response.getStatusLine().toString()); |
|
250 } catch (ClientProtocolException e) { |
|
251 delegate.handleHttpProtocolException(e); |
|
252 return; |
|
253 } catch (IOException e) { |
|
254 Logger.debug(LOG_TAG, "I/O exception returned from execute."); |
|
255 if (!retryOnFailedRequest) { |
|
256 delegate.handleHttpIOException(e); |
|
257 } else { |
|
258 retryRequest(); |
|
259 } |
|
260 return; |
|
261 } catch (Exception e) { |
|
262 // Bug 740731: Don't let an exception fall through. Wrapping isn't |
|
263 // optimal, but often the exception is treated as an Exception anyway. |
|
264 if (!retryOnFailedRequest) { |
|
265 // Bug 769671: IOException(Throwable cause) was added only in API level 9. |
|
266 final IOException ex = new IOException(); |
|
267 ex.initCause(e); |
|
268 delegate.handleHttpIOException(ex); |
|
269 } else { |
|
270 retryRequest(); |
|
271 } |
|
272 return; |
|
273 } |
|
274 |
|
275 // Don't retry if the observer or delegate throws! |
|
276 HttpResponseObserver observer = getHttpResponseObserver(); |
|
277 if (observer != null) { |
|
278 observer.observeHttpResponse(response); |
|
279 } |
|
280 delegate.handleHttpResponse(response); |
|
281 } |
|
282 |
|
283 private void retryRequest() { |
|
284 // Only retry once. |
|
285 retryOnFailedRequest = false; |
|
286 Logger.debug(LOG_TAG, "Retrying request..."); |
|
287 this.execute(); |
|
288 } |
|
289 |
|
290 private void go(HttpRequestBase request) { |
|
291 if (delegate == null) { |
|
292 throw new IllegalArgumentException("No delegate provided."); |
|
293 } |
|
294 this.request = request; |
|
295 try { |
|
296 this.prepareClient(); |
|
297 } catch (KeyManagementException e) { |
|
298 Logger.error(LOG_TAG, "Couldn't prepare client.", e); |
|
299 delegate.handleTransportException(e); |
|
300 return; |
|
301 } catch (NoSuchAlgorithmException e) { |
|
302 Logger.error(LOG_TAG, "Couldn't prepare client.", e); |
|
303 delegate.handleTransportException(e); |
|
304 return; |
|
305 } catch (GeneralSecurityException e) { |
|
306 Logger.error(LOG_TAG, "Couldn't prepare client.", e); |
|
307 delegate.handleTransportException(e); |
|
308 return; |
|
309 } catch (Exception e) { |
|
310 // Bug 740731: Don't let an exception fall through. Wrapping isn't |
|
311 // optimal, but often the exception is treated as an Exception anyway. |
|
312 delegate.handleTransportException(new GeneralSecurityException(e)); |
|
313 return; |
|
314 } |
|
315 this.execute(); |
|
316 } |
|
317 |
|
318 @Override |
|
319 public void get() { |
|
320 Logger.debug(LOG_TAG, "HTTP GET " + this.uri.toASCIIString()); |
|
321 this.go(new HttpGet(this.uri)); |
|
322 } |
|
323 |
|
324 /** |
|
325 * Perform an HTTP GET as with {@link BaseResource#get()}, returning only |
|
326 * after callbacks have been invoked. |
|
327 */ |
|
328 public void getBlocking() { |
|
329 // Until we use the asynchronous Apache HttpClient, we can simply call |
|
330 // through. |
|
331 this.get(); |
|
332 } |
|
333 |
|
334 @Override |
|
335 public void delete() { |
|
336 Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString()); |
|
337 this.go(new HttpDelete(this.uri)); |
|
338 } |
|
339 |
|
340 @Override |
|
341 public void post(HttpEntity body) { |
|
342 Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString()); |
|
343 HttpPost request = new HttpPost(this.uri); |
|
344 request.setEntity(body); |
|
345 this.go(request); |
|
346 } |
|
347 |
|
348 @Override |
|
349 public void put(HttpEntity body) { |
|
350 Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString()); |
|
351 HttpPut request = new HttpPut(this.uri); |
|
352 request.setEntity(body); |
|
353 this.go(request); |
|
354 } |
|
355 |
|
356 protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) throws UnsupportedEncodingException { |
|
357 StringEntity e = new StringEntity(s, "UTF-8"); |
|
358 e.setContentType("application/json"); |
|
359 return e; |
|
360 } |
|
361 |
|
362 /** |
|
363 * Helper for turning a JSON object into a payload. |
|
364 * @throws UnsupportedEncodingException |
|
365 */ |
|
366 protected static StringEntity jsonEntity(JSONObject body) throws UnsupportedEncodingException { |
|
367 return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); |
|
368 } |
|
369 |
|
370 /** |
|
371 * Helper for turning an extended JSON object into a payload. |
|
372 * @throws UnsupportedEncodingException |
|
373 */ |
|
374 protected static StringEntity jsonEntity(ExtendedJSONObject body) throws UnsupportedEncodingException { |
|
375 return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); |
|
376 } |
|
377 |
|
378 /** |
|
379 * Helper for turning a JSON array into a payload. |
|
380 * @throws UnsupportedEncodingException |
|
381 */ |
|
382 protected static HttpEntity jsonEntity(JSONArray toPOST) throws UnsupportedEncodingException { |
|
383 return stringEntityWithContentTypeApplicationJSON(toPOST.toJSONString()); |
|
384 } |
|
385 |
|
386 /** |
|
387 * Best-effort attempt to ensure that the entity has been fully consumed and |
|
388 * that the underlying stream has been closed. |
|
389 * |
|
390 * This releases the connection back to the connection pool. |
|
391 * |
|
392 * @param entity The HttpEntity to be consumed. |
|
393 */ |
|
394 public static void consumeEntity(HttpEntity entity) { |
|
395 try { |
|
396 EntityUtils.consume(entity); |
|
397 } catch (IOException e) { |
|
398 // Doesn't matter. |
|
399 } |
|
400 } |
|
401 |
|
402 /** |
|
403 * Best-effort attempt to ensure that the entity corresponding to the given |
|
404 * HTTP response has been fully consumed and that the underlying stream has |
|
405 * been closed. |
|
406 * |
|
407 * This releases the connection back to the connection pool. |
|
408 * |
|
409 * @param response |
|
410 * The HttpResponse to be consumed. |
|
411 */ |
|
412 public static void consumeEntity(HttpResponse response) { |
|
413 if (response == null) { |
|
414 return; |
|
415 } |
|
416 try { |
|
417 EntityUtils.consume(response.getEntity()); |
|
418 } catch (IOException e) { |
|
419 } |
|
420 } |
|
421 |
|
422 /** |
|
423 * Best-effort attempt to ensure that the entity corresponding to the given |
|
424 * Sync storage response has been fully consumed and that the underlying |
|
425 * stream has been closed. |
|
426 * |
|
427 * This releases the connection back to the connection pool. |
|
428 * |
|
429 * @param response |
|
430 * The SyncStorageResponse to be consumed. |
|
431 */ |
|
432 public static void consumeEntity(SyncStorageResponse response) { |
|
433 if (response.httpResponse() == null) { |
|
434 return; |
|
435 } |
|
436 consumeEntity(response.httpResponse()); |
|
437 } |
|
438 |
|
439 /** |
|
440 * Best-effort attempt to ensure that the reader has been fully consumed, so |
|
441 * that the underlying stream will be closed. |
|
442 * |
|
443 * This should allow the connection to be released back to the connection pool. |
|
444 * |
|
445 * @param reader The BufferedReader to be consumed. |
|
446 */ |
|
447 public static void consumeReader(BufferedReader reader) { |
|
448 try { |
|
449 reader.close(); |
|
450 } catch (IOException e) { |
|
451 // Do nothing. |
|
452 } |
|
453 } |
|
454 |
|
455 public void post(JSONArray jsonArray) throws UnsupportedEncodingException { |
|
456 post(jsonEntity(jsonArray)); |
|
457 } |
|
458 |
|
459 public void put(JSONObject jsonObject) throws UnsupportedEncodingException { |
|
460 put(jsonEntity(jsonObject)); |
|
461 } |
|
462 |
|
463 public void post(ExtendedJSONObject o) throws UnsupportedEncodingException { |
|
464 post(jsonEntity(o)); |
|
465 } |
|
466 |
|
467 public void post(JSONObject jsonObject) throws UnsupportedEncodingException { |
|
468 post(jsonEntity(jsonObject)); |
|
469 } |
|
470 } |