blob: b018aa789cc0375cf0414611cc1fca731e60e1ac [file] [log] [blame]
var CHANGES_BATCH_SIZE = 25;
var MAX_SIMULTANEOUS_REVS = 50;
var supportsBulkGetMap = {};
// according to http://stackoverflow.com/a/417184/680742,
// the de facto URL length limit is 2000 characters.
// but since most of our measurements don't take the full
// URL into account, we fudge it a bit.
// TODO: we could measure the full URL to enforce exactly 2000 chars
var MAX_URL_LENGTH = 1800;
import jsExtend from 'js-extend'; var extend = jsExtend.extend;
import Promise from 'pouchdb-promise';
import ajaxCore from 'pouchdb-ajax';
import getArguments from 'argsarray';
import {
pick,
filterChange,
adapterFun as coreAdapterFun,
explainError,
binaryStringToBlobOrBuffer as binStringToBluffer,
base64StringToBlobOrBuffer as b64StringToBluffer,
clone,
parseUri,
atob,
btoa,
blobOrBufferToBase64 as blufferToBase64,
bulkGetShim,
flatten
} from 'pouchdb-utils';
import { createError, BAD_ARG } from 'pouchdb-errors';
import debug from 'debug';
var log = debug('pouchdb:http');
function readAttachmentsAsBlobOrBuffer(row) {
var atts = row.doc && row.doc._attachments;
if (!atts) {
return;
}
Object.keys(atts).forEach(function (filename) {
var att = atts[filename];
att.data = b64StringToBluffer(att.data, att.content_type);
});
}
function encodeDocId(id) {
if (/^_design/.test(id)) {
return '_design/' + encodeURIComponent(id.slice(8));
}
if (/^_local/.test(id)) {
return '_local/' + encodeURIComponent(id.slice(7));
}
return encodeURIComponent(id);
}
function preprocessAttachments(doc) {
if (!doc._attachments || !Object.keys(doc._attachments)) {
return Promise.resolve();
}
return Promise.all(Object.keys(doc._attachments).map(function (key) {
var attachment = doc._attachments[key];
if (attachment.data && typeof attachment.data !== 'string') {
return blufferToBase64(attachment.data).then(function (b64) {
attachment.data = b64;
});
}
}));
}
// Get all the information you possibly can about the URI given by name and
// return it as a suitable object.
function getHost(name) {
// Prase the URI into all its little bits
var uri = parseUri(name);
// Store the user and password as a separate auth object
if (uri.user || uri.password) {
uri.auth = {username: uri.user, password: uri.password};
}
// Split the path part of the URI into parts using '/' as the delimiter
// after removing any leading '/' and any trailing '/'
var parts = uri.path.replace(/(^\/|\/$)/g, '').split('/');
// Store the first part as the database name and remove it from the parts
// array
uri.db = parts.pop();
// Prevent double encoding of URI component
if (uri.db.indexOf('%') === -1) {
uri.db = encodeURIComponent(uri.db);
}
// Restore the path by joining all the remaining parts (all the parts
// except for the database name) with '/'s
uri.path = parts.join('/');
return uri;
}
// Generate a URL with the host data given by opts and the given path
function genDBUrl(opts, path) {
return genUrl(opts, opts.db + '/' + path);
}
// Generate a URL with the host data given by opts and the given path
function genUrl(opts, path) {
// If the host already has a path, then we need to have a path delimiter
// Otherwise, the path delimiter is the empty string
var pathDel = !opts.path ? '' : '/';
// If the host already has a path, then we need to have a path delimiter
// Otherwise, the path delimiter is the empty string
return opts.protocol + '://' + opts.host +
(opts.port ? (':' + opts.port) : '') +
'/' + opts.path + pathDel + path;
}
function paramsToStr(params) {
return '?' + Object.keys(params).map(function (k) {
return k + '=' + encodeURIComponent(params[k]);
}).join('&');
}
// Implements the PouchDB API for dealing with CouchDB instances over HTTP
function HttpPouch(opts, callback) {
// The functions that will be publicly available for HttpPouch
var api = this;
// Parse the URI given by opts.name into an easy-to-use object
var getHostFun = getHost;
// TODO: this seems to only be used by yarong for the Thali project.
// Verify whether or not it's still needed.
/* istanbul ignore if */
if (opts.getHost) {
getHostFun = opts.getHost;
}
var host = getHostFun(opts.name, opts);
var dbUrl = genDBUrl(host, '');
opts = clone(opts);
var ajaxOpts = opts.ajax || {};
api.getUrl = function () { return dbUrl; };
api.getHeaders = function () { return ajaxOpts.headers || {}; };
if (opts.auth || host.auth) {
var nAuth = opts.auth || host.auth;
var str = nAuth.username + ':' + nAuth.password;
var token = btoa(unescape(encodeURIComponent(str)));
ajaxOpts.headers = ajaxOpts.headers || {};
ajaxOpts.headers.Authorization = 'Basic ' + token;
}
// Not strictly necessary, but we do this because numerous tests
// rely on swapping ajax in and out.
api._ajax = ajaxCore;
function ajax(userOpts, options, callback) {
var reqAjax = userOpts.ajax || {};
var reqOpts = extend(clone(ajaxOpts), reqAjax, options);
log(reqOpts.method + ' ' + reqOpts.url);
return api._ajax(reqOpts, callback);
}
function ajaxPromise(userOpts, opts) {
return new Promise(function (resolve, reject) {
ajax(userOpts, opts, function (err, res) {
if (err) {
return reject(err);
}
resolve(res);
});
});
}
function adapterFun(name, fun) {
return coreAdapterFun(name, getArguments(function (args) {
setup().then(function () {
return fun.apply(this, args);
}).catch(function (e) {
var callback = args.pop();
callback(e);
});
}));
}
var setupPromise;
function setup() {
// TODO: Remove `skipSetup` in favor of `skip_setup` in a future release
if (opts.skipSetup || opts.skip_setup) {
return Promise.resolve();
}
// If there is a setup in process or previous successful setup
// done then we will use that
// If previous setups have been rejected we will try again
if (setupPromise) {
return setupPromise;
}
var checkExists = {method: 'GET', url: dbUrl};
setupPromise = ajaxPromise({}, checkExists).catch(function (err) {
if (err && err.status && err.status === 404) {
// Doesnt exist, create it
explainError(404, 'PouchDB is just detecting if the remote exists.');
return ajaxPromise({}, {method: 'PUT', url: dbUrl});
} else {
return Promise.reject(err);
}
}).catch(function (err) {
// If we try to create a database that already exists
if (err && err.status && err.status === 412) {
return true;
}
return Promise.reject(err);
});
setupPromise.catch(function () {
setupPromise = null;
});
return setupPromise;
}
setTimeout(function () {
callback(null, api);
});
api.type = function () {
return 'http';
};
api.id = adapterFun('id', function (callback) {
ajax({}, {method: 'GET', url: genUrl(host, '')}, function (err, result) {
var uuid = (result && result.uuid) ?
(result.uuid + host.db) : genDBUrl(host, '');
callback(null, uuid);
});
});
api.request = adapterFun('request', function (options, callback) {
options.url = genDBUrl(host, options.url);
ajax({}, options, callback);
});
// Sends a POST request to the host calling the couchdb _compact function
// version: The version of CouchDB it is running
api.compact = adapterFun('compact', function (opts, callback) {
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
opts = clone(opts);
ajax(opts, {
url: genDBUrl(host, '_compact'),
method: 'POST'
}, function () {
function ping() {
api.info(function (err, res) {
if (res && !res.compact_running) {
callback(null, {ok: true});
} else {
setTimeout(ping, opts.interval || 200);
}
});
}
// Ping the http if it's finished compaction
ping();
});
});
api.bulkGet = coreAdapterFun('bulkGet', function (opts, callback) {
var self = this;
function doBulkGet(cb) {
var params = {};
if (opts.revs) {
params.revs = true;
}
if (opts.attachments) {
params.attachments = true;
}
ajax({}, {
url: genDBUrl(host, '_bulk_get' + paramsToStr(params)),
method: 'POST',
body: { docs: opts.docs}
}, cb);
}
function doBulkGetShim() {
// avoid "url too long error" by splitting up into multiple requests
var batchSize = MAX_SIMULTANEOUS_REVS;
var numBatches = Math.ceil(opts.docs.length / batchSize);
var numDone = 0;
var results = new Array(numBatches);
function onResult(batchNum) {
return function (err, res) {
// err is impossible because shim returns a list of errs in that case
results[batchNum] = res.results;
if (++numDone === numBatches) {
callback(null, {results: flatten(results)});
}
};
}
for (var i = 0; i < numBatches; i++) {
var subOpts = pick(opts, ['revs', 'attachments']);
subOpts.ajax = ajaxOpts;
subOpts.docs = opts.docs.slice(i * batchSize,
Math.min(opts.docs.length, (i + 1) * batchSize));
bulkGetShim(self, subOpts, onResult(i));
}
}
// mark the whole database as either supporting or not supporting _bulk_get
var dbUrl = genUrl(host, '');
var supportsBulkGet = supportsBulkGetMap[dbUrl];
if (typeof supportsBulkGet !== 'boolean') {
// check if this database supports _bulk_get
doBulkGet(function (err, res) {
/* istanbul ignore else */
if (err) {
var status = Math.floor(err.status / 100);
/* istanbul ignore else */
if (status === 4 || status === 5) { // 40x or 50x
supportsBulkGetMap[dbUrl] = false;
explainError(
err.status,
'PouchDB is just detecting if the remote ' +
'supports the _bulk_get API.'
);
doBulkGetShim();
} else {
callback(err);
}
} else {
supportsBulkGetMap[dbUrl] = true;
callback(null, res);
}
});
} else if (supportsBulkGet) {
/* istanbul ignore next */
doBulkGet(callback);
} else {
doBulkGetShim();
}
});
// Calls GET on the host, which gets back a JSON string containing
// couchdb: A welcome string
// version: The version of CouchDB it is running
api._info = function (callback) {
setup().then(function () {
ajax({}, {
method: 'GET',
url: genDBUrl(host, '')
}, function (err, res) {
/* istanbul ignore next */
if (err) {
return callback(err);
}
res.host = genDBUrl(host, '');
callback(null, res);
});
}).catch(callback);
};
// Get the document with the given id from the database given by host.
// The id could be solely the _id in the database, or it may be a
// _design/ID or _local/ID path
api.get = adapterFun('get', function (id, opts, callback) {
// If no options were given, set the callback to the second parameter
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
opts = clone(opts);
// List of parameters to add to the GET request
var params = {};
if (opts.revs) {
params.revs = true;
}
if (opts.revs_info) {
params.revs_info = true;
}
if (opts.open_revs) {
if (opts.open_revs !== "all") {
opts.open_revs = JSON.stringify(opts.open_revs);
}
params.open_revs = opts.open_revs;
}
if (opts.rev) {
params.rev = opts.rev;
}
if (opts.conflicts) {
params.conflicts = opts.conflicts;
}
id = encodeDocId(id);
// Set the options for the ajax call
var options = {
method: 'GET',
url: genDBUrl(host, id + paramsToStr(params))
};
function fetchAttachments(doc) {
var atts = doc._attachments;
var filenames = atts && Object.keys(atts);
if (!atts || !filenames.length) {
return;
}
// we fetch these manually in separate XHRs, because
// Sync Gateway would normally send it back as multipart/mixed,
// which we cannot parse. Also, this is more efficient than
// receiving attachments as base64-encoded strings.
return Promise.all(filenames.map(function (filename) {
var att = atts[filename];
var path = encodeDocId(doc._id) + '/' + encodeAttachmentId(filename) +
'?rev=' + doc._rev;
return ajaxPromise(opts, {
method: 'GET',
url: genDBUrl(host, path),
binary: true
}).then(function (blob) {
if (opts.binary) {
return blob;
}
return blufferToBase64(blob);
}).then(function (data) {
delete att.stub;
delete att.length;
att.data = data;
});
}));
}
function fetchAllAttachments(docOrDocs) {
if (Array.isArray(docOrDocs)) {
return Promise.all(docOrDocs.map(function (doc) {
if (doc.ok) {
return fetchAttachments(doc.ok);
}
}));
}
return fetchAttachments(docOrDocs);
}
ajaxPromise(opts, options).then(function (res) {
return Promise.resolve().then(function () {
if (opts.attachments) {
return fetchAllAttachments(res);
}
}).then(function () {
callback(null, res);
});
}).catch(callback);
});
// Delete the document given by doc from the database given by host.
api.remove = adapterFun('remove',
function (docOrId, optsOrRev, opts, callback) {
var doc;
if (typeof optsOrRev === 'string') {
// id, rev, opts, callback style
doc = {
_id: docOrId,
_rev: optsOrRev
};
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
} else {
// doc, opts, callback style
doc = docOrId;
if (typeof optsOrRev === 'function') {
callback = optsOrRev;
opts = {};
} else {
callback = opts;
opts = optsOrRev;
}
}
var rev = (doc._rev || opts.rev);
// Delete the document
ajax(opts, {
method: 'DELETE',
url: genDBUrl(host, encodeDocId(doc._id)) + '?rev=' + rev
}, callback);
});
function encodeAttachmentId(attachmentId) {
return attachmentId.split("/").map(encodeURIComponent).join("/");
}
// Get the attachment
api.getAttachment =
adapterFun('getAttachment', function (docId, attachmentId, opts,
callback) {
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
var params = opts.rev ? ('?rev=' + opts.rev) : '';
var url = genDBUrl(host, encodeDocId(docId)) + '/' +
encodeAttachmentId(attachmentId) + params;
ajax(opts, {
method: 'GET',
url: url,
binary: true
}, callback);
});
// Remove the attachment given by the id and rev
api.removeAttachment =
adapterFun('removeAttachment', function (docId, attachmentId, rev,
callback) {
var url = genDBUrl(host, encodeDocId(docId) + '/' +
encodeAttachmentId(attachmentId)) + '?rev=' + rev;
ajax({}, {
method: 'DELETE',
url: url
}, callback);
});
// Add the attachment given by blob and its contentType property
// to the document with the given id, the revision given by rev, and
// add it to the database given by host.
api.putAttachment =
adapterFun('putAttachment', function (docId, attachmentId, rev, blob,
type, callback) {
if (typeof type === 'function') {
callback = type;
type = blob;
blob = rev;
rev = null;
}
var id = encodeDocId(docId) + '/' + encodeAttachmentId(attachmentId);
var url = genDBUrl(host, id);
if (rev) {
url += '?rev=' + rev;
}
if (typeof blob === 'string') {
// input is assumed to be a base64 string
var binary;
try {
binary = atob(blob);
} catch (err) {
return callback(createError(BAD_ARG,
'Attachment is not a valid base64 string'));
}
blob = binary ? binStringToBluffer(binary, type) : '';
}
var opts = {
headers: {'Content-Type': type},
method: 'PUT',
url: url,
processData: false,
body: blob,
timeout: ajaxOpts.timeout || 60000
};
// Add the attachment
ajax({}, opts, callback);
});
// Update/create multiple documents given by req in the database
// given by host.
api._bulkDocs = function (req, opts, callback) {
// If new_edits=false then it prevents the database from creating
// new revision numbers for the documents. Instead it just uses
// the old ones. This is used in database replication.
req.new_edits = opts.new_edits;
setup().then(function () {
return Promise.all(req.docs.map(preprocessAttachments));
}).then(function () {
// Update/create the documents
ajax(opts, {
method: 'POST',
url: genDBUrl(host, '_bulk_docs'),
body: req
}, function (err, results) {
if (err) {
return callback(err);
}
results.forEach(function (result) {
result.ok = true; // smooths out cloudant not adding this
});
callback(null, results);
});
}).catch(callback);
};
// Get a listing of the documents in the database given
// by host and ordered by increasing id.
api.allDocs = adapterFun('allDocs', function (opts, callback) {
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
opts = clone(opts);
// List of parameters to add to the GET request
var params = {};
var body;
var method = 'GET';
if (opts.conflicts) {
params.conflicts = true;
}
if (opts.descending) {
params.descending = true;
}
if (opts.include_docs) {
params.include_docs = true;
}
// added in CouchDB 1.6.0
if (opts.attachments) {
params.attachments = true;
}
if (opts.key) {
params.key = JSON.stringify(opts.key);
}
if (opts.start_key) {
opts.startkey = opts.start_key;
}
if (opts.startkey) {
params.startkey = JSON.stringify(opts.startkey);
}
if (opts.end_key) {
opts.endkey = opts.end_key;
}
if (opts.endkey) {
params.endkey = JSON.stringify(opts.endkey);
}
if (typeof opts.inclusive_end !== 'undefined') {
params.inclusive_end = !!opts.inclusive_end;
}
if (typeof opts.limit !== 'undefined') {
params.limit = opts.limit;
}
if (typeof opts.skip !== 'undefined') {
params.skip = opts.skip;
}
var paramStr = paramsToStr(params);
if (typeof opts.keys !== 'undefined') {
var keysAsString =
'keys=' + encodeURIComponent(JSON.stringify(opts.keys));
if (keysAsString.length + paramStr.length + 1 <= MAX_URL_LENGTH) {
// If the keys are short enough, do a GET. we do this to work around
// Safari not understanding 304s on POSTs (see issue #1239)
paramStr += '&' + keysAsString;
} else {
// If keys are too long, issue a POST request to circumvent GET
// query string limits
// see http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
method = 'POST';
body = {keys: opts.keys};
}
}
// Get the document listing
ajaxPromise(opts, {
method: method,
url: genDBUrl(host, '_all_docs' + paramStr),
body: body
}).then(function (res) {
if (opts.include_docs && opts.attachments && opts.binary) {
res.rows.forEach(readAttachmentsAsBlobOrBuffer);
}
callback(null, res);
}).catch(callback);
});
// Get a list of changes made to documents in the database given by host.
// TODO According to the README, there should be two other methods here,
// api.changes.addListener and api.changes.removeListener.
api._changes = function (opts) {
// We internally page the results of a changes request, this means
// if there is a large set of changes to be returned we can start
// processing them quicker instead of waiting on the entire
// set of changes to return and attempting to process them at once
var batchSize = 'batch_size' in opts ? opts.batch_size : CHANGES_BATCH_SIZE;
opts = clone(opts);
opts.timeout = ('timeout' in opts) ? opts.timeout :
('timeout' in ajaxOpts) ? ajaxOpts.timeout :
30 * 1000;
// We give a 5 second buffer for CouchDB changes to respond with
// an ok timeout (if a timeout it set)
var params = opts.timeout ? {timeout: opts.timeout - (5 * 1000)} : {};
var limit = (typeof opts.limit !== 'undefined') ? opts.limit : false;
var returnDocs;
if ('return_docs' in opts) {
returnDocs = opts.return_docs;
} else if ('returnDocs' in opts) {
// TODO: Remove 'returnDocs' in favor of 'return_docs' in a future release
returnDocs = opts.returnDocs;
} else {
returnDocs = true;
}
//
var leftToFetch = limit;
if (opts.style) {
params.style = opts.style;
}
if (opts.include_docs || opts.filter && typeof opts.filter === 'function') {
params.include_docs = true;
}
if (opts.attachments) {
params.attachments = true;
}
if (opts.continuous) {
params.feed = 'longpoll';
}
if (opts.conflicts) {
params.conflicts = true;
}
if (opts.descending) {
params.descending = true;
}
if ('heartbeat' in opts) {
// If the heartbeat value is false, it disables the default heartbeat
if (opts.heartbeat) {
params.heartbeat = opts.heartbeat;
}
} else {
// Default heartbeat to 10 seconds
params.heartbeat = 10000;
}
if (opts.filter && typeof opts.filter === 'string') {
params.filter = opts.filter;
if (opts.filter === '_view' &&
opts.view &&
typeof opts.view === 'string') {
params.view = opts.view;
}
}
// If opts.query_params exists, pass it through to the changes request.
// These parameters may be used by the filter on the source database.
if (opts.query_params && typeof opts.query_params === 'object') {
for (var param_name in opts.query_params) {
/* istanbul ignore else */
if (opts.query_params.hasOwnProperty(param_name)) {
params[param_name] = opts.query_params[param_name];
}
}
}
var method = 'GET';
var body;
if (opts.doc_ids) {
// set this automagically for the user; it's annoying that couchdb
// requires both a "filter" and a "doc_ids" param.
params.filter = '_doc_ids';
var docIdsJson = JSON.stringify(opts.doc_ids);
if (docIdsJson.length < MAX_URL_LENGTH) {
params.doc_ids = docIdsJson;
} else {
// anything greater than ~2000 is unsafe for gets, so
// use POST instead
method = 'POST';
body = {doc_ids: opts.doc_ids };
}
}
var xhr;
var lastFetchedSeq;
// Get all the changes starting wtih the one immediately after the
// sequence number given by since.
var fetch = function (since, callback) {
if (opts.aborted) {
return;
}
params.since = since;
// "since" can be any kind of json object in Coudant/CouchDB 2.x
/* istanbul ignore next */
if (typeof params.since === "object") {
params.since = JSON.stringify(params.since);
}
if (opts.descending) {
if (limit) {
params.limit = leftToFetch;
}
} else {
params.limit = (!limit || leftToFetch > batchSize) ?
batchSize : leftToFetch;
}
// Set the options for the ajax call
var xhrOpts = {
method: method,
url: genDBUrl(host, '_changes' + paramsToStr(params)),
timeout: opts.timeout,
body: body
};
lastFetchedSeq = since;
/* istanbul ignore if */
if (opts.aborted) {
return;
}
// Get the changes
setup().then(function () {
xhr = ajax(opts, xhrOpts, callback);
}).catch(callback);
};
// If opts.since exists, get all the changes from the sequence
// number given by opts.since. Otherwise, get all the changes
// from the sequence number 0.
var results = {results: []};
var fetched = function (err, res) {
if (opts.aborted) {
return;
}
var raw_results_length = 0;
// If the result of the ajax call (res) contains changes (res.results)
if (res && res.results) {
raw_results_length = res.results.length;
results.last_seq = res.last_seq;
// For each change
var req = {};
req.query = opts.query_params;
res.results = res.results.filter(function (c) {
leftToFetch--;
var ret = filterChange(opts)(c);
if (ret) {
if (opts.include_docs && opts.attachments && opts.binary) {
readAttachmentsAsBlobOrBuffer(c);
}
if (returnDocs) {
results.results.push(c);
}
opts.onChange(c);
}
return ret;
});
} else if (err) {
// In case of an error, stop listening for changes and call
// opts.complete
opts.aborted = true;
opts.complete(err);
return;
}
// The changes feed may have timed out with no results
// if so reuse last update sequence
if (res && res.last_seq) {
lastFetchedSeq = res.last_seq;
}
var finished = (limit && leftToFetch <= 0) ||
(res && raw_results_length < batchSize) ||
(opts.descending);
if ((opts.continuous && !(limit && leftToFetch <= 0)) || !finished) {
// Queue a call to fetch again with the newest sequence number
setTimeout(function () { fetch(lastFetchedSeq, fetched); }, 0);
} else {
// We're done, call the callback
opts.complete(null, results);
}
};
fetch(opts.since || 0, fetched);
// Return a method to cancel this method from processing any more
return {
cancel: function () {
opts.aborted = true;
if (xhr) {
xhr.abort();
}
}
};
};
// Given a set of document/revision IDs (given by req), tets the subset of
// those that do NOT correspond to revisions stored in the database.
// See http://wiki.apache.org/couchdb/HttpPostRevsDiff
api.revsDiff = adapterFun('revsDiff', function (req, opts, callback) {
// If no options were given, set the callback to be the second parameter
if (typeof opts === 'function') {
callback = opts;
opts = {};
}
// Get the missing document/revision IDs
ajax(opts, {
method: 'POST',
url: genDBUrl(host, '_revs_diff'),
body: req
}, callback);
});
api._close = function (callback) {
callback();
};
api._destroy = function (options, callback) {
ajax(options, {
url: genDBUrl(host, ''),
method: 'DELETE'
}, function (err, resp) {
if (err && err.status && err.status !== 404) {
return callback(err);
}
api.emit('destroyed');
api.constructor.emit('destroyed', opts.name);
callback(null, resp);
});
};
}
// HttpPouch is a valid adapter.
HttpPouch.valid = function () {
return true;
};
export default function (PouchDB) {
PouchDB.adapter('http', HttpPouch, false);
PouchDB.adapter('https', HttpPouch, false);
}