@google-cloud/nodejs-common 2.0.16-alpha → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,10 +18,11 @@ and [Data Tasks Coordinator]. This library includes:
18
18
  - Google Ads click conversions upload
19
19
  - Google Ads customer match upload
20
20
  - Google Ads enhanced conversions upload
21
+ - Google Ads offline userdata job data upload
21
22
  - Google Ads conversions scheduled uploads based on Google Sheets
22
23
  - Measurement Protocol Google Analytics 4
23
24
 
24
- 1. Wrapper for some Google APIs for reporting, mainly
25
+ 2. Wrapper for some Google APIs for reporting, mainly
25
26
  for [Data Tasks Coordinator]:
26
27
 
27
28
  - Google Ads reporting
@@ -31,7 +32,7 @@ and [Data Tasks Coordinator]. This library includes:
31
32
  - YouTube Data API
32
33
  - Ads Data Hub querying
33
34
 
34
- 1. Utilities wrapper class for Google Cloud Products:
35
+ 3. Utilities wrapper class for Google Cloud Products:
35
36
 
36
37
  - **Firestore Access Object**: Firestore has two modes[[comparison]] which
37
38
  have different API. This class, with its two successors, offer a unified
@@ -66,7 +67,7 @@ and [Data Tasks Coordinator]. This library includes:
66
67
  an adapter to wrap a Node8 Cloud Functions into Node6 and Node8 compatible
67
68
  functions.~~ (This has been removed since v1.9.0)
68
69
 
69
- 1. A share library for [Bash] to facilitate installation tasks.
70
+ 4. A share library for [Bash] to facilitate installation tasks.
70
71
 
71
72
  [gmp and google ads connector]: https://github.com/GoogleCloudPlatform/cloud-for-marketing/tree/master/marketing-analytics/activation/gmp-googleads-connector
72
73
  [data tasks coordinator]: https://github.com/GoogleCloudPlatform/cloud-for-marketing/tree/master/marketing-analytics/activation/data-tasks-coordinator
package/bin/google_ads.sh CHANGED
@@ -15,7 +15,7 @@
15
15
  # limitations under the License.
16
16
 
17
17
  # Google Ads API version
18
- GOOGLE_ADS_API_VERSION=13
18
+ GOOGLE_ADS_API_VERSION=16
19
19
 
20
20
  #######################################
21
21
  # Verify whether the current OAuth token, CID and developer token can work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/nodejs-common",
3
- "version": "2.0.16-alpha",
3
+ "version": "2.1.0",
4
4
  "description": "A NodeJs common library for solutions based on Cloud Functions",
5
5
  "author": "Google Inc.",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,116 @@
