/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.background.bagheera; import java.io.IOException; import java.net.URISyntaxException; import java.security.GeneralSecurityException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.regex.Pattern; import org.mozilla.gecko.sync.net.BaseResource; import org.mozilla.gecko.sync.net.BaseResourceDelegate; import org.mozilla.gecko.sync.net.Resource; import ch.boye.httpclientandroidlib.HttpEntity; import ch.boye.httpclientandroidlib.HttpResponse; import ch.boye.httpclientandroidlib.client.ClientProtocolException; import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; import ch.boye.httpclientandroidlib.protocol.HTTP; /** * Provides encapsulated access to a Bagheera document server. * The two permitted operations are: * * Delete a document. * * Upload a document, optionally deleting an expired document. */ public class BagheeraClient { protected final String serverURI; protected final Executor executor; protected static final Pattern URI_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$"); protected static String PROTOCOL_VERSION = "1.0"; protected static String SUBMIT_PATH = "/submit/"; /** * Instantiate a new client pointing at the provided server. * {@link #deleteDocument(String, String, BagheeraRequestDelegate)} and * {@link #uploadJSONDocument(String, String, String, String, BagheeraRequestDelegate)} * both accept delegate arguments; the {@link Executor} provided to this * constructor will be used to invoke callbacks on those delegates. * * @param serverURI * the destination server URI. * @param executor * the executor which will be used to invoke delegate callbacks. */ public BagheeraClient(final String serverURI, final Executor executor) { if (serverURI == null) { throw new IllegalArgumentException("Must provide a server URI."); } if (executor == null) { throw new IllegalArgumentException("Must provide a non-null executor."); } this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/"; this.executor = executor; } /** * Instantiate a new client pointing at the provided server. * Delegate callbacks will be invoked on a new background thread. * * See {@link #BagheeraClient(String, Executor)} for more details. * * @param serverURI * the destination server URI. */ public BagheeraClient(final String serverURI) { this(serverURI, Executors.newSingleThreadExecutor()); } /** * Delete the specified document from the server. * The delegate's callbacks will be invoked by the BagheeraClient's executor. */ public void deleteDocument(final String namespace, final String id, final BagheeraRequestDelegate delegate) throws URISyntaxException { if (namespace == null) { throw new IllegalArgumentException("Must provide namespace."); } if (id == null) { throw new IllegalArgumentException("Must provide id."); } final BaseResource resource = makeResource(namespace, id); resource.delegate = new BagheeraResourceDelegate(resource, delegate); resource.delete(); } /** * Upload a JSON document to a Bagheera server. The delegate's callbacks will * be invoked in tasks run by the client's executor. * * @param namespace * the namespace, such as "test" * @param id * the document ID, which is typically a UUID. * @param payload * a document, typically JSON-encoded. * @param oldID * an optional ID which denotes a document to supersede. Can be null. * @param delegate * the delegate whose methods should be invoked on success or * failure. */ public void uploadJSONDocument(final String namespace, final String id, final String payload, final String oldID, final BagheeraRequestDelegate delegate) throws URISyntaxException { if (namespace == null) { throw new IllegalArgumentException("Must provide namespace."); } if (id == null) { throw new IllegalArgumentException("Must provide id."); } if (payload == null) { throw new IllegalArgumentException("Must provide payload."); } final BaseResource resource = makeResource(namespace, id); final HttpEntity deflatedBody = DeflateHelper.deflateBody(payload); resource.delegate = new BagheeraUploadResourceDelegate(resource, oldID, delegate); resource.post(deflatedBody); } public static boolean isValidURIComponent(final String in) { return URI_PATTERN.matcher(in).matches(); } protected BaseResource makeResource(final String namespace, final String id) throws URISyntaxException { if (!isValidURIComponent(namespace)) { throw new URISyntaxException(namespace, "Illegal namespace name. Must be alphanumeric + [_-]."); } if (!isValidURIComponent(id)) { throw new URISyntaxException(id, "Illegal id value. Must be alphanumeric + [_-]."); } final String uri = this.serverURI + PROTOCOL_VERSION + SUBMIT_PATH + namespace + "/" + id; return new BaseResource(uri); } public class BagheeraResourceDelegate extends BaseResourceDelegate { private static final int DEFAULT_SOCKET_TIMEOUT_MSEC = 5 * 60 * 1000; // Five minutes. protected BagheeraRequestDelegate delegate; public BagheeraResourceDelegate(Resource resource) { super(resource); } public BagheeraResourceDelegate(final Resource resource, final BagheeraRequestDelegate delegate) { this(resource); this.delegate = delegate; } @Override public int socketTimeout() { return DEFAULT_SOCKET_TIMEOUT_MSEC; } @Override public void handleHttpResponse(HttpResponse response) { final int status = response.getStatusLine().getStatusCode(); switch (status) { case 200: case 201: invokeHandleSuccess(status, response); return; default: invokeHandleFailure(status, response); } } protected void invokeHandleError(final Exception e) { executor.execute(new Runnable() { @Override public void run() { delegate.handleError(e); } }); } protected void invokeHandleFailure(final int status, final HttpResponse response) { executor.execute(new Runnable() { @Override public void run() { delegate.handleFailure(status, response); } }); } protected void invokeHandleSuccess(final int status, final HttpResponse response) { executor.execute(new Runnable() { @Override public void run() { delegate.handleSuccess(status, response); } }); } @Override public void handleHttpProtocolException(final ClientProtocolException e) { invokeHandleError(e); } @Override public void handleHttpIOException(IOException e) { invokeHandleError(e); } @Override public void handleTransportException(GeneralSecurityException e) { invokeHandleError(e); } } public final class BagheeraUploadResourceDelegate extends BagheeraResourceDelegate { private static final String HEADER_OBSOLETE_DOCUMENT = "X-Obsolete-Document"; private static final String COMPRESSED_CONTENT_TYPE = "application/json+zlib; charset=utf-8"; protected String obsoleteDocumentID; public BagheeraUploadResourceDelegate(Resource resource, String obsoleteDocumentID, BagheeraRequestDelegate delegate) { super(resource, delegate); this.obsoleteDocumentID = obsoleteDocumentID; } @Override public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { super.addHeaders(request, client); request.setHeader(HTTP.CONTENT_TYPE, COMPRESSED_CONTENT_TYPE); if (this.obsoleteDocumentID != null) { request.addHeader(HEADER_OBSOLETE_DOCUMENT, this.obsoleteDocumentID); } } } }