Skip to content

Commit

Permalink
Merge pull request #14 from jordanwalsh23/add_refresh_token_functiona…
Browse files Browse the repository at this point in the history
…lity

Addded automatic refresh token functionality, including the event hook for listening for when the tokens have updated.
  • Loading branch information
jordanwalsh23 committed Mar 14, 2017
2 parents 003da15 + ed4d797 commit d2bff73
Show file tree
Hide file tree
Showing 6 changed files with 507 additions and 206 deletions.
297 changes: 207 additions & 90 deletions lib/application.js
Expand Up @@ -8,7 +8,8 @@ var _ = require('lodash'),
Core = require('./core'),
qs = require('querystring'),
Payroll = require('./payroll'),
xml2js = require('xml2js')
xml2js = require('xml2js'),
events = new require('events');

function Batch(application) {
logger.debug('Batch::constructor');
Expand Down Expand Up @@ -45,6 +46,7 @@ function Application(options) {

var core = new Core(this);
var payroll = new Payroll(this);

Object.defineProperties(this, {
core: {
get: function() {
Expand Down Expand Up @@ -81,6 +83,8 @@ Object.assign(Application.prototype, {
if (this.options["runscopeBucketId"] && this.options["runscopeBucketId"] !== "") {
this.options.baseUrl = "https://api-xero-com-" + this.options["runscopeBucketId"] + ".runscope.net";
}

this.eventEmitter = new events.EventEmitter();
},
post: function(path, body, options, callback) {
return this.putOrPost('post', path, body, options, callback);
Expand All @@ -98,55 +102,73 @@ Object.assign(Application.prototype, {
options = options || {};
return new Promise(function(resolve, reject) {
var params = {};
if (options.summarizeErrors === false)
params.summarizeErrors = false;

//Added to support more than 2dp being added.
if (options.unitdp)
params.unitdp = options.unitdp;
self.checkExpiry()
.then(function() {
if (options.summarizeErrors === false)
params.summarizeErrors = false;

var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl;
var url = self.options.baseUrl + endPointUrl + path;
if (!_.isEmpty(params))
url += '?' + querystring.stringify(params);
//Added to support more than 2dp being added.
if (options.unitdp)
params.unitdp = options.unitdp;

self.oa[method](url, self.options.accessToken, self.options.accessSecret, { xml: body }, function(err, data, res) {
var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl;
var url = self.options.baseUrl + endPointUrl + path;
if (!_.isEmpty(params))
url += '?' + querystring.stringify(params);

if (err && data && data.indexOf('oauth_problem') >= 0) {
var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode);
errObj.data = qs.parse(data);
reject(errObj);
callback && callback(errObj);
return;
}
self.oa[method](url, self.options.accessToken, self.options.accessSecret, { xml: body }, function(err, data, res) {

self.xml2js(data)
.then(function(obj) {
if (err) {
var exception = "";
if (obj.ApiException)
exception = obj.ApiException;
else if (obj.Response.ErrorNumber)
exception = obj.Response;
var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode + ' and exception: ' + JSON.stringify(exception, null, 2));
if (err && data && data.indexOf('oauth_problem') >= 0) {
var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode);
errObj.data = qs.parse(data);
reject(errObj);
callback && callback(errObj);
} else {
var ret = { response: obj.Response, res: res };
if (options.entityConstructor) {
ret.entities = self.convertEntities(obj.Response, options);
}
resolve(ret);
callback && callback(null, obj, res, ret.entities);
return;
}

})
.catch(function(err) {
logger.error(err);
throw err;
})
self.xml2js(data)
.then(function(obj) {
if (err) {
var exception = "";
if (obj.ApiException)
exception = obj.ApiException;
else if (obj.Response.ErrorNumber)
exception = obj.Response;
var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode + ' and exception: ' + JSON.stringify(exception, null, 2));
reject(errObj);
callback && callback(errObj);
} else {
var ret = { response: obj.Response, res: res };
if (options.entityConstructor) {
ret.entities = self.convertEntities(obj.Response, options);
}
resolve(ret);
callback && callback(null, obj, res, ret.entities);
}

})
.catch(function(err) {
logger.error(err);
throw err;
})

});
})
.catch(function(err) {
logger.debug(err);
if (err && err.data) {
var dataParts = qs.parse(err.data);

var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode);
errObj.data = dataParts;
reject(errObj);
callback && callback(errObj);
return;
}
});


});
});
},
delete: function(path, options, callback) {
Expand All @@ -157,42 +179,57 @@ Object.assign(Application.prototype, {
var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl;
var url = self.options.baseUrl + endPointUrl + path;

self.oa.delete(url, self.options.accessToken, self.options.accessSecret, function(err, data, res) {
if (options.stream && !err) {
// Already done
return resolve();
}
if (err && data && data.indexOf('oauth_problem') >= 0) {
var errObj = new Error('DELETE call failed with: ' + err.statusCode);
errObj.data = qs.parse(data);
reject(errObj);
callback && callback(errObj);
return;
}
self.checkExpiry()
.then(function() {
self.oa.delete(url, self.options.accessToken, self.options.accessSecret, function(err, data, res) {
if (options.stream && !err) {
// Already done
return resolve();
}
if (err && data && data.indexOf('oauth_problem') >= 0) {
var errObj = new Error('DELETE call failed with: ' + err.statusCode);
errObj.data = qs.parse(data);
reject(errObj);
callback && callback(errObj);
return;
}

if (err) {
var errObj = new Error('DELETE call failed with: ' + err.statusCode + ' and message: ' + err.data);
reject(errObj);
callback && callback(errObj);
return;
}
if (err) {
var errObj = new Error('DELETE call failed with: ' + err.statusCode + ' and message: ' + err.data);
reject(errObj);
callback && callback(errObj);
return;
}

//Some delete operations don't return any content (e.g. HTTP204) so simply resolve the promise
if (!data || data === "") {
return resolve();
}
//Some delete operations don't return any content (e.g. HTTP204) so simply resolve the promise
if (!data || data === "") {
return resolve();
}

self.xml2js(data)
.then(function(obj) {
var ret = { response: obj.Response, res: res };
resolve(ret);
callback && callback(null, obj, res);
})
.catch(function(err) {
logger.error(err);
throw err;
})
}, { stream: options.stream });
self.xml2js(data)
.then(function(obj) {
var ret = { response: obj.Response, res: res };
resolve(ret);
callback && callback(null, obj, res);
})
.catch(function(err) {
logger.error(err);
throw err;
})
}, { stream: options.stream });
})
.catch(function(err) {
logger.debug(err);
if (err && err.data) {
var dataParts = qs.parse(err.data);

var errObj = new Error('DELETE call failed with: ' + err.statusCode);
errObj.data = dataParts;
reject(errObj);
callback && callback(errObj);
return;
}
});
});
},
get: function(path, options, callback) {
Expand All @@ -207,10 +244,25 @@ Object.assign(Application.prototype, {
if (options.format)
self.oa._headers['Accept'] = 'application/' + options.format;

if (options.pager)
getResource(options.pager.start || 1)
else
getResource();
self.checkExpiry()
.then(function() {
if (options.pager)
getResource(options.pager.start || 1)
else
getResource();
})
.catch(function(err) {
logger.debug(err);
if (err && err.data) {
var dataParts = qs.parse(err.data);

var errObj = new Error('GET call failed with: ' + err.statusCode);
errObj.data = dataParts;
reject(errObj);
callback && callback(errObj);
return;
}
});

function getResource(offset) {
var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl;
Expand Down Expand Up @@ -243,11 +295,7 @@ Object.assign(Application.prototype, {
return resolve();
}
if (err && data) {
var dataParts;
if (_.isObject(data))
dataParts = qs.parse(data);
else
dataParts = data;
var dataParts = qs.parse(data);

var errObj = new Error('GET call failed with: ' + err.statusCode);
errObj.data = dataParts;
Expand Down Expand Up @@ -417,6 +465,31 @@ Object.assign(Application.prototype, {
var builder = new xml2js.Builder({ rootName: rootName, headless: true });
var obj = builder.buildObject(obj);
return obj;
},
checkExpiry: function() {

/**
* CheckExpiry is a helper function that will compare the current token expiry to the current time.
*
* As there is potential for a time difference, instead of waiting all the way until the current time
* has passed the expiry time, we instead add 3 minutes to the current time, and use that as a comparison.
*
* This ensures that if the token is 'nearing' the expiry, it'll attempt to be refreshed.
*/

var expiry = new Date(this.options.tokenExpiry),
checkTime = addMinutes(new Date(), 3);

if (checkTime >= expiry) {
logger.debug("Refreshing Access Token");
return this.refreshAccessToken();
} else {
return Promise.resolve();
}

function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * 60000);
}
}
})

Expand Down Expand Up @@ -471,15 +544,47 @@ var RequireAuthorizationApplication = Application.extend({
});
});
},
getAccessToken: function(token, secret, verifier, callback, options) {
setAccessToken: function(token, secret, verifier, callback, options) {
var self = this;

return new Promise(function(resolve, reject) {
self.oa.getOAuthAccessToken(token, secret, verifier, function(err, token, secret, results) {
self.oa.getOAuthAccessToken(token, secret, verifier, function(err, results) {
if (err)
reject(err);
else
resolve({ token: token, secret: secret, results: results });
else {
var exp = new Date();
exp.setTime(exp.getTime() + (results.oauth_expires_in * 1000));
self.setOptions({
accessToken: results.oauth_token,
accessSecret: results.oauth_token_secret,
sessionHandle: results.oauth_session_handle,
tokenExpiry: exp.toString()
}, false);
resolve({ results: results });
}
callback && callback.apply(callback, arguments);
})
});
},
refreshAccessToken: function(callback, options) {
var self = this;

return new Promise(function(resolve, reject) {
self.oa.getOAuthAccessToken(self.options.accessToken, self.options.accessSecret, { oauth_session_handle: self.options.sessionHandle }, function(err, results) {
if (err)
reject(err);
else {
var exp = new Date();
exp.setTime(exp.getTime() + (results.oauth_expires_in * 1000));
self.setOptions({
accessToken: results.oauth_token,
accessSecret: results.oauth_token_secret,
sessionHandle: results.oauth_session_handle,
tokenExpiry: exp.toString()
}, true);
resolve({ results: results });
}

callback && callback.apply(callback, arguments);
})
});
Expand All @@ -488,9 +593,21 @@ var RequireAuthorizationApplication = Application.extend({
var q = Object.assign({}, { oauth_token: requestToken }, other);
return this.options.baseUrl + this.options.authorizeUrl + '?' + querystring.stringify(q);
},
setOptions: function(options) {
this.options.accessToken = options.accessToken;
this.options.accessSecret = options.accessSecret;
setOptions: function(options, emitThisEvent) {
logger.debug("Setting options");
options.accessToken ? this.options.accessToken = options.accessToken : false;
options.accessSecret ? this.options.accessSecret = options.accessSecret : false;
options.sessionHandle ? this.options.sessionHandle = options.sessionHandle : false;
options.tokenExpiry ? this.options.tokenExpiry = options.tokenExpiry : false;

if (emitThisEvent && this.eventEmitter) {
logger.debug("Emitting event");
try {
this.eventEmitter.emit('xeroTokenUpdate', options);
} catch (e) {
logger.error(e);
}
}
}
});

Expand Down Expand Up @@ -560,4 +677,4 @@ var PartnerApplication = RequireAuthorizationApplication.extend({
module.exports.PrivateApplication = PrivateApplication;
module.exports.PublicApplication = PublicApplication;
module.exports.PartnerApplication = PartnerApplication;
module.exports.Application = Application;
module.exports.Application = Application;

0 comments on commit d2bff73

Please sign in to comment.