Strava Node & Trigger (#1071)

*  Strava Node & Trigger

*  Small fixes

*  Add improvements
This commit is contained in:
Ricardo Espinoza 2020-10-22 04:17:39 -04:00 committed by GitHub
parent 7daeed5c0b
commit 95b33662c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1003 additions and 0 deletions

View file

@ -0,0 +1,47 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class StravaOAuth2Api implements ICredentialType {
name = 'stravaOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Strava OAuth2 API';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://www.strava.com/oauth/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://www.strava.com/oauth/token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: 'activity:read_all,activity:write',
required: true
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'body',
},
];
}

View file

@ -0,0 +1,412 @@
import {
INodeProperties,
} from "n8n-workflow";
export const activityOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'activity',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new activity',
},
{
name: 'Get',
value: 'get',
description: 'Get an activity',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all activities',
},
{
name: 'Get Comments',
value: 'getComments',
description: 'Get all activity comments',
},
{
name: 'Get Kudoers',
value: 'getKudoers',
description: 'Get all activity kudoers',
},
{
name: 'Get Laps',
value: 'getLaps',
description: 'Get all activity laps',
},
{
name: 'Get Zones',
value: 'getZones',
description: 'Get all activity zones',
},
{
name: 'Update',
value: 'update',
description: 'Update an activity',
},
],
default: 'create',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const activityFields = [
/* -------------------------------------------------------------------------- */
/* activity:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Name',
name: 'name',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'create'
]
},
},
default: '',
description: 'The name of the activity',
},
{
displayName: 'Type',
name: 'type',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'create'
]
},
},
default: '',
description: 'Type of activity. For example - Run, Ride etc.',
},
{
displayName: 'Start Date',
name: 'startDate',
type: 'dateTime',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'create'
]
},
},
description: 'ISO 8601 formatted date time.',
},
{
displayName: 'Elapsed Time (Seconds)',
name: 'elapsedTime',
type: 'number',
required :true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'create'
]
},
},
typeOptions: {
minValue: 0,
},
default: 0,
description: 'In seconds.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'create',
],
},
},
options: [
{
displayName: 'Commute',
name: 'commute',
type: 'boolean',
default: false,
description: 'Set to true to mark as commute.',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
description: 'Description of the activity.',
},
{
displayName: 'Distance',
name: 'distance',
type: 'number',
typeOptions: {
minValue: 0,
},
default: 0,
description: 'In meters.',
},
{
displayName: 'Trainer',
name: 'trainer',
type: 'boolean',
default: false,
description: 'Set to true to mark as a trainer activity.',
},
],
},
/* -------------------------------------------------------------------------- */
/* activity:update */
/* -------------------------------------------------------------------------- */
{
displayName: 'Activity ID',
name: 'activityId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'update',
],
},
},
default: '',
description: 'ID or email of activity',
},
{
displayName: 'Update Fields',
name: 'updateFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'update',
],
},
},
options: [
{
displayName: 'Commute',
name: 'commute',
type: 'boolean',
default: false,
description: 'Set to true to mark as commute.',
},
{
displayName: 'Description',
name: 'description',
type: 'string',
default: '',
description: 'Description of the activity.',
},
{
displayName: 'Gear ID',
name: 'gear_id',
type: 'string',
default: '',
description: 'Identifier for the gear associated with the activity. none clears gear from activity',
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'The name of the activity',
},
{
displayName: 'Type',
name: 'type',
type: 'string',
default: '',
description: 'Type of activity. For example - Run, Ride etc.',
},
{
displayName: 'Trainer',
name: 'trainer',
type: 'boolean',
default: false,
description: 'Set to true to mark as a trainer activity.',
},
],
},
/* -------------------------------------------------------------------------- */
/* activity:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Activity ID',
name: 'activityId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'get',
],
},
},
default: '',
description: 'ID or email of activity',
},
/* -------------------------------------------------------------------------- */
/* activity */
/* -------------------------------------------------------------------------- */
{
displayName: 'Activity ID',
name: 'activityId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'comment',
'lap',
'kudo',
'zone',
],
},
},
default: '',
description: 'ID or email of activity',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'getComments',
'getLaps',
'getKudoers',
'getZones',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'getComments',
'getLaps',
'getKudoers',
'getZones',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'How many results to return.',
},
/* -------------------------------------------------------------------------- */
/* activity:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'getAll',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: [
'activity',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,92 @@
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
IExecuteSingleFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
} from 'n8n-workflow';
export async function stravaApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, headers: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const options: OptionsWithUri = {
method,
form: body,
qs,
uri: uri || `https://www.strava.com/api/v3${resource}`,
json: true
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body).length === 0) {
delete options.body;
}
if (this.getNode().type.includes('Trigger') && resource.includes('/push_subscriptions')) {
const credentials = this.getCredentials('stravaOAuth2Api') as IDataObject;
if (method === 'GET') {
qs.client_id = credentials.clientId;
qs.client_secret = credentials.clientSecret;
} else {
body.client_id = credentials.clientId;
body.client_secret = credentials.clientSecret;
}
//@ts-ignore
return this.helpers?.request(options);
} else {
//@ts-ignore
return await this.helpers.requestOAuth2.call(this, 'stravaOAuth2Api', options);
}
} catch (error) {
if (error.statusCode === 402) {
throw new Error(
`Strava error response [${error.statusCode}]: Payment Required`
);
}
if (error.response && error.response.body && error.response.body.errors) {
let errors = error.response.body.errors;
errors = errors.map((e: IDataObject) => `${e.code} -> ${e.field}`);
// Try to return the error prettier
throw new Error(
`Strava error response [${error.statusCode}]: ${errors.join('|')}`
);
}
throw error;
}
}
export async function stravaApiRequestAllItems(this: IHookFunctions | ILoadOptionsFunctions | IExecuteFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const returnData: IDataObject[] = [];
let responseData;
query.per_page = 30;
query.page = 1;
do {
responseData = await stravaApiRequest.call(this, method, resource, body, query);
query.page++;
returnData.push.apply(returnData, responseData);
} while (
responseData.length !== 0
);
return returnData;
}

View file

@ -0,0 +1,175 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
stravaApiRequest,
stravaApiRequestAllItems,
} from './GenericFunctions';
import {
activityFields,
activityOperations,
} from './ActivityDescription';
import * as moment from 'moment';
export class Strava implements INodeType {
description: INodeTypeDescription = {
displayName: 'Strava',
name: 'strava',
icon: 'file:strava.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Strava API.',
defaults: {
name: 'Strava',
color: '#ea5929',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'stravaOAuth2Api',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Activity',
value: 'activity',
},
],
default: 'activity',
description: 'The resource to operate on.'
},
...activityOperations,
...activityFields,
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const length = (items.length as unknown) as number;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < length; i++) {
if (resource === 'activity') {
//https://developers.strava.com/docs/reference/#api-Activities-createActivity
if (operation === 'create') {
const name = this.getNodeParameter('name', i) as string;
const type = this.getNodeParameter('type', i) as string;
const startDate = this.getNodeParameter('startDate', i) as string;
const elapsedTime = this.getNodeParameter('elapsedTime', i) as number;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
if (additionalFields.trainer === true) {
additionalFields.trainer = 1;
}
if (additionalFields.commute === true) {
additionalFields.commute = 1;
}
const body: IDataObject = {
name,
type,
start_date_local: moment(startDate).toISOString(),
elapsed_time: elapsedTime,
};
Object.assign(body, additionalFields);
responseData = await stravaApiRequest.call(this, 'POST', '/activities', body);
}
//https://developers.strava.com/docs/reference/#api-Activities-getActivityById
if (operation === 'get') {
const activityId = this.getNodeParameter('activityId', i) as string;
responseData = await stravaApiRequest.call(this, 'GET', `/activities/${activityId}`);
}
if (['getLaps', 'getZones', 'getKudoers', 'getComments'].includes(operation)) {
const path: IDataObject = {
'getComments': 'comments',
'getZones': 'zones',
'getKudoers': 'kudoers',
'getLaps': 'laps',
};
const activityId = this.getNodeParameter('activityId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
responseData = await stravaApiRequest.call(this, 'GET', `/activities/${activityId}/${path[operation]}`);
if (returnAll === false) {
const limit = this.getNodeParameter('limit', i) as number;
responseData = responseData.splice(0, limit);
}
}
//https://developers.mailerlite.com/reference#subscribers
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
if (returnAll) {
responseData = await stravaApiRequestAllItems.call(this, 'GET', `/activities`, {}, qs);
} else {
qs.limit = this.getNodeParameter('limit', i) as number;
responseData = await stravaApiRequest.call(this, 'GET', `/activities`, {}, qs);
}
}
//https://developers.strava.com/docs/reference/#api-Activities-updateActivityById
if (operation === 'update') {
const activityId = this.getNodeParameter('activityId', i) as string;
const updateFields = this.getNodeParameter('updateFields', i) as IDataObject;
if (updateFields.trainer === true) {
updateFields.trainer = 1;
}
if (updateFields.commute === true) {
updateFields.commute = 1;
}
const body: IDataObject = {};
Object.assign(body, updateFields);
responseData = await stravaApiRequest.call(this, 'PUT', `/activities/${activityId}`, body);
}
}
}
if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]);
} else if (responseData !== undefined) {
returnData.push(responseData as IDataObject);
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,273 @@
import {
IHookFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
} from 'n8n-workflow';
import {
stravaApiRequest,
} from './GenericFunctions';
import {
randomBytes,
} from 'crypto';
export class StravaTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Strava Trigger',
name: 'stravTrigger',
icon: 'file:strava.svg',
group: ['trigger'],
version: 1,
description: 'Starts the workflow when a Github events occurs.',
defaults: {
name: 'Strava Trigger',
color: '#ea5929',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'stravaOAuth2Api',
required: true,
},
],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
path: 'webhook',
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Object',
name: 'object',
type: 'options',
options: [
{
name: '*',
value: '*',
},
{
name: 'Activity',
value: 'activity',
},
{
name: 'Athlete',
value: 'athlete',
},
],
default: '*',
},
{
displayName: 'Event',
name: 'event',
type: 'options',
options: [
{
name: '*',
value: '*',
},
{
name: 'created',
value: 'create',
},
{
name: 'Deleted',
value: 'delete',
},
{
name: 'Updated',
value: 'update',
},
],
default: '*',
},
{
displayName: 'Resolve Data',
name: 'resolveData',
type: 'boolean',
default: true,
description: 'By default the webhook-data only contain the Object ID. If this option gets activated it<br />will resolve the data automatically.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Delete If Exist',
name: 'deleteIfExist',
type: 'boolean',
default: false,
description: `Strava allows just one subscription at all times. If you want to delete the current subscription to make<br>
room for a new subcription with the current parameters, set this parameter to true. Keep in mind this is a destructive operation.`,
},
],
},
],
};
// @ts-ignore (because of request)
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default');
const webhookData = this.getWorkflowStaticData('node');
// Check all the webhooks which exist already if it is identical to the
// one that is supposed to get created.
const endpoint = '/push_subscriptions';
const webhooks = await stravaApiRequest.call(this, 'GET', endpoint, {});
for (const webhook of webhooks) {
if (webhook.callback_url === webhookUrl) {
webhookData.webhookId = webhook.id;
return true;
}
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default');
const endpoint = '/push_subscriptions';
const body = {
callback_url: webhookUrl,
verify_token: randomBytes(20).toString('hex') as string,
};
let responseData;
try {
responseData = await stravaApiRequest.call(this, 'POST', endpoint, body);
} catch (error) {
const errors = error.response.body.errors;
for (error of errors) {
// if there is a subscription already created
if (error.resource === 'PushSubscription' && error.code === 'already exists') {
const options = this.getNodeParameter('options') as IDataObject;
//get the current subscription
const webhooks = await stravaApiRequest.call(this, 'GET', `/push_subscriptions`, {});
if (options.deleteIfExist) {
// delete the subscription
await stravaApiRequest.call(this, 'DELETE', `/push_subscriptions/${webhooks[0].id}`);
// now there is room create a subscription with the n8n data
const body = {
callback_url: webhookUrl,
verify_token: randomBytes(20).toString('hex') as string,
};
responseData = await stravaApiRequest.call(this, 'POST', `/push_subscriptions`, body);
} else {
throw new Error(`A subscription already exist [${webhooks[0].callback_url}].
If you want to delete this subcription and create a new one with the current parameters please go to options and set delete if exist to true`);
}
}
}
}
if (responseData.id === undefined) {
// Required data is missing so was not successful
return false;
}
webhookData.webhookId = responseData.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const endpoint = `/push_subscriptions/${webhookData.webhookId}`;
try {
await stravaApiRequest.call(this, 'DELETE', endpoint);
} catch (e) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registred anymore
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const body = this.getBodyData() as IDataObject;
const query = this.getQueryData() as IDataObject;
const object = this.getNodeParameter('object');
const event = this.getNodeParameter('event');
const resolveData = this.getNodeParameter('resolveData') as boolean;
let objectType, eventType;
if (object === '*') {
objectType = ['activity', 'athlete'];
} else {
objectType = [object];
}
if (event === '*') {
eventType = ['create', 'update', 'delete'];
} else {
eventType = [event];
}
if (this.getWebhookName() === 'setup') {
if (query['hub.challenge']) {
// Is a create webhook confirmation request
const res = this.getResponseObject();
res.status(200).json({ 'hub.challenge': query['hub.challenge'] }).end();
return {
noWebhookResponse: true,
};
}
}
if (object !== '*' && !objectType.includes(body.object_type as string)) {
return {};
}
if (event !== '*' && !eventType.includes(body.aspect_type as string)) {
return {};
}
if (resolveData) {
let endpoint = `/athletes/${body.object_id}/stats`;
if (body.object_type === 'activity') {
endpoint = `/activities/${body.object_id}`;
}
body.object_data = await stravaApiRequest.call(this, 'GET', endpoint);
}
return {
workflowData: [
this.helpers.returnJsonArray(body)
],
};
}
}

View file

@ -0,0 +1 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="2500" height="2500"><path d="M0 0h16v16H0z" fill="#fc4c02"/><g fill="#fff" fill-rule="evenodd"><path d="M6.9 8.8l2.5 4.5 2.4-4.5h-1.5l-.9 1.7-1-1.7z" opacity=".6"/><path d="M7.2 2.5l3.1 6.3H4zm0 3.8l1.2 2.5H5.9z"/></g></svg>

After

Width:  |  Height:  |  Size: 291 B

View file

@ -155,6 +155,7 @@
"dist/credentials/SlackOAuth2Api.credentials.js",
"dist/credentials/Sms77Api.credentials.js",
"dist/credentials/Smtp.credentials.js",
"dist/credentials/StravaOAuth2Api.credentials.js",
"dist/credentials/StripeApi.credentials.js",
"dist/credentials/SalesmateApi.credentials.js",
"dist/credentials/SegmentApi.credentials.js",
@ -357,6 +358,8 @@
"dist/nodes/SpreadsheetFile.node.js",
"dist/nodes/SseTrigger.node.js",
"dist/nodes/Start.node.js",
"dist/nodes/Strava/Strava.node.js",
"dist/nodes/Strava/StravaTrigger.node.js",
"dist/nodes/Stripe/StripeTrigger.node.js",
"dist/nodes/Switch.node.js",
"dist/nodes/Salesmate/Salesmate.node.js",