1
+ // Copyright 2024 Google Inc.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this fileAccessObject except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ /**
15
+ * @fileoverview Common functions for Google Ads and Search Ads API classes.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { Transform } = require('stream');
21
+ const {
22
+ extractObject,
23
+ changeObjectNamingFromLowerCamelToSnake,
24
+ getLogger,
25
+ } = require('../../components/utils.js');
26
+
27
+ /**
28
+ * Returns a integer format CID by removing dashes and spaces.
29
+ * @param {string} cid
30
+ * @return {string}
31
+ */
32
+ function getCleanCid(cid) {
33
+ return cid.toString().trim().replace(/-/g, '');
34
+ }
35
+
36
+ const START_TAG = '"results":';
37
+ const FIELD_MASK_TAG = '"fieldMask"';
38
+ const END_TAG = '"requestId"';
39
+
40
+ /**
41
+ * Generates a function that can convert a given JSON object to a JSON string
42
+ * with only specified fields(fieldMask), in specified naming convention.
43
+ * @param {string} fieldMask The 'fieldMask' string from response.
44
+ * @param {boolean=} snakeCase Whether or not output JSON in snake naming.
45
+ */
46
+ function generateProcessFn(fieldMask, snakeCase = false) {
47
+ const extractor = extractObject(fieldMask.split(','));
48
+ return (originalObject) => {
49
+ const extracted = extractor(originalObject);
50
+ const generatedObject = snakeCase
51
+ ? changeObjectNamingFromLowerCamelToSnake(extracted) : extracted;
52
+ return JSON.stringify(generatedObject);
53
+ };
54
+ };
55
+
56
+ /**
57
+ * A stream.Transform that can extract properties and convert naming of the
58
+ * reponse of Google/Search Ads report from REST interface.
59
+ */
60
+ class RestSearchStreamTransform extends Transform {
61
+
62
+ /**
63
+ * @constructor
64
+ * @param {boolean=} snakeCase Whether or not output JSON in snake naming.
65
+ */
66
+ constructor(snakeCase = false) {
67
+ super({ objectMode: true });
68
+ this.snakeCase = snakeCase;
69
+ this.chunks = [Buffer.from('')];
70
+ this.processFn; // The function to process a row of the report.
71
+ this.logger = getLogger('ADS.STREAM.T');
72
+ this.stopwatch = Date.now();
73
+ }
74
+
75
+ _transform(chunk, encoding, callback) {
76
+ const latest = Buffer.concat([this.chunks[this.chunks.length - 1], chunk]);
77
+ const endIndex = latest.indexOf(END_TAG);
78
+ if (endIndex > -1) {
79
+ this.chunks.push(chunk);
80
+ const rawString = Buffer.concat(this.chunks).toString();
81
+ const startIndex = rawString.indexOf(START_TAG) + START_TAG.length;
82
+ const maskIndex = rawString.lastIndexOf(FIELD_MASK_TAG);
83
+ if (!this.processFn) {
84
+ const fieldMask = rawString
85
+ .substring(maskIndex + FIELD_MASK_TAG.length, rawString.indexOf(END_TAG))
86
+ .split('"')[1];
87
+ this.logger.debug(`Got fieldMask: ${fieldMask}`);
88
+ this.processFn = generateProcessFn(fieldMask, this.snakeCase);
89
+ }
90
+ const resultsWithTailing = rawString.substring(startIndex, maskIndex);
91
+ const results = resultsWithTailing.substring(
92
+ 0, resultsWithTailing.lastIndexOf(','));
93
+ const rows = JSON.parse(results);
94
+ const data = rows.map(this.processFn).join('\n') + '\n';
95
+ // Clear cached chunks.
96
+ this.chunks = [latest.subarray(latest.indexOf(END_TAG) + END_TAG.length)];
97
+
98
+ this.logger.debug(`Got ${rows.length} rows. Process time:`,
99
+ Date.now() - this.stopwatch);
100
+ this.stopwatch = Date.now();
101
+ callback(null, data);
102
+ } else {
103
+ if (chunk.length < END_TAG.length) {// Update latest chunk for short chunk
104
+ this.chunks[this.chunks.length - 1] = latest;
105
+ } else {
106
+ this.chunks.push(chunk);
107
+ }
108
+ callback();
109
+ }
110
+ }
111
+ }
112
+
113
+ module.exports = {
114
+ getCleanCid,
115
+ RestSearchStreamTransform,
116
+ };
@@ -34,8 +34,8 @@ const API_SCOPES = Object.freeze([
34
34
  const API_VERSION = 'v3';
35
35
 
36
36
  /**
37
- * Display and Video 360 API v2 stub.
38
- * @see https://developers.google.com/display-video/api/reference/rest/v2
37
+ * Display and Video 360 API v3 stub.
38
+ * @see https://developers.google.com/display-video/api/reference/rest/v3
39
39
  * This is not the same to Reports Display & Video 360 API which is from Google
40
40
  * Bid Manager API.
41
41
  * @see https://developers.google.com/bid-manager/reference/rest
@@ -22,7 +22,7 @@
22
22
 
23
23
  const { Transform } = require('stream');
24
24
  const lodash = require('lodash');
25
-
25
+ const { request: gaxiosRequest } = require('gaxios');
26
26
  const {
27
27
  ConversionAdjustmentUploadServiceClient,
28
28
  ConversionUploadServiceClient,
@@ -33,6 +33,8 @@ const {
33
33
  UserListServiceClient,
34
34
  protos: { google: { ads: { googleads } } },
35
35
  } = require('google-ads-nodejs-client');
36
+
37
+ const API_VERSION = Object.keys(googleads)[0];
36
38
  const {
37
39
  common: {
38
40
  Consent,
@@ -88,19 +90,23 @@ const {
88
90
  UserListTypeEnum: { UserListType },
89
91
  CustomerMatchUploadKeyTypeEnum: { CustomerMatchUploadKeyType },
90
92
  },
91
- } = googleads.v16;
93
+ } = googleads[API_VERSION];
92
94
 
93
95
  const AuthClient = require('./auth_client.js');
94
96
  const {
95
97
  getLogger,
96
98
  BatchResult,
97
99
  extractObject,
100
+ changeNamingFromSnakeToLowerCamel,
98
101
  changeObjectNamingFromSnakeToLowerCamel,
102
+ changeObjectNamingFromLowerCamelToSnake,
99
103
  } = require('../components/utils.js');
104
+ const { getCleanCid, RestSearchStreamTransform }
105
+ = require('./base/ads_api_common.js');
100
106
 
101
107
  /** @type {!ReadonlyArray<string>} */
102
108
  const API_SCOPES = Object.freeze(['https://www.googleapis.com/auth/adwords']);
103
-
109
+ const API_ENDPOINT = 'https://googleads.googleapis.com';
104
110
  /**
105
111
  * List of properties that will be taken from the data file as elements of a
106
112
  * conversion or a conversion adjustment.
@@ -255,6 +261,9 @@ let ConversionConfig;
255
261
  * If audience listId is not present, 'listName' and 'uploadKeyType' need to
256
262
  * be there so they can be used to create a customer match user list.
257
263
  * operation must be one of 'create' or 'remove'.
264
+ * `customerMatchUserListMetadata` offers a request level metadata, including
265
+ * 'consent'. While the top level 'consent', if presents, it will serve as the
266
+ * fallback value for each UserIdentifier.
258
267
  * Should not include `userIdentifierSource` based on:
259
268
  * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserIdentifier
260
269
  * @see https://developers.google.com/google-ads/api/reference/rpc/latest/UserDataOperation
@@ -265,6 +274,7 @@ let ConversionConfig;
265
274
  * listId: (string|undefined),
266
275
  * listName: (string|undefined),
267
276
  * uploadKeyType: ('CONTACT_INFO'|'CRM_ID'|'MOBILE_ADVERTISING_ID'|undefined),
277
+ * customerMatchUserListMetadata: (undefined|CustomerMatchUserListMetadata),
268
278
  * operation: ('create'|'remove'),
269
279
  * consent: (!Consent),
270
280
  * }}
@@ -282,6 +292,9 @@ let CustomerMatchConfig;
282
292
  * create a customer match user list.
283
293
  * For job type 'CUSTOMER_MATCH_WITH_ATTRIBUTES', 'user_attribute' can be used
284
294
  * to store shared additional user attributes.
295
+ * For job type 'CUSTOMER_MATCH_*', `customerMatchUserListMetadata` offers a job
296
+ * level metadata, including 'consent'. While the top level 'consent',
297
+ * if presents, it will serve as the fallback value for each UserIdentifier.
285
298
  * For job type 'STORE_SALES_UPLOAD_FIRST_PARTY', `storeSalesMetadata` is
286
299
  * required to offer StoreSalesMetadata. Besides that, for the store sales data,
287
300
  * common data (e.g. `currencyCode`, `conversionAction`) in
@@ -296,6 +309,7 @@ let CustomerMatchConfig;
296
309
  * operation: ('create'|'remove'),
297
310
  * type: !OfflineUserDataJobType,
298
311
  * storeSalesMetadata: (undefined|StoreSalesMetadata),
312
+ * customerMatchUserListMetadata: (undefined|CustomerMatchUserListMetadata),
299
313
  * transactionAttribute: (undefined|TransactionAttribute),
300
314
  * userAttribute: (undefined|UserAttribute),
301
315
  * userIdentifierSource: (!UserIdentifierSource|undefined),
@@ -354,7 +368,7 @@ class GoogleAdsApi {
354
368
  async getReport(customerId, loginCustomerId, query) {
355
369
  const request = new SearchGoogleAdsRequest({
356
370
  query,
357
- customerId: this.getCleanCid_(customerId),
371
+ customerId: getCleanCid(customerId),
358
372
  });
359
373
  return this.getReport_(request, loginCustomerId, true);
360
374
  }
@@ -380,7 +394,7 @@ class GoogleAdsApi {
380
394
  const request = new SearchGoogleAdsRequest(
381
395
  Object.assign({
382
396
  query,
383
- customerId: this.getCleanCid_(customerId),
397
+ customerId: getCleanCid(customerId),
384
398
  pageSize: 10000,
385
399
  }, options)
386
400
  );
@@ -411,7 +425,6 @@ class GoogleAdsApi {
411
425
  return result;
412
426
  }
413
427
 
414
- //TODO: Test a big report see how the event 'data' is triggered
415
428
  /**
416
429
  * Gets stream report of a given Customer account. The stream will send
417
430
  * `SearchGoogleAdsResponse` objects with the event 'data'.
@@ -424,7 +437,7 @@ class GoogleAdsApi {
424
437
  const client = await this.getGoogleAdsServiceClient_();
425
438
  const request = new SearchGoogleAdsRequest({
426
439
  query,
427
- customerId: this.getCleanCid_(customerId),
440
+ customerId: getCleanCid(customerId),
428
441
  });
429
442
  const callOptions = this.getCallOptions_(loginCustomerId);
430
443
  const response = await client.searchStream(request, callOptions);
@@ -438,23 +451,76 @@ class GoogleAdsApi {
438
451
  * `results` in JSON format strings with the delimit of a line breaker.
439
452
  * @param {string} customerId
440
453
  * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
441
- * @param {string} query
442
- * @return {!Promise<string>}
454
+ * @param {string} query A Google Ads Query string.
455
+ * @param {boolean} snakeCase Output JSON objects in snake_case.
456
+ * @return {!Promise<stream>}
443
457
  */
444
- async cleanedStreamReport(customerId, loginCustomerId, query) {
458
+ async cleanedStreamReport(customerId, loginCustomerId, query,
459
+ snakeCase = false) {
445
460
  const cleanReportStream = new Transform({
446
461
  writableObjectMode: true,
447
462
  transform(chunk, encoding, callback) {
448
463
  const { fieldMask: { paths } } = chunk;
449
- const extractor = extractObject(paths);
464
+ const camelPaths = paths.map((path) => {
465
+ return path.split('.').map(changeNamingFromSnakeToLowerCamel).join('.');
466
+ });
467
+ const extractor = extractObject(camelPaths);
468
+ const results = snakeCase
469
+ ? chunk.results.map(extractor).map(changeObjectNamingFromLowerCamelToSnake)
470
+ : chunk.results.map(extractor);
450
471
  // Add a line break after each chunk to keep files in proper format.
451
- const data = chunk.results.map(extractor).map(JSON.stringify).join('\n')
452
- + '\n';
472
+ const data = results.map(JSON.stringify).join('\n') + '\n';
453
473
  callback(null, data);
454
474
  }
455
475
  });
456
476
  const stream = await this.streamReport(customerId, loginCustomerId, query);
457
- return stream.pipe(cleanReportStream);
477
+ return stream.on('error', (error) => cleanReportStream.emit('error', error))
478
+ .pipe(cleanReportStream);
479
+ }
480
+
481
+ /**
482
+ * Gets the report stream through REST interface.
483
+ * @param {string} customerId
484
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
485
+ * @param {string} query A Google Ads Query string.
486
+ * @return {!Promise<stream>}
487
+ */
488
+ async restStreamReport(customerId, loginCustomerId, query) {
489
+ await this.authClient.prepareCredentials();
490
+ const headers = Object.assign(
491
+ await this.authClient.getDefaultAuth().getRequestHeaders(),
492
+ this.getGoogleAdsHeaders_(loginCustomerId)
493
+ );
494
+ const options = {
495
+ baseURL: `${API_ENDPOINT}/${API_VERSION}/`,
496
+ url: `customers/${getCleanCid(customerId)}/googleAds:searchStream`,
497
+ headers,
498
+ data: { query },
499
+ method: 'POST',
500
+ responseType: 'stream',
501
+ };
502
+ const response = await gaxiosRequest(options);
503
+ return response.data;
504
+ }
505
+
506
+ /**
507
+ * Gets the report stream through REST interface.
508
+ * Based on the `fieldMask` in the response to filter out
509
+ * selected fields of the report and returns an array of JSON format strings
510
+ * with the delimit of a line breaker.
511
+ * @param {string} customerId
512
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
513
+ * @param {string} query A Google Ads Query string.
514
+ * @param {boolean} snakeCase Output JSON objects in snake_case.
515
+ * @return {!Promise<stream>}
516
+ */
517
+ async cleanedRestStreamReport(customerId, loginCustomerId, query,
518
+ snakeCase = false) {
519
+ const transform = new RestSearchStreamTransform(snakeCase);
520
+ const stream =
521
+ await this.restStreamReport(customerId, loginCustomerId, query);
522
+ return stream.on('error', (error) => transform.emit('error', error))
523
+ .pipe(transform);
458
524
  }
459
525
 
460
526
  /**
@@ -462,20 +528,19 @@ class GoogleAdsApi {
462
528
  * https://developers.google.com/google-ads/api/docs/concepts/field-service
463
529
  * Note, it looks like this function doesn't check the CID, just using
464
530
  * developer token and OAuth.
465
- * @param {string|number} loginCustomerId Login customer account ID.
466
531
  * @param {Array<string>} adFields Array of Ad fields.
467
532
  * @param {Array<string>} metadata Select fields, default values are:
468
533
  * name, data_type, is_repeated, type_url.
469
534
  * @return {!Promise<!Array<GoogleAdsField>>}
470
535
  */
471
- async searchMetaData(loginCustomerId, adFields, metadata = [
472
- 'name', 'data_type', 'is_repeated', 'type_url',]) {
536
+ async searchReportField(adFields,
537
+ metadata = ['name', 'data_type', 'is_repeated', 'type_url',]) {
473
538
  const client = await this.getGoogleAdsFieldServiceClient_();
474
539
  const selectClause = metadata.join(',');
475
540
  const fields = adFields.join('","');
476
541
  const query = `SELECT ${selectClause} WHERE name IN ("${fields}")`;
477
542
  const request = new SearchGoogleAdsFieldsRequest({ query });
478
- const callOptions = this.getCallOptions_(loginCustomerId);
543
+ const callOptions = this.getCallOptions_();
479
544
  const [results] = await client.searchGoogleAdsFields(request, callOptions);
480
545
  return results;
481
546
  }
@@ -559,8 +624,8 @@ class GoogleAdsApi {
559
624
  functionName, propertyForDebug) {
560
625
  /** @type {!ConversionConfig} */
561
626
  const adsConfig = this.getCamelConfig_(conversionConfig);
562
- adsConfig.customerId = this.getCleanCid_(customerId);
563
- adsConfig.loginCustomerId = this.getCleanCid_(loginCustomerId);
627
+ adsConfig.customerId = getCleanCid(customerId);
628
+ adsConfig.loginCustomerId = getCleanCid(loginCustomerId);
564
629
  /**
565
630
  * Sends a batch of hits to Google Ads API.
566
631
  * @param {!Array<string>} lines Data for single request. It should be
@@ -875,7 +940,7 @@ class GoogleAdsApi {
875
940
  SELECT user_list.id, user_list.resource_name
876
941
  FROM user_list
877
942
  WHERE user_list.name = '${listName}'
878
- AND customer.id = ${this.getCleanCid_(customerId)}
943
+ AND customer.id = ${getCleanCid(customerId)}
879
944
  AND user_list.type = CRM_BASED
880
945
  AND user_list.membership_status = OPEN
881
946
  AND user_list.crm_based_user_list.upload_key_type = ${uploadKeyType}
@@ -905,7 +970,7 @@ class GoogleAdsApi {
905
970
  crmBasedUserList: { uploadKeyType },
906
971
  });
907
972
  const request = new MutateUserListsRequest({
908
- customerId: this.getCleanCid_(customerId),
973
+ customerId: getCleanCid(customerId),
909
974
  operations: [{ create: userList }],
910
975
  validateOnly: this.debugMode, // when true makes no changes
911
976
  partialFailure: false, // Simplify error handling in creating userlist
@@ -1011,9 +1076,9 @@ class GoogleAdsApi {
1011
1076
  const operations = userDataList.map(
1012
1077
  (userData) => new UserDataOperation({ [operation]: new UserData(userData) })
1013
1078
  );
1014
- const metadata = this.buildCustomerMatchUserListMetadata_(customerId, listId);
1079
+ const metadata = this.buildCustomerMatchUserListMetadata_(config);
1015
1080
  const request = new UploadUserDataRequest({
1016
- customerId: this.getCleanCid_(customerId),
1081
+ customerId: getCleanCid(customerId),
1017
1082
  operations,
1018
1083
  customerMatchUserListMetadata: metadata,
1019
1084
  });
@@ -1025,16 +1090,20 @@ class GoogleAdsApi {
1025
1090
  /**
1026
1091
  * Creates CustomerMatchUserListMetadata.
1027
1092
  * @see https://developers.google.com/google-ads/api/reference/rpc/latest/CustomerMatchUserListMetadata
1028
- * @param {string} customerId part of the ResourceName to be mutated
1029
- * @param {string} userListId part of the ResourceName to be mutated
1093
+ * @param {{
1094
+ * customerId: string,
1095
+ * listId: string,
1096
+ * customerMatchUserListMetadata: undefined|!CustomerMatchUserListMetadata
1097
+ * }} config Configuration for CustomerMatchUserListMetadata
1030
1098
  * @return {!CustomerMatchUserListMetadata}
1031
1099
  * @private
1032
1100
  */
1033
- buildCustomerMatchUserListMetadata_(customerId, userListId) {
1034
- const resourceName = `customers/${customerId}/userLists/${userListId}`;
1035
- return new CustomerMatchUserListMetadata({
1101
+ buildCustomerMatchUserListMetadata_(config) {
1102
+ const { customerId, listId, customerMatchUserListMetadata } = config;
1103
+ const resourceName = `customers/${customerId}/userLists/${listId}`;
1104
+ return new CustomerMatchUserListMetadata(Object.assign({
1036
1105
  userList: resourceName,
1037
- });
1106
+ }, customerMatchUserListMetadata));
1038
1107
  }
1039
1108
 
1040
1109
  /**
@@ -1080,8 +1149,7 @@ class GoogleAdsApi {
1080
1149
  const jobData = { type };
1081
1150
  // https://developers.google.com/google-ads/api/rest/reference/rest/latest/OfflineUserDataJobs?hl=en#CustomerMatchUserListMetadata
1082
1151
  if (type.startsWith('CUSTOMER_MATCH')) {
1083
- const metadata = this.buildCustomerMatchUserListMetadata_(customerId,
1084
- listId);
1152
+ const metadata = this.buildCustomerMatchUserListMetadata_(config);
1085
1153
  jobData.customerMatchUserListMetadata = metadata;
1086
1154
  // https://developers.google.com/google-ads/api/rest/reference/rest/latest/OfflineUserDataJob?hl=en#StoreSalesMetadata
1087
1155
  } else if (type.startsWith('STORE_SALES')) {
@@ -1095,7 +1163,7 @@ class GoogleAdsApi {
1095
1163
  }
1096
1164
  const job = new OfflineUserDataJob(jobData);
1097
1165
  const request = new CreateOfflineUserDataJobRequest({
1098
- customerId: this.getCleanCid_(customerId),
1166
+ customerId: getCleanCid(customerId),
1099
1167
  job,
1100
1168
  validateOnly: this.debugMode, // when true makes no changes
1101
1169
  enableMatchRateRangePreview: true,
@@ -1248,16 +1316,6 @@ class GoogleAdsApi {
1248
1316
  }
1249
1317
  }
1250
1318
 
1251
- /**
1252
- * Returns a integer format CID by removing dashes.
1253
- * @param {string} cid
1254
- * @return {string}
1255
- * @private
1256
- */
1257
- getCleanCid_(cid) {
1258
- return cid.toString().replace(/-/g, '');
1259
- }
1260
-
1261
1319
  /**
1262
1320
  * Historically, we used a 3rd party library that adopted snake naming
1263
1321
  * convention as the protobuf files and API documents. However, the
@@ -1279,10 +1337,11 @@ class GoogleAdsApi {
1279
1337
  * @private
1280
1338
  */
1281
1339
  getGoogleAdsHeaders_(loginCustomerId) {
1282
- return {
1283
- "developer-token": this.developerToken,
1284
- "login-customer-id": loginCustomerId,
1285
- };
1340
+ const headers = { 'developer-token': this.developerToken };
1341
+ if (loginCustomerId) {
1342
+ headers['login-customer-id'] = getCleanCid(loginCustomerId);
1343
+ }
1344
+ return headers;
1286
1345
  }
1287
1346
 
1288
1347
  /**
@@ -1295,7 +1354,7 @@ class GoogleAdsApi {
1295
1354
  getCallOptions_(loginCustomerId) {
1296
1355
  return {
1297
1356
  otherArgs: {
1298
- headers: this.getGoogleAdsHeaders_(this.getCleanCid_(loginCustomerId)),
1357
+ headers: this.getGoogleAdsHeaders_(loginCustomerId),
1299
1358
  },
1300
1359
  };
1301
1360
  }
@@ -1536,6 +1595,7 @@ const debugGoogleAdsFailure = (failures, request) => {
1536
1595
  }
1537
1596
 
1538
1597
  module.exports = {
1598
+ GoogleAdsField,
1539
1599
  ConversionConfig,
1540
1600
  CustomerMatchRecord,
1541
1601
  CustomerMatchConfig,
@@ -18,13 +18,17 @@
18
18
 
19
19
  'use strict';
20
20
 
21
+ const { request: gaxiosRequest } = require('gaxios');
21
22
  const { google } = require('googleapis');
22
23
  const AuthClient = require('./auth_client.js');
23
24
  const { getLogger } = require('../components/utils.js');
25
+ const { getCleanCid, RestSearchStreamTransform }
26
+ = require('./base/ads_api_common.js');
24
27
 
25
28
  const API_SCOPES = Object.freeze([
26
29
  'https://www.googleapis.com/auth/doubleclicksearch',
27
30
  ]);
31
+ const API_ENDPOINT = 'https://searchads360.googleapis.com';
28
32
  const API_VERSION = 'v0';
29
33
 
30
34
  /**
@@ -56,13 +60,15 @@ class SearchAds {
56
60
  * @private
57
61
  */
58
62
  async getApiClient_(loginCustomerId) {
59
- this.logger.debug(`Initialized Search Ads reporting instance for ${loginCustomerId}`);
60
- this.searchads360 = google.searchads360({
63
+ this.logger.debug(`Initialized SA reporting for ${loginCustomerId}`);
64
+ const options = {
61
65
  version: API_VERSION,
62
66
  auth: await this.getAuth_(),
63
- headers: { 'login-customer-id': loginCustomerId },
64
- });
65
- return this.searchads360;
67
+ };
68
+ if (loginCustomerId) {
69
+ options.headers = { 'login-customer-id': getCleanCid(loginCustomerId) };
70
+ }
71
+ return google.searchads360(options);
66
72
  }
67
73
 
68
74
  /**
@@ -77,61 +83,117 @@ class SearchAds {
77
83
  }
78
84
 
79
85
  /**
80
- * Returns all rows that match the search stream query.
81
- * The streamed content is not NDJSON format, but one JSON object with the
82
- * property `results`. The whole JSON string can be parsed to an object and
83
- * the `results` be extracted and converted to NDJSON lines. If the report
84
- * is too large to be handled in this way, a possible solution is to parse
85
- * the string directly to get the content of `results`.
86
- * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/customers.searchAds360/searchStream
86
+ * Gets a report synchronously from a given Customer account.
87
+ * If there is a `nextPageToken` in the response, it means the report is not
88
+ * finished and there are more pages.
89
+ * @see https://developers.google.com/search-ads/reporting/api/reference/rpc/google.ads.searchads360.v0.services#searchads360service
87
90
  * @param {string} customerId
88
91
  * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
89
92
  * @param {string} query
90
- * @return {!ReadableStream}
93
+ * @param {object=} options Options for `SearchSearchAds360Request`.
94
+ * @see https://developers.google.com/search-ads/reporting/api/reference/rpc/google.ads.searchads360.v0.services#searchsearchads360request
95
+ * @return {!SearchAds360Field}
96
+ * @see https://developers.google.com/search-ads/reporting/api/reference/rpc/google.ads.searchads360.v0.services#searchsearchads360response
91
97
  */
92
- async streamReport(customerId, loginCustomerId, query) {
98
+ async getPaginatedReport(customerId, loginCustomerId, query, options = {}) {
93
99
  const searchads = await this.getApiClient_(loginCustomerId);
100
+ const requestBody = Object.assign({
101
+ query,
102
+ pageSize: 10000,
103
+ }, options);
94
104
  const response = await searchads.customers.searchAds360.search({
95
- customerId,
96
- requestBody: { query },
97
- }, { responseType: 'stream' });
105
+ customerId: getCleanCid(customerId),
106
+ requestBody,
107
+ });
98
108
  return response.data;
99
109
  }
100
110
 
101
111
  /**
102
- * Gets a report synchronously from a given Customer account.
103
- * This is for test as it does not handle page token. For product env, use
104
- * function `streamReport`.
105
- * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/customers.searchAds360/search#request-body
112
+ * Gets a report stream from a Search Ads 360 reporting API.
113
+ * The streamed content is not NDJSON format, but an array of JSON objects
114
+ * with each element has a property `results`.
115
+ * `data` support `batchSize` to set how many rows in one result element.
106
116
  * @param {string} customerId
107
117
  * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
108
118
  * @param {string} query
109
- * @return {!SearchAds360Field}
110
- * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/searchAds360Fields#SearchAds360Field
119
+ * @return {!Promise<stream>}
120
+ * @see https://developers.google.com/search-ads/reporting/api/reference/rest/search
111
121
  */
112
- async getReport(customerId, loginCustomerId, query) {
113
- const searchads = await this.getApiClient_(loginCustomerId);
114
- const response = await searchads.customers.searchAds360.search({
115
- customerId,
116
- requestBody: { query },
122
+ async restStreamReport(customerId, loginCustomerId, query) {
123
+ const auth = await this.getAuth_();
124
+ const headers = Object.assign(
125
+ await auth.getRequestHeaders(), {
126
+ 'login-customer-id': getCleanCid(loginCustomerId),
117
127
  });
118
- return response.data.results;
128
+ const options = {
129
+ baseURL: `${API_ENDPOINT}/${API_VERSION}/`,
130
+ url: `customers/${getCleanCid(customerId)}/searchAds360:searchStream`,
131
+ headers,
132
+ data: { query },
133
+ method: 'POST',
134
+ responseType: 'stream',
135
+ };
136
+ const response = await gaxiosRequest(options);
137
+ return response.data;
138
+ }
139
+
140
+ /**
141
+ * Gets the report stream through REST interface.
142
+ * Based on the `fieldMask` in the response to filter out
143
+ * selected fields of the report and returns an array of JSON format strings
144
+ * with the delimit of a line breaker.
145
+ * @param {string} customerId
146
+ * @param {string} loginCustomerId Login customer account ID (Mcc Account id).
147
+ * @param {string} query A Google Ads Query string.
148
+ * @param {boolean=} snakeCase Output JSON objects in snake_case.
149
+ * @return {!Promise<stream>}
150
+ */
151
+ async cleanedRestStreamReport(customerId, loginCustomerId, query,
152
+ snakeCase = false) {
153
+ const transform = new RestSearchStreamTransform(snakeCase);
154
+ const stream =
155
+ await this.restStreamReport(customerId, loginCustomerId, query);
156
+ return stream.on('error', (error) => transform.emit('error', error))
157
+ .pipe(transform);
119
158
  }
120
159
 
121
160
  /**
122
161
  * Returns the requested field or resource (artifact) used by SearchAds360Service.
123
162
  * This service doesn't require `login-customer-id` HTTP header.
124
163
  * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/searchAds360Fields/get
125
- * @param {string} resourceName
164
+ * @param {string} fieldName
126
165
  * @return {!SearchAds360Field}
127
166
  * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/searchAds360Fields#SearchAds360Field
128
167
  */
129
- async getReportField(resourceName) {
168
+ async getReportField(fieldName) {
130
169
  const searchads = await this.getApiClient_();
131
- const response = await searchads.searchAds360Fields.get({ resourceName });
170
+ const resourceName = `searchAds360Fields/${fieldName}`;
171
+ const response =
172
+ await searchads.searchAds360Fields.get({ resourceName });
132
173
  return response.data;
133
174
  }
134
175
 
176
+ /**
177
+ * Returns resources information from Search Ads API.
178
+ * @see: https://developers.google.com/search-ads/reporting/api/reference/rest/v0/searchAds360Fields
179
+ * Note, it looks like this function doesn't check the CID, just using OAuth.
180
+ * @param {Array<string>} adFields Array of Ad fields.
181
+ * @param {Array<string>} metadata Select fields, default values are:
182
+ * name, data_type, is_repeated, type_url.
183
+ * @return {!Promise<!Array<GoogleAdsField>>}
184
+ * @see GoogleAdsApi.searchReportField
185
+ */
186
+ async searchReportField(adFields,
187
+ metadata = ['name', 'data_type', 'is_repeated', 'type_url',]) {
188
+ const searchads = await this.getApiClient_();
189
+ const selectClause = metadata.join(',');
190
+ const fields = adFields.join('","');
191
+ const query = `SELECT ${selectClause} WHERE name IN ("${fields}")`;
192
+ const response =
193
+ await searchads.searchAds360Fields.search({ query, pageSize: 10000 });
194
+ return response.data.results;
195
+ }
196
+
135
197
  /**
136
198
  * Returns all the custom columns associated with the customer in full detail.
137
199
  * @see https://developers.google.com/search-ads/reporting/api/reference/rest/v0/customers.customColumns/list
@@ -489,7 +489,7 @@ const extractObject = (paths) => {
489
489
  paths.forEach((path) => {
490
490
  const [value, owner, property] = path.split('.')
491
491
  .reduce(transcribe, [sourceObject, output, undefined]);
492
- if (value) {
492
+ if (typeof value !== 'undefined') {
493
493
  owner[property] = value;
494
494
  }
495
495
  });
@@ -539,6 +539,16 @@ const changeNamingFromSnakeToLowerCamel = (name) => {
539
539
  (initial) => initial.substring(1).toUpperCase());
540
540
  };
541
541
 
542
+ /**
543
+ * For more details, see:
544
+ * https://developers.google.com/google-ads/api/docs/rest/design/json-mappings
545
+ * @param {string} name Identifiers.
546
+ * @return {string}
547
+ */
548
+ const changeNamingFromLowerCamelToSnake = (name) => {
549
+ return name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
550
+ };
551
+
542
552
  /**
543
553
  * Converts a JSON object which has snake naming convention to lower camel
544
554
  * naming.
@@ -561,6 +571,28 @@ const changeObjectNamingFromSnakeToLowerCamel = (obj) => {
561
571
  }
562
572
  }
563
573
 
574
+ /**
575
+ * Converts a JSON object which has lower camel naming convention to snake
576
+ * naming.
577
+ *
578
+ * @param {object} obj
579
+ * @return {object}
580
+ */
581
+ const changeObjectNamingFromLowerCamelToSnake = (obj) => {
582
+ if (Array.isArray(obj)) {
583
+ return obj.map(changeObjectNamingFromLowerCamelToSnake);
584
+ } else if (typeof obj === 'object') {
585
+ const newObj = {};
586
+ Object.keys(obj).forEach((key) => {
587
+ newObj[changeNamingFromLowerCamelToSnake(key)] =
588
+ changeObjectNamingFromLowerCamelToSnake(obj[key]);
589
+ });
590
+ return newObj;
591
+ } else {
592
+ return obj;
593
+ }
594
+ }
595
+
564
596
  /**
565
597
  * Returns the response data for a HTTP request. It will retry the specific
566
598
  * times if there was errors happened.
@@ -606,6 +638,8 @@ module.exports = {
606
638
  getObjectByPath,
607
639
  changeNamingFromSnakeToUpperCamel,
608
640
  changeNamingFromSnakeToLowerCamel,
641
+ changeNamingFromLowerCamelToSnake,
609
642
  changeObjectNamingFromSnakeToLowerCamel,
643
+ changeObjectNamingFromLowerCamelToSnake,
610
644
  requestWithRetry,
611
645
  };