first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,287 @@
import type {
IDataObject,
IExecuteSingleFunctions,
IN8nHttpFullResponse,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
async function processCampaignSearchResponse(
this: IExecuteSingleFunctions,
_inputData: INodeExecutionData[],
responseData: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
const results = ((responseData.body as IDataObject).results as GoogleAdsCampaignElement) ?? [];
return results.map((result) => ({
json: {
...result.campaign,
...result.metrics,
...result.campaignBudget,
},
}));
}
export const campaignOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['campaign'],
},
},
options: [
{
name: 'Get Many',
value: 'getAll',
description: 'Get many campaigns linked to the specified account',
routing: {
request: {
method: 'POST',
url: '={{"/v20/customers/" + $parameter["clientCustomerId"].toString().replace(/-/g, "") + "/googleAds:search"}}',
body: {
query:
'={{ "' +
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id > 0 ' + // create a dummy where clause so we can append more conditions
'" + (["allTime", undefined, ""].includes($parameter.additionalOptions?.dateRange) ? "" : " and segments.date DURING " + $parameter.additionalOptions.dateRange) + " ' +
'" + (["all", undefined, ""].includes($parameter.additionalOptions?.campaignStatus) ? "" : " and campaign.status = \'" + $parameter.additionalOptions.campaignStatus + "\'") + "' +
'" }}',
},
headers: {
'login-customer-id':
'={{$parameter["managerCustomerId"].toString().replace(/-/g, "")}}',
},
},
output: {
postReceive: [processCampaignSearchResponse],
},
},
action: 'Get many campaigns',
},
{
name: 'Get',
value: 'get',
description: 'Get a specific campaign',
routing: {
request: {
method: 'POST',
url: '={{"/v20/customers/" + $parameter["clientCustomerId"].toString().replace(/-/g, "") + "/googleAds:search"}}',
returnFullResponse: true,
body: {
query:
'={{ "' +
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id = " + $parameter["campaignId"].toString().replace(/-/g, "")' +
'}}',
},
headers: {
'login-customer-id':
'={{$parameter["managerCustomerId"].toString().replace(/-/g, "")}}',
},
},
output: {
postReceive: [processCampaignSearchResponse],
},
},
action: 'Get a campaign',
},
],
default: 'getAll',
},
];
export const campaignFields: INodeProperties[] = [
{
displayName: 'Manager Customer ID',
name: 'managerCustomerId',
type: 'string',
required: true,
placeholder: '9998887777',
displayOptions: {
show: {
resource: ['campaign'],
},
},
default: '',
},
{
displayName: 'Client Customer ID',
name: 'clientCustomerId',
type: 'string',
required: true,
placeholder: '6665554444',
displayOptions: {
show: {
resource: ['campaign'],
},
},
default: '',
},
{
displayName: 'Campaign ID',
name: 'campaignId',
type: 'string',
required: true,
displayOptions: {
show: {
operation: ['get'],
resource: ['campaign'],
},
},
default: '',
description: 'ID of the campaign',
},
{
displayName: 'Additional Options',
name: 'additionalOptions',
type: 'collection',
displayOptions: {
show: {
resource: ['campaign'],
operation: ['getAll'],
},
},
default: {},
description: 'Additional options for fetching campaigns',
placeholder: 'Add option',
options: [
{
displayName: 'Date Range',
name: 'dateRange',
description: 'Filters statistics by period',
type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'All Time',
value: 'allTime',
description: 'Fetch statistics for all period',
},
{
name: 'Today',
value: 'TODAY',
description: 'Today only',
},
{
name: 'Yesterday',
value: 'YESTERDAY',
description: 'Yesterday only',
},
{
name: 'Last 7 Days',
value: 'LAST_7_DAYS',
description: 'Last 7 days, not including today',
},
{
name: 'Last Business Week',
value: 'LAST_BUSINESS_WEEK',
description:
'The 5 day business week, Monday through Friday, of the previous business week',
},
{
name: 'This Month',
value: 'THIS_MONTH',
description: 'All days in the current month',
},
{
name: 'Last Month',
value: 'LAST_MONTH',
description: 'All days in the previous month',
},
{
name: 'Last 14 Days',
value: 'LAST_14_DAYS',
description: 'The last 14 days not including today',
},
{
name: 'Last 30 Days',
value: 'LAST_30_DAYS',
description: 'The last 30 days not including today',
},
],
default: 'allTime',
},
{
displayName: 'Show Campaigns by Status',
name: 'campaignStatus',
description: 'Filters campaigns by status',
type: 'options',
options: [
{
name: 'All',
value: 'all',
description: 'Fetch all campaigns regardless of status',
},
{
name: 'Enabled',
value: 'ENABLED',
description: 'Filter only active campaigns',
},
{
name: 'Paused',
value: 'PAUSED',
description: 'Filter only paused campaigns',
},
{
name: 'Removed',
value: 'REMOVED',
description: 'Filter only removed campaigns',
},
],
default: 'all',
},
],
},
];
type GoogleAdsCampaignElement = [
{
campaign: object;
metrics: object;
campaignBudget: object;
},
];
@@ -0,0 +1,25 @@
{
"node": "n8n-nodes-base.googleAds",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Analytics"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googleads/"
}
],
"generic": [
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
}
]
}
}
@@ -0,0 +1,73 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { campaignFields, campaignOperations } from './CampaignDescription';
export class GoogleAds implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Ads',
name: 'googleAds',
icon: 'file:googleAds.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Use the Google Ads API',
schemaPath: 'Google/Ads',
defaults: {
name: 'Google Ads',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleAdsOAuth2Api',
required: true,
testedBy: {
request: {
method: 'GET',
url: '/v20/customers:listAccessibleCustomers',
},
},
},
],
requestDefaults: {
returnFullResponse: true,
baseURL: 'https://googleads.googleapis.com',
headers: {
'developer-token': '={{$credentials.developerToken}}',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Campaign',
value: 'campaign',
},
],
default: 'campaign',
},
//-------------------------------
// Campaign Operations
//-------------------------------
...campaignOperations,
{
displayName:
'Divide field names expressed with <i>micros</i> by 1,000,000 to get the actual value',
name: 'campaigsNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
resource: ['campaign'],
},
},
},
...campaignFields,
],
};
}
@@ -0,0 +1,51 @@
{
"type": "object",
"properties": {
"advertisingChannelType": {
"type": "string"
},
"amountMicros": {
"type": "string"
},
"averageCost": {
"type": "number"
},
"averageCpm": {
"type": "number"
},
"costMicros": {
"type": "string"
},
"ctr": {
"type": "number"
},
"id": {
"type": "string"
},
"impressions": {
"type": "string"
},
"interactionRate": {
"type": "number"
},
"interactions": {
"type": "string"
},
"name": {
"type": "string"
},
"period": {
"type": "string"
},
"resourceName": {
"type": "string"
},
"status": {
"type": "string"
},
"videoViews": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,48 @@
{
"type": "object",
"properties": {
"advertisingChannelType": {
"type": "string"
},
"amountMicros": {
"type": "string"
},
"averageCpm": {
"type": "number"
},
"costMicros": {
"type": "string"
},
"ctr": {
"type": "number"
},
"id": {
"type": "string"
},
"impressions": {
"type": "string"
},
"interactionRate": {
"type": "number"
},
"interactions": {
"type": "string"
},
"name": {
"type": "string"
},
"period": {
"type": "string"
},
"resourceName": {
"type": "string"
},
"status": {
"type": "string"
},
"videoViews": {
"type": "string"
}
},
"version": 6
}
@@ -0,0 +1,161 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import getResult from './fixtures/get.json';
import getManyResult from './fixtures/getMany.json';
describe('Google Ads Node', () => {
const credentials = {
googleAdsOAuth2Api: {
oauthTokenData: {
access_token: 'access-token',
},
developerToken: 'test-developer-token',
},
};
describe('get', () => {
const googleAdsNock = nock('https://googleads.googleapis.com');
beforeAll(() => {
googleAdsNock
.post('/v20/customers/4445556666/googleAds:search', {
query:
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id = 12345678901',
})
.matchHeader('login-customer-id', '1112223333')
.reply(200, getResult);
});
afterAll(() => googleAdsNock.done());
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['get.workflow.json'],
});
});
describe('getMany', () => {
const googleAdsNock = nock('https://googleads.googleapis.com');
beforeAll(() => {
googleAdsNock
.post('/v20/customers/4445556666/googleAds:search', {
query:
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id > 0 ',
})
.matchHeader('login-customer-id', '1112223333')
.reply(200, getManyResult);
googleAdsNock
.post('/v20/customers/4445556666/googleAds:search', {
query:
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id > 0 ' +
' and segments.date DURING LAST_7_DAYS ' +
" and campaign.status = 'ENABLED'",
})
.matchHeader('login-customer-id', '1112223333')
.reply(200, getManyResult);
googleAdsNock
.post('/v20/customers/4445556666/googleAds:search', {
query:
'select ' +
'campaign.id, ' +
'campaign.name, ' +
'campaign_budget.amount_micros, ' +
'campaign_budget.period,' +
'campaign.status,' +
'campaign.optimization_score,' +
'campaign.advertising_channel_type,' +
'campaign.advertising_channel_sub_type,' +
'metrics.impressions,' +
'metrics.interactions,' +
'metrics.interaction_rate,' +
'metrics.average_cost,' +
'metrics.cost_micros,' +
'metrics.conversions,' +
'metrics.cost_per_conversion,' +
'metrics.conversions_from_interactions_rate,' +
'metrics.video_views,' +
'metrics.average_cpm,' +
'metrics.ctr ' +
'from campaign ' +
'where campaign.id > 0 ' +
" and campaign.status = 'REMOVED'",
})
.matchHeader('login-customer-id', '1112223333')
.reply(200, { ...getManyResult, results: undefined });
});
afterAll(() => googleAdsNock.done());
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['getMany.workflow.json'],
});
});
});
@@ -0,0 +1,27 @@
{
"results": [
{
"campaign": {
"resourceName": "customers/5171368254/campaigns/22682295812",
"status": "ENABLED",
"advertisingChannelType": "SEARCH",
"name": "Search-1",
"id": "22682295812"
},
"metrics": {
"videoViews": "0",
"conversions": 0,
"costMicros": "0",
"impressions": "0",
"interactions": "0"
},
"campaignBudget": {
"resourceName": "customers/5171368254/campaignBudgets/14669466664",
"period": "DAILY",
"amountMicros": "10000000"
}
}
],
"fieldMask": "campaign.id,campaign.name,campaignBudget.amountMicros,campaignBudget.period,campaign.status,campaign.optimizationScore,campaign.advertisingChannelType,campaign.advertisingChannelSubType,metrics.impressions,metrics.interactions,metrics.interactionRate,metrics.averageCost,metrics.costMicros,metrics.conversions,metrics.costPerConversion,metrics.conversionsFromInteractionsRate,metrics.videoViews,metrics.averageCpm,metrics.ctr",
"queryResourceConsumption": "596"
}
@@ -0,0 +1,27 @@
{
"results": [
{
"campaign": {
"resourceName": "customers/5171368254/campaigns/22682295812",
"status": "ENABLED",
"advertisingChannelType": "SEARCH",
"name": "Search-1",
"id": "22682295812"
},
"metrics": {
"videoViews": "0",
"conversions": 0,
"costMicros": "0",
"impressions": "0",
"interactions": "0"
},
"campaignBudget": {
"resourceName": "customers/5171368254/campaignBudgets/14669466664",
"period": "DAILY",
"amountMicros": "10000000"
}
}
],
"fieldMask": "campaign.id,campaign.name,campaignBudget.amountMicros,campaignBudget.period,campaign.status,campaign.optimizationScore,campaign.advertisingChannelType,campaign.advertisingChannelSubType,metrics.impressions,metrics.interactions,metrics.interactionRate,metrics.averageCost,metrics.costMicros,metrics.conversions,metrics.costPerConversion,metrics.conversionsFromInteractionsRate,metrics.videoViews,metrics.averageCpm,metrics.ctr",
"queryResourceConsumption": "570"
}
@@ -0,0 +1,77 @@
{
"name": "Google Ads Get",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, -160],
"id": "efd3f04e-8695-4f2c-8f8d-0b0166390d3f",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"operation": "get",
"managerCustomerId": "111-222-3333",
"clientCustomerId": "444-555-6666",
"campaignId": "12345678901",
"requestOptions": {}
},
"type": "n8n-nodes-base.googleAds",
"typeVersion": 1,
"position": [220, -160],
"id": "773cca04-b4be-4bfd-8fb4-e37154df2991",
"name": "Get",
"credentials": {
"googleAdsOAuth2Api": {
"id": "VFEJAm9y8GDEsnS4",
"name": "Google Ads account"
}
}
}
],
"pinData": {
"Get": [
{
"json": {
"resourceName": "customers/5171368254/campaignBudgets/14669466664",
"status": "ENABLED",
"advertisingChannelType": "SEARCH",
"name": "Search-1",
"id": "22682295812",
"videoViews": "0",
"conversions": 0,
"costMicros": "0",
"impressions": "0",
"interactions": "0",
"period": "DAILY",
"amountMicros": "10000000"
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Get",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "b72b855a-9f8a-4872-9311-6e65940e85f1",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "e115be144a6a5547dbfca93e774dfffa178aa94a181854c13e2ce5e14d195b2e"
},
"id": "Ffm7CA1AIhKGiOlK",
"tags": []
}
@@ -0,0 +1,148 @@
{
"name": "Google Ads Get Many",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 40],
"id": "efd3f04e-8695-4f2c-8f8d-0b0166390d3f",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"managerCustomerId": "111-222-3333",
"clientCustomerId": "444-555-6666",
"additionalOptions": {},
"requestOptions": {}
},
"type": "n8n-nodes-base.googleAds",
"typeVersion": 1,
"position": [220, -160],
"id": "773cca04-b4be-4bfd-8fb4-e37154df2991",
"name": "Get Many",
"credentials": {
"googleAdsOAuth2Api": {
"id": "VFEJAm9y8GDEsnS4",
"name": "Google Ads account"
}
}
},
{
"parameters": {
"managerCustomerId": "111-222-3333",
"clientCustomerId": "444-555-6666",
"additionalOptions": {
"dateRange": "LAST_7_DAYS",
"campaignStatus": "ENABLED"
},
"requestOptions": {}
},
"type": "n8n-nodes-base.googleAds",
"typeVersion": 1,
"position": [220, 40],
"id": "3e913683-85bf-4295-98cd-2c8324d97094",
"name": "With Options",
"credentials": {
"googleAdsOAuth2Api": {
"id": "VFEJAm9y8GDEsnS4",
"name": "Google Ads account"
}
}
},
{
"parameters": {
"managerCustomerId": "111-222-3333",
"clientCustomerId": "444-555-6666",
"additionalOptions": {
"campaignStatus": "REMOVED"
},
"requestOptions": {}
},
"type": "n8n-nodes-base.googleAds",
"typeVersion": 1,
"position": [220, 240],
"id": "7043d66a-a68c-495b-b6db-ffc8f1551065",
"name": "No Results",
"credentials": {
"googleAdsOAuth2Api": {
"id": "VFEJAm9y8GDEsnS4",
"name": "Google Ads account"
}
}
}
],
"pinData": {
"Get Many": [
{
"json": {
"resourceName": "customers/5171368254/campaignBudgets/14669466664",
"status": "ENABLED",
"advertisingChannelType": "SEARCH",
"name": "Search-1",
"id": "22682295812",
"videoViews": "0",
"conversions": 0,
"costMicros": "0",
"impressions": "0",
"interactions": "0",
"period": "DAILY",
"amountMicros": "10000000"
}
}
],
"With Options": [
{
"json": {
"resourceName": "customers/5171368254/campaignBudgets/14669466664",
"status": "ENABLED",
"advertisingChannelType": "SEARCH",
"name": "Search-1",
"id": "22682295812",
"videoViews": "0",
"conversions": 0,
"costMicros": "0",
"impressions": "0",
"interactions": "0",
"period": "DAILY",
"amountMicros": "10000000"
}
}
],
"No Results": []
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Get Many",
"type": "main",
"index": 0
},
{
"node": "With Options",
"type": "main",
"index": 0
},
{
"node": "No Results",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "a77e4f29-8caa-4d3b-a7f9-9232c1234b93",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "e115be144a6a5547dbfca93e774dfffa178aa94a181854c13e2ce5e14d195b2e"
},
"id": "Ffm7CA1AIhKGiOlK",
"tags": []
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="230" preserveAspectRatio="xMidYMid"><path fill="#FBBC04" d="M5.888 166.405 90.88 20.9c10.796 6.356 65.236 36.484 74.028 42.214L79.916 208.627c-9.295 12.28-85.804-23.587-74.028-42.23z"/><path fill="#4285F4" d="M250.084 166.402 165.092 20.906C153.21 1.132 127.62-6.054 106.601 5.625S79.182 42.462 91.064 63.119l84.992 145.514c11.882 19.765 37.473 26.95 58.492 15.272 20.1-11.68 27.418-37.73 15.536-57.486z"/><ellipse cx="42.664" cy="187.924" fill="#34A853" rx="42.664" ry="41.604"/></svg>

After

Width:  |  Height:  |  Size: 546 B

@@ -0,0 +1,25 @@
{
"node": "n8n-nodes-base.googleAnalytics",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Analytics"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googleanalytics/"
}
],
"generic": [
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
}
]
}
}
@@ -0,0 +1,27 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { GoogleAnalyticsV1 } from './v1/GoogleAnalyticsV1.node';
import { GoogleAnalyticsV2 } from './v2/GoogleAnalyticsV2.node';
export class GoogleAnalytics extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Google Analytics',
name: 'googleAnalytics',
icon: 'file:analytics.svg',
group: ['transform'],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Use the Google Analytics API',
defaultVersion: 2,
schemaPath: 'Google/Analytics',
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new GoogleAnalyticsV1(baseDescription),
2: new GoogleAnalyticsV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1,54 @@
{
"type": "object",
"properties": {
"date": {
"type": "string"
},
"dimensionHeaders": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}
},
"kind": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"currencyCode": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"metricHeaders": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
},
"sessions": {
"type": "string"
},
"totalUsers": {
"type": "string"
}
},
"version": 6
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192"><path fill="none" d="M0 0h192v192H0z"/><path fill="#F9AB00" d="M130 29v132c0 14.77 10.19 23 21 23 10 0 21-7 21-23V30c0-13.54-10-22-21-22s-21 9.33-21 21"/><path fill="#E37400" d="M75 96v65c0 14.77 10.19 23 21 23 10 0 21-7 21-23V97c0-13.54-10-22-21-22s-21 9.33-21 21"/><circle cx="41" cy="163" r="21" fill="#E37400"/></svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1,104 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
describe('GoogleAnalyticsV2', () => {
const credentials = {
googleAnalyticsOAuth2: {
scope: '',
oauthTokenData: {
access_token: 'ACCESSTOKEN',
},
},
};
describe('Report Resource - GA4 Get Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsdata.googleapis.com');
mock.post('/v1beta/properties/123456789:runReport').reply(200, {
dimensionHeaders: [{ name: 'date' }],
metricHeaders: [{ name: 'totalUsers', type: 'TYPE_INTEGER' }],
rows: [
{
dimensionValues: [{ value: '20240101' }],
metricValues: [{ value: '100' }],
},
],
rowCount: 1,
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['report-ga4-get.workflow.json'],
});
});
describe('Report Resource - Universal Analytics Get Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsreporting.googleapis.com');
mock.post('/v4/reports:batchGet').reply(200, {
reports: [
{
columnHeader: {
dimensions: ['ga:date'],
metricHeader: {
metricHeaderEntries: [
{ name: 'ga:users', type: 'INTEGER' },
{ name: 'ga:sessions', type: 'INTEGER' },
],
},
},
data: {
rows: [
{
dimensions: ['20240101'],
metrics: [{ values: ['100', '50'] }],
},
],
},
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['report-universal-get.workflow.json'],
});
});
describe('UserActivity Resource - Search Operation', () => {
beforeAll(() => {
const mock = nock('https://analyticsreporting.googleapis.com');
mock.post('/v4/userActivity:search').reply(200, {
sessions: [
{
sessionId: 'session123',
deviceCategory: 'desktop',
platform: 'web',
dataSource: 'web',
activities: [
{
activityTime: '2024-01-01T10:00:00Z',
source: 'web',
medium: 'organic',
channelGrouping: 'Organic Search',
campaign: 'spring_sale',
keyword: 'analytics',
hostname: 'example.com',
},
],
},
],
});
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['useractivity-search.workflow.json'],
});
});
});
@@ -0,0 +1,80 @@
{
"name": "Google Analytics GA4 Report Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "report",
"operation": "get",
"propertyType": "ga4",
"propertyId": {
"mode": "id",
"value": "123456789"
},
"dateRange": "last7days",
"metricsGA4": {
"metricValues": [
{
"listName": "totalUsers"
}
]
},
"dimensionsGA4": {
"dimensionValues": [
{
"listName": "date"
}
]
},
"returnAll": false,
"limit": 10,
"simple": true
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "ga4-report-node",
"name": "GA4 Report",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"GA4 Report": [
{
"json": {
"date": "20240101",
"totalUsers": "100"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "GA4 Report",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,83 @@
{
"name": "Google Analytics Universal Report Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "report",
"operation": "get",
"propertyType": "universal",
"viewId": {
"mode": "id",
"value": "123456789"
},
"dateRange": "last7days",
"metricsUA": {
"metricValues": [
{
"listName": "ga:users"
},
{
"listName": "ga:sessions"
}
]
},
"dimensionsUA": {
"dimensionValues": [
{
"listName": "ga:date"
}
]
},
"returnAll": false,
"limit": 10
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "universal-report-node",
"name": "Universal Report",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"Universal Report": [
{
"json": {
"ga:date": "20240101",
"ga:users": "100",
"ga:sessions": "50"
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "Universal Report",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,77 @@
{
"name": "Google Analytics UserActivity Search Test Workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute Workflow'"
},
{
"parameters": {
"resource": "userActivity",
"operation": "search",
"viewId": "123456789",
"userId": "user123",
"returnAll": false,
"limit": 100,
"additionalFields": {
"activityTypes": ["PAGEVIEW", "EVENT"]
}
},
"type": "n8n-nodes-base.googleAnalytics",
"typeVersion": 2,
"position": [200, 0],
"id": "useractivity-search-node",
"name": "UserActivity Search",
"credentials": {
"googleAnalyticsOAuth2": {
"id": "ga-oauth-cred-id",
"name": "Google Analytics OAuth2"
}
}
}
],
"pinData": {
"UserActivity Search": [
{
"json": {
"sessionId": "session123",
"deviceCategory": "desktop",
"platform": "web",
"dataSource": "web",
"activities": [
{
"activityTime": "2024-01-01T10:00:00Z",
"source": "web",
"medium": "organic",
"channelGrouping": "Organic Search",
"campaign": "spring_sale",
"keyword": "analytics",
"hostname": "example.com"
}
]
}
}
]
},
"connections": {
"When clicking 'Execute Workflow'": {
"main": [
[
{
"node": "UserActivity Search",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,135 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IDataObject,
JsonObject,
IHttpRequestMethods,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
qs: IDataObject = {},
uri?: string,
option: IDataObject = {},
) {
const baseURL = 'https://analyticsreporting.googleapis.com';
let options: IRequestOptions = {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `${baseURL}${endpoint}`,
json: true,
};
options = Object.assign({}, options, option);
try {
if (Object.keys(body).length === 0) {
delete options.body;
}
if (Object.keys(qs).length === 0) {
delete options.qs;
}
return await this.helpers.requestOAuth2.call(this, 'googleAnalyticsOAuth2', options);
} catch (error) {
const errorData = (error.message || '').split(' - ')[1] as string;
if (errorData) {
const parsedError = JSON.parse(errorData.trim());
const [message, ...rest] = parsedError.error.message.split('\n');
const description = rest.join('\n');
const httpCode = parsedError.error.code;
throw new NodeApiError(this.getNode(), error as JsonObject, {
message,
description,
httpCode,
});
}
throw new NodeApiError(this.getNode(), error as JsonObject, { message: error.message });
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
query: IDataObject = {},
uri?: string,
) {
const returnData: IDataObject[] = [];
let responseData;
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri);
if (body.reportRequests && Array.isArray(body.reportRequests)) {
(body.reportRequests as IDataObject[])[0].pageToken =
responseData[propertyName][0].nextPageToken;
} else {
body.pageToken = responseData.nextPageToken;
}
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (
(responseData.nextPageToken !== undefined && responseData.nextPageToken !== '') ||
responseData[propertyName]?.[0].nextPageToken !== undefined
);
return returnData;
}
export function simplify(responseData: any | [any]) {
const response = [];
for (const {
columnHeader: { dimensions, metricHeader },
data: { rows },
} of responseData) {
if (rows === undefined) {
// Do not error if there is no data
continue;
}
const metrics = metricHeader.metricHeaderEntries.map((entry: { name: string }) => entry.name);
for (const row of rows) {
const data: IDataObject = {};
if (dimensions) {
for (let i = 0; i < dimensions.length; i++) {
data[dimensions[i]] = row.dimensions[i];
for (const [index, metric] of metrics.entries()) {
data[metric] = row.metrics[0].values[index];
}
}
} else {
for (const [index, metric] of metrics.entries()) {
data[metric] = row.metrics[0].values[index];
}
}
response.push(data);
}
}
return response;
}
export function merge(responseData: [any]) {
const response: { columnHeader: IDataObject; data: { rows: [] } } = {
columnHeader: responseData[0].columnHeader,
data: responseData[0].data,
};
const allRows = [];
for (const {
data: { rows },
} of responseData) {
allRows.push(...(rows as IDataObject[]));
}
response.data.rows = allRows as [];
return [response];
}
@@ -0,0 +1,308 @@
import moment from 'moment-timezone';
import {
type IExecuteFunctions,
type IDataObject,
type ILoadOptionsFunctions,
type INodeExecutionData,
type INodePropertyOptions,
type INodeType,
type INodeTypeBaseDescription,
type INodeTypeDescription,
type IHttpRequestMethods,
NodeConnectionTypes,
} from 'n8n-workflow';
import { oldVersionNotice } from '@utils/descriptions';
import { googleApiRequest, googleApiRequestAllItems, merge, simplify } from './GenericFunctions';
import type { IData } from './Interfaces';
import { reportFields, reportOperations } from './ReportDescription';
import { userActivityFields, userActivityOperations } from './UserActivityDescription';
const versionDescription: INodeTypeDescription = {
displayName: 'Google Analytics',
name: 'googleAnalytics',
icon: 'file:analytics.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Use the Google Analytics API',
defaults: {
name: 'Google Analytics',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleAnalyticsOAuth2',
required: true,
},
],
properties: [
oldVersionNotice,
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Report',
value: 'report',
},
{
name: 'User Activity',
value: 'userActivity',
},
],
default: 'report',
},
//-------------------------------
// Reports Operations
//-------------------------------
...reportOperations,
...reportFields,
//-------------------------------
// User Activity Operations
//-------------------------------
...userActivityOperations,
...userActivityFields,
],
};
export class GoogleAnalyticsV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions: {
// Get all the dimensions to display them to user so that they can
// select them easily
async getDimensions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { items: dimensions } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/metadata/ga/columns',
);
for (const dimension of dimensions) {
if (
dimension.attributes.type === 'DIMENSION' &&
dimension.attributes.status !== 'DEPRECATED'
) {
returnData.push({
name: dimension.attributes.uiName,
value: dimension.id,
description: dimension.attributes.description,
});
}
}
returnData.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
return returnData;
},
// Get all the views to display them to user so that they can
// select them easily
async getViews(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { items } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/management/accounts/~all/webproperties/~all/profiles',
);
for (const item of items) {
returnData.push({
name: item.name,
value: item.id,
description: item.websiteUrl,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
let method: IHttpRequestMethods = 'GET';
const qs: IDataObject = {};
let endpoint = '';
let responseData;
for (let i = 0; i < items.length; i++) {
try {
if (resource === 'report') {
if (operation === 'get') {
//https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet
method = 'POST';
endpoint = '/v4/reports:batchGet';
const viewId = this.getNodeParameter('viewId', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', i);
const simple = this.getNodeParameter('simple', i) as boolean;
const body: IData = {
viewId,
};
if (additionalFields.useResourceQuotas) {
qs.useResourceQuotas = additionalFields.useResourceQuotas;
}
if (additionalFields.dateRangesUi) {
const dateValues = (additionalFields.dateRangesUi as IDataObject)
.dateRanges as IDataObject;
if (dateValues) {
const start = dateValues.startDate as string;
const end = dateValues.endDate as string;
Object.assign(body, {
dateRanges: [
{
startDate: moment(start).utc().format('YYYY-MM-DD'),
endDate: moment(end).utc().format('YYYY-MM-DD'),
},
],
});
}
}
if (additionalFields.metricsUi) {
const metrics = (additionalFields.metricsUi as IDataObject)
.metricValues as IDataObject[];
body.metrics = metrics;
}
if (additionalFields.dimensionUi) {
const dimensions = (additionalFields.dimensionUi as IDataObject)
.dimensionValues as IDataObject[];
if (dimensions) {
body.dimensions = dimensions;
}
}
if (additionalFields.dimensionFiltersUi) {
const dimensionFilters = (additionalFields.dimensionFiltersUi as IDataObject)
.filterValues as IDataObject[];
if (dimensionFilters) {
dimensionFilters.forEach((filter) => (filter.expressions = [filter.expressions]));
body.dimensionFilterClauses = { filters: dimensionFilters };
}
}
if (additionalFields.includeEmptyRows) {
Object.assign(body, { includeEmptyRows: additionalFields.includeEmptyRows });
}
if (additionalFields.hideTotals) {
Object.assign(body, { hideTotals: additionalFields.hideTotals });
}
if (additionalFields.hideValueRanges) {
Object.assign(body, { hideTotals: additionalFields.hideTotals });
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'reports',
method,
endpoint,
{ reportRequests: [body] },
qs,
);
} else {
responseData = await googleApiRequest.call(
this,
method,
endpoint,
{ reportRequests: [body] },
qs,
);
responseData = responseData.reports;
}
if (simple) {
responseData = simplify(responseData);
} else if (returnAll && responseData.length > 1) {
responseData = merge(responseData);
}
}
}
if (resource === 'userActivity') {
if (operation === 'search') {
//https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/userActivity/search
method = 'POST';
endpoint = '/v4/userActivity:search';
const viewId = this.getNodeParameter('viewId', i);
const userId = this.getNodeParameter('userId', i);
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {
viewId,
user: {
userId,
},
};
if (additionalFields.activityTypes) {
Object.assign(body, { activityTypes: additionalFields.activityTypes });
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'sessions',
method,
endpoint,
body,
);
} else {
body.pageSize = this.getNodeParameter('limit', 0);
responseData = await googleApiRequest.call(this, method, endpoint, body);
responseData = responseData.sessions;
}
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
}
@@ -0,0 +1,28 @@
import type { IDataObject } from 'n8n-workflow';
export interface IData {
viewId: string;
dimensions?: IDimension[];
dimensionFilterClauses?: {
filters: IDimensionFilter[];
};
pageSize?: number;
metrics?: IMetric[];
dateRanges?: IDataObject[];
}
export interface IDimension {
name?: string;
histogramBuckets?: string[];
}
export interface IDimensionFilter {
dimensionName?: string;
operator?: string;
expressions?: string[];
}
export interface IMetric {
expression?: string;
alias?: string;
formattingType?: string;
}
@@ -0,0 +1,339 @@
import type { INodeProperties } from 'n8n-workflow';
export const reportOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['report'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Return the analytics data',
action: 'Get a report',
},
],
default: 'get',
},
];
export const reportFields: INodeProperties[] = [
{
displayName: 'View Name or ID',
name: 'viewId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getViews',
},
default: '',
required: true,
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
},
},
placeholder: '123456',
description:
'The View ID of Google Analytics. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 1000,
},
default: 1000,
description: 'Max number of results to return',
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
},
},
default: true,
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
},
},
options: [
{
displayName: 'Date Ranges',
name: 'dateRangesUi',
placeholder: 'Add Date Range',
type: 'fixedCollection',
default: {},
description: 'Date ranges in the request',
options: [
{
displayName: 'Date Range',
name: 'dateRanges',
values: [
{
displayName: 'Start Date',
name: 'startDate',
type: 'dateTime',
default: '',
},
{
displayName: 'End Date',
name: 'endDate',
type: 'dateTime',
default: '',
},
],
},
],
},
{
displayName: 'Dimensions',
name: 'dimensionUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Dimension',
description:
'Dimensions are attributes of your data. For example, the dimension ga:city indicates the city, for example, "Paris" or "New York", from which a session originates.',
options: [
{
displayName: 'Dimension',
name: 'dimensionValues',
values: [
{
displayName: 'Name or ID',
name: 'name',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDimensions',
},
default: '',
description:
'Name of the dimension to fetch, for example ga:browser. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
],
},
],
},
{
displayName: 'Dimension Filters',
name: 'dimensionFiltersUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Dimension Filter',
description: 'Dimension Filters in the request',
options: [
{
displayName: 'Filters',
name: 'filterValues',
values: [
{
displayName: 'Dimension Name or ID',
name: 'dimensionName',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDimensions',
},
default: '',
description:
'Name of the dimension to filter by. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
// https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#Operator
{
displayName: 'Operator',
name: 'operator',
type: 'options',
default: 'EXACT',
description: 'Operator to use in combination with value',
options: [
{
name: 'Begins With',
value: 'BEGINS_WITH',
},
{
name: 'Ends With',
value: 'ENDS_WITH',
},
{
name: 'Equal (Number)',
value: 'NUMERIC_EQUAL',
},
{
name: 'Exact',
value: 'EXACT',
},
{
name: 'Greater Than (Number)',
value: 'NUMERIC_GREATER_THAN',
},
{
name: 'Less Than (Number)',
value: 'NUMERIC_LESS_THAN',
},
{
name: 'Partial',
value: 'PARTIAL',
},
{
name: 'Regular Expression',
value: 'REGEXP',
},
],
},
{
displayName: 'Value',
name: 'expressions',
type: 'string',
default: '',
placeholder: 'ga:newUsers',
description:
'String or <a href="https://support.google.com/analytics/answer/1034324?hl=en">regular expression</a> to match against',
},
],
},
],
},
{
displayName: 'Hide Totals',
name: 'hideTotals',
type: 'boolean',
default: false,
description:
'Whether to hide the total of all metrics for all the matching rows, for every date range',
},
{
displayName: 'Hide Value Ranges',
name: 'hideValueRanges',
type: 'boolean',
default: false,
description: 'Whether to hide the minimum and maximum across all matching rows',
},
{
displayName: 'Include Empty Rows',
name: 'includeEmptyRows',
type: 'boolean',
default: false,
description:
'Whether the response exclude rows if all the retrieved metrics are equal to zero',
},
{
displayName: 'Metrics',
name: 'metricsUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Metrics',
description: 'Metrics in the request',
options: [
{
displayName: 'Metric',
name: 'metricValues',
values: [
{
displayName: 'Alias',
name: 'alias',
type: 'string',
default: '',
description:
'An alias for the metric expression is an alternate name for the expression. The alias can be used for filtering and sorting.',
},
{
displayName: 'Expression',
name: 'expression',
type: 'string',
default: 'ga:newUsers',
description:
'<p>A metric expression in the request. An expression is constructed from one or more metrics and numbers.</p><p>Accepted operators include: Plus (+), Minus (-), Negation (Unary -), Divided by (/), Multiplied by (*), Parenthesis, Positive cardinal numbers (0-9), can include decimals and is limited to 1024 characters.</p><p>Example ga:totalRefunds/ga:users, in most cases the metric expression is just a single metric name like ga:users.</p><p>Adding mixed MetricType (E.g., CURRENCY + PERCENTAGE) metrics will result in unexpected results.</p>.',
},
{
displayName: 'Formatting Type',
name: 'formattingType',
type: 'options',
default: 'INTEGER',
description: 'Specifies how the metric expression should be formatted',
options: [
{
name: 'Currency',
value: 'CURRENCY',
},
{
name: 'Float',
value: 'FLOAT',
},
{
name: 'Integer',
value: 'INTEGER',
},
{
name: 'Percent',
value: 'PERCENT',
},
{
name: 'Time',
value: 'TIME',
},
],
},
],
},
],
},
{
displayName: 'Use Resource Quotas',
name: 'useResourceQuotas',
type: 'boolean',
default: false,
description: 'Whether to enable resource based quotas',
},
],
},
];
@@ -0,0 +1,136 @@
import type { INodeProperties } from 'n8n-workflow';
export const userActivityOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['userActivity'],
},
},
options: [
{
name: 'Search',
value: 'search',
description: 'Return user activity data',
action: 'Search user activity data',
},
],
default: 'search',
},
];
export const userActivityFields: INodeProperties[] = [
{
displayName: 'View Name or ID',
name: 'viewId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getViews',
},
default: '',
required: true,
displayOptions: {
show: {
resource: ['userActivity'],
operation: ['search'],
},
},
placeholder: '123456',
description:
'The View ID of Google Analytics. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['userActivity'],
operation: ['search'],
},
},
placeholder: '123456',
description: 'ID of a user',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
},
},
options: [
{
displayName: 'Activity Types',
name: 'activityTypes',
type: 'multiOptions',
options: [
{
name: 'Ecommerce',
value: 'ECOMMERCE',
},
{
name: 'Event',
value: 'EVENT',
},
{
name: 'Goal',
value: 'GOAL',
},
{
name: 'Pageview',
value: 'PAGEVIEW',
},
{
name: 'Screenview',
value: 'SCREENVIEW',
},
],
description: 'Type of activites requested',
default: [],
},
],
},
];
@@ -0,0 +1,29 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { router } from './actions/router';
import { versionDescription } from './actions/versionDescription';
import { listSearch, loadOptions } from './methods';
export class GoogleAnalyticsV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
usableAsTool: true,
};
}
methods = { loadOptions, listSearch };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
return await router.call(this);
}
}
@@ -0,0 +1,13 @@
import type { AllEntities, Entity } from 'n8n-workflow';
type GoogleAnalyticsMap = {
userActivity: 'search';
report: ReportBasedOnProperty;
};
export type GoogleAnalytics = AllEntities<GoogleAnalyticsMap>;
export type GoogleAnalyticsUserActivity = Entity<GoogleAnalyticsMap, 'userActivity'>;
export type GoogleAnalyticReport = Entity<GoogleAnalyticsMap, 'report'>;
export type ReportBasedOnProperty = 'getga4' | 'getuniversal';
@@ -0,0 +1,488 @@
import type { INodeProperties } from 'n8n-workflow';
export const dimensionDropdown: INodeProperties[] = [
{
displayName: 'Dimension',
name: 'listName',
type: 'options',
default: 'date',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Browser',
value: 'browser',
},
{
name: 'Campaign',
value: 'campaignName',
},
{
name: 'City',
value: 'city',
},
{
name: 'Country',
value: 'country',
},
{
name: 'Date',
value: 'date',
},
{
name: 'Device Category',
value: 'deviceCategory',
},
{
name: 'Item Name',
value: 'itemName',
},
{
name: 'Language',
value: 'language',
},
{
name: 'Page Location',
value: 'pageLocation',
},
{
name: 'Source / Medium',
value: 'sourceMedium',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Other dimensions…',
value: 'other',
},
],
},
{
displayName: 'Name or ID',
name: 'name',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDimensionsGA4',
loadOptionsDependsOn: ['propertyId.value'],
},
default: 'date',
description:
'The name of the dimension. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
displayOptions: {
show: {
listName: ['other'],
},
},
},
];
export const metricDropdown: INodeProperties[] = [
{
displayName: 'Metric',
name: 'listName',
type: 'options',
default: 'totalUsers',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: '1 Day Active Users',
value: 'active1DayUsers',
},
{
name: '28 Day Active Users',
value: 'active28DayUsers',
},
{
name: '7 Day Active Users',
value: 'active7DayUsers',
},
{
name: 'Checkouts',
value: 'checkouts',
},
{
name: 'Events',
value: 'eventCount',
},
{
name: 'Page Views',
value: 'screenPageViews',
},
{
name: 'Session Duration',
value: 'userEngagementDuration',
},
{
name: 'Sessions',
value: 'sessions',
},
{
name: 'Sessions per User',
value: 'sessionsPerUser',
},
{
name: 'Total Users',
value: 'totalUsers',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Other metrics…',
value: 'other',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Custom metric…',
value: 'custom',
},
],
},
{
displayName: 'Name or ID',
name: 'name',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getMetricsGA4',
loadOptionsDependsOn: ['propertyId.value'],
},
default: 'totalUsers',
hint: 'If expression is specified, name can be any string that you would like',
description:
'The name of the metric. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
displayOptions: {
show: {
listName: ['other'],
},
},
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'custom_metric',
displayOptions: {
show: {
listName: ['custom'],
},
},
},
];
const dimensionsFilterExpressions: INodeProperties[] = [
{
displayName: 'Expression',
name: 'expression',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
placeholder: 'Add Expression',
options: [
{
displayName: 'String Filter',
name: 'stringFilter',
values: [
...dimensionDropdown,
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
{
displayName: 'Case Sensitive',
name: 'caseSensitive',
type: 'boolean',
default: true,
},
{
displayName: 'Match Type',
name: 'matchType',
type: 'options',
default: 'EXACT',
options: [
{
name: 'Begins With',
value: 'BEGINS_WITH',
},
{
name: 'Contains Value',
value: 'CONTAINS',
},
{
name: 'Ends With',
value: 'ENDS_WITH',
},
{
name: 'Exact Match',
value: 'EXACT',
},
{
name: 'Full Match for the Regular Expression',
value: 'FULL_REGEXP',
},
{
name: 'Partial Match for the Regular Expression',
value: 'PARTIAL_REGEXP',
},
],
},
],
},
{
displayName: 'In List Filter',
name: 'inListFilter',
values: [
...dimensionDropdown,
{
displayName: 'Values',
name: 'values',
type: 'string',
default: '',
hint: 'Comma separated list of values. Must be non-empty.',
},
{
displayName: 'Case Sensitive',
name: 'caseSensitive',
type: 'boolean',
default: true,
},
],
},
{
displayName: 'Numeric Filter',
name: 'numericFilter',
values: [
...dimensionDropdown,
{
displayName: 'Value Type',
name: 'valueType',
type: 'options',
default: 'doubleValue',
options: [
{
name: 'Double Value',
value: 'doubleValue',
},
{
name: 'Integer Value',
value: 'int64Value',
},
],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
default: 'EQUAL',
options: [
{
name: 'Equal',
value: 'EQUAL',
},
{
name: 'Greater Than',
value: 'GREATER_THAN',
},
{
name: 'Greater than or Equal',
value: 'GREATER_THAN_OR_EQUAL',
},
{
name: 'Less Than',
value: 'LESS_THAN',
},
{
name: 'Less than or Equal',
value: 'LESS_THAN_OR_EQUAL',
},
],
},
],
},
],
},
];
export const dimensionFilterField: INodeProperties[] = [
{
displayName: 'Dimensions Filters',
name: 'dimensionFiltersUI',
type: 'fixedCollection',
default: {},
placeholder: 'Add Filter',
options: [
{
displayName: 'Filter Expressions',
name: 'filterExpressions',
values: [
{
displayName: 'Filter Expression Type',
name: 'filterExpressionType',
type: 'options',
default: 'andGroup',
options: [
{
name: 'And Group',
value: 'andGroup',
},
{
name: 'Or Group',
value: 'orGroup',
},
],
},
...dimensionsFilterExpressions,
],
},
],
},
];
const metricsFilterExpressions: INodeProperties[] = [
{
displayName: 'Expression',
name: 'expression',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
placeholder: 'Add Expression',
options: [
{
displayName: 'Between Filter',
name: 'betweenFilter',
values: [
...metricDropdown,
{
displayName: 'Value Type',
name: 'valueType',
type: 'options',
default: 'doubleValue',
options: [
{
name: 'Double Value',
value: 'doubleValue',
},
{
name: 'Integer Value',
value: 'int64Value',
},
],
},
{
displayName: 'From Value',
name: 'fromValue',
type: 'string',
default: '',
},
{
displayName: 'To Value',
name: 'toValue',
type: 'string',
default: '',
},
],
},
{
displayName: 'Numeric Filter',
name: 'numericFilter',
values: [
...metricDropdown,
{
displayName: 'Value Type',
name: 'valueType',
type: 'options',
default: 'doubleValue',
options: [
{
name: 'Double Value',
value: 'doubleValue',
},
{
name: 'Integer Value',
value: 'int64Value',
},
],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
default: 'EQUAL',
options: [
{
name: 'Equal',
value: 'EQUAL',
},
{
name: 'Greater Than',
value: 'GREATER_THAN',
},
{
name: 'Greater than or Equal',
value: 'GREATER_THAN_OR_EQUAL',
},
{
name: 'Less Than',
value: 'LESS_THAN',
},
{
name: 'Less than or Equal',
value: 'LESS_THAN_OR_EQUAL',
},
],
},
],
},
],
},
];
export const metricsFilterField: INodeProperties[] = [
{
displayName: 'Metrics Filters',
name: 'metricsFiltersUI',
type: 'fixedCollection',
default: {},
placeholder: 'Add Filter',
options: [
{
displayName: 'Filter Expressions',
name: 'filterExpressions',
values: [
{
displayName: 'Filter Expression Type',
name: 'filterExpressionType',
type: 'options',
default: 'andGroup',
options: [
{
name: 'And Group',
value: 'andGroup',
},
{
name: 'Or Group',
value: 'orGroup',
},
],
},
...metricsFilterExpressions,
],
},
],
},
];
@@ -0,0 +1,56 @@
import type { INodeProperties } from 'n8n-workflow';
import * as getga4 from './get.ga4.operation';
import * as getuniversal from './get.universal.operation';
export { getga4, getuniversal };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['report'],
},
},
options: [
{
name: 'Get',
value: 'get',
description: 'Return the analytics data',
action: 'Get a report',
},
],
default: 'get',
},
{
displayName: 'Property Type',
name: 'propertyType',
type: 'options',
noDataExpression: true,
description:
'Google Analytics 4 is the latest version. Universal Analytics is an older version that is not fully functional after the end of June 2023.',
options: [
{
name: 'Google Analytics 4',
value: 'ga4',
},
{
name: 'Universal Analytics',
value: 'universal',
},
],
default: 'ga4',
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
},
},
},
...getga4.description,
...getuniversal.description,
];
@@ -0,0 +1,625 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import {
dimensionDropdown,
dimensionFilterField,
metricDropdown,
metricsFilterField,
} from './FiltersDescription';
import {
checkDuplicates,
defaultEndDate,
defaultStartDate,
prepareDateRange,
processFilters,
simplifyGA4,
} from '../../helpers/utils';
import { googleApiRequest, googleApiRequestAllItems } from '../../transport';
export const description: INodeProperties[] = [
{
displayName: 'Property',
name: 'propertyId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'The Property of Google Analytics',
hint: "If this doesn't work, try changing the 'Property Type' field above",
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a property...',
typeOptions: {
searchListMethod: 'searchProperties',
searchFilterRequired: false,
searchable: false,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
placeholder: 'https://analytics.google.com/analytics/...',
validation: [
{
type: 'regex',
properties: {
regex: '.*analytics\\.google\\.com\\/analytics.*\\/p([0-9]{1,})(?:\\/.*|)*',
errorMessage: 'Not a valid Google Analytics URL',
},
},
],
extractValue: {
type: 'regex',
regex: '.*analytics\\.google\\.com\\/analytics.*\\/p([0-9]{1,})(?:\\/.*|)',
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: '123456',
validation: [
{
type: 'regex',
properties: {
regex: '[0-9]{1,}',
errorMessage: 'Not a valid Google Analytics Property ID',
},
},
],
url: '=https://analytics.google.com/analytics/web/#/p{{$value}}/',
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['ga4'],
},
},
},
{
displayName: 'Date Range',
name: 'dateRange',
type: 'options',
required: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Last 7 Days',
value: 'last7days',
},
{
name: 'Last 30 Days',
value: 'last30days',
},
{
name: 'Today',
value: 'today',
},
{
name: 'Yesterday',
value: 'yesterday',
},
{
name: 'Last Complete Calendar Week',
value: 'lastCalendarWeek',
},
{
name: 'Last Complete Calendar Month',
value: 'lastCalendarMonth',
},
{
name: 'Custom',
value: 'custom',
},
],
default: 'last7days',
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['ga4'],
},
},
},
{
displayName: 'Start',
name: 'startDate',
type: 'dateTime',
required: true,
default: defaultStartDate(),
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
dateRange: ['custom'],
propertyType: ['ga4'],
},
},
},
{
displayName: 'End',
name: 'endDate',
type: 'dateTime',
required: true,
default: defaultEndDate(),
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
dateRange: ['custom'],
propertyType: ['ga4'],
},
},
},
{
displayName: 'Metrics',
name: 'metricsGA4',
type: 'fixedCollection',
default: { metricValues: [{ listName: 'totalUsers' }] },
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Metric',
description:
'The quantitative measurements of a report. For example, the metric eventCount is the total number of events. Requests are allowed up to 10 metrics.',
options: [
{
displayName: 'Values',
name: 'metricValues',
values: [
...metricDropdown,
{
displayName: 'Expression',
name: 'expression',
type: 'string',
default: '',
description:
'A mathematical expression for derived metrics. For example, the metric Event count per user is eventCount/totalUsers.',
placeholder: 'e.g. eventCount/totalUsers',
displayOptions: {
show: {
listName: ['custom'],
},
},
},
{
displayName: 'Invisible',
name: 'invisible',
type: 'boolean',
default: false,
displayOptions: {
show: {
listName: ['custom'],
},
},
description:
'Whether a metric is invisible in the report response. If a metric is invisible, the metric will not produce a column in the response, but can be used in metricFilter, orderBys, or a metric expression.',
},
],
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['ga4'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Dimensions to split by',
name: 'dimensionsGA4',
type: 'fixedCollection',
default: { dimensionValues: [{ listName: 'date' }] },
// default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Dimension',
description:
'Dimensions are attributes of your data. For example, the dimension city indicates the city from which an event originates. Dimension values in report responses are strings; for example, the city could be "Paris" or "New York". Requests are allowed up to 9 dimensions.',
options: [
{
displayName: 'Values',
name: 'dimensionValues',
values: [...dimensionDropdown],
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['ga4'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['ga4'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['ga4'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 1000,
},
default: 50,
description: 'Max number of results to return',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-simplify
displayName: 'Simplify Output',
name: 'simple',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['ga4'],
},
},
default: true,
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['ga4'],
},
},
options: [
{
displayName: 'Currency Code',
name: 'currencyCode',
type: 'string',
default: '',
description:
'A currency code in ISO4217 format, such as "AED", "USD", "JPY". If the field is empty, the report uses the property\'s default currency.',
},
...dimensionFilterField,
{
displayName: 'Metric Aggregation',
name: 'metricAggregations',
type: 'multiOptions',
default: [],
options: [
{
name: 'MAXIMUM',
value: 'MAXIMUM',
},
{
name: 'MINIMUM',
value: 'MINIMUM',
},
{
name: 'TOTAL',
value: 'TOTAL',
},
],
displayOptions: {
show: {
'/simple': [false],
},
},
},
...metricsFilterField,
{
displayName: 'Keep Empty Rows',
name: 'keepEmptyRows',
type: 'boolean',
default: false,
description:
'Whether false or unspecified, each row with all metrics equal to 0 will not be returned. If true, these rows will be returned if they are not separately removed by a filter.',
},
{
displayName: 'Order By',
name: 'orderByUI',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Order',
description: 'Specifies how rows are ordered in the response',
options: [
{
displayName: 'Metric Order By',
name: 'metricOrderBy',
values: [
{
displayName: 'Descending',
name: 'desc',
type: 'boolean',
default: false,
description: 'Whether true, sorts by descending order',
},
{
displayName: 'Metric Name or ID',
name: 'metricName',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getMetricsGA4',
loadOptionsDependsOn: ['propertyId.value'],
},
default: '',
description:
'Sorts by metric values. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
],
},
{
displayName: 'Dimmension Order By',
name: 'dimmensionOrderBy',
values: [
{
displayName: 'Descending',
name: 'desc',
type: 'boolean',
default: false,
description: 'Whether true, sorts by descending order',
},
{
displayName: 'Dimmension Name or ID',
name: 'dimensionName',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDimensionsGA4',
loadOptionsDependsOn: ['propertyId.value'],
},
default: '',
description:
'Sorts by metric values. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Order Type',
name: 'orderType',
type: 'options',
default: 'ORDER_TYPE_UNSPECIFIED',
options: [
{
name: 'Alphanumeric',
value: 'ALPHANUMERIC',
description: 'Alphanumeric sort by Unicode code point',
},
{
name: 'Case Insensitive Alphanumeric',
value: 'CASE_INSENSITIVE_ALPHANUMERIC',
description:
'Case insensitive alphanumeric sort by lower case Unicode code point',
},
{
name: 'Numeric',
value: 'NUMERIC',
description: 'Dimension values are converted to numbers before sorting',
},
{
name: 'Unspecified',
value: 'ORDER_TYPE_UNSPECIFIED',
},
],
},
],
},
],
},
{
displayName: 'Return Property Quota',
name: 'returnPropertyQuota',
type: 'boolean',
default: false,
description:
"Whether to return the current state of this Analytics Property's quota. Quota is returned in PropertyQuota.",
displayOptions: {
show: {
'/simple': [false],
},
},
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
//migration guide: https://developers.google.com/analytics/devguides/migration/api/reporting-ua-to-ga4#core_reporting
const propertyId = this.getNodeParameter('propertyId', index, undefined, {
extractValue: true,
}) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', index);
const dateRange = this.getNodeParameter('dateRange', index) as string;
const metricsGA4 = this.getNodeParameter('metricsGA4', index, {}) as IDataObject;
const dimensionsGA4 = this.getNodeParameter('dimensionsGA4', index, {}) as IDataObject;
const simple = this.getNodeParameter('simple', index) as boolean;
let responseData: IDataObject[] = [];
const qs: IDataObject = {};
const body: IDataObject = {
dateRanges: prepareDateRange.call(this, dateRange, index),
};
if (metricsGA4.metricValues) {
const metrics = (metricsGA4.metricValues as IDataObject[]).map((metric) => {
switch (metric.listName) {
case 'other':
return { name: metric.name };
case 'custom':
const newMetric = {
name: metric.name,
expression: metric.expression,
invisible: metric.invisible,
};
if (newMetric.invisible === false) {
delete newMetric.invisible;
}
if (newMetric.expression === '') {
delete newMetric.expression;
}
return newMetric;
default:
return { name: metric.listName };
}
});
if (metrics.length) {
checkDuplicates.call(this, metrics, 'name', 'metrics');
body.metrics = metrics;
}
}
if (dimensionsGA4.dimensionValues) {
const dimensions = (dimensionsGA4.dimensionValues as IDataObject[]).map((dimension) => {
switch (dimension.listName) {
case 'other':
return { name: dimension.name };
default:
return { name: dimension.listName };
}
});
if (dimensions.length) {
checkDuplicates.call(this, dimensions, 'name', 'dimensions');
body.dimensions = dimensions;
}
}
if (additionalFields.currencyCode) {
body.currencyCode = additionalFields.currencyCode;
}
if (additionalFields.dimensionFiltersUI) {
const { filterExpressionType, expression } = (
additionalFields.dimensionFiltersUI as IDataObject
).filterExpressions as IDataObject;
if (expression) {
body.dimensionFilter = {
[filterExpressionType as string]: {
expressions: processFilters(expression as IDataObject),
},
};
}
}
if (additionalFields.metricsFiltersUI) {
const { filterExpressionType, expression } = (additionalFields.metricsFiltersUI as IDataObject)
.filterExpressions as IDataObject;
if (expression) {
body.metricFilter = {
[filterExpressionType as string]: {
expressions: processFilters(expression as IDataObject),
},
};
}
}
if (additionalFields.metricAggregations) {
body.metricAggregations = additionalFields.metricAggregations;
}
if (additionalFields.keepEmptyRows) {
body.keepEmptyRows = additionalFields.keepEmptyRows;
}
if (additionalFields.orderByUI) {
let orderBys: IDataObject[] = [];
const metricOrderBy = (additionalFields.orderByUI as IDataObject)
.metricOrderBy as IDataObject[];
const dimmensionOrderBy = (additionalFields.orderByUI as IDataObject)
.dimmensionOrderBy as IDataObject[];
if (metricOrderBy) {
orderBys = orderBys.concat(
metricOrderBy.map((order) => {
return {
desc: order.desc,
metric: {
metricName: order.metricName,
},
};
}),
);
}
if (dimmensionOrderBy) {
orderBys = orderBys.concat(
dimmensionOrderBy.map((order) => {
return {
desc: order.desc,
dimension: {
dimensionName: order.dimensionName,
orderType: order.orderType,
},
};
}),
);
}
body.orderBys = orderBys;
}
if (additionalFields.returnPropertyQuota) {
body.returnPropertyQuota = additionalFields.returnPropertyQuota;
}
const method = 'POST';
const endpoint = `/v1beta/properties/${propertyId}:runReport`;
if (returnAll) {
responseData = await googleApiRequestAllItems.call(this, '', method, endpoint, body, qs);
} else {
body.limit = this.getNodeParameter('limit', 0);
responseData = [await googleApiRequest.call(this, method, endpoint, body, qs)];
}
if (responseData?.length && simple) {
responseData = simplifyGA4(responseData[0]);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: index } },
);
return executionData;
}
@@ -0,0 +1,730 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import type { IData, IDimension, IMetric } from '../../helpers/Interfaces';
import {
checkDuplicates,
defaultEndDate,
defaultStartDate,
merge,
prepareDateRange,
simplify,
} from '../../helpers/utils';
import { googleApiRequest, googleApiRequestAllItems } from '../../transport';
const dimensionDropdown: INodeProperties[] = [
{
displayName: 'Dimension',
name: 'listName',
type: 'options',
default: 'ga:date',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Browser',
value: 'ga:browser',
},
{
name: 'Campaign',
value: 'ga:campaign',
},
{
name: 'City',
value: 'ga:city',
},
{
name: 'Country',
value: 'ga:country',
},
{
name: 'Date',
value: 'ga:date',
},
{
name: 'Device Category',
value: 'ga:deviceCategory',
},
{
name: 'Item Name',
value: 'ga:productName',
},
{
name: 'Language',
value: 'ga:language',
},
{
name: 'Page',
value: 'ga:pagePath',
},
{
name: 'Source / Medium',
value: 'ga:sourceMedium',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Other dimensions…',
value: 'other',
},
],
},
{
displayName: 'Name or ID',
name: 'name',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDimensions',
loadOptionsDependsOn: ['viewId.value'],
},
default: 'ga:date',
description:
'Name of the dimension to fetch, for example ga:browser. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
displayOptions: {
show: {
listName: ['other'],
},
},
},
];
export const description: INodeProperties[] = [
{
displayName: 'View',
name: 'viewId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'The View of Google Analytics',
hint: "If this doesn't work, try changing the 'Property Type' field above",
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a view...',
typeOptions: {
searchListMethod: 'searchViews',
searchFilterRequired: false,
searchable: false,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
placeholder: 'https://analytics.google.com/analytics/...',
validation: [
{
type: 'regex',
properties: {
regex: '.*analytics.google.com/analytics.*p[0-9]{1,}.*',
errorMessage: 'Not a valid Google Analytics URL',
},
},
],
extractValue: {
type: 'regex',
regex: '.*analytics.google.com/analytics.*p([0-9]{1,})',
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: '123456',
validation: [
{
type: 'regex',
properties: {
regex: '[0-9]{1,}',
errorMessage: 'Not a valid Google Analytics View ID',
},
},
],
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
},
},
},
{
displayName: 'Date Range',
name: 'dateRange',
type: 'options',
required: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Last 7 Days',
value: 'last7days',
},
{
name: 'Last 30 Days',
value: 'last30days',
},
{
name: 'Today',
value: 'today',
},
{
name: 'Yesterday',
value: 'yesterday',
},
{
name: 'Last Complete Calendar Week',
value: 'lastCalendarWeek',
},
{
name: 'Last Complete Calendar Month',
value: 'lastCalendarMonth',
},
{
name: 'Custom',
value: 'custom',
},
],
default: 'last7days',
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
},
},
},
{
displayName: 'Start',
name: 'startDate',
type: 'dateTime',
required: true,
default: defaultStartDate(),
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
dateRange: ['custom'],
},
},
},
{
displayName: 'End',
name: 'endDate',
type: 'dateTime',
required: true,
default: defaultEndDate(),
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
dateRange: ['custom'],
},
},
},
{
displayName: 'Metrics',
name: 'metricsUA',
type: 'fixedCollection',
default: { metricValues: [{ listName: 'ga:users' }] },
typeOptions: {
multipleValues: true,
},
placeholder: 'Add metric',
description: 'Metrics in the request',
options: [
{
displayName: 'Metric',
name: 'metricValues',
values: [
{
displayName: 'Metric',
name: 'listName',
type: 'options',
default: 'ga:users',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Checkouts',
value: 'ga:productCheckouts',
},
{
name: 'Events',
value: 'ga:totalEvents',
},
{
name: 'Page Views',
value: 'ga:pageviews',
},
{
name: 'Session Duration',
value: 'ga:sessionDuration',
},
{
name: 'Sessions',
value: 'ga:sessions',
},
{
name: 'Sessions per User',
value: 'ga:sessionsPerUser',
},
{
name: 'Total Users',
value: 'ga:users',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Other metrics…',
value: 'other',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Custom metric…',
value: 'custom',
},
],
},
{
displayName: 'Name or ID',
name: 'name',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getMetrics',
loadOptionsDependsOn: ['viewId.value'],
},
default: 'ga:users',
hint: 'If expression is specified, name can be any string that you would like',
description:
'The name of the metric. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
displayOptions: {
show: {
listName: ['other'],
},
},
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'custom_metric',
displayOptions: {
show: {
listName: ['custom'],
},
},
},
{
displayName: 'Expression',
name: 'expression',
type: 'string',
default: '',
placeholder: 'e.g. ga:totalRefunds/ga:users',
description:
'Learn more about Google Analytics <a href="https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#Metric">metric expressions</a>',
displayOptions: {
show: {
listName: ['custom'],
},
},
},
{
displayName: 'Formatting Type',
name: 'formattingType',
type: 'options',
default: 'INTEGER',
description: 'Specifies how the metric expression should be formatted',
options: [
{
name: 'Currency',
value: 'CURRENCY',
},
{
name: 'Float',
value: 'FLOAT',
},
{
name: 'Integer',
value: 'INTEGER',
},
{
name: 'Percent',
value: 'PERCENT',
},
{
name: 'Time',
value: 'TIME',
},
],
displayOptions: {
show: {
listName: ['custom'],
},
},
},
],
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Dimensions to split by',
name: 'dimensionsUA',
type: 'fixedCollection',
default: { dimensionValues: [{ listName: 'ga:date' }] },
// default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Dimension',
description:
'Dimensions are attributes of your data. For example, the dimension ga:city indicates the city, for example, "Paris" or "New York", from which a session originates.',
options: [
{
displayName: 'Values',
name: 'dimensionValues',
values: [...dimensionDropdown],
},
],
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['universal'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['universal'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 1000,
},
default: 50,
description: 'Max number of results to return',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-simplify
displayName: 'Simplify Output',
name: 'simple',
type: 'boolean',
displayOptions: {
show: {
operation: ['get'],
resource: ['report'],
propertyType: ['universal'],
},
},
default: true,
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['report'],
operation: ['get'],
propertyType: ['universal'],
},
},
options: [
{
displayName: 'Dimension Filters',
name: 'dimensionFiltersUi',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Dimension Filter',
description: 'Dimension Filters in the request',
options: [
{
displayName: 'Filters',
name: 'filterValues',
values: [
...dimensionDropdown,
// https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#Operator
{
displayName: 'Operator',
name: 'operator',
type: 'options',
default: 'EXACT',
description: 'Operator to use in combination with value',
options: [
{
name: 'Begins With',
value: 'BEGINS_WITH',
},
{
name: 'Ends With',
value: 'ENDS_WITH',
},
{
name: 'Equals (Number)',
value: 'NUMERIC_EQUAL',
},
{
name: 'Exactly Matches',
value: 'EXACT',
},
{
name: 'Greater Than (Number)',
value: 'NUMERIC_GREATER_THAN',
},
{
name: 'Less Than (Number)',
value: 'NUMERIC_LESS_THAN',
},
{
name: 'Partly Matches',
value: 'PARTIAL',
},
{
name: 'Regular Expression',
value: 'REGEXP',
},
],
},
{
displayName: 'Value',
name: 'expressions',
type: 'string',
default: '',
placeholder: '',
description:
'String or <a href="https://support.google.com/analytics/answer/1034324?hl=en">regular expression</a> to match against',
},
],
},
],
},
{
displayName: 'Hide Totals',
name: 'hideTotals',
type: 'boolean',
default: false,
description:
'Whether to hide the total of all metrics for all the matching rows, for every date range',
displayOptions: {
show: {
'/simple': [false],
},
},
},
{
displayName: 'Hide Value Ranges',
name: 'hideValueRanges',
type: 'boolean',
default: false,
description: 'Whether to hide the minimum and maximum across all matching rows',
displayOptions: {
show: {
'/simple': [false],
},
},
},
{
displayName: 'Include Empty Rows',
name: 'includeEmptyRows',
type: 'boolean',
default: false,
description:
'Whether the response exclude rows if all the retrieved metrics are equal to zero',
},
{
displayName: 'Use Resource Quotas',
name: 'useResourceQuotas',
type: 'boolean',
default: false,
description: 'Whether to enable resource based quotas',
displayOptions: {
show: {
'/simple': [false],
},
},
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
//https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet
// const viewId = this.getNodeParameter('viewId', index) as string;
const viewId = this.getNodeParameter('viewId', index, undefined, {
extractValue: true,
}) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const dateRange = this.getNodeParameter('dateRange', index) as string;
const metricsUA = this.getNodeParameter('metricsUA', index) as IDataObject;
const dimensionsUA = this.getNodeParameter('dimensionsUA', index) as IDataObject;
const additionalFields = this.getNodeParameter('additionalFields', index);
const simple = this.getNodeParameter('simple', index) as boolean;
let responseData;
const qs: IDataObject = {};
const body: IData = {
viewId,
dateRanges: prepareDateRange.call(this, dateRange, index),
};
if (metricsUA.metricValues) {
const metrics = (metricsUA.metricValues as IDataObject[]).map((metric) => {
switch (metric.listName) {
case 'other':
return {
alias: metric.name,
expression: metric.name,
};
case 'custom':
const newMetric = {
alias: metric.name,
expression: metric.expression,
formattingType: metric.formattingType,
};
return newMetric;
default:
return {
alias: metric.listName,
expression: metric.listName,
};
}
});
if (metrics.length) {
checkDuplicates.call(this, metrics, 'alias', 'metrics');
body.metrics = metrics as IMetric[];
}
}
if (dimensionsUA.dimensionValues) {
const dimensions = (dimensionsUA.dimensionValues as IDataObject[]).map((dimension) => {
switch (dimension.listName) {
case 'other':
return { name: dimension.name };
default:
return { name: dimension.listName };
}
});
if (dimensions.length) {
checkDuplicates.call(this, dimensions, 'name', 'dimensions');
body.dimensions = dimensions as IDimension[];
}
}
if (additionalFields.useResourceQuotas) {
qs.useResourceQuotas = additionalFields.useResourceQuotas;
}
if (additionalFields.dimensionFiltersUi) {
const dimensionFilters = (additionalFields.dimensionFiltersUi as IDataObject)
.filterValues as IDataObject[];
if (dimensionFilters) {
dimensionFilters.forEach((filter) => {
filter.expressions = [filter.expressions];
switch (filter.listName) {
case 'other':
filter.dimensionName = filter.name;
delete filter.name;
delete filter.listName;
break;
default:
filter.dimensionName = filter.listName;
delete filter.listName;
}
});
body.dimensionFilterClauses = { filters: dimensionFilters };
}
}
if (additionalFields.includeEmptyRows) {
Object.assign(body, { includeEmptyRows: additionalFields.includeEmptyRows });
}
if (additionalFields.hideTotals) {
Object.assign(body, { hideTotals: additionalFields.hideTotals });
}
if (additionalFields.hideValueRanges) {
Object.assign(body, { hideTotals: additionalFields.hideTotals });
}
const method = 'POST';
const endpoint = '/v4/reports:batchGet';
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'reports',
method,
endpoint,
{ reportRequests: [body] },
qs,
);
} else {
body.pageSize = this.getNodeParameter('limit', 0);
responseData = await googleApiRequest.call(
this,
method,
endpoint,
{ reportRequests: [body] },
qs,
);
responseData = responseData.reports;
}
if (simple) {
responseData = simplify(responseData);
} else if (returnAll && responseData.length > 1) {
responseData = merge(responseData);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: index } },
);
return executionData;
}
@@ -0,0 +1,51 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { GoogleAnalytics, ReportBasedOnProperty } from './node.type';
import * as report from './report/Report.resource';
import * as userActivity from './userActivity/UserActivity.resource';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter<GoogleAnalytics>('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0);
let responseData;
const googleAnalytics = {
resource,
operation,
} as GoogleAnalytics;
for (let i = 0; i < items.length; i++) {
try {
switch (googleAnalytics.resource) {
case 'report':
const propertyType = this.getNodeParameter('propertyType', 0) as string;
const operationBasedOnProperty =
`${googleAnalytics.operation}${propertyType}` as ReportBasedOnProperty;
responseData = await report[operationBasedOnProperty].execute.call(this, i);
break;
case 'userActivity':
responseData = await userActivity[googleAnalytics.operation].execute.call(this, i);
break;
default:
throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`);
}
returnData.push(...responseData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as search from './search.operation';
export { search };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['userActivity'],
},
},
options: [
{
name: 'Search',
value: 'search',
description: 'Return user activity data',
action: 'Search user activity data',
},
],
default: 'search',
},
...search.description,
];
@@ -0,0 +1,163 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { googleApiRequest, googleApiRequestAllItems } from '../../transport';
export const description: INodeProperties[] = [
{
displayName: 'View Name or ID',
name: 'viewId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getViews',
},
default: '',
required: true,
displayOptions: {
show: {
resource: ['userActivity'],
operation: ['search'],
},
},
placeholder: '123456',
description:
'The view from Google Analytics. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
hint: "If there's nothing here, try changing the 'Property type' field above",
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['userActivity'],
operation: ['search'],
},
},
placeholder: '123456',
description: 'ID of a user',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'Max number of results to return',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
operation: ['search'],
resource: ['userActivity'],
},
},
options: [
{
displayName: 'Activity Types',
name: 'activityTypes',
type: 'multiOptions',
options: [
{
name: 'Ecommerce',
value: 'ECOMMERCE',
},
{
name: 'Event',
value: 'EVENT',
},
{
name: 'Goal',
value: 'GOAL',
},
{
name: 'Pageview',
value: 'PAGEVIEW',
},
{
name: 'Screenview',
value: 'SCREENVIEW',
},
],
description: 'Type of activites requested',
default: [],
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
//https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/userActivity/search
const viewId = this.getNodeParameter('viewId', index);
const userId = this.getNodeParameter('userId', index);
const returnAll = this.getNodeParameter('returnAll', 0);
const additionalFields = this.getNodeParameter('additionalFields', index);
let responseData;
const body: IDataObject = {
viewId,
user: {
userId,
},
};
if (additionalFields.activityTypes) {
Object.assign(body, { activityTypes: additionalFields.activityTypes });
}
const method = 'POST';
const endpoint = '/v4/userActivity:search';
if (returnAll) {
responseData = await googleApiRequestAllItems.call(this, 'sessions', method, endpoint, body);
} else {
body.pageSize = this.getNodeParameter('limit', 0);
responseData = await googleApiRequest.call(this, method, endpoint, body);
responseData = responseData.sessions;
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: index } },
);
return executionData;
}
@@ -0,0 +1,47 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import * as report from './report/Report.resource';
import * as userActivity from './userActivity/UserActivity.resource';
export const versionDescription: INodeTypeDescription = {
displayName: 'Google Analytics',
name: 'googleAnalytics',
icon: 'file:analytics.svg',
group: ['transform'],
version: 2,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Use the Google Analytics API',
defaults: {
name: 'Google Analytics',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleAnalyticsOAuth2',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Report',
value: 'report',
},
{
name: 'User Activity',
value: 'userActivity',
},
],
default: 'report',
},
...report.description,
...userActivity.description,
],
};
@@ -0,0 +1,28 @@
import type { IDataObject } from 'n8n-workflow';
export interface IData {
viewId: string;
dimensions?: IDimension[];
dimensionFilterClauses?: {
filters: IDimensionFilter[];
};
pageSize?: number;
metrics?: IMetric[];
dateRanges?: IDataObject[];
}
export interface IDimension {
name?: string;
histogramBuckets?: string[];
}
export interface IDimensionFilter {
dimensionName?: string;
operator?: string;
expressions?: string[];
}
export interface IMetric {
expression?: string;
alias?: string;
formattingType?: string;
}
@@ -0,0 +1,256 @@
import { DateTime } from 'luxon';
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IDataObject,
INodeListSearchItems,
INodePropertyOptions,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
// tslint:disable-next-line:no-any
export function simplify(responseData: any | [any]) {
const returnData = [];
for (const {
columnHeader: { dimensions, metricHeader },
data: { rows },
} of responseData) {
if (rows === undefined) {
// Do not error if there is no data
continue;
}
const metrics = metricHeader.metricHeaderEntries.map((entry: { name: string }) => entry.name);
for (const row of rows) {
const rowDimensions: IDataObject = {};
const rowMetrics: IDataObject = {};
if (dimensions) {
for (let i = 0; i < dimensions.length; i++) {
rowDimensions[dimensions[i]] = row.dimensions[i];
for (const [index, metric] of metrics.entries()) {
rowMetrics[metric] = row.metrics[0].values[index];
}
}
} else {
for (const [index, metric] of metrics.entries()) {
rowMetrics[metric] = row.metrics[0].values[index];
}
}
returnData.push({ ...rowDimensions, ...rowMetrics });
}
}
return returnData;
}
// tslint:disable-next-line:no-any
export function merge(responseData: [any]) {
const response: { columnHeader: IDataObject; data: { rows: [] } } = {
columnHeader: responseData[0].columnHeader,
data: responseData[0].data,
};
const allRows = [];
for (const {
data: { rows },
} of responseData) {
allRows.push(...(rows as IDataObject[]));
}
response.data.rows = allRows as [];
return [response];
}
export function simplifyGA4(response: IDataObject) {
if (!response.rows) return [];
const dimensionHeaders = ((response.dimensionHeaders as IDataObject[]) || []).map(
(header) => header.name as string,
);
const metricHeaders = ((response.metricHeaders as IDataObject[]) || []).map(
(header) => header.name as string,
);
const returnData: IDataObject[] = [];
(response.rows as IDataObject[]).forEach((row) => {
if (!row) return;
const rowDimensions: IDataObject = {};
const rowMetrics: IDataObject = {};
dimensionHeaders.forEach((dimension, index) => {
rowDimensions[dimension] = (row.dimensionValues as IDataObject[])[index].value;
});
metricHeaders.forEach((metric, index) => {
rowMetrics[metric] = (row.metricValues as IDataObject[])[index].value;
});
returnData.push({ ...rowDimensions, ...rowMetrics });
});
return returnData;
}
export function processFilters(expression: IDataObject): IDataObject[] {
const processedFilters: IDataObject[] = [];
Object.entries(expression).forEach((entry) => {
const [filterType, filters] = entry;
(filters as IDataObject[]).forEach((filter) => {
let fieldName = '';
switch (filter.listName) {
case 'other':
fieldName = filter.name as string;
delete filter.name;
break;
case 'custom':
fieldName = filter.name as string;
delete filter.name;
break;
default:
fieldName = filter.listName as string;
}
delete filter.listName;
if (filterType === 'inListFilter') {
filter.values = (filter.values as string).split(',');
}
if (filterType === 'numericFilter') {
filter.value = {
[filter.valueType as string]: filter.value,
};
delete filter.valueType;
}
if (filterType === 'betweenFilter') {
filter.fromValue = {
[filter.valueType as string]: filter.fromValue,
};
filter.toValue = {
[filter.valueType as string]: filter.toValue,
};
delete filter.valueType;
}
processedFilters.push({
filter: {
fieldName,
[filterType]: filter,
},
});
});
});
return processedFilters;
}
export function prepareDateRange(
this: IExecuteFunctions | ILoadOptionsFunctions,
period: string,
itemIndex: number,
) {
const dateRanges: IDataObject[] = [];
switch (period) {
case 'today':
dateRanges.push({
startDate: DateTime.local().startOf('day').toISODate(),
endDate: DateTime.now().toISODate(),
});
break;
case 'yesterday':
dateRanges.push({
startDate: DateTime.local().startOf('day').minus({ days: 1 }).toISODate(),
endDate: DateTime.local().endOf('day').minus({ days: 1 }).toISODate(),
});
break;
case 'lastCalendarWeek':
const begginingOfLastWeek = DateTime.local().startOf('week').minus({ weeks: 1 }).toISODate();
const endOfLastWeek = DateTime.local().endOf('week').minus({ weeks: 1 }).toISODate();
dateRanges.push({
startDate: begginingOfLastWeek,
endDate: endOfLastWeek,
});
break;
case 'lastCalendarMonth':
const begginingOfLastMonth = DateTime.local()
.startOf('month')
.minus({ months: 1 })
.toISODate();
const endOfLastMonth = DateTime.local().endOf('month').minus({ months: 1 }).toISODate();
dateRanges.push({
startDate: begginingOfLastMonth,
endDate: endOfLastMonth,
});
break;
case 'last7days':
dateRanges.push({
startDate: DateTime.now().minus({ days: 7 }).toISODate(),
endDate: DateTime.now().toISODate(),
});
break;
case 'last30days':
dateRanges.push({
startDate: DateTime.now().minus({ days: 30 }).toISODate(),
endDate: DateTime.now().toISODate(),
});
break;
case 'custom':
const start = DateTime.fromISO(this.getNodeParameter('startDate', itemIndex, '') as string);
const end = DateTime.fromISO(this.getNodeParameter('endDate', itemIndex, '') as string);
if (start > end) {
throw new NodeOperationError(
this.getNode(),
`Parameter Start: ${start.toISO()} cannot be after End: ${end.toISO()}`,
);
}
dateRanges.push({
startDate: start.toISODate(),
endDate: end.toISODate(),
});
break;
default:
throw new NodeOperationError(
this.getNode(),
`The period '${period}' is not supported, to specify own period use 'custom' option`,
);
}
return dateRanges;
}
export const defaultStartDate = () => DateTime.now().startOf('day').minus({ days: 8 }).toISO();
export const defaultEndDate = () => DateTime.now().startOf('day').minus({ days: 1 }).toISO();
export function checkDuplicates(
this: IExecuteFunctions,
data: IDataObject[],
key: string,
type: string,
) {
const fields = data.map((item) => item[key] as string);
const duplicates = fields.filter((field, i) => fields.indexOf(field) !== i);
const unique = Array.from(new Set(duplicates));
if (unique.length) {
throw new NodeOperationError(
this.getNode(),
`A ${type} is specified more than once (${unique.join(', ')})`,
);
}
}
export function sortLoadOptions(data: INodePropertyOptions[] | INodeListSearchItems[]) {
const returnData = [...data];
returnData.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (aName < bName) {
return -1;
}
if (aName > bName) {
return 1;
}
return 0;
});
return returnData;
}
@@ -0,0 +1,2 @@
export * as loadOptions from './loadOptions';
export * as listSearch from './listSearch';
@@ -0,0 +1,70 @@
import type {
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { sortLoadOptions } from '../helpers/utils';
import { googleApiRequest } from '../transport';
export async function searchProperties(
this: ILoadOptionsFunctions,
): Promise<INodeListSearchResult> {
const returnData: INodeListSearchItems[] = [];
const { accounts } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://analyticsadmin.googleapis.com/v1alpha/accounts',
);
for (const acount of accounts || []) {
const { properties } = await googleApiRequest.call(
this,
'GET',
'',
{},
{ filter: `parent:${acount.name}` },
'https://analyticsadmin.googleapis.com/v1alpha/properties',
);
if (properties && properties.length > 0) {
for (const property of properties) {
const name = property.displayName;
const value = property.name.split('/')[1] || property.name;
const url = `https://analytics.google.com/analytics/web/#/p${value}/`;
returnData.push({ name, value, url });
}
}
}
return {
results: sortLoadOptions(returnData),
};
}
export async function searchViews(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
const returnData: INodeListSearchItems[] = [];
const { items } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/management/accounts/~all/webproperties/~all/profiles',
);
for (const item of items) {
returnData.push({
name: `${item.name} [${item.websiteUrl}]`,
value: item.id,
url: `https://analytics.google.com/analytics/web/#/report-home/a${item.accountId}w${item.internalWebPropertyId}p${item.id}`,
});
}
return {
results: sortLoadOptions(returnData),
};
}
@@ -0,0 +1,153 @@
import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { sortLoadOptions } from '../helpers/utils';
import { googleApiRequest } from '../transport';
export async function getDimensions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { items: dimensions } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/metadata/ga/columns',
);
for (const dimension of dimensions) {
if (dimension.attributes.type === 'DIMENSION' && dimension.attributes.status !== 'DEPRECATED') {
returnData.push({
name: dimension.attributes.uiName,
value: dimension.id,
description: dimension.attributes.description,
});
}
}
return sortLoadOptions(returnData);
}
export async function getMetrics(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { items: metrics } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/metadata/ga/columns',
);
for (const metric of metrics) {
if (metric.attributes.type === 'METRIC' && metric.attributes.status !== 'DEPRECATED') {
returnData.push({
name: metric.attributes.uiName,
value: metric.id,
description: metric.attributes.description,
});
}
}
return sortLoadOptions(returnData);
}
export async function getViews(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { items } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://www.googleapis.com/analytics/v3/management/accounts/~all/webproperties/~all/profiles',
);
for (const item of items) {
returnData.push({
name: item.name,
value: item.id,
description: item.websiteUrl,
});
}
return sortLoadOptions(returnData);
}
export async function getProperties(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { accounts } = await googleApiRequest.call(
this,
'GET',
'',
{},
{},
'https://analyticsadmin.googleapis.com/v1alpha/accounts',
);
for (const acount of accounts || []) {
const { properties } = await googleApiRequest.call(
this,
'GET',
'',
{},
{ filter: `parent:${acount.name}` },
'https://analyticsadmin.googleapis.com/v1alpha/properties',
);
if (properties && properties.length > 0) {
for (const property of properties) {
const name = property.displayName;
const value = property.name.split('/')[1] || property.name;
returnData.push({ name, value });
}
}
}
return sortLoadOptions(returnData);
}
export async function getDimensionsGA4(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const propertyId = this.getNodeParameter('propertyId', undefined, {
extractValue: true,
}) as string;
const { dimensions } = await googleApiRequest.call(
this,
'GET',
`/v1beta/properties/${propertyId}/metadata`,
{},
{ fields: 'dimensions' },
);
for (const dimension of dimensions) {
returnData.push({
name: dimension.uiName as string,
value: dimension.apiName as string,
description: dimension.description as string,
});
}
return sortLoadOptions(returnData);
}
export async function getMetricsGA4(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const propertyId = this.getNodeParameter('propertyId', undefined, {
extractValue: true,
}) as string;
const { metrics } = await googleApiRequest.call(
this,
'GET',
`/v1beta/properties/${propertyId}/metadata`,
{},
{ fields: 'metrics' },
);
for (const metric of metrics) {
returnData.push({
name: metric.uiName as string,
value: metric.apiName as string,
description: metric.description as string,
});
}
return sortLoadOptions(returnData);
}
@@ -0,0 +1,114 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
qs: IDataObject = {},
uri?: string,
option: IDataObject = {},
) {
const propertyType = this.getNodeParameter('propertyType', 0, 'universal') as string;
const baseURL =
propertyType === 'ga4'
? 'https://analyticsdata.googleapis.com'
: 'https://analyticsreporting.googleapis.com';
let options: IRequestOptions = {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri ?? `${baseURL}${endpoint}`,
json: true,
};
options = Object.assign({}, options, option);
try {
if (Object.keys(body).length === 0) {
delete options.body;
}
if (Object.keys(qs).length === 0) {
delete options.qs;
}
return await this.helpers.requestOAuth2.call(this, 'googleAnalyticsOAuth2', options);
} catch (error) {
const errorData = (error.message || '').split(' - ')[1] as string;
if (errorData) {
const parsedError = JSON.parse(errorData.trim());
if (parsedError.error?.message) {
const [message, ...rest] = parsedError.error.message.split('\n');
const description = rest.join('\n');
const httpCode = parsedError.error.code;
throw new NodeApiError(this.getNode(), error as JsonObject, {
message,
description,
httpCode,
});
}
}
throw new NodeApiError(this.getNode(), error as JsonObject, { message: error.message });
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
query: IDataObject = {},
uri?: string,
) {
const propertyType = this.getNodeParameter('propertyType', 0, 'universal') as string;
const returnData: IDataObject[] = [];
let responseData;
if (propertyType === 'ga4') {
let rows: IDataObject[] = [];
query.limit = 100000;
query.offset = 0;
responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri);
rows = rows.concat(responseData.rows as IDataObject[]);
query.offset = rows.length;
while (responseData.rowCount > rows.length) {
responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri);
rows = rows.concat(responseData.rows as IDataObject[]);
query.offset = rows.length;
}
responseData.rows = rows;
returnData.push(responseData as IDataObject);
} else {
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri);
if (body.reportRequests && Array.isArray(body.reportRequests)) {
(body.reportRequests as IDataObject[])[0].pageToken =
responseData[propertyName][0].nextPageToken;
} else {
body.pageToken = responseData.nextPageToken;
}
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (
(responseData.nextPageToken !== undefined && responseData.nextPageToken !== '') ||
responseData[propertyName]?.[0].nextPageToken !== undefined
);
}
return returnData;
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleBigQuery",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Data & Storage", "Development"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlebigquery/"
}
]
}
}
@@ -0,0 +1,27 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { GoogleBigQueryV1 } from './v1/GoogleBigQueryV1.node';
import { GoogleBigQueryV2 } from './v2/GoogleBigQueryV2.node';
export class GoogleBigQuery extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Google BigQuery',
name: 'googleBigQuery',
icon: 'file:googleBigQuery.svg',
group: ['input'],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google BigQuery API',
defaultVersion: 2.1,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new GoogleBigQueryV1(baseDescription),
2: new GoogleBigQueryV2(baseDescription),
2.1: new GoogleBigQueryV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 66 58"><use xlink:href="#a" x=".5" y=".5"/><symbol id="a" overflow="visible"><g fill-rule="nonzero" stroke="none"><path fill="#4386fa" d="M14.48 54.473.558 30.359a4.16 4.16 0 0 1 0-4.164L14.48 2.082A4.16 4.16 0 0 1 18.08 0h27.857c1.48.007 2.845.8 3.585 2.082l13.92 24.113a4.16 4.16 0 0 1 0 4.164L49.52 54.473a4.16 4.16 0 0 1-3.6 2.082H18.07a4.17 4.17 0 0 1-3.593-2.082z"/><path fill="#000" d="M40.697 20.512s3.87 9.283-1.406 14.545-14.883 1.894-14.883 1.894L43.95 56.547h1.984a4.16 4.16 0 0 0 3.6-2.082l9.216-15.958z" opacity=".1"/><path d="M45.266 39.507 41 35.23a.7.7 0 0 0-.158-.12 11.63 11.63 0 0 0-1.499-15.859 11.63 11.63 0 0 0-16.396 16.436 11.63 11.63 0 0 0 15.863 1.46.7.7 0 0 0 .113.15l4.277 4.277a.67.67 0 0 0 .947 0l1.12-1.12a.67.67 0 0 0 0-.947zM31.64 36.741a8.75 8.75 0 0 1-6.188-14.937 8.75 8.75 0 0 1 14.937 6.188 8.75 8.75 0 0 1-8.749 8.749m-5.593-9.216v3.616a6.4 6.4 0 0 0 2.338 2.375v-6.013zm4.375-2.998v9.772a6.5 6.5 0 0 0 2.338 0v-9.772zm6.764 6.606v-2.142H34.85v4.5a6.43 6.43 0 0 0 2.338-2.368z"/></g></symbol></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,61 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
jest.mock('jsonwebtoken', () => ({
sign: jest.fn().mockReturnValue('signature'),
}));
describe('Test Google BigQuery V2, executeQuery with named parameters', () => {
nock('https://oauth2.googleapis.com')
.persist()
.post(
'/token',
'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=signature',
)
.reply(200, { access_token: 'token' });
nock('https://bigquery.googleapis.com/bigquery')
.post('/v2/projects/test-project/jobs', {
configuration: {
query: {
queryParameters: [
{
name: 'email',
parameterType: { type: 'STRING' },
parameterValue: { value: 'test@n8n.io' },
},
{
name: 'name',
parameterType: { type: 'STRING' },
parameterValue: { value: 'Test Testerson' },
},
{
name: 'n8n_variable',
parameterType: { type: 'STRING' },
parameterValue: { value: 42 },
},
],
query:
'SELECT * FROM bigquery_node_dev_test_dataset.test_json WHERE email = @email AND name = @name AND n8n_variable = @n8n_variable;',
useLegacySql: false,
parameterMode: 'NAMED',
},
},
})
.reply(200, {
jobReference: {
jobId: 'job_123',
},
status: {
state: 'DONE',
},
})
.get('/v2/projects/test-project/queries/job_123')
.reply(200)
.get('/v2/projects/test-project/queries/job_123?maxResults=1000&timeoutMs=10000')
.reply(200, { rows: [], schema: {} });
new NodeTestHarness().setupTests({
workflowFiles: ['executeQuery.queryParameters.workflow.json'],
});
});
@@ -0,0 +1,79 @@
{
"name": "My workflow 12",
"nodes": [
{
"parameters": {},
"id": "7db7d51a-83c2-4aa0-a736-9c3d1c031b60",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 340]
},
{
"parameters": {
"authentication": "serviceAccount",
"projectId": {
"__rl": true,
"value": "test-project",
"mode": "list",
"cachedResultName": "test-project",
"cachedResultUrl": "https://console.cloud.google.com/bigquery?project=test-project"
},
"sqlQuery": "SELECT * FROM bigquery_node_dev_test_dataset.test_json WHERE email = @email AND name = @name AND n8n_variable = @n8n_variable;",
"options": {
"queryParameters": {
"namedParameters": [
{
"name": "email",
"value": "test@n8n.io"
},
{
"name": "name",
"value": "Test Testerson"
},
{
"name": "n8n_variable",
"value": "={{ 40 + 2 }}"
}
]
}
}
},
"id": "83d00275-0f98-4d5e-a3d6-bbca940ff8ac",
"name": "Google BigQuery",
"type": "n8n-nodes-base.googleBigQuery",
"typeVersion": 2,
"position": [620, 340],
"credentials": {
"googleApi": {
"id": "66",
"name": "Google account 5"
}
}
}
],
"pinData": {
"Google BigQuery": []
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Google BigQuery",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "be2fc126-5d71-4e86-9a4e-eb62ad266860",
"id": "156",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,42 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
jest.mock('jsonwebtoken', () => ({
sign: jest.fn().mockReturnValue('signature'),
}));
describe('Test Google BigQuery V2, executeQuery', () => {
nock('https://oauth2.googleapis.com')
.persist()
.post(
'/token',
'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=signature',
)
.reply(200, { access_token: 'token' });
nock('https://bigquery.googleapis.com/bigquery')
.post('/v2/projects/test-project/jobs', {
configuration: {
query: {
query: 'SELECT * FROM bigquery_node_dev_test_dataset.test_json;',
useLegacySql: false,
},
},
})
.reply(200, {
jobReference: {
jobId: 'job_123',
},
status: {
state: 'DONE',
},
})
.get('/v2/projects/test-project/queries/job_123')
.reply(200)
.get('/v2/projects/test-project/queries/job_123?maxResults=1000&timeoutMs=10000')
.reply(200, { rows: [], schema: {} });
new NodeTestHarness().setupTests({
workflowFiles: ['executeQuery.workflow.json'],
});
});
@@ -0,0 +1,62 @@
{
"name": "My workflow 12",
"nodes": [
{
"parameters": {},
"id": "7db7d51a-83c2-4aa0-a736-9c3d1c031b60",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 340]
},
{
"parameters": {
"authentication": "serviceAccount",
"projectId": {
"__rl": true,
"value": "test-project",
"mode": "list",
"cachedResultName": "test-project",
"cachedResultUrl": "https://console.cloud.google.com/bigquery?project=test-project"
},
"sqlQuery": "SELECT * FROM bigquery_node_dev_test_dataset.test_json;",
"options": {}
},
"id": "83d00275-0f98-4d5e-a3d6-bbca940ff8ac",
"name": "Google BigQuery",
"type": "n8n-nodes-base.googleBigQuery",
"typeVersion": 2,
"position": [620, 340],
"credentials": {
"googleApi": {
"id": "66",
"name": "Google account 5"
}
}
}
],
"pinData": {
"Google BigQuery": []
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Google BigQuery",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "be2fc126-5d71-4e86-9a4e-eb62ad266860",
"id": "156",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,40 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
jest.mock('jsonwebtoken', () => ({
sign: jest.fn().mockReturnValue('signature'),
}));
describe('Test Google BigQuery V2, executeQuery', () => {
nock('https://oauth2.googleapis.com')
.persist()
.post(
'/token',
'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=signature',
)
.reply(200, { access_token: 'token' });
nock('https://bigquery.googleapis.com/bigquery')
.post('/v2/projects/test-project/jobs', {
configuration: {
query: {
query: 'SELECT * FROM bigquery_node_dev_test_dataset.test_json;',
useLegacySql: false,
},
},
})
.reply(200, {
jobReference: {
jobId: 'job_123',
},
status: {
state: 'RUNNING',
},
})
.get('/v2/projects/test-project/queries/job_123?maxResults=1000&timeoutMs=10000')
.replyWithError('Internal server error');
new NodeTestHarness().setupTests({
workflowFiles: ['executeQueryContinueOnJobFail.workflow.json'],
});
});
@@ -0,0 +1,69 @@
{
"name": "My workflow 12",
"nodes": [
{
"parameters": {},
"id": "7db7d51a-83c2-4aa0-a736-9c3d1c031b60",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 340]
},
{
"parameters": {
"authentication": "serviceAccount",
"projectId": {
"__rl": true,
"value": "test-project",
"mode": "list",
"cachedResultName": "test-project",
"cachedResultUrl": "https://console.cloud.google.com/bigquery?project=test-project"
},
"sqlQuery": "SELECT * FROM bigquery_node_dev_test_dataset.test_json;",
"options": {}
},
"id": "83d00275-0f98-4d5e-a3d6-bbca940ff8ac",
"name": "Google BigQuery",
"type": "n8n-nodes-base.googleBigQuery",
"typeVersion": 2,
"position": [620, 340],
"credentials": {
"googleApi": {
"id": "66",
"name": "Google account 5"
}
},
"onError": "continueRegularOutput"
}
],
"pinData": {
"Google BigQuery": [
{
"json": {
"error": "Internal server error"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Google BigQuery",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "be2fc126-5d71-4e86-9a4e-eb62ad266860",
"id": "156",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,47 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
jest.mock('jsonwebtoken', () => ({
sign: jest.fn().mockReturnValue('signature'),
}));
describe('Test Google BigQuery V2, insert auto map', () => {
nock('https://oauth2.googleapis.com')
.persist()
.post(
'/token',
'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=signature',
)
.reply(200, { access_token: 'token' });
nock('https://bigquery.googleapis.com/bigquery')
.get('/v2/projects/test-project/datasets/bigquery_node_dev_test_dataset/tables/num_text')
.reply(200, {
schema: {
fields: [
{ name: 'id', type: 'INT' },
{ name: 'test', type: 'STRING' },
],
},
})
.post(
'/v2/projects/test-project/datasets/bigquery_node_dev_test_dataset/tables/num_text/insertAll',
{
rows: [
{ json: { id: 1, test: '111' } },
{ json: { id: 2, test: '222' } },
{ json: { id: 3, test: '333' } },
],
traceId: 'trace_id',
},
)
.reply(200, [
{ kind: 'bigquery#tableDataInsertAllResponse' },
{ kind: 'bigquery#tableDataInsertAllResponse' },
{ kind: 'bigquery#tableDataInsertAllResponse' },
]);
new NodeTestHarness().setupTests({
workflowFiles: ['insert.autoMapMode.workflow.json'],
});
});
@@ -0,0 +1,146 @@
{
"name": "My workflow 12",
"nodes": [
{
"parameters": {},
"id": "7db7d51a-83c2-4aa0-a736-9c3d1c031b60",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [20, 340]
},
{
"parameters": {
"authentication": "serviceAccount",
"operation": "insert",
"projectId": {
"__rl": true,
"value": "test-project",
"mode": "list",
"cachedResultName": "test-project",
"cachedResultUrl": "https://console.cloud.google.com/bigquery?project=test-project"
},
"datasetId": {
"__rl": true,
"value": "bigquery_node_dev_test_dataset",
"mode": "list",
"cachedResultName": "bigquery_node_dev_test_dataset"
},
"tableId": {
"__rl": true,
"value": "num_text",
"mode": "list",
"cachedResultName": "num_text"
},
"options": {
"traceId": "trace_id"
}
},
"id": "83d00275-0f98-4d5e-a3d6-bbca940ff8ac",
"name": "Google BigQuery",
"type": "n8n-nodes-base.googleBigQuery",
"typeVersion": 2,
"position": [500, 340],
"credentials": {
"googleApi": {
"id": "66",
"name": "Google account 5"
}
}
},
{
"parameters": {
"data": [
{
"id": 1,
"test": "111"
},
{
"id": 2,
"test": "222"
},
{
"id": 3,
"test": "333"
}
]
},
"id": "11d06660-cbd3-4bd2-9619-68e82438a0e3",
"name": "Code",
"type": "n8n-nodes-testing.testData",
"typeVersion": 1,
"position": [240, 340]
}
],
"pinData": {
"Code": [
{
"json": {
"id": 1,
"test": "111"
}
},
{
"json": {
"id": 2,
"test": "222"
}
},
{
"json": {
"id": 3,
"test": "333"
}
}
],
"Google BigQuery": [
{
"json": {
"kind": "bigquery#tableDataInsertAllResponse"
}
},
{
"json": {
"kind": "bigquery#tableDataInsertAllResponse"
}
},
{
"json": {
"kind": "bigquery#tableDataInsertAllResponse"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Google BigQuery",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "30d3e38a-b5a4-4999-816d-7c05a68f31c8",
"id": "156",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,40 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
jest.mock('jsonwebtoken', () => ({
sign: jest.fn().mockReturnValue('signature'),
}));
describe('Test Google BigQuery V2, insert define manually', () => {
nock('https://oauth2.googleapis.com')
.persist()
.post(
'/token',
'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=signature',
)
.reply(200, { access_token: 'token' });
nock('https://bigquery.googleapis.com/bigquery')
.get('/v2/projects/test-project/datasets/bigquery_node_dev_test_dataset/tables/test_json')
.reply(200, {
schema: {
fields: [
{ name: 'json', type: 'JSON' },
{ name: 'name with space', type: 'STRING' },
{ name: 'active', type: 'BOOLEAN' },
],
},
})
.post(
'/v2/projects/test-project/datasets/bigquery_node_dev_test_dataset/tables/test_json/insertAll',
{
rows: [{ json: { active: 'true', json: '{"test": 1}', 'name with space': 'some name' } }],
traceId: 'trace_id',
},
)
.reply(200, [{ kind: 'bigquery#tableDataInsertAllResponse' }]);
new NodeTestHarness().setupTests({
workflowFiles: ['insert.manualMode.workflow.json'],
});
});
@@ -0,0 +1,99 @@
{
"name": "My workflow 12",
"nodes": [
{
"parameters": {},
"id": "7db7d51a-83c2-4aa0-a736-9c3d1c031b60",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 340]
},
{
"parameters": {
"authentication": "serviceAccount",
"operation": "insert",
"projectId": {
"__rl": true,
"value": "test-project",
"mode": "list",
"cachedResultName": "test-project",
"cachedResultUrl": "https://console.cloud.google.com/bigquery?project=test-project"
},
"datasetId": {
"__rl": true,
"value": "bigquery_node_dev_test_dataset",
"mode": "list",
"cachedResultName": "bigquery_node_dev_test_dataset"
},
"tableId": {
"__rl": true,
"value": "test_json",
"mode": "list",
"cachedResultName": "test_json"
},
"dataMode": "define",
"fieldsUi": {
"values": [
{
"fieldId": "active",
"fieldValue": "true"
},
{
"fieldId": "name with space",
"fieldValue": "some name"
},
{
"fieldId": "json",
"fieldValue": "{\"test\": 1}"
}
]
},
"options": {
"traceId": "trace_id"
}
},
"id": "83d00275-0f98-4d5e-a3d6-bbca940ff8ac",
"name": "Google BigQuery",
"type": "n8n-nodes-base.googleBigQuery",
"typeVersion": 2,
"position": [620, 340],
"credentials": {
"googleApi": {
"id": "66",
"name": "Google account 5"
}
}
}
],
"pinData": {
"Google BigQuery": [
{
"json": {
"kind": "bigquery#tableDataInsertAllResponse"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Google BigQuery",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "abd49f26-184d-4f9b-95f0-389ea20df809",
"id": "156",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}
@@ -0,0 +1,272 @@
import { mock } from 'jest-mock-extended';
import { constructExecutionMetaData } from 'n8n-core';
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
import { prepareOutput } from '../../../v2/helpers/utils';
describe('Google BigQuery v2 Utils', () => {
it('should prepareOutput', () => {
const thisArg = mock<IExecuteFunctions>({
getNode: () => ({ typeVersion: 2.1 }) as INode,
helpers: mock({ constructExecutionMetaData }),
});
const response: IDataObject = {
kind: 'bigquery#getQueryResultsResponse',
etag: 'e_tag',
schema: {
fields: [
{
name: 'nodes',
type: 'RECORD',
mode: 'REPEATED',
fields: [
{
name: 'webhookId',
type: 'STRING',
mode: 'NULLABLE',
},
{
name: 'position',
type: 'INTEGER',
mode: 'REPEATED',
},
{
name: 'name',
type: 'STRING',
mode: 'NULLABLE',
},
{
name: 'typeVersion',
type: 'INTEGER',
mode: 'NULLABLE',
},
{
name: 'credentials',
type: 'RECORD',
mode: 'NULLABLE',
fields: [
{
name: 'zendeskApi',
type: 'RECORD',
mode: 'NULLABLE',
fields: [
{
name: 'name',
type: 'STRING',
mode: 'NULLABLE',
},
{
name: 'id',
type: 'INTEGER',
mode: 'NULLABLE',
},
],
},
],
},
{
name: 'type',
type: 'STRING',
mode: 'NULLABLE',
},
{
name: 'parameters',
type: 'RECORD',
mode: 'NULLABLE',
fields: [
{
name: 'conditions',
type: 'RECORD',
mode: 'NULLABLE',
fields: [
{
name: 'all',
type: 'RECORD',
mode: 'REPEATED',
fields: [
{
name: 'value',
type: 'STRING',
mode: 'NULLABLE',
},
],
},
],
},
{
name: 'options',
type: 'RECORD',
mode: 'NULLABLE',
fields: [
{
name: 'fields',
type: 'STRING',
mode: 'REPEATED',
},
],
},
],
},
],
},
],
},
jobReference: {
projectId: 'project_id',
jobId: 'job_ref',
location: 'US',
},
totalRows: '1',
rows: [
{
f: [
{
v: [
{
v: {
f: [
{
v: 'web_hook_id',
},
{
v: [
{
v: '100',
},
{
v: '100',
},
],
},
{
v: 'Zendesk Trigger',
},
{
v: '1',
},
{
v: {
f: [
{
v: {
f: [
{
v: 'Zendesk account',
},
{
v: '8',
},
],
},
},
],
},
},
{
v: 'n8n-nodes-base.zendeskTrigger',
},
{
v: {
f: [
{
v: {
f: [
{
v: [
{
v: {
f: [
{
v: 'closed',
},
],
},
},
],
},
],
},
},
{
v: {
f: [
{
v: [
{
v: 'ticket.title',
},
{
v: 'ticket.description',
},
],
},
],
},
},
],
},
},
],
},
},
],
},
],
},
],
totalBytesProcessed: '0',
jobComplete: true,
cacheHit: true,
};
const returnData = prepareOutput.call(thisArg, response, 0, false, false);
expect(returnData).toBeDefined();
// expect(returnData).toHaveProperty('nodes');
expect(returnData).toEqual([
{
json: {
nodes: [
{
webhookId: 'web_hook_id',
position: ['100', '100'],
name: 'Zendesk Trigger',
typeVersion: '1',
credentials: [
{
zendeskApi: [
{
name: 'Zendesk account',
id: '8',
},
],
},
],
type: 'n8n-nodes-base.zendeskTrigger',
parameters: [
{
conditions: [
{
all: [
{
value: 'closed',
},
],
},
],
options: [
{
fields: ['ticket.title', 'ticket.description'],
},
],
},
],
},
],
},
pairedItem: {
item: 0,
},
},
]);
});
});
@@ -0,0 +1,102 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IDataObject,
JsonObject,
IHttpRequestMethods,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { getGoogleAccessToken } from '../../GenericFunctions';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
): Promise<any> {
const authenticationMethod = this.getNodeParameter(
'authentication',
0,
'serviceAccount',
) as string;
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://bigquery.googleapis.com/bigquery${resource}`,
json: true,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
if (authenticationMethod === 'serviceAccount') {
const credentials = await this.getCredentials('googleApi');
if (credentials === undefined) {
throw new NodeOperationError(this.getNode(), 'No credentials got returned!');
}
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'bigquery');
options.headers!.Authorization = `Bearer ${access_token}`;
return await this.helpers.request(options);
} else {
return await this.helpers.requestOAuth2.call(this, 'googleBigQueryOAuth2Api', options);
}
} catch (error) {
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
error.statusCode = '401';
}
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.maxResults = 100;
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query);
query.pageToken = responseData.pageToken;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.pageToken !== undefined && responseData.pageToken !== '');
return returnData;
}
export function simplify(rows: IDataObject[], fields: string[]) {
const results = [];
for (const row of rows) {
const record: IDataObject = {};
for (const [index, field] of fields.entries()) {
record[field] = (row.f as IDataObject[])[index].v;
}
results.push(record);
}
return results;
}
@@ -0,0 +1,305 @@
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
JsonObject,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeApiError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { oldVersionNotice } from '@utils/descriptions';
import { googleApiRequest, googleApiRequestAllItems, simplify } from './GenericFunctions';
import { recordFields, recordOperations } from './RecordDescription';
import { generatePairedItemData } from '../../../../utils/utilities';
const versionDescription: INodeTypeDescription = {
displayName: 'Google BigQuery',
name: 'googleBigQuery',
icon: 'file:googleBigQuery.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google BigQuery API',
defaults: {
name: 'Google BigQuery',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
},
{
name: 'googleBigQueryOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
oldVersionNotice,
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
noDataExpression: true,
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'OAuth2 (recommended)',
value: 'oAuth2',
},
{
name: 'Service Account',
value: 'serviceAccount',
},
],
default: 'oAuth2',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Record',
value: 'record',
},
],
default: 'record',
},
...recordOperations,
...recordFields,
],
};
export class GoogleBigQueryV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
loadOptions: {
async getProjects(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { projects } = await googleApiRequest.call(this, 'GET', '/v2/projects');
for (const project of projects) {
returnData.push({
name: project.friendlyName as string,
value: project.id,
});
}
return returnData;
},
async getDatasets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const projectId = this.getCurrentNodeParameter('projectId');
const returnData: INodePropertyOptions[] = [];
const { datasets } = await googleApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets`,
);
for (const dataset of datasets) {
returnData.push({
name: dataset.datasetReference.datasetId as string,
value: dataset.datasetReference.datasetId,
});
}
return returnData;
},
async getTables(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const projectId = this.getCurrentNodeParameter('projectId');
const datasetId = this.getCurrentNodeParameter('datasetId');
const returnData: INodePropertyOptions[] = [];
const { tables } = await googleApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables`,
);
for (const table of tables) {
returnData.push({
name: table.tableReference.tableId as string,
value: table.tableReference.tableId,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
if (resource === 'record') {
// *********************************************************************
// record
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// record: create
// ----------------------------------
// https://cloud.google.com/bigquery/docs/reference/rest/v2/tabledata/insertAll
const projectId = this.getNodeParameter('projectId', 0) as string;
const datasetId = this.getNodeParameter('datasetId', 0) as string;
const tableId = this.getNodeParameter('tableId', 0) as string;
const rows: IDataObject[] = [];
const body: IDataObject = {};
for (let i = 0; i < length; i++) {
const options = this.getNodeParameter('options', i);
Object.assign(body, options);
if (body.traceId === undefined) {
body.traceId = uuid();
}
const columns = this.getNodeParameter('columns', i) as string;
const columnList = columns.split(',').map((column) => column.trim());
const record: IDataObject = {};
for (const key of Object.keys(items[i].json)) {
if (columnList.includes(key)) {
record[`${key}`] = items[i].json[key];
}
}
rows.push({ json: record });
}
body.rows = rows;
const itemData = generatePairedItemData(items.length);
try {
responseData = await googleApiRequest.call(
this,
'POST',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}/insertAll`,
body,
);
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData },
);
returnData.push(...executionErrorData);
}
throw new NodeApiError(this.getNode(), error as JsonObject, { itemIndex: 0 });
}
} else if (operation === 'getAll') {
// ----------------------------------
// record: getAll
// ----------------------------------
// https://cloud.google.com/bigquery/docs/reference/rest/v2/tables/get
const returnAll = this.getNodeParameter('returnAll', 0);
const projectId = this.getNodeParameter('projectId', 0) as string;
const datasetId = this.getNodeParameter('datasetId', 0) as string;
const tableId = this.getNodeParameter('tableId', 0) as string;
const simple = this.getNodeParameter('simple', 0) as boolean;
let fields;
if (simple) {
const { schema } = await googleApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}`,
{},
);
fields = (schema.fields || []).map((field: IDataObject) => field.name);
}
for (let i = 0; i < length; i++) {
try {
const options = this.getNodeParameter('options', i);
Object.assign(qs, options);
if (qs.selectedFields) {
fields = (qs.selectedFields as string).split(',');
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'rows',
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}/data`,
{},
qs,
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}/data`,
{},
qs,
);
}
if (!returnAll) {
responseData = responseData.rows;
}
responseData = simple
? simplify(responseData as IDataObject[], fields as string[])
: responseData;
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw new NodeApiError(this.getNode(), error as JsonObject, { itemIndex: i });
}
}
}
}
return [returnData];
}
}
@@ -0,0 +1,286 @@
import type { INodeProperties } from 'n8n-workflow';
export const recordOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['record'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new record',
action: 'Create a record',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Retrieve many records',
action: 'Get many records',
},
],
default: 'create',
},
];
export const recordFields: INodeProperties[] = [
// ----------------------------------
// record: create
// ----------------------------------
{
displayName: 'Project Name or ID',
name: 'projectId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getProjects',
},
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['record'],
},
},
default: '',
description:
'ID of the project to create the record in. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Dataset Name or ID',
name: 'datasetId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDatasets',
loadOptionsDependsOn: ['projectId'],
},
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['record'],
},
},
default: '',
description:
'ID of the dataset to create the record in. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Table Name or ID',
name: 'tableId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getTables',
loadOptionsDependsOn: ['projectId', 'datasetId'],
},
required: true,
displayOptions: {
show: {
operation: ['create'],
resource: ['record'],
},
},
default: '',
description:
'ID of the table to create the record in. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Columns',
name: 'columns',
type: 'string',
displayOptions: {
show: {
resource: ['record'],
operation: ['create'],
},
},
default: '',
required: true,
placeholder: 'id,name,description',
description: 'Comma-separated list of the item properties to use as columns',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
operation: ['create'],
resource: ['record'],
},
},
options: [
{
displayName: 'Ignore Unknown Values',
name: 'ignoreUnknownValues',
type: 'boolean',
default: false,
description: 'Whether to ignore row values that do not match the schema',
},
{
displayName: 'Skip Invalid Rows',
name: 'skipInvalidRows',
type: 'boolean',
default: false,
description: 'Whether to skip rows with values that do not match the schema',
},
{
displayName: 'Template Suffix',
name: 'templateSuffix',
type: 'string',
default: '',
description:
'Create a new table based on the destination table and insert rows into the new table. The new table will be named <code>{destinationTable}{templateSuffix}</code>',
},
{
displayName: 'Trace ID',
name: 'traceId',
type: 'string',
default: '',
description:
'Unique ID for the request, for debugging only. It is case-sensitive, limited to up to 36 ASCII characters. A UUID is recommended.',
},
],
},
// ----------------------------------
// record: getAll
// ----------------------------------
{
displayName: 'Project Name or ID',
name: 'projectId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getProjects',
},
required: true,
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
},
},
default: '',
description:
'ID of the project to retrieve all rows from. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Dataset Name or ID',
name: 'datasetId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDatasets',
loadOptionsDependsOn: ['projectId'],
},
required: true,
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
},
},
default: '',
description:
'ID of the dataset to retrieve all rows from. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Table Name or ID',
name: 'tableId',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getTables',
loadOptionsDependsOn: ['projectId', 'datasetId'],
},
required: true,
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
},
},
default: '',
description:
'ID of the table to retrieve all rows from. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'Simplify',
name: 'simple',
type: 'boolean',
displayOptions: {
show: {
resource: ['record'],
operation: ['getAll'],
},
},
default: true,
description: 'Whether to return a simplified version of the response instead of the raw data',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
operation: ['getAll'],
resource: ['record'],
},
},
options: [
{
displayName: 'Fields',
name: 'selectedFields',
type: 'string',
default: '',
description:
'Subset of fields to return, supports select into sub fields. Example: <code>selectedFields = "a,e.d.f"</code>',
},
// {
// displayName: 'Use Int64 Timestamp',
// name: 'useInt64Timestamp',
// type: 'boolean',
// default: false,
// description: 'Output timestamp as usec int64.',
// },
],
},
];
@@ -0,0 +1,29 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { router } from './actions/router';
import { versionDescription } from './actions/versionDescription';
import { loadOptions, listSearch } from './methods';
export class GoogleBigQueryV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
usableAsTool: true,
};
}
methods = { loadOptions, listSearch };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
return await router.call(this);
}
}
@@ -0,0 +1,121 @@
import type { INodeProperties } from 'n8n-workflow';
export const projectRLC: INodeProperties = {
displayName: 'Project',
name: 'projectId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchProjects',
searchable: true,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
extractValue: {
type: 'regex',
regex: 'https:\\/\\/console.cloud.google.com\\/bigquery\\?project=([0-9a-zA-Z\\-_]+).{0,}',
},
validation: [
{
type: 'regex',
properties: {
regex:
'https:\\/\\/console.cloud.google.com\\/bigquery\\?project=([0-9a-zA-Z\\-_]+).{0,}',
errorMessage: 'Not a valid BigQuery Project URL',
},
},
],
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[a-zA-Z0-9\\-_]{2,}',
errorMessage: 'Not a valid BigQuery Project ID',
},
},
],
url: '=https://console.cloud.google.com/bigquery?project={{$value}}',
},
],
description: 'Projects to which you have been granted any project role',
};
export const datasetRLC: INodeProperties = {
displayName: 'Dataset',
name: 'datasetId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatasets',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[a-zA-Z0-9\\-_]{2,}',
errorMessage: 'Not a valid Dataset ID',
},
},
],
},
],
};
export const tableRLC: INodeProperties = {
displayName: 'Table',
name: 'tableId',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchTables',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '[a-zA-Z0-9\\-_]{2,}',
errorMessage: 'Not a valid Table ID',
},
},
],
},
],
};
@@ -0,0 +1,65 @@
import type { INodeProperties } from 'n8n-workflow';
import * as executeQuery from './executeQuery.operation';
import * as insert from './insert.operation';
import { datasetRLC, projectRLC, tableRLC } from '../commonDescriptions/RLC.description';
export { executeQuery, insert };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['database'],
},
},
options: [
{
name: 'Execute Query',
value: 'executeQuery',
description: 'Execute a SQL query',
action: 'Execute a SQL query',
},
{
name: 'Insert',
value: 'insert',
description: 'Insert rows in a table',
action: 'Insert rows in a table',
},
],
default: 'executeQuery',
},
{
...projectRLC,
displayOptions: {
show: {
resource: ['database'],
operation: ['executeQuery', 'insert'],
},
},
},
{
...datasetRLC,
displayOptions: {
show: {
resource: ['database'],
operation: ['insert'],
},
},
},
{
...tableRLC,
displayOptions: {
show: {
resource: ['database'],
operation: ['insert'],
},
},
},
...executeQuery.description,
...insert.description,
];
@@ -0,0 +1,468 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { ApplicationError, NodeOperationError, sleep } from 'n8n-workflow';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import type { ResponseWithJobReference } from '../../helpers/interfaces';
import { prepareOutput } from '../../helpers/utils';
import { googleBigQueryApiRequestAllItems, googleBigQueryApiRequest } from '../../transport';
interface IQueryParameterOptions {
namedParameters: Array<{
name: string;
value: string;
}>;
}
const properties: INodeProperties[] = [
{
displayName: 'SQL Query',
name: 'sqlQuery',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
},
displayOptions: {
hide: {
'/options.useLegacySql': [true],
},
},
default: '',
placeholder: 'SELECT * FROM dataset.table LIMIT 100',
description:
'SQL query to execute, you can find more information <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax" target="_blank">here</a>. Standard SQL syntax used by default, but you can also use Legacy SQL syntax by using optinon \'Use Legacy SQL\'.',
},
{
displayName: 'SQL Query',
name: 'sqlQuery',
type: 'string',
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
},
displayOptions: {
show: {
'/options.useLegacySql': [true],
},
},
default: '',
placeholder: 'SELECT * FROM [project:dataset.table] LIMIT 100;',
hint: 'Legacy SQL syntax',
description:
'SQL query to execute, you can find more information about Legacy SQL syntax <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax" target="_blank">here</a>',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Default Dataset Name or ID',
name: 'defaultDataset',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getDatasets',
loadOptionsDependsOn: ['projectId.value'],
},
default: '',
description:
'If not set, all table names in the query string must be qualified in the format \'datasetId.tableId\'. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
displayName: 'Dry Run',
name: 'dryRun',
type: 'boolean',
default: false,
description:
"Whether set to true BigQuery doesn't run the job. Instead, if the query is valid, BigQuery returns statistics about the job such as how many bytes would be processed. If the query is invalid, an error returns.",
},
{
displayName: 'Include Schema in Output',
name: 'includeSchema',
type: 'boolean',
default: false,
description:
"Whether to include the schema in the output. If set to true, the output will contain key '_schema' with the schema of the table.",
displayOptions: {
hide: {
rawOutput: [true],
},
},
},
{
displayName: 'Location (Region)',
name: 'location',
type: 'string',
default: '',
placeholder: 'e.g. europe-west3',
description:
'Location or the region where data would be stored and processed. Pricing for storage and analysis is also defined by location of data and reservations, more information <a href="https://cloud.google.com/bigquery/docs/locations" target="_blank">here</a>.',
},
{
displayName: 'Maximum Bytes Billed',
name: 'maximumBytesBilled',
type: 'string',
default: '',
description:
'Limits the bytes billed for this query. Queries with bytes billed above this limit will fail (without incurring a charge). String in <a href="https://developers.google.com/discovery/v1/type-format?utm_source=cloud.google.com&utm_medium=referral" target="_blank">Int64Value</a> format',
},
{
displayName: 'Max Results Per Page',
name: 'maxResults',
type: 'number',
default: 1000,
description:
'Maximum number of results to return per page of results. This is particularly useful when dealing with large datasets. It will not affect the total number of results returned, e.g. rows in a table. You can use LIMIT in your SQL query to limit the number of rows returned.',
},
{
displayName: 'Timeout',
name: 'timeoutMs',
type: 'number',
default: 10000,
hint: 'How long to wait for the query to complete, in milliseconds',
description:
'Specifies the maximum amount of time, in milliseconds, that the client is willing to wait for the query to complete. Be aware that the call is not guaranteed to wait for the specified timeout; it typically returns after around 200 seconds (200,000 milliseconds), even if the query is not complete.',
},
{
displayName: 'Raw Output',
name: 'rawOutput',
type: 'boolean',
default: false,
displayOptions: {
hide: {
dryRun: [true],
},
},
},
{
displayName: 'Use Legacy SQL',
name: 'useLegacySql',
type: 'boolean',
default: false,
description:
"Whether to use BigQuery's legacy SQL dialect for this query. If set to false, the query will use BigQuery's standard SQL.",
},
{
displayName: 'Return Integers as Numbers',
name: 'returnAsNumbers',
type: 'boolean',
default: false,
description:
'Whether all integer values will be returned as numbers. If set to false, all integer values will be returned as strings.',
},
{
displayName: 'Query Parameters (Named)',
name: 'queryParameters',
type: 'fixedCollection',
description:
'Use <a href="https://cloud.google.com/bigquery/docs/parameterized-queries#using_structs_in_parameterized_queries" target="_blank">parameterized queries</a> to prevent SQL injections. Positional arguments are not supported at the moment. This feature won\'t be available when using legacy SQL.',
displayOptions: {
hide: {
'/options.useLegacySql': [true],
},
},
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Parameter',
default: {
namedParameters: [
{
name: '',
value: '',
},
],
},
options: [
{
name: 'namedParameters',
displayName: 'Named Parameter',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the parameter',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description:
'The substitute value. It must be a string. Arrays, dates and struct types mentioned in <a href="https://cloud.google.com/bigquery/docs/parameterized-queries#using_structs_in_parameterized_queries" target="_blank">the official documentation</a> are not yet supported.',
},
],
},
],
},
],
},
];
const displayOptions = {
show: {
resource: ['database'],
operation: ['executeQuery'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
const abortSignal = this.getExecutionCancelSignal();
const items = this.getInputData();
const length = items.length;
const returnData: INodeExecutionData[] = [];
let jobs = [];
let maxResults = 1000;
let timeoutMs = 10000;
for (let i = 0; i < length; i++) {
try {
let sqlQuery = this.getNodeParameter('sqlQuery', i) as string;
const options = this.getNodeParameter('options', i) as {
defaultDataset?: string;
dryRun?: boolean;
includeSchema?: boolean;
location?: string;
maximumBytesBilled?: string;
maxResults?: number;
timeoutMs?: number;
rawOutput?: boolean;
useLegacySql?: boolean;
returnAsNumbers?: boolean;
queryParameters?: IQueryParameterOptions;
};
const projectId = this.getNodeParameter('projectId', i, undefined, {
extractValue: true,
});
for (const resolvable of getResolvables(sqlQuery)) {
sqlQuery = sqlQuery.replace(resolvable, this.evaluateExpression(resolvable, i) as string);
}
let rawOutput = false;
let includeSchema = false;
if (options.rawOutput !== undefined) {
rawOutput = options.rawOutput;
delete options.rawOutput;
}
if (options.includeSchema !== undefined) {
includeSchema = options.includeSchema;
delete options.includeSchema;
}
if (options.maxResults) {
maxResults = options.maxResults;
delete options.maxResults;
}
if (options.timeoutMs) {
timeoutMs = options.timeoutMs;
delete options.timeoutMs;
}
const body: IDataObject = { ...options };
body.query = sqlQuery;
if (body.defaultDataset) {
body.defaultDataset = {
datasetId: options.defaultDataset,
projectId,
};
}
if (body.useLegacySql === undefined) {
body.useLegacySql = false;
}
if (typeof body.queryParameters === 'object') {
const { namedParameters } = body.queryParameters as IQueryParameterOptions;
body.parameterMode = 'NAMED';
body.queryParameters = namedParameters.map(({ name, value }) => {
// BigQuery type descriptors are very involved, and it would be hard to support all possible
// options, that's why the only supported type here is "STRING".
//
// If we switch this node to the official JS SDK from Google, we should be able to use `getTypeDescriptorFromValue`
// at runtime, which would infer BQ type descriptors of any valid JS value automatically:
//
// https://github.com/googleapis/nodejs-bigquery/blob/22021957f697ce67491bd50535f6fb43a99feea0/src/bigquery.ts#L1111
//
// Another, less user-friendly option, would be to allow users to specify the types manually.
return { name, parameterType: { type: 'STRING' }, parameterValue: { value } };
});
}
//https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs/insert
const response: ResponseWithJobReference = await googleBigQueryApiRequest.call(
this,
'POST',
`/v2/projects/${projectId}/jobs`,
{
configuration: {
query: body,
},
},
);
if (!response?.jobReference?.jobId) {
throw new NodeOperationError(this.getNode(), `No job ID returned, item ${i}`, {
description: `sql: ${sqlQuery}`,
itemIndex: i,
});
}
const jobId = response?.jobReference?.jobId;
const raw = rawOutput || options.dryRun || false;
const location = options.location || response.jobReference.location;
if (response.status?.state === 'DONE') {
const qs = { location, maxResults, timeoutMs };
//https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs/getQueryResults
const queryResponse: IDataObject = await googleBigQueryApiRequestAllItems.call(
this,
'GET',
`/v2/projects/${projectId}/queries/${jobId}`,
undefined,
qs,
);
if (body.returnAsNumbers === true) {
const numericDataTypes = ['INTEGER', 'NUMERIC', 'FLOAT', 'BIGNUMERIC']; // https://cloud.google.com/bigquery/docs/schemas#standard_sql_data_types
const schema: IDataObject = queryResponse?.schema as IDataObject;
const schemaFields: IDataObject[] = schema.fields as IDataObject[];
const schemaDataTypes: string[] = schemaFields?.map(
(field: IDataObject) => field.type as string,
);
const rows: IDataObject[] = queryResponse.rows as IDataObject[];
for (const row of rows) {
if (!row?.f || !Array.isArray(row.f)) continue;
row.f.forEach((entry: IDataObject, index: number) => {
if (entry && typeof entry === 'object' && 'v' in entry) {
// Skip this row if it's null or doesn't have 'f' as an array
const value = entry.v;
if (numericDataTypes.includes(schemaDataTypes[index])) {
entry.v = Number(value);
}
}
});
}
}
returnData.push(...prepareOutput.call(this, queryResponse, i, raw, includeSchema));
} else {
jobs.push({ jobId, projectId, i, raw, includeSchema, location });
}
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
if ((error.message as string).includes('location') || error.httpCode === '404') {
error.description =
"Are you sure your table is in that region? You can specify the region using the 'Location' parameter from options.";
}
if (error.httpCode === '403' && error.message.includes('Drive')) {
error.description =
'If your table(s) pull from a document in Google Drive, make sure that document is shared with your user';
}
throw new NodeOperationError(this.getNode(), error as Error, {
itemIndex: i,
description: error.description,
});
}
}
let waitTime = 1000;
outerLoop: while (jobs.length > 0) {
const settledJobs: string[] = [];
for (const job of jobs) {
if (abortSignal?.aborted) {
break outerLoop;
}
try {
const qs: IDataObject = job.location ? { location: job.location } : {};
qs.maxResults = maxResults;
qs.timeoutMs = timeoutMs;
//https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs/getQueryResults
const response: IDataObject = await googleBigQueryApiRequestAllItems.call(
this,
'GET',
`/v2/projects/${job.projectId}/queries/${job.jobId}`,
undefined,
qs,
);
if (response.jobComplete) {
settledJobs.push(job.jobId);
returnData.push(...prepareOutput.call(this, response, job.i, job.raw, job.includeSchema));
}
if ((response?.errors as IDataObject[])?.length) {
const errorMessages = (response.errors as IDataObject[]).map((error) => error.message);
throw new ApplicationError(
`Error(s) ocurring while executing query from item ${job.i.toString()}: ${errorMessages.join(
', ',
)}`,
{ level: 'warning' },
);
}
} catch (error) {
if (this.continueOnFail()) {
settledJobs.push(job.jobId);
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: job.i } },
);
returnData.push(...executionErrorData);
continue;
}
throw new NodeOperationError(this.getNode(), error as Error, {
itemIndex: job.i,
description: error.description,
});
}
}
jobs = jobs.filter((job) => !settledJobs.includes(job.jobId));
if (jobs.length > 0) {
await sleep(waitTime);
if (waitTime < 30000) {
waitTime = waitTime * 2;
}
}
}
return returnData;
}
@@ -0,0 +1,293 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { generatePairedItemData, updateDisplayOptions } from '@utils/utilities';
import type { TableSchema } from '../../helpers/interfaces';
import { checkSchema, wrapData } from '../../helpers/utils';
import { googleBigQueryApiRequest } from '../../transport';
const properties: INodeProperties[] = [
{
displayName: 'Data Mode',
name: 'dataMode',
type: 'options',
options: [
{
name: 'Auto-Map Input Data',
value: 'autoMap',
description: 'Use when node input properties match destination field names',
},
{
name: 'Map Each Field Below',
value: 'define',
description: 'Set the value for each destination field',
},
],
default: 'autoMap',
description: 'Whether to insert the input data this node receives in the new row',
},
{
displayName:
"In this mode, make sure the incoming data fields are named the same as the columns in BigQuery. (Use an 'Edit Fields' node before this node to change them if required.)",
name: 'info',
type: 'notice',
default: '',
displayOptions: {
show: {
dataMode: ['autoMap'],
},
},
},
{
displayName: 'Fields to Send',
name: 'fieldsUi',
placeholder: 'Add Field',
type: 'fixedCollection',
typeOptions: {
multipleValueButtonText: 'Add Field',
multipleValues: true,
},
default: {},
options: [
{
displayName: 'Field',
name: 'values',
values: [
{
displayName: 'Field Name or ID',
name: 'fieldId',
type: 'options',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsDependsOn: ['projectId.value', 'datasetId.value', 'tableId.value'],
loadOptionsMethod: 'getSchema',
},
default: '',
},
{
displayName: 'Field Value',
name: 'fieldValue',
type: 'string',
default: '',
},
],
},
],
displayOptions: {
show: {
dataMode: ['define'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Batch Size',
name: 'batchSize',
type: 'number',
default: 100,
typeOptions: {
minValue: 1,
},
},
{
displayName: 'Ignore Unknown Values',
name: 'ignoreUnknownValues',
type: 'boolean',
default: false,
description: 'Whether to gnore row values that do not match the schema',
},
{
displayName: 'Skip Invalid Rows',
name: 'skipInvalidRows',
type: 'boolean',
default: false,
description: 'Whether to skip rows with values that do not match the schema',
},
{
displayName: 'Template Suffix',
name: 'templateSuffix',
type: 'string',
default: '',
description:
'Create a new table based on the destination table and insert rows into the new table. The new table will be named <code>{destinationTable}{templateSuffix}</code>',
},
{
displayName: 'Trace ID',
name: 'traceId',
type: 'string',
default: '',
description:
'Unique ID for the request, for debugging only. It is case-sensitive, limited to up to 36 ASCII characters. A UUID is recommended.',
},
],
},
];
const displayOptions = {
show: {
resource: ['database'],
operation: ['insert'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
// https://cloud.google.com/bigquery/docs/reference/rest/v2/tabledata/insertAll
const projectId = this.getNodeParameter('projectId', 0, undefined, {
extractValue: true,
});
const datasetId = this.getNodeParameter('datasetId', 0, undefined, {
extractValue: true,
});
const tableId = this.getNodeParameter('tableId', 0, undefined, {
extractValue: true,
});
const options = this.getNodeParameter('options', 0);
const dataMode = this.getNodeParameter('dataMode', 0) as string;
let batchSize = 100;
if (options.batchSize) {
batchSize = options.batchSize as number;
delete options.batchSize;
}
const items = this.getInputData();
const length = items.length;
const returnData: INodeExecutionData[] = [];
const rows: IDataObject[] = [];
const body: IDataObject = {};
Object.assign(body, options);
if (body.traceId === undefined) {
body.traceId = uuid();
}
const schema = (
await googleBigQueryApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}`,
{},
)
).schema as TableSchema;
if (schema === undefined) {
throw new NodeOperationError(this.getNode(), 'The destination table has no defined schema');
}
for (let i = 0; i < length; i++) {
try {
const record: IDataObject = {};
if (dataMode === 'autoMap') {
schema.fields.forEach(({ name }) => {
record[name] = items[i].json[name];
});
}
if (dataMode === 'define') {
const fields = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
fields.forEach(({ fieldId, fieldValue }) => {
record[`${fieldId}`] = fieldValue;
});
}
rows.push({ json: checkSchema.call(this, schema, record, i) });
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw new NodeOperationError(this.getNode(), error.message as string, {
itemIndex: i,
description: error?.description,
});
}
}
const itemData = generatePairedItemData(items.length);
for (let i = 0; i < rows.length; i += batchSize) {
const batch = rows.slice(i, i + batchSize);
body.rows = batch;
const responseData = await googleBigQueryApiRequest.call(
this,
'POST',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}/insertAll`,
body,
);
if (responseData?.insertErrors && !options.skipInvalidRows) {
const errors: string[] = [];
const failedRows: number[] = [];
const stopedRows: number[] = [];
(responseData.insertErrors as IDataObject[]).forEach((entry) => {
const invalidRows = (entry.errors as IDataObject[]).filter(
(error) => error.reason !== 'stopped',
);
if (invalidRows.length) {
const entryIndex = (entry.index as number) + i;
errors.push(
`Row ${entryIndex} failed with error: ${invalidRows
.map((error) => error.message)
.join(', ')}`,
);
failedRows.push(entryIndex);
} else {
const entryIndex = (entry.index as number) + i;
stopedRows.push(entryIndex);
}
});
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: errors.join('\n, ') }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
const failedMessage = `Problem inserting item(s) [${failedRows.join(', ')}]`;
const stoppedMessage = stopedRows.length
? `, nothing was inserted item(s) [${stopedRows.join(', ')}]`
: '';
throw new NodeOperationError(this.getNode(), `${failedMessage}${stoppedMessage}`, {
description: errors.join('\n, '),
itemIndex: i,
});
}
const executionData = this.helpers.constructExecutionMetaData(
wrapData(responseData as IDataObject[]),
{ itemData },
);
returnData.push(...executionData);
}
return returnData;
}
@@ -0,0 +1,9 @@
import type { AllEntities, Entity } from 'n8n-workflow';
type GoogleBigQueryMap = {
database: 'executeQuery' | 'insert';
};
export type GoogleBigQuery = AllEntities<GoogleBigQueryMap>;
export type GoogleBigQueryDatabase = Entity<GoogleBigQueryMap, 'database'>;
@@ -0,0 +1,27 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as record from './database/Database.resource';
import type { GoogleBigQuery } from './node.type';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const resource = this.getNodeParameter<GoogleBigQuery>('resource', 0);
const operation = this.getNodeParameter('operation', 0);
let returnData: INodeExecutionData[] = [];
const googleBigQuery = {
resource,
operation,
} as GoogleBigQuery;
switch (googleBigQuery.resource) {
case 'database':
returnData = await record[googleBigQuery.operation].execute.call(this);
break;
default:
throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`);
}
return [returnData];
}
@@ -0,0 +1,73 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import * as database from './database/Database.resource';
export const versionDescription: INodeTypeDescription = {
displayName: 'Google BigQuery',
name: 'googleBigQuery',
icon: 'file:googleBigQuery.svg',
group: ['input'],
version: [2, 2.1],
subtitle: '={{$parameter["operation"]}}',
description: 'Consume Google BigQuery API',
defaults: {
name: 'Google BigQuery',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
},
{
name: 'googleBigQueryOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
noDataExpression: true,
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'OAuth2 (recommended)',
value: 'oAuth2',
},
{
name: 'Service Account',
value: 'serviceAccount',
},
],
default: 'oAuth2',
},
{
displayName: 'Resource',
name: 'resource',
type: 'hidden',
noDataExpression: true,
options: [
{
name: 'Database',
value: 'database',
},
],
default: 'database',
},
...database.description,
],
};
@@ -0,0 +1,31 @@
import type { IDataObject } from 'n8n-workflow';
export type SchemaField = {
name: string;
type: string;
mode: string;
fields?: SchemaField[];
};
export type TableSchema = {
fields: SchemaField[];
};
export type TableRawData = {
f: Array<{ v: IDataObject | TableRawData }>;
};
export type JobReference = {
projectId: string;
jobId: string;
location: string;
};
export type ResponseWithJobReference = {
kind: string;
id: string;
jobReference: JobReference;
status: {
state: 'PENDING' | 'RUNNING' | 'DONE';
};
};
@@ -0,0 +1,156 @@
import { DateTime } from 'luxon';
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import type { SchemaField, TableRawData, TableSchema } from './interfaces';
function getFieldValue(schemaField: SchemaField, field: IDataObject, parseTimestamps = false) {
if (schemaField.type === 'RECORD') {
return simplify([field.v as TableRawData], schemaField.fields as unknown as SchemaField[]);
} else {
let value = field.v;
if (schemaField.type === 'JSON') {
try {
value = jsonParse(value as string);
} catch (error) {}
} else if (schemaField.type === 'TIMESTAMP' && parseTimestamps) {
const dt = DateTime.fromSeconds(Number(value));
value = dt.isValid ? dt.toISO() : value;
}
return value;
}
}
export function wrapData(data: IDataObject | IDataObject[]): INodeExecutionData[] {
if (!Array.isArray(data)) {
return [{ json: data }];
}
return data.map((item) => ({
json: item,
}));
}
export function simplify(
data: TableRawData[],
schema: SchemaField[],
includeSchema = false,
parseTimestamps = false,
) {
const returnData: IDataObject[] = [];
for (const entry of data) {
const record: IDataObject = {};
for (const [index, field] of entry.f.entries()) {
if (schema[index].mode !== 'REPEATED') {
record[schema[index].name] = getFieldValue(schema[index], field, parseTimestamps);
} else {
record[schema[index].name] = (field.v as unknown as IDataObject[]).flatMap(
(repeatedField) => {
return getFieldValue(
schema[index],
repeatedField as unknown as IDataObject,
parseTimestamps,
);
},
);
}
}
if (includeSchema) {
record._schema = schema;
}
returnData.push(record);
}
return returnData;
}
export function prepareOutput(
this: IExecuteFunctions,
response: IDataObject,
itemIndex: number,
rawOutput: boolean,
includeSchema = false,
) {
let responseData;
if (response === undefined) return [];
if (rawOutput) {
responseData = response;
} else {
const { rows, schema } = response;
const parseTimestamps = this.getNode().typeVersion >= 2.1;
if (rows !== undefined && schema !== undefined) {
const fields = (schema as TableSchema).fields;
responseData = rows;
responseData = simplify(
responseData as TableRawData[],
fields,
includeSchema,
parseTimestamps,
);
} else if (schema && includeSchema) {
responseData = { success: true, _schema: schema };
} else {
responseData = { success: true };
}
}
const executionData = this.helpers.constructExecutionMetaData(
wrapData(responseData as IDataObject[]),
{
itemData: { item: itemIndex },
},
);
return executionData;
}
export function checkSchema(
this: IExecuteFunctions,
schema: TableSchema,
record: IDataObject,
i: number,
) {
const returnData = { ...record };
schema.fields.forEach(({ name, mode, type, fields }) => {
if (mode === 'REQUIRED' && returnData[name] === undefined) {
throw new NodeOperationError(
this.getNode(),
`The property '${name}' is required, please define it in the 'Fields to Send'`,
{ itemIndex: i },
);
}
if (type !== 'STRING' && returnData[name] === '') {
returnData[name] = null;
}
if (type === 'JSON') {
let value = returnData[name];
if (typeof value === 'object') {
value = JSON.stringify(value);
}
returnData[name] = value;
}
if (type === 'RECORD' && typeof returnData[name] !== 'object') {
let parsedField;
try {
parsedField = jsonParse(returnData[name] as string);
} catch (error) {
const recordField = fields ? `Field Schema:\n ${JSON.stringify(fields)}` : '';
throw new NodeOperationError(
this.getNode(),
`The property '${name}' is a RECORD type, but the value is nor an object nor a valid JSON string`,
{ itemIndex: i, description: recordField },
);
}
returnData[name] = parsedField as IDataObject;
}
});
return returnData;
}
@@ -0,0 +1,2 @@
export * as loadOptions from './loadOptions';
export * as listSearch from './listSearch';
@@ -0,0 +1,116 @@
import type { IDataObject, ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import { googleBigQueryApiRequest } from '../transport';
export async function searchProjects(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const qs = {
pageToken: (paginationToken as string) || undefined,
};
const response = await googleBigQueryApiRequest.call(this, 'GET', '/v2/projects', undefined, qs);
let { projects } = response;
if (filter) {
projects = projects.filter(
(project: IDataObject) =>
(project.friendlyName as string).includes(filter) ||
(project.id as string).includes(filter),
);
}
return {
results: projects.map((project: IDataObject) => ({
name: project.friendlyName as string,
value: project.id,
url: `https://console.cloud.google.com/bigquery?project=${project.id as string}`,
})),
paginationToken: response.nextPageToken,
};
}
export async function searchDatasets(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const projectId = this.getNodeParameter('projectId', undefined, {
extractValue: true,
});
const qs = {
pageToken: (paginationToken as string) || undefined,
};
const response = await googleBigQueryApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets`,
undefined,
qs,
);
let { datasets } = response;
if (filter) {
datasets = datasets.filter((dataset: { datasetReference: IDataObject }) =>
(dataset.datasetReference.datasetId as string).includes(filter),
);
}
return {
results: datasets.map((dataset: { datasetReference: IDataObject }) => ({
name: dataset.datasetReference.datasetId as string,
value: dataset.datasetReference.datasetId,
})),
paginationToken: response.nextPageToken,
};
}
export async function searchTables(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const projectId = this.getNodeParameter('projectId', undefined, {
extractValue: true,
});
const datasetId = this.getNodeParameter('datasetId', undefined, {
extractValue: true,
});
const qs = {
pageToken: (paginationToken as string) || undefined,
};
const response = await googleBigQueryApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables`,
undefined,
qs,
);
let { tables } = response;
if (filter) {
tables = tables.filter((table: { tableReference: IDataObject }) =>
(table.tableReference.tableId as string).includes(filter),
);
}
const returnData = {
results: tables.map((table: { tableReference: IDataObject }) => ({
name: table.tableReference.tableId as string,
value: table.tableReference.tableId,
})),
paginationToken: response.nextPageToken,
};
return returnData;
}
@@ -0,0 +1,54 @@
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { googleBigQueryApiRequest } from '../transport';
export async function getDatasets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const projectId = this.getNodeParameter('projectId', undefined, {
extractValue: true,
});
const returnData: INodePropertyOptions[] = [];
const { datasets } = await googleBigQueryApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets`,
);
for (const dataset of datasets) {
returnData.push({
name: dataset.datasetReference.datasetId as string,
value: dataset.datasetReference.datasetId,
});
}
return returnData;
}
export async function getSchema(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const projectId = this.getNodeParameter('projectId', undefined, {
extractValue: true,
});
const datasetId = this.getNodeParameter('datasetId', undefined, {
extractValue: true,
});
const tableId = this.getNodeParameter('tableId', undefined, {
extractValue: true,
});
const returnData: INodePropertyOptions[] = [];
const { schema } = await googleBigQueryApiRequest.call(
this,
'GET',
`/v2/projects/${projectId}/datasets/${datasetId}/tables/${tableId}`,
{},
);
for (const field of schema.fields as IDataObject[]) {
returnData.push({
name: field.name as string,
value: field.name as string,
description:
`type: ${field.type as string}` + (field.mode ? ` mode: ${field.mode as string}` : ''),
});
}
return returnData;
}
@@ -0,0 +1,93 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { getGoogleAccessToken } from '../../../GenericFunctions';
export async function googleBigQueryApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: IDataObject = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
) {
const authenticationMethod = this.getNodeParameter(
'authentication',
0,
'serviceAccount',
) as string;
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://bigquery.googleapis.com/bigquery${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 (authenticationMethod === 'serviceAccount') {
const credentials = await this.getCredentials('googleApi');
if (credentials === undefined) {
throw new NodeOperationError(this.getNode(), 'No credentials got returned!');
}
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'bigquery');
options.headers!.Authorization = `Bearer ${access_token}`;
return await this.helpers.request(options);
} else {
return await this.helpers.requestOAuth2.call(this, 'googleBigQueryOAuth2Api', options);
}
} catch (error) {
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
error.statusCode = '401';
}
throw new NodeApiError(this.getNode(), error as JsonObject, {
message: error?.error?.error?.message || error.message,
});
}
}
export async function googleBigQueryApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
query: IDataObject = {},
) {
let rows: IDataObject[] = [];
let responseData;
if (query.maxResults === undefined) {
query.maxResults = 1000;
}
do {
responseData = await googleBigQueryApiRequest.call(this, method, endpoint, body, query);
query.pageToken = responseData.pageToken;
rows = rows.concat((responseData.rows as IDataObject[]) ?? []);
} while (responseData.pageToken !== undefined && responseData.pageToken !== '');
return { ...(responseData || {}), rows };
}
@@ -0,0 +1,88 @@
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
IDataObject,
JsonObject,
IHttpRequestMethods,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { getGoogleAccessToken } from '../GenericFunctions';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
): Promise<any> {
const authenticationMethod = this.getNodeParameter(
'authentication',
0,
'serviceAccount',
) as string;
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://www.googleapis.com/books/${resource}`,
json: true,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
if (authenticationMethod === 'serviceAccount') {
const credentials = await this.getCredentials<{
email: string;
privateKey: string;
}>('googleApi');
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'books');
options.headers!.Authorization = `Bearer ${access_token}`;
return await this.helpers.request(options);
} else {
return await this.helpers.requestOAuth2.call(this, 'googleBooksOAuth2Api', options);
}
} catch (error) {
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
error.statusCode = '401';
}
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.maxResults = 40;
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query);
returnData.push.apply(returnData, (responseData[propertyName] as IDataObject[]) || []);
} while (returnData.length < responseData.totalItems);
return returnData;
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleBooks",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Miscellaneous"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlebooks/"
}
]
}
}
@@ -0,0 +1,529 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions';
export interface IGoogleAuthCredentials {
email: string;
privateKey: string;
}
export class GoogleBooks implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Books',
name: 'googleBooks',
icon: 'file:googlebooks.svg',
group: ['input', 'output'],
version: [1, 2],
description: 'Read data from Google Books',
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
defaults: {
name: 'Google Books',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'googleApi',
required: true,
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
},
{
name: 'googleBooksOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Service Account',
value: 'serviceAccount',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'serviceAccount',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'OAuth2 (recommended)',
value: 'oAuth2',
},
{
name: 'Service Account',
value: 'serviceAccount',
},
],
default: 'oAuth2',
displayOptions: {
show: {
'@version': [2],
},
},
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Bookshelf',
value: 'bookshelf',
},
{
name: 'Bookshelf Volume',
value: 'bookshelfVolume',
},
{
name: 'Volume',
value: 'volume',
},
],
default: 'bookshelf',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Get',
value: 'get',
description: 'Retrieve a specific bookshelf resource for the specified user',
action: 'Get a bookshelf',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many public bookshelf resource for the specified user',
action: 'Get many bookshelves',
},
],
displayOptions: {
show: {
resource: ['bookshelf'],
},
},
default: 'get',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Add',
value: 'add',
description: 'Add a volume to a bookshelf',
action: 'Add a bookshelf volume',
},
{
name: 'Clear',
value: 'clear',
description: 'Clears all volumes from a bookshelf',
action: 'Clear a bookshelf volume',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many volumes in a specific bookshelf for the specified user',
action: 'Get many bookshelf volumes',
},
{
name: 'Move',
value: 'move',
description: 'Moves a volume within a bookshelf',
action: 'Move a bookshelf volume',
},
{
name: 'Remove',
value: 'remove',
description: 'Removes a volume from a bookshelf',
action: 'Remove a bookshelf volume',
},
],
displayOptions: {
show: {
resource: ['bookshelfVolume'],
},
},
default: 'getAll',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Get',
value: 'get',
description: 'Get a volume resource based on ID',
action: 'Get a volume',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many volumes filtered by query',
action: 'Get many volumes',
},
],
displayOptions: {
show: {
resource: ['volume'],
},
},
default: 'get',
},
{
displayName: 'My Library',
name: 'myLibrary',
type: 'boolean',
default: false,
required: true,
displayOptions: {
show: {
operation: ['get', 'getAll'],
resource: ['bookshelf', 'bookshelfVolume'],
},
},
},
// ----------------------------------
// All
// ----------------------------------
{
displayName: 'Search Query',
name: 'searchQuery',
type: 'string',
description: 'Full-text search query string',
default: '',
required: true,
displayOptions: {
show: {
operation: ['getAll'],
resource: ['volume'],
},
},
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
description: 'ID of user',
default: '',
required: true,
displayOptions: {
show: {
operation: ['get', 'getAll'],
resource: ['bookshelf', 'bookshelfVolume'],
},
hide: {
myLibrary: [true],
},
},
},
{
displayName: 'Bookshelf ID',
name: 'shelfId',
type: 'string',
description: 'ID of the bookshelf',
default: '',
required: true,
displayOptions: {
show: {
operation: ['get', 'add', 'clear', 'move', 'remove'],
resource: ['bookshelf', 'bookshelfVolume'],
},
},
},
{
displayName: 'Bookshelf ID',
name: 'shelfId',
type: 'string',
description: 'ID of the bookshelf',
default: '',
required: true,
displayOptions: {
show: {
operation: ['getAll'],
resource: ['bookshelfVolume'],
},
},
},
{
displayName: 'Volume ID',
name: 'volumeId',
type: 'string',
description: 'ID of the volume',
default: '',
required: true,
displayOptions: {
show: {
operation: ['add', 'move', 'remove', 'get'],
resource: ['bookshelfVolume', 'volume'],
},
},
},
{
displayName: 'Volume Position',
name: 'volumePosition',
type: 'string',
description:
'Position on shelf to move the item (0 puts the item before the current first item, 1 puts it between the first and the second and so on)',
default: '',
required: true,
displayOptions: {
show: {
operation: ['move'],
resource: ['bookshelfVolume'],
},
},
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: ['getAll'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: ['getAll'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 40,
},
default: 40,
description: 'Max number of results to return',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const length = items.length;
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const qs: IDataObject = {};
let responseData;
for (let i = 0; i < length; i++) {
try {
if (resource === 'volume') {
if (operation === 'get') {
const volumeId = this.getNodeParameter('volumeId', i) as string;
responseData = await googleApiRequest.call(this, 'GET', `v1/volumes/${volumeId}`, {});
} else if (operation === 'getAll') {
const searchQuery = this.getNodeParameter('searchQuery', i) as string;
const returnAll = this.getNodeParameter('returnAll', i);
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'items',
'GET',
`v1/volumes?q=${searchQuery}`,
{},
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(
this,
'GET',
`v1/volumes?q=${searchQuery}`,
{},
qs,
);
responseData = responseData.items || [];
}
}
}
if (resource === 'bookshelf') {
if (operation === 'get') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
const myLibrary = this.getNodeParameter('myLibrary', i) as boolean;
let endpoint;
if (!myLibrary) {
const userId = this.getNodeParameter('userId', i) as string;
endpoint = `v1/users/${userId}/bookshelves/${shelfId}`;
} else {
endpoint = `v1/mylibrary/bookshelves/${shelfId}`;
}
responseData = await googleApiRequest.call(this, 'GET', endpoint, {});
} else if (operation === 'getAll') {
const myLibrary = this.getNodeParameter('myLibrary', i) as boolean;
const returnAll = this.getNodeParameter('returnAll', i);
let endpoint;
if (!myLibrary) {
const userId = this.getNodeParameter('userId', i) as string;
endpoint = `v1/users/${userId}/bookshelves`;
} else {
endpoint = 'v1/mylibrary/bookshelves';
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'items',
'GET',
endpoint,
{},
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(this, 'GET', endpoint, {}, qs);
responseData = responseData.items || [];
}
}
}
if (resource === 'bookshelfVolume') {
if (operation === 'add') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
const volumeId = this.getNodeParameter('volumeId', i) as string;
const body: IDataObject = {
volumeId,
};
responseData = await googleApiRequest.call(
this,
'POST',
`v1/mylibrary/bookshelves/${shelfId}/addVolume`,
body,
);
}
if (operation === 'clear') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
responseData = await googleApiRequest.call(
this,
'POST',
`v1/mylibrary/bookshelves/${shelfId}/clearVolumes`,
);
}
if (operation === 'getAll') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i);
const myLibrary = this.getNodeParameter('myLibrary', i) as boolean;
let endpoint;
if (!myLibrary) {
const userId = this.getNodeParameter('userId', i) as string;
endpoint = `v1/users/${userId}/bookshelves/${shelfId}/volumes`;
} else {
endpoint = `v1/mylibrary/bookshelves/${shelfId}/volumes`;
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'items',
'GET',
endpoint,
{},
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(this, 'GET', endpoint, {}, qs);
responseData = responseData.items || [];
}
}
if (operation === 'move') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
const volumeId = this.getNodeParameter('volumeId', i) as string;
const volumePosition = this.getNodeParameter('volumePosition', i) as number;
const body: IDataObject = {
volumeId,
volumePosition,
};
responseData = await googleApiRequest.call(
this,
'POST',
`v1/mylibrary/bookshelves/${shelfId}/moveVolume`,
body,
);
}
if (operation === 'remove') {
const shelfId = this.getNodeParameter('shelfId', i) as string;
const volumeId = this.getNodeParameter('volumeId', i) as string;
const body: IDataObject = {
volumeId,
};
responseData = await googleApiRequest.call(
this,
'POST',
`v1/mylibrary/bookshelves/${shelfId}/removeVolume`,
body,
);
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448.7 500"><path fill="#0B5E9F" d="M428.2 221.9 49.2 6.5C33-2.6 18.5-1.7 9.7 6.8 3.4 12.8 0 22.4 0 34.9v429.9C0 477.4 3.7 487 9.7 493c9.1 8.8 23.3 9.7 39.5.3l379.1-215.4c27.2-15.4 27.2-40.4-.1-56"/><linearGradient id="a" x1="223.168" x2="223.168" y1="7682.241" y2="8182.269" gradientTransform="matrix(.9998 0 0 -.9998 1.213 8180.705)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity=".1"/></linearGradient><path fill="url(#a)" fill-rule="evenodd" d="m428.3 221.9-95-54-1.7-1.1-.1.1L49.2 6.6c-15.7-9-29.9-8.3-38.9-.2L8.8 7.7C3.2 13.7 0 23 0 35.1v429.7c0 12.1 3.2 21.4 9.1 27.2l-.2.2c8.9 9.5 23.7 10.6 40.4 1.3L331.5 333l.1.1s1.7-1.1 1.8-1.1l95-54c27.1-15.5 27.1-40.8-.1-56.1" clip-rule="evenodd"/><path fill="#FFF" fill-rule="evenodd" d="m49.2 9.4 379 215.3c12.3 6.9 19.2 16 20.3 25.3 0-10.1-6.7-20.3-20.3-28.1L49.2 6.6C22-9 0 4 0 35.1v2.8C0 6.8 22-5.9 49.2 9.4" clip-rule="evenodd" opacity=".25"/><path fill="#40DCFF" d="M358.2 70.2H135.9V444l242.9-138V90.9c0-11.4-9.2-20.7-20.6-20.7"/><linearGradient id="b" x1="7121.753" x2="7123.592" y1="-324.192" y2="-327.498" gradientTransform="matrix(38.316 0 0 -36.19 -272599.25 -11433.2)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5b7c8c" stop-opacity="0"/><stop offset="1" stop-color="#5b7c8c" stop-opacity=".4"/></linearGradient><path fill="url(#b)" d="M358.2 70.2H135.9v373.6l242.9-137.9v-215c0-11.4-9.2-20.7-20.6-20.7"/><path fill="#009BF0" d="M135.9 70.2H70.3v411l65.6-37.2z"/><path fill="#FFF" d="M358.2 70.2H70.3v2.3h287.9c11.4 0 20.7 6.9 20.7 18.3-.1-11.3-9.3-20.6-20.7-20.6" opacity=".15"/><path fill="#FAFAFA" d="m261.1 173.3 33.8-20.9 35 20.9V70.2h-68.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,520 @@
import {
NodeApiError,
NodeOperationError,
type DeclarativeRestApiSettings,
type IDataObject,
type IExecutePaginationFunctions,
type IExecuteSingleFunctions,
type IHttpRequestMethods,
type IHttpRequestOptions,
type ILoadOptionsFunctions,
type IN8nHttpFullResponse,
type INodeExecutionData,
type INodeListSearchItems,
type INodeListSearchResult,
type IPollFunctions,
type JsonObject,
} from 'n8n-workflow';
import type { ITimeInterval } from './Interfaces';
const addOptName = 'additionalOptions';
const possibleRootProperties = ['localPosts', 'reviews'];
const getAllParams = (execFns: IExecuteSingleFunctions): Record<string, unknown> => {
const params = execFns.getNode().parameters;
const additionalOptions = execFns.getNodeParameter(addOptName, {}) as Record<string, unknown>;
// Merge standard parameters with additional options from the node parameters
return { ...params, ...additionalOptions };
};
/* Helper function to adjust date-time parameters for API requests */
export async function handleDatesPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const params = getAllParams(this);
const body = Object.assign({}, opts.body) as IDataObject;
const event = (body.event as IDataObject) ?? ({} as IDataObject);
if (!params.startDateTime && !params.startDate && !params.endDateTime && !params.endDate) {
return opts;
}
const createDateTimeObject = (dateString: string) => {
const date = new Date(dateString);
return {
date: {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
},
time: dateString.includes('T')
? {
hours: date.getUTCHours(),
minutes: date.getUTCMinutes(),
seconds: date.getUTCSeconds(),
nanos: 0,
}
: undefined,
};
};
// Convert start and end date-time parameters if provided
const startDateTime =
params.startDateTime || params.startDate
? createDateTimeObject((params.startDateTime || params.startDate) as string)
: null;
const endDateTime =
params.endDateTime || params.endDate
? createDateTimeObject((params.endDateTime || params.endDate) as string)
: null;
const schedule: Partial<ITimeInterval> = {
startDate: startDateTime?.date,
endDate: endDateTime?.date,
startTime: startDateTime?.time,
endTime: endDateTime?.time,
};
event.schedule = schedule;
Object.assign(body, { event });
opts.body = body;
return opts;
}
/* Helper function adding update mask to the request */
export async function addUpdateMaskPresend(
this: IExecuteSingleFunctions,
opts: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject;
const propertyMapping: { [key: string]: string } = {
postType: 'topicType',
actionType: 'actionType',
callToActionType: 'callToAction.actionType',
url: 'callToAction.url',
startDateTime: 'event.schedule.startDate,event.schedule.startTime',
endDateTime: 'event.schedule.endDate,event.schedule.endTime',
title: 'event.title',
startDate: 'event.schedule.startDate',
endDate: 'event.schedule.endDate',
couponCode: 'offer.couponCode',
redeemOnlineUrl: 'offer.redeemOnlineUrl',
termsAndConditions: 'offer.termsAndConditions',
};
if (Object.keys(additionalOptions).length) {
const updateMask = Object.keys(additionalOptions)
.map((key) => propertyMapping[key] || key)
.join(',');
opts.qs = {
...opts.qs,
updateMask,
};
}
return opts;
}
/* Helper function to handle pagination */
export async function handlePagination(
this: IExecutePaginationFunctions,
resultOptions: DeclarativeRestApiSettings.ResultOptions,
): Promise<INodeExecutionData[]> {
const aggregatedResult: IDataObject[] = [];
let nextPageToken: string | undefined;
const returnAll = this.getNodeParameter('returnAll') as boolean;
let limit = 100;
if (!returnAll) {
limit = this.getNodeParameter('limit') as number;
resultOptions.maxResults = limit;
}
resultOptions.paginate = true;
do {
if (nextPageToken) {
resultOptions.options.qs = { ...resultOptions.options.qs, pageToken: nextPageToken };
}
const responseData = await this.makeRoutingRequest(resultOptions);
for (const page of responseData) {
for (const prop of possibleRootProperties) {
if (page.json[prop]) {
const currentData = page.json[prop] as IDataObject[];
aggregatedResult.push(...currentData);
}
}
if (!returnAll && aggregatedResult.length >= limit) {
return aggregatedResult.slice(0, limit).map((item) => ({ json: item }));
}
nextPageToken = page.json.nextPageToken as string | undefined;
}
} while (nextPageToken);
return aggregatedResult.map((item) => ({ json: item }));
}
/* Helper functions to handle errors */
export async function handleErrorsDeletePost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post', undefined) as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are deleting could not be found. Adjust the "post" parameter setting to delete the post correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsGetPost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post', undefined) as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are requesting could not be found. Adjust the "post" parameter setting to retrieve the post correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsUpdatePost(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const post = this.getNodeParameter('post') as IDataObject;
const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject;
// Provide a better error message
if (post && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The post you are updating could not be found. Adjust the "post" parameter setting to update the post correctly.',
);
}
// Do not throw an error if the user didn't set additional options (a hint will be shown)
if (response.statusCode === 400 && Object.keys(additionalOptions).length === 0) {
return [{ json: { success: true } }];
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsDeleteReply(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are deleting could not be found. Adjust the "review" parameter setting to update the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsGetReview(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are requesting could not be found. Adjust the "review" parameter setting to update the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
export async function handleErrorsReplyToReview(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode < 200 || response.statusCode >= 300) {
const review = this.getNodeParameter('review', undefined) as IDataObject;
// Provide a better error message
if (review && response.statusCode === 404) {
throw new NodeOperationError(
this.getNode(),
'The review you are replying to could not be found. Adjust the "review" parameter setting to reply to the review correctly.',
);
}
throw new NodeApiError(this.getNode(), response.body as JsonObject, {
message: response.statusMessage,
httpCode: response.statusCode.toString(),
});
}
return data;
}
/* Helper function used in listSearch methods */
export async function googleApiRequest(
this: ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
resource: string,
body: IDataObject = {},
qs: IDataObject = {},
url?: string,
): Promise<IDataObject> {
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
url: url ?? `https://mybusiness.googleapis.com/v4${resource}`,
json: true,
};
try {
if (Object.keys(body).length === 0) {
delete options.body;
}
return (await this.helpers.httpRequestWithAuthentication.call(
this,
'googleBusinessProfileOAuth2Api',
options,
)) as IDataObject;
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
/* listSearch methods */
export async function searchAccounts(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
// Docs for this API call can be found here:
// https://developers.google.com/my-business/reference/accountmanagement/rest/v1/accounts/list
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
'',
{},
{
pageSize: 20,
...query,
},
'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
);
const accounts = responseData.accounts as Array<{ name: string; accountName: string }>;
const results: INodeListSearchItems[] = accounts
.map((a) => ({
name: a.accountName,
value: a.name,
}))
.filter(
(a) =>
!filter ||
a.name.toLowerCase().includes(filter.toLowerCase()) ||
a.value.toLowerCase().includes(filter.toLowerCase()),
)
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results, paginationToken: responseData.nextPageToken };
}
export async function searchLocations(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
// Docs for this API call can be found here:
// https://developers.google.com/my-business/reference/businessinformation/rest/v1/accounts.locations/list
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
'',
{},
{
readMask: 'name',
pageSize: 100,
...query,
},
`https://mybusinessbusinessinformation.googleapis.com/v1/${account}/locations`,
);
const locations = responseData.locations as Array<{ name: string }>;
const results: INodeListSearchItems[] = locations
.map((a) => ({
name: a.name,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results, paginationToken: responseData.nextPageToken };
}
export async function searchReviews(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const location = (this.getNodeParameter('location') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/reviews`,
{},
{
pageSize: 50,
...query,
},
);
const reviews = responseData.reviews as Array<{ name: string; comment: string }>;
const results: INodeListSearchItems[] = reviews
.map((a) => ({
name: a.comment,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results, paginationToken: responseData.nextPageToken };
}
export async function searchPosts(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const query: IDataObject = {};
if (paginationToken) {
query.pageToken = paginationToken;
}
const account = (this.getNodeParameter('account') as IDataObject).value as string;
const location = (this.getNodeParameter('location') as IDataObject).value as string;
const responseData: IDataObject = await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/localPosts`,
{},
{
pageSize: 100,
...query,
},
);
const localPosts = responseData.localPosts as Array<{ name: string; summary: string }>;
const results: INodeListSearchItems[] = localPosts
.map((a) => ({
name: a.summary,
value: a.name,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results, paginationToken: responseData.nextPageToken };
}
@@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.googleBusinessProfile",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlebusinessprofile/"
}
]
},
"alias": ["Google My Business", "GMB", "My Business"]
}
@@ -0,0 +1,79 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { searchAccounts, searchLocations, searchPosts, searchReviews } from './GenericFunctions';
import { postFields, postOperations } from './PostDescription';
import { reviewFields, reviewOperations } from './ReviewDescription';
export class GoogleBusinessProfile implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Business Profile',
name: 'googleBusinessProfile',
icon: 'file:googleBusinessProfile.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Business Profile API',
schemaPath: 'Google/BusinessProfile',
defaults: {
name: 'Google Business Profile',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
hints: [
{
message: 'Please select a parameter in the options to modify the post',
displayCondition:
'={{$parameter["resource"] === "post" && $parameter["operation"] === "update" && Object.keys($parameter["additionalOptions"]).length === 0}}',
whenToDisplay: 'always',
location: 'outputPane',
type: 'warning',
},
],
credentials: [
{
name: 'googleBusinessProfileOAuth2Api',
required: true,
},
],
requestDefaults: {
baseURL: 'https://mybusiness.googleapis.com/v4',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Post',
value: 'post',
},
{
name: 'Review',
value: 'review',
},
],
default: 'post',
},
...postOperations,
...postFields,
...reviewOperations,
...reviewFields,
],
};
methods = {
listSearch: {
searchAccounts,
searchLocations,
searchReviews,
searchPosts,
},
};
}
@@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.googleBusinessProfileTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlebusinessprofiletrigger/"
}
]
},
"alias": ["Google My Business", "GMB", "My Business"]
}
@@ -0,0 +1,192 @@
import {
NodeApiError,
NodeConnectionTypes,
type IPollFunctions,
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { googleApiRequest, searchAccounts, searchLocations } from './GenericFunctions';
export class GoogleBusinessProfileTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Business Profile Trigger',
name: 'googleBusinessProfileTrigger',
icon: 'file:googleBusinessProfile.svg',
group: ['trigger'],
version: 1,
description:
'Fetches reviews from Google Business Profile and starts the workflow on specified polling intervals.',
subtitle: '={{"Google Business Profile Trigger"}}',
defaults: {
name: 'Google Business Profile Trigger',
},
credentials: [
{
name: 'googleBusinessProfileOAuth2Api',
required: true,
},
],
polling: true,
inputs: [],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'Event',
name: 'event',
required: true,
type: 'options',
noDataExpression: true,
default: 'reviewAdded',
options: [
{
name: 'Review Added',
value: 'reviewAdded',
},
],
},
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google Business Profile account',
displayOptions: { show: { event: ['reviewAdded'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { event: ['reviewAdded'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
],
};
methods = {
listSearch: {
searchAccounts,
searchLocations,
},
};
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
const nodeStaticData = this.getWorkflowStaticData('node');
let responseData;
const qs: IDataObject = {};
const account = (this.getNodeParameter('account') as { value: string; mode: string }).value;
const location = (this.getNodeParameter('location') as { value: string; mode: string }).value;
const manualMode = this.getMode() === 'manual';
if (manualMode) {
qs.pageSize = 1; // In manual mode we only want to fetch the latest review
} else {
qs.pageSize = 50; // Maximal page size for the get reviews endpoint
}
try {
responseData = (await googleApiRequest.call(
this,
'GET',
`/${account}/${location}/reviews`,
{},
qs,
)) as { reviews: IDataObject[]; totalReviewCount: number; nextPageToken?: string };
if (manualMode) {
responseData = responseData.reviews;
} else {
// During the first execution there is no delta
if (!nodeStaticData.totalReviewCountLastTimeChecked) {
nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount;
return null;
}
// When count did't change the node shouldn't trigger
if (
!responseData?.reviews?.length ||
nodeStaticData?.totalReviewCountLastTimeChecked === responseData?.totalReviewCount
) {
return null;
}
const numNewReviews =
// @ts-ignore
responseData.totalReviewCount - nodeStaticData.totalReviewCountLastTimeChecked;
nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount;
// By default the reviews will be sorted by updateTime in descending order
// Return only the delta reviews since last pooling
responseData = responseData.reviews.slice(0, numNewReviews);
}
if (Array.isArray(responseData) && responseData.length) {
return [this.helpers.returnJsonArray(responseData)];
}
return null;
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}
}
@@ -0,0 +1,23 @@
interface IDate {
year: number;
month: number;
day: number;
}
interface ITimeOfDay {
hours: number;
minutes: number;
seconds: number;
nanos: number;
}
export interface ITimeInterval {
startDate: IDate;
startTime: ITimeOfDay;
endDate: IDate;
endTime: ITimeOfDay;
}
export interface IReviewReply {
comment: string;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,574 @@
import type { INodeProperties } from 'n8n-workflow';
import {
handleErrorsDeleteReply,
handleErrorsGetReview,
handleErrorsReplyToReview,
handlePagination,
} from './GenericFunctions';
export const reviewOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'get',
noDataExpression: true,
displayOptions: { show: { resource: ['review'] } },
options: [
{
name: 'Delete Reply',
value: 'delete',
action: 'Delete a reply to a review',
description: 'Delete a reply to a review',
routing: {
request: {
method: 'DELETE',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsDeleteReply],
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get review',
description: 'Retrieve details of a specific review on Google Business Profile',
routing: {
request: {
method: 'GET',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsGetReview],
},
},
},
{
name: 'Get Many',
value: 'getAll',
action: 'Get many reviews',
description: 'Retrieve multiple reviews',
routing: {
send: { paginate: true },
operations: { pagination: handlePagination },
request: {
method: 'GET',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews',
qs: {
pageSize:
'={{ $parameter["limit"] ? ($parameter["limit"] < 50 ? $parameter["limit"] : 50) : 50 }}', // Google allows maximum 50 results per page
},
},
},
},
{
name: 'Reply',
value: 'reply',
action: 'Reply to review',
description: 'Reply to a review',
routing: {
request: {
method: 'PUT',
url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply',
ignoreHttpStatusErrors: true,
},
output: {
postReceive: [handleErrorsReplyToReview],
},
},
},
],
},
];
export const reviewFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* review:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google Business Profile account',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['get'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
/* -------------------------------------------------------------------------- */
/* review:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google Business Profile account',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['delete'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
/* -------------------------------------------------------------------------- */
/* review:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google Business Profile account',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Return All',
name: 'returnAll',
default: false,
description: 'Whether to return all results or only up to a given limit',
displayOptions: { show: { resource: ['review'], operation: ['getAll'] } },
type: 'boolean',
},
{
displayName: 'Limit',
name: 'limit',
required: true,
type: 'number',
typeOptions: {
minValue: 1,
},
default: 20,
description: 'Max number of results to return',
displayOptions: { show: { resource: ['review'], operation: ['getAll'], returnAll: [false] } },
},
/* -------------------------------------------------------------------------- */
/* review:reply */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account',
name: 'account',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Google Business Profile account',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchAccounts',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the account name',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+',
errorMessage: 'The name must start with "accounts/"',
},
},
],
placeholder: 'e.g. accounts/0123456789',
},
],
},
{
displayName: 'Location',
name: 'location',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The specific location or business associated with the account',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchLocations',
searchable: true,
},
},
{
displayName: 'By name',
name: 'name',
type: 'string',
hint: 'Enter the location name',
validation: [
{
type: 'regex',
properties: {
regex: 'locations/[0-9]+',
errorMessage: 'The name must start with "locations/"',
},
},
],
placeholder: 'e.g. locations/0123456789',
},
],
},
{
displayName: 'Review',
name: 'review',
required: true,
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Select the review to retrieve its details',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchReviews',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*',
errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. ABC123_review-ID_456xyz',
},
{
displayName: 'By name',
name: 'name',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$',
errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"',
},
},
],
placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz',
},
],
},
{
displayName: 'Reply',
name: 'reply',
type: 'string',
default: '',
description: 'The body of the reply (up to 4096 characters)',
displayOptions: { show: { resource: ['review'], operation: ['reply'] } },
typeOptions: { rows: 5 },
routing: { send: { type: 'body', property: 'comment' } },
},
];
@@ -0,0 +1,46 @@
{
"type": "object",
"properties": {
"comment": {
"type": "string"
},
"createTime": {
"type": "string"
},
"name": {
"type": "string"
},
"reviewer": {
"type": "object",
"properties": {
"displayName": {
"type": "string"
},
"profilePhotoUrl": {
"type": "string"
}
}
},
"reviewId": {
"type": "string"
},
"reviewReply": {
"type": "object",
"properties": {
"comment": {
"type": "string"
},
"updateTime": {
"type": "string"
}
}
},
"starRating": {
"type": "string"
},
"updateTime": {
"type": "string"
}
},
"version": 3
}
@@ -0,0 +1 @@
<svg height="2185" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0.43 1064 928.69"><linearGradient id="a" x1="0%" x2="99.999%" y1="49.999%" y2="49.999%"><stop offset=".03" stop-color="#4079d8"/><stop offset="1" stop-color="#4989f5"/></linearGradient><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><rect fill="#4989f5" height="696.14" rx="36.88" width="931" x="53.45" y="232.98"/><path d="M936.81 227.75H100.06c-25.92 0-46.09 200.6-46.09 226.52L512.2 929.12h424.61c26-.071 47.059-21.13 47.13-47.13V274.87c-.077-25.996-21.134-47.049-47.13-47.12z" fill="url(#a)"/><path d="M266.03 349.56h266V.44H305.86z" fill="#3c4ba6"/><path d="M798.03 349.56h-266V.44H758.2zM984.45 66.62l.33 1.19c-.08-.42-.24-.81-.33-1.19z" fill="#7babf7"/><path d="M984.78 67.8l-.33-1.19C976.017 27.993 941.837.455 902.31.43H758.2L798 349.56h266z" fill="#3f51b5"/><path d="M79.61 66.62l-.33 1.19c.08-.42.24-.81.33-1.19z" fill="#7babf7"/><path d="M79.27 67.8l.33-1.19C88.033 27.993 122.213.455 161.74.43h144.12L266 349.56H0z" fill="#7babf7"/></g><path d="M266.48 349.47c0 73.412-59.513 132.925-132.925 132.925S.63 422.882.63 349.47z" fill="#709be0"/><path d="M532.33 349.47c0 73.412-59.513 132.925-132.925 132.925S266.48 422.882 266.48 349.47z" fill="#3c4ba6"/><path d="M798.18 349.47c0 73.412-59.513 132.925-132.925 132.925S532.33 422.882 532.33 349.47z" fill="#709be0"/><path d="M1064 349.47c0 73.412-59.513 132.925-132.925 132.925S798.15 422.882 798.15 349.47z" fill="#3c4ba6"/><path d="M931.08 709.6c-.47-6.33-1.25-12.11-2.36-19.49h-145c0 20.28 0 42.41-.08 62.7h84a73.05 73.05 0 0 1-30.75 46.89s0-.35-.06-.36a88 88 0 0 1-34 13.27 99.85 99.85 0 0 1-36.79-.16 91.9 91.9 0 0 1-34.31-14.87 95.72 95.72 0 0 1-33.73-43.1c-.52-1.35-1-2.71-1.49-4.09v-.15l.13-.1a93 93 0 0 1-.05-59.84A96.27 96.27 0 0 1 718.9 654c23.587-24.399 58.829-33.576 91.32-23.78a83 83 0 0 1 33.23 19.56l28.34-28.34c5-5.05 10.19-9.94 15-15.16a149.78 149.78 0 0 0-49.64-30.74 156.08 156.08 0 0 0-103.83-.91c-1.173.4-2.34.817-3.5 1.25A155.18 155.18 0 0 0 646 651a152.61 152.61 0 0 0-13.42 38.78c-16.052 79.772 32.623 158.294 111.21 179.4 25.69 6.88 53 6.71 78.89.83a139.88 139.88 0 0 0 63.14-32.81c18.64-17.15 32-40 39-64.27a179 179 0 0 0 6.26-63.33z" fill="#fff" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,84 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { addUpdateMaskPresend } from '../GenericFunctions';
describe('GenericFunctions - addUpdateMask', () => {
const mockGetNodeParameter = jest.fn();
const mockContext = {
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecuteSingleFunctions;
beforeEach(() => {
mockGetNodeParameter.mockClear();
});
it('should add updateMask with mapped properties to the query string', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
url: 'https://example.com',
startDateTime: '2023-09-15T10:00:00.000Z',
couponCode: 'DISCOUNT123',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
updateMask:
'topicType,callToAction.url,event.schedule.startDate,event.schedule.startTime,offer.couponCode',
});
});
it('should handle empty additionalOptions and not add updateMask', async () => {
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({});
});
it('should include unmapped properties in the updateMask', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
unmappedProperty: 'someValue',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
updateMask: 'topicType,unmappedProperty',
});
});
it('should merge updateMask with existing query string', async () => {
mockGetNodeParameter.mockReturnValue({
postType: 'postTypeValue',
redeemOnlineUrl: 'https://google.example.com',
});
const opts: Partial<IHttpRequestOptions> = {
qs: {
existingQuery: 'existingValue',
},
};
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.qs).toEqual({
existingQuery: 'existingValue',
updateMask: 'topicType,offer.redeemOnlineUrl',
});
});
});
@@ -0,0 +1,84 @@
import { NodeApiError, type ILoadOptionsFunctions, type IPollFunctions } from 'n8n-workflow';
import { googleApiRequest } from '../GenericFunctions';
describe('googleApiRequest', () => {
const mockHttpRequestWithAuthentication = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
},
getNode: jest.fn(),
} as unknown as ILoadOptionsFunctions | IPollFunctions;
beforeEach(() => {
jest.clearAllMocks();
});
it('should make a GET request and return data', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleBusinessProfileOAuth2Api',
expect.objectContaining({
method: 'GET',
url: 'https://mybusiness.googleapis.com/v4/test-resource',
qs: {},
json: true,
}),
);
expect(result).toEqual(mockResponse);
});
it('should make a POST request with body and return data', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const requestBody = { key: 'value' };
const result = await googleApiRequest.call(mockContext, 'POST', '/test-resource', requestBody);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleBusinessProfileOAuth2Api',
expect.objectContaining({
method: 'POST',
body: requestBody,
url: 'https://mybusiness.googleapis.com/v4/test-resource',
qs: {},
json: true,
}),
);
expect(result).toEqual(mockResponse);
});
it('should remove the body for GET requests', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource', {});
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'googleBusinessProfileOAuth2Api',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.not.objectContaining({ body: expect.anything() }),
);
expect(result).toEqual(mockResponse);
});
it('should throw NodeApiError on API failure', async () => {
const mockError = new Error('API request failed');
mockHttpRequestWithAuthentication.mockRejectedValue(mockError);
await expect(googleApiRequest.call(mockContext, 'GET', '/test-resource')).rejects.toThrow(
NodeApiError,
);
expect(mockContext.getNode).toHaveBeenCalled();
expect(mockHttpRequestWithAuthentication).toHaveBeenCalled();
});
});
@@ -0,0 +1,131 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { handleDatesPresend } from '../GenericFunctions';
describe('GenericFunctions - handleDatesPresend', () => {
const mockGetNode = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
getNode: mockGetNode,
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecuteSingleFunctions;
beforeEach(() => {
mockGetNode.mockClear();
mockGetNodeParameter.mockClear();
});
it('should return options unchanged if no date-time parameters are provided', async () => {
mockGetNode.mockReturnValue({
parameters: {},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result).toEqual(opts);
});
it('should merge startDateTime parameter into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDateTime: '2023-09-15T10:00:00.000Z',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
startTime: { hours: 10, minutes: 0, seconds: 0, nanos: 0 },
},
},
});
});
it('should merge startDate and endDateTime parameters into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
endDateTime: '2023-09-16T12:30:00.000Z',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
endDate: { year: 2023, month: 9, day: 16 },
endTime: { hours: 12, minutes: 30, seconds: 0, nanos: 0 },
},
},
});
});
it('should merge additional options into event schedule', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
},
});
mockGetNodeParameter.mockReturnValue({
additionalOption: 'someValue',
});
const opts: Partial<IHttpRequestOptions> = {
body: {},
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
},
},
});
});
it('should modify the body with event schedule containing only date', async () => {
mockGetNode.mockReturnValue({
parameters: {
startDate: '2023-09-15',
},
});
mockGetNodeParameter.mockReturnValue({});
const opts: Partial<IHttpRequestOptions> = {
body: { event: {} },
};
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
expect(result.body).toEqual({
event: {
schedule: {
startDate: { year: 2023, month: 9, day: 15 },
},
},
});
});
});
@@ -0,0 +1,123 @@
import type { DeclarativeRestApiSettings, IExecutePaginationFunctions } from 'n8n-workflow';
import { handlePagination } from '../GenericFunctions';
describe('GenericFunctions - handlePagination', () => {
const mockMakeRoutingRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
makeRoutingRequest: mockMakeRoutingRequest,
getNodeParameter: mockGetNodeParameter,
} as unknown as IExecutePaginationFunctions;
beforeEach(() => {
mockMakeRoutingRequest.mockClear();
mockGetNodeParameter.mockClear();
});
it('should stop fetching when the limit is reached and returnAll is false', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 1 }, { id: 2 }],
nextPageToken: 'nextToken1',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 3 }, { id: 4 }],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(false);
mockGetNodeParameter.mockReturnValueOnce(3);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]);
});
it('should handle empty results', async () => {
mockMakeRoutingRequest.mockResolvedValueOnce([
{
json: {
localPosts: [],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(false);
mockGetNodeParameter.mockReturnValueOnce(5);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1);
expect(result).toEqual([]);
});
it('should fetch all items when returnAll is true', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 1 }, { id: 2 }],
nextPageToken: 'nextToken1',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 3 }, { id: 4 }],
nextPageToken: 'nextToken2',
},
},
])
.mockResolvedValueOnce([
{
json: {
localPosts: [{ id: 5 }],
},
},
]);
mockGetNodeParameter.mockReturnValueOnce(true);
const requestOptions = {
options: {
qs: {},
},
} as unknown as DeclarativeRestApiSettings.ResultOptions;
const result = await handlePagination.call(mockContext, requestOptions);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(3);
expect(result).toEqual([
{ json: { id: 1 } },
{ json: { id: 2 } },
{ json: { id: 3 } },
{ json: { id: 4 } },
{ json: { id: 5 } },
]);
});
});
@@ -0,0 +1,65 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchAccounts } from '../GenericFunctions';
describe('GenericFunctions - searchAccounts', () => {
const mockGoogleApiRequest = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
});
it('should return accounts with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
accounts: [
{ name: 'accounts/123', accountName: 'Test Account 1' },
{ name: 'accounts/234', accountName: 'Test Account 2' },
],
});
const filter = '123';
const result = await searchAccounts.call(mockContext, filter);
expect(result).toEqual({
results: [{ name: 'Test Account 1', value: 'accounts/123' }],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ accounts: [] });
const result = await searchAccounts.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/123', accountName: 'Test Account 1' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/234', accountName: 'Test Account 2' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
accounts: [{ name: 'accounts/345', accountName: 'Test Account 3' }],
});
const result = await searchAccounts.call(mockContext);
// The request would only return the last result
// N8N handles the pagination and adds the previous results to the results array
expect(result).toEqual({
results: [{ name: 'Test Account 3', value: 'accounts/345' }],
paginationToken: undefined,
});
});
});
@@ -0,0 +1,68 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchLocations } from '../GenericFunctions';
describe('GenericFunctions - searchLocations', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return locations with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/123' }, { name: 'locations/234' }],
});
const filter = '123';
const result = await searchLocations.call(mockContext, filter);
expect(result).toEqual({
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
results: [{ name: 'locations/123', value: 'locations/123' }],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ locations: [] });
const result = await searchLocations.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/123' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/234' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
locations: [{ name: 'locations/345' }],
});
const result = await searchLocations.call(mockContext);
// The request would only return the last result
// N8N handles the pagination and adds the previous results to the results array
expect(result).toEqual({
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
results: [{ name: 'locations/345', value: 'locations/345' }],
paginationToken: undefined,
});
});
});
@@ -0,0 +1,72 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchPosts } from '../GenericFunctions';
describe('GenericFunctions - searchPosts', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return posts with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
localPosts: [
{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' },
{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' },
],
});
const filter = 'First';
const result = await searchPosts.call(mockContext, filter);
expect(result).toEqual({
results: [
{
name: 'First Post',
value: 'accounts/123/locations/123/localPosts/123',
},
],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ localPosts: [] });
const result = await searchPosts.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
localPosts: [{ name: 'accounts/123/locations/123/localPosts/345', summary: 'Third Post' }],
});
const result = await searchPosts.call(mockContext);
expect(result).toEqual({
results: [{ name: 'Third Post', value: 'accounts/123/locations/123/localPosts/345' }],
paginationToken: undefined,
});
});
});
@@ -0,0 +1,73 @@
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchReviews } from '../GenericFunctions';
describe('GenericFunctions - searchReviews', () => {
const mockGoogleApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockContext = {
helpers: {
httpRequestWithAuthentication: mockGoogleApiRequest,
},
getNodeParameter: mockGetNodeParameter,
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockGoogleApiRequest.mockClear();
mockGetNodeParameter.mockClear();
mockGetNodeParameter.mockReturnValue('parameterValue');
});
it('should return reviews with filtering', async () => {
mockGoogleApiRequest.mockResolvedValue({
reviews: [
{ name: 'accounts/123/locations/123/reviews/123', comment: 'Great service!' },
{ name: 'accounts/123/locations/123/reviews/234', comment: 'Good experience.' },
],
});
const filter = 'Great';
const result = await searchReviews.call(mockContext, filter);
expect(result).toEqual({
results: [
{
name: 'Great service!',
value: 'accounts/123/locations/123/reviews/123',
},
],
paginationToken: undefined,
});
});
it('should handle empty results', async () => {
mockGoogleApiRequest.mockResolvedValue({ reviews: [] });
const result = await searchReviews.call(mockContext);
expect(result).toEqual({ results: [], paginationToken: undefined });
});
it('should handle pagination', async () => {
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/123', comment: 'First Review' }],
nextPageToken: 'nextToken1',
});
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/234', comment: 'Second Review' }],
nextPageToken: 'nextToken2',
});
mockGoogleApiRequest.mockResolvedValue({
reviews: [{ name: 'accounts/123/locations/123/reviews/345', comment: 'Third Review' }],
});
const result = await searchReviews.call(mockContext);
expect(result).toEqual({
results: [{ name: 'Third Review', value: 'accounts/123/locations/123/reviews/345' }],
paginationToken: undefined,
});
});
});
@@ -0,0 +1,217 @@
import type { INodeProperties } from 'n8n-workflow';
import { TIMEZONE_VALIDATION_REGEX } from './GenericFunctions';
export const calendarOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['calendar'],
},
},
options: [
{
name: 'Availability',
value: 'availability',
description: 'If a time-slot is available in a calendar',
action: 'Get availability in a calendar',
},
],
default: 'availability',
},
];
export const calendarFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* calendar:availability */
/* -------------------------------------------------------------------------- */
{
displayName: 'Calendar',
name: 'calendar',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'Google Calendar to operate on',
modes: [
{
displayName: 'Calendar',
name: 'list',
type: 'list',
placeholder: 'Select a Calendar...',
typeOptions: {
searchListMethod: 'getCalendars',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
// calendar ids are emails. W3C email regex with optional trailing whitespace.
regex:
'(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:[ \t]+)*$)',
errorMessage: 'Not a valid Google Calendar ID',
},
},
],
extractValue: {
type: 'regex',
regex: '(^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)',
},
placeholder: 'name@google.com',
},
],
displayOptions: {
show: {
resource: ['calendar'],
},
},
},
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'Start of the interval',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End of the interval',
},
{
displayName: 'Start Time',
name: 'timeMin',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: '={{ $now }}',
description:
'Start of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'End Time',
name: 'timeMax',
type: 'dateTime',
required: true,
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
default: "={{ $now.plus(1, 'hour') }}",
description:
'End of the interval, use <a href="https://docs.n8n.io/code/cookbook/luxon/" target="_blank">expression</a> to set a date, or switch to fixed mode to choose date from widget',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
displayOptions: {
show: {
operation: ['availability'],
resource: ['calendar'],
},
},
default: {},
options: [
{
displayName: 'Output Format',
name: 'outputFormat',
type: 'options',
options: [
{
name: 'Availability',
value: 'availability',
description: 'Returns if there are any events in the given time or not',
},
{
name: 'Booked Slots',
value: 'bookedSlots',
description: 'Returns the booked slots',
},
{
name: 'RAW',
value: 'raw',
description: 'Returns the RAW data from the API',
},
],
default: 'availability',
description: 'The format to return the data in',
},
{
displayName: 'Timezone',
name: 'timezone',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'Time zone used in the response. By default n8n timezone is used.',
modes: [
{
displayName: 'Timezone',
name: 'list',
type: 'list',
placeholder: 'Select a Timezone...',
typeOptions: {
searchListMethod: 'getTimezones',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
validation: [
{
type: 'regex',
properties: {
regex: TIMEZONE_VALIDATION_REGEX,
errorMessage: 'Not a valid Timezone',
},
},
],
extractValue: {
type: 'regex',
regex: '([-+/_a-zA-Z0-9]*)',
},
placeholder: 'Europe/Berlin',
},
],
},
],
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import type { IDataObject } from 'n8n-workflow';
export interface IReminder {
useDefault?: boolean;
overrides?: IDataObject[];
}
export interface IConferenceData {
createRequest?: {
requestId: string;
conferenceSolution: {
type: string;
};
};
}
export interface IEvent {
attendees?: IDataObject[];
colorId?: string;
description?: string;
end?: IDataObject;
guestsCanInviteOthers?: boolean;
guestsCanModify?: boolean;
guestsCanSeeOtherGuests?: boolean;
id?: string;
location?: string;
maxAttendees?: number;
recurrence?: string[];
reminders?: IReminder;
sendUpdates?: string;
start?: IDataObject;
summary?: string;
transparency?: string;
visibility?: string;
conferenceData?: IConferenceData;
}
export type RecurringEventInstance = {
recurringEventId?: string;
start: { dateTime: string; date: string };
};
@@ -0,0 +1,304 @@
import { DateTime } from 'luxon';
import moment from 'moment-timezone';
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
INode,
INodeListSearchItems,
INodeListSearchResult,
IPollFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule';
import type { RecurringEventInstance } from './EventInterface';
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
headers: IDataObject = {},
): Promise<any> {
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs,
uri: uri || `https://www.googleapis.com${resource}`,
json: true,
};
try {
if (Object.keys(headers).length !== 0) {
options.headers = Object.assign({}, options.headers, headers);
}
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
return await this.helpers.requestOAuth2.call(this, 'googleCalendarOAuth2Api', options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
export async function googleApiRequestAllItems(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
body: any = {},
query: IDataObject = {},
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.maxResults = 100;
do {
responseData = await googleApiRequest.call(this, method, endpoint, body, query);
query.pageToken = responseData.nextPageToken;
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
return returnData;
}
export function encodeURIComponentOnce(uri: string) {
// load options used to save encoded uri strings
return encodeURIComponent(decodeURIComponent(uri));
}
export async function getCalendars(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const calendars = (await googleApiRequestAllItems.call(
this,
'items',
'GET',
'/calendar/v3/users/me/calendarList',
)) as Array<{ id: string; summary: string }>;
const results: INodeListSearchItems[] = calendars
.map((c) => ({
name: c.summary,
value: c.id,
}))
.filter(
(c) =>
!filter ||
c.name.toLowerCase().includes(filter.toLowerCase()) ||
c.value?.toString() === filter,
)
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results };
}
export const TIMEZONE_VALIDATION_REGEX = `(${moment.tz
.names()
.map((t) => t.replace('+', '\\+'))
.join('|')})[ \t]*`;
export async function getTimezones(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const results: INodeListSearchItems[] = moment.tz
.names()
.map((timezone) => ({
name: timezone,
value: timezone,
}))
.filter(
(c) =>
!filter ||
c.name.toLowerCase().includes(filter.toLowerCase()) ||
c.value?.toString() === filter,
);
return { results };
}
export type RecurrentEvent = {
start: {
date?: string;
dateTime?: string;
timeZone?: string;
};
end: {
date?: string;
dateTime?: string;
timeZone?: string;
};
recurrence: string[];
nextOccurrence?: {
start: {
dateTime: string;
timeZone?: string;
};
end: {
dateTime: string;
timeZone?: string;
};
};
};
export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) {
if (item.recurrence) {
let eventRecurrence;
try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
if (!eventRecurrence) continue;
const start = moment(item.start.dateTime || item.end.date).utc();
const end = moment(item.end.dateTime || item.end.date).utc();
const rruleWithStartDate = `DTSTART:${start.format(
'YYYYMMDDTHHmmss',
)}Z\n${eventRecurrence}`;
const rrule = RRule.fromString(rruleWithStartDate);
const until = rrule.options?.until;
const now = moment().utc();
if (until && moment(until).isBefore(now)) {
continue;
}
const nextDate = rrule.after(now.toDate(), false);
if (nextDate) {
const nextStart = moment(nextDate);
const duration = moment.duration(moment(end).diff(moment(start)));
const nextEnd = moment(nextStart).add(duration);
item.nextOccurrence = {
start: {
dateTime: nextStart.format(),
timeZone: item.start.timeZone,
},
end: {
dateTime: nextEnd.format(),
timeZone: item.end.timeZone,
},
};
}
} catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`);
}
}
}
return items;
}
const hasTimezone = (date: string) => date.endsWith('Z') || /\+\d{2}:\d{2}$/.test(date);
export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format();
}
async function requestWithRetries(
node: INode,
requestFn: () => Promise<any>,
retryCount: number = 0,
maxRetries: number = 10,
itemIndex: number = 0,
): Promise<any> {
try {
return await requestFn();
} catch (error) {
if (!(error instanceof NodeApiError)) {
throw new NodeOperationError(node, error.message, { itemIndex });
}
if (retryCount >= maxRetries) throw error;
if (error.httpCode === '403' || error.httpCode === '429') {
const delay = 1000 * Math.pow(2, retryCount);
console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
await sleep(delay);
return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
}
throw error;
}
}
export async function googleApiRequestWithRetries({
context,
method,
resource,
body = {},
qs = {},
uri,
headers = {},
itemIndex = 0,
}: {
context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
method: IHttpRequestMethods;
resource: string;
body?: any;
qs?: IDataObject;
uri?: string;
headers?: IDataObject;
itemIndex?: number;
}) {
const requestFn = async (): Promise<any> => {
return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
};
const retryCount = 0;
const maxRetries = 10;
return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
}
export const eventExtendYearIntoFuture = (
data: RecurringEventInstance[],
timezone: string,
currentYear?: number, // for testing purposes
) => {
const thisYear = currentYear || moment().tz(timezone).year();
return data.some((event) => {
if (!event.recurringEventId) return false;
const eventStart = event.start.dateTime || event.start.date;
const eventDateTime = moment(eventStart).tz(timezone);
if (!eventDateTime.isValid()) return false;
const targetYear = eventDateTime.year();
if (targetYear - thisYear >= 1) {
return true;
} else {
return false;
}
});
};
export function dateObjectToISO<T>(date: T): string {
if (date instanceof DateTime) return date.toISO();
if (date instanceof Date) return date.toISOString();
return date as string;
}
@@ -0,0 +1,50 @@
{
"node": "n8n-nodes-base.googleCalendar",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlecalendar/"
}
],
"generic": [
{
"label": "How to host virtual coffee breaks with n8n",
"icon": "☕️",
"url": "https://n8n.io/blog/how-to-host-virtual-coffee-breaks-with-n8n/"
},
{
"label": "Supercharging your conference registration process with n8n",
"icon": "🎫",
"url": "https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/"
},
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
},
{
"label": "Hey founders! Your business doesn't need you to operate",
"icon": " 🖥️",
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
},
{
"label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},
{
"label": "Tracking Time Spent in Meetings With Google Calendar, Twilio, and n8n",
"icon": "🗓",
"url": "https://n8n.io/blog/tracking-time-spent-in-meetings-with-google-calendar-twilio-and-n8n/"
}
]
}
}
@@ -0,0 +1,823 @@
import moment from 'moment-timezone';
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
JsonObject,
NodeExecutionHint,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription';
import type { IEvent, RecurringEventInstance } from './EventInterface';
import {
addNextOccurrence,
addTimezoneToDate,
dateObjectToISO,
encodeURIComponentOnce,
eventExtendYearIntoFuture,
getCalendars,
getTimezones,
googleApiRequest,
googleApiRequestAllItems,
googleApiRequestWithRetries,
type RecurrentEvent,
} from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = {
displayName: 'Google Calendar',
name: 'googleCalendar',
icon: 'file:googleCalendar.svg',
group: ['input'],
version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API',
schemaPath: 'Google/Calendar',
defaults: {
name: 'Google Calendar',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
relatedNodes: [
{
nodeType: 'n8n-nodes-base.googleCalendarTool',
relationHint: 'Tool version for AI Agent use',
},
],
},
usableAsTool: true,
credentials: [
{
name: 'googleCalendarOAuth2Api',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Calendar',
value: 'calendar',
},
{
name: 'Event',
value: 'event',
},
],
default: 'event',
},
...calendarOperations,
...calendarFields,
...eventOperations,
...eventFields,
{
displayName:
'This node will use the time zone set in n8ns settings, but you can override this in the workflow settings',
name: 'useN8nTimeZone',
type: 'notice',
default: '',
},
],
};
methods = {
listSearch: {
getCalendars,
getTimezones,
},
loadOptions: {
// Get all the calendars to display them to user so that they can
// select them easily
async getConferenceSolutions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const calendar = this.getCurrentNodeParameter('calendar', { extractValue: true }) as string;
const possibleSolutions: IDataObject = {
eventHangout: 'Google Hangout',
eventNamedHangout: 'Google Hangout Classic',
hangoutsMeet: 'Google Meet',
};
const {
conferenceProperties: { allowedConferenceSolutionTypes },
} = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/users/me/calendarList/${calendar}`,
);
for (const solution of allowedConferenceSolutionTypes) {
returnData.push({
name: possibleSolutions[solution] as string,
value: solution,
});
}
return returnData;
},
// Get all the colors to display them to user so that they can
// select them easily
async getColors(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { event } = await googleApiRequest.call(this, 'GET', '/calendar/v3/colors');
for (const key of Object.keys(event as IDataObject)) {
const colorName = `Background: ${event[key].background} - Foreground: ${event[key].foreground}`;
const colorId = key;
returnData.push({
name: `${colorName}`,
value: colorId,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
const hints: NodeExecutionHint[] = [];
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const timezone = this.getTimezone();
const nodeVersion = this.getNode().typeVersion;
for (let i = 0; i < length; i++) {
try {
if (resource === 'calendar') {
//https://developers.google.com/calendar/v3/reference/freebusy/query
if (operation === 'availability') {
// we need to decode once because calendar used to be saved encoded
const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', {
extractValue: true,
}) as string;
const body: IDataObject = {
timeMin: moment(timeMin).utc().format(),
timeMax: moment(timeMax).utc().format(),
items: [
{
id: calendarId,
},
],
timeZone: tz || timezone,
};
responseData = await googleApiRequest.call(
this,
'POST',
'/calendar/v3/freeBusy',
body,
{},
);
if (responseData.calendars[calendarId].errors) {
throw new NodeApiError(
this.getNode(),
responseData.calendars[calendarId] as JsonObject,
{
itemIndex: i,
},
);
}
if (outputFormat === 'availability') {
responseData = {
available: !responseData.calendars[calendarId].busy.length,
};
} else if (outputFormat === 'bookedSlots') {
responseData = responseData.calendars[calendarId].busy;
}
}
}
if (resource === 'event') {
//https://developers.google.com/calendar/v3/reference/events/insert
if (operation === 'create') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const start = dateObjectToISO(this.getNodeParameter('start', i));
const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i);
if (additionalFields.maxAttendees) {
qs.maxAttendees = additionalFields.maxAttendees as number;
}
if (additionalFields.sendNotifications) {
qs.sendNotifications = additionalFields.sendNotifications as boolean;
}
if (additionalFields.sendUpdates) {
qs.sendUpdates = additionalFields.sendUpdates as string;
}
const body: IEvent = {
start: {
dateTime: moment.tz(start, timezone).utc().format(),
timeZone: timezone,
},
end: {
dateTime: moment.tz(end, timezone).utc().format(),
timeZone: timezone,
},
};
if (additionalFields.attendees) {
body.attendees = [];
(additionalFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
if (additionalFields.color) {
body.colorId = additionalFields.color as string;
}
if (additionalFields.description) {
body.description = additionalFields.description as string;
}
if (additionalFields.guestsCanInviteOthers) {
body.guestsCanInviteOthers = additionalFields.guestsCanInviteOthers as boolean;
}
if (additionalFields.guestsCanModify) {
body.guestsCanModify = additionalFields.guestsCanModify as boolean;
}
if (additionalFields.guestsCanSeeOtherGuests) {
body.guestsCanSeeOtherGuests = additionalFields.guestsCanSeeOtherGuests as boolean;
}
if (additionalFields.id) {
body.id = additionalFields.id as string;
}
if (additionalFields.location) {
body.location = additionalFields.location as string;
}
if (additionalFields.summary) {
body.summary = additionalFields.summary as string;
}
if (additionalFields.showMeAs) {
body.transparency = additionalFields.showMeAs as string;
}
if (additionalFields.visibility) {
body.visibility = additionalFields.visibility as string;
}
if (!useDefaultReminders) {
const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject)
.remindersValues as IDataObject[];
body.reminders = {
useDefault: false,
};
if (reminders) {
body.reminders.overrides = reminders;
}
}
if (additionalFields.allday === 'yes') {
body.start = {
date: timezone
? moment.tz(start, timezone).utc(true).format('YYYY-MM-DD')
: moment.tz(start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
body.end = {
date: timezone
? moment.tz(end, timezone).utc(true).format('YYYY-MM-DD')
: moment.tz(end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
}
//exampel: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z
//https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html
body.recurrence = [];
if (additionalFields.rrule) {
body.recurrence = [`RRULE:${additionalFields.rrule}`];
} else {
if (additionalFields.repeatHowManyTimes && additionalFields.repeatUntil) {
throw new NodeOperationError(
this.getNode(),
"You can set either 'Repeat How Many Times' or 'Repeat Until' but not both",
{ itemIndex: i },
);
}
if (additionalFields.repeatFrecuency) {
body.recurrence?.push(
`FREQ=${(additionalFields.repeatFrecuency as string).toUpperCase()};`,
);
}
if (additionalFields.repeatHowManyTimes) {
body.recurrence?.push(`COUNT=${additionalFields.repeatHowManyTimes};`);
}
if (additionalFields.repeatUntil) {
const repeatUntil = moment(additionalFields.repeatUntil as string)
.utc()
.format('YYYYMMDDTHHmmss');
body.recurrence?.push(`UNTIL=${repeatUntil}Z`);
}
if (body.recurrence.length !== 0) {
body.recurrence = [`RRULE:${body.recurrence.join('')}`];
}
}
if (additionalFields.conferenceDataUi) {
const conferenceData = (additionalFields.conferenceDataUi as IDataObject)
.conferenceDataValues as IDataObject;
if (conferenceData) {
qs.conferenceDataVersion = 1;
body.conferenceData = {
createRequest: {
requestId: uuid(),
conferenceSolution: {
type: conferenceData.conferenceSolution as string,
},
},
};
}
}
responseData = await googleApiRequest.call(
this,
'POST',
`/calendar/v3/calendars/${calendarId}/events`,
body,
qs,
);
}
//https://developers.google.com/calendar/v3/reference/events/delete
if (operation === 'delete') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const eventId = this.getNodeParameter('eventId', i) as string;
const options = this.getNodeParameter('options', i);
if (options.sendUpdates) {
qs.sendUpdates = options.sendUpdates as number;
}
responseData = await googleApiRequest.call(
this,
'DELETE',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
);
responseData = { success: true };
}
//https://developers.google.com/calendar/v3/reference/events/get
if (operation === 'get') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const eventId = this.getNodeParameter('eventId', i) as string;
const options = this.getNodeParameter('options', i);
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
if (options.maxAttendees) {
qs.maxAttendees = options.maxAttendees as number;
}
if (tz) {
qs.timeZone = tz;
}
responseData = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
if (responseData) {
if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
const eventInstances =
((
(await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
{},
{
timeMin: new Date().toISOString(),
maxResults: 1,
},
)) as IDataObject
).items as IDataObject[]) || [];
responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
} else {
responseData = addNextOccurrence([responseData as RecurrentEvent]);
}
}
}
//https://developers.google.com/calendar/v3/reference/events/list
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i);
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
const options = this.getNodeParameter('options', i);
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
if (nodeVersion >= 1.3) {
const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
if (timeMin) {
qs.timeMin = addTimezoneToDate(timeMin, tz || timezone);
}
if (timeMax) {
qs.timeMax = addTimezoneToDate(timeMax, tz || timezone);
}
if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
qs.singleEvents = true;
}
}
if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string;
}
if (options.maxAttendees) {
qs.maxAttendees = options.maxAttendees as number;
}
if (options.orderBy) {
qs.orderBy = options.orderBy as number;
}
if (options.query) {
qs.q = options.query as number;
}
if (options.showDeleted) {
qs.showDeleted = options.showDeleted as boolean;
}
if (options.showHiddenInvitations) {
qs.showHiddenInvitations = options.showHiddenInvitations as boolean;
}
if (options.singleEvents) {
qs.singleEvents = options.singleEvents as boolean;
}
if (options.timeMax) {
qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
}
if (options.timeMin) {
qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
}
if (tz) {
qs.timeZone = tz;
}
if (options.updatedMin) {
qs.updatedMin = addTimezoneToDate(
dateObjectToISO(options.updatedMin),
tz || timezone,
);
}
if (options.fields) {
qs.fields = options.fields as string;
}
if (returnAll) {
responseData = await googleApiRequestAllItems.call(
this,
'items',
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
} else {
qs.maxResults = this.getNodeParameter('limit', i);
responseData = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events`,
{},
qs,
);
responseData = responseData.items;
}
if (responseData) {
if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
const updatedEvents: IDataObject[] = [];
for (const event of responseData) {
if (event.recurrence) {
const eventInstances =
((
(await googleApiRequestWithRetries({
context: this,
method: 'GET',
resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
qs: {
timeMin: new Date().toISOString(),
maxResults: 1,
},
itemIndex: i,
})) as IDataObject
).items as IDataObject[]) || [];
updatedEvents.push(eventInstances[0] || event);
continue;
}
updatedEvents.push(event);
}
responseData = updatedEvents;
} else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
responseData = responseData.filter((event: IDataObject) => {
if (
qs.timeMin &&
event.recurrence &&
event.created &&
event.created < qs.timeMin
) {
return false;
}
if (
qs.timeMax &&
event.recurrence &&
event.created &&
event.created > qs.timeMax
) {
return false;
}
return true;
});
} else if (nodeVersion < 1.3) {
// in node version above or equal to 1.3, this would correspond to the 'expand' option,
// so no need to add the next occurrence as event instances returned by the API
responseData = addNextOccurrence(responseData);
}
if (
!qs.timeMax &&
(!options.recurringEventHandling || options.recurringEventHandling === 'expand')
) {
const suggestTrim = eventExtendYearIntoFuture(
responseData as RecurringEventInstance[],
timezone,
);
if (suggestTrim) {
hints.push({
message:
"Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
location: 'outputPane',
});
}
}
}
}
//https://developers.google.com/calendar/v3/reference/events/patch
if (operation === 'update') {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
let eventId = this.getNodeParameter('eventId', i) as string;
if (nodeVersion >= 1.3) {
const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
if (modifyTarget === 'event') {
const instance = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
)) as IDataObject;
eventId = instance.recurringEventId as string;
}
}
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string;
if (nodeVersion > 1 && updateTimezone === undefined) {
updateTimezone = timezone;
}
if (updateFields.maxAttendees) {
qs.maxAttendees = updateFields.maxAttendees as number;
}
if (updateFields.sendNotifications) {
qs.sendNotifications = updateFields.sendNotifications as boolean;
}
if (updateFields.sendUpdates) {
qs.sendUpdates = updateFields.sendUpdates as string;
}
const body: IEvent = {};
if (updateFields.start) {
body.start = {
dateTime: moment.tz(updateFields.start, updateTimezone).utc().format(),
timeZone: updateTimezone,
};
}
if (updateFields.end) {
body.end = {
dateTime: moment.tz(updateFields.end, updateTimezone).utc().format(),
timeZone: updateTimezone,
};
}
// nodeVersion < 1.2
if (updateFields.attendees) {
body.attendees = [];
(updateFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
// nodeVersion >= 1.2
if (updateFields.attendeesUi) {
const { mode, attendees } = (
updateFields.attendeesUi as {
values: {
mode: string;
attendees: string[];
};
}
).values;
body.attendees = [];
if (mode === 'add') {
const event = await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
);
((event?.attendees as IDataObject[]) || []).forEach((attendee) => {
body.attendees?.push(attendee);
});
}
attendees.forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
}
if (updateFields.color) {
body.colorId = updateFields.color as string;
}
if (updateFields.description) {
body.description = updateFields.description as string;
}
if (updateFields.guestsCanInviteOthers) {
body.guestsCanInviteOthers = updateFields.guestsCanInviteOthers as boolean;
}
if (updateFields.guestsCanModify) {
body.guestsCanModify = updateFields.guestsCanModify as boolean;
}
if (updateFields.guestsCanSeeOtherGuests) {
body.guestsCanSeeOtherGuests = updateFields.guestsCanSeeOtherGuests as boolean;
}
if (updateFields.id) {
body.id = updateFields.id as string;
}
if (updateFields.location) {
body.location = updateFields.location as string;
}
if (updateFields.summary) {
body.summary = updateFields.summary as string;
}
if (updateFields.showMeAs) {
body.transparency = updateFields.showMeAs as string;
}
if (updateFields.visibility) {
body.visibility = updateFields.visibility as string;
}
if (!useDefaultReminders) {
const reminders = (this.getNodeParameter('remindersUi', i) as IDataObject)
.remindersValues as IDataObject[];
body.reminders = {
useDefault: false,
};
if (reminders) {
body.reminders.overrides = reminders;
}
}
if (updateFields.allday === 'yes' && updateFields.start && updateFields.end) {
body.start = {
date: updateTimezone
? moment.tz(updateFields.start, updateTimezone).utc(true).format('YYYY-MM-DD')
: moment.tz(updateFields.start, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
body.end = {
date: updateTimezone
? moment.tz(updateFields.end, updateTimezone).utc(true).format('YYYY-MM-DD')
: moment.tz(updateFields.end, moment.tz.guess()).utc(true).format('YYYY-MM-DD'),
};
}
//example: RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=10;UNTIL=20110701T170000Z
//https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html
body.recurrence = [];
if (updateFields.rrule) {
body.recurrence = [`RRULE:${updateFields.rrule}`];
} else {
if (updateFields.repeatHowManyTimes && updateFields.repeatUntil) {
throw new NodeOperationError(
this.getNode(),
"You can set either 'Repeat How Many Times' or 'Repeat Until' but not both",
{ itemIndex: i },
);
}
if (updateFields.repeatFrecuency) {
body.recurrence?.push(
`FREQ=${(updateFields.repeatFrecuency as string).toUpperCase()};`,
);
}
if (updateFields.repeatHowManyTimes) {
body.recurrence?.push(`COUNT=${updateFields.repeatHowManyTimes};`);
}
if (updateFields.repeatUntil) {
const repeatUntil = moment(updateFields.repeatUntil as string)
.utc()
.format('YYYYMMDDTHHmmss');
body.recurrence?.push(`UNTIL=${repeatUntil}Z`);
}
if (body.recurrence.length !== 0) {
body.recurrence = [`RRULE:${body.recurrence.join('')}`];
} else {
delete body.recurrence;
}
}
responseData = await googleApiRequest.call(
this,
'PATCH',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
body,
qs,
);
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (!this.continueOnFail()) {
throw error;
} else {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
}
}
const keysPriorityList = [
'id',
'summary',
'start',
'end',
'attendees',
'creator',
'organizer',
'description',
'location',
'created',
'updated',
];
let nodeExecutionData = returnData;
if (nodeVersion >= 1.3) {
nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
}
if (hints.length) {
this.addExecutionHints(...hints);
}
return [nodeExecutionData];
}
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.googleCalendarTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlecalendartrigger/"
}
]
}
}

Some files were not shown because too many files have changed in this diff Show More