diff --git a/README.md b/README.md index 5a87e14f..d46b4a55 100644 --- a/README.md +++ b/README.md @@ -10,34 +10,86 @@ Harvest is a tool that enables businesses to track time, track projects, manage # Usage - var Harvest = require('harvest'), - harvest = new Harvest({ - subdomain: config.harvest.subdomain, - email: config.harvest.email, - password: config.harvest.password - }), - TimeTracking = harvest.TimeTracking; +## Basic Authentication + +```js +var Harvest = require('harvest'), + harvest = new Harvest({ + subdomain: config.harvest.subdomain, + email: config.harvest.email, + password: config.harvest.password + }), + TimeTracking = harvest.TimeTracking; + +TimeTracking.daily({}, function(err, tasks) { + if (err) throw new Error(err); + +// work with tasks +}); +``` +## OAuth Authentication + +### When you already have an access token + +```js +var harvest_with_token = new Harvest({ + subdomain: config.harvest_oauth.subdomain, + access_token: stored_access_token +}); + +var TimeTracking = harvest.TimeTracking; + +TimeTracking.daily({}, function(err, tasks) { + if (err) throw new Error(err); + + console.log('Loaded tasks using passed in auth_token!'); +}); +``` + +### To get an access token which can then be stored and used in future + +```js +// See https://platform.harvestapp.com/oauth2_clients to get these +var harvest = new Harvest({ + subdomain: config.harvest_oauth.subdomain, + redirect_uri: config.harvest_oauth.redirect_uri, + identifier: config.harvest_oauth.client_id, + secret: config.harvest_oauth.secret +}); + +// Send the user to harvest.getAccessTokenURL()) and grab the access code passed as a get parameter +// e.g. By running an express.js server at redirect_url +var access_code = req.query.code; + +harvest.parseAccessCode(access_code, function(access_token) { + console.log('Grabbed the access token to save', access_token); + + var TimeTracking = harvest.TimeTracking; TimeTracking.daily({}, function(err, tasks) { if (err) throw new Error(err); - // work with tasks + console.log('Loaded tasks using oauth!'); }); +}); +``` # Testing In order to test you will need to create a config file that uses your credentials inside `config/default.json` - { - "harvest": { - "subdomain": "...", - "email": "...", - "password": "...", - "identifier": "...", - "secret": "...", - "user_agent": "node-harvest test runner" - } - } +```js +{ + "harvest": { + "subdomain": "...", + "email": "...", + "password": "...", + "identifier": "...", + "secret": "...", + "user_agent": "node-harvest test runner" + } +} +``` This API is designed to work either using HTTP Basic authentication, or OAuth so either set of credentials will work. Subdomain is always required. diff --git a/index.js b/index.js index c85a2a5a..e2eaecc4 100644 --- a/index.js +++ b/index.js @@ -12,13 +12,13 @@ module.exports = Harvest = function (opts) { throw new Error('The Harvest API client requires a subdomain'); } - this.use_oauth = (opts.identifier !== undefined && - opts.secret !== undefined); + this.use_oauth = ((opts.identifier !== undefined && opts.secret !== undefined && opts.redirect_uri !== undefined) + || (opts.access_token !== undefined)); this.use_basic_auth = (opts.email !== undefined && opts.password !== undefined); if (!this.use_oauth && !this.use_basic_auth) { - throw new Error('The Harvest API client requires credentials for basic authentication or an identifier and secret for OAuth'); + throw new Error('The Harvest API client requires credentials for basic authentication or an identifier, secret and redirect_uri (or an access_token) for OAuth'); } this.subdomain = opts.subdomain; @@ -27,6 +27,8 @@ module.exports = Harvest = function (opts) { this.password = opts.password; this.identifier = opts.identifier; this.secret = opts.secret; + this.redirect_uri = opts.redirect_uri; + this.access_token = opts.access_token || false; this.user_agent = opts.user_agent; this.debug = opts.debug || false; this.throttle_concurrency = opts.throttle_concurrency || null; @@ -38,6 +40,13 @@ module.exports = Harvest = function (opts) { baseURL: self.host }, { run: function (type, url, data) { + if(self.use_oauth) { + if(!self.access_token) { + throw new Error("An access token is required if using oAuth, use parseAccessCode or pass an access_token before making any requests"); + } + url = url + "?access_token=" + self.access_token; + } + if (self.debug) { console.log('run', type, url, data); } @@ -104,7 +113,41 @@ module.exports = Harvest = function (opts) { } }; + this.getAccessTokenURL = function() { + return this.host + + "/oauth2/authorize?client_id=" + this.identifier + + "&redirect_uri=" + encodeURIComponent(this.redirect_uri) + + "&response_type=code"; + } + + this.parseAccessCode = function(access_code, cb) { + var self = this; + this.access_code = access_code; + + var options = { + "code": this.access_code, + "client_id": this.identifier, + "client_secret": this.secret, + "redirect_uri": this.redirect_uri, + "grant_type": "authorization_code" + } + if (self.debug) { + console.log('request token', options); + } + + restler.post(this.host + '/oauth2/token', { + data: options + }).on('complete', function(response) { + if(!response.access_token) { + throw new Error("Provided access code was rejected by Harvest, no token was returned"); + } + + self.access_token = response.access_token; + + cb(self.access_token); + }); + } var Account = require('./lib/account'); var TimeTracking = require('./lib/time-tracking'); diff --git a/lib/expenses.js b/lib/expenses.js index 9ee51da9..3e575bbd 100644 --- a/lib/expenses.js +++ b/lib/expenses.js @@ -1,4 +1,4 @@ -var Expenses, _isUndefined = require('../mixin'); +var Expenses, _isUndefined = require('../mixin'), fs = require('fs'), request = require('request'); module.exports = Expenses = function (api) { this.api = api; @@ -11,7 +11,7 @@ Expenses.prototype.list = function (options, cb) { }; Expenses.prototype.get = function (options, cb) { - if (_isUndefined(option, 'id')) { + if (_isUndefined(options, 'id')) { return cb(new Error('retrieving a single exspense requires an id')); } @@ -44,29 +44,49 @@ Expenses.prototype.delete = function (options, cb) { this.client.delete(url, {}, cb); }; -Expenses.prototype.attachReceipt = function (options, cb) { - if (_isUndefined(option, 'id')) { +Expenses.prototype.attachReceipt = function(options, cb) { + if (_isUndefined(options, 'id')) { return cb(new Error('attaching a receipt requires an id')); } - - // TODO post image data - // POST /expenses/#{expense_id}/receipt HTTP/1.1 - // User-Agent: #{Your app name here} - // Host: #{yoursubdomain}.harvestapp.com - // Accept: application/xml - // Authorization: Basic (insert your authentication string here) - // Content-Length: 47899 - // Content-Type: multipart/form-data; boundary=------------------------------b7edea381b46 - // ------------------------------b7edea381b46 - // Content-Disposition: form-data; name="expense[receipt]"; filename="hotel.jpg" - // Content-Type: image/jpeg - // - // #{ BINARY IMAGE DATA } - // - // ------------------------------b7edea381b46 - - var url = '/expenses/' + options.id + '/receipt'; - this.client.post(url, {}, cb); + if (_isUndefined(options, 'file')) { + return cb(new Error('attaching a receipt requires a file object')); + } + if (_isUndefined(options.file, 'path') || _isUndefined(options.file, 'originalname')) { + return cb(new Error('file object must have a path and an originalname')); + } + var formData = { + 'expense[receipt]': { + value: fs.createReadStream(options.file.path), + options: { + filename: options.file.originalname + } + } + }; + var options = { + url: this.api.host + '/expenses/' + options.id + "/receipt", + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Expenses' + }, + formData: formData + }; + if (this.api.use_basic_auth) { + options.auth = { + user: this.api.email, + pass: this.api.password + } + } else { + if(!this.api.access_token) { + throw new Error("An access token is required if using oAuth to upload files"); + } + options.url += "?access_token=" + this.api.access_token; + } + request.post(options, function callback(err, httpResponse, body) { + if (err) { + return cb(err, {}); + } + return cb(null, body); + }); }; Expenses.prototype.getReceipt = function (options, cb) {