Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
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);
}
}