Skip to content
This repository was archived by the owner on Jan 24, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 70 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 46 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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');
Expand Down
66 changes: 43 additions & 23 deletions lib/expenses.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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'));
}

Expand Down Expand Up @@ -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) {
Expand Down