michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.sync.net; michael@0: michael@0: import java.io.BufferedReader; michael@0: import java.io.IOException; michael@0: import java.io.UnsupportedEncodingException; michael@0: import java.lang.ref.WeakReference; michael@0: import java.net.URI; michael@0: import java.net.URISyntaxException; michael@0: import java.security.GeneralSecurityException; michael@0: import java.security.KeyManagementException; michael@0: import java.security.NoSuchAlgorithmException; michael@0: import java.security.SecureRandom; michael@0: michael@0: import javax.net.ssl.SSLContext; michael@0: michael@0: import org.json.simple.JSONArray; michael@0: import org.json.simple.JSONObject; michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.sync.ExtendedJSONObject; michael@0: michael@0: import ch.boye.httpclientandroidlib.Header; michael@0: import ch.boye.httpclientandroidlib.HttpEntity; michael@0: import ch.boye.httpclientandroidlib.HttpResponse; michael@0: import ch.boye.httpclientandroidlib.HttpVersion; michael@0: import ch.boye.httpclientandroidlib.client.AuthCache; michael@0: import ch.boye.httpclientandroidlib.client.ClientProtocolException; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpDelete; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpGet; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpPost; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpPut; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; michael@0: import ch.boye.httpclientandroidlib.client.protocol.ClientContext; michael@0: import ch.boye.httpclientandroidlib.conn.ClientConnectionManager; michael@0: import ch.boye.httpclientandroidlib.conn.scheme.PlainSocketFactory; michael@0: import ch.boye.httpclientandroidlib.conn.scheme.Scheme; michael@0: import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry; michael@0: import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory; michael@0: import ch.boye.httpclientandroidlib.entity.StringEntity; michael@0: import ch.boye.httpclientandroidlib.impl.client.BasicAuthCache; michael@0: import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; michael@0: import ch.boye.httpclientandroidlib.impl.conn.tsccm.ThreadSafeClientConnManager; michael@0: import ch.boye.httpclientandroidlib.params.HttpConnectionParams; michael@0: import ch.boye.httpclientandroidlib.params.HttpParams; michael@0: import ch.boye.httpclientandroidlib.params.HttpProtocolParams; michael@0: import ch.boye.httpclientandroidlib.protocol.BasicHttpContext; michael@0: import ch.boye.httpclientandroidlib.protocol.HttpContext; michael@0: import ch.boye.httpclientandroidlib.util.EntityUtils; michael@0: michael@0: /** michael@0: * Provide simple HTTP access to a Sync server or similar. michael@0: * Implements Basic Auth by asking its delegate for credentials. michael@0: * Communicates with a ResourceDelegate to asynchronously return responses and errors. michael@0: * Exposes simple get/post/put/delete methods. michael@0: */ michael@0: public class BaseResource implements Resource { michael@0: private static final String ANDROID_LOOPBACK_IP = "10.0.2.2"; michael@0: michael@0: private static final int MAX_TOTAL_CONNECTIONS = 20; michael@0: private static final int MAX_CONNECTIONS_PER_ROUTE = 10; michael@0: michael@0: private boolean retryOnFailedRequest = true; michael@0: michael@0: public static boolean rewriteLocalhost = true; michael@0: michael@0: private static final String LOG_TAG = "BaseResource"; michael@0: michael@0: protected final URI uri; michael@0: protected BasicHttpContext context; michael@0: protected DefaultHttpClient client; michael@0: public ResourceDelegate delegate; michael@0: protected HttpRequestBase request; michael@0: public String charset = "utf-8"; michael@0: michael@0: protected static WeakReference httpResponseObserver = null; michael@0: michael@0: public BaseResource(String uri) throws URISyntaxException { michael@0: this(uri, rewriteLocalhost); michael@0: } michael@0: michael@0: public BaseResource(URI uri) { michael@0: this(uri, rewriteLocalhost); michael@0: } michael@0: michael@0: public BaseResource(String uri, boolean rewrite) throws URISyntaxException { michael@0: this(new URI(uri), rewrite); michael@0: } michael@0: michael@0: public BaseResource(URI uri, boolean rewrite) { michael@0: if (uri == null) { michael@0: throw new IllegalArgumentException("uri must not be null"); michael@0: } michael@0: if (rewrite && "localhost".equals(uri.getHost())) { michael@0: // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface. michael@0: Logger.debug(LOG_TAG, "Rewriting " + uri + " to point to " + ANDROID_LOOPBACK_IP + "."); michael@0: try { michael@0: this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment()); michael@0: } catch (URISyntaxException e) { michael@0: Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e); michael@0: throw new IllegalArgumentException("Invalid URI", e); michael@0: } michael@0: } else { michael@0: this.uri = uri; michael@0: } michael@0: } michael@0: michael@0: public static synchronized HttpResponseObserver getHttpResponseObserver() { michael@0: if (httpResponseObserver == null) { michael@0: return null; michael@0: } michael@0: return httpResponseObserver.get(); michael@0: } michael@0: michael@0: public static synchronized void setHttpResponseObserver(HttpResponseObserver newHttpResponseObserver) { michael@0: if (httpResponseObserver != null) { michael@0: httpResponseObserver.clear(); michael@0: } michael@0: httpResponseObserver = new WeakReference(newHttpResponseObserver); michael@0: } michael@0: michael@0: @Override michael@0: public URI getURI() { michael@0: return this.uri; michael@0: } michael@0: michael@0: @Override michael@0: public String getURIString() { michael@0: return this.uri.toString(); michael@0: } michael@0: michael@0: @Override michael@0: public String getHostname() { michael@0: return this.getURI().getHost(); michael@0: } michael@0: michael@0: /** michael@0: * This shuts up HttpClient, which will otherwise debug log about there michael@0: * being no auth cache in the context. michael@0: */ michael@0: private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) { michael@0: AuthCache authCache = new BasicAuthCache(); // Not thread safe. michael@0: context.setAttribute(ClientContext.AUTH_CACHE, authCache); michael@0: } michael@0: michael@0: /** michael@0: * Invoke this after delegate and request have been set. michael@0: * @throws NoSuchAlgorithmException michael@0: * @throws KeyManagementException michael@0: */ michael@0: protected void prepareClient() throws KeyManagementException, NoSuchAlgorithmException, GeneralSecurityException { michael@0: context = new BasicHttpContext(); michael@0: michael@0: // We could reuse these client instances, except that we mess around michael@0: // with their parameters… so we'd need a pool of some kind. michael@0: client = new DefaultHttpClient(getConnectionManager()); michael@0: michael@0: // TODO: Eventually we should use Apache HttpAsyncClient. It's not out of alpha yet. michael@0: // Until then, we synchronously make the request, then invoke our delegate's callback. michael@0: AuthHeaderProvider authHeaderProvider = delegate.getAuthHeaderProvider(); michael@0: if (authHeaderProvider != null) { michael@0: Header authHeader = authHeaderProvider.getAuthHeader(request, context, client); michael@0: if (authHeader != null) { michael@0: request.addHeader(authHeader); michael@0: Logger.debug(LOG_TAG, "Added auth header."); michael@0: } michael@0: } michael@0: michael@0: addAuthCacheToContext(request, context); michael@0: michael@0: HttpParams params = client.getParams(); michael@0: HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout()); michael@0: HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout()); michael@0: HttpConnectionParams.setStaleCheckingEnabled(params, false); michael@0: HttpProtocolParams.setContentCharset(params, charset); michael@0: HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1); michael@0: final String userAgent = delegate.getUserAgent(); michael@0: if (userAgent != null) { michael@0: HttpProtocolParams.setUserAgent(params, userAgent); michael@0: } michael@0: delegate.addHeaders(request, client); michael@0: } michael@0: michael@0: private static Object connManagerMonitor = new Object(); michael@0: private static ClientConnectionManager connManager; michael@0: michael@0: // Call within a synchronized block on connManagerMonitor. michael@0: private static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException { michael@0: SSLContext sslContext = SSLContext.getInstance("TLS"); michael@0: sslContext.init(null, null, new SecureRandom()); michael@0: SSLSocketFactory sf = new TLSSocketFactory(sslContext); michael@0: SchemeRegistry schemeRegistry = new SchemeRegistry(); michael@0: schemeRegistry.register(new Scheme("https", 443, sf)); michael@0: schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory())); michael@0: ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry); michael@0: michael@0: cm.setMaxTotal(MAX_TOTAL_CONNECTIONS); michael@0: cm.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE); michael@0: connManager = cm; michael@0: return cm; michael@0: } michael@0: michael@0: public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException michael@0: { michael@0: // TODO: shutdown. michael@0: synchronized (connManagerMonitor) { michael@0: if (connManager != null) { michael@0: return connManager; michael@0: } michael@0: return enableTLSConnectionManager(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Do some cleanup, so we don't need the stale connection check. michael@0: */ michael@0: public static void closeExpiredConnections() { michael@0: ClientConnectionManager connectionManager; michael@0: synchronized (connManagerMonitor) { michael@0: connectionManager = connManager; michael@0: } michael@0: if (connectionManager == null) { michael@0: return; michael@0: } michael@0: Logger.trace(LOG_TAG, "Closing expired connections."); michael@0: connectionManager.closeExpiredConnections(); michael@0: } michael@0: michael@0: public static void shutdownConnectionManager() { michael@0: ClientConnectionManager connectionManager; michael@0: synchronized (connManagerMonitor) { michael@0: connectionManager = connManager; michael@0: connManager = null; michael@0: } michael@0: if (connectionManager == null) { michael@0: return; michael@0: } michael@0: Logger.debug(LOG_TAG, "Shutting down connection manager."); michael@0: connectionManager.shutdown(); michael@0: } michael@0: michael@0: private void execute() { michael@0: HttpResponse response; michael@0: try { michael@0: response = client.execute(request, context); michael@0: Logger.debug(LOG_TAG, "Response: " + response.getStatusLine().toString()); michael@0: } catch (ClientProtocolException e) { michael@0: delegate.handleHttpProtocolException(e); michael@0: return; michael@0: } catch (IOException e) { michael@0: Logger.debug(LOG_TAG, "I/O exception returned from execute."); michael@0: if (!retryOnFailedRequest) { michael@0: delegate.handleHttpIOException(e); michael@0: } else { michael@0: retryRequest(); michael@0: } michael@0: return; michael@0: } catch (Exception e) { michael@0: // Bug 740731: Don't let an exception fall through. Wrapping isn't michael@0: // optimal, but often the exception is treated as an Exception anyway. michael@0: if (!retryOnFailedRequest) { michael@0: // Bug 769671: IOException(Throwable cause) was added only in API level 9. michael@0: final IOException ex = new IOException(); michael@0: ex.initCause(e); michael@0: delegate.handleHttpIOException(ex); michael@0: } else { michael@0: retryRequest(); michael@0: } michael@0: return; michael@0: } michael@0: michael@0: // Don't retry if the observer or delegate throws! michael@0: HttpResponseObserver observer = getHttpResponseObserver(); michael@0: if (observer != null) { michael@0: observer.observeHttpResponse(response); michael@0: } michael@0: delegate.handleHttpResponse(response); michael@0: } michael@0: michael@0: private void retryRequest() { michael@0: // Only retry once. michael@0: retryOnFailedRequest = false; michael@0: Logger.debug(LOG_TAG, "Retrying request..."); michael@0: this.execute(); michael@0: } michael@0: michael@0: private void go(HttpRequestBase request) { michael@0: if (delegate == null) { michael@0: throw new IllegalArgumentException("No delegate provided."); michael@0: } michael@0: this.request = request; michael@0: try { michael@0: this.prepareClient(); michael@0: } catch (KeyManagementException e) { michael@0: Logger.error(LOG_TAG, "Couldn't prepare client.", e); michael@0: delegate.handleTransportException(e); michael@0: return; michael@0: } catch (NoSuchAlgorithmException e) { michael@0: Logger.error(LOG_TAG, "Couldn't prepare client.", e); michael@0: delegate.handleTransportException(e); michael@0: return; michael@0: } catch (GeneralSecurityException e) { michael@0: Logger.error(LOG_TAG, "Couldn't prepare client.", e); michael@0: delegate.handleTransportException(e); michael@0: return; michael@0: } catch (Exception e) { michael@0: // Bug 740731: Don't let an exception fall through. Wrapping isn't michael@0: // optimal, but often the exception is treated as an Exception anyway. michael@0: delegate.handleTransportException(new GeneralSecurityException(e)); michael@0: return; michael@0: } michael@0: this.execute(); michael@0: } michael@0: michael@0: @Override michael@0: public void get() { michael@0: Logger.debug(LOG_TAG, "HTTP GET " + this.uri.toASCIIString()); michael@0: this.go(new HttpGet(this.uri)); michael@0: } michael@0: michael@0: /** michael@0: * Perform an HTTP GET as with {@link BaseResource#get()}, returning only michael@0: * after callbacks have been invoked. michael@0: */ michael@0: public void getBlocking() { michael@0: // Until we use the asynchronous Apache HttpClient, we can simply call michael@0: // through. michael@0: this.get(); michael@0: } michael@0: michael@0: @Override michael@0: public void delete() { michael@0: Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString()); michael@0: this.go(new HttpDelete(this.uri)); michael@0: } michael@0: michael@0: @Override michael@0: public void post(HttpEntity body) { michael@0: Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString()); michael@0: HttpPost request = new HttpPost(this.uri); michael@0: request.setEntity(body); michael@0: this.go(request); michael@0: } michael@0: michael@0: @Override michael@0: public void put(HttpEntity body) { michael@0: Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString()); michael@0: HttpPut request = new HttpPut(this.uri); michael@0: request.setEntity(body); michael@0: this.go(request); michael@0: } michael@0: michael@0: protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) throws UnsupportedEncodingException { michael@0: StringEntity e = new StringEntity(s, "UTF-8"); michael@0: e.setContentType("application/json"); michael@0: return e; michael@0: } michael@0: michael@0: /** michael@0: * Helper for turning a JSON object into a payload. michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: protected static StringEntity jsonEntity(JSONObject body) throws UnsupportedEncodingException { michael@0: return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); michael@0: } michael@0: michael@0: /** michael@0: * Helper for turning an extended JSON object into a payload. michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: protected static StringEntity jsonEntity(ExtendedJSONObject body) throws UnsupportedEncodingException { michael@0: return stringEntityWithContentTypeApplicationJSON(body.toJSONString()); michael@0: } michael@0: michael@0: /** michael@0: * Helper for turning a JSON array into a payload. michael@0: * @throws UnsupportedEncodingException michael@0: */ michael@0: protected static HttpEntity jsonEntity(JSONArray toPOST) throws UnsupportedEncodingException { michael@0: return stringEntityWithContentTypeApplicationJSON(toPOST.toJSONString()); michael@0: } michael@0: michael@0: /** michael@0: * Best-effort attempt to ensure that the entity has been fully consumed and michael@0: * that the underlying stream has been closed. michael@0: * michael@0: * This releases the connection back to the connection pool. michael@0: * michael@0: * @param entity The HttpEntity to be consumed. michael@0: */ michael@0: public static void consumeEntity(HttpEntity entity) { michael@0: try { michael@0: EntityUtils.consume(entity); michael@0: } catch (IOException e) { michael@0: // Doesn't matter. michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Best-effort attempt to ensure that the entity corresponding to the given michael@0: * HTTP response has been fully consumed and that the underlying stream has michael@0: * been closed. michael@0: * michael@0: * This releases the connection back to the connection pool. michael@0: * michael@0: * @param response michael@0: * The HttpResponse to be consumed. michael@0: */ michael@0: public static void consumeEntity(HttpResponse response) { michael@0: if (response == null) { michael@0: return; michael@0: } michael@0: try { michael@0: EntityUtils.consume(response.getEntity()); michael@0: } catch (IOException e) { michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Best-effort attempt to ensure that the entity corresponding to the given michael@0: * Sync storage response has been fully consumed and that the underlying michael@0: * stream has been closed. michael@0: * michael@0: * This releases the connection back to the connection pool. michael@0: * michael@0: * @param response michael@0: * The SyncStorageResponse to be consumed. michael@0: */ michael@0: public static void consumeEntity(SyncStorageResponse response) { michael@0: if (response.httpResponse() == null) { michael@0: return; michael@0: } michael@0: consumeEntity(response.httpResponse()); michael@0: } michael@0: michael@0: /** michael@0: * Best-effort attempt to ensure that the reader has been fully consumed, so michael@0: * that the underlying stream will be closed. michael@0: * michael@0: * This should allow the connection to be released back to the connection pool. michael@0: * michael@0: * @param reader The BufferedReader to be consumed. michael@0: */ michael@0: public static void consumeReader(BufferedReader reader) { michael@0: try { michael@0: reader.close(); michael@0: } catch (IOException e) { michael@0: // Do nothing. michael@0: } michael@0: } michael@0: michael@0: public void post(JSONArray jsonArray) throws UnsupportedEncodingException { michael@0: post(jsonEntity(jsonArray)); michael@0: } michael@0: michael@0: public void put(JSONObject jsonObject) throws UnsupportedEncodingException { michael@0: put(jsonEntity(jsonObject)); michael@0: } michael@0: michael@0: public void post(ExtendedJSONObject o) throws UnsupportedEncodingException { michael@0: post(jsonEntity(o)); michael@0: } michael@0: michael@0: public void post(JSONObject jsonObject) throws UnsupportedEncodingException { michael@0: post(jsonEntity(jsonObject)); michael@0: } michael@0: }