Add Harvest OAuth2 support (#697)

* credentials, generic functions fit to oauth2, UI elements

*  Improvements to Harvest-Node

* Update Harvest.node.ts

*  Add OAuth2 and move account id from credentials to node parameters

*  Minor improvements to Harvest-Node

Co-authored-by: Rupenieks <ru@myos,co>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ronalds Upenieks 2020-11-24 14:15:47 +01:00 committed by GitHub
parent af9d8e1cba
commit 3351113f28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1133 additions and 1004 deletions

View file

@ -2,6 +2,20 @@
This list shows all the versions which include breaking changes and how to upgrade.
## 0.95.0
### What changed?
In the Harvest Node, we moved the account field from the credentials to the node parameters. This will allow you to work witn multiples accounts without having to create multiples credentials.
### When is action necessary?
If you are using the Harvest Node.
### How to upgrade:
Open the node set the parameter `Account ID`.
## 0.94.0
### What changed?

View file

@ -8,13 +8,6 @@ export class HarvestApi implements ICredentialType {
displayName = 'Harvest API';
documentationUrl = 'harvest';
properties = [
{
displayName: 'Account ID',
name: 'accountId',
type: 'string' as NodePropertyTypes,
default: '',
description: 'Visit your account details page, and grab the Account ID. See <a href="https://help.getharvest.com/api-v2/authentication-api/authentication/authentication/">Harvest Personal Access Tokens</a>.',
},
{
displayName: 'Access Token',
name: 'accessToken',

View file

@ -0,0 +1,48 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class HarvestOAuth2Api implements ICredentialType {
name = 'harvestOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Harvest OAuth2 API';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://id.getharvest.com/oauth2/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://id.getharvest.com/api/v2/oauth2/token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: 'all',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'body',
description: 'Resource to consume.',
},
];
}

View file

@ -1,60 +1,53 @@
import { OptionsWithUri } from 'request';
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
IExecuteSingleFunctions,
IHookFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import { IDataObject } from 'n8n-workflow';
export async function harvestApiRequest(
this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions,
method: string,
qs: IDataObject = {},
uri: string,
body: IDataObject = {},
option: IDataObject = {},
): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('harvestApi') as IDataObject;
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
qs.access_token = credentials.accessToken;
qs.account_id = credentials.accountId;
// Convert to query string into a format the API can read
const queryStringElements: string[] = [];
for (const key of Object.keys(qs)) {
if (Array.isArray(qs[key])) {
(qs[key] as string[]).forEach(value => {
queryStringElements.push(`${key}=${value}`);
});
} else {
queryStringElements.push(`${key}=${qs[key]}`);
}
}
import {
IDataObject
} from 'n8n-workflow';
export async function harvestApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, qs: IDataObject = {}, path: string, body: IDataObject = {}, option: IDataObject = {}, uri?: string): Promise<any> { // tslint:disable-line:no-any
let options: OptionsWithUri = {
headers: {
'Harvest-Account-Id': `${this.getNodeParameter('accountId', 0)}`,
'User-Agent': 'Harvest App',
'Authorization': '',
},
method,
body,
uri: `https://api.harvestapp.com/v2/${uri}?${queryStringElements.join('&')}`,
uri: uri || `https://api.harvestapp.com/v2/${path}`,
qs,
json: true,
headers: {
"User-Agent": "Harvest API",
},
};
options = Object.assign({}, options, option);
if (Object.keys(options.body).length === 0) {
delete options.body;
}
const authenticationMethod = this.getNodeParameter('authentication', 0);
try {
const result = await this.helpers.request!(options);
if (authenticationMethod === 'accessToken') {
const credentials = this.getCredentials('harvestApi') as IDataObject;
return result;
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
//@ts-ignore
options.headers['Authorization'] = `Bearer ${credentials.accessToken}`;
return await this.helpers.request!(options);
} else {
return await this.helpers.requestOAuth2!.call(this, 'harvestOAuth2Api', options);
}
} catch (error) {
if (error.statusCode === 401) {
// Return a clear error
@ -89,14 +82,38 @@ export async function harvestApiRequestAllItems(
let responseData;
try {
do {
responseData = await harvestApiRequest.call(this, method, qs, uri, body, option);
qs.page = responseData.next_page;
returnData.push.apply(returnData, responseData[resource]);
} while (responseData.next_page);
return returnData;
} catch(error) {
throw error;
}
/**
* fetch All resource using paginated calls
*/
export async function getAllResource(this: IExecuteFunctions | ILoadOptionsFunctions, resource: string, i: number) {
const endpoint = resource;
const qs: IDataObject = {};
const requestMethod = 'GET';
qs.per_page = 100;
const additionalFields = this.getNodeParameter('filters', i) as IDataObject;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
Object.assign(qs, additionalFields);
let responseData: IDataObject = {};
if (returnAll) {
responseData[resource] = await harvestApiRequestAllItems.call(this, requestMethod, qs, endpoint, resource);
} else {
const limit = this.getNodeParameter('limit', i) as string;
qs.per_page = limit;
responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint);
}
return responseData[resource] as IDataObject[];
}

View file

@ -1,9 +1,12 @@
import {
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
@ -12,31 +15,41 @@ import {
clientFields,
clientOperations,
} from './ClientDescription';
import {
contactFields,
contactOperations,
} from './ContactDescription';
import { companyOperations } from './CompanyDescription';
import {
companyOperations,
} from './CompanyDescription';
import {
estimateFields,
estimateOperations,
} from './EstimateDescription';
import {
expenseFields,
expenseOperations,
} from './ExpenseDescription';
import {
getAllResource,
harvestApiRequest,
harvestApiRequestAllItems,
} from './GenericFunctions';
import {
invoiceFields,
invoiceOperations,
} from './InvoiceDescription';
import {
projectFields,
projectOperations,
} from './ProjectDescription';
import {
taskFields,
taskOperations,
@ -45,36 +58,12 @@ import {
timeEntryFields,
timeEntryOperations,
} from './TimeEntryDescription';
import {
userFields,
userOperations,
} from './UserDescription';
/**
* fetch All resource using paginated calls
*/
async function getAllResource(this: IExecuteFunctions, resource: string, i: number) {
const endpoint = resource;
const qs: IDataObject = {};
const requestMethod = 'GET';
qs.per_page = 100;
const additionalFields = this.getNodeParameter('filters', i) as IDataObject;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
Object.assign(qs, additionalFields);
let responseData: IDataObject = {};
if (returnAll) {
responseData[resource] = await harvestApiRequestAllItems.call(this, requestMethod, qs, endpoint, resource);
} else {
const limit = this.getNodeParameter('limit', i) as string;
qs.per_page = limit;
responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint);
}
return responseData[resource] as IDataObject[];
}
export class Harvest implements INodeType {
description: INodeTypeDescription = {
displayName: 'Harvest',
@ -86,7 +75,7 @@ export class Harvest implements INodeType {
description: 'Access data on Harvest',
defaults: {
name: 'Harvest',
color: '#22BB44',
color: '#e7863f',
},
inputs: ['main'],
outputs: ['main'],
@ -94,9 +83,46 @@ export class Harvest implements INodeType {
{
name: 'harvestApi',
required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'harvestOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'accessToken',
description: 'Method of authentication.',
},
{
displayName: 'Resource',
name: 'resource',
@ -148,6 +174,17 @@ export class Harvest implements INodeType {
description: 'The resource to operate on.',
},
{
displayName: 'Account ID',
name: 'accountId',
type: 'options',
required: true,
typeOptions: {
loadOptionsMethod: 'getAccounts',
},
default: '',
},
// operations
...clientOperations,
...companyOperations,
@ -173,6 +210,26 @@ export class Harvest implements INodeType {
],
};
methods = {
loadOptions: {
// Get all the available accounts to display them to user so that he can
// select them easily
async getAccounts(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { accounts } = await harvestApiRequest.call(this, 'GET', {}, '', {}, {}, 'https://id.getharvest.com/api/v2/accounts');
for (const account of accounts) {
const accountName = account.name;
const accountId = account.id;
returnData.push({
name: accountName,
value: accountId,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];

View file

@ -48,7 +48,6 @@ export const taskFields = [
/* -------------------------------------------------------------------------- */
/* task:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',

View file

@ -95,6 +95,7 @@
"dist/credentials/YouTubeOAuth2Api.credentials.js",
"dist/credentials/GumroadApi.credentials.js",
"dist/credentials/HarvestApi.credentials.js",
"dist/credentials/HarvestOAuth2Api.credentials.js",
"dist/credentials/HelpScoutOAuth2Api.credentials.js",
"dist/credentials/HttpBasicAuth.credentials.js",
"dist/credentials/HttpDigestAuth.credentials.js",