All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.couchbase.lite.router.Router Maven / Gradle / Ivy

package com.couchbase.lite.router;

import com.couchbase.lite.AsyncTask;
import com.couchbase.lite.BlobStoreWriter;
import com.couchbase.lite.ChangesOptions;
import com.couchbase.lite.CouchbaseLiteException;
import com.couchbase.lite.Database;
import com.couchbase.lite.Document;
import com.couchbase.lite.DocumentChange;
import com.couchbase.lite.Manager;
import com.couchbase.lite.Mapper;
import com.couchbase.lite.Misc;
import com.couchbase.lite.QueryOptions;
import com.couchbase.lite.QueryRow;
import com.couchbase.lite.Reducer;
import com.couchbase.lite.ReplicationFilter;
import com.couchbase.lite.RevisionList;
import com.couchbase.lite.Status;
import com.couchbase.lite.TransactionalTask;
import com.couchbase.lite.View;
import com.couchbase.lite.auth.FacebookAuthorizer;
import com.couchbase.lite.auth.PersonaAuthorizer;
import com.couchbase.lite.internal.AttachmentInternal;
import com.couchbase.lite.internal.Body;
import com.couchbase.lite.internal.RevisionInternal;
import com.couchbase.lite.replicator.Replication;
import com.couchbase.lite.replicator.Replication.ChangeEvent;
import com.couchbase.lite.replicator.Replication.ChangeListener;
import com.couchbase.lite.replicator.Replication.ReplicationStatus;
import com.couchbase.lite.replicator.ReplicationState;
import com.couchbase.lite.storage.SQLException;
import com.couchbase.lite.store.Store;
import com.couchbase.lite.support.RevisionUtils;
import com.couchbase.lite.support.Version;
import com.couchbase.lite.util.Log;
import com.couchbase.lite.util.StreamUtils;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;

import org.apache.http.client.HttpResponseException;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class Router implements Database.ChangeListener, Database.DatabaseListener {

    private final static long MIN_HEARTBEAT = 5000; // 5 second

    /**
     * Options for what metadata to include in document bodies
     */
    private enum TDContentOptions {
        TDIncludeAttachments,
        TDIncludeConflicts,
        TDIncludeRevs,
        TDIncludeRevsInfo,
        TDIncludeLocalSeq,
        TDNoBody,
        TDBigAttachmentsFollow,
        TDNoAttachments
    }

    private Manager manager;
    private Database db;
    private URLConnection connection;
    private Map queries;
    private boolean changesIncludesDocs = false;
    private boolean changesIncludesConflicts = false;
    private RouterCallbackBlock callbackBlock;
    private boolean responseSent = false;
    private ReplicationFilter changesFilter;
    Map changesFilterParams = null;
    private boolean longpoll = false;
    private boolean waiting = false;
    private URL source = null;
    private Timer timer = null; // timer for heartbeat

    private final Object databaseChangesLongpollLock = new Object();

    public static String getVersionString() {
        return Version.getVersion();
    }

    public Router(Manager manager, URLConnection connection) {
        this.manager = manager;
        this.connection = connection;
    }

    @Override
    protected void finalize() throws Throwable {
        stop();
        super.finalize();
    }

    public void setCallbackBlock(RouterCallbackBlock callbackBlock) {
        this.callbackBlock = callbackBlock;
    }

    private Map getQueries() {
        if (queries == null) {
            String queryString = connection.getURL().getQuery();
            if (queryString != null && queryString.length() > 0) {
                queries = new HashMap();
                for (String component : queryString.split("&")) {
                    int location = component.indexOf('=');
                    if (location > 0) {
                        String key = component.substring(0, location);
                        String value = component.substring(location + 1);
                        queries.put(key, value);
                    }
                }
            }
        }
        return queries;
    }

    private static boolean getBooleanValueFromBody(String paramName, Map bodyDict,
                                                   boolean defaultVal) {
        boolean value = defaultVal;
        if (bodyDict.containsKey(paramName)) {
            value = Boolean.TRUE.equals(bodyDict.get(paramName));
        }
        return value;
    }

    private String getQuery(String param) {
        Map queries = getQueries();
        if (queries != null) {
            String value = queries.get(param);
            if (value != null) {
                return URLDecoder.decode(value);
            }
        }
        return null;
    }

    private boolean getBooleanQuery(String param) {
        String value = getQuery(param);
        return (value != null) && !"false".equals(value) && !"0".equals(value);
    }

    private int getIntQuery(String param, int defaultValue) {
        int result = defaultValue;
        String value = getQuery(param);
        if (value != null) {
            try {
                result = Integer.parseInt(value);
            } catch (NumberFormatException e) {
                //ignore, will return default value
            }
        }

        return result;
    }

    private Object getJSONQuery(String param) {
        String value = getQuery(param);
        if (value == null) {
            return null;
        }
        Object result = null;
        try {
            result = Manager.getObjectMapper().readValue(value, Object.class);
        } catch (Exception e) {
            Log.w("Unable to parse JSON Query", e);
        }
        return result;
    }

    private boolean cacheWithEtag(String etag) {
        String eTag = String.format("\"%s\"", etag);
        connection.getResHeader().add("Etag", eTag);
        String requestIfNoneMatch = connection.getRequestProperty("If-None-Match");
        return eTag.equals(requestIfNoneMatch);
    }

    private Map getBodyAsDictionary() throws CouchbaseLiteException {
        // check if content-type is `application/json`
        String contentType = getRequestHeaderContentType();
        if (contentType != null && !contentType.equals("application/json"))
            throw new CouchbaseLiteException(Status.NOT_ACCEPTABLE);

        // parse body text
        InputStream contentStream = connection.getRequestInputStream();
        try {
            return Manager.getObjectMapper().readValue(contentStream, Map.class);
        } catch (JsonParseException jpe) {
            throw new CouchbaseLiteException(Status.BAD_JSON);
        } catch (JsonMappingException jme) {
            throw new CouchbaseLiteException(Status.BAD_JSON);
        } catch (IOException ioe) {
            throw new CouchbaseLiteException(Status.REQUEST_TIMEOUT);
        }
    }

    private EnumSet getContentOptions() {
        EnumSet result = EnumSet.noneOf(TDContentOptions.class);
        if (getBooleanQuery("attachments")) {
            result.add(TDContentOptions.TDIncludeAttachments);
        }
        if (getBooleanQuery("local_seq")) {
            result.add(TDContentOptions.TDIncludeLocalSeq);
        }
        if (getBooleanQuery("conflicts")) {
            result.add(TDContentOptions.TDIncludeConflicts);
        }
        if (getBooleanQuery("revs")) {
            result.add(TDContentOptions.TDIncludeRevs);
        }
        if (getBooleanQuery("revs_info")) {
            result.add(TDContentOptions.TDIncludeRevsInfo);
        }
        return result;
    }

    private boolean getQueryOptions(QueryOptions options) {
        // http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
        options.setSkip(getIntQuery("skip", options.getSkip()));
        options.setLimit(getIntQuery("limit", options.getLimit()));
        options.setGroupLevel(getIntQuery("group_level", options.getGroupLevel()));
        options.setDescending(getBooleanQuery("descending"));
        options.setIncludeDocs(getBooleanQuery("include_docs"));
        options.setUpdateSeq(getBooleanQuery("update_seq"));
        if (getQuery("inclusive_end") != null) {
            options.setInclusiveEnd(getBooleanQuery("inclusive_end"));
        }
        if (getQuery("reduce") != null) {
            options.setReduceSpecified(true);
            options.setReduce(getBooleanQuery("reduce"));
        }
        options.setGroup(getBooleanQuery("group"));
        //options.setContentOptions(getContentOptions());


        List keys;

        Object keysParam = getJSONQuery("keys");
        if (keysParam != null && !(keysParam instanceof List)) {
            return false;
        } else {
            keys = (List) keysParam;
        }
        if (keys == null) {
            Object key = getJSONQuery("key");
            if (key != null) {
                keys = new ArrayList();
                keys.add(key);
            }
        }
        if (keys != null) {
            options.setKeys(keys);
        } else {
            options.setStartKey(getJSONQuery("startkey"));
            options.setEndKey(getJSONQuery("endkey"));
            if (getJSONQuery("startkey_docid") != null) {
                options.setStartKeyDocId(getJSONQuery("startkey_docid").toString());
            }
            if (getJSONQuery("endkey_docid") != null) {
                options.setEndKeyDocId(getJSONQuery("endkey_docid").toString());
            }

        }

        return true;
    }

    private String getMultipartRequestType() {
        String accept = getRequestHeaderValue("Accept");
        if (accept.startsWith("multipart/")) {
            return accept;
        }
        return null;
    }

    private Status openDB() {
        if (db == null) {
            return new Status(Status.INTERNAL_SERVER_ERROR);
        }
        if (!db.exists()) {
            return new Status(Status.NOT_FOUND);
        }

        try {
            db.open();
        } catch (CouchbaseLiteException e) {
            return e.getCBLStatus();
        }

        return new Status(Status.OK);
    }

    private static List splitPath(URL url) {
        String pathString = url.getPath();
        if (pathString.startsWith("/")) {
            pathString = pathString.substring(1);
        }
        List result = new ArrayList();
        //we want empty string to return empty list
        if (pathString.length() == 0) {
            return result;
        }
        for (String component : pathString.split("/")) {
            result.add(URLDecoder.decode(component));
        }
        return result;
    }

    private void sendResponse() {
        if (!responseSent) {
            responseSent = true;
            if (callbackBlock != null) {
                callbackBlock.onResponseReady();
            }
        }
    }

    // get Content-Type from URLConnection
    private String getRequestHeaderContentType() {
        String contentType = getRequestHeaderValue("Content-Type");
        if (contentType != null) {
            // remove parameter (Content-Type := type "/" subtype *[";" parameter] )
            int index = contentType.indexOf(';');
            if (index > 0)
                contentType = contentType.substring(0, index);
            contentType = contentType.trim();
        }
        return contentType;
    }

    private String getRequestHeaderValue(String paramName) {
        String value = connection.getRequestProperty(paramName);
        if (value == null)
            // From Android: http://developer.android.com/reference/java/net/URLConnection.html
            value = connection.getRequestProperty(paramName.toLowerCase());
        return value;
    }

    public void start() {
        // Refer to: http://wiki.apache.org/couchdb/Complete_HTTP_API_Reference

        String method = connection.getRequestMethod();
        // We're going to map the request into a method call using reflection based on the method and path.
        // Accumulate the method name into the string 'message':
         if ("HEAD".equals(method)) {
            method = "GET";
        }
        String message = String.format("do_%s", method);

        // First interpret the components of the request:
        List path = splitPath(connection.getURL());
        if (path == null) {
            connection.setResponseCode(Status.BAD_REQUEST);
            try {
                connection.getResponseOutputStream().close();
            } catch (IOException e) {
                Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
            }
            sendResponse();
            return;
        }

        int pathLen = path.size();
        if (pathLen > 0) {
            String dbName = path.get(0);
            if (dbName.startsWith("_")) {
                message += dbName;  // special root path, like /_all_dbs
            } else {
                message += "_Database";
                if (!Manager.isValidDatabaseName(dbName)) {
                    Header resHeader = connection.getResHeader();
                    if (resHeader != null) {
                        resHeader.add("Content-Type", "application/json");
                    }
                    Map result = new HashMap();
                    result.put("error", "Invalid database");
                    result.put("status", Status.BAD_REQUEST);
                    connection.setResponseBody(new Body(result));
                    ByteArrayInputStream bais = new ByteArrayInputStream(connection.getResponseBody().getJson());
                    connection.setResponseInputStream(bais);

                    connection.setResponseCode(Status.BAD_REQUEST);
                    try {
                        connection.getResponseOutputStream().close();
                    } catch (IOException e) {
                        Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
                    }
                    sendResponse();
                    return;
                } else {
                    boolean mustExist = false;
                    db = manager.getDatabase(dbName, mustExist); // NOTE: synchronized
                    if (db == null) {
                        connection.setResponseCode(Status.BAD_REQUEST);
                        try {
                            connection.getResponseOutputStream().close();
                        } catch (IOException e) {
                            Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
                        }
                        sendResponse();
                        return;
                    }

                }
            }
        } else {
            message += "Root";
        }

        String docID = null;
        if (db != null && pathLen > 1) {
            message = message.replaceFirst("_Database", "_Document");
            // Make sure database exists, then interpret doc name:
            Status status = openDB();
            if (!status.isSuccessful()) {
                connection.setResponseCode(status.getCode());
                try {
                    connection.getResponseOutputStream().close();
                } catch (IOException e) {
                    Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
                }
                sendResponse();
                return;
            }
            String name = path.get(1);
            if (!name.startsWith("_")) {
                // Regular document
                if (!Document.isValidDocumentId(name)) {
                    connection.setResponseCode(Status.BAD_REQUEST);
                    try {
                        connection.getResponseOutputStream().close();
                    } catch (IOException e) {
                        Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
                    }
                    sendResponse();
                    return;
                }
                docID = name;
            } else if ("_design".equals(name) || "_local".equals(name)) {
                // "_design/____" and "_local/____" are document names
                if (pathLen <= 2) {
                    connection.setResponseCode(Status.NOT_FOUND);
                    try {
                        connection.getResponseOutputStream().close();
                    } catch (IOException e) {
                        Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
                    }
                    sendResponse();
                    return;
                }
                docID = name + '/' + path.get(2);
                path.set(1, docID);
                path.remove(2);
                pathLen--;
            } else if (name.startsWith("_design") || name.startsWith("_local")) {
                // This is also a document, just with a URL-encoded "/"
                docID = name;
            } else if ("_session".equals(name)) {
                // There are two possible uri to get a session, //_session or /_session.
                // This is for //_session.
                message = message.replaceFirst("_Document", name);
            } else {
                // Special document name like "_all_docs":
                message += name;
                if (pathLen > 2) {
                    List subList = path.subList(2, pathLen - 1);
                    StringBuilder sb = new StringBuilder();
                    Iterator iter = subList.iterator();
                    while (iter.hasNext()) {
                        sb.append(iter.next());
                        if (iter.hasNext()) {
                            sb.append('/');
                        }
                    }
                    docID = sb.toString();
                }
            }
        }

        String attachmentName = null;
        if (docID != null && pathLen > 2) {
            message = message.replaceFirst("_Document", "_Attachment");
            // Interpret attachment name:
            attachmentName = path.get(2);
            if (attachmentName.startsWith("_") && docID.startsWith("_design")) {
                // Design-doc attribute like _info or _view
                message = message.replaceFirst("_Attachment", "_DesignDocument");
                docID = docID.substring(8); // strip the "_design/" prefix
                attachmentName = pathLen > 3 ? path.get(3) : null;
            } else {
                if (pathLen > 3) {
                    List subList = path.subList(2, pathLen);
                    StringBuilder sb = new StringBuilder();
                    Iterator iter = subList.iterator();
                    while (iter.hasNext()) {
                        sb.append(iter.next());
                        if (iter.hasNext()) {
                            //sb.append("%2F");
                            sb.append('/');
                        }
                    }
                    attachmentName = sb.toString();
                }
            }
        }

        //Log.d(TAG, "path: " + path + " message: " + message + " docID: " + docID + " attachmentName: " + attachmentName);

        // Send myself a message based on the components:
        Status status = null;
        try {
            Method m = Router.class.getMethod(message, Database.class, String.class, String.class);
            status = (Status) m.invoke(this, db, docID, attachmentName);
        } catch (NoSuchMethodException msme) {
            try {
                String errorMessage = String.format("Router unable to route request to %s", message);
                Log.e(Log.TAG_ROUTER, errorMessage);
                Map result = new HashMap();
                result.put("error", "not_found");
                result.put("reason", errorMessage);
                connection.setResponseBody(new Body(result));
                Method m = Router.class.getMethod("do_UNKNOWN", Database.class, String.class, String.class);
                status = (Status) m.invoke(this, db, docID, attachmentName);
            } catch (Exception e) {
                //default status is internal server error
                Log.e(Log.TAG_ROUTER, "Router attempted do_UNKNWON fallback, but that threw an exception", e);
                status = new Status(Status.NOT_FOUND);
                Map result = new HashMap();
                result.put("status", status.getHTTPCode());
                result.put("error", status.getHTTPMessage());
                result.put("reason", "Router unable to route request");
                connection.setResponseBody(new Body(result));
            }
        } catch (Exception e) {
            String errorMessage = "Router unable to route request to " + message;
            Log.e(Log.TAG_ROUTER, errorMessage, e);
            Map result = new HashMap();
            if (e.getCause() != null && e.getCause() instanceof CouchbaseLiteException) {
                status = ((CouchbaseLiteException) e.getCause()).getCBLStatus();
                result.put("status", status.getHTTPCode());
                result.put("error", status.getHTTPMessage());
                result.put("reason", errorMessage + e.getCause().toString());
            } else {
                status = new Status(Status.NOT_FOUND);
                result.put("status", status.getHTTPCode());
                result.put("error", status.getHTTPMessage());
                result.put("reason", errorMessage + e.toString());
            }
            connection.setResponseBody(new Body(result));
        }

        // If response is ready (nonzero status), tell my client about it:
        if (status.getCode() != 0) {
            // NOTE: processRequestRanges() is not implemented for CBL Java Core

            // Configure response headers:
            status = sendResponseHeaders(status);

            connection.setResponseCode(status.getCode());

            if (status.isSuccessful() && connection.getResponseBody() == null && connection.getHeaderField("Content-Type") == null) {
                connection.setResponseBody(new Body("{\"ok\":true}".getBytes()));
            }

            if (status.getCode() != 0 && status.isSuccessful() == false && connection.getResponseBody() == null) {
                Map result = new HashMap();
                result.put("status", status.getCode());
                result.put("error", status.getHTTPMessage());
                connection.setResponseBody(new Body(result));
            }

            setResponse();
            sendResponse();
        } else {
            // NOTE code == 0
            waiting = true;
        }

        if (waiting && db != null) {
            Log.v(Log.TAG_ROUTER, "waiting=true & db!=null: call Database.addDatabaseListener()");
            db.addDatabaseListener(this);
        }
    }

    /**
     * implementation of Database.DatabaseListener
     */
    @Override
    public void databaseClosing() {
        dbClosing();
    }

    private void dbClosing() {
        Log.d(Log.TAG_ROUTER, "Database closing! Returning error 500");
        Status status = new Status(Status.INTERNAL_SERVER_ERROR);
        status = sendResponseHeaders(status);
        connection.setResponseCode(status.getCode());
        setResponse();
        sendResponse();
    }

    public void stop() {
        stopHeartbeat();
        callbackBlock = null;
        if (db != null) {
            db.removeChangeListener(this);
            db.removeDatabaseListener(this);
        }
    }

    @SuppressWarnings("MethodMayBeStatic")
    public Status do_UNKNOWN(Database db, String docID, String attachmentName) {
        return new Status(Status.NOT_FOUND);
    }

    private void setResponse() {
        if (connection.getResponseBody() != null) {
            ByteArrayInputStream bais = new ByteArrayInputStream(connection.getResponseBody().getJson());
            connection.setResponseInputStream(bais);
        } else {
            try {
                connection.getResponseOutputStream().close();
            } catch (IOException e) {
                Log.e(Log.TAG_ROUTER, "Error closing empty output stream");
            }
        }
    }

    /**
     * in CBL_Router.m
     * - (void) sendResponseHeaders
     */
    private Status sendResponseHeaders(Status status) {
        // NOTE: Line 572-574 of CBL_Router.m is not in CBL Java Core
        //       This check is in sendResponse();

        connection.getResHeader().add("Server", String.format("Couchbase Lite %s", getVersionString()));

        // Check for a mismatch between the Accept request header and the response type:
        String accept = getRequestHeaderValue("Accept");
        if (accept != null && !"*/*".equals(accept)) {
            String responseType = connection.getBaseContentType();
            if (responseType != null && responseType.indexOf(accept) < 0) {
                Log.e(Log.TAG_ROUTER, "Error 406: Can't satisfy request Accept: %s", accept);
                status = new Status(Status.NOT_ACCEPTABLE);
            }
        }

        if (connection.getResponseBody() != null && connection.getResponseBody().isValidJSON()) {
            Header resHeader = connection.getResHeader();
            if (resHeader != null) {
                resHeader.add("Content-Type", "application/json");
            } else {
                Log.w(Log.TAG_ROUTER, "Cannot add Content-Type header because getResHeader() returned null");
            }
        }

        // NOTE: Line 596-607 of CBL_Router.m is not in CBL Java Core

        return status;
    }

    /**
     * Router+Handlers
     */

    private void setResponseLocation(URL url) {
        String location = url.getPath();
        String query = url.getQuery();
        if (query != null) {
            int startOfQuery = location.indexOf(query);
            if (startOfQuery > 0) {
                location = location.substring(0, startOfQuery);
            }
        }
        connection.getResHeader().add("Location", location);
    }

    /**
     * SERVER REQUESTS: *
     */

    public Status do_GETRoot(Database _db, String _docID, String _attachmentName) {
        Map info = new HashMap();
        info.put("CBLite", "Welcome");
        info.put("couchdb", "Welcome"); // for compatibility
        info.put("version", getVersionString());
        connection.setResponseBody(new Body(info));
        return new Status(Status.OK);
    }

    public Status do_GET_all_dbs(Database _db, String _docID, String _attachmentName) {
        List dbs = manager.getAllDatabaseNames();
        connection.setResponseBody(new Body(dbs));
        return new Status(Status.OK);
    }

    public Status do_GET_session(Database _db, String _docID, String _attachmentName) {
        // Send back an "Admin Party"-like response
        Map session = new HashMap();
        Map userCtx = new HashMap();
        String[] roles = {"_admin"};
        session.put("ok", true);
        userCtx.put("name", null);
        userCtx.put("roles", roles);
        session.put("userCtx", userCtx);
        connection.setResponseBody(new Body(session));
        return new Status(Status.OK);
    }

    public Status do_POST_replicate(Database _db, String _docID, String _attachmentName) {

        Replication replicator;

        // Extract the parameters from the JSON request body:
        // http://wiki.apache.org/couchdb/Replication
        Map body = null;
        try {
            body = getBodyAsDictionary();
        } catch (CouchbaseLiteException e) {
            return e.getCBLStatus();
        }

        try {
            // NOTE: replicator instance is created per request. not access shared instances
            replicator = manager.getReplicator(body);
        } catch (CouchbaseLiteException e) {
            Map result = new HashMap();
            result.put("error", e.toString());
            connection.setResponseBody(new Body(result));
            return e.getCBLStatus();
        }

        Boolean cancelBoolean = (Boolean) body.get("cancel");
        boolean cancel = (cancelBoolean != null && cancelBoolean.booleanValue());

        if (!cancel) {

            if (!replicator.isRunning()) {

                final CountDownLatch replicationStarted = new CountDownLatch(1);
                replicator.addChangeListener(new Replication.ChangeListener() {
                    @Override
                    public void changed(Replication.ChangeEvent event) {
                        if (event.getTransition() != null && event.getTransition().getDestination() == ReplicationState.RUNNING) {
                            replicationStarted.countDown();
                        }
                    }
                });

                if (!replicator.isContinuous()) {
                    replicator.addChangeListener(new Replication.ChangeListener() {
                        @Override
                        public void changed(Replication.ChangeEvent event) {
                            if (event.getTransition() != null && event.getTransition().getDestination() == ReplicationState.STOPPED) {
                                Status status = new Status(Status.OK);
                                status = sendResponseHeaders(status);
                                connection.setResponseCode(status.getCode());
                                Map result = new HashMap();
                                result.put("session_id", event.getSource().getSessionID());
                                connection.setResponseBody(new Body(result));

                                setResponse();
                                sendResponse();
                            }
                        }
                    });
                }

                replicator.start();

                // wait for replication to start, otherwise replicator.getSessionId() will return null
                try {
                    replicationStarted.await();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }

            if (replicator.isContinuous()) {
                Map result = new HashMap();
                result.put("session_id", replicator.getSessionID());
                connection.setResponseBody(new Body(result));
                return new Status(Status.OK);
            } else {
                return new Status(0);
            }
        } else {
            // Cancel replication:
            replicator.stop();
            return new Status(Status.OK);
        }
    }

    public Status do_GET_uuids(Database _db, String _docID, String _attachmentName) {
        int count = Math.min(1000, getIntQuery("count", 1));
        List uuids = new ArrayList(count);
        for (int i = 0; i < count; i++) {
            uuids.add(Misc.CreateUUID());
        }
        Map result = new HashMap();
        result.put("uuids", uuids);
        connection.setResponseBody(new Body(result));
        return new Status(Status.OK);
    }

    /**
     * TODO: CBL Java Core codes are out of sync with CBL iOS. Need to catch up CBL iOS
     */
    public Status do_GET_active_tasks(Database _db, String _docID, String _attachmentName) {
        // http://wiki.apache.org/couchdb/HttpGetActiveTasks

        String feed = getQuery("feed");
        longpoll = "longpoll".equals(feed);
        boolean continuous = !longpoll && "continuous".equals(feed);

        String session_id = getQuery("session_id");

        ChangeListener listener = new ChangeListener() {

            ChangeListener self = this;

            @Override
            public void changed(ChangeEvent event) {

                Map activity = getActivity(event.getSource());

                // NOTE: Followings are not supported by iOS. We might remove them in the future.
                if (event.getTransition() != null) {
                    // this adds data to the response based on the trigger.
                    activity.put("transition_source", event.getTransition().getSource());
                    activity.put("transition_destination", event.getTransition().getDestination());
                    activity.put("trigger", event.getTransition().getTrigger());
                    Log.d(Log.TAG_ROUTER,
                            "do_GET_active_tasks Transition [" + event.getTransition().getTrigger() +
                                    "] Source:" + event.getTransition().getSource() +
                                    ", Destination:" + event.getTransition().getDestination());
                }

                if (longpoll) {
                    Log.w(Log.TAG_ROUTER, "Router: Sending longpoll replication response");
                    sendResponse();
                    if (callbackBlock != null) {
                        byte[] data = null;
                        try {
                            data = Manager.getObjectMapper().writeValueAsBytes(activity);
                        } catch (Exception e) {
                            Log.w(Log.TAG_ROUTER, "Error serializing JSON", e);
                        }
                        OutputStream os = connection.getResponseOutputStream();
                        try {
                            os.write(data);
                            os.close();
                        } catch (IOException e) {
                            Log.e(Log.TAG_ROUTER, "IOException writing to internal streams", e);
                        }
                    }
                    //remove this change listener because a new one will be added when this responds
                    event.getSource().removeChangeListener(self);
                } else {
                    Log.w(Log.TAG_ROUTER, "Router: Sending continous replication change chunk");
                    sendContinuousReplicationChanges(activity);
                }
            }
        };

        List> activities = new ArrayList>();
        for (Database db : manager.allOpenDatabases()) {
            List activeReplicators = db.getActiveReplications();
            if (activeReplicators != null) {
                for (Replication replicator : activeReplicators) {
                    if (replicator.isRunning()) {
                        Map activity = getActivity(replicator);

                        if (session_id != null) {
                            if (replicator.getSessionID().equals(session_id)) {
                                activities.add(activity);
                            }
                        } else {
                            activities.add(activity);
                        }

                        if (continuous || longpoll) {
                            if (session_id != null) {
                                if (replicator.getSessionID().equals(session_id)) {
                                    replicator.addChangeListener(listener);
                                }
                            } else {
                                replicator.addChangeListener(listener);
                            }
                        }
                    }
                }
            }
        }

        if (continuous || longpoll) {
            connection.setChunked(true);
            connection.setResponseCode(Status.OK);
            sendResponse();
            if (continuous && !activities.isEmpty()) {
                for (Map activity : activities) {
                    sendContinuousReplicationChanges(activity);
                }
            }

            // Don't close connection; more data to come
            return new Status(0);
        } else {
            connection.setResponseBody(new Body(activities));
            return new Status(Status.OK);
        }
    }

    /**
     * TODO: To be compatible with CBL iOS, this method should move to Replicator.activeTaskInfo().
     * TODO: Reference: - (NSDictionary*) activeTaskInfo in CBL_Replicator.m
     */
    private static Map getActivity(Replication replicator) {
        // For schema, see http://wiki.apache.org/couchdb/HttpGetActiveTasks
        Map activity = new HashMap();

        String source = replicator.getRemoteUrl().toExternalForm();
        String target = replicator.getLocalDatabase().getName();

        if (!replicator.isPull()) {
            String tmp = source;
            source = target;
            target = tmp;
        }
        int processed = replicator.getCompletedChangesCount();
        int total = replicator.getChangesCount();
        String status = String.format("Processed %d / %d changes", processed, total);
        if (!replicator.getStatus().equals(ReplicationStatus.REPLICATION_ACTIVE)) {
            //These values match the values for IOS.
            if (replicator.getStatus().equals(ReplicationStatus.REPLICATION_IDLE)) {
                status = "Idle";     // nonstandard
            } else if (replicator.getStatus().equals(ReplicationStatus.REPLICATION_STOPPED)) {
                status = "Stopped";
            } else if (replicator.getStatus().equals(ReplicationStatus.REPLICATION_OFFLINE)) {
                status = "Offline";  // nonstandard
            }
        }
        int progress = (total > 0) ? Math.round(100 * processed / (float) total) : 0;

        activity.put("type", "Replication");
        activity.put("task", replicator.getSessionID());
        activity.put("source", source);
        activity.put("target", target);
        activity.put("status", status);
        activity.put("progress", progress);
        activity.put("continuous", replicator.isContinuous());

        //NOTE: Need to support "x_active_requests"

        if (replicator.getLastError() != null) {
            String msg = String.format("Replicator error: %s.  Repl: %s.  Source: %s, Target: %s",
                    replicator.getLastError(), replicator, source, target);
            Log.e(Log.TAG_ROUTER, msg);
            Throwable error = replicator.getLastError();
            int statusCode = 400;
            if (error instanceof HttpResponseException) {
                statusCode = ((HttpResponseException) error).getStatusCode();
            }
            Object[] errorObjects = new Object[]{statusCode, replicator.getLastError().toString()};
            activity.put("error", errorObjects);
        } else {
            // NOTE: Following two parameters: CBL iOS does not support. We might remove them in the future.
            activity.put("change_count", total);
            activity.put("completed_change_count", processed);
        }

        return activity;
    }

    /**
     * Send a JSON object followed by a newline without closing the connection.
     * Used by the continuous mode of _changes and _active_tasks.
     * 

* TODO: CBL iOS supports EventSourceFeed in addition to longpoll and continuous. * TODO: Need to catch up CBL iOS: - (void) sendContinuousLine: (NSDictionary*)changeDict in CBL_Router+Handlers.m */ private void sendContinuousReplicationChanges(Map activity) { try { String jsonString = Manager.getObjectMapper().writeValueAsString(activity); if (callbackBlock != null) { byte[] json = (jsonString + '\n').getBytes(); OutputStream os = connection.getResponseOutputStream(); try { os.write(json); os.flush(); } catch (Exception e) { Log.e(Log.TAG_ROUTER, "IOException writing to internal streams", e); } } } catch (Exception e) { Log.w("Unable to serialize change to JSON", e); } } /** * DATABASE REQUESTS: * */ public Status do_GET_Database(Database _db, String _docID, String _attachmentName) { // http://wiki.apache.org/couchdb/HTTP_database_API#Database_Information Status status = openDB(); if (!status.isSuccessful()) { return status; } // NOTE: all methods are read operation. not necessary to be synchronized int num_docs = db.getDocumentCount(); long update_seq = db.getLastSequenceNumber(); long instanceStartTimeMicroseconds = db.getStartTime() * 1000; Map result = new HashMap(); result.put("db_name", db.getName()); result.put("db_uuid", db.publicUUID()); result.put("doc_count", num_docs); result.put("update_seq", update_seq); result.put("disk_size", db.totalDataSize()); result.put("instance_start_time", instanceStartTimeMicroseconds); connection.setResponseBody(new Body(result)); return new Status(Status.OK); } public Status do_PUT_Database(Database _db, String _docID, String _attachmentName) { if (db.exists()) { return new Status(Status.DUPLICATE); } try { // note: synchronized db.open(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } setResponseLocation(connection.getURL()); return new Status(Status.CREATED); } public Status do_DELETE_Database(Database _db, String _docID, String _attachmentName) throws CouchbaseLiteException { if (getQuery("rev") != null) { return new Status(Status.BAD_REQUEST); // CouchDB checks for this; probably meant to be a document deletion } db.delete(); return new Status(Status.OK); } /** * This is a hack to deal with the fact that there is currently no custom * serializer for QueryRow. Instead, just convert everything to generic Maps. */ private static void convertCBLQueryRowsToMaps(Map allDocsResult) { List> rowsAsMaps = new ArrayList>(); List rows = (List) allDocsResult.get("rows"); if (rows != null) { for (QueryRow row : rows) { rowsAsMaps.add(row.asJSONDictionary()); } } allDocsResult.put("rows", rowsAsMaps); } public Status do_POST_Database(Database _db, String _docID, String _attachmentName) { Status status = openDB(); if (!status.isSuccessful()) { return status; } Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } return update(db, null, new Body(body), false); } public Status do_GET_Document_all_docs(Database _db, String _docID, String _attachmentName) throws CouchbaseLiteException { QueryOptions options = new QueryOptions(); if (!getQueryOptions(options)) { return new Status(Status.BAD_REQUEST); } Map result = db.getAllDocs(options); convertCBLQueryRowsToMaps(result); if (result == null) { return new Status(Status.INTERNAL_SERVER_ERROR); } connection.setResponseBody(new Body(result)); return new Status(Status.OK); } public Status do_POST_Document_all_docs(Database _db, String _docID, String _attachmentName) throws CouchbaseLiteException { QueryOptions options = new QueryOptions(); if (!getQueryOptions(options)) { return new Status(Status.BAD_REQUEST); } Map body = getBodyAsDictionary(); if (body == null) { return new Status(Status.BAD_REQUEST); } List keys = (List) body.get("keys"); options.setKeys(keys); Map result = null; result = db.getAllDocs(options); convertCBLQueryRowsToMaps(result); if (result == null) { return new Status(Status.INTERNAL_SERVER_ERROR); } connection.setResponseBody(new Body(result)); return new Status(Status.OK); } public Status do_POST_facebook_token(Database _db, String _docID, String _attachmentName) { Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } String email = (String) body.get("email"); String remoteUrl = (String) body.get("remote_url"); String accessToken = (String) body.get("access_token"); if (email != null && remoteUrl != null && accessToken != null) { try { URL siteUrl = new URL(remoteUrl); } catch (MalformedURLException e) { Map result = new HashMap(); result.put("error", "invalid remote_url: " + e.getLocalizedMessage()); connection.setResponseBody(new Body(result)); return new Status(Status.BAD_REQUEST); } try { FacebookAuthorizer.registerAccessToken(accessToken, email, remoteUrl); } catch (Exception e) { Map result = new HashMap(); result.put("error", "error registering access token: " + e.getLocalizedMessage()); connection.setResponseBody(new Body(result)); return new Status(Status.BAD_REQUEST); } Map result = new HashMap(); result.put("ok", "registered"); connection.setResponseBody(new Body(result)); return new Status(Status.OK); } else { Map result = new HashMap(); result.put("error", "required fields: access_token, email, remote_url"); connection.setResponseBody(new Body(result)); return new Status(Status.BAD_REQUEST); } } public Status do_POST_persona_assertion(Database _db, String _docID, String _attachmentName) { Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } String assertion = (String) body.get("assertion"); if (assertion == null) { Map result = new HashMap(); result.put("error", "required fields: assertion"); connection.setResponseBody(new Body(result)); return new Status(Status.BAD_REQUEST); } try { String email = PersonaAuthorizer.registerAssertion(assertion); Map result = new HashMap(); result.put("ok", "registered"); result.put("email", email); connection.setResponseBody(new Body(result)); return new Status(Status.OK); } catch (Exception e) { Map result = new HashMap(); result.put("error", "error registering persona assertion: " + e.getLocalizedMessage()); connection.setResponseBody(new Body(result)); return new Status(Status.BAD_REQUEST); } } public Status do_POST_Document_bulk_docs(Database _db, String _docID, String _attachmentName) { // http://wiki.apache.org/couchdb/HTTP_Bulk_Document_API Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } final List> docs = (List>) body.get("docs"); final boolean noNewEdits = getBooleanValueFromBody("new_edits", body, true) == false; final boolean allOrNothing = getBooleanValueFromBody("all_or_nothing", body, false); final Status status = new Status(Status.OK); final List> results = new ArrayList>(); // Transaction provide synchronized feature by SQLiteDatabase. // In the transaction block, should not use `synchronized` block boolean ret = db.getStore().runInTransaction(new TransactionalTask() { @Override public boolean run() { boolean ok = false; try { for (Map doc : docs) { String docID = (String) doc.get("_id"); RevisionInternal rev = null; Body docBody = new Body(doc); if (noNewEdits) { rev = new RevisionInternal(docBody); if (rev.getRevID() == null || rev.getDocID() == null || !rev.getDocID().equals(docID)) { status.setCode(Status.BAD_REQUEST); } else { List history = Database.parseCouchDBRevisionHistory(doc); db.forceInsert(rev, history, source); } } else { Status outStatus = new Status(); rev = update(db, docID, docBody, false, allOrNothing, outStatus); status.setCode(outStatus.getCode()); } Map result = null; if (status.isSuccessful()) { result = new HashMap(); result.put("ok", true); result.put("id", rev.getDocID()); if (rev != null) { result.put("rev", rev.getRevID()); } } else if (allOrNothing) { return false; } else if (status.getCode() == Status.FORBIDDEN) { result = new HashMap(); result.put("error", "validation failed"); result.put("id", rev.getDocID()); } else if (status.getCode() == Status.CONFLICT) { result = new HashMap(); result.put("error", "conflict"); result.put("id", rev.getDocID()); } else { //return status; // abort the whole thing if something goes badly wrong return false; } if (result != null) { results.add(result); } } Log.w(Log.TAG_ROUTER, "%s finished inserting %d revisions in bulk", this, docs.size()); ok = true; } catch (Exception e) { Log.e(Log.TAG_ROUTER, "%s: Exception inserting revisions in bulk", e, this); } finally { return ok; } } }); if (ret) { connection.setResponseBody(new Body(results)); return new Status(Status.CREATED); } else { return status; } } public Status do_POST_Document_revs_diff(Database _db, String _docID, String _attachmentName) { // http://wiki.apache.org/couchdb/HttpPostRevsDiff // Collect all of the input doc/revision IDs as TDRevisions: RevisionList revs = new RevisionList(); Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } for (String docID : body.keySet()) { List revIDs = (List) body.get(docID); for (String revID : revIDs) { RevisionInternal rev = new RevisionInternal(docID, revID, false); revs.add(rev); } } // Look them up, removing the existing ones from revs: try { // query db only, not necessary to be syncrhonized db.findMissingRevisions(revs); } catch (SQLException e) { Log.e(Log.TAG_ROUTER, "Exception", e); return new Status(Status.DB_ERROR); } // Return the missing revs in a somewhat different format: Map diffs = new HashMap(); for (RevisionInternal rev : revs) { String docID = rev.getDocID(); List missingRevs = null; Map idObj = (Map) diffs.get(docID); if (idObj != null) { missingRevs = (List) idObj.get("missing"); } else { idObj = new HashMap(); } if (missingRevs == null) { missingRevs = new ArrayList(); idObj.put("missing", missingRevs); diffs.put(docID, idObj); } missingRevs.add(rev.getRevID()); } // FIXME add support for possible_ancestors connection.setResponseBody(new Body(diffs)); return new Status(Status.OK); } @SuppressWarnings("MethodMayBeStatic") public Status do_POST_Document_compact(Database _db, String _docID, String _attachmentName) { Status status = new Status(Status.OK); try { // Make Database.compact() thread-safe _db.compact(); } catch (CouchbaseLiteException e) { status = e.getCBLStatus(); } if (status.getCode() < 300) { Status outStatus = new Status(); outStatus.setCode(202); // CouchDB returns 202 'cause it's an async operation return outStatus; } else { return status; } } public Status do_POST_Document_purge(Database _db, String ignored1, String ignored2) { Map body; try { body = getBodyAsDictionary(); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } // convert from Map -> Map> - is there a cleaner way? final Map> docsToRevs = new HashMap>(); for (String key : body.keySet()) { Object val = body.get(key); if (val instanceof List) { docsToRevs.put(key, (List) val); } } final List> asyncApiCallResponse = new ArrayList>(); // this is run in an async db task to fix the following race condition // found in issue #167 (https://github.com/couchbase/couchbase-lite-android/issues/167) // replicator thread: call db.loadRevisionBody for doc1 // liteserv thread: call db.purge on doc1 // replicator thread: call db.getRevisionHistory for doc1, which returns empty history since it was purged Future future = db.runAsync(new AsyncTask() { @Override public void run(Database database) { // purgeRevisions uses transaction internally. Map purgedRevisions = db.purgeRevisions(docsToRevs); asyncApiCallResponse.add(purgedRevisions); } }); try { future.get(60, TimeUnit.SECONDS); } catch (InterruptedException e) { Log.e(Log.TAG_ROUTER, "Exception waiting for future", e); return new Status(Status.INTERNAL_SERVER_ERROR); } catch (ExecutionException e) { Log.e(Log.TAG_ROUTER, "Exception waiting for future", e); return new Status(Status.INTERNAL_SERVER_ERROR); } catch (TimeoutException e) { Log.e(Log.TAG_ROUTER, "Exception waiting for future", e); return new Status(Status.INTERNAL_SERVER_ERROR); } Map purgedRevisions = asyncApiCallResponse.get(0); Map responseMap = new HashMap(); responseMap.put("purged", purgedRevisions); Body responseBody = new Body(responseMap); connection.setResponseBody(responseBody); return new Status(Status.OK); } @SuppressWarnings("MethodMayBeStatic") public Status do_POST_Document_ensure_full_commit(Database _db, String _docID, String _attachmentName) { return new Status(Status.OK); } /** * CHANGES: * */ private Map changesDictForRevision(RevisionInternal rev) { Map changesDict = new HashMap(); changesDict.put("rev", rev.getRevID()); List> changes = new ArrayList>(); changes.add(changesDict); Map result = new HashMap(); result.put("seq", rev.getSequence()); result.put("id", rev.getDocID()); result.put("changes", changes); if (rev.isDeleted()) { result.put("deleted", true); } if (changesIncludesDocs) { result.put("doc", rev.getProperties()); } return result; } private Map responseBodyForChanges(List changes, long since) { List> results = new ArrayList>(); for (RevisionInternal rev : changes) { Map changeDict = changesDictForRevision(rev); results.add(changeDict); } if (changes.size() > 0) { since = changes.get(changes.size() - 1).getSequence(); } Map result = new HashMap(); result.put("results", results); result.put("last_seq", since); return result; } private Map responseBodyForChangesWithConflicts(List changes, long since) { // Assumes the changes are grouped by docID so that conflicts will be adjacent. List> entries = new ArrayList>(); String lastDocID = null; Map lastEntry = null; for (RevisionInternal rev : changes) { String docID = rev.getDocID(); if (docID.equals(lastDocID)) { Map changesDict = new HashMap(); changesDict.put("rev", rev.getRevID()); List> inchanges = (List>) lastEntry.get("changes"); inchanges.add(changesDict); } else { lastEntry = changesDictForRevision(rev); entries.add(lastEntry); lastDocID = docID; } } // After collecting revisions, sort by sequence: Collections.sort(entries, new Comparator>() { public int compare(Map e1, Map e2) { return Misc.SequenceCompare((Long) e1.get("seq"), (Long) e2.get("seq")); } }); Long lastSeq; if (entries.size() == 0) { lastSeq = since; } else { lastSeq = (Long) entries.get(entries.size() - 1).get("seq"); if (lastSeq == null) { lastSeq = since; } } Map result = new HashMap(); result.put("results", entries); result.put("last_seq", lastSeq); return result; } private void sendContinuousChange(RevisionInternal rev) { Map changeDict = changesDictForRevision(rev); try { String jsonString = Manager.getObjectMapper().writeValueAsString(changeDict); if (callbackBlock != null) { byte[] json = (jsonString + '\n').getBytes(); OutputStream os = connection.getResponseOutputStream(); try { os.write(json); os.flush(); } catch (Exception e) { Log.e(Log.TAG_ROUTER, "IOException writing to internal streams", e); } } } catch (Exception e) { Log.w("Unable to serialize change to JSON", e); } } /** * Implementation of ChangeListener */ @Override public void changed(Database.ChangeEvent event) { List revs = new ArrayList(); List changes = event.getChanges(); for (DocumentChange change : changes) { RevisionInternal rev = change.getAddedRevision(); String winningRevID = change.getWinningRevisionID(); if (!this.changesIncludesConflicts) { if (winningRevID == null) continue; // // this change doesn't affect the winning rev ID, no need to send it else if (!winningRevID.equals(rev.getRevID())) { // This rev made a _different_ rev current, so substitute that one. // We need to emit the current sequence # in the feed, so put it in the rev. // This isn't correct internally (this is an old rev so it has an older sequence) // but consumers of the _changes feed don't care about the internal state. RevisionInternal mRev = db.getDocument(rev.getDocID(), winningRevID, changesIncludesDocs); mRev.setSequence(rev.getSequence()); rev = mRev; } } if (!event.getSource().runFilter(changesFilter, changesFilterParams, rev)) continue; if (longpoll) { revs.add(rev); } else { Log.i(Log.TAG_ROUTER, "Router: Sending continous change chunk"); sendContinuousChange(rev); } } if (longpoll && revs.size() > 0) { // in case of /_changes with longpoll, the connection is critical section // when case multiple threads write a doc simultaneously. synchronized (databaseChangesLongpollLock) { Log.i(Log.TAG_ROUTER, "Router: Sending longpoll response: START"); sendResponse(); OutputStream os = connection.getResponseOutputStream(); try { Map body = responseBodyForChanges(revs, 0); if (callbackBlock != null) { byte[] data = null; try { data = Manager.getObjectMapper().writeValueAsBytes(body); } catch (Exception e) { Log.w(Log.TAG_ROUTER, "Error serializing JSON", e); } os.write(data); os.flush(); } } catch (IOException e) { // NOTE: Under multi-threads environment, OutputStream could be already closed // by other thread. Because multiple Database write operations // from multiple threads cause `changed(ChangeEvent)` callbacks // from multiple threads simultaneously because `changed` is fired // at out of transaction after endTransaction(). So this is ignorable error. // So print warning message, and exit from method. // Stacktrace should not be printed, it confuses developer. // https://github.com/couchbase/couchbase-lite-java-core/issues/1043 Log.w(Log.TAG_ROUTER, "IOException writing to internal streams: " + e.getMessage()); } finally { try { if (os != null) { os.close(); } } catch (IOException e) { Log.w(Log.TAG_ROUTER, "Failed to close connection: " + e.getMessage()); } } Log.i(Log.TAG_ROUTER, "Router: Sending longpoll response: END"); } } } public Status do_GET_Document_changes(Database _db, String docID, String _attachmentName) { // http://wiki.apache.org/couchdb/HTTP_database_API#Changes // Get options: changesIncludesDocs = getBooleanQuery("include_docs"); String style = getQuery("style"); if (style != null && "all_docs".equals(style)) changesIncludesConflicts = true; ChangesOptions options = new ChangesOptions(); options.setIncludeDocs(changesIncludesDocs); options.setIncludeConflicts(changesIncludesConflicts); options.setSortBySequence(!options.isIncludeConflicts()); // TODO: descending option is not supported by ChangesOptions options.setLimit(getIntQuery("limit", options.getLimit())); int since = getIntQuery("since", 0); String filterName = getQuery("filter"); if (filterName != null) { changesFilter = db.getFilter(filterName); if (changesFilter == null) { return new Status(Status.NOT_FOUND); } changesFilterParams = new HashMap(queries); Log.v(Log.TAG_ROUTER, "Filter params=" + changesFilterParams); } // changesSince() is query only. not required synchronized RevisionList changes = db.changesSince(since, options, changesFilter, changesFilterParams); if (changes == null) { return new Status(Status.INTERNAL_SERVER_ERROR); } String feed = getQuery("feed"); longpoll = "longpoll".equals(feed); boolean continuous = !longpoll && "continuous".equals(feed); if (continuous || (longpoll && changes.size() == 0)) { connection.setChunked(true); connection.setResponseCode(Status.OK); sendResponse(); if (continuous) { for (RevisionInternal rev : changes) { sendContinuousChange(rev); } } db.addChangeListener(this); // heartbeat String heartbeatParam = getQuery("heartbeat"); if (heartbeatParam != null) { long heartbeat = 0; try { heartbeat = (long) Double.parseDouble(heartbeatParam); } catch (Exception e) { return new Status(Status.BAD_REQUEST); } if (heartbeat <= 0) return new Status(Status.BAD_REQUEST); else if (heartbeat < MIN_HEARTBEAT) heartbeat = MIN_HEARTBEAT; startHeartbeat(heartbeat); } // Don't close connection; more data to come return new Status(0); } else { if (options.isIncludeConflicts()) { connection.setResponseBody(new Body(responseBodyForChangesWithConflicts(changes, since))); } else { connection.setResponseBody(new Body(responseBodyForChanges(changes, since))); } return new Status(Status.OK); } } private void startHeartbeat(long interval) { if (interval <= 0) return; stopHeartbeat(); timer = new Timer(); timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { synchronized (databaseChangesLongpollLock) { OutputStream os = connection.getResponseOutputStream(); if (os != null) { try { Log.v(Log.TAG_ROUTER, "[%s] Sent heart beat!", this); os.write("\r\n".getBytes()); os.flush(); } catch (IOException e) { Log.w(Log.TAG_ROUTER, "IOException writing to internal streams: " + e.getMessage()); } finally { // no close outputstream, OutputStream might be re-used } } } } }, interval, interval); } private void stopHeartbeat() { if (timer != null) { timer.cancel(); timer.purge(); timer = null; } } /** * DOCUMENT REQUESTS: * */ private String getRevIDFromIfMatchHeader() { String ifMatch = getRequestHeaderValue("If-Match"); if (ifMatch == null) { return null; } // Value of If-Match is an ETag, so have to trim the quotes around it: if (ifMatch.length() > 2 && ifMatch.startsWith("\"") && ifMatch.endsWith("\"")) { return ifMatch.substring(1, ifMatch.length() - 2); } else { return null; } } public Status do_GET_Document(Database _db, String docID, String _attachmentName) { try { // http://wiki.apache.org/couchdb/HTTP_Document_API#GET boolean isLocalDoc = docID.startsWith("_local"); EnumSet options = getContentOptions(); String openRevsParam = getQuery("open_revs"); if (openRevsParam == null || isLocalDoc) { // Regular GET: String revID = getQuery("rev"); // often null RevisionInternal rev = null; if (isLocalDoc) { // query only -> not required synchronized rev = db.getLocalDocument(docID, revID); } else { boolean includeAttachments = options.contains(TDContentOptions.TDIncludeAttachments); if (includeAttachments) { options.remove(TDContentOptions.TDIncludeAttachments); } // query only -> not required synchronized rev = db.getDocument(docID, revID, true); if (rev != null) { rev = applyOptionsToRevision(options, rev); } if (rev == null) { } } if (rev == null) return new Status(Status.NOT_FOUND); if (cacheWithEtag(rev.getRevID())) return new Status(Status.NOT_MODIFIED); // set ETag and check conditional GET connection.setResponseBody(rev.getBody()); } else { List> result = null; if ("all".equals(openRevsParam)) { // Get all conflicting revisions: RevisionList allRevs = db.getStore().getAllRevisions(docID, true); result = new ArrayList>(allRevs.size()); for (RevisionInternal rev : allRevs) { try { // loadRevisionBody is synchronized with store instance. db.loadRevisionBody(rev); } catch (CouchbaseLiteException e) { if (e.getCBLStatus().getCode() != Status.INTERNAL_SERVER_ERROR) { Map dict = new HashMap(); dict.put("missing", rev.getRevID()); result.add(dict); } else { throw e; } } Map dict = new HashMap(); dict.put("ok", rev.getProperties()); result.add(dict); } } else { // ?open_revs=[...] returns an array of revisions of the document: List openRevs = (List) getJSONQuery("open_revs"); if (openRevs == null) { return new Status(Status.BAD_REQUEST); } result = new ArrayList>(openRevs.size()); for (String revID : openRevs) { RevisionInternal rev = db.getDocument(docID, revID, true); if (rev != null) { Map dict = new HashMap(); dict.put("ok", rev.getProperties()); result.add(dict); } else { Map dict = new HashMap(); dict.put("missing", revID); result.add(dict); } } } String acceptMultipart = getMultipartRequestType(); if (acceptMultipart != null) { //FIXME figure out support for multipart throw new UnsupportedOperationException(); } else { connection.setResponseBody(new Body(result)); } } return new Status(Status.OK); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } } /** * in CBL_Router+Handlers.m * - (CBL_Revision*) applyOptions: (CBLContentOptions)options * toRevision: (CBL_Revision*)rev * status: (CBLStatus*)outStatus * 1.1 or earlier => Database.extraPropertiesForRevision() */ private RevisionInternal applyOptionsToRevision(EnumSet options, RevisionInternal rev) { if (options != null && ( options.contains(TDContentOptions.TDIncludeLocalSeq) || options.contains(TDContentOptions.TDIncludeRevs) || options.contains(TDContentOptions.TDIncludeRevsInfo) || options.contains(TDContentOptions.TDIncludeConflicts) || options.contains(TDContentOptions.TDBigAttachmentsFollow))) { Map dst = new HashMap(); dst.putAll(rev.getProperties()); Store store = db.getStore(); if (options.contains(TDContentOptions.TDIncludeLocalSeq)) { dst.put("_local_seq", rev.getSequence()); rev.setProperties(dst); } if (options.contains(TDContentOptions.TDIncludeRevs)) { List revs = db.getRevisionHistory(rev); Map historyDict = RevisionUtils.makeRevisionHistoryDict(revs); dst.put("_revisions", historyDict); rev.setProperties(dst); } if (options.contains(TDContentOptions.TDIncludeRevsInfo)) { List revsInfo = new ArrayList(); List revs = db.getRevisionHistory(rev); for (RevisionInternal historicalRev : revs) { Map revHistoryItem = new HashMap(); String status = "available"; if (historicalRev.isDeleted()) { status = "deleted"; } if (historicalRev.isMissing()) { status = "missing"; } revHistoryItem.put("rev", historicalRev.getRevID()); revHistoryItem.put("status", status); revsInfo.add(revHistoryItem); } dst.put("_revs_info", revsInfo); rev.setProperties(dst); } if (options.contains(TDContentOptions.TDIncludeConflicts)) { RevisionList revs = store.getAllRevisions(rev.getDocID(), true); if (revs.size() > 1) { List conflicts = new ArrayList(); for (RevisionInternal aRev : revs) { if (aRev.equals(rev) || aRev.isDeleted()) { // don't add in this case } else { conflicts.add(aRev.getRevID()); } } dst.put("_conflicts", conflicts); } rev.setProperties(dst); } if (options.contains(TDContentOptions.TDBigAttachmentsFollow)) { RevisionInternal nuRev = new RevisionInternal(dst); Status outStatus = new Status(Status.OK); if (!db.expandAttachments(nuRev, 0, false, getBooleanQuery("att_encoding_info"), outStatus)) return null; rev = nuRev; } } return rev; } public Status do_GET_Attachment(Database _db, String docID, String _attachmentName) { try { // http://wiki.apache.org/couchdb/HTTP_Document_API#GET EnumSet options = getContentOptions(); options.add(TDContentOptions.TDNoBody); String revID = getQuery("rev"); // often null RevisionInternal rev = db.getDocument(docID, revID, false); if (rev == null) { return new Status(Status.NOT_FOUND); } if (cacheWithEtag(rev.getRevID())) { return new Status(Status.NOT_MODIFIED); // set ETag and check conditional GET } String acceptEncoding = connection.getRequestProperty("accept-encoding"); // getAttachment is safe. this could be static method?? AttachmentInternal attachment = db.getAttachment(rev, _attachmentName); if (attachment == null) { return new Status(Status.NOT_FOUND); } String type = attachment.getContentType(); if (type != null) { connection.getResHeader().add("Content-Type", type); } if (acceptEncoding != null && acceptEncoding.contains("gzip") && attachment.getEncoding() == AttachmentInternal.AttachmentEncoding.AttachmentEncodingGZIP) { connection.getResHeader().add("Content-Encoding", "gzip"); } connection.setResponseInputStream(attachment.getContentInputStream()); return new Status(Status.OK); } catch (CouchbaseLiteException e) { return e.getCBLStatus(); } } /** * NOTE this departs from the iOS version, returning revision, passing status back by reference *

* - (CBLStatus) update: (CBLDatabase*)db * docID: (NSString*)docID * body: (CBL_Body*)body * deleting: (BOOL)deleting * allowConflict: (BOOL)allowConflict * createdRev: (CBL_Revision**)outRev * error: (NSError**)outError */ private RevisionInternal update(Database _db, String docID, Body body, boolean deleting, boolean allowConflict, Status outStatus) { if (body != null && !body.isValidJSON()) { outStatus.setCode(Status.BAD_JSON); return null; } boolean isLocalDoc = docID != null && docID.startsWith(("_local")); String prevRevID; if (!deleting) { Boolean deletingBoolean = (Boolean) body.getPropertyForKey("_deleted"); deleting = (deletingBoolean != null && deletingBoolean.booleanValue()); if (docID == null) { if (isLocalDoc) { outStatus.setCode(Status.METHOD_NOT_ALLOWED); return null; } // POST's doc ID may come from the _id field of the JSON body, else generate a random one. docID = (String) body.getPropertyForKey("_id"); if (docID == null) { if (deleting) { outStatus.setCode(Status.BAD_REQUEST); return null; } docID = Misc.CreateUUID(); } } // PUT's revision ID comes from the JSON body. prevRevID = (String) body.getPropertyForKey("_rev"); } else { // DELETE's revision ID comes from the ?rev= query param prevRevID = getQuery("rev"); } // A backup source of revision ID is an If-Match header: if (prevRevID == null) { prevRevID = getRevIDFromIfMatchHeader(); } RevisionInternal rev = new RevisionInternal(docID, null, deleting); rev.setBody(body); RevisionInternal result = null; try { if (isLocalDoc) { // NOTE: putLocalRevision() does not use transaction internally with obeyMVCC=true final Database fDb = _db; final RevisionInternal _rev = rev; final String _prevRevID = prevRevID; final List _revs = new ArrayList(); try { fDb.getStore().runInTransaction(new TransactionalTask() { @Override public boolean run() { try { RevisionInternal r = fDb.getStore().putLocalRevision(_rev, _prevRevID, true); _revs.add(r); return true; } catch (CouchbaseLiteException e) { throw new RuntimeException(e); } } }); // success if (_revs.size() > 0) result = _revs.get(0); } catch (RuntimeException ex) { if (ex.getCause() != null && ex.getCause().getCause() != null && ex.getCause().getCause() instanceof CouchbaseLiteException) throw (CouchbaseLiteException) ex.getCause().getCause(); else throw new CouchbaseLiteException(ex, Status.INTERNAL_SERVER_ERROR); } } else { // putRevision() uses transaction internally. result = _db.putRevision(rev, prevRevID, allowConflict); } if (deleting) { outStatus.setCode(Status.OK); } else { outStatus.setCode(Status.CREATED); } } catch (CouchbaseLiteException e) { if (e.getCBLStatus() != null && e.getCBLStatus().getCode() == Status.CONFLICT) { // conflict is not critical error for replicators, not print stack trace Log.w(Log.TAG_ROUTER, "Error updating doc: %s", docID); } else { Log.e(Log.TAG_ROUTER, "Error updating doc: %s", e, docID); } outStatus.setCode(e.getCBLStatus().getCode()); } return result; } /** * in CBL_Router+Handlers.m * - (CBLStatus) update: (CBLDatabase*)db * docID: (NSString*)docID * body: (CBL_Body*)body * deleting: (BOOL)deleting * error: (NSError**)outError */ private Status update(Database _db, String docID, Body body, boolean deleting) { Status status = new Status(); if (docID != null && docID.isEmpty() == false) { // On PUT/DELETE, get revision ID from either ?rev= query or doc body: String revParam = getQuery("rev"); String ifMatch = getRequestHeaderValue("If-Match"); if (ifMatch != null) { if(revParam == null) revParam = ifMatch; else if(!ifMatch.equals(revParam)) return new Status(Status.BAD_REQUEST); } if (revParam != null && body != null) { String revProp = (String) body.getProperties().get("_rev"); if (revProp == null) { // No _rev property in body, so use ?rev= query param instead: body.getProperties().put("_rev", revParam); //body = new Body(bodyDict); } else if (!revParam.equals(revProp)) { throw new IllegalArgumentException("Mismatch between _rev and rev"); } } } RevisionInternal rev = update(_db, docID, body, deleting, false, status); if (status.isSuccessful()) { cacheWithEtag(rev.getRevID()); // set ETag if (!deleting) { URL url = connection.getURL(); String urlString = url.toExternalForm(); if (docID != null) { urlString += '/' + rev.getDocID(); try { url = new URL(urlString); } catch (MalformedURLException e) { Log.w("Malformed URL", e); } } setResponseLocation(url); } Map result = new HashMap(); result.put("ok", true); result.put("id", rev.getDocID()); result.put("rev", rev.getRevID()); connection.setResponseBody(new Body(result)); } return status; } public Status do_PUT_Document(Database _db, String docID, String _attachmentName) throws CouchbaseLiteException { Status status = new Status(Status.CREATED); Map body = getBodyAsDictionary(); if (body == null) { throw new CouchbaseLiteException(Status.BAD_REQUEST); } if (getQuery("new_edits") == null || (getQuery("new_edits") != null && (Boolean.valueOf(getQuery("new_edits"))))) { // Regular PUT status = update(_db, docID, new Body(body), false); } else { // PUT with new_edits=false -- forcible insertion of existing revision: Body revBody = new Body(body); RevisionInternal rev = new RevisionInternal(revBody); if (rev.getRevID() == null || rev.getDocID() == null || !rev.getDocID().equals(docID)) { throw new CouchbaseLiteException(Status.BAD_REQUEST); } List history = Database.parseCouchDBRevisionHistory(revBody.getProperties()); // forceInsert uses transaction internally, not necessary to apply synchronized db.forceInsert(rev, history, source); } return status; } public Status do_DELETE_Document(Database _db, String docID, String _attachmentName) { return update(_db, docID, null, true); } private Status updateAttachment(String attachment, String docID, InputStream contentStream) throws CouchbaseLiteException { Status status = new Status(Status.OK); String revID = getQuery("rev"); if (revID == null) { revID = getRevIDFromIfMatchHeader(); } BlobStoreWriter body = new BlobStoreWriter(db.getAttachmentStore()); ByteArrayOutputStream dataStream = new ByteArrayOutputStream(); try { StreamUtils.copyStream(contentStream, dataStream); body.appendData(dataStream.toByteArray()); body.finish(); } catch (Exception e) { throw new CouchbaseLiteException(e.getCause(), Status.BAD_ATTACHMENT); } // updateAttachment uses transaction internally, not necessary to be synchronized RevisionInternal rev = db.updateAttachment( attachment, body, getRequestHeaderContentType(), AttachmentInternal.AttachmentEncoding.AttachmentEncodingNone, docID, revID, null); Map resultDict = new HashMap(); resultDict.put("ok", true); resultDict.put("id", rev.getDocID()); resultDict.put("rev", rev.getRevID()); connection.setResponseBody(new Body(resultDict)); cacheWithEtag(rev.getRevID()); if (contentStream != null) { setResponseLocation(connection.getURL()); } return status; } public Status do_PUT_Attachment(Database _db, String docID, String _attachmentName) throws CouchbaseLiteException { return updateAttachment(_attachmentName, docID, connection.getRequestInputStream()); } public Status do_DELETE_Attachment(Database _db, String docID, String _attachmentName) throws CouchbaseLiteException { return updateAttachment(_attachmentName, docID, null); } /** * VIEW QUERIES: * */ private View compileView(String viewName, Map viewProps) { String language = (String) viewProps.get("language"); if (language == null) { language = "javascript"; } String mapSource = (String) viewProps.get("map"); if (mapSource == null) { return null; } Mapper mapBlock = View.getCompiler().compileMap(mapSource, language); if (mapBlock == null) { Log.w(Log.TAG_ROUTER, "View %s has unknown map function: %s", viewName, mapSource); return null; } String reduceSource = (String) viewProps.get("reduce"); Reducer reduceBlock = null; if (reduceSource != null) { reduceBlock = View.getCompiler().compileReduce(reduceSource, language); if (reduceBlock == null) { Log.w(Log.TAG_ROUTER, "View %s has unknown reduce function: %s", viewName, reduceBlock); return null; } } View view = db.getView(viewName); view.setMapReduce(mapBlock, reduceBlock, "1"); String collation = (String) viewProps.get("collation"); if ("raw".equals(collation)) { view.setCollation(View.TDViewCollation.TDViewCollationRaw); } return view; } private Status queryDesignDoc(String designDoc, String viewName, List keys) throws CouchbaseLiteException { String tdViewName = String.format("%s/%s", designDoc, viewName); // getExistingView is not thread-safe, but not access to db. In the database, it should protect instance variable View view = db.getExistingView(tdViewName); if (view == null || view.getMap() == null) { // No TouchDB view is defined, or it hasn't had a map block assigned; // see if there's a CouchDB view definition we can compile: RevisionInternal rev = db.getDocument(String.format("_design/%s", designDoc), null, true); if (rev == null) { return new Status(Status.NOT_FOUND); } Map views = (Map) rev.getProperties().get("views"); Map viewProps = (Map) views.get(viewName); if (viewProps == null) { return new Status(Status.NOT_FOUND); } // If there is a CouchDB view, see if it can be compiled from source: view = compileView(tdViewName, viewProps); if (view == null) { return new Status(Status.INTERNAL_SERVER_ERROR); } } QueryOptions options = new QueryOptions(); //if the view contains a reduce block, it should default to reduce=true if (view.getReduce() != null) { options.setReduce(true); } if (!getQueryOptions(options)) { return new Status(Status.BAD_REQUEST); } if (keys != null) { options.setKeys(keys); } // updateIndex() uses transaction internally, not necessary to apply syncrhonized. view.updateIndex(); long lastSequenceIndexed = view.getLastSequenceIndexed(); // Check for conditional GET and set response Etag header: if (keys == null) { long eTag = options.isIncludeDocs() ? db.getLastSequenceNumber() : lastSequenceIndexed; if (cacheWithEtag(String.format("%d", eTag))) { return new Status(Status.NOT_MODIFIED); } } // convert from QueryRow -> Map List queryRows = view.query(options); List> rows = new ArrayList>(); for (QueryRow queryRow : queryRows) { rows.add(queryRow.asJSONDictionary()); } Map responseBody = new HashMap(); responseBody.put("rows", rows); responseBody.put("total_rows", view.getCurrentTotalRows()); responseBody.put("offset", options.getSkip()); if (options.isUpdateSeq()) { responseBody.put("update_seq", lastSequenceIndexed); } connection.setResponseBody(new Body(responseBody)); return new Status(Status.OK); } public Status do_GET_DesignDocument(Database _db, String designDocID, String viewName) throws CouchbaseLiteException { return queryDesignDoc(designDocID, viewName, null); } public Status do_POST_DesignDocument(Database _db, String designDocID, String viewName) throws CouchbaseLiteException { Map body = getBodyAsDictionary(); if (body == null) { return new Status(Status.BAD_REQUEST); } List keys = (List) body.get("keys"); return queryDesignDoc(designDocID, viewName, keys); } public void setSource(URL source) { this.source = source; } @Override public String toString() { String url = "Unknown"; if (connection != null && connection.getURL() != null) { url = connection.getURL().toExternalForm(); } return String.format("Router [%s]", url); } }