feat(Google Ads Node): Add new node (#3526)

* Add basic layout with icon for Google Ads

* Add node versioning(V1)

* Add node and credential to package

* Add basic layout with icon for Google Ads

* Add node versioning(V1)

* Add node and credential to package

* Add api call to getall

* Fix formdata in the body for the request

* N8N-2928 Added custom queries to campaign

* Fix header bug and add developer-token field

* Add operation and fields to campaign new format

* Add more configurations and queries

* Add Invoice ressources and operations

* Remov old version from the node

* Fixed bud with typo

* Correctly prepends the baseURL

* add query to invocie request

* Fixes header not parsing the expression

* Invoice param changes

* Fixes bug related to headers not being parsed, and bug with auth

* Remove useless imports

* Added analytics to google ad node and removed useless header

* Removed url for testing

* Fixed inconsistent behaviour with the access token not being refreshed

* Added placeholders to help user

* Removed useless comments

* Resolved name confusion

* Added support for body in a GET method

* Removed hyphens, parse body's expression

* Renamed operation for clarity

* Remove unused code

* Removed invoice resource and fixed bug with body and headers

The invoice operation was removed since it does not reflect
what a user would expect from it. Google ADS invoices are
only used for big advertisers where invoicing is performed
after the end of the month and for big sums. This would
be misleading for the majority of the users expecting
an expenses report.

Also fixed a bug with header and body being sent since it
was broken for multiple input rows. The first execution
would override all others.

Lastly, made some improvements to the node itself by
transforming data, adding filters and operations.

* Improve campagin operation and remove analytics; fix tests

* Improve tooltips and descriptions

* Fix lint issues

* Improve tooltip to explain amounts in micros

* Change wording for micros

* Change the fix to a more elegant solution

Co-authored-by: Cyril Gobrecht <cyril.gobrecht@gmail.com>
Co-authored-by: Aël Gobrecht <ael.gobrecht@gmail.com>
This commit is contained in:
Omar Ajoue 2022-07-04 22:47:50 +02:00 committed by GitHub
parent 637e81552f
commit 088daf952e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 511 additions and 18 deletions

View file

@ -58,7 +58,6 @@ import {
LoggerProxy as Logger,
IExecuteData,
OAuth2GrantType,
IOAuth2Credentials,
} from 'n8n-workflow';
import { Agent } from 'https';
@ -78,6 +77,7 @@ import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import axios, {
AxiosError,
AxiosPromise,
AxiosProxyConfig,
AxiosRequestConfig,
@ -731,6 +731,10 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest
axiosRequest.headers = axiosRequest.headers || {};
axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
} else if (
axiosRequest.headers[existingContentTypeHeaderKey] === 'application/x-www-form-urlencoded'
) {
axiosRequest.data = new URLSearchParams(n8nRequest.body as Record<string, string>);
}
}
@ -761,6 +765,12 @@ async function httpRequest(
requestOptions: IHttpRequestOptions,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
const axiosRequest = convertN8nRequestToAxios(requestOptions);
if (
axiosRequest.data === undefined ||
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
) {
delete axiosRequest.data;
}
const result = await axios(axiosRequest);
if (requestOptions.returnFullResponse) {
return {
@ -886,7 +896,7 @@ export async function requestOAuth2(
oAuth2Options?: IOAuth2Options,
isN8nRequest = false,
) {
const credentials = (await this.getCredentials(credentialsType)) as unknown as IOAuth2Credentials;
const credentials = await this.getCredentials(credentialsType);
// Only the OAuth2 with authorization code grant needs connection
if (
@ -897,10 +907,10 @@ export async function requestOAuth2(
}
const oAuthClient = new clientOAuth2({
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
accessTokenUri: credentials.accessTokenUrl,
scopes: credentials.scope.split(' '),
clientId: credentials.clientId as string,
clientSecret: credentials.clientSecret as string,
accessTokenUri: credentials.accessTokenUrl as string,
scopes: (credentials.scope as string).split(' '),
});
let oauthTokenData = credentials.oauthTokenData as clientOAuth2.Data;
@ -936,7 +946,6 @@ export async function requestOAuth2(
// Signs the request by adding authorization headers or query parameters depending
// on the token-type used.
const newRequestOptions = token.sign(requestOptions as clientOAuth2.RequestObject);
// If keep bearer is false remove the it from the authorization header
if (oAuth2Options?.keepBearer === false) {
// @ts-ignore
@ -944,7 +953,46 @@ export async function requestOAuth2(
// @ts-ignore
newRequestOptions?.headers?.Authorization.split(' ')[1];
}
if (isN8nRequest) {
return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => {
if (error.response?.status === 401) {
Logger.debug(
`OAuth2 token for "${credentialsType}" used by node "${node.name}" expired. Should revalidate.`,
);
const tokenRefreshOptions: IDataObject = {};
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
const body: IDataObject = {
client_id: credentials.clientId as string,
client_secret: credentials.clientSecret as string,
};
tokenRefreshOptions.body = body;
tokenRefreshOptions.headers = {
Authorization: '',
};
}
const newToken = await token.refresh(tokenRefreshOptions);
Logger.debug(
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
);
credentials.oauthTokenData = newToken.data;
// Find the credentials
if (!node.credentials || !node.credentials[credentialsType]) {
throw new Error(
`The node "${node.name}" does not have credentials of type "${credentialsType}"!`,
);
}
const nodeCredentials = node.credentials[credentialsType];
await additionalData.credentialsHelper.updateCredentials(
nodeCredentials,
credentialsType,
credentials,
);
const refreshedRequestOption = newToken.sign(requestOptions as clientOAuth2.RequestObject);
return this.helpers.httpRequest(refreshedRequestOption);
}
throw error;
});
}
return this.helpers.request!(newRequestOptions).catch(async (error: IResponseError) => {
const statusCodeReturned =
oAuth2Options?.tokenExpiredStatusCode === undefined
@ -1081,7 +1129,6 @@ export async function requestOAuth1(
// @ts-ignore
requestOptions.headers = oauth.toHeader(oauth.authorize(requestOptions, token));
if (isN8nRequest) {
return this.helpers.httpRequest(requestOptions as IHttpRequestOptions);
}
@ -1103,7 +1150,6 @@ export async function httpRequestWithAuthentication(
) {
try {
const parentTypes = additionalData.credentialsHelper.getParentTypes(credentialsType);
if (parentTypes.includes('oAuth1Api')) {
return await requestOAuth1.call(this, credentialsType, requestOptions, true);
}
@ -1141,7 +1187,6 @@ export async function httpRequestWithAuthentication(
node,
additionalData.timezone,
);
return await httpRequest(requestOptions);
} catch (error) {
throw new NodeApiError(this.getNode(), error);

View file

@ -0,0 +1,32 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
const scopes = [
'https://www.googleapis.com/auth/adwords',
];
export class GoogleAdsOAuth2Api implements ICredentialType {
name = 'googleAdsOAuth2Api';
extends = [
'googleOAuth2Api',
];
displayName = 'Google Ads OAuth2 API';
documentationUrl = 'google';
properties: INodeProperties[] = [
{
displayName: 'Developer Token',
name: 'developerToken',
type: 'string',
default: '',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: scopes.join(' '),
},
];
}

View file

@ -0,0 +1,298 @@
import { IDataObject } from 'n8n-workflow';
import {
IExecuteSingleFunctions,
IN8nHttpFullResponse,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
export const campaignOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: [
'campaign',
],
},
},
options: [
{
name: 'Get All',
value: 'getAll',
description: 'Get all the campaigns linked to the specified account',
routing: {
request: {
method: 'POST',
url: '={{"/v9/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,
],
},
},
},
{
name: 'Get',
value: 'get',
description: 'Get a specific campaign',
routing: {
request: {
method: 'POST',
url: '={{"/v9/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, "")}}',
'content-type': 'application/x-www-form-urlencoded',
},
},
output: {
postReceive: [
processCampaignSearchResponse,
],
},
},
},
],
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',
},
],
},
];
function processCampaignSearchResponse(this: IExecuteSingleFunctions, _inputData: INodeExecutionData[], responseData: IN8nHttpFullResponse): Promise<INodeExecutionData[]> {
const results = (responseData.body as IDataObject).results as GoogleAdsCampaignElement;
return Promise.resolve(results.map((result) => {
return {
json: {
...result.campaign,
...result.metrics,
...result.campaignBudget,
},
};
}));
}
type GoogleAdsCampaignElement = [
{
campaign: object,
metrics: object,
campaignBudget: object,
}
];

View file

@ -0,0 +1,22 @@
{
"node": "n8n-nodes-base.googleAds",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Analytics"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/google"
}
],
"generic": [
{
"label": "15 Google apps you can combine and automate to increase productivity",
"icon": "💡",
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
}
]
}
}

View file

@ -0,0 +1,79 @@
import {
INodeType,
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',
defaults: {
name: 'Google Ads',
color: '#ff0000',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'googleAdsOAuth2Api',
required: true,
testedBy: {
request: {
method: 'GET',
url: '/v9/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,
],
};
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="230px" viewBox="0 0 256 230" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M5.888,166.405103 L90.88,20.9 C101.676138,27.2558621 156.115862,57.3844138 164.908138,63.1135172 L79.9161379,208.627448 C70.6206897,220.906621 -5.888,185.040138 5.888,166.396276 L5.888,166.405103 Z" fill="#FBBC04"></path>
<path d="M250.084224,166.401789 L165.092224,20.9055131 C153.210293,1.13172 127.619121,-6.05393517 106.600638,5.62496138 C85.582155,17.3038579 79.182155,42.4624786 91.0640861,63.1190303 L176.056086,208.632961 C187.938017,228.397927 213.52919,235.583582 234.547672,223.904686 C254.648086,212.225789 261.966155,186.175582 250.084224,166.419444 L250.084224,166.401789 Z" fill="#4285F4"></path>
<ellipse fill="#34A853" cx="42.6637241" cy="187.924414" rx="42.6637241" ry="41.6044138"></ellipse>
</g>
</svg>

After

Width:  |  Height:  |  Size: 975 B

View file

@ -110,6 +110,7 @@
"dist/credentials/GitlabOAuth2Api.credentials.js",
"dist/credentials/GitPassword.credentials.js",
"dist/credentials/GmailOAuth2Api.credentials.js",
"dist/credentials/GoogleAdsOAuth2Api.credentials.js",
"dist/credentials/GoogleAnalyticsOAuth2Api.credentials.js",
"dist/credentials/GoogleApi.credentials.js",
"dist/credentials/GoogleBigQueryOAuth2Api.credentials.js",
@ -436,6 +437,7 @@
"dist/nodes/Github/GithubTrigger.node.js",
"dist/nodes/Gitlab/Gitlab.node.js",
"dist/nodes/Gitlab/GitlabTrigger.node.js",
"dist/nodes/Google/Ads/GoogleAds.node.js",
"dist/nodes/Google/Analytics/GoogleAnalytics.node.js",
"dist/nodes/Google/BigQuery/GoogleBigQuery.node.js",
"dist/nodes/Google/Books/GoogleBooks.node.js",

View file

@ -127,11 +127,11 @@ export class RoutingNode {
executeData,
this.mode,
);
const requestData: IRequestOptionsFromParameters = {
options: {
qs: {},
body: {},
headers: {},
},
preSend: [],
postReceive: [],
@ -153,7 +153,7 @@ export class RoutingNode {
runIndex,
executeData,
{ $credentials: credentials },
true,
false,
) as string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(requestData.options as Record<string, any>)[key] = value;
@ -383,7 +383,6 @@ export class RoutingNode {
): Promise<INodeExecutionData[]> {
let responseData: IN8nHttpFullResponse;
requestData.options.returnFullResponse = true;
if (credentialType) {
responseData = (await executeSingleFunctions.helpers.httpRequestWithAuthentication.call(
executeSingleFunctions,
@ -396,7 +395,6 @@ export class RoutingNode {
requestData.options as IHttpRequestOptions,
)) as IN8nHttpFullResponse;
}
let returnData: INodeExecutionData[] = [
{
json: responseData.body as IDataObject,
@ -598,6 +596,7 @@ export class RoutingNode {
options: {
qs: {},
body: {},
headers: {},
},
preSend: [],
postReceive: [],
@ -626,7 +625,6 @@ export class RoutingNode {
if (nodeProperties.routing.operations) {
returnData.requestOperations = { ...nodeProperties.routing.operations };
}
if (nodeProperties.routing.request) {
for (const key of Object.keys(nodeProperties.routing.request)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -638,7 +636,7 @@ export class RoutingNode {
runIndex,
executeSingleFunctions.getExecuteData(),
{ ...additionalKeys, $value: parameterValue },
true,
false,
) as string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(returnData.options as Record<string, any>)[key] = propertyValue;
@ -850,7 +848,6 @@ export class RoutingNode {
}
}
}
return returnData;
}
}

View file

@ -72,6 +72,7 @@ describe('RoutingNode', () => {
body: {
toEmail: 'fixedValue',
},
headers: {},
},
preSend: [],
postReceive: [],
@ -104,6 +105,7 @@ describe('RoutingNode', () => {
body: {
toEmail: 'TEST@TEST.COM',
},
headers: {},
},
preSend: [],
postReceive: [],
@ -146,6 +148,7 @@ describe('RoutingNode', () => {
body: {
toEmail: 'fixedValue',
},
headers: {},
},
preSend: [],
postReceive: [],
@ -527,6 +530,7 @@ describe('RoutingNode', () => {
},
],
},
headers: {},
},
preSend: [preSendFunction1, preSendFunction1],
postReceive: [
@ -732,6 +736,7 @@ describe('RoutingNode', () => {
statusCode: 200,
requestOptions: {
url: '/test-url',
headers: {},
qs: {},
body: {
toEmail: 'fixedValue',
@ -780,6 +785,7 @@ describe('RoutingNode', () => {
statusCode: 200,
requestOptions: {
url: '/test-url',
headers: {},
qs: {},
body: {
toEmail: 'fixedValue',
@ -834,6 +840,7 @@ describe('RoutingNode', () => {
statusCode: 200,
requestOptions: {
url: '/overwritten',
headers: {},
qs: {},
body: {
toEmail: 'TEST@TEST.COM',
@ -888,6 +895,7 @@ describe('RoutingNode', () => {
statusCode: 200,
requestOptions: {
url: '/custom-overwritten',
headers: {},
qs: {},
body: {
theProperty: 'custom-overwritten',
@ -944,6 +952,7 @@ describe('RoutingNode', () => {
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
body: {
toEmail: 'fixedValue',
limit: 10,
@ -1462,6 +1471,7 @@ describe('RoutingNode', () => {
headers: {},
statusCode: 200,
requestOptions: {
headers: {},
qs: {},
body: {
jsonData: {