Source: NodeApiClient.js

"use strict";

// Tell JSHint about EventSource
/*global
	EventSource
*/

// Import external names locally
var Shared = require('./Shared'),
	Core,
	CoreInit,
	Collection,
	NodeApiClient,
	Overload;

/**
 * The NodeApiClient class.
 * @class
 * @constructor
 */
NodeApiClient = function () {
	this.init.apply(this, arguments);
};

/**
 * The init method that can be overridden or extended.
 * @param {Core} core The ForerunnerDB core instance.
 */
NodeApiClient.prototype.init = function (core) {
	var self = this;
	self._core = core;

	self.rootPath('/fdb');
};

Shared.addModule('NodeApiClient', NodeApiClient);
Shared.mixin(NodeApiClient.prototype, 'Mixin.Common');
Shared.mixin(NodeApiClient.prototype, 'Mixin.Events');
Shared.mixin(NodeApiClient.prototype, 'Mixin.ChainReactor');

Core = Shared.modules.Core;
CoreInit = Core.prototype.init;
Collection = Shared.modules.Collection;
Overload = Shared.overload;

/**
 * Gets / sets the rootPath for the client to use in each API call.
 * @name rootPath
 * @method NodeApiClient.rootPath
 * @param {String=} val The path to set.
 * @returns {*}
 */
Shared.synthesize(NodeApiClient.prototype, 'rootPath');

/**
 * Set the url of the server to use for API.
 * @param {String} host The server host name including protocol. E.g.
 * "https://0.0.0.0".
 * @param {String} port The server port number e.g. "8080".
 */
NodeApiClient.prototype.server = function (host, port) {
	if (host !== undefined) {
		if (host.substr(host.length - 1, 1) === '/') {
			// Strip trailing /
			host = host.substr(0, host.length - 1);
		}

		if (port !== undefined) {
			this._server = host + ":" + port;
		} else {
			this._server = host;
		}

		this._host = host;
		this._port = port;

		return this;
	}

	if (port !== undefined) {
		return {
			host: this._host,
			port: this._port,
			url: this._server
		};
	} else {
		return this._server;
	}
};

NodeApiClient.prototype.http = function (method, url, data, options, callback) {
	var self = this,
		finalUrl,
		sessionData,
		bodyData,
		xmlHttp = new XMLHttpRequest();

	method = method.toUpperCase();

	xmlHttp.onreadystatechange = function () {
		if (xmlHttp.readyState === 4) {
			if (xmlHttp.status === 200) {
				// Tell the callback about success
				if (xmlHttp.responseText) {
					callback(false, self.jParse(xmlHttp.responseText));
				} else {
					callback(false, {});
				}
			} else if (xmlHttp.status === 204) {
				callback(false, {});
			} else {
				// Tell the callback about the error
				callback(xmlHttp.status, xmlHttp.responseText);

				// Emit the error code
				self.emit('httpError', xmlHttp.status, xmlHttp.responseText);
			}
		}
	};

	switch (method) {
		case 'GET':
		case 'DELETE':
		case 'HEAD':
			// Check for global auth
			if (this._sessionData) {
				data = data !== undefined ? data : {};

				// Add the session data to the key specified
				data[this._sessionData.key] = this._sessionData.obj;
			}

			finalUrl = url + (data !== undefined ? '?' + self.jStringify(data) : '');
			bodyData = null;
			break;

		case 'POST':
		case 'PUT':
		case 'PATCH':
			// Check for global auth
			if (this._sessionData) {
				sessionData = {};

				// Add the session data to the key specified
				sessionData[this._sessionData.key] = this._sessionData.obj;
			}

			finalUrl = url + (sessionData !== undefined ? '?' + self.jStringify(sessionData) : '');
			bodyData = (data !== undefined ? self.jStringify(data) : null);
			break;

		default:
			return false;
	}


	xmlHttp.open(method, finalUrl, true);
	xmlHttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
	xmlHttp.send(bodyData);

	return this;
};

// Define HTTP helper methods
NodeApiClient.prototype.head = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('HEAD', this.server() + this._rootPath + path, data, options, callback);
	}
});

NodeApiClient.prototype.get = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('GET', this.server() + this._rootPath + path, data, options, callback);
	}
});

NodeApiClient.prototype.put = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('PUT', this.server() + this._rootPath + path, data, options, callback);
	}
});

NodeApiClient.prototype.post = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('POST', this.server() + this._rootPath + path, data, options, callback);
	}
});

NodeApiClient.prototype.patch = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('PATCH', this.server() + this._rootPath + path, data, options, callback);
	}
});

NodeApiClient.prototype.postPatch = function (path, id, data, options, callback) {
	var self = this;

	// Determine if the item exists or not
	this.head(path + '/' + id, undefined, {}, function (err, headData) {
		if (err) {
			if (err === 404) {
				// Item does not exist, run post
				return self.http('POST', self.server() + self._rootPath + path, data, options, callback);
			} else {
				callback(err, data);
			}
		} else {
			// Item already exists, run patch
			return self.http('PATCH', self.server() + self._rootPath + path + '/' + id, data, options, callback);
		}
	});
};

NodeApiClient.prototype.delete = new Overload({
	'string, function': function (path, callback) {
		return this.$main.call(this, path, undefined, {}, callback);
	},

	'string, *, function': function (path, data, callback) {
		return this.$main.call(this, path, data, {}, callback);
	},

	'string, *, object, function': function (path, data, options, callback) {
		return this.$main.call(this, path, data, options, callback);
	},

	'$main': function (path, data, options, callback) {
		return this.http('DELETE', this.server() + this._rootPath + path, data, options, callback);
	}
});

/**
 * Gets/ sets a global object that will be sent up with client
 * requests to the API or REST server.
 * @param {String} key The key to send the session object up inside.
 * @param {*} obj The object / value to send up with all requests. If
 * a request has its own data to send up, this session data will be
 * mixed in to the request data under the specified key.
 */
NodeApiClient.prototype.session = function (key, obj) {
	if (key !== undefined && obj !== undefined) {
		this._sessionData = {
			key: key,
			obj: obj
		};
		return this;
	}

	return this._sessionData;
};

/**
 * Initiates a client connection to the API server.
 * @param collectionInstance
 * @param path
 * @param query
 * @param options
 * @param callback
 */
NodeApiClient.prototype.sync = function (collectionInstance, path, query, options, callback) {
	var self = this,
		source,
		finalPath,
		queryParams,
		queryString = '',
		connecting = true;

	if (this.debug()) {
		console.log(this.logIdentifier() + ' Connecting to API server ' + this.server() + this._rootPath + path);
	}

	finalPath = this.server() + this._rootPath + path + '/_sync';

	// Check for global auth
	if (this._sessionData) {
		queryParams = queryParams || {};

		if (this._sessionData.key) {
			// Add the session data to the key specified
			queryParams[this._sessionData.key] = this._sessionData.obj;
		} else {
			// Add the session data to the root query object
			Shared.mixin(queryParams, this._sessionData.obj);
		}
	}

	if (query) {
		queryParams = queryParams || {};
		queryParams.$query = query;
	}

	if (options) {
		queryParams = queryParams || {};
		if (options.$initialData === undefined) {
			options.$initialData = true;
		}

		queryParams.$options = options;
	}

	if (queryParams) {
		queryString = this.jStringify(queryParams);
		finalPath += '?' + queryString;
	}

	source = new EventSource(finalPath);
	collectionInstance.__apiConnection = source;

	source.addEventListener('open', function (e) {
		if (!options || (options && options.$initialData)) {
			// The connection is open, grab the initial data
			self.get(path, queryParams, function (err, data) {
				if (!err) {
					collectionInstance.upsert(data);
				}
			});
		}
	}, false);

	source.addEventListener('error', function (e) {
		if (source.readyState === 2) {
			// The connection is dead, remove the connection
			collectionInstance.unSync();
		}

		if (connecting) {
			connecting = false;
			callback(e);
		}
	}, false);

	source.addEventListener('insert', function(e) {
		var data = self.jParse(e.data);
		collectionInstance.insert(data.dataSet);
	}, false);

	source.addEventListener('update', function(e) {
		var data = self.jParse(e.data);
		collectionInstance.update(data.query, data.update);
	}, false);

	source.addEventListener('remove', function(e) {
		var data = self.jParse(e.data);
		collectionInstance.remove(data.query);
	}, false);

	if (callback) {
		source.addEventListener('connected', function (e) {
			if (connecting) {
				connecting = false;
				callback(false);
			}
		}, false);
	}
};

Collection.prototype.sync = new Overload({
	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'function': function (callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + this.name(), null, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} collectionName The collection to sync from.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, function': function (collectionName, callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + collectionName, null, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {Object} query A query object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'object, function': function (query, callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + this.name(), query, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} objectType The type of object to sync to e.g.
	 * "collection" or "view".
	 * @param {String} objectName The name of the object to sync from.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, string, function': function (objectType, objectName, callback) {
		this.$main.call(this, '/' + this._db.name() + '/' + objectType + '/' + objectName, null, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} collectionName The collection to sync from.
	 * @param {Object} query A query object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, object, function': function (collectionName, query, callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + collectionName, query, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} objectType The type of object to sync to e.g.
	 * "collection" or "view".
	 * @param {String} objectName The name of the object to sync from.
	 * @param {Object} query A query object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, string, object, function': function (objectType, objectName, query, callback) {
		this.$main.call(this, '/' + this._db.name() + '/' + objectType + '/' + objectName, query, null, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {Object} query A query object.
	 * @param {Object} options An options object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'object, object, function': function (query, options, callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + this.name(), query, options, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} collectionName The collection to sync from.
	 * @param {Object} query A query object.
	 * @param {Object} options An options object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, object, object, function': function (collectionName, query, options, callback) {
		this.$main.call(this, '/' + this._db.name() + '/collection/' + collectionName, query, options, callback);
	},

	/**
	 * Sync with this collection on the server-side.
	 * @name sync
	 * @method Collection.sync
	 * @param {String} objectType The type of object to sync to e.g.
	 * "collection" or "view".
	 * @param {String} objectName The name of the object to sync from.
	 * @param {Object} query A query object.
	 * @param {Object} options An options object.
	 * @param {Function} callback The callback method to call once
	 * the connection to the server has been established.
	 */
	'string, string, object, object, function': function (objectType, objectName, query, options, callback) {
		this.$main.call(this, '/' + this._db.name() + '/' + objectType + '/' + objectName, query, options, callback);
	},

	'$main': function (path, query, options, callback) {
		var self = this;

		if (this._db && this._db._core) {
			// Kill any existing sync connection
			this.unSync();

			// Create new sync connection
			this._db._core.api.sync(this, path, query, options, callback);

			// Hook on drop to call unsync
			this.on('drop', function () {
				self.unSync();
			});
		} else {
			throw(this.logIdentifier() + ' Cannot sync for an anonymous collection! (Collection must be attached to a database)');
		}
	}
});

/**
 * Disconnects an existing connection to a sync server.
 * @returns {boolean} True if a connection existed, false
 * if no connection existed.
 */
Collection.prototype.unSync = function () {
	if (this.__apiConnection) {
		if (this.__apiConnection.readyState !== 2) {
			this.__apiConnection.close();
		}

		delete this.__apiConnection;

		return true;
	}

	return false;
};

Collection.prototype.http = new Overload({
	'string, function': function (method, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/collection/' + this.name(), undefined, undefined, {}, callback);
	},

	'$main': function (method, path, queryObj, queryOptions, options, callback) {
		if (this._db && this._db._core) {
			return this._db._core.api.http('GET', this._db._core.api.server() + this._rootPath + path, {"$query": queryObj, "$options": queryOptions}, options, callback);
		} else {
			throw(this.logIdentifier() + ' Cannot do HTTP for an anonymous collection! (Collection must be attached to a database)');
		}
	}
});

Collection.prototype.autoHttp = new Overload({
	'string, function': function (method, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/collection/' + this.name(), undefined, undefined, {}, callback);
	},

	'string, string, function': function (method, collectionName, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/collection/' + collectionName, undefined, undefined, {}, callback);
	},

	'string, string, string, function': function (method, objType, objName, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/' + objType + '/' + objName, undefined, undefined, {}, callback);
	},

	'string, string, string, object, function': function (method, objType, objName, queryObj, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/' + objType + '/' + objName, queryObj, undefined, {}, callback);
	},

	'string, string, string, object, object, function': function (method, objType, objName, queryObj, queryOptions, callback) {
		this.$main.call(this, method, '/' + this._db.name() + '/' + objType + '/' + objName, queryObj, queryOptions, {}, callback);
	},

	'$main': function (method, path, queryObj, queryOptions, options, callback) {
		var self = this;

		if (this._db && this._db._core) {
			return this._db._core.api.http('GET', this._db._core.api.server() + this._db._core.api._rootPath + path, {"$query": queryObj, "$options": queryOptions}, options, function (err, data) {
				var i;

				if (!err && data) {
					// Check the type of method we used and operate on the collection accordingly
					switch (method) {
						// Find insert
						case 'GET':
							self.insert(data);
							break;

						// Insert
						case 'POST':
							if (data.inserted && data.inserted.length) {
								self.insert(data.inserted);
							}
							break;

						// Update overwrite
						case 'PUT':
						case 'PATCH':
							if (data instanceof Array) {
								// Update each document
								for (i = 0; i < data.length; i++) {
									self.updateById(data[i]._id, {$overwrite: data[i]});
								}
							} else {
								// Update single document
								self.updateById(data._id, {$overwrite: data});
							}
							break;

						// Remove
						case 'DELETE':
							self.remove(data);
							break;

						default:
							// Nothing to do with this method
							break;
					}
				}

				// Send the data back to the callback
				callback(err, data);
			});
		} else {
			throw(this.logIdentifier() + ' Cannot do HTTP for an anonymous collection! (Collection must be attached to a database)');
		}
	}
});

// Override the Core init to instantiate the plugin
Core.prototype.init = function () {
	CoreInit.apply(this, arguments);
	this.api = new NodeApiClient(this);
};

Shared.finishModule('NodeApiClient');

module.exports = NodeApiClient;