diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b5aeeb75c1..8bd837b6df 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -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); } } @@ -761,6 +765,12 @@ async function httpRequest( requestOptions: IHttpRequestOptions, ): Promise { 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); diff --git a/packages/nodes-base/credentials/GoogleAdsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleAdsOAuth2Api.credentials.ts new file mode 100644 index 0000000000..377f31d2bb --- /dev/null +++ b/packages/nodes-base/credentials/GoogleAdsOAuth2Api.credentials.ts @@ -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(' '), + }, + ]; + +} diff --git a/packages/nodes-base/nodes/Google/Ads/CampaignDescription.ts b/packages/nodes-base/nodes/Google/Ads/CampaignDescription.ts new file mode 100644 index 0000000000..93865aa842 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Ads/CampaignDescription.ts @@ -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 { + 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, + } +]; diff --git a/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.json b/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.json new file mode 100644 index 0000000000..7775bb04da --- /dev/null +++ b/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.json @@ -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/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.ts b/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.ts new file mode 100644 index 0000000000..dab1d6591b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Ads/GoogleAds.node.ts @@ -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 micros by 1,000,000 to get the actual value', + name: 'campaigsNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + }, + }, + }, + ...campaignFields, + ], + }; +} diff --git a/packages/nodes-base/nodes/Google/Ads/googleAds.svg b/packages/nodes-base/nodes/Google/Ads/googleAds.svg new file mode 100644 index 0000000000..a295244f04 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Ads/googleAds.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 867c1e080c..fcd0cb2607 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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", diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index 525ee062a8..a103dcabc3 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -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)[key] = value; @@ -383,7 +383,6 @@ export class RoutingNode { ): Promise { 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)[key] = propertyValue; @@ -850,7 +848,6 @@ export class RoutingNode { } } } - return returnData; } } diff --git a/packages/workflow/test/RoutingNode.test.ts b/packages/workflow/test/RoutingNode.test.ts index 396411f88d..8da36731a4 100644 --- a/packages/workflow/test/RoutingNode.test.ts +++ b/packages/workflow/test/RoutingNode.test.ts @@ -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: {