@runnable/api-client
Version:
Runnable API Client
475 lines (436 loc) • 13.7 kB
JavaScript
'use strict';
var util = require('util');
var extend = require('extend');
var clone = require('clone');
var keypather = require('keypather')();
var exists = require('101/exists');
var isString = require('101/is-string');
var isFunction = require('101/is-function');
var isObject = require('101/is-object');
var isEmpty = require('101/is-empty');
var assign = require('101/assign');
var urlJoin = require('../url-join');
var intercept = require('../intercept');
var pathJoin = require('../url-join');
var modelStore = require('../stores/model-store');
var EventEmitter = require('../event-emitter');
var defaultMaxRetryCount = 5;
var defaultRetryTimeout = 5;
module.exports = Model;
function Model (attrs, opts) {
this.opts = opts || {};
if (this.opts.noStore) {
this.noStore = this.opts.noStore;
}
else {
if (!this.opts.client) {
throw new TypeError('model requires a client');
}
if (!exists(this.urlPath)) {
throw new TypeError('model requires a urlPath');
}
if (this.opts.parentPath) {
this.urlPath = pathJoin(this.opts.parentPath, this.urlPath);
}
}
this.attrs = {};
this._state = {};
this.client = this.opts.client;
this.user = this.opts.user;
this.setMaxListeners(100);
this.etag = null;
this.lastModified = null;
// below must be last
this.reset(attrs || {});
}
util.inherits(Model, EventEmitter);
Model.prototype.idAttribute = '_id';
Model.prototype.useETags = false;
Model.prototype.id = function (id) {
if (exists(id)) { //set
this.attrs[this.idAttribute] = id;
}
return this.attrs[this.idAttribute];
};
Model.prototype.path = function (id) {
id = id || this.id() || '';
return urlJoin(this.urlPath, id);
};
/**
* Accept arguments in three forms
* cb
* opts, cb
* id, cb
*/
Model.prototype.formatArgs = function (args) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
var id = args[0];
var opts = args[1];
var cb = args[2];
// cb
if (isFunction (id)) {
cb = id;
id = null;
opts = null;
}
// opts, cb
if (isObject(id)) {
cb = opts;
opts = id;
id = null;
}
// id, cb
if (isFunction(opts)) {
cb = opts;
opts = null;
}
id = id || this.id();
opts = opts || {};
this.id(id); // sets the id
return {
id: id,
opts: opts,
cb: cb
};
};
Model.prototype.extend = function (attrs) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
if (attrs.toJSON) {
attrs = attrs.toJSON();
}
attrs = this.parse(attrs);
this.attrs = extend(this.attrs, attrs);
this.emit('update', this, attrs);
};
Model.prototype.reset = function (attrs) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
if (isString(attrs)) {
var id = attrs;
this.id(id);
this.extend({});
return;
}
else if (attrs.toJSON) {
attrs = attrs.toJSON();
}
attrs = clone(attrs, false);
attrs = this.parse(attrs);
this.attrs = attrs;
this.emit('update', this, attrs);
};
Model.prototype.parse = function (attrs) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return attrs;
};
Model.prototype.create = function (opts, cb) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
var self = this;
if (isFunction(opts)) {
cb = opts;
opts = null;
}
if (this.id()) {
return cb(new Error('Cannot call create on an existing '+
Object.getPrototypeOf(this).constructor.name));
}
opts = opts || {};
opts = opts.json || opts.body || opts.qs || opts.headers ?
opts : { json: opts }; // assume opts are json if no json/body/qs key
opts.statusCodes = opts.statusCodes || {
201: true,
400: false,
401: false,
409: false
};
var retryCount = 0;
var maxRetryCount = opts.maxRetryCount || defaultMaxRetryCount;
return makeRequest();
function makeRequest () {
return self.client.post(self.path(), opts, intercept(retryOrErr, function (body, code, res) {
self.reset(body);
modelStore.check(self);
cb(null, body, code, res);
}));
}
function retryOrErr (err) {
if (opts.retryable &&
retryCount < maxRetryCount &&
opts.retryable(err)) {
retryCount++;
return makeRequest();
}
else {
self.emit('created:error', self, err);
cb(err);
}
}
};
Model.prototype.fetch = function (id, opts, cb) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
var args = this.formatArgs(arguments);
id = args.id;
opts = args.opts;
opts = opts.qs || opts.headers ?
opts : { qs: opts }; // assume opts are qs if no qs key
cb = args.cb;
if (!isString(id) || id.length === 0) {
return cb(new TypeError('id is required'));
}
opts.statusCodes = opts.statusCodes || {
200: true,
304: true,
400: false,
401: false,
404: false
};
var self = this;
var retryCount = 0;
var maxRetryCount = opts.maxRetryCount || defaultMaxRetryCount;
// Add ETag headers when applicable
if (this.useETags && isString(this.etag)) {
opts.headers = isObject(opts.headers) ? opts.headers : {};
opts.headers['If-None-Match'] = this.etag;
}
return makeRequest();
function makeRequest () {
return self.client.get(self.path(id), opts, intercept(retryOrErr, function (body, code, res) {
var oldId = self.id();
if (self.useETags && res.headers.etag) {
self.etag = res.headers.etag;
}
if (res.statusCode != '304') {
self.reset(body);
self.lastModified = res.headers['last-modified'];
}
if (oldId === 'me' && self.id() !== oldId) { // hack: ensure users/me and user/id are same
modelStore.check(self);
}
if (typeof cb === 'function') {
cb(null, body, code, res);
}
}));
}
function retryOrErr (err) {
if (opts.retryable &&
retryCount < maxRetryCount &&
opts.retryable(err)) {
retryCount++;
var retryTimeout = opts.retryTimeout || defaultRetryTimeout;
return setTimeout(makeRequest, retryTimeout);
}
else {
cb(err);
}
}
};
Model.prototype.update = function (id, opts, cb) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
var args = this.formatArgs(arguments);
id = args.id;
opts = args.opts;
opts = opts.json || opts.body || opts.qs || opts.headers ?
opts : { json: opts }; // assume opts are json if no json/body/qs key
cb = args.cb;
if (!isString(id) || id.length === 0) {
return cb(new TypeError('id is required'));
}
opts.statusCodes = opts.statusCodes || {
200: true,
400: false,
401: false,
404: false,
409: false
};
var self = this;
var lastAttrs = this.attrs;
if (opts.put) {
this.reset(opts.json);
}
else {
this.extend(opts.json);
}
var retryCount = 0;
var maxRetryCount = opts.maxRetryCount || defaultMaxRetryCount;
return makeRequest();
function makeRequest () {
return self.client[opts.put ? 'put' : 'patch'](self.path(id), opts, intercept(retryOrRevert, function (body, code, res) {
self.reset(body);
cb(null, body, code, res);
}));
}
function retryOrRevert (err) {
if (opts.retryable &&
retryCount < maxRetryCount &&
opts.retryable(err)) {
retryCount++;
var retryTimeout = opts.retryTimeout || defaultRetryTimeout;
return setTimeout(makeRequest, retryTimeout);
}
self.reset(lastAttrs);
cb(err);
}
};
Model.prototype.destroy = function (id, opts, cb) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
var args = this.formatArgs(arguments);
id = args.id;
opts = args.opts;
opts = opts.json || opts.body || opts.qs || opts.headers ?
opts : { json: opts }; // assume opts are json if no json/body/qs key
cb = args.cb;
if (!isString(id) || id.length === 0) {
return cb(new TypeError('id is required'));
}
opts.statusCodes = opts.statusCodes || {
200: true,
204: true,
400: false,
401: false,
404: false
};
var self = this;
self.emit('destroy', self);
return this.client.del(this.path(id), opts, function (err) {
if (err) {
self.emit('destroyed:error', self, err);
}
else {
self.dealloc(false);
}
cb.apply(null, arguments);
});
};
/**
* remove model from cache and collections, and allow to be garbage collected
*/
Model.prototype.dealloc = function (emitDestroy) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
emitDestroy = exists(emitDestroy) ? emitDestroy : true; // default emitDestroy to true
if (emitDestroy) {
this.emit('destroy', this);
}
this.emit('destroyed', this);
this.user = {}; // circular
this.opts = {}; // circular
this.destroyed = true;
};
Model.prototype.json = function () {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return this.toJSON();
};
Model.prototype.toJSON = function () {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return clone(this.attrs, false);
};
Model.prototype.equals = function (model) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return (this.id() && model.id()) ?
this.path() === model.path() :
this === model;
};
/**
* Reset the working state of the model. This will, in effect clear all
* unsaved state changes. Optional attributes can be passed clear the state
* to a given initial value.
*
* @param {object} [state] State to set after clear.
*/
Model.prototype.resetState = function(state) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
this._state = exists(state) ? clone(state) : {};
};
/**
* Saves the current working state into the attributes for the model.
*/
Model.prototype.saveState = function() {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
assign(this.attrs, this._state);
this.resetState();
};
/**
* Determines if there are changes in the working state for the model
* that are not reflected in the attributes.
*
* @return {Boolean} True, if there are state changes, false otherwise.
*/
Model.prototype.isDirty = function() {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return !isEmpty(this._state);
};
/**
* Sets the value for a given key in the objects working state.
*
* @param {string} keypath Key path for the item in the state.
* @param value Value to set.
*/
Model.prototype.setState = function(keypath, value) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
keypather.set(this._state, keypath, value);
};
/**
* Finds the value of a given keypath in the state. If no value
* can be found for the given path this will attempt to return
* the value of the same path in the model's attributes.
*
* @param {string} keypath Key path in the state for the value to retrieve.
* @return The value at the given point in the state if the given
* keypath exists, otherwise the value in the attributes for
* the same path.
*/
Model.prototype.getState = function(keypath) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return keypather.get(this._state, keypath);
};
/**
* Determines if there is a given path in the current working state.
*
* @param {string} keypath Key path to check.
* @return {Boolean} True if there is a value for the given path, false otherwise.
*/
Model.prototype.hasState = function(keypath) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
return keypather.in(this._state, keypath);
};
/**
* Determines if the origin for the model has changes that are not accounted for
* in the model's current set of attributes. This is useful, for instance, to
* see if the resource has been changed at the origin since the last fetch.
*
* @param id Id of the model to check againsts.
* @param {Object} opts Options for the request.
* @param {responseCallback} cb Handles response.
*/
/**
* Handles the response from the remote changes check.
* @callback responseCallback
* @param err Error response, if applicable.
* @param {Boolean} hasChanges True if there are changes at the origin since the
* last fetch, false otherwise.
* @return {Boolean}
*/
Model.prototype.hasRemoteChanges = function(id, opts, cb) {
if (this.destroyed) { return console.trace('this model is destroyed, don\'t use it anymore!'); }
if (!exists(this.lastModified)) {
return console.trace('Cannot check for remote changes on a model that has not been fetched.');
}
var args = this.formatArgs(arguments);
id = args.id;
opts = args.opts;
opts = opts.json || opts.body || opts.qs || opts.headers ? opts : {};
cb = args.cb;
if (!isString(id) || id.length === 0) {
return cb(new TypeError('id is required'));
}
opts.statusCodes = opts.statusCodes || {
200: true,
204: true,
400: false,
401: false,
404: false
};
var self = this;
this.client.head(this.path(id), opts, function(err, body, code, res) {
if (err) { cb(err); }
cb(null, res.headers['last-modified'] != self.lastModified);
});
